diff --git a/.travis.yml b/.travis.yml
index 3abd52c25f..901dee8186 100644
--- a/.travis.yml
+++ b/.travis.yml
@@ -17,13 +17,12 @@ before_install:
- curl -L https://github.com/docker/compose/releases/download/${COMPOSE_VERSION}/docker-compose-`uname -s`-`uname -m` > docker-compose
- chmod +x docker-compose
- sudo mv docker-compose /usr/local/bin
- - git clone https://github.com/DSpace-Labs/DSpace-Docker-Images.git
install:
# Start up DSpace 7 using the entities database dump
- - docker-compose -f DSpace-Docker-Images/docker-compose-files/dspace-compose-v2/d7.travis.ci.yml up -d
+ - docker-compose -f ./docker/docker-compose-travis.yml up -d
# Use the dspace-cli image to populate the assetstore. Trigger a discovery and oai update
- - docker-compose -f DSpace-Docker-Images/docker-compose-files/dspace-compose-v2/d7.cli.yml -f DSpace-Docker-Images/docker-compose-files/dspace-compose-v2/d7.cli.assetstore.yml run --rm dspace-cli
+ - docker-compose -f ./docker/cli.yml -f ./docker/cli.assetstore.yml run --rm dspace-cli
- travis_retry yarn install
before_script:
@@ -32,7 +31,7 @@ before_script:
#- curl http://localhost:8080/
after_script:
- - docker-compose -f DSpace-Docker-Images/docker-compose-files/dspace-compose-v2/d7.travis.ci.yml down
+ - docker-compose -f ./docker/docker-compose-travis.yml down
addons:
apt:
diff --git a/README.md b/README.md
index 1b3ed9b7cb..a9f2b0861b 100644
--- a/README.md
+++ b/README.md
@@ -131,6 +131,11 @@ yarn run clean:prod
yarn run clean:dist
```
+Running the application with Docker
+-----------------------------------
+See [Docker Runtime Options](docker/README.md)
+
+
Testing
-------
diff --git a/docker/README.md b/docker/README.md
new file mode 100644
index 0000000000..f7b4b04848
--- /dev/null
+++ b/docker/README.md
@@ -0,0 +1,79 @@
+# Docker Compose files
+
+## docker directory
+- docker-compose.yml
+ - Starts DSpace Angular with Docker Compose from the current branch. This file assumes that a DSpace 7 REST instance will also be started in Docker.
+- docker-compose-rest.yml
+ - Runs a published instance of the DSpace 7 REST API - persists data in Docker volumes
+- docker-compose-travis.yml
+ - Runs a published instance of the DSpace 7 REST API for CI testing. The database is re-populated from a SQL dump on each startup.
+- cli.yml
+ - Docker compose file that provides a DSpace CLI container to work with a running DSpace REST container.
+- cli.assetstore.yml
+ - Docker compose file that will download and install data into a DSpace REST assetstore. This script points to a default dataset that will be utilized for CI testing.
+- environment.dev.js
+ - Environment file for running DSpace Angular in Docker
+- local.cfg
+ - Environment file for running the DSpace 7 REST API in Docker.
+
+
+## To refresh / pull DSpace images from Dockerhub
+```
+docker-compose -f docker/docker-compose.yml pull
+```
+
+## To build DSpace images using code in your branch
+```
+docker-compose -f docker/docker-compose.yml build
+```
+
+## To start DSpace (REST and Angular) from your branch
+
+```
+docker-compose -p d7 -f docker/docker-compose.yml -f docker/docker-compose-rest.yml up -d
+```
+
+## Run DSpace REST and DSpace Angular from local branches.
+_The system will be started in 2 steps. Each step shares the same docker network._
+
+From DSpace/DSpace (build as needed)
+```
+docker-compose -p d7 up -d
+```
+
+From DSpace/DSpace-angular
+```
+docker-compose -p d7 -f docker/docker-compose.yml up -d
+```
+
+## Ingest test data from AIPDIR
+
+Create an administrator
+```
+docker-compose -p d7 -f docker/cli.yml run --rm dspace-cli create-administrator -e test@test.edu -f admin -l user -p admin -c en
+```
+
+Load content from AIP files
+```
+docker-compose -p d7 -f docker/cli.yml -f ./docker/cli.ingest.yml run --rm dspace-cli
+```
+
+## Alternative Ingest - Use Entities dataset
+_Delete your docker volumes or use a unique project (-p) name_
+
+Start DSpace with Database Content from a database dump
+```
+docker-compose -p d7 -f docker/docker-compose.yml -f docker/docker-compose-rest.yml -f docker/db.entities.yml up -d
+```
+
+Load assetstore content and trigger a re-index of the repository
+```
+docker-compose -p d7 -f docker/cli.yml -f docker/cli.assetstore.yml run --rm dspace-cli
+```
+
+## End to end testing of the rest api (runs in travis).
+_In this instance, only the REST api runs in Docker using the Entities dataset. Travis will perform CI testing of Angular using Node to drive the tests._
+
+```
+docker-compose -p d7ci -f docker/docker-compose-travis.yml up -d
+```
diff --git a/docker/cli.assetstore.yml b/docker/cli.assetstore.yml
new file mode 100644
index 0000000000..075c494a6c
--- /dev/null
+++ b/docker/cli.assetstore.yml
@@ -0,0 +1,23 @@
+version: "3.7"
+
+networks:
+ dspacenet:
+
+services:
+ dspace-cli:
+ networks:
+ dspacenet: {}
+ environment:
+ - LOADASSETS=https://www.dropbox.com/s/zv7lj8j2lp3egjs/assetstore.tar.gz?dl=1
+ entrypoint:
+ - /bin/bash
+ - '-c'
+ - |
+ if [ ! -z $${LOADASSETS} ]
+ then
+ curl $${LOADASSETS} -L -s --output /tmp/assetstore.tar.gz
+ cd /dspace
+ tar xvfz /tmp/assetstore.tar.gz
+ fi
+
+ /dspace/bin/dspace index-discovery
diff --git a/docker/cli.ingest.yml b/docker/cli.ingest.yml
new file mode 100644
index 0000000000..f5ec7eb90d
--- /dev/null
+++ b/docker/cli.ingest.yml
@@ -0,0 +1,32 @@
+#
+# The contents of this file are subject to the license and copyright
+# detailed in the LICENSE and NOTICE files at the root of the source
+# tree and available online at
+#
+# http://www.dspace.org/license/
+#
+
+version: "3.7"
+
+services:
+ dspace-cli:
+ environment:
+ - AIPZIP=https://github.com/DSpace-Labs/AIP-Files/raw/master/dogAndReport.zip
+ - ADMIN_EMAIL=test@test.edu
+ - AIPDIR=/tmp/aip-dir
+ entrypoint:
+ - /bin/bash
+ - '-c'
+ - |
+ rm -rf $${AIPDIR}
+ mkdir $${AIPDIR} /dspace/upload
+ cd $${AIPDIR}
+ pwd
+ curl $${AIPZIP} -L -s --output aip.zip
+ unzip aip.zip
+ cd $${AIPDIR}
+
+ /dspace/bin/dspace packager -r -a -t AIP -e $${ADMIN_EMAIL} -f -u SITE*.zip
+ /dspace/bin/dspace database update-sequences
+
+ /dspace/bin/dspace index-discovery
diff --git a/docker/cli.yml b/docker/cli.yml
new file mode 100644
index 0000000000..ea5e3e0595
--- /dev/null
+++ b/docker/cli.yml
@@ -0,0 +1,22 @@
+version: "3.7"
+
+services:
+ dspace-cli:
+ image: "${DOCKER_OWNER:-dspace}/dspace-cli:${DSPACE_VER:-dspace-7_x}"
+ container_name: dspace-cli
+ #environment:
+ volumes:
+ - "assetstore:/dspace/assetstore"
+ - "./local.cfg:/dspace/config/local.cfg"
+ entrypoint: /dspace/bin/dspace
+ command: help
+ networks:
+ - dspacenet
+ tty: true
+ stdin_open: true
+
+volumes:
+ assetstore:
+
+networks:
+ dspacenet:
diff --git a/docker/db.entities.yml b/docker/db.entities.yml
new file mode 100644
index 0000000000..91d96bd72b
--- /dev/null
+++ b/docker/db.entities.yml
@@ -0,0 +1,16 @@
+#
+# The contents of this file are subject to the license and copyright
+# detailed in the LICENSE and NOTICE files at the root of the source
+# tree and available online at
+#
+# http://www.dspace.org/license/
+#
+
+version: "3.7"
+
+services:
+ dspacedb:
+ image: dspace/dspace-postgres-pgcrypto:loadsql
+ environment:
+ # Double underbars in env names will be replaced with periods for apache commons
+ - LOADSQL=https://www.dropbox.com/s/xh3ack0vg0922p2/configurable-entities-2019-05-08.sql?dl=1
diff --git a/docker/docker-compose-rest.yml b/docker/docker-compose-rest.yml
new file mode 100644
index 0000000000..222557bc81
--- /dev/null
+++ b/docker/docker-compose-rest.yml
@@ -0,0 +1,59 @@
+networks:
+ dspacenet:
+services:
+ dspace:
+ container_name: dspace
+ depends_on:
+ - dspacedb
+ image: dspace/dspace:dspace-7_x-jdk8-test
+ networks:
+ dspacenet:
+ ports:
+ - published: 8080
+ target: 8080
+ stdin_open: true
+ tty: true
+ volumes:
+ - assetstore:/dspace/assetstore
+ - "./local.cfg:/dspace/config/local.cfg"
+ # Ensure that the database is ready before starting tomcat
+ entrypoint:
+ - /bin/bash
+ - '-c'
+ - |
+ /dspace/bin/dspace database migrate
+ catalina.sh run
+ dspacedb:
+ container_name: dspacedb
+ image: dspace/dspace-postgres-pgcrypto
+ environment:
+ PGDATA: /pgdata
+ networks:
+ dspacenet:
+ stdin_open: true
+ tty: true
+ volumes:
+ - pgdata:/pgdata
+ dspacesolr:
+ container_name: dspacesolr
+ image: dspace/dspace-solr
+ networks:
+ dspacenet:
+ ports:
+ - published: 8983
+ target: 8983
+ stdin_open: true
+ tty: true
+ volumes:
+ - solr_authority:/opt/solr/server/solr/authority/data
+ - solr_oai:/opt/solr/server/solr/oai/data
+ - solr_search:/opt/solr/server/solr/search/data
+ - solr_statistics:/opt/solr/server/solr/statistics/data
+version: '3.7'
+volumes:
+ assetstore:
+ pgdata:
+ solr_authority:
+ solr_oai:
+ solr_search:
+ solr_statistics:
diff --git a/docker/docker-compose-travis.yml b/docker/docker-compose-travis.yml
new file mode 100644
index 0000000000..6ca44e4e47
--- /dev/null
+++ b/docker/docker-compose-travis.yml
@@ -0,0 +1,53 @@
+networks:
+ dspacenet:
+services:
+ dspace:
+ container_name: dspace
+ depends_on:
+ - dspacedb
+ image: dspace/dspace:dspace-7_x-jdk8-test
+ networks:
+ dspacenet:
+ ports:
+ - published: 8080
+ target: 8080
+ stdin_open: true
+ tty: true
+ volumes:
+ - assetstore:/dspace/assetstore
+ - "./local.cfg:/dspace/config/local.cfg"
+ dspacedb:
+ container_name: dspacedb
+ environment:
+ LOADSQL: https://www.dropbox.com/s/xh3ack0vg0922p2/configurable-entities-2019-05-08.sql?dl=1
+ PGDATA: /pgdata
+ image: dspace/dspace-postgres-pgcrypto:loadsql
+ networks:
+ dspacenet:
+ stdin_open: true
+ tty: true
+ volumes:
+ - pgdata:/pgdata
+ dspacesolr:
+ container_name: dspacesolr
+ image: dspace/dspace-solr
+ networks:
+ dspacenet:
+ ports:
+ - published: 8983
+ target: 8983
+ stdin_open: true
+ tty: true
+ volumes:
+ - solr_authority:/opt/solr/server/solr/authority/data
+ - solr_oai:/opt/solr/server/solr/oai/data
+ - solr_search:/opt/solr/server/solr/search/data
+ - solr_statistics:/opt/solr/server/solr/statistics/data
+version: '3.7'
+volumes:
+ assetstore:
+ pgdata:
+ solr_authority:
+ solr_oai:
+ solr_search:
+ solr_statistics:
diff --git a/docker/docker-compose.yml b/docker/docker-compose.yml
new file mode 100644
index 0000000000..23f0615a1f
--- /dev/null
+++ b/docker/docker-compose.yml
@@ -0,0 +1,26 @@
+version: '3.7'
+networks:
+ dspacenet:
+services:
+ dspace-angular:
+ container_name: dspace-angular
+ environment:
+ DSPACE_HOST: dspace-angular
+ DSPACE_NAMESPACE: /
+ DSPACE_PORT: '3000'
+ DSPACE_SSL: "false"
+ image: dspace/dspace-angular:latest
+ build:
+ context: ..
+ dockerfile: Dockerfile
+ networks:
+ dspacenet:
+ ports:
+ - published: 3000
+ target: 3000
+ - published: 9876
+ target: 9876
+ stdin_open: true
+ tty: true
+ volumes:
+ - ./environment.dev.js:/app/config/environment.dev.js
diff --git a/docker/environment.dev.js b/docker/environment.dev.js
new file mode 100644
index 0000000000..f88506012f
--- /dev/null
+++ b/docker/environment.dev.js
@@ -0,0 +1,16 @@
+/*
+ * The contents of this file are subject to the license and copyright
+ * detailed in the LICENSE and NOTICE files at the root of the source
+ * tree and available online at
+ *
+ * http://www.dspace.org/license/
+ */
+module.exports = {
+ rest: {
+ ssl: false,
+ host: 'localhost',
+ port: 8080,
+ // NOTE: Space is capitalized because 'namespace' is a reserved string in TypeScript
+ nameSpace: '/server/api'
+ }
+};
diff --git a/docker/local.cfg b/docker/local.cfg
new file mode 100644
index 0000000000..6692b13658
--- /dev/null
+++ b/docker/local.cfg
@@ -0,0 +1,6 @@
+dspace.dir=/dspace
+db.url=jdbc:postgresql://dspacedb:5432/dspace
+dspace.hostname=dspace
+dspace.baseUrl=http://localhost:8080
+dspace.name=DSpace Started with Docker Compose
+solr.server=http://dspacesolr:8983/solr
diff --git a/resources/i18n/en.json5 b/resources/i18n/en.json5
index 6570d5bf3a..0ad08652b5 100644
--- a/resources/i18n/en.json5
+++ b/resources/i18n/en.json5
@@ -128,8 +128,28 @@
"collection.delete.notification.fail": "Collection could not be deleted",
"collection.delete.notification.success": "Successfully deleted collection",
"collection.delete.text": "Are you sure you want to delete collection \"{{ dso }}\"",
+
"collection.edit.delete": "Delete this collection",
"collection.edit.head": "Edit Collection",
+
+ "collection.edit.item-mapper.cancel": "Cancel",
+ "collection.edit.item-mapper.collection": "Collection: \"{{name}}\"",
+ "collection.edit.item-mapper.confirm": "Map selected items",
+ "collection.edit.item-mapper.description": "This is the item mapper tool that allows collection administrators to map items from other collections into this collection. You can search for items from other collections and map them, or browse the list of currently mapped items.",
+ "collection.edit.item-mapper.head": "Item Mapper - Map Items from Other Collections",
+ "collection.edit.item-mapper.no-search": "Please enter a query to search",
+ "collection.edit.item-mapper.notifications.map.error.content": "Errors occurred for mapping of {{amount}} items.",
+ "collection.edit.item-mapper.notifications.map.error.head": "Mapping errors",
+ "collection.edit.item-mapper.notifications.map.success.content": "Successfully mapped {{amount}} items.",
+ "collection.edit.item-mapper.notifications.map.success.head": "Mapping completed",
+ "collection.edit.item-mapper.notifications.unmap.error.content": "Errors occurred for removing the mappings of {{amount}} items.",
+ "collection.edit.item-mapper.notifications.unmap.error.head": "Remove mapping errors",
+ "collection.edit.item-mapper.notifications.unmap.success.content": "Successfully removed the mappings of {{amount}} items.",
+ "collection.edit.item-mapper.notifications.unmap.success.head": "Remove mapping completed",
+ "collection.edit.item-mapper.remove": "Remove selected item mappings",
+ "collection.edit.item-mapper.tabs.browse": "Browse mapped items",
+ "collection.edit.item-mapper.tabs.map": "Map new items",
+
"collection.form.abstract": "Short Description",
"collection.form.description": "Introductory text (HTML)",
"collection.form.errors.title.required": "Please enter a collection name",
@@ -138,11 +158,16 @@
"collection.form.rights": "Copyright text (HTML)",
"collection.form.tableofcontents": "News (HTML)",
"collection.form.title": "Name",
+
"collection.page.browse.recent.head": "Recent Submissions",
"collection.page.browse.recent.empty": "No items to show",
"collection.page.license": "License",
"collection.page.news": "News",
+ "collection.select.confirm": "Confirm selected",
+ "collection.select.empty": "No collections to show",
+ "collection.select.table.title": "Title",
+
"community.create.head": "Create a Community",
"community.create.sub-head": "Create a Sub-Community for Community {{ parent }}",
"community.delete.cancel": "Cancel",
@@ -177,9 +202,11 @@
"error.browse-by": "Error fetching items",
"error.collection": "Error fetching collection",
+ "error.collections": "Error fetching collections",
"error.community": "Error fetching community",
"error.default": "Error",
"error.item": "Error fetching item",
+ "error.items": "Error fetching items",
"error.objects": "Error fetching objects",
"error.recent-submissions": "Error fetching recent submissions",
"error.search-results": "Error fetching search results",
@@ -229,6 +256,24 @@
"item.edit.delete.success": "The item has been deleted",
"item.edit.head": "Edit Item",
+ "item.edit.item-mapper.buttons.add": "Map item to selected collections",
+ "item.edit.item-mapper.buttons.remove": "Remove item's mapping for selected collections",
+ "item.edit.item-mapper.cancel": "Cancel",
+ "item.edit.item-mapper.description": "This is the item mapper tool that allows administrators to map this item to other collections. You can search for collections and map them, or browse the list of collections the item is currently mapped to.",
+ "item.edit.item-mapper.head": "Item Mapper - Map Item to Collections",
+ "item.edit.item-mapper.item": "Item: \"{{name}}\"",
+ "item.edit.item-mapper.no-search": "Please enter a query to search",
+ "item.edit.item-mapper.notifications.add.error.content": "Errors occurred for mapping of item to {{amount}} collections.",
+ "item.edit.item-mapper.notifications.add.error.head": "Mapping errors",
+ "item.edit.item-mapper.notifications.add.success.content": "Successfully mapped item to {{amount}} collections.",
+ "item.edit.item-mapper.notifications.add.success.head": "Mapping completed",
+ "item.edit.item-mapper.notifications.remove.error.content": "Errors occurred for the removal of the mapping to {{amount}} collections.",
+ "item.edit.item-mapper.notifications.remove.error.head": "Removal of mapping errors",
+ "item.edit.item-mapper.notifications.remove.success.content": "Successfully removed mapping of item to {{amount}} collections.",
+ "item.edit.item-mapper.notifications.remove.success.head": "Removal of mapping completed",
+ "item.edit.item-mapper.tabs.browse": "Browse mapped collections",
+ "item.edit.item-mapper.tabs.map": "Map new collections",
+
"item.edit.metadata.add-button": "Add",
"item.edit.metadata.discard-button": "Discard",
"item.edit.metadata.edit.buttons.edit": "Edit",
@@ -361,6 +406,7 @@
"item.page.uri": "URI",
"item.select.confirm": "Confirm selected",
+ "item.select.empty": "No items to show",
"item.select.table.author": "Author",
"item.select.table.collection": "Collection",
"item.select.table.title": "Title",
@@ -392,9 +438,11 @@
"loading.browse-by": "Loading items...",
"loading.browse-by-page": "Loading page...",
"loading.collection": "Loading collection...",
+ "loading.collections": "Loading collections...",
"loading.community": "Loading community...",
"loading.default": "Loading...",
"loading.item": "Loading item...",
+ "loading.items": "Loading items...",
"loading.mydspace-results": "Loading items...",
"loading.objects": "Loading...",
"loading.recent-submissions": "Loading recent submissions...",
diff --git a/src/app/+collection-page/collection-item-mapper/collection-item-mapper.component.html b/src/app/+collection-page/collection-item-mapper/collection-item-mapper.component.html
new file mode 100644
index 0000000000..af4153220f
--- /dev/null
+++ b/src/app/+collection-page/collection-item-mapper/collection-item-mapper.component.html
@@ -0,0 +1,57 @@
+
+
+
+
{{'collection.edit.item-mapper.head' | translate}}
+
+
{{'collection.edit.item-mapper.description' | translate}}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {{'collection.edit.item-mapper.no-search' | translate}}
+
+
+
+
+
+
+
diff --git a/src/app/+collection-page/collection-item-mapper/collection-item-mapper.component.scss b/src/app/+collection-page/collection-item-mapper/collection-item-mapper.component.scss
new file mode 100644
index 0000000000..50be6f5ad0
--- /dev/null
+++ b/src/app/+collection-page/collection-item-mapper/collection-item-mapper.component.scss
@@ -0,0 +1 @@
+@import '../../../styles/variables.scss';
diff --git a/src/app/+collection-page/collection-item-mapper/collection-item-mapper.component.spec.ts b/src/app/+collection-page/collection-item-mapper/collection-item-mapper.component.spec.ts
new file mode 100644
index 0000000000..0bbfb30821
--- /dev/null
+++ b/src/app/+collection-page/collection-item-mapper/collection-item-mapper.component.spec.ts
@@ -0,0 +1,214 @@
+import { CollectionItemMapperComponent } from './collection-item-mapper.component';
+import { async, ComponentFixture, fakeAsync, TestBed, tick } from '@angular/core/testing';
+import { RouterTestingModule } from '@angular/router/testing';
+import { NgbModule } from '@ng-bootstrap/ng-bootstrap';
+import { CommonModule } from '@angular/common';
+import { TranslateModule, TranslateService } from '@ngx-translate/core';
+import { SearchFormComponent } from '../../shared/search-form/search-form.component';
+import { SearchPageModule } from '../../+search-page/search-page.module';
+import { ObjectCollectionComponent } from '../../shared/object-collection/object-collection.component';
+import { ActivatedRoute, Router } from '@angular/router';
+import { ActivatedRouteStub } from '../../shared/testing/active-router-stub';
+import { RouterStub } from '../../shared/testing/router-stub';
+import { SearchConfigurationService } from '../../+search-page/search-service/search-configuration.service';
+import { SearchService } from '../../+search-page/search-service/search.service';
+import { SearchServiceStub } from '../../shared/testing/search-service-stub';
+import { NotificationsService } from '../../shared/notifications/notifications.service';
+import { NotificationsServiceStub } from '../../shared/testing/notifications-service-stub';
+import { ItemDataService } from '../../core/data/item-data.service';
+import { FormsModule } from '@angular/forms';
+import { SharedModule } from '../../shared/shared.module';
+import { Collection } from '../../core/shared/collection.model';
+import { RemoteData } from '../../core/data/remote-data';
+import { PaginatedSearchOptions } from '../../+search-page/paginated-search-options.model';
+import { PaginationComponentOptions } from '../../shared/pagination/pagination-component-options.model';
+import { SortDirection, SortOptions } from '../../core/cache/models/sort-options.model';
+import { EventEmitter, NgModule } from '@angular/core';
+import { HostWindowService } from '../../shared/host-window.service';
+import { HostWindowServiceStub } from '../../shared/testing/host-window-service-stub';
+import { By } from '@angular/platform-browser';
+import { PaginatedList } from '../../core/data/paginated-list';
+import { PageInfo } from '../../core/shared/page-info.model';
+import { CollectionDataService } from '../../core/data/collection-data.service';
+import { PaginationComponent } from '../../shared/pagination/pagination.component';
+import { EnumKeysPipe } from '../../shared/utils/enum-keys-pipe';
+import { ItemSelectComponent } from '../../shared/object-select/item-select/item-select.component';
+import { ObjectSelectService } from '../../shared/object-select/object-select.service';
+import { ObjectSelectServiceStub } from '../../shared/testing/object-select-service-stub';
+import { VarDirective } from '../../shared/utils/var.directive';
+import { Observable } from 'rxjs/internal/Observable';
+import { of as observableOf, of } from 'rxjs/internal/observable/of';
+import { RestResponse } from '../../core/cache/response.models';
+import { SearchFixedFilterService } from '../../+search-page/search-filters/search-filter/search-fixed-filter.service';
+import { RouteService } from '../../core/services/route.service';
+import { ErrorComponent } from '../../shared/error/error.component';
+import { LoadingComponent } from '../../shared/loading/loading.component';
+
+describe('CollectionItemMapperComponent', () => {
+ let comp: CollectionItemMapperComponent;
+ let fixture: ComponentFixture;
+
+ let route: ActivatedRoute;
+ let router: Router;
+ let searchConfigService: SearchConfigurationService;
+ let searchService: SearchService;
+ let notificationsService: NotificationsService;
+ let itemDataService: ItemDataService;
+
+ const mockCollection: Collection = Object.assign(new Collection(), {
+ id: 'ce41d451-97ed-4a9c-94a1-7de34f16a9f4',
+ name: 'test-collection'
+ });
+ const mockCollectionRD: RemoteData = new RemoteData(false, false, true, null, mockCollection);
+ const mockSearchOptions = of(new PaginatedSearchOptions({
+ pagination: Object.assign(new PaginationComponentOptions(), {
+ id: 'search-page-configuration',
+ pageSize: 10,
+ currentPage: 1
+ }),
+ sort: new SortOptions('dc.title', SortDirection.ASC),
+ scope: mockCollection.id
+ }));
+ const url = 'http://test.url';
+ const urlWithParam = url + '?param=value';
+ const routerStub = Object.assign(new RouterStub(), {
+ url: urlWithParam,
+ navigateByUrl: {},
+ navigate: {}
+ });
+ const searchConfigServiceStub = {
+ paginatedSearchOptions: mockSearchOptions
+ };
+ const itemDataServiceStub = {
+ mapToCollection: () => of(new RestResponse(true, 200, 'OK'))
+ };
+ const activatedRouteStub = new ActivatedRouteStub({}, { collection: mockCollectionRD });
+ const translateServiceStub = {
+ get: () => of('test-message of collection ' + mockCollection.name),
+ onLangChange: new EventEmitter(),
+ onTranslationChange: new EventEmitter(),
+ onDefaultLangChange: new EventEmitter()
+ };
+ const emptyList = new RemoteData(false, false, true, null, new PaginatedList(new PageInfo(), []));
+ const searchServiceStub = Object.assign(new SearchServiceStub(), {
+ search: () => of(emptyList),
+ /* tslint:disable:no-empty */
+ clearDiscoveryRequests: () => {}
+ /* tslint:enable:no-empty */
+ });
+ const collectionDataServiceStub = {
+ getMappedItems: () => of(emptyList),
+ /* tslint:disable:no-empty */
+ clearMappedItemsRequests: () => {}
+ /* tslint:enable:no-empty */
+ };
+ const routeServiceStub = {
+ getRouteParameterValue: () => {
+ return observableOf('');
+ },
+ getQueryParameterValue: () => {
+ return observableOf('')
+ },
+ getQueryParamsWithPrefix: () => {
+ return observableOf('')
+ }
+ };
+ const fixedFilterServiceStub = {
+ getQueryByFilterName: () => {
+ return observableOf('')
+ }
+ };
+
+ beforeEach(async(() => {
+ TestBed.configureTestingModule({
+ imports: [CommonModule, FormsModule, RouterTestingModule.withRoutes([]), TranslateModule.forRoot(), NgbModule.forRoot()],
+ declarations: [CollectionItemMapperComponent, ItemSelectComponent, SearchFormComponent, PaginationComponent, EnumKeysPipe, VarDirective, ErrorComponent, LoadingComponent],
+ providers: [
+ { provide: ActivatedRoute, useValue: activatedRouteStub },
+ { provide: Router, useValue: routerStub },
+ { provide: SearchConfigurationService, useValue: searchConfigServiceStub },
+ { provide: SearchService, useValue: searchServiceStub },
+ { provide: NotificationsService, useValue: new NotificationsServiceStub() },
+ { provide: ItemDataService, useValue: itemDataServiceStub },
+ { provide: CollectionDataService, useValue: collectionDataServiceStub },
+ { provide: TranslateService, useValue: translateServiceStub },
+ { provide: HostWindowService, useValue: new HostWindowServiceStub(0) },
+ { provide: ObjectSelectService, useValue: new ObjectSelectServiceStub() },
+ { provide: RouteService, useValue: routeServiceStub },
+ { provide: SearchFixedFilterService, useValue: fixedFilterServiceStub }
+ ]
+ }).compileComponents();
+ }));
+
+ beforeEach(() => {
+ fixture = TestBed.createComponent(CollectionItemMapperComponent);
+ comp = fixture.componentInstance;
+ fixture.detectChanges();
+ route = (comp as any).route;
+ router = (comp as any).router;
+ searchConfigService = (comp as any).searchConfigService;
+ searchService = (comp as any).searchService;
+ notificationsService = (comp as any).notificationsService;
+ itemDataService = (comp as any).itemDataService;
+ });
+
+ it('should display the correct collection name', () => {
+ const name: HTMLElement = fixture.debugElement.query(By.css('#collection-name')).nativeElement;
+ expect(name.innerHTML).toContain(mockCollection.name);
+ });
+
+ describe('mapItems', () => {
+ const ids = ['id1', 'id2', 'id3', 'id4'];
+
+ it('should display a success message if at least one mapping was successful', () => {
+ comp.mapItems(ids);
+ expect(notificationsService.success).toHaveBeenCalled();
+ expect(notificationsService.error).not.toHaveBeenCalled();
+ });
+
+ it('should display an error message if at least one mapping was unsuccessful', () => {
+ spyOn(itemDataService, 'mapToCollection').and.returnValue(of(new RestResponse(false, 404, 'Not Found')));
+ comp.mapItems(ids);
+ expect(notificationsService.success).not.toHaveBeenCalled();
+ expect(notificationsService.error).toHaveBeenCalled();
+ });
+ });
+
+ describe('tabChange', () => {
+ beforeEach(() => {
+ spyOn(routerStub, 'navigateByUrl');
+ comp.tabChange({});
+ });
+
+ it('should navigate to the same page to remove parameters', () => {
+ expect(router.navigateByUrl).toHaveBeenCalledWith(url);
+ });
+ });
+
+ describe('buildQuery', () => {
+ const query = 'query';
+ const expected = `-location.coll:\"${mockCollection.id}\" AND ${query}`;
+
+ let result;
+
+ beforeEach(() => {
+ result = comp.buildQuery(mockCollection.id, query);
+ });
+
+ it('should build a solr query to exclude the provided collection', () => {
+ expect(result).toEqual(expected);
+ })
+ });
+
+ describe('onCancel', () => {
+ beforeEach(() => {
+ spyOn(routerStub, 'navigate');
+ comp.onCancel();
+ });
+
+ it('should navigate to the collection page', () => {
+ expect(router.navigate).toHaveBeenCalledWith(['/collections/', mockCollection.id]);
+ });
+ });
+
+});
diff --git a/src/app/+collection-page/collection-item-mapper/collection-item-mapper.component.ts b/src/app/+collection-page/collection-item-mapper/collection-item-mapper.component.ts
new file mode 100644
index 0000000000..750578cc35
--- /dev/null
+++ b/src/app/+collection-page/collection-item-mapper/collection-item-mapper.component.ts
@@ -0,0 +1,256 @@
+import { combineLatest as observableCombineLatest, Observable } from 'rxjs';
+
+import { ChangeDetectionStrategy, Component, Inject, OnInit, ViewChild } from '@angular/core';
+import { fadeIn, fadeInOut } from '../../shared/animations/fade';
+import { ActivatedRoute, Router } from '@angular/router';
+import { RemoteData } from '../../core/data/remote-data';
+import { Collection } from '../../core/shared/collection.model';
+import { SearchConfigurationService } from '../../+search-page/search-service/search-configuration.service';
+import { PaginatedSearchOptions } from '../../+search-page/paginated-search-options.model';
+import { PaginatedList } from '../../core/data/paginated-list';
+import { map, startWith, switchMap, take, tap } from 'rxjs/operators';
+import { getRemoteDataPayload, getSucceededRemoteData, toDSpaceObjectListRD } from '../../core/shared/operators';
+import { SearchService } from '../../+search-page/search-service/search.service';
+import { DSpaceObject } from '../../core/shared/dspace-object.model';
+import { DSpaceObjectType } from '../../core/shared/dspace-object-type.model';
+import { SortDirection, SortOptions } from '../../core/cache/models/sort-options.model';
+import { NotificationsService } from '../../shared/notifications/notifications.service';
+import { ItemDataService } from '../../core/data/item-data.service';
+import { TranslateService } from '@ngx-translate/core';
+import { CollectionDataService } from '../../core/data/collection-data.service';
+import { isNotEmpty } from '../../shared/empty.util';
+import { RestResponse } from '../../core/cache/response.models';
+import { BehaviorSubject } from 'rxjs/internal/BehaviorSubject';
+import { SEARCH_CONFIG_SERVICE } from '../../+my-dspace-page/my-dspace-page.component';
+
+@Component({
+ selector: 'ds-collection-item-mapper',
+ styleUrls: ['./collection-item-mapper.component.scss'],
+ templateUrl: './collection-item-mapper.component.html',
+ changeDetection: ChangeDetectionStrategy.OnPush,
+ animations: [
+ fadeIn,
+ fadeInOut
+ ],
+ providers: [
+ {
+ provide: SEARCH_CONFIG_SERVICE,
+ useClass: SearchConfigurationService
+ }
+ ]
+})
+/**
+ * Component used to map items to a collection
+ */
+export class CollectionItemMapperComponent implements OnInit {
+
+ /**
+ * A view on the tabset element
+ * Used to switch tabs programmatically
+ */
+ @ViewChild('tabs') tabs;
+
+ /**
+ * The collection to map items to
+ */
+ collectionRD$: Observable>;
+
+ /**
+ * Search options
+ */
+ searchOptions$: Observable;
+
+ /**
+ * List of items to show under the "Browse" tab
+ * Items inside the collection
+ */
+ collectionItemsRD$: Observable>>;
+
+ /**
+ * List of items to show under the "Map" tab
+ * Items outside the collection
+ */
+ mappedItemsRD$: Observable>>;
+
+ /**
+ * Sort on title ASC by default
+ * @type {SortOptions}
+ */
+ defaultSortOptions: SortOptions = new SortOptions('dc.title', SortDirection.ASC);
+
+ /**
+ * Firing this observable (shouldUpdate$.next(true)) forces the two lists to reload themselves
+ * Usually fired after the lists their cache is cleared (to force a new request to the REST API)
+ */
+ shouldUpdate$: BehaviorSubject;
+
+ /**
+ * Track whether at least one search has been performed or not
+ * As soon as at least one search has been performed, we display the search results
+ */
+ performedSearch = false;
+
+ constructor(private route: ActivatedRoute,
+ private router: Router,
+ @Inject(SEARCH_CONFIG_SERVICE) private searchConfigService: SearchConfigurationService,
+ private searchService: SearchService,
+ private notificationsService: NotificationsService,
+ private itemDataService: ItemDataService,
+ private collectionDataService: CollectionDataService,
+ private translateService: TranslateService) {
+ }
+
+ ngOnInit(): void {
+ this.collectionRD$ = this.route.data.pipe(map((data) => data.collection)).pipe(getSucceededRemoteData()) as Observable>;
+ this.searchOptions$ = this.searchConfigService.paginatedSearchOptions;
+ this.loadItemLists();
+ }
+
+ /**
+ * Load collectionItemsRD$ with a fixed scope to only obtain the items this collection owns
+ * Load mappedItemsRD$ to only obtain items this collection doesn't own
+ */
+ loadItemLists() {
+ this.shouldUpdate$ = new BehaviorSubject(true);
+ const collectionAndOptions$ = observableCombineLatest(
+ this.collectionRD$,
+ this.searchOptions$,
+ this.shouldUpdate$
+ );
+ this.collectionItemsRD$ = collectionAndOptions$.pipe(
+ switchMap(([collectionRD, options, shouldUpdate]) => {
+ if (shouldUpdate) {
+ return this.collectionDataService.getMappedItems(collectionRD.payload.id, Object.assign(options, {
+ sort: this.defaultSortOptions
+ }))
+ }
+ })
+ );
+ this.mappedItemsRD$ = collectionAndOptions$.pipe(
+ switchMap(([collectionRD, options, shouldUpdate]) => {
+ if (shouldUpdate) {
+ return this.searchService.search(Object.assign(new PaginatedSearchOptions(options), {
+ query: this.buildQuery(collectionRD.payload.id, options.query),
+ scope: undefined,
+ dsoType: DSpaceObjectType.ITEM,
+ sort: this.defaultSortOptions
+ }), 10000).pipe(
+ toDSpaceObjectListRD(),
+ startWith(undefined)
+ );
+ }
+ })
+ );
+ }
+
+ /**
+ * Map/Unmap the selected items to the collection and display notifications
+ * @param ids The list of item UUID's to map/unmap to the collection
+ * @param remove Whether or not it's supposed to remove mappings
+ */
+ mapItems(ids: string[], remove?: boolean) {
+ const responses$ = this.collectionRD$.pipe(
+ getSucceededRemoteData(),
+ map((collectionRD: RemoteData) => collectionRD.payload),
+ switchMap((collection: Collection) =>
+ observableCombineLatest(ids.map((id: string) =>
+ remove ? this.itemDataService.removeMappingFromCollection(id, collection.id) : this.itemDataService.mapToCollection(id, collection.self)
+ ))
+ )
+ );
+
+ this.showNotifications(responses$, remove);
+ }
+
+ /**
+ * Display notifications
+ * @param {Observable} responses$ The responses after adding/removing a mapping
+ * @param {boolean} remove Whether or not the goal was to remove mappings
+ */
+ private showNotifications(responses$: Observable, remove?: boolean) {
+ const messageInsertion = remove ? 'unmap' : 'map';
+
+ responses$.subscribe((responses: RestResponse[]) => {
+ const successful = responses.filter((response: RestResponse) => response.isSuccessful);
+ const unsuccessful = responses.filter((response: RestResponse) => !response.isSuccessful);
+ if (successful.length > 0) {
+ const successMessages = observableCombineLatest(
+ this.translateService.get(`collection.edit.item-mapper.notifications.${messageInsertion}.success.head`),
+ this.translateService.get(`collection.edit.item-mapper.notifications.${messageInsertion}.success.content`, { amount: successful.length })
+ );
+
+ successMessages.subscribe(([head, content]) => {
+ this.notificationsService.success(head, content);
+ });
+ }
+ if (unsuccessful.length > 0) {
+ const unsuccessMessages = observableCombineLatest(
+ this.translateService.get(`collection.edit.item-mapper.notifications.${messageInsertion}.error.head`),
+ this.translateService.get(`collection.edit.item-mapper.notifications.${messageInsertion}.error.content`, { amount: unsuccessful.length })
+ );
+
+ unsuccessMessages.subscribe(([head, content]) => {
+ this.notificationsService.error(head, content);
+ });
+ }
+ // Force an update on all lists and switch back to the first tab
+ this.shouldUpdate$.next(true);
+ this.switchToFirstTab();
+ });
+ }
+
+ /**
+ * Clear url parameters on tab change (temporary fix until pagination is improved)
+ * @param event
+ */
+ tabChange(event) {
+ this.performedSearch = false;
+ this.router.navigateByUrl(this.getCurrentUrl());
+ }
+
+ /**
+ * Get current url without parameters
+ * @returns {string}
+ */
+ getCurrentUrl(): string {
+ if (this.router.url.indexOf('?') > -1) {
+ return this.router.url.substring(0, this.router.url.indexOf('?'));
+ }
+ return this.router.url;
+ }
+
+ /**
+ * Build a query where items that are already mapped to a collection are excluded from
+ * @param collectionId The collection's UUID
+ * @param query The query to add to it
+ */
+ buildQuery(collectionId: string, query: string): string {
+ const excludeColQuery = `-location.coll:\"${collectionId}\"`;
+ if (isNotEmpty(query)) {
+ return `${excludeColQuery} AND ${query}`;
+ } else {
+ return excludeColQuery;
+ }
+ }
+
+ /**
+ * Switch the view to focus on the first tab
+ */
+ switchToFirstTab() {
+ this.tabs.select('browseTab');
+ }
+
+ /**
+ * When a cancel event is fired, return to the collection page
+ */
+ onCancel() {
+ this.collectionRD$.pipe(
+ getSucceededRemoteData(),
+ getRemoteDataPayload(),
+ take(1)
+ ).subscribe((collection: Collection) => {
+ this.router.navigate(['/collections/', collection.id])
+ });
+ }
+
+}
diff --git a/src/app/+collection-page/collection-page-routing.module.ts b/src/app/+collection-page/collection-page-routing.module.ts
index cdbd7650b2..66c623657d 100644
--- a/src/app/+collection-page/collection-page-routing.module.ts
+++ b/src/app/+collection-page/collection-page-routing.module.ts
@@ -10,6 +10,7 @@ import { CreateCollectionPageGuard } from './create-collection-page/create-colle
import { DeleteCollectionPageComponent } from './delete-collection-page/delete-collection-page.component';
import { URLCombiner } from '../core/url-combiner/url-combiner';
import { getCollectionModulePath } from '../app-routing.module';
+import { CollectionItemMapperComponent } from './collection-item-mapper/collection-item-mapper.component';
export const COLLECTION_PARENT_PARAMETER = 'parent';
@@ -61,6 +62,15 @@ const COLLECTION_EDIT_PATH = ':id/edit';
resolve: {
collection: CollectionPageResolver
}
+ },
+ {
+ path: ':id/edit/mapper',
+ component: CollectionItemMapperComponent,
+ pathMatch: 'full',
+ resolve: {
+ collection: CollectionPageResolver
+ },
+ canActivate: [AuthenticatedGuard]
}
])
],
diff --git a/src/app/+collection-page/collection-page.module.ts b/src/app/+collection-page/collection-page.module.ts
index bdeffa34f3..0eaeca8ca7 100644
--- a/src/app/+collection-page/collection-page.module.ts
+++ b/src/app/+collection-page/collection-page.module.ts
@@ -10,6 +10,8 @@ import { CollectionFormComponent } from './collection-form/collection-form.compo
import { EditCollectionPageComponent } from './edit-collection-page/edit-collection-page.component';
import { DeleteCollectionPageComponent } from './delete-collection-page/delete-collection-page.component';
import { SearchService } from '../+search-page/search-service/search.service';
+import { CollectionItemMapperComponent } from './collection-item-mapper/collection-item-mapper.component';
+import { SearchFixedFilterService } from '../+search-page/search-filters/search-filter/search-fixed-filter.service';
@NgModule({
imports: [
@@ -22,10 +24,12 @@ import { SearchService } from '../+search-page/search-service/search.service';
CreateCollectionPageComponent,
EditCollectionPageComponent,
DeleteCollectionPageComponent,
- CollectionFormComponent
+ CollectionFormComponent,
+ CollectionItemMapperComponent
],
providers: [
- SearchService
+ SearchService,
+ SearchFixedFilterService
]
})
export class CollectionPageModule {
diff --git a/src/app/+item-page/edit-item-page/edit-item-page.module.ts b/src/app/+item-page/edit-item-page/edit-item-page.module.ts
index 236388109e..77740f0c6c 100644
--- a/src/app/+item-page/edit-item-page/edit-item-page.module.ts
+++ b/src/app/+item-page/edit-item-page/edit-item-page.module.ts
@@ -15,6 +15,8 @@ import { ItemDeleteComponent } from './item-delete/item-delete.component';
import { ItemMetadataComponent } from './item-metadata/item-metadata.component';
import { EditInPlaceFieldComponent } from './item-metadata/edit-in-place-field/edit-in-place-field.component';
import { ItemBitstreamsComponent } from './item-bitstreams/item-bitstreams.component';
+import { SearchPageModule } from '../../+search-page/search-page.module';
+import { ItemCollectionMapperComponent } from './item-collection-mapper/item-collection-mapper.component';
import { ItemRelationshipsComponent } from './item-relationships/item-relationships.component';
import { EditRelationshipComponent } from './item-relationships/edit-relationship/edit-relationship.component';
import { EditRelationshipListComponent } from './item-relationships/edit-relationship-list/edit-relationship-list.component';
@@ -27,7 +29,8 @@ import { ItemMoveComponent } from './item-move/item-move.component';
imports: [
CommonModule,
SharedModule,
- EditItemPageRoutingModule
+ EditItemPageRoutingModule,
+ SearchPageModule
],
declarations: [
EditItemPageComponent,
@@ -46,6 +49,7 @@ import { ItemMoveComponent } from './item-move/item-move.component';
EditInPlaceFieldComponent,
EditRelationshipComponent,
EditRelationshipListComponent,
+ ItemCollectionMapperComponent,
ItemMoveComponent,
]
})
diff --git a/src/app/+item-page/edit-item-page/edit-item-page.routing.module.ts b/src/app/+item-page/edit-item-page/edit-item-page.routing.module.ts
index 65e2a36fd1..1b386440c0 100644
--- a/src/app/+item-page/edit-item-page/edit-item-page.routing.module.ts
+++ b/src/app/+item-page/edit-item-page/edit-item-page.routing.module.ts
@@ -10,6 +10,7 @@ import { ItemDeleteComponent } from './item-delete/item-delete.component';
import { ItemStatusComponent } from './item-status/item-status.component';
import { ItemMetadataComponent } from './item-metadata/item-metadata.component';
import { ItemBitstreamsComponent } from './item-bitstreams/item-bitstreams.component';
+import { ItemCollectionMapperComponent } from './item-collection-mapper/item-collection-mapper.component';
import { ItemMoveComponent } from './item-move/item-move.component';
import { ItemRelationshipsComponent } from './item-relationships/item-relationships.component';
@@ -72,6 +73,13 @@ const ITEM_EDIT_MOVE_PATH = 'move';
},
]
},
+ {
+ path: 'mapper',
+ component: ItemCollectionMapperComponent,
+ resolve: {
+ item: ItemPageResolver
+ }
+ },
{
path: ITEM_EDIT_WITHDRAW_PATH,
component: ItemWithdrawComponent,
diff --git a/src/app/+item-page/edit-item-page/item-collection-mapper/item-collection-mapper.component.html b/src/app/+item-page/edit-item-page/item-collection-mapper/item-collection-mapper.component.html
new file mode 100644
index 0000000000..43bf7ecd02
--- /dev/null
+++ b/src/app/+item-page/edit-item-page/item-collection-mapper/item-collection-mapper.component.html
@@ -0,0 +1,56 @@
+
+
+
+
{{'item.edit.item-mapper.head' | translate}}
+
+
{{'item.edit.item-mapper.description' | translate}}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {{'item.edit.item-mapper.no-search' | translate}}
+
+
+
+
+
+
+
diff --git a/src/app/+item-page/edit-item-page/item-collection-mapper/item-collection-mapper.component.scss b/src/app/+item-page/edit-item-page/item-collection-mapper/item-collection-mapper.component.scss
new file mode 100644
index 0000000000..e69de29bb2
diff --git a/src/app/+item-page/edit-item-page/item-collection-mapper/item-collection-mapper.component.spec.ts b/src/app/+item-page/edit-item-page/item-collection-mapper/item-collection-mapper.component.spec.ts
new file mode 100644
index 0000000000..ed9351d5d2
--- /dev/null
+++ b/src/app/+item-page/edit-item-page/item-collection-mapper/item-collection-mapper.component.spec.ts
@@ -0,0 +1,207 @@
+import { async, ComponentFixture, fakeAsync, TestBed, tick } from '@angular/core/testing';
+import { RouterTestingModule } from '@angular/router/testing';
+import { NgbModule } from '@ng-bootstrap/ng-bootstrap';
+import { CommonModule } from '@angular/common';
+import { ItemCollectionMapperComponent } from './item-collection-mapper.component';
+import { ActivatedRoute, Router } from '@angular/router';
+import { SearchConfigurationService } from '../../../+search-page/search-service/search-configuration.service';
+import { SearchService } from '../../../+search-page/search-service/search.service';
+import { NotificationsService } from '../../../shared/notifications/notifications.service';
+import { ItemDataService } from '../../../core/data/item-data.service';
+import { RemoteData } from '../../../core/data/remote-data';
+import { PaginatedSearchOptions } from '../../../+search-page/paginated-search-options.model';
+import { PaginationComponentOptions } from '../../../shared/pagination/pagination-component-options.model';
+import { SortDirection, SortOptions } from '../../../core/cache/models/sort-options.model';
+import { RouterStub } from '../../../shared/testing/router-stub';
+import { ActivatedRouteStub } from '../../../shared/testing/active-router-stub';
+import { EventEmitter } from '@angular/core';
+import { SearchServiceStub } from '../../../shared/testing/search-service-stub';
+import { PaginatedList } from '../../../core/data/paginated-list';
+import { PageInfo } from '../../../core/shared/page-info.model';
+import { FormsModule } from '@angular/forms';
+import { SharedModule } from '../../../shared/shared.module';
+import { TranslateModule, TranslateService } from '@ngx-translate/core';
+import { NotificationsServiceStub } from '../../../shared/testing/notifications-service-stub';
+import { HostWindowService } from '../../../shared/host-window.service';
+import { HostWindowServiceStub } from '../../../shared/testing/host-window-service-stub';
+import { By } from '@angular/platform-browser';
+import { Item } from '../../../core/shared/item.model';
+import { ObjectSelectService } from '../../../shared/object-select/object-select.service';
+import { ObjectSelectServiceStub } from '../../../shared/testing/object-select-service-stub';
+import { Observable } from 'rxjs/internal/Observable';
+import { of } from 'rxjs/internal/observable/of';
+import { RestResponse } from '../../../core/cache/response.models';
+import { CollectionSelectComponent } from '../../../shared/object-select/collection-select/collection-select.component';
+import { PaginationComponent } from '../../../shared/pagination/pagination.component';
+import { EnumKeysPipe } from '../../../shared/utils/enum-keys-pipe';
+import { VarDirective } from '../../../shared/utils/var.directive';
+import { SearchFormComponent } from '../../../shared/search-form/search-form.component';
+import { Collection } from '../../../core/shared/collection.model';
+import { ErrorComponent } from '../../../shared/error/error.component';
+import { LoadingComponent } from '../../../shared/loading/loading.component';
+
+describe('ItemCollectionMapperComponent', () => {
+ let comp: ItemCollectionMapperComponent;
+ let fixture: ComponentFixture;
+
+ let route: ActivatedRoute;
+ let router: Router;
+ let searchConfigService: SearchConfigurationService;
+ let searchService: SearchService;
+ let notificationsService: NotificationsService;
+ let itemDataService: ItemDataService;
+
+ const mockCollection = Object.assign(new Collection(), { id: 'collection1' });
+ const mockItem: Item = Object.assign(new Item(), {
+ id: '932c7d50-d85a-44cb-b9dc-b427b12877bd',
+ name: 'test-item'
+ });
+ const mockItemRD: RemoteData- = new RemoteData
- (false, false, true, null, mockItem);
+ const mockSearchOptions = of(new PaginatedSearchOptions({
+ pagination: Object.assign(new PaginationComponentOptions(), {
+ id: 'search-page-configuration',
+ pageSize: 10,
+ currentPage: 1
+ }),
+ sort: new SortOptions('dc.title', SortDirection.ASC)
+ }));
+ const url = 'http://test.url';
+ const urlWithParam = url + '?param=value';
+ const routerStub = Object.assign(new RouterStub(), {
+ url: urlWithParam,
+ navigateByUrl: {},
+ navigate: {}
+ });
+ const searchConfigServiceStub = {
+ paginatedSearchOptions: mockSearchOptions
+ };
+ const mockCollectionsRD = new RemoteData(false, false, true, null, new PaginatedList(new PageInfo(), []));
+ const itemDataServiceStub = {
+ mapToCollection: () => of(new RestResponse(true, 200, 'OK')),
+ removeMappingFromCollection: () => of(new RestResponse(true, 200, 'OK')),
+ getMappedCollections: () => of(mockCollectionsRD),
+ /* tslint:disable:no-empty */
+ clearMappedCollectionsRequests: () => {}
+ /* tslint:enable:no-empty */
+ };
+ const searchServiceStub = Object.assign(new SearchServiceStub(), {
+ search: () => of(mockCollectionsRD),
+ /* tslint:disable:no-empty */
+ clearDiscoveryRequests: () => {}
+ /* tslint:enable:no-empty */
+ });
+ const activatedRouteStub = new ActivatedRouteStub({}, { item: mockItemRD });
+ const translateServiceStub = {
+ get: () => of('test-message of item ' + mockItem.name),
+ onLangChange: new EventEmitter(),
+ onTranslationChange: new EventEmitter(),
+ onDefaultLangChange: new EventEmitter()
+ };
+
+ beforeEach(async(() => {
+ TestBed.configureTestingModule({
+ imports: [CommonModule, FormsModule, RouterTestingModule.withRoutes([]), TranslateModule.forRoot(), NgbModule.forRoot()],
+ declarations: [ItemCollectionMapperComponent, CollectionSelectComponent, SearchFormComponent, PaginationComponent, EnumKeysPipe, VarDirective, ErrorComponent, LoadingComponent],
+ providers: [
+ { provide: ActivatedRoute, useValue: activatedRouteStub },
+ { provide: Router, useValue: routerStub },
+ { provide: SearchConfigurationService, useValue: searchConfigServiceStub },
+ { provide: NotificationsService, useValue: new NotificationsServiceStub() },
+ { provide: ItemDataService, useValue: itemDataServiceStub },
+ { provide: SearchService, useValue: searchServiceStub },
+ { provide: ObjectSelectService, useValue: new ObjectSelectServiceStub() },
+ { provide: TranslateService, useValue: translateServiceStub },
+ { provide: HostWindowService, useValue: new HostWindowServiceStub(0) }
+ ]
+ }).compileComponents();
+ }));
+
+ beforeEach(() => {
+ fixture = TestBed.createComponent(ItemCollectionMapperComponent);
+ comp = fixture.componentInstance;
+ fixture.detectChanges();
+ route = (comp as any).route;
+ router = (comp as any).router;
+ searchConfigService = (comp as any).searchConfigService;
+ notificationsService = (comp as any).notificationsService;
+ itemDataService = (comp as any).itemDataService;
+ searchService = (comp as any).searchService;
+ });
+
+ it('should display the correct collection name', () => {
+ const name: HTMLElement = fixture.debugElement.query(By.css('#item-name')).nativeElement;
+ expect(name.innerHTML).toContain(mockItem.name);
+ });
+
+ describe('mapCollections', () => {
+ const ids = ['id1', 'id2', 'id3', 'id4'];
+
+ it('should display a success message if at least one mapping was successful', () => {
+ comp.mapCollections(ids);
+ expect(notificationsService.success).toHaveBeenCalled();
+ expect(notificationsService.error).not.toHaveBeenCalled();
+ });
+
+ it('should display an error message if at least one mapping was unsuccessful', () => {
+ spyOn(itemDataService, 'mapToCollection').and.returnValue(of(new RestResponse(false, 404, 'Not Found')));
+ comp.mapCollections(ids);
+ expect(notificationsService.success).not.toHaveBeenCalled();
+ expect(notificationsService.error).toHaveBeenCalled();
+ });
+ });
+
+ describe('removeMappings', () => {
+ const ids = ['id1', 'id2', 'id3', 'id4'];
+
+ it('should display a success message if the removal of at least one mapping was successful', () => {
+ comp.removeMappings(ids);
+ expect(notificationsService.success).toHaveBeenCalled();
+ expect(notificationsService.error).not.toHaveBeenCalled();
+ });
+
+ it('should display an error message if the removal of at least one mapping was unsuccessful', () => {
+ spyOn(itemDataService, 'removeMappingFromCollection').and.returnValue(of(new RestResponse(false, 404, 'Not Found')));
+ comp.removeMappings(ids);
+ expect(notificationsService.success).not.toHaveBeenCalled();
+ expect(notificationsService.error).toHaveBeenCalled();
+ });
+ });
+
+ describe('tabChange', () => {
+ beforeEach(() => {
+ spyOn(routerStub, 'navigateByUrl');
+ comp.tabChange({});
+ });
+
+ it('should navigate to the same page to remove parameters', () => {
+ expect(router.navigateByUrl).toHaveBeenCalledWith(url);
+ });
+ });
+
+ describe('buildQuery', () => {
+ const query = 'query';
+ const expected = `${query} AND -search.resourceid:${mockCollection.id}`;
+
+ let result;
+
+ beforeEach(() => {
+ result = comp.buildQuery([mockCollection], query);
+ });
+
+ it('should build a solr query to exclude the provided collection', () => {
+ expect(result).toEqual(expected);
+ })
+ });
+
+ describe('onCancel', () => {
+ beforeEach(() => {
+ spyOn(routerStub, 'navigate');
+ comp.onCancel();
+ });
+
+ it('should navigate to the item page', () => {
+ expect(router.navigate).toHaveBeenCalledWith(['/items/', mockItem.id]);
+ });
+ });
+
+});
diff --git a/src/app/+item-page/edit-item-page/item-collection-mapper/item-collection-mapper.component.ts b/src/app/+item-page/edit-item-page/item-collection-mapper/item-collection-mapper.component.ts
new file mode 100644
index 0000000000..97b8164a6e
--- /dev/null
+++ b/src/app/+item-page/edit-item-page/item-collection-mapper/item-collection-mapper.component.ts
@@ -0,0 +1,283 @@
+import { combineLatest as observableCombineLatest, Observable } from 'rxjs';
+
+import { ChangeDetectionStrategy, Component, OnInit, ViewChild } from '@angular/core';
+import { fadeIn, fadeInOut } from '../../../shared/animations/fade';
+import { PaginatedSearchOptions } from '../../../+search-page/paginated-search-options.model';
+import { RemoteData } from '../../../core/data/remote-data';
+import { PaginatedList } from '../../../core/data/paginated-list';
+import { Collection } from '../../../core/shared/collection.model';
+import { Item } from '../../../core/shared/item.model';
+import { getRemoteDataPayload, getSucceededRemoteData, toDSpaceObjectListRD } from '../../../core/shared/operators';
+import { ActivatedRoute, Router } from '@angular/router';
+import { SearchService } from '../../../+search-page/search-service/search.service';
+import { SearchConfigurationService } from '../../../+search-page/search-service/search-configuration.service';
+import { map, startWith, switchMap, take } from 'rxjs/operators';
+import { ItemDataService } from '../../../core/data/item-data.service';
+import { TranslateService } from '@ngx-translate/core';
+import { NotificationsService } from '../../../shared/notifications/notifications.service';
+import { DSpaceObjectType } from '../../../core/shared/dspace-object-type.model';
+import { isNotEmpty } from '../../../shared/empty.util';
+import { RestResponse } from '../../../core/cache/response.models';
+import { BehaviorSubject } from 'rxjs/internal/BehaviorSubject';
+
+@Component({
+ selector: 'ds-item-collection-mapper',
+ styleUrls: ['./item-collection-mapper.component.scss'],
+ templateUrl: './item-collection-mapper.component.html',
+ changeDetection: ChangeDetectionStrategy.OnPush,
+ animations: [
+ fadeIn,
+ fadeInOut
+ ]
+})
+/**
+ * Component for mapping collections to an item
+ */
+export class ItemCollectionMapperComponent implements OnInit {
+
+ /**
+ * A view on the tabset element
+ * Used to switch tabs programmatically
+ */
+ @ViewChild('tabs') tabs;
+
+ /**
+ * The item to map to collections
+ */
+ itemRD$: Observable>;
+
+ /**
+ * Search options
+ */
+ searchOptions$: Observable;
+
+ /**
+ * List of collections to show under the "Browse" tab
+ * Collections that are mapped to the item
+ */
+ itemCollectionsRD$: Observable>>;
+
+ /**
+ * List of collections to show under the "Map" tab
+ * Collections that are not mapped to the item
+ */
+ mappedCollectionsRD$: Observable>>;
+
+ /**
+ * Firing this observable (shouldUpdate$.next(true)) forces the two lists to reload themselves
+ * Usually fired after the lists their cache is cleared (to force a new request to the REST API)
+ */
+ shouldUpdate$: BehaviorSubject;
+
+ /**
+ * Track whether at least one search has been performed or not
+ * As soon as at least one search has been performed, we display the search results
+ */
+ performedSearch = false;
+
+ constructor(private route: ActivatedRoute,
+ private router: Router,
+ private searchConfigService: SearchConfigurationService,
+ private searchService: SearchService,
+ private notificationsService: NotificationsService,
+ private itemDataService: ItemDataService,
+ private translateService: TranslateService) {
+ }
+
+ ngOnInit(): void {
+ this.itemRD$ = this.route.data.pipe(map((data) => data.item)).pipe(getSucceededRemoteData()) as Observable>;
+ this.searchOptions$ = this.searchConfigService.paginatedSearchOptions;
+ this.loadCollectionLists();
+ }
+
+ /**
+ * Load itemCollectionsRD$ with a fixed scope to only obtain the collections that own this item
+ * Load mappedCollectionsRD$ to only obtain collections that don't own this item
+ */
+ loadCollectionLists() {
+ this.shouldUpdate$ = new BehaviorSubject(true);
+ this.itemCollectionsRD$ = observableCombineLatest(this.itemRD$, this.shouldUpdate$).pipe(
+ map(([itemRD, shouldUpdate]) => {
+ if (shouldUpdate) {
+ return itemRD.payload
+ }
+ }),
+ switchMap((item: Item) => this.itemDataService.getMappedCollections(item.id))
+ );
+
+ const owningCollectionRD$ = this.itemRD$.pipe(
+ switchMap((itemRD: RemoteData
- ) => itemRD.payload.owningCollection)
+ );
+ const itemCollectionsAndOptions$ = observableCombineLatest(
+ this.itemCollectionsRD$,
+ owningCollectionRD$,
+ this.searchOptions$
+ );
+ this.mappedCollectionsRD$ = itemCollectionsAndOptions$.pipe(
+ switchMap(([itemCollectionsRD, owningCollectionRD, searchOptions]) => {
+ return this.searchService.search(Object.assign(new PaginatedSearchOptions(searchOptions), {
+ query: this.buildQuery([...itemCollectionsRD.payload.page, owningCollectionRD.payload], searchOptions.query),
+ dsoType: DSpaceObjectType.COLLECTION
+ }), 10000).pipe(
+ toDSpaceObjectListRD(),
+ startWith(undefined)
+ );
+ })
+ ) as Observable>>;
+ }
+
+ /**
+ * Map the item to the selected collections and display notifications
+ * @param {string[]} ids The list of collection UUID's to map the item to
+ */
+ mapCollections(ids: string[]) {
+ const itemIdAndExcludingIds$ = observableCombineLatest(
+ this.itemRD$.pipe(
+ getSucceededRemoteData(),
+ take(1),
+ map((rd: RemoteData
- ) => rd.payload),
+ map((item: Item) => item.id)
+ ),
+ this.itemCollectionsRD$.pipe(
+ getSucceededRemoteData(),
+ take(1),
+ map((rd: RemoteData>) => rd.payload.page),
+ map((collections: Collection[]) => collections.map((collection: Collection) => collection.id))
+ )
+ );
+
+ // Map the item to the collections found in ids, excluding the collections the item is already mapped to
+ const responses$ = itemIdAndExcludingIds$.pipe(
+ switchMap(([itemId, excludingIds]) => observableCombineLatest(this.filterIds(ids, excludingIds).map((id: string) => this.itemDataService.mapToCollection(itemId, id))))
+ );
+
+ this.showNotifications(responses$, 'item.edit.item-mapper.notifications.add');
+ }
+
+ /**
+ * Remove the mapping of the item to the selected collections and display notifications
+ * @param {string[]} ids The list of collection UUID's to remove the mapping of the item for
+ */
+ removeMappings(ids: string[]) {
+ const responses$ = this.itemRD$.pipe(
+ getSucceededRemoteData(),
+ map((itemRD: RemoteData
- ) => itemRD.payload.id),
+ switchMap((itemId: string) => observableCombineLatest(ids.map((id: string) => this.itemDataService.removeMappingFromCollection(itemId, id))))
+ );
+
+ this.showNotifications(responses$, 'item.edit.item-mapper.notifications.remove');
+ }
+
+ /**
+ * Filters ids from a given list of ids, which exist in a second given list of ids
+ * @param {string[]} ids The list of ids to filter out of
+ * @param {string[]} excluding The ids that should be excluded from the first list
+ * @returns {string[]}
+ */
+ private filterIds(ids: string[], excluding: string[]): string[] {
+ return ids.filter((id: string) => excluding.indexOf(id) < 0);
+ }
+
+ /**
+ * Display notifications
+ * @param {Observable} responses$ The responses after adding/removing a mapping
+ * @param {string} messagePrefix The prefix to build the notification messages with
+ */
+ private showNotifications(responses$: Observable, messagePrefix: string) {
+ responses$.subscribe((responses: RestResponse[]) => {
+ const successful = responses.filter((response: RestResponse) => response.isSuccessful);
+ const unsuccessful = responses.filter((response: RestResponse) => !response.isSuccessful);
+ if (successful.length > 0) {
+ const successMessages = observableCombineLatest(
+ this.translateService.get(`${messagePrefix}.success.head`),
+ this.translateService.get(`${messagePrefix}.success.content`, { amount: successful.length })
+ );
+
+ successMessages.subscribe(([head, content]) => {
+ this.notificationsService.success(head, content);
+ });
+ }
+ if (unsuccessful.length > 0) {
+ const unsuccessMessages = observableCombineLatest(
+ this.translateService.get(`${messagePrefix}.error.head`),
+ this.translateService.get(`${messagePrefix}.error.content`, { amount: unsuccessful.length })
+ );
+
+ unsuccessMessages.subscribe(([head, content]) => {
+ this.notificationsService.error(head, content);
+ });
+ }
+ // Force an update on all lists and switch back to the first tab
+ this.shouldUpdate$.next(true);
+ this.switchToFirstTab();
+ });
+ }
+
+ /**
+ * Clear url parameters on tab change (temporary fix until pagination is improved)
+ * @param event
+ */
+ tabChange(event) {
+ this.performedSearch = false;
+ this.router.navigateByUrl(this.getCurrentUrl());
+ }
+
+ /**
+ * Get current url without parameters
+ * @returns {string}
+ */
+ getCurrentUrl(): string {
+ if (this.router.url.indexOf('?') > -1) {
+ return this.router.url.substring(0, this.router.url.indexOf('?'));
+ }
+ return this.router.url;
+ }
+
+ /**
+ * Build a query to exclude collections from
+ * @param collections The collections their UUIDs
+ * @param query The query to add to it
+ */
+ buildQuery(collections: Collection[], query: string): string {
+ let result = query;
+ for (const collection of collections) {
+ result = this.addExcludeCollection(collection.id, result);
+ }
+ return result;
+ }
+
+ /**
+ * Add an exclusion of a collection to a query
+ * @param collectionId The collection's UUID
+ * @param query The query to add the exclusion to
+ */
+ addExcludeCollection(collectionId: string, query: string): string {
+ const excludeQuery = `-search.resourceid:${collectionId}`;
+ if (isNotEmpty(query)) {
+ return `${query} AND ${excludeQuery}`;
+ } else {
+ return excludeQuery;
+ }
+ }
+
+ /**
+ * Switch the view to focus on the first tab
+ */
+ switchToFirstTab() {
+ this.tabs.select('browseTab');
+ }
+
+ /**
+ * When a cancel event is fired, return to the item page
+ */
+ onCancel() {
+ this.itemRD$.pipe(
+ getSucceededRemoteData(),
+ getRemoteDataPayload(),
+ take(1)
+ ).subscribe((item: Item) => {
+ this.router.navigate(['/items/', item.id])
+ });
+ }
+
+}
diff --git a/src/app/+item-page/edit-item-page/item-relationships/edit-relationship-list/edit-relationship-list.component.spec.ts b/src/app/+item-page/edit-item-page/item-relationships/edit-relationship-list/edit-relationship-list.component.spec.ts
index 3748ebca9d..3293711d73 100644
--- a/src/app/+item-page/edit-item-page/item-relationships/edit-relationship-list/edit-relationship-list.component.spec.ts
+++ b/src/app/+item-page/edit-item-page/item-relationships/edit-relationship-list/edit-relationship-list.component.spec.ts
@@ -38,8 +38,8 @@ describe('EditRelationshipListComponent', () => {
relationshipType = Object.assign(new RelationshipType(), {
id: '1',
uuid: '1',
- leftLabel: 'isAuthorOfPublication',
- rightLabel: 'isPublicationOfAuthor'
+ leftwardType: 'isAuthorOfPublication',
+ rightwardType: 'isPublicationOfAuthor'
});
relationships = [
@@ -119,7 +119,7 @@ describe('EditRelationshipListComponent', () => {
de = fixture.debugElement;
comp.item = item;
comp.url = url;
- comp.relationshipLabel = relationshipType.leftLabel;
+ comp.relationshipLabel = relationshipType.leftwardType;
fixture.detectChanges();
});
diff --git a/src/app/+item-page/edit-item-page/item-relationships/edit-relationship/edit-relationship.component.spec.ts b/src/app/+item-page/edit-item-page/item-relationships/edit-relationship/edit-relationship.component.spec.ts
index b90543d6ca..54fce0a68e 100644
--- a/src/app/+item-page/edit-item-page/item-relationships/edit-relationship/edit-relationship.component.spec.ts
+++ b/src/app/+item-page/edit-item-page/item-relationships/edit-relationship/edit-relationship.component.spec.ts
@@ -33,8 +33,8 @@ describe('EditRelationshipComponent', () => {
relationshipType = Object.assign(new RelationshipType(), {
id: '1',
uuid: '1',
- leftLabel: 'isAuthorOfPublication',
- rightLabel: 'isPublicationOfAuthor'
+ leftwardType: 'isAuthorOfPublication',
+ rightwardType: 'isPublicationOfAuthor'
});
relationships = [
diff --git a/src/app/+item-page/edit-item-page/item-relationships/item-relationships.component.spec.ts b/src/app/+item-page/edit-item-page/item-relationships/item-relationships.component.spec.ts
index b1a4e11371..48bc28a1b9 100644
--- a/src/app/+item-page/edit-item-page/item-relationships/item-relationships.component.spec.ts
+++ b/src/app/+item-page/edit-item-page/item-relationships/item-relationships.component.spec.ts
@@ -68,8 +68,8 @@ describe('ItemRelationshipsComponent', () => {
relationshipType = Object.assign(new RelationshipType(), {
id: '1',
uuid: '1',
- leftLabel: 'isAuthorOfPublication',
- rightLabel: 'isPublicationOfAuthor'
+ leftwardType: 'isAuthorOfPublication',
+ rightwardType: 'isPublicationOfAuthor'
});
relationships = [
diff --git a/src/app/+item-page/edit-item-page/item-status/item-status.component.ts b/src/app/+item-page/edit-item-page/item-status/item-status.component.ts
index d293188aa6..e63154918b 100644
--- a/src/app/+item-page/edit-item-page/item-status/item-status.component.ts
+++ b/src/app/+item-page/edit-item-page/item-status/item-status.component.ts
@@ -68,6 +68,7 @@ export class ItemStatusComponent implements OnInit {
The value is supposed to be a href for the button
*/
this.operations = [];
+ this.operations.push(new ItemOperation('mappedCollections', this.getCurrentUrl(item) + '/mapper'));
if (item.isWithdrawn) {
this.operations.push(new ItemOperation('reinstate', this.getCurrentUrl(item) + '/reinstate'));
} else {
diff --git a/src/app/+item-page/item-page.module.ts b/src/app/+item-page/item-page.module.ts
index f510ccf19b..2a5d0b6da7 100644
--- a/src/app/+item-page/item-page.module.ts
+++ b/src/app/+item-page/item-page.module.ts
@@ -31,8 +31,8 @@ import { MetadataFieldWrapperComponent } from './field-components/metadata-field
imports: [
CommonModule,
SharedModule,
- EditItemPageModule,
ItemPageRoutingModule,
+ EditItemPageModule,
SearchPageModule
],
declarations: [
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 b4eda2abfb..9f120a87dd 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
@@ -60,10 +60,10 @@ export const filterRelationsByTypeLabel = (label: string, thisId?: string) =>
return relatedItems$.pipe(
map((arr) => relsCurrentPage.filter((rel: Relationship, idx: number) =>
hasValue(relTypesCurrentPage[idx]) && (
- (hasNoValue(thisId) && (relTypesCurrentPage[idx].leftLabel === label ||
- relTypesCurrentPage[idx].rightLabel === label)) ||
- (thisId === arr[idx][0].id && relTypesCurrentPage[idx].leftLabel === label) ||
- (thisId === arr[idx][1].id && relTypesCurrentPage[idx].rightLabel === label)
+ (hasNoValue(thisId) && (relTypesCurrentPage[idx].leftwardType === label ||
+ relTypesCurrentPage[idx].rightwardType === label)) ||
+ (thisId === arr[idx][0].id && relTypesCurrentPage[idx].leftwardType === label) ||
+ (thisId === arr[idx][1].id && relTypesCurrentPage[idx].rightwardType === label)
)
))
);
diff --git a/src/app/+search-page/search-page.module.ts b/src/app/+search-page/search-page.module.ts
index da6d5856d5..f4c665d06e 100644
--- a/src/app/+search-page/search-page.module.ts
+++ b/src/app/+search-page/search-page.module.ts
@@ -77,7 +77,6 @@ const components = [
SearchFilterService,
SearchFixedFilterService,
ConfigurationSearchPageGuard,
- SearchFilterService,
SearchConfigurationService
],
entryComponents: [
diff --git a/src/app/+search-page/search-service/search.service.ts b/src/app/+search-page/search-service/search.service.ts
index 3d48ca54c5..22a1aba637 100644
--- a/src/app/+search-page/search-service/search.service.ts
+++ b/src/app/+search-page/search-service/search.service.ts
@@ -1,7 +1,7 @@
import { combineLatest as observableCombineLatest, Observable, of as observableOf, zip as observableZip } from 'rxjs';
import { Injectable, OnDestroy } from '@angular/core';
import { NavigationExtras, PRIMARY_OUTLET, Router, UrlSegmentGroup } from '@angular/router';
-import { first, map, switchMap, tap } from 'rxjs/operators';
+import { first, map, switchMap, take, tap } from 'rxjs/operators';
import { RemoteDataBuildService } from '../../core/cache/builders/remote-data-build.service';
import {
FacetConfigSuccessResponse,
@@ -100,9 +100,10 @@ export class SearchService implements OnDestroy {
/**
* Method to retrieve a paginated list of search results from the server
* @param {PaginatedSearchOptions} searchOptions The configuration necessary to perform this search
+ * @param responseMsToLive The amount of milliseconds for the response to live in cache
* @returns {Observable>>>} Emits a paginated list with all search results found
*/
- search(searchOptions?: PaginatedSearchOptions): Observable>>> {
+ search(searchOptions?: PaginatedSearchOptions, responseMsToLive?: number): Observable>>> {
const hrefObs = this.halService.getEndpoint(this.searchLinkPath).pipe(
map((url: string) => {
if (hasValue(searchOptions)) {
@@ -122,6 +123,7 @@ export class SearchService implements OnDestroy {
};
return Object.assign(request, {
+ responseMsToLive: hasValue(responseMsToLive) ? responseMsToLive : request.responseMsToLive,
getResponseParser: getResponseParserFn
});
}),
diff --git a/src/app/app.reducer.ts b/src/app/app.reducer.ts
index e3333fb34a..bc84f961fb 100644
--- a/src/app/app.reducer.ts
+++ b/src/app/app.reducer.ts
@@ -27,6 +27,7 @@ import {
bitstreamFormatReducer,
BitstreamFormatRegistryState
} from './+admin/admin-registries/bitstream-formats/bitstream-format.reducers';
+import { ObjectSelectionListState, objectSelectionReducer } from './shared/object-select/object-select.reducer';
export interface AppState {
router: fromRouter.RouterReducerState;
@@ -41,6 +42,7 @@ export interface AppState {
truncatable: TruncatablesState;
cssVariables: CSSVariablesState;
menus: MenusState;
+ objectSelection: ObjectSelectionListState;
}
export const appReducers: ActionReducerMap = {
@@ -56,6 +58,7 @@ export const appReducers: ActionReducerMap = {
truncatable: truncatableReducer,
cssVariables: cssVariablesReducer,
menus: menusReducer,
+ objectSelection: objectSelectionReducer
};
export const routerStateSelector = (state: AppState) => state.router;
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 85ba7636b6..a9fd699af2 100644
--- a/src/app/core/cache/builders/remote-data-build.service.ts
+++ b/src/app/core/cache/builders/remote-data-build.service.ts
@@ -82,8 +82,8 @@ export class RemoteDataBuildService {
toRemoteDataObservable(requestEntry$: Observable, payload$: Observable) {
return observableCombineLatest(requestEntry$, payload$).pipe(
map(([reqEntry, payload]) => {
- const requestPending = hasValue(reqEntry.requestPending) ? reqEntry.requestPending : true;
- const responsePending = hasValue(reqEntry.responsePending) ? reqEntry.responsePending : false;
+ const requestPending = hasValue(reqEntry) && hasValue(reqEntry.requestPending) ? reqEntry.requestPending : true;
+ const responsePending = hasValue(reqEntry) && hasValue(reqEntry.responsePending) ? reqEntry.responsePending : false;
let isSuccessful: boolean;
let error: RemoteDataError;
if (hasValue(reqEntry) && hasValue(reqEntry.response)) {
diff --git a/src/app/core/cache/models/items/normalized-relationship-type.model.ts b/src/app/core/cache/models/items/normalized-relationship-type.model.ts
index 800b27cd7e..23c3333a9b 100644
--- a/src/app/core/cache/models/items/normalized-relationship-type.model.ts
+++ b/src/app/core/cache/models/items/normalized-relationship-type.model.ts
@@ -23,7 +23,7 @@ export class NormalizedRelationshipType extends NormalizedObject {
+ let service: CollectionDataService;
+ let objectCache: ObjectCacheService;
+ let requestService: RequestService;
+ let halService: HALEndpointService;
+ let rdbService: RemoteDataBuildService;
+
+ const url = 'fake-collections-url';
+
+ beforeEach(() => {
+ objectCache = jasmine.createSpyObj('objectCache', {
+ remove: jasmine.createSpy('remove')
+ });
+ requestService = getMockRequestService();
+ halService = Object.assign(new HALEndpointServiceStub(url));
+ rdbService = jasmine.createSpyObj('rdbService', {
+ buildList: jasmine.createSpy('buildList')
+ });
+
+ service = new CollectionDataService(requestService, rdbService, null, null, null, objectCache, halService, null, null, null);
+ });
+
+ describe('getMappedItems', () => {
+ let result;
+
+ beforeEach(() => {
+ result = service.getMappedItems('collection-id');
+ });
+
+ it('should configure a GET request', () => {
+ expect(requestService.configure).toHaveBeenCalledWith(jasmine.any(GetRequest), undefined);
+ });
+ });
+
+});
diff --git a/src/app/core/data/collection-data.service.ts b/src/app/core/data/collection-data.service.ts
index 993954a360..e49267d1f2 100644
--- a/src/app/core/data/collection-data.service.ts
+++ b/src/app/core/data/collection-data.service.ts
@@ -1,6 +1,6 @@
import { Injectable } from '@angular/core';
-import { filter, map, take } from 'rxjs/operators';
+import { distinctUntilChanged, filter, map, take } from 'rxjs/operators';
import { Store } from '@ngrx/store';
import { RemoteDataBuildService } from '../cache/builders/remote-data-build.service';
@@ -16,9 +16,17 @@ import { HttpClient } from '@angular/common/http';
import { NormalizedObjectBuildService } from '../cache/builders/normalized-object-build.service';
import { DSOChangeAnalyzer } from './dso-change-analyzer.service';
import { Observable } from 'rxjs/internal/Observable';
-import { FindAllOptions } from './request.models';
+import { FindAllOptions, GetRequest } from './request.models';
import { RemoteData } from './remote-data';
import { PaginatedList } from './paginated-list';
+import { configureRequest } from '../shared/operators';
+import { DSOResponseParsingService } from './dso-response-parsing.service';
+import { ResponseParsingService } from './parsing.service';
+import { GenericConstructor } from '../shared/generic-constructor';
+import { hasValue, isNotEmptyOperator } from '../../shared/empty.util';
+import { DSpaceObject } from '../shared/dspace-object.model';
+import { PaginatedSearchOptions } from '../../+search-page/paginated-search-options.model';
+import { SearchParam } from '../cache/models/search-param.model';
@Injectable()
export class CollectionDataService extends ComColDataService {
@@ -40,6 +48,36 @@ export class CollectionDataService extends ComColDataService {
super();
}
+ /**
+ * Get all collections the user is authorized to submit to
+ *
+ * @param options The [[FindAllOptions]] object
+ * @return Observable>>
+ * collection list
+ */
+ getAuthorizedCollection(options: FindAllOptions = {}): Observable>> {
+ const searchHref = 'findAuthorized';
+
+ return this.searchBy(searchHref, options).pipe(
+ filter((collections: RemoteData>) => !collections.isResponsePending));
+ }
+
+ /**
+ * Get all collections the user is authorized to submit to, by community
+ *
+ * @param communityId The community id
+ * @param options The [[FindAllOptions]] object
+ * @return Observable>>
+ * collection list
+ */
+ getAuthorizedCollectionByCommunity(communityId: string, options: FindAllOptions = {}): Observable>> {
+ const searchHref = 'findAuthorizedByCommunity';
+ options.searchParams = [new SearchParam('uuid', communityId)];
+
+ return this.searchBy(searchHref, options).pipe(
+ filter((collections: RemoteData>) => !collections.isResponsePending));
+ }
+
/**
* Find whether there is a collection whom user has authorization to submit to
*
@@ -57,4 +95,46 @@ export class CollectionDataService extends ComColDataService {
map((collections: RemoteData>) => collections.payload.totalElements > 0)
);
}
+
+ /**
+ * Fetches the endpoint used for mapping items to a collection
+ * @param collectionId The id of the collection to map items to
+ */
+ getMappedItemsEndpoint(collectionId): Observable {
+ return this.halService.getEndpoint(this.linkPath).pipe(
+ map((endpoint: string) => this.getIDHref(endpoint, collectionId)),
+ map((endpoint: string) => `${endpoint}/mappedItems`)
+ );
+ }
+
+ /**
+ * Fetches a list of items that are mapped to a collection
+ * @param collectionId The id of the collection
+ * @param searchOptions Search options to sort or filter out items
+ */
+ getMappedItems(collectionId: string, searchOptions?: PaginatedSearchOptions): Observable>> {
+ const requestUuid = this.requestService.generateRequestId();
+
+ const href$ = this.getMappedItemsEndpoint(collectionId).pipe(
+ isNotEmptyOperator(),
+ distinctUntilChanged(),
+ map((endpoint: string) => hasValue(searchOptions) ? searchOptions.toRestUrl(endpoint) : endpoint)
+ );
+
+ href$.pipe(
+ map((endpoint: string) => {
+ const request = new GetRequest(requestUuid, endpoint);
+ return Object.assign(request, {
+ responseMsToLive: 0,
+ getResponseParser(): GenericConstructor {
+ return DSOResponseParsingService;
+ }
+ });
+ }),
+ configureRequest(this.requestService)
+ ).subscribe();
+
+ return this.rdbService.buildList(href$);
+ }
+
}
diff --git a/src/app/core/data/item-data.service.spec.ts b/src/app/core/data/item-data.service.spec.ts
index 3553a63af4..36b8e6b3c5 100644
--- a/src/app/core/data/item-data.service.spec.ts
+++ b/src/app/core/data/item-data.service.spec.ts
@@ -7,7 +7,14 @@ import { CoreState } from '../core.reducers';
import { ItemDataService } from './item-data.service';
import { RequestService } from './request.service';
import { HALEndpointService } from '../shared/hal-endpoint.service';
-import { FindAllOptions, RestRequest } from './request.models';
+import {
+ DeleteRequest,
+ FindAllOptions,
+ GetRequest,
+ MappedCollectionsRequest,
+ PostRequest,
+ RestRequest
+} from './request.models';
import { ObjectCacheService } from '../cache/object-cache.service';
import { Observable } from 'rxjs';
import { RestResponse } from '../cache/response.models';
@@ -16,12 +23,13 @@ import { NormalizedObjectBuildService } from '../cache/builders/normalized-objec
import { HttpClient } from '@angular/common/http';
import { RequestEntry } from './request.reducer';
import { of as observableOf } from 'rxjs';
+import { getMockRequestService } from '../../shared/mocks/mock-request.service';
describe('ItemDataService', () => {
let scheduler: TestScheduler;
let service: ItemDataService;
let bs: BrowseService;
- const requestService = {
+ const requestService = Object.assign(getMockRequestService(), {
generateRequestId(): string {
return scopeID;
},
@@ -32,9 +40,14 @@ describe('ItemDataService', () => {
const responseCacheEntry = new RequestEntry();
responseCacheEntry.response = new RestResponse(true, 200, 'OK');
return observableOf(responseCacheEntry);
+ },
+ removeByHrefSubstring(href: string) {
+ // Do nothing
}
- } as RequestService;
- const rdbService = {} as RemoteDataBuildService;
+ }) as RequestService;
+ const rdbService = jasmine.createSpyObj('rdbService', {
+ toRemoteDataObservable: observableOf({})
+ });
const store = {} as Store;
const objectCache = {} as ObjectCacheService;
@@ -162,4 +175,32 @@ describe('ItemDataService', () => {
});
});
+ describe('removeMappingFromCollection', () => {
+ let result;
+
+ beforeEach(() => {
+ service = initTestService();
+ spyOn(requestService, 'configure');
+ result = service.removeMappingFromCollection('item-id', 'collection-id');
+ });
+
+ it('should configure a DELETE request', () => {
+ result.subscribe(() => expect(requestService.configure).toHaveBeenCalledWith(jasmine.any(DeleteRequest), undefined));
+ });
+ });
+
+ describe('mapToCollection', () => {
+ let result;
+
+ beforeEach(() => {
+ service = initTestService();
+ spyOn(requestService, 'configure');
+ result = service.mapToCollection('item-id', 'collection-href');
+ });
+
+ it('should configure a POST request', () => {
+ result.subscribe(() => expect(requestService.configure).toHaveBeenCalledWith(jasmine.any(PostRequest), undefined));
+ });
+ });
+
});
diff --git a/src/app/core/data/item-data.service.ts b/src/app/core/data/item-data.service.ts
index 07d8ed8405..de05dad0c1 100644
--- a/src/app/core/data/item-data.service.ts
+++ b/src/app/core/data/item-data.service.ts
@@ -1,8 +1,8 @@
-import { distinctUntilChanged, filter, find, map } from 'rxjs/operators';
+import { distinctUntilChanged, filter, find, map, switchMap, take } from 'rxjs/operators';
import { Injectable } from '@angular/core';
import { Store } from '@ngrx/store';
import { Observable } from 'rxjs';
-import { hasValue, isNotEmpty } from '../../shared/empty.util';
+import { hasValue, isNotEmpty, isNotEmptyOperator } from '../../shared/empty.util';
import { BrowseService } from '../browse/browse.service';
import { RemoteDataBuildService } from '../cache/builders/remote-data-build.service';
import { CoreState } from '../core.reducers';
@@ -12,17 +12,31 @@ import { URLCombiner } from '../url-combiner/url-combiner';
import { DataService } from './data.service';
import { RequestService } from './request.service';
import { HALEndpointService } from '../shared/hal-endpoint.service';
-import { FindAllOptions, PatchRequest, PutRequest, RestRequest } from './request.models';
+import {
+ DeleteRequest,
+ FindAllOptions,
+ MappedCollectionsRequest,
+ PatchRequest,
+ PostRequest, PutRequest,
+ RestRequest
+} from './request.models';
import { ObjectCacheService } from '../cache/object-cache.service';
import { NotificationsService } from '../../shared/notifications/notifications.service';
import { DSOChangeAnalyzer } from './dso-change-analyzer.service';
import { HttpClient, HttpHeaders } from '@angular/common/http';
import { NormalizedObjectBuildService } from '../cache/builders/normalized-object-build.service';
-import { configureRequest, getRequestFromRequestHref } from '../shared/operators';
+import {
+ configureRequest,
+ filterSuccessfulResponses,
+ getRequestFromRequestHref,
+ getResponseFromEntry
+} from '../shared/operators';
import { RequestEntry } from './request.reducer';
-import { RestResponse } from '../cache/response.models';
+import { GenericSuccessResponse, RestResponse } from '../cache/response.models';
import { HttpOptions } from '../dspace-rest-v2/dspace-rest-v2.service';
import { Collection } from '../shared/collection.model';
+import { RemoteData } from './remote-data';
+import { PaginatedList } from './paginated-list';
@Injectable()
export class ItemDataService extends DataService
- {
@@ -60,6 +74,80 @@ export class ItemDataService extends DataService
- {
distinctUntilChanged(),);
}
+ /**
+ * Fetches the endpoint used for mapping an item to a collection,
+ * or for fetching all collections the item is mapped to if no collection is provided
+ * @param itemId The item's id
+ * @param collectionId The collection's id (optional)
+ */
+ public getMappedCollectionsEndpoint(itemId: string, collectionId?: string): Observable {
+ return this.halService.getEndpoint(this.linkPath).pipe(
+ map((endpoint: string) => this.getIDHref(endpoint, itemId)),
+ map((endpoint: string) => `${endpoint}/mappedCollections${collectionId ? `/${collectionId}` : ''}`)
+ );
+ }
+
+ /**
+ * Removes the mapping of an item from a collection
+ * @param itemId The item's id
+ * @param collectionId The collection's id
+ */
+ public removeMappingFromCollection(itemId: string, collectionId: string): Observable {
+ return this.getMappedCollectionsEndpoint(itemId, collectionId).pipe(
+ isNotEmptyOperator(),
+ distinctUntilChanged(),
+ map((endpointURL: string) => new DeleteRequest(this.requestService.generateRequestId(), endpointURL)),
+ configureRequest(this.requestService),
+ switchMap((request: RestRequest) => this.requestService.getByUUID(request.uuid)),
+ getResponseFromEntry()
+ );
+ }
+
+ /**
+ * Maps an item to a collection
+ * @param itemId The item's id
+ * @param collectionHref The collection's self link
+ */
+ public mapToCollection(itemId: string, collectionHref: string): Observable {
+ return this.getMappedCollectionsEndpoint(itemId).pipe(
+ isNotEmptyOperator(),
+ distinctUntilChanged(),
+ map((endpointURL: string) => {
+ const options: HttpOptions = Object.create({});
+ let headers = new HttpHeaders();
+ headers = headers.append('Content-Type', 'text/uri-list');
+ options.headers = headers;
+ return new PostRequest(this.requestService.generateRequestId(), endpointURL, collectionHref, options);
+ }),
+ configureRequest(this.requestService),
+ switchMap((request: RestRequest) => this.requestService.getByUUID(request.uuid)),
+ getResponseFromEntry()
+ );
+ }
+
+ /**
+ * Fetches all collections the item is mapped to
+ * @param itemId The item's id
+ */
+ public getMappedCollections(itemId: string): Observable>> {
+ const request$ = this.getMappedCollectionsEndpoint(itemId).pipe(
+ isNotEmptyOperator(),
+ distinctUntilChanged(),
+ map((endpointURL: string) => new MappedCollectionsRequest(this.requestService.generateRequestId(), endpointURL)),
+ configureRequest(this.requestService)
+ );
+
+ const requestEntry$ = request$.pipe(
+ switchMap((request: RestRequest) => this.requestService.getByHref(request.href))
+ );
+ const payload$ = requestEntry$.pipe(
+ filterSuccessfulResponses(),
+ map((response: GenericSuccessResponse>) => response.payload)
+ );
+
+ return this.rdbService.toRemoteDataObservable(requestEntry$, payload$);
+ }
+
/**
* Get the endpoint for item withdrawal and reinstatement
* @param itemId
diff --git a/src/app/core/data/mapped-collections-reponse-parsing.service.ts b/src/app/core/data/mapped-collections-reponse-parsing.service.ts
new file mode 100644
index 0000000000..bf8ed036e3
--- /dev/null
+++ b/src/app/core/data/mapped-collections-reponse-parsing.service.ts
@@ -0,0 +1,37 @@
+import { Injectable } from '@angular/core';
+import { ResponseParsingService } from './parsing.service';
+import { RestRequest } from './request.models';
+import { DSpaceRESTV2Response } from '../dspace-rest-v2/dspace-rest-v2-response.model';
+import { PaginatedList } from './paginated-list';
+import { PageInfo } from '../shared/page-info.model';
+import { ErrorResponse, GenericSuccessResponse, RestResponse } from '../cache/response.models';
+
+@Injectable()
+/**
+ * A ResponseParsingService used to parse DSpaceRESTV2Response coming from the REST API to a GenericSuccessResponse
+ * containing a PaginatedList of mapped collections
+ */
+export class MappedCollectionsReponseParsingService implements ResponseParsingService {
+ parse(request: RestRequest, data: DSpaceRESTV2Response): RestResponse {
+ const payload = data.payload;
+
+ if (payload._embedded && payload._embedded.mappedCollections) {
+ const mappedCollections = payload._embedded.mappedCollections;
+ // TODO: When the API supports it, change this to fetch a paginated list, instead of creating static one
+ // Reason: Pagination is currently not supported on the mappedCollections endpoint
+ const paginatedMappedCollections = new PaginatedList(Object.assign(new PageInfo(), {
+ elementsPerPage: mappedCollections.length,
+ totalElements: mappedCollections.length,
+ totalPages: 1,
+ currentPage: 1
+ }), mappedCollections);
+ return new GenericSuccessResponse(paginatedMappedCollections, data.statusCode, data.statusText);
+ } else {
+ return new ErrorResponse(
+ Object.assign(
+ new Error('Unexpected response from mappedCollections endpoint'), data
+ )
+ );
+ }
+ }
+}
diff --git a/src/app/core/data/relationship.service.spec.ts b/src/app/core/data/relationship.service.spec.ts
index 0ced517d74..31513bb779 100644
--- a/src/app/core/data/relationship.service.spec.ts
+++ b/src/app/core/data/relationship.service.spec.ts
@@ -5,7 +5,6 @@ import { getMockRemoteDataBuildService } from '../../shared/mocks/mock-remote-da
import { of as observableOf } from 'rxjs/internal/observable/of';
import { RequestEntry } from './request.reducer';
import { RelationshipType } from '../shared/item-relationships/relationship-type.model';
-import { ResourceType } from '../shared/resource-type';
import { Relationship } from '../shared/item-relationships/relationship.model';
import { RemoteData } from './remote-data';
import { getMockRequestService } from '../../shared/mocks/mock-request.service';
@@ -33,8 +32,8 @@ describe('RelationshipService', () => {
const relationshipType = Object.assign(new RelationshipType(), {
id: '1',
uuid: '1',
- leftLabel: 'isAuthorOfPublication',
- rightLabel: 'isPublicationOfAuthor'
+ leftwardType: 'isAuthorOfPublication',
+ rightwardType: 'isPublicationOfAuthor'
});
const relationship1 = Object.assign(new Relationship(), {
@@ -129,7 +128,7 @@ describe('RelationshipService', () => {
describe('getItemRelationshipLabels', () => {
it('should return the correct labels', () => {
service.getItemRelationshipLabels(item).subscribe((result) => {
- expect(result).toEqual([relationshipType.rightLabel]);
+ expect(result).toEqual([relationshipType.rightwardType]);
});
});
});
@@ -144,7 +143,7 @@ describe('RelationshipService', () => {
describe('getRelatedItemsByLabel', () => {
it('should return the related items by label', () => {
- service.getRelatedItemsByLabel(item, relationshipType.rightLabel).subscribe((result) => {
+ service.getRelatedItemsByLabel(item, relationshipType.rightwardType).subscribe((result) => {
expect(result).toEqual(relatedItems);
});
});
diff --git a/src/app/core/data/relationship.service.ts b/src/app/core/data/relationship.service.ts
index 1699b6a27d..b07e4b714c 100644
--- a/src/app/core/data/relationship.service.ts
+++ b/src/app/core/data/relationship.service.ts
@@ -182,9 +182,9 @@ export class RelationshipService {
map(([leftItems, rightItems, relTypesCurrentPage]) => {
return relTypesCurrentPage.map((type, index) => {
if (leftItems[index].uuid === item.uuid) {
- return type.leftLabel;
+ return type.leftwardType;
} else {
- return type.rightLabel;
+ return type.rightwardType;
}
});
}),
diff --git a/src/app/core/data/request.models.ts b/src/app/core/data/request.models.ts
index a2b3423960..43ab9e58e7 100644
--- a/src/app/core/data/request.models.ts
+++ b/src/app/core/data/request.models.ts
@@ -18,6 +18,7 @@ import { MetadataschemaParsingService } from './metadataschema-parsing.service';
import { MetadatafieldParsingService } from './metadatafield-parsing.service';
import { URLCombiner } from '../url-combiner/url-combiner';
import { TaskResponseParsingService } from '../tasks/task-response-parsing.service';
+import { MappedCollectionsReponseParsingService } from './mapped-collections-reponse-parsing.service';
/* tslint:disable:max-classes-per-file */
@@ -185,6 +186,17 @@ export class BrowseItemsRequest extends GetRequest {
}
}
+/**
+ * Request to fetch the mapped collections of an item
+ */
+export class MappedCollectionsRequest extends GetRequest {
+ public responseMsToLive = 10000;
+
+ getResponseParser(): GenericConstructor {
+ return MappedCollectionsReponseParsingService;
+ }
+}
+
export class ConfigRequest extends GetRequest {
constructor(uuid: string, href: string, public options?: HttpOptions) {
super(uuid, href, null, options);
diff --git a/src/app/core/data/request.service.ts b/src/app/core/data/request.service.ts
index 775118dbc0..0980d48537 100644
--- a/src/app/core/data/request.service.ts
+++ b/src/app/core/data/request.service.ts
@@ -3,7 +3,7 @@ import { HttpHeaders } from '@angular/common/http';
import { createSelector, MemoizedSelector, select, Store } from '@ngrx/store';
import { Observable, race as observableRace } from 'rxjs';
-import { filter, find, map, mergeMap, take } from 'rxjs/operators';
+import { filter, map, mergeMap, take } from 'rxjs/operators';
import { cloneDeep, remove } from 'lodash';
import { AppState } from '../../app.reducer';
@@ -262,12 +262,13 @@ export class RequestService {
*/
private clearRequestsOnTheirWayToTheStore(request: GetRequest) {
this.getByHref(request.href).pipe(
- find((re: RequestEntry) => hasValue(re)))
- .subscribe((re: RequestEntry) => {
- if (!re.responsePending) {
- remove(this.requestsOnTheirWayToTheStore, (item) => item === request.href);
- }
- });
+ filter((re: RequestEntry) => hasValue(re)),
+ take(1)
+ ).subscribe((re: RequestEntry) => {
+ if (!re.responsePending) {
+ remove(this.requestsOnTheirWayToTheStore, (item) => item === request.href);
+ }
+ });
}
/**
diff --git a/src/app/core/metadata/metadata.service.ts b/src/app/core/metadata/metadata.service.ts
index 309dfd8890..2b1cf4ffc1 100644
--- a/src/app/core/metadata/metadata.service.ts
+++ b/src/app/core/metadata/metadata.service.ts
@@ -2,7 +2,6 @@ import {
catchError,
distinctUntilKeyChanged,
filter,
- find,
first,
map,
take
diff --git a/src/app/core/shared/item-relationships/relationship-type.model.ts b/src/app/core/shared/item-relationships/relationship-type.model.ts
index 98454bc000..06ac94b041 100644
--- a/src/app/core/shared/item-relationships/relationship-type.model.ts
+++ b/src/app/core/shared/item-relationships/relationship-type.model.ts
@@ -33,7 +33,7 @@ export class RelationshipType implements CacheableObject {
/**
* The label that describes the Relation to the left of this RelationshipType
*/
- leftLabel: string;
+ leftwardType: string;
/**
* The maximum amount of Relationships allowed to the left of this RelationshipType
@@ -48,7 +48,7 @@ export class RelationshipType implements CacheableObject {
/**
* The label that describes the Relation to the right of this RelationshipType
*/
- rightLabel: string;
+ rightwardType: string;
/**
* The maximum amount of Relationships allowed to the right of this RelationshipType
diff --git a/src/app/core/shared/operators.spec.ts b/src/app/core/shared/operators.spec.ts
index 56b5d5db7e..548a3f1339 100644
--- a/src/app/core/shared/operators.spec.ts
+++ b/src/app/core/shared/operators.spec.ts
@@ -103,7 +103,7 @@ describe('Core Module - RxJS Operators', () => {
scheduler.schedule(() => source.pipe(getRequestFromRequestUUID(requestService)).subscribe());
scheduler.flush();
- expect(requestService.getByUUID).toHaveBeenCalledWith(testRequestUUID)
+ expect(requestService.getByUUID).toHaveBeenCalledWith(testRequestUUID);
});
it('shouldn\'t return anything if there is no request matching the request uuid', () => {
diff --git a/src/app/core/submission/submission-response-parsing.service.ts b/src/app/core/submission/submission-response-parsing.service.ts
index a0811c8f2d..de7d683d91 100644
--- a/src/app/core/submission/submission-response-parsing.service.ts
+++ b/src/app/core/submission/submission-response-parsing.service.ts
@@ -128,7 +128,10 @@ export class SubmissionResponseParsingService extends BaseResponseParsingService
// Iterate over all workspaceitem's sections
Object.keys(item.sections)
.forEach((sectionId) => {
- if (typeof item.sections[sectionId] === 'object' && isNotEmpty(item.sections[sectionId])) {
+ if (typeof item.sections[sectionId] === 'object' && (isNotEmpty(item.sections[sectionId]) &&
+ // When Upload section is disabled, add to submission only if there are files
+ (!item.sections[sectionId].hasOwnProperty('files') || isNotEmpty((item.sections[sectionId] as any).files)))) {
+
const normalizedSectionData = Object.create({});
// Iterate over all sections property
Object.keys(item.sections[sectionId])
diff --git a/src/app/header/header.component.scss b/src/app/header/header.component.scss
index 4d25bd0d43..70c66f119d 100644
--- a/src/app/header/header.component.scss
+++ b/src/app/header/header.component.scss
@@ -8,3 +8,14 @@
background-image: none !important;
line-height: 1.5;
}
+
+.navbar ::ng-deep {
+ a {
+ color: $header-icon-color;
+
+ &:hover, &focus {
+ color: darken($header-icon-color, 15%);
+ }
+ }
+}
+
diff --git a/src/app/shared/chips/models/chips-item.model.ts b/src/app/shared/chips/models/chips-item.model.ts
index 540f94166f..913232fa71 100644
--- a/src/app/shared/chips/models/chips-item.model.ts
+++ b/src/app/shared/chips/models/chips-item.model.ts
@@ -2,6 +2,7 @@ import { isObject, uniqueId } from 'lodash';
import { hasValue, isNotEmpty } from '../../empty.util';
import { FormFieldMetadataValueObject } from '../../form/builder/models/form-field-metadata-value.model';
import { ConfidenceType } from '../../../core/integration/models/confidence-type';
+import { PLACEHOLDER_PARENT_METADATA } from '../../form/builder/ds-dynamic-form-ui/models/relation-group/dynamic-relation-group.model';
export interface ChipsItemIcon {
metadata: string;
@@ -62,7 +63,7 @@ export class ChipsItem {
if (this._item.hasOwnProperty(icon.metadata)
&& (((typeof this._item[icon.metadata] === 'string') && hasValue(this._item[icon.metadata]))
|| (this._item[icon.metadata] as FormFieldMetadataValueObject).hasValue())
- && !(this._item[icon.metadata] as FormFieldMetadataValueObject).hasPlaceholder()) {
+ && !this.hasPlaceholder(this._item[icon.metadata])) {
if ((icon.visibleWhenAuthorityEmpty
|| (this._item[icon.metadata] as FormFieldMetadataValueObject).confidence !== ConfidenceType.CF_UNSET)
&& isNotEmpty(icon.style)) {
@@ -109,4 +110,9 @@ export class ChipsItem {
this.display = value;
}
+
+ private hasPlaceholder(value: any) {
+ return (typeof value === 'string') ? (value === PLACEHOLDER_PARENT_METADATA) :
+ (value as FormFieldMetadataValueObject).hasPlaceholder()
+ }
}
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 217f9e79cf..52a924604f 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
@@ -14,7 +14,8 @@
-
+
{{ message | translate:model.validators }}
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 fc618023f9..66bdf97dad 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
@@ -1,4 +1,7 @@
import { DynamicFormControlLayout, DynamicFormGroupModel, DynamicFormGroupModelConfig, serializable } from '@ng-dynamic-forms/core';
+
+import { Subject } from 'rxjs';
+
import { isNotEmpty } from '../../../../empty.util';
import { DsDynamicInputModel } from './ds-dynamic-input.model';
import { FormFieldMetadataValueObject } from '../../models/form-field-metadata-value.model';
@@ -16,12 +19,16 @@ export class DynamicConcatModel extends DynamicFormGroupModel {
@serializable() separator: string;
@serializable() hasLanguages = false;
isCustomGroup = true;
+ valueUpdates: Subject;
constructor(config: DynamicConcatModelConfig, layout?: DynamicFormControlLayout) {
super(config, layout);
this.separator = config.separator + ' ';
+
+ this.valueUpdates = new Subject();
+ this.valueUpdates.subscribe((value: string) => this.value = value);
}
get value() {
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 860c481820..4e4a944319 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
@@ -28,6 +28,7 @@ export class DsDynamicInputModel extends DynamicInputModel {
constructor(config: DsDynamicInputModelConfig, layout?: DynamicFormControlLayout) {
super(config, layout);
+ this.hint = config.hint;
this.readOnly = config.readOnly;
this.value = config.value;
this.language = config.language;
@@ -57,11 +58,7 @@ export class DsDynamicInputModel extends DynamicInputModel {
}
get hasLanguages(): boolean {
- if (this.languageCodes && this.languageCodes.length > 1) {
- return true;
- } else {
- return false;
- }
+ return this.languageCodes && this.languageCodes.length > 1;
}
get language(): string {
diff --git a/src/app/shared/form/builder/ds-dynamic-form-ui/models/ds-dynamic-qualdrop.model.ts b/src/app/shared/form/builder/ds-dynamic-form-ui/models/ds-dynamic-qualdrop.model.ts
index 6bd5a604a0..5d2cbc58b7 100644
--- a/src/app/shared/form/builder/ds-dynamic-form-ui/models/ds-dynamic-qualdrop.model.ts
+++ b/src/app/shared/form/builder/ds-dynamic-form-ui/models/ds-dynamic-qualdrop.model.ts
@@ -1,5 +1,5 @@
-import { DynamicFormControlLayout, DynamicFormGroupModel, DynamicInputModelConfig, serializable } from '@ng-dynamic-forms/core';
-import { DsDynamicInputModel, DsDynamicInputModelConfig } from './ds-dynamic-input.model';
+import { DynamicFormControlLayout, DynamicFormGroupModel, serializable } from '@ng-dynamic-forms/core';
+import { DsDynamicInputModel } from './ds-dynamic-input.model';
import { Subject } from 'rxjs';
import { DynamicFormGroupModelConfig } from '@ng-dynamic-forms/core/src/model/form-group/dynamic-form-group.model';
import { LanguageCode } from '../../models/form-field-language-value.model';
@@ -12,6 +12,7 @@ export interface DsDynamicQualdropModelConfig extends DynamicFormGroupModelConfi
languageCodes?: LanguageCode[];
language?: string;
readOnly: boolean;
+ hint?: string;
}
export class DynamicQualdropModel extends DynamicFormGroupModel {
@@ -20,6 +21,7 @@ export class DynamicQualdropModel extends DynamicFormGroupModel {
@serializable() languageUpdates: Subject;
@serializable() hasLanguages = false;
@serializable() readOnly: boolean;
+ @serializable() hint: string;
isCustomGroup = true;
constructor(config: DsDynamicQualdropModelConfig, layout?: DynamicFormControlLayout) {
@@ -33,6 +35,8 @@ export class DynamicQualdropModel extends DynamicFormGroupModel {
this.languageUpdates.subscribe((lang: string) => {
this.language = lang;
});
+
+ this.hint = config.hint;
}
get value() {
diff --git a/src/app/shared/form/builder/ds-dynamic-form-ui/models/lookup/dynamic-lookup.component.html b/src/app/shared/form/builder/ds-dynamic-form-ui/models/lookup/dynamic-lookup.component.html
index cb2d1fe217..3cfb5980c6 100644
--- a/src/app/shared/form/builder/ds-dynamic-form-ui/models/lookup/dynamic-lookup.component.html
+++ b/src/app/shared/form/builder/ds-dynamic-form-ui/models/lookup/dynamic-lookup.component.html
@@ -20,11 +20,10 @@
[disabled]="isInputDisabled()"
[placeholder]="model.placeholder | translate"
[readonly]="model.readOnly"
- (change)="$event.preventDefault()"
+ (change)="onChange($event)"
(blur)="onBlurEvent($event); $event.stopPropagation(); sdRef.close();"
(focus)="onFocusEvent($event); $event.stopPropagation(); sdRef.close();"
- (click)="$event.stopPropagation(); $event.stopPropagation(); sdRef.close();"
- (input)="onInput($event)">
+ (click)="$event.stopPropagation(); $event.stopPropagation(); sdRef.close();">
@@ -40,11 +39,10 @@
[disabled]="firstInputValue.length === 0 || isInputDisabled()"
[placeholder]="model.secondPlaceholder | translate"
[readonly]="model.readOnly"
- (change)="$event.preventDefault()"
+ (change)="onChange($event)"
(blur)="onBlurEvent($event); $event.stopPropagation(); sdRef.close();"
(focus)="onFocusEvent($event); $event.stopPropagation(); sdRef.close();"
- (click)="$event.stopPropagation(); sdRef.close();"
- (input)="onInput($event)">
+ (click)="$event.stopPropagation(); sdRef.close();">
Object.assign(new SearchResult
(), { indexableObject: collection }));
-const searchService = {
- search: () => {
- return observableOf(new RemoteData(true, true, true,
- undefined, new PaginatedList(new PageInfo(), collectionResults)))
- }
-};
+const mockCommunity = Object.assign(new Community(), {
+ name: 'Community 1',
+ id: '123456789-1',
+ metadata: [
+ {
+ key: 'dc.title',
+ language: 'en_US',
+ value: 'Community 1'
+ }],
+ collections: observableOf(new RemoteData(true, true, true,
+ undefined, new PaginatedList(new PageInfo(), [mockCommunity1Collection1, mockCommunity1Collection2]))),
+ subcommunities: observableOf(new RemoteData(true, true, true,
+ undefined, new PaginatedList(new PageInfo(), subcommunities))),
+});
+
+const mockCommunity2 = Object.assign(new Community(), {
+ name: 'Community 2',
+ id: '123456789-2',
+ metadata: [
+ {
+ key: 'dc.title',
+ language: 'en_US',
+ value: 'Community 2'
+ }],
+ collections: observableOf(new RemoteData(true, true, true,
+ undefined, new PaginatedList(new PageInfo(), [mockCommunity2Collection1, mockCommunity2Collection2]))),
+ subcommunities: observableOf(new RemoteData(true, true, true,
+ undefined, new PaginatedList(new PageInfo(), []))),
+});
+
+const mockCommunity1Collection1Rd = observableOf(new RemoteData(true, true, true,
+ undefined, mockCommunity1Collection1));
+
+const mockCommunityList = observableOf(new RemoteData(true, true, true,
+ undefined, new PaginatedList(new PageInfo(), [mockCommunity, mockCommunity2])));
+
+const mockCommunityCollectionList = observableOf(new RemoteData(true, true, true,
+ undefined, new PaginatedList(new PageInfo(), [mockCommunity1Collection1, mockCommunity1Collection2])));
+
+const mockCommunity2CollectionList = observableOf(new RemoteData(true, true, true,
+ undefined, new PaginatedList(new PageInfo(), [mockCommunity2Collection1, mockCommunity2Collection2])));
const mockCollectionList = [
{
communities: [
{
- id: 'c0e4de93-f506-4990-a840-d406f6f2ada7',
- name: 'Submission test'
+ id: '123456789-1',
+ name: 'Community 1'
}
],
collection: {
@@ -106,8 +151,8 @@ const mockCollectionList = [
{
communities: [
{
- id: 'c0e4de93-f506-4990-a840-d406f6f2ada7',
- name: 'Submission test'
+ id: '123456789-1',
+ name: 'Community 1'
}
],
collection: {
@@ -118,8 +163,8 @@ const mockCollectionList = [
{
communities: [
{
- id: 'c0e4de93-f506-4990-a840-d406f6f2ada7',
- name: 'Submission test'
+ id: '123456789-2',
+ name: 'Community 2'
}
],
collection: {
@@ -130,8 +175,8 @@ const mockCollectionList = [
{
communities: [
{
- id: 'c0e4de93-f506-4990-a840-d406f6f2ada7',
- name: 'Submission test'
+ id: '123456789-2',
+ name: 'Community 2'
}
],
collection: {
@@ -158,6 +203,12 @@ describe('SubmissionFormCollectionComponent Component', () => {
const communityDataService: any = jasmine.createSpyObj('communityDataService', {
findAll: jasmine.createSpy('findAll')
});
+
+ const collectionDataService: any = jasmine.createSpyObj('collectionDataService', {
+ findById: jasmine.createSpy('findById'),
+ getAuthorizedCollectionByCommunity: jasmine.createSpy('getAuthorizedCollectionByCommunity')
+ });
+
const store: any = jasmine.createSpyObj('store', {
dispatch: jasmine.createSpy('dispatch'),
select: jasmine.createSpy('select')
@@ -179,15 +230,12 @@ describe('SubmissionFormCollectionComponent Component', () => {
TestComponent
],
providers: [
- {
- provide: SubmissionJsonPatchOperationsService,
- useClass: SubmissionJsonPatchOperationsServiceStub
- },
+ { provide: CollectionDataService, useValue: collectionDataService },
+ { provide: SubmissionJsonPatchOperationsService, useClass: SubmissionJsonPatchOperationsServiceStub },
{ provide: SubmissionService, useClass: SubmissionServiceStub },
{ provide: CommunityDataService, useValue: communityDataService },
{ provide: JsonPatchOperationsBuilder, useValue: jsonPatchOpBuilder },
{ provide: Store, useValue: store },
- { provide: SearchService, useValue: searchService },
ChangeDetectorRef,
SubmissionFormCollectionComponent
],
@@ -252,17 +300,21 @@ describe('SubmissionFormCollectionComponent Component', () => {
});
it('should init collection list properly', () => {
+ communityDataService.findAll.and.returnValue(mockCommunityList);
+ collectionDataService.findById.and.returnValue(mockCommunity1Collection1Rd);
+ collectionDataService.getAuthorizedCollectionByCommunity.and.returnValues(mockCommunityCollectionList, mockCommunity2CollectionList);
+
comp.ngOnChanges({
currentCollectionId: new SimpleChange(null, collectionId, true)
});
- expect(comp.searchListCollection$).toBeObservable(cold('(b)', {
+ expect(comp.searchListCollection$).toBeObservable(cold('(ab)', {
+ a: [],
b: mockCollectionList
}));
- expect(comp.selectedCollectionName$).toBeObservable(cold('(ab|)', {
- a: '',
- b: 'Community 1-Collection 1'
+ expect(comp.selectedCollectionName$).toBeObservable(cold('(a|)', {
+ a: 'Community 1-Collection 1'
}));
});
@@ -394,8 +446,6 @@ class TestComponent {
definitionId = 'traditional';
submissionId = mockSubmissionId;
- onCollectionChange = () => {
- return;
- }
+ onCollectionChange = () => { return; }
}
diff --git a/src/app/submission/form/collection/submission-form-collection.component.ts b/src/app/submission/form/collection/submission-form-collection.component.ts
index e9832985fe..79d2f2a7bc 100644
--- a/src/app/submission/form/collection/submission-form-collection.component.ts
+++ b/src/app/submission/form/collection/submission-form-collection.component.ts
@@ -17,13 +17,16 @@ import {
distinctUntilChanged,
filter,
find,
+ flatMap,
map,
mergeMap,
+ reduce,
startWith
} from 'rxjs/operators';
import { Collection } from '../../../core/shared/collection.model';
import { CommunityDataService } from '../../../core/data/community-data.service';
+import { Community } from '../../../core/shared/community.model';
import { hasValue, isEmpty, isNotEmpty } from '../../../shared/empty.util';
import { RemoteData } from '../../../core/data/remote-data';
import { JsonPatchOperationPathCombiner } from '../../../core/json-patch/builder/json-patch-operation-path-combiner';
@@ -32,12 +35,8 @@ import { PaginatedList } from '../../../core/data/paginated-list';
import { SubmissionService } from '../../submission.service';
import { SubmissionObject } from '../../../core/submission/models/submission-object.model';
import { SubmissionJsonPatchOperationsService } from '../../../core/submission/submission-json-patch-operations.service';
-import { SearchService } from '../../../+search-page/search-service/search.service';
-import { PaginatedSearchOptions } from '../../../+search-page/paginated-search-options.model';
-import { PaginationComponentOptions } from '../../../shared/pagination/pagination-component-options.model';
-import { DSpaceObjectType } from '../../../core/shared/dspace-object-type.model';
-import { getSucceededRemoteData } from '../../../core/shared/operators';
-import { SearchResult } from '../../../+search-page/search-result.model';
+import { CollectionDataService } from '../../../core/data/collection-data.service';
+import { FindAllOptions } from '../../../core/data/request.models';
/**
* An interface to represent a collection entry
@@ -95,6 +94,12 @@ export class SubmissionFormCollectionComponent implements OnChanges, OnInit {
*/
public disabled$ = new BehaviorSubject(true);
+ /**
+ * A boolean representing if a collection change operation is processing
+ * @type {BehaviorSubject}
+ */
+ public processingChange$ = new BehaviorSubject(false);
+
/**
* The search form control
* @type {FormControl}
@@ -148,17 +153,17 @@ export class SubmissionFormCollectionComponent implements OnChanges, OnInit {
*
* @param {ChangeDetectorRef} cdr
* @param {CommunityDataService} communityDataService
+ * @param {CollectionDataService} collectionDataService
* @param {JsonPatchOperationsBuilder} operationsBuilder
* @param {SubmissionJsonPatchOperationsService} operationsService
* @param {SubmissionService} submissionService
- * @param {SearchService} searchService
*/
constructor(protected cdr: ChangeDetectorRef,
private communityDataService: CommunityDataService,
+ private collectionDataService: CollectionDataService,
private operationsBuilder: JsonPatchOperationsBuilder,
private operationsService: SubmissionJsonPatchOperationsService,
- private submissionService: SubmissionService,
- private searchService: SearchService) {
+ private submissionService: SubmissionService) {
}
/**
@@ -195,57 +200,55 @@ export class SubmissionFormCollectionComponent implements OnChanges, OnInit {
&& hasValue(changes.currentCollectionId.currentValue)) {
this.selectedCollectionId = this.currentCollectionId;
- // // @TODO replace with search/top browse endpoint
- // // @TODO implement community/subcommunity hierarchy
- // const communities$ = this.communityDataService.findAll().pipe(
- // find((communities: RemoteData>) => isNotEmpty(communities.payload)),
- // mergeMap((communities: RemoteData>) => communities.payload.page));
+ this.selectedCollectionName$ = this.collectionDataService.findById(this.currentCollectionId).pipe(
+ find((collectionRD: RemoteData) => isNotEmpty(collectionRD.payload)),
+ map((collectionRD: RemoteData) => collectionRD.payload.name)
+ );
- const listCollection$: Observable = this.searchService.search(
- new PaginatedSearchOptions({
- dsoType: DSpaceObjectType.COLLECTION,
- pagination: new PaginationComponentOptions(),
- scope: 'c0e4de93-f506-4990-a840-d406f6f2ada7'
- })
- ).pipe(
- getSucceededRemoteData(),
- map((collections: RemoteData>>) => collections.payload.page),
- filter((collectionData: Array>) => isNotEmpty(collectionData)),
- map((collectionData: Array>) => {
- return collectionData.map((collection: SearchResult) => {
- return {
- communities: [{
- id: 'c0e4de93-f506-4990-a840-d406f6f2ada7',
- name: 'Submission test'
- }],
- collection: { id: collection.indexableObject.id, name: collection.indexableObject.name }
+ const findOptions: FindAllOptions = {
+ elementsPerPage: 1000
+ };
+
+ // Retrieve collection list only when is the first change
+ if (changes.currentCollectionId.isFirstChange()) {
+ // @TODO replace with search/top browse endpoint
+ // @TODO implement community/subcommunity hierarchy
+ const communities$ = this.communityDataService.findAll(findOptions).pipe(
+ find((communities: RemoteData>) => isNotEmpty(communities.payload)),
+ mergeMap((communities: RemoteData>) => communities.payload.page));
+
+ const listCollection$ = communities$.pipe(
+ flatMap((communityData: Community) => {
+ return this.collectionDataService.getAuthorizedCollectionByCommunity(communityData.uuid, findOptions).pipe(
+ find((collections: RemoteData>) => !collections.isResponsePending && collections.hasSucceeded),
+ mergeMap((collections: RemoteData>) => collections.payload.page),
+ filter((collectionData: Collection) => isNotEmpty(collectionData)),
+ map((collectionData: Collection) => ({
+ communities: [{ id: communityData.id, name: communityData.name }],
+ collection: { id: collectionData.id, name: collectionData.name }
+ }))
+ );
+ }),
+ reduce((acc: any, value: any) => [...acc, ...value], []),
+ startWith([])
+ );
+
+ const searchTerm$ = this.searchField.valueChanges.pipe(
+ debounceTime(200),
+ distinctUntilChanged(),
+ startWith('')
+ );
+
+ this.searchListCollection$ = combineLatest(searchTerm$, listCollection$).pipe(
+ map(([searchTerm, listCollection]) => {
+ this.disabled$.next(isEmpty(listCollection));
+ if (isEmpty(searchTerm)) {
+ return listCollection;
+ } else {
+ return listCollection.filter((v) => v.collection.name.toLowerCase().indexOf(searchTerm.toLowerCase()) > -1).slice(0, 5);
}
- })
- })
- );
-
- this.selectedCollectionName$ = listCollection$.pipe(
- map((collectionData: CollectionListEntry[]) => collectionData.find((entry: CollectionListEntry) => entry.collection.id === this.selectedCollectionId)),
- filter((entry: CollectionListEntry) => hasValue(entry.collection)),
- map((entry: CollectionListEntry) => entry.collection.name),
- startWith('')
- );
-
- const searchTerm$ = this.searchField.valueChanges.pipe(
- debounceTime(200),
- distinctUntilChanged(),
- startWith('')
- );
-
- this.searchListCollection$ = combineLatest(searchTerm$, listCollection$).pipe(
- map(([searchTerm, listCollection]) => {
- this.disabled$.next(isEmpty(listCollection));
- if (isEmpty(searchTerm)) {
- return listCollection;
- } else {
- return listCollection.filter((v) => v.collection.name.toLowerCase().indexOf(searchTerm.toLowerCase()) > -1).slice(0, 5);
- }
- }));
+ }));
+ }
}
}
@@ -271,7 +274,7 @@ export class SubmissionFormCollectionComponent implements OnChanges, OnInit {
*/
onSelect(event) {
this.searchField.reset();
- this.disabled$.next(true);
+ this.processingChange$.next(true);
this.operationsBuilder.replace(this.pathCombiner.getPath(), event.collection.id, true);
this.subs.push(this.operationsService.jsonPatchByResourceID(
this.submissionService.getSubmissionObjectLinkName(),
@@ -283,7 +286,7 @@ export class SubmissionFormCollectionComponent implements OnChanges, OnInit {
this.selectedCollectionName$ = observableOf(event.collection.name);
this.collectionChange.emit(submissionObject[0]);
this.submissionService.changeSubmissionCollection(this.submissionId, event.collection.id);
- this.disabled$.next(false);
+ this.processingChange$.next(false);
this.cdr.detectChanges();
})
);
diff --git a/src/app/submission/objects/submission-objects.reducer.ts b/src/app/submission/objects/submission-objects.reducer.ts
index 1a65783945..8c111dde67 100644
--- a/src/app/submission/objects/submission-objects.reducer.ts
+++ b/src/app/submission/objects/submission-objects.reducer.ts
@@ -361,7 +361,7 @@ const addError = (state: SubmissionObjectState, action: InertSectionErrorsAction
* @param state
* the current state
* @param action
- * an RemoveSectionErrorsAction
+ * a RemoveSectionErrorsAction
* @return SubmissionObjectState
* the new state, with the section's errors updated.
*/
@@ -416,7 +416,7 @@ function initSubmission(state: SubmissionObjectState, action: InitSubmissionForm
* @param state
* the current state
* @param action
- * an ResetSubmissionFormAction
+ * a ResetSubmissionFormAction
* @return SubmissionObjectState
* the new state, with the section removed.
*/
@@ -439,7 +439,7 @@ function resetSubmission(state: SubmissionObjectState, action: ResetSubmissionFo
* @param state
* the current state
* @param action
- * an CompleteInitSubmissionFormAction
+ * a CompleteInitSubmissionFormAction
* @return SubmissionObjectState
* the new state, with the section removed.
*/
@@ -461,7 +461,7 @@ function completeInit(state: SubmissionObjectState, action: CompleteInitSubmissi
* @param state
* the current state
* @param action
- * an SaveSubmissionFormAction | SaveSubmissionSectionFormAction
+ * a SaveSubmissionFormAction | SaveSubmissionSectionFormAction
* | SaveForLaterSubmissionFormAction | SaveAndDepositSubmissionAction
* @return SubmissionObjectState
* the new state, with the flag set to true.
@@ -491,7 +491,7 @@ function saveSubmission(state: SubmissionObjectState,
* @param state
* the current state
* @param action
- * an SaveSubmissionFormSuccessAction | SaveForLaterSubmissionFormSuccessAction
+ * a SaveSubmissionFormSuccessAction | SaveForLaterSubmissionFormSuccessAction
* | SaveSubmissionSectionFormSuccessAction | SaveSubmissionFormErrorAction
* | SaveForLaterSubmissionFormErrorAction | SaveSubmissionSectionFormErrorAction
* @return SubmissionObjectState
@@ -521,7 +521,7 @@ function completeSave(state: SubmissionObjectState,
* @param state
* the current state
* @param action
- * an DepositSubmissionAction
+ * a DepositSubmissionAction
* @return SubmissionObjectState
* the new state, with the deposit flag changed.
*/
@@ -544,7 +544,7 @@ function startDeposit(state: SubmissionObjectState, action: DepositSubmissionAct
* @param state
* the current state
* @param action
- * an DepositSubmissionSuccessAction or DepositSubmissionErrorAction
+ * a DepositSubmissionSuccessAction or a DepositSubmissionErrorAction
* @return SubmissionObjectState
* the new state, with the deposit flag changed.
*/
@@ -586,7 +586,7 @@ function changeCollection(state: SubmissionObjectState, action: ChangeSubmission
* @param state
* the current state
* @param action
- * an SetActiveSectionAction
+ * a SetActiveSectionAction
* @return SubmissionObjectState
* the new state, with the active section.
*/
@@ -676,7 +676,7 @@ function updateSectionData(state: SubmissionObjectState, action: UpdateSectionDa
* @param state
* the current state
* @param action
- * an DisableSectionAction
+ * a DisableSectionAction
* @param enabled
* enabled or disabled section.
* @return SubmissionObjectState
@@ -705,7 +705,7 @@ function changeSectionState(state: SubmissionObjectState, action: EnableSectionA
* @param state
* the current state
* @param action
- * an SectionStatusChangeAction
+ * a SectionStatusChangeAction
* @return SubmissionObjectState
* the new state, with the section new validity status.
*/
@@ -769,7 +769,7 @@ function newFile(state: SubmissionObjectState, action: NewUploadedFileAction): S
* @param state
* the current state
* @param action
- * a EditFileDataAction action
+ * an EditFileDataAction action
* @return SubmissionObjectState
* the new state, with the edited file.
*/
diff --git a/src/app/submission/sections/form/section-form.component.ts b/src/app/submission/sections/form/section-form.component.ts
index ef817a7568..2269ccd5f1 100644
--- a/src/app/submission/sections/form/section-form.component.ts
+++ b/src/app/submission/sections/form/section-form.component.ts
@@ -64,6 +64,12 @@ export class SubmissionSectionformComponent extends SectionModelComponent {
*/
public isLoading = true;
+ /**
+ * A map representing all field on their way to be removed
+ * @type {Map}
+ */
+ protected fieldsOnTheirWayToBeRemoved: Map = new Map();
+
/**
* The form config
* @type {SubmissionFormsModel}
@@ -295,6 +301,7 @@ export class SubmissionSectionformComponent extends SectionModelComponent {
}),
distinctUntilChanged())
.subscribe((sectionState: SubmissionSectionObject) => {
+ this.fieldsOnTheirWayToBeRemoved = new Map();
this.updateForm(sectionState.data as WorkspaceitemSectionFormObject, sectionState.errors);
})
)
@@ -348,11 +355,24 @@ export class SubmissionSectionformComponent extends SectionModelComponent {
* the [[DynamicFormControlEvent]] emitted
*/
onRemove(event: DynamicFormControlEvent): void {
+ const fieldId = this.formBuilderService.getId(event.model);
+ const fieldIndex = this.formOperationsService.getArrayIndexFromEvent(event);
+
+ // Keep track that this field will be removed
+ if (this.fieldsOnTheirWayToBeRemoved.has(fieldId)) {
+ const indexes = this.fieldsOnTheirWayToBeRemoved.get(fieldId);
+ indexes.push(fieldIndex);
+ this.fieldsOnTheirWayToBeRemoved.set(fieldId, indexes);
+ } else {
+ this.fieldsOnTheirWayToBeRemoved.set(fieldId, [fieldIndex]);
+ }
+
this.formOperationsService.dispatchOperationsFromEvent(
this.pathCombiner,
event,
this.previousValue,
- this.hasStoredValue(this.formBuilderService.getId(event.model), this.formOperationsService.getArrayIndexFromEvent(event)));
+ this.hasStoredValue(fieldId, fieldIndex));
+
}
/**
@@ -365,9 +385,23 @@ export class SubmissionSectionformComponent extends SectionModelComponent {
*/
hasStoredValue(fieldId, index): boolean {
if (isNotEmpty(this.sectionData.data)) {
- return this.sectionData.data.hasOwnProperty(fieldId) && isNotEmpty(this.sectionData.data[fieldId][index]);
+ return this.sectionData.data.hasOwnProperty(fieldId) &&
+ isNotEmpty(this.sectionData.data[fieldId][index]) &&
+ !this.isFieldToRemove(fieldId, index);
} else {
return false;
}
}
+
+ /**
+ * Check if the specified field is on the way to be removed
+ *
+ * @param fieldId
+ * the section data retrieved from the serverù
+ * @param index
+ * the section data retrieved from the server
+ */
+ isFieldToRemove(fieldId, index) {
+ return this.fieldsOnTheirWayToBeRemoved.has(fieldId) && this.fieldsOnTheirWayToBeRemoved.get(fieldId).includes(index);
+ }
}
diff --git a/src/app/submission/sections/upload/section-upload.component.ts b/src/app/submission/sections/upload/section-upload.component.ts
index 826385af45..9dbd1079f4 100644
--- a/src/app/submission/sections/upload/section-upload.component.ts
+++ b/src/app/submission/sections/upload/section-upload.component.ts
@@ -155,14 +155,14 @@ export class SubmissionSectionUploadComponent extends SectionModelComponent {
filter((submissionObject: SubmissionObjectEntry) => isUndefined(this.collectionId) || this.collectionId !== submissionObject.collection),
tap((submissionObject: SubmissionObjectEntry) => this.collectionId = submissionObject.collection),
flatMap((submissionObject: SubmissionObjectEntry) => this.collectionDataService.findById(submissionObject.collection)),
- find((rd: RemoteData) => isNotUndefined((rd.payload))),
+ filter((rd: RemoteData) => isNotUndefined((rd.payload))),
tap((collectionRemoteData: RemoteData) => this.collectionName = collectionRemoteData.payload.name),
flatMap((collectionRemoteData: RemoteData) => {
return this.resourcePolicyService.findByHref(
(collectionRemoteData.payload as any)._links.defaultAccessConditions
);
}),
- find((defaultAccessConditionsRemoteData: RemoteData) =>
+ filter((defaultAccessConditionsRemoteData: RemoteData) =>
defaultAccessConditionsRemoteData.hasSucceeded),
tap((defaultAccessConditionsRemoteData: RemoteData) => {
if (isNotEmpty(defaultAccessConditionsRemoteData.payload)) {
@@ -171,7 +171,6 @@ export class SubmissionSectionUploadComponent extends SectionModelComponent {
}
}),
flatMap(() => config$),
- take(1),
flatMap((config: SubmissionUploadsModel) => {
this.availableAccessConditionOptions = isNotEmpty(config.accessConditionOptions) ? config.accessConditionOptions : [];
diff --git a/src/app/submission/submission.service.ts b/src/app/submission/submission.service.ts
index 0191b49e9b..7605f1c73a 100644
--- a/src/app/submission/submission.service.ts
+++ b/src/app/submission/submission.service.ts
@@ -197,7 +197,11 @@ export class SubmissionService {
* The submission id
*/
dispatchSave(submissionId) {
- this.store.dispatch(new SaveSubmissionFormAction(submissionId));
+ this.getSubmissionSaveProcessingStatus(submissionId).pipe(
+ find((isPending: boolean) => !isPending)
+ ).subscribe(() => {
+ this.store.dispatch(new SaveSubmissionFormAction(submissionId));
+ })
}
/**
diff --git a/src/server.ts b/src/server.ts
index 0526f196ba..de0f8082bb 100644
--- a/src/server.ts
+++ b/src/server.ts
@@ -67,7 +67,7 @@ export function startServer(bootstrap: Type<{}> | NgModuleFactory<{}>) {
function onHandleError(parentZoneDelegate, currentZone, targetZone, error) {
if (!res._headerSent) {
- console.warn('Error in SSR, serving for direct CSR');
+ console.warn('Error in SSR, serving for direct CSR. Error details : ', error);
res.sendFile('index.csr.html', { root: './src' });
}
}
diff --git a/src/styles/_bootstrap_variables.scss b/src/styles/_bootstrap_variables.scss
index 5258365cfd..6af8f1d170 100644
--- a/src/styles/_bootstrap_variables.scss
+++ b/src/styles/_bootstrap_variables.scss
@@ -1,16 +1,16 @@
@import '_themed_bootstrap_variables.scss';
/** Help Variables **/
-$fa-fixed-width: 1.25rem;
-$icon-padding: 1rem;
-$collapsed-sidebar-width: calculatePx($fa-fixed-width + (2 * $icon-padding));
-$sidebar-items-width: 250px;
-$total-sidebar-width: $collapsed-sidebar-width + $sidebar-items-width;
+$fa-fixed-width: 1.25rem !default;
+$icon-padding: 1rem !default;
+$collapsed-sidebar-width: calculatePx($fa-fixed-width + (2 * $icon-padding)) !default;
+$sidebar-items-width: 250px !default;
+$total-sidebar-width: $collapsed-sidebar-width + $sidebar-items-width !default;
/* Fonts */
-$fa-font-path: "../assets/fonts";
+$fa-font-path: "../assets/fonts" !default;
/* Images */
-$image-path: "../assets/images";
+$image-path: "../assets/images" !default;
/** Bootstrap Variables **/
/* Colors */
@@ -44,8 +44,8 @@ $link-color: map-get($theme-colors, info) !default;
$navbar-dark-color: rgba(white, .5) !default;
$navbar-light-color: rgba(black, .5) !default;
-$navbar-dark-toggler-icon-bg: url("data%3Aimage%2Fsvg+xml%3Bcharset%3Dutf8%2C%3Csvg+viewBox%3D%270+0+30+30%27+xmlns%3D%27http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%27%3E%3Cpath+stroke%3D%27#{$navbar-dark-color}%27+stroke-width%3D%272%27+stroke-linecap%3D%27round%27+stroke-miterlimit%3D%2710%27+d%3D%27M4+7h22M4+15h22M4+23h22%27%2F%3E%3C%2Fsvg%3E");
-$navbar-light-toggler-icon-bg: url("data%3Aimage%2Fsvg+xml%3Bcharset%3Dutf8%2C%3Csvg+viewBox%3D%270+0+30+30%27+xmlns%3D%27http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%27%3E%3Cpath+stroke%3D%27#{$navbar-light-color}%27+stroke-width%3D%272%27+stroke-linecap%3D%27round%27+stroke-miterlimit%3D%2710%27+d%3D%27M4+7h22M4+15h22M4+23h22%27%2F%3E%3C%2Fsvg%3E");
+$navbar-dark-toggler-icon-bg: url("data%3Aimage%2Fsvg+xml%3Bcharset%3Dutf8%2C%3Csvg+viewBox%3D%270+0+30+30%27+xmlns%3D%27http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%27%3E%3Cpath+stroke%3D%27#{$navbar-dark-color}%27+stroke-width%3D%272%27+stroke-linecap%3D%27round%27+stroke-miterlimit%3D%2710%27+d%3D%27M4+7h22M4+15h22M4+23h22%27%2F%3E%3C%2Fsvg%3E") !default;
+$navbar-light-toggler-icon-bg: url("data%3Aimage%2Fsvg+xml%3Bcharset%3Dutf8%2C%3Csvg+viewBox%3D%270+0+30+30%27+xmlns%3D%27http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%27%3E%3Cpath+stroke%3D%27#{$navbar-light-color}%27+stroke-width%3D%272%27+stroke-linecap%3D%27round%27+stroke-miterlimit%3D%2710%27+d%3D%27M4+7h22M4+15h22M4+23h22%27%2F%3E%3C%2Fsvg%3E") !default;
$enable-shadows: true !default;
diff --git a/src/styles/_custom_variables.scss b/src/styles/_custom_variables.scss
index 8ca2067a2e..c1f155fa39 100644
--- a/src/styles/_custom_variables.scss
+++ b/src/styles/_custom_variables.scss
@@ -1,38 +1,39 @@
@import '_themed_custom_variables.scss';
-$content-spacing: $spacer * 1.5;
+$content-spacing: $spacer * 1.5 !default;
-$button-height: $input-btn-padding-y * 2 + $input-btn-line-height + calculateRem($input-btn-border-width*2);
+$button-height: $input-btn-padding-y * 2 + $input-btn-line-height + calculateRem($input-btn-border-width*2) !default;
-$card-height-percentage:98%;
-$card-thumbnail-height:240px;
-$dropdown-menu-max-height: 200px;
-$drop-zone-area-height: 44px;
-$drop-zone-area-z-index: 1025;
-$drop-zone-area-inner-z-index: 1021;
-$login-logo-height:72px;
-$login-logo-width:72px;
-$submission-header-z-index: 1001;
-$submission-footer-z-index: 1000;
+$card-height-percentage:98% !default;
+$card-thumbnail-height:240px !default;
+$dropdown-menu-max-height: 200px !default;
+$drop-zone-area-height: 44px !default;
+$drop-zone-area-z-index: 1025 !default;
+$drop-zone-area-inner-z-index: 1021 !default;
+$login-logo-height:72px !default;
+$login-logo-width:72px !default;
+$submission-header-z-index: 1001 !default;
+$submission-footer-z-index: 999 !default;
-$main-z-index: 0;
-$nav-z-index: 10;
-$sidebar-z-index: 20;
+$main-z-index: 0 !default;
+$nav-z-index: 10 !default;
+$sidebar-z-index: 20 !default;
-$header-logo-height: 80px;
-$header-logo-height-xs: 50px;
+$header-logo-height: 80px !default;
+$header-logo-height-xs: 50px !default;
+$header-icon-color: $link-color !default;
-$admin-sidebar-bg: darken(#2B4E72, 17%);
-$admin-sidebar-active-bg: darken($admin-sidebar-bg, 3%);
-$admin-sidebar-header-bg: darken($admin-sidebar-bg, 7%);
+$admin-sidebar-bg: darken(#2B4E72, 17%) !default;
+$admin-sidebar-active-bg: darken($admin-sidebar-bg, 3%) !default;
+$admin-sidebar-header-bg: darken($admin-sidebar-bg, 7%) !default;
-$dark-scrollbar-background: $admin-sidebar-active-bg;
-$dark-scrollbar-foreground: #47495d;
+$dark-scrollbar-background: $admin-sidebar-active-bg !default;
+$dark-scrollbar-foreground: #47495d !default;
$submission-sections-margin-bottom: .5rem !default;
-$edit-item-button-min-width: 100px;
-$edit-item-metadata-field-width: 190px;
-$edit-item-language-field-width: 43px;
+$edit-item-button-min-width: 100px !default;
+$edit-item-metadata-field-width: 190px !default;
+$edit-item-language-field-width: 43px !default;
-$thumbnail-max-width: 175px;
+$thumbnail-max-width: 175px !default;