Merge branch 'master' into w2p-65717_Bundles-in-edit-item

Conflicts:
	src/app/+item-page/edit-item-page/edit-item-page.module.ts
	src/app/core/core.module.ts
	src/app/core/data/item-data.service.ts
	src/app/shared/shared.module.ts
This commit is contained in:
Kristof De Langhe
2019-10-18 12:42:11 +02:00
122 changed files with 3826 additions and 370 deletions

View File

@@ -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:

View File

@@ -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
-------

79
docker/README.md Normal file
View File

@@ -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
```

23
docker/cli.assetstore.yml Normal file
View File

@@ -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

32
docker/cli.ingest.yml Normal file
View File

@@ -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

22
docker/cli.yml Normal file
View File

@@ -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:

16
docker/db.entities.yml Normal file
View File

@@ -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

View File

@@ -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:

View File

@@ -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:

26
docker/docker-compose.yml Normal file
View File

@@ -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

16
docker/environment.dev.js Normal file
View File

@@ -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'
}
};

6
docker/local.cfg Normal file
View File

@@ -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

View File

@@ -146,8 +146,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: \"<b>{{name}}</b>\"",
"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",
@@ -156,11 +176,17 @@
"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.handle": "Permanent URI for this collection",
"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,8 +203,10 @@
"community.form.rights": "Copyright text (HTML)",
"community.form.tableofcontents": "News (HTML)",
"community.form.title": "Name",
"community.page.handle": "Permanent URI for this community",
"community.page.license": "License",
"community.page.news": "News",
"community.all-lists.head": "Subcommunities and Collections",
"community.sub-collection-list.head": "Collections of this Community",
"community.sub-community-list.head": "Communities of this Community",
@@ -196,9 +224,11 @@
"error.bitstream": "Error fetching bitstream",
"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",
@@ -276,6 +306,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: \"<b>{{name}}</b>\"",
"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",
@@ -408,6 +456,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",
@@ -440,9 +489,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...",

View File

@@ -19,6 +19,7 @@ import { ENV_CONFIG, GLOBAL_CONFIG } from '../../../config';
import { BrowseEntrySearchOptions } from '../../core/browse/browse-entry-search-options.model';
import { toRemoteData } from '../+browse-by-metadata-page/browse-by-metadata-page.component.spec';
import { createSuccessfulRemoteDataObject$ } from '../../shared/testing/utils';
import { VarDirective } from '../../shared/utils/var.directive';
describe('BrowseByDatePageComponent', () => {
let comp: BrowseByDatePageComponent;
@@ -69,7 +70,7 @@ describe('BrowseByDatePageComponent', () => {
beforeEach(async(() => {
TestBed.configureTestingModule({
imports: [CommonModule, RouterTestingModule.withRoutes([]), TranslateModule.forRoot(), NgbModule.forRoot()],
declarations: [BrowseByDatePageComponent, EnumKeysPipe],
declarations: [BrowseByDatePageComponent, EnumKeysPipe, VarDirective],
providers: [
{ provide: GLOBAL_CONFIG, useValue: ENV_CONFIG },
{ provide: ActivatedRoute, useValue: activatedRouteStub },

View File

@@ -1,7 +1,31 @@
<div class="container">
<div class="browse-by-metadata w-100">
<ng-container *ngVar="(parent$ | async) as parent">
<ng-container *ngIf="parent?.payload as parentContext">
<header class="comcol-header border-bottom mb-4 pb-4">
<!-- Parent Name -->
<ds-comcol-page-header [name]="parentContext.name">
</ds-comcol-page-header>
<!-- Handle -->
<ds-comcol-page-handle
[content]="parentContext.handle"
[title]="parentContext.type+'.page.handle'" >
</ds-comcol-page-handle>
<!-- Introductory text -->
<ds-comcol-page-content [content]="parentContext.introductoryText" [hasInnerHtml]="true">
</ds-comcol-page-content>
<!-- News -->
<ds-comcol-page-content [content]="parentContext.sidebarText" [hasInnerHtml]="true" [title]="'community.page.news'">
</ds-comcol-page-content>
</header>
<!-- Browse-By Links -->
<ds-comcol-page-browse-by [id]="parentContext.id" [contentType]="parentContext.type"></ds-comcol-page-browse-by>
</ng-container></ng-container>
<section class="comcol-page-browse-section">
<div class="browse-by-metadata w-100">
<ds-browse-by *ngIf="startsWithOptions" class="col-xs-12 w-100"
title="{{'browse.title' | translate:{collection: (parent$ | async)?.payload?.name || '', field: 'browse.metadata.' + browseId | translate, value: (value)? '&quot;' + value + '&quot;': ''} }}"
parentname="{{(parent$ | async)?.payload?.name || ''}}"
[objects$]="(items$ !== undefined)? items$ : browseEntries$"
[paginationConfig]="paginationConfig"
[sortConfig]="sortConfig"
@@ -15,4 +39,17 @@
</ds-browse-by>
<ds-loading *ngIf="!startsWithOptions" message="{{'loading.browse-by-page' | translate}}"></ds-loading>
</div>
</section>
<ng-container *ngVar="(parent$ | async) as parent">
<ng-container *ngIf="parent?.payload as parentContext">
<footer *ngIf="parentContext.copyrightText" class="border-top my-5 pt-4">
<div >
<!-- Copyright -->
<ds-comcol-page-content [content]="parentContext.copyrightText" [hasInnerHtml]="true">
</ds-comcol-page-content>
</div>
</footer>
</ng-container>
</ng-container>
</div>

View File

@@ -23,6 +23,7 @@ import { MockRouter } from '../../shared/mocks/mock-router';
import { ResourceType } from '../../core/shared/resource-type';
import { createSuccessfulRemoteDataObject$ } from '../../shared/testing/utils';
import { BrowseEntry } from '../../core/shared/browse-entry.model';
import { VarDirective } from '../../shared/utils/var.directive';
describe('BrowseByMetadataPageComponent', () => {
let comp: BrowseByMetadataPageComponent;
@@ -86,7 +87,7 @@ describe('BrowseByMetadataPageComponent', () => {
beforeEach(async(() => {
TestBed.configureTestingModule({
imports: [CommonModule, RouterTestingModule.withRoutes([]), TranslateModule.forRoot(), NgbModule.forRoot()],
declarations: [BrowseByMetadataPageComponent, EnumKeysPipe],
declarations: [BrowseByMetadataPageComponent, EnumKeysPipe, VarDirective],
providers: [
{ provide: ActivatedRoute, useValue: activatedRouteStub },
{ provide: BrowseService, useValue: mockBrowseService },

View File

@@ -18,6 +18,7 @@ import { DSpaceObjectDataService } from '../../core/data/dspace-object-data.serv
import { BrowseService } from '../../core/browse/browse.service';
import { MockRouter } from '../../shared/mocks/mock-router';
import { createSuccessfulRemoteDataObject$ } from '../../shared/testing/utils';
import { VarDirective } from '../../shared/utils/var.directive';
describe('BrowseByTitlePageComponent', () => {
let comp: BrowseByTitlePageComponent;
@@ -64,7 +65,7 @@ describe('BrowseByTitlePageComponent', () => {
beforeEach(async(() => {
TestBed.configureTestingModule({
imports: [CommonModule, RouterTestingModule.withRoutes([]), TranslateModule.forRoot(), NgbModule.forRoot()],
declarations: [BrowseByTitlePageComponent, EnumKeysPipe],
declarations: [BrowseByTitlePageComponent, EnumKeysPipe, VarDirective],
providers: [
{ provide: ActivatedRoute, useValue: activatedRouteStub },
{ provide: BrowseService, useValue: mockBrowseService },

View File

@@ -0,0 +1,57 @@
<div class="container">
<div class="row">
<div class="col-12">
<h2>{{'collection.edit.item-mapper.head' | translate}}</h2>
<p [innerHTML]="'collection.edit.item-mapper.collection' | translate:{ name: (collectionRD$ | async)?.payload?.name }" id="collection-name"></p>
<p>{{'collection.edit.item-mapper.description' | translate}}</p>
<ngb-tabset (tabChange)="tabChange($event)" [destroyOnHide]="true" #tabs="ngbTabset">
<ngb-tab title="{{'collection.edit.item-mapper.tabs.browse' | translate}}" id="browseTab">
<ng-template ngbTabContent>
<div class="mt-2">
<ds-item-select class="mt-2"
[key]="'browse'"
[dsoRD$]="collectionItemsRD$"
[paginationOptions]="(searchOptions$ | async)?.pagination"
[confirmButton]="'collection.edit.item-mapper.remove'"
[cancelButton]="'collection.edit.item-mapper.cancel'"
[dangerConfirm]="true"
[hideCollection]="true"
(confirm)="mapItems($event, true)"
(cancel)="onCancel()"></ds-item-select>
</div>
</ng-template>
</ngb-tab>
<ngb-tab title="{{'collection.edit.item-mapper.tabs.map' | translate}}" id="mapTab">
<ng-template ngbTabContent>
<div class="row mt-2">
<div class="col-12 col-lg-6">
<ds-search-form id="search-form"
[query]="(searchOptions$ | async)?.query"
[scope]="(searchOptions$ | async)?.scope"
[currentUrl]="'./'"
[inPlaceSearch]="true"
(submitSearch)="performedSearch = true">
</ds-search-form>
</div>
</div>
<div *ngIf="performedSearch">
<ds-item-select class="mt-2"
[key]="'map'"
[dsoRD$]="mappedItemsRD$"
[paginationOptions]="(searchOptions$ | async)?.pagination"
[confirmButton]="'collection.edit.item-mapper.confirm'"
[cancelButton]="'collection.edit.item-mapper.cancel'"
(confirm)="mapItems($event)"
(cancel)="onCancel()"></ds-item-select>
</div>
<div *ngIf="!performedSearch" class="alert alert-info w-100" role="alert">
{{'collection.edit.item-mapper.no-search' | translate}}
</div>
</ng-template>
</ngb-tab>
</ngb-tabset>
</div>
</div>
</div>

View File

@@ -0,0 +1 @@
@import '../../../styles/variables.scss';

View File

@@ -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<CollectionItemMapperComponent>;
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<Collection> = new RemoteData<Collection>(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]);
});
});
});

View File

@@ -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<RemoteData<Collection>>;
/**
* Search options
*/
searchOptions$: Observable<PaginatedSearchOptions>;
/**
* List of items to show under the "Browse" tab
* Items inside the collection
*/
collectionItemsRD$: Observable<RemoteData<PaginatedList<DSpaceObject>>>;
/**
* List of items to show under the "Map" tab
* Items outside the collection
*/
mappedItemsRD$: Observable<RemoteData<PaginatedList<DSpaceObject>>>;
/**
* 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<boolean>;
/**
* 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<RemoteData<Collection>>;
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<boolean>(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<Collection>) => 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<RestResponse[]>} responses$ The responses after adding/removing a mapping
* @param {boolean} remove Whether or not the goal was to remove mappings
*/
private showNotifications(responses$: Observable<RestResponse[]>, 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])
});
}
}

View File

@@ -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]
}
])
],

View File

@@ -3,18 +3,22 @@
*ngVar="(collectionRD$ | async) as collectionRD">
<div *ngIf="collectionRD?.hasSucceeded" @fadeInOut>
<div *ngIf="collectionRD?.payload as collection">
<header class="comcol-header border-bottom mb-4 pb-4">
<!-- Collection logo -->
<ds-comcol-page-logo *ngIf="logoRD$"
[logo]="(logoRD$ | async)?.payload" [alternateText]="'Collection Logo'">
[alternateText]="'Collection Logo'">
</ds-comcol-page-logo>
<!-- Collection Name -->
<ds-comcol-page-header
[name]="collection.name">
</ds-comcol-page-header>
<!-- Browse-By Links -->
<ds-comcol-page-browse-by [id]="collection.id"></ds-comcol-page-browse-by>
<!-- Collection logo -->
<ds-comcol-page-logo *ngIf="logoRD$"
[logo]="(logoRD$ | async)?.payload"
[alternateText]="'Collection Logo'">
</ds-comcol-page-logo>
<!-- Introductionary text -->
<!-- Handle -->
<ds-comcol-page-handle
[content]="collection.handle"
[title]="'collection.page.handle'" >
</ds-comcol-page-handle>
<!-- Introductory text -->
<ds-comcol-page-content
[content]="collection.introductoryText"
[hasInnerHtml]="true">
@@ -23,23 +27,20 @@
<ds-comcol-page-content
[content]="collection.sidebarText"
[hasInnerHtml]="true"
[title]="'community.page.news'">
[title]="'collection.page.news'">
</ds-comcol-page-content>
<!-- Copyright -->
<ds-comcol-page-content
[content]="collection.copyrightText"
[hasInnerHtml]="true">
</ds-comcol-page-content>
<!-- Licence -->
<ds-comcol-page-content
[content]="collection.dcLicense"
[title]="'collection.page.license'">
</ds-comcol-page-content>
</div>
<br>
</header>
<section class="comcol-page-browse-section">
<!-- Browse-By Links -->
<ds-comcol-page-browse-by
[id]="collection.id"
[contentType]="collection.type">
</ds-comcol-page-browse-by>
<ng-container *ngVar="(itemRD$ | async) as itemRD">
<div *ngIf="itemRD?.hasSucceeded" @fadeIn>
<h2>{{'collection.page.browse.recent.head' | translate}}</h2>
<div class="mt-4" *ngIf="itemRD?.hasSucceeded" @fadeIn>
<h3 class="sr-only">{{'collection.page.browse.recent.head' | translate}}</h3>
<ds-viewable-collection
[config]="paginationConfig"
[sortConfig]="sortConfig"
@@ -56,10 +57,19 @@
{{'collection.page.browse.recent.empty' | translate}}
</div>
</ng-container>
</div>
<ds-error *ngIf="collectionRD?.hasFailed"
message="{{'error.collection' | translate}}"></ds-error>
<ds-loading *ngIf="collectionRD?.isLoading"
message="{{'loading.collection' | translate}}"></ds-loading>
</section>
<footer *ngIf="collection.copyrightText" class="border-top my-5 pt-4">
<!-- Copyright -->
<ds-comcol-page-content
[content]="collection.copyrightText"
[hasInnerHtml]="true">
</ds-comcol-page-content>
</footer>
</div>
</div>
<ds-error *ngIf="collectionRD?.hasFailed"
message="{{'error.collection' | translate}}"></ds-error>
<ds-loading *ngIf="collectionRD?.isLoading"
message="{{'loading.collection' | translate}}"></ds-loading>
</div>
</div>

View File

@@ -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 {

View File

@@ -1,33 +1,38 @@
<div class="container" *ngVar="(communityRD$ | async) as communityRD">
<div class="community-page" *ngIf="communityRD?.hasSucceeded" @fadeInOut>
<div *ngIf="communityRD?.payload; let communityPayload">
<!-- Community name -->
<ds-comcol-page-header [name]="communityPayload.name"></ds-comcol-page-header>
<!-- Browse-By Links -->
<ds-comcol-page-browse-by [id]="communityPayload.id"></ds-comcol-page-browse-by>
<!-- Community logo -->
<ds-comcol-page-logo *ngIf="logoRD$"
[logo]="(logoRD$ | async)?.payload"
[alternateText]="'Community Logo'">
</ds-comcol-page-logo>
<!-- Introductory text -->
<ds-comcol-page-content
[content]="communityPayload.introductoryText"
[hasInnerHtml]="true">
</ds-comcol-page-content>
<!-- News -->
<ds-comcol-page-content
[content]="communityPayload.sidebarText"
[hasInnerHtml]="true"
[title]="'community.page.news'">
</ds-comcol-page-content>
<!-- Copyright -->
<ds-comcol-page-content
[content]="communityPayload.copyrightText"
[hasInnerHtml]="true">
</ds-comcol-page-content>
<ds-community-page-sub-community-list [community]="communityPayload"></ds-community-page-sub-community-list>
<ds-community-page-sub-collection-list [community]="communityPayload"></ds-community-page-sub-collection-list>
<header class="comcol-header border-bottom mb-4 pb-4">
<!-- Community logo -->
<ds-comcol-page-logo *ngIf="logoRD$" [logo]="(logoRD$ | async)?.payload" [alternateText]="'Community Logo'">
</ds-comcol-page-logo>
<!-- Community name -->
<ds-comcol-page-header [name]="communityPayload.name"></ds-comcol-page-header>
<!-- Handle -->
<ds-comcol-page-handle [content]="communityPayload.handle" [title]="'community.page.handle'">
</ds-comcol-page-handle>
<!-- Introductory text -->
<ds-comcol-page-content [content]="communityPayload.introductoryText" [hasInnerHtml]="true">
</ds-comcol-page-content>
<!-- News -->
<ds-comcol-page-content [content]="communityPayload.sidebarText" [hasInnerHtml]="true"
[title]="'community.page.news'">
</ds-comcol-page-content>
</header>
<section class="comcol-page-browse-section">
<!-- Browse-By Links -->
<ds-comcol-page-browse-by [id]="communityPayload.id" [contentType]="communityPayload.type">
</ds-comcol-page-browse-by>
<ds-community-page-sub-community-list [community]="communityPayload"></ds-community-page-sub-community-list>
<ds-community-page-sub-collection-list [community]="communityPayload"></ds-community-page-sub-collection-list>
</section>
<footer *ngIf="communityPayload.copyrightText" class="border-top my-5 pt-4">
<!-- Copyright -->
<ds-comcol-page-content [content]="communityPayload.copyrightText" [hasInnerHtml]="true">
</ds-comcol-page-content>
</footer>
</div>
</div>

View File

@@ -16,6 +16,8 @@ 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 { ItemEditBitstreamComponent } from './item-bitstreams/item-edit-bitstream/item-edit-bitstream.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';
@@ -29,7 +31,8 @@ import { ItemMoveComponent } from './item-move/item-move.component';
imports: [
CommonModule,
SharedModule,
EditItemPageRoutingModule
EditItemPageRoutingModule,
SearchPageModule
],
declarations: [
EditItemPageComponent,
@@ -51,6 +54,7 @@ import { ItemMoveComponent } from './item-move/item-move.component';
EditInPlaceFieldComponent,
EditRelationshipComponent,
EditRelationshipListComponent,
ItemCollectionMapperComponent,
ItemMoveComponent,
]
})

View File

@@ -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,

View File

@@ -0,0 +1,56 @@
<div class="container">
<div class="row">
<div class="col-12">
<h2>{{'item.edit.item-mapper.head' | translate}}</h2>
<p [innerHTML]="'item.edit.item-mapper.item' | translate:{ name: (itemRD$ | async)?.payload?.name }" id="item-name"></p>
<p>{{'item.edit.item-mapper.description' | translate}}</p>
<ngb-tabset (tabChange)="tabChange($event)" [destroyOnHide]="true" #tabs="ngbTabset">
<ngb-tab title="{{'item.edit.item-mapper.tabs.browse' | translate}}" id="browseTab">
<ng-template ngbTabContent>
<div class="mt-2">
<ds-collection-select class="mt-2"
[key]="'browse'"
[dsoRD$]="itemCollectionsRD$"
[paginationOptions]="(searchOptions$ | async)?.pagination"
[confirmButton]="'item.edit.item-mapper.buttons.remove'"
[cancelButton]="'item.edit.item-mapper.cancel'"
[dangerConfirm]="true"
(confirm)="removeMappings($event)"
(cancel)="onCancel()"></ds-collection-select>
</div>
</ng-template>
</ngb-tab>
<ngb-tab title="{{'item.edit.item-mapper.tabs.map' | translate}}" id="mapTab">
<ng-template ngbTabContent>
<div class="row mt-2">
<div class="col-12 col-lg-6">
<ds-search-form id="search-form"
[query]="(searchOptions$ | async)?.query"
[currentUrl]="'./'"
[inPlaceSearch]="true"
(submitSearch)="performedSearch = true">
</ds-search-form>
</div>
</div>
<div *ngIf="performedSearch">
<ds-collection-select class="mt-2"
[key]="'map'"
[dsoRD$]="mappedCollectionsRD$"
[paginationOptions]="(searchOptions$ | async)?.pagination"
[sortOptions]="(searchOptions$ | async)?.sort"
[confirmButton]="'item.edit.item-mapper.buttons.add'"
[cancelButton]="'item.edit.item-mapper.cancel'"
(confirm)="mapCollections($event)"
(cancel)="onCancel()"></ds-collection-select>
</div>
<div *ngIf="!performedSearch" class="alert alert-info w-100" role="alert">
{{'item.edit.item-mapper.no-search' | translate}}
</div>
</ng-template>
</ngb-tab>
</ngb-tabset>
</div>
</div>
</div>

View File

@@ -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<ItemCollectionMapperComponent>;
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<Item> = new RemoteData<Item>(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]);
});
});
});

View File

@@ -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<RemoteData<Item>>;
/**
* Search options
*/
searchOptions$: Observable<PaginatedSearchOptions>;
/**
* List of collections to show under the "Browse" tab
* Collections that are mapped to the item
*/
itemCollectionsRD$: Observable<RemoteData<PaginatedList<Collection>>>;
/**
* List of collections to show under the "Map" tab
* Collections that are not mapped to the item
*/
mappedCollectionsRD$: Observable<RemoteData<PaginatedList<Collection>>>;
/**
* 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<boolean>;
/**
* 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<RemoteData<Item>>;
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<boolean>(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<Item>) => 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<RemoteData<PaginatedList<Collection>>>;
}
/**
* 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<Item>) => rd.payload),
map((item: Item) => item.id)
),
this.itemCollectionsRD$.pipe(
getSucceededRemoteData(),
take(1),
map((rd: RemoteData<PaginatedList<Collection>>) => 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<Item>) => 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<RestResponse[]>} responses$ The responses after adding/removing a mapping
* @param {string} messagePrefix The prefix to build the notification messages with
*/
private showNotifications(responses$: Observable<RestResponse[]>, 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])
});
}
}

View File

@@ -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();
});

View File

@@ -34,8 +34,8 @@ describe('EditRelationshipComponent', () => {
relationshipType = Object.assign(new RelationshipType(), {
id: '1',
uuid: '1',
leftLabel: 'isAuthorOfPublication',
rightLabel: 'isPublicationOfAuthor'
leftwardType: 'isAuthorOfPublication',
rightwardType: 'isPublicationOfAuthor'
});
relationships = [

View File

@@ -68,8 +68,8 @@ describe('ItemRelationshipsComponent', () => {
relationshipType = Object.assign(new RelationshipType(), {
id: '1',
uuid: '1',
leftLabel: 'isAuthorOfPublication',
rightLabel: 'isPublicationOfAuthor'
leftwardType: 'isAuthorOfPublication',
rightwardType: 'isPublicationOfAuthor'
});
relationships = [

View File

@@ -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 {

View File

@@ -32,8 +32,8 @@ import { UploadBitstreamComponent } from './bitstreams/upload/upload-bitstream.c
imports: [
CommonModule,
SharedModule,
EditItemPageModule,
ItemPageRoutingModule,
EditItemPageModule,
SearchPageModule
],
declarations: [

View File

@@ -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)
)
))
);

View File

@@ -3,7 +3,8 @@
<div>
<img class="mb-4 login-logo" src="assets/images/dspace-logo.png">
<h1 class="h3 mb-0 font-weight-normal">{{"login.form.header" | translate}}</h1>
<ds-log-in></ds-log-in>
<ds-log-in
[isStandalonePage]="true"></ds-log-in>
</div>
</div>
</div>

View File

@@ -81,7 +81,6 @@ const components = [
SearchFilterService,
SearchFixedFilterService,
ConfigurationSearchPageGuard,
SearchFilterService,
SearchConfigurationService
],
entryComponents: [

View File

@@ -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<RemoteData<PaginatedList<SearchResult<DSpaceObject>>>>} Emits a paginated list with all search results found
*/
search(searchOptions?: PaginatedSearchOptions): Observable<RemoteData<PaginatedList<SearchResult<DSpaceObject>>>> {
search(searchOptions?: PaginatedSearchOptions, responseMsToLive?: number): Observable<RemoteData<PaginatedList<SearchResult<DSpaceObject>>>> {
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
});
}),

View File

@@ -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<AppState> = {
@@ -56,6 +58,7 @@ export const appReducers: ActionReducerMap<AppState> = {
truncatable: truncatableReducer,
cssVariables: cssVariablesReducer,
menus: menusReducer,
objectSelection: objectSelectionReducer
};
export const routerStateSelector = (state: AppState) => state.router;

View File

@@ -23,11 +23,14 @@ import { AppState } from '../../app.reducer';
import { ClientCookieService } from '../services/client-cookie.service';
import { RemoteDataBuildService } from '../cache/builders/remote-data-build.service';
import { getMockRemoteDataBuildService } from '../../shared/mocks/mock-remote-data-build.service';
import { routeServiceStub } from '../../shared/testing/route-service-stub';
import { RouteService } from '../services/route.service';
describe('AuthService test', () => {
let mockStore: Store<AuthState>;
let authService: AuthService;
let routeServiceMock: RouteService;
let authRequest;
let window;
let routerStub;
@@ -74,6 +77,7 @@ describe('AuthService test', () => {
{ provide: NativeWindowService, useValue: window },
{ provide: REQUEST, useValue: {} },
{ provide: Router, useValue: routerStub },
{ provide: RouteService, useValue: routeServiceStub },
{ provide: ActivatedRoute, useValue: routeStub },
{ provide: Store, useValue: mockStore },
{ provide: RemoteDataBuildService, useValue: rdbService },
@@ -138,6 +142,7 @@ describe('AuthService test', () => {
{ provide: AuthRequestService, useValue: authRequest },
{ provide: REQUEST, useValue: {} },
{ provide: Router, useValue: routerStub },
{ provide: RouteService, useValue: routeServiceStub },
{ provide: RemoteDataBuildService, useValue: rdbService },
CookieService,
AuthService
@@ -145,13 +150,13 @@ describe('AuthService test', () => {
}).compileComponents();
}));
beforeEach(inject([CookieService, AuthRequestService, Store, Router], (cookieService: CookieService, authReqService: AuthRequestService, store: Store<AppState>, router: Router) => {
beforeEach(inject([CookieService, AuthRequestService, Store, Router, RouteService], (cookieService: CookieService, authReqService: AuthRequestService, store: Store<AppState>, router: Router, routeService: RouteService) => {
store
.subscribe((state) => {
(state as any).core = Object.create({});
(state as any).core.auth = authenticatedState;
});
authService = new AuthService({}, window, undefined, authReqService, router, cookieService, store, rdbService);
authService = new AuthService({}, window, undefined, authReqService, router, routeService, cookieService, store, rdbService);
}));
it('should return true when user is logged in', () => {
@@ -189,6 +194,7 @@ describe('AuthService test', () => {
{ provide: AuthRequestService, useValue: authRequest },
{ provide: REQUEST, useValue: {} },
{ provide: Router, useValue: routerStub },
{ provide: RouteService, useValue: routeServiceStub },
{ provide: RemoteDataBuildService, useValue: rdbService },
ClientCookieService,
CookieService,
@@ -197,7 +203,7 @@ describe('AuthService test', () => {
}).compileComponents();
}));
beforeEach(inject([ClientCookieService, AuthRequestService, Store, Router], (cookieService: ClientCookieService, authReqService: AuthRequestService, store: Store<AppState>, router: Router) => {
beforeEach(inject([ClientCookieService, AuthRequestService, Store, Router, RouteService], (cookieService: ClientCookieService, authReqService: AuthRequestService, store: Store<AppState>, router: Router, routeService: RouteService) => {
const expiredToken: AuthTokenInfo = new AuthTokenInfo('test_token');
expiredToken.expires = Date.now() - (1000 * 60 * 60);
authenticatedState = {
@@ -212,11 +218,14 @@ describe('AuthService test', () => {
(state as any).core = Object.create({});
(state as any).core.auth = authenticatedState;
});
authService = new AuthService({}, window, undefined, authReqService, router, cookieService, store, rdbService);
authService = new AuthService({}, window, undefined, authReqService, router, routeService, cookieService, store, rdbService);
storage = (authService as any).storage;
routeServiceMock = TestBed.get(RouteService);
routerStub = TestBed.get(Router);
spyOn(storage, 'get');
spyOn(storage, 'remove');
spyOn(storage, 'set');
}));
it('should throw false when token is not valid', () => {
@@ -238,5 +247,32 @@ describe('AuthService test', () => {
expect(storage.remove).toHaveBeenCalled();
});
it ('should set redirect url to previous page', () => {
spyOn(routeServiceMock, 'getHistory').and.callThrough();
authService.redirectAfterLoginSuccess(true);
expect(routeServiceMock.getHistory).toHaveBeenCalled();
expect(routerStub.navigate).toHaveBeenCalledWith(['/collection/123']);
});
it ('should set redirect url to current page', () => {
spyOn(routeServiceMock, 'getHistory').and.callThrough();
authService.redirectAfterLoginSuccess(false);
expect(routeServiceMock.getHistory).toHaveBeenCalled();
expect(routerStub.navigate).toHaveBeenCalledWith(['/home']);
});
it ('should redirect to / and not to /login', () => {
spyOn(routeServiceMock, 'getHistory').and.returnValue(observableOf(['/login', '/login']));
authService.redirectAfterLoginSuccess(true);
expect(routeServiceMock.getHistory).toHaveBeenCalled();
expect(routerStub.navigate).toHaveBeenCalledWith(['/']);
});
it ('should redirect to / when no redirect url is found', () => {
spyOn(routeServiceMock, 'getHistory').and.returnValue(observableOf(['']));
authService.redirectAfterLoginSuccess(true);
expect(routeServiceMock.getHistory).toHaveBeenCalled();
expect(routerStub.navigate).toHaveBeenCalledWith(['/']);
});
});
});

View File

@@ -22,6 +22,7 @@ import { ResetAuthenticationMessagesAction, SetRedirectUrlAction } from './auth.
import { NativeWindowRef, NativeWindowService } from '../services/window.service';
import { Base64EncodeUrl } from '../../shared/utils/encode-decode.util';
import { RemoteDataBuildService } from '../cache/builders/remote-data-build.service';
import {RouteService} from '../services/route.service';
export const LOGIN_ROUTE = '/login';
export const LOGOUT_ROUTE = '/logout';
@@ -45,6 +46,7 @@ export class AuthService {
protected authRequestService: AuthRequestService,
@Optional() @Inject(RESPONSE) private response: any,
protected router: Router,
protected routeService: RouteService,
protected storage: CookieService,
protected store: Store<AppState>,
protected rdbService: RemoteDataBuildService
@@ -337,7 +339,7 @@ export class AuthService {
/**
* Redirect to the route navigated before the login
*/
public redirectToPreviousUrl() {
public redirectAfterLoginSuccess(isStandalonePage: boolean) {
this.getRedirectUrl().pipe(
take(1))
.subscribe((redirectUrl) => {
@@ -346,18 +348,39 @@ export class AuthService {
this.clearRedirectUrl();
this.router.onSameUrlNavigation = 'reload';
const url = decodeURIComponent(redirectUrl);
this.router.navigateByUrl(url);
/* TODO Reenable hard redirect when REST API can handle x-forwarded-for, see https://github.com/DSpace/DSpace/pull/2207 */
// this._window.nativeWindow.location.href = url;
this.navigateToRedirectUrl(url);
} else {
this.router.navigate(['/']);
/* TODO Reenable hard redirect when REST API can handle x-forwarded-for, see https://github.com/DSpace/DSpace/pull/2207 */
// this._window.nativeWindow.location.href = '/';
// If redirectUrl is empty use history.
this.routeService.getHistory().pipe(
take(1)
).subscribe((history) => {
let redirUrl;
if (isStandalonePage) {
// For standalone login pages, use the previous route.
redirUrl = history[history.length - 2] || '';
} else {
redirUrl = history[history.length - 1] || '';
}
this.navigateToRedirectUrl(redirUrl);
});
}
})
});
}
protected navigateToRedirectUrl(url: string) {
// in case the user navigates directly to /login (via bookmark, etc), or the route history is not found.
if (isEmpty(url) || url.startsWith(LOGIN_ROUTE)) {
this.router.navigate(['/']);
/* TODO Reenable hard redirect when REST API can handle x-forwarded-for, see https://github.com/DSpace/DSpace/pull/2207 */
// this._window.nativeWindow.location.href = '/';
} else {
/* TODO Reenable hard redirect when REST API can handle x-forwarded-for, see https://github.com/DSpace/DSpace/pull/2207 */
// this._window.nativeWindow.location.href = url;
this.router.navigate([url]);
}
}
/**
* Refresh route navigated
*/
@@ -400,4 +423,5 @@ export class AuthService {
this.store.dispatch(new SetRedirectUrlAction(''));
this.storage.remove(REDIRECT_COOKIE);
}
}

View File

@@ -1,12 +1,12 @@
import { map, switchMap, take } from 'rxjs/operators';
import { filter, map, switchMap, take } from 'rxjs/operators';
import { Injectable } from '@angular/core';
import { Observable } from 'rxjs';
import { HttpHeaders } from '@angular/common/http';
import { HttpOptions } from '../dspace-rest-v2/dspace-rest-v2.service';
import { AuthStatus } from './models/auth-status.model';
import { isNotEmpty } from '../../shared/empty.util';
import { AuthService } from './auth.service';
import { isEmpty, isNotEmpty } from '../../shared/empty.util';
import { AuthService, LOGIN_ROUTE } from './auth.service';
import { AuthTokenInfo } from './models/auth-token-info.model';
import { CheckAuthenticationTokenAction } from './auth.actions';
import { EPerson } from '../eperson/models/eperson.model';
@@ -54,7 +54,7 @@ export class ServerAuthService extends AuthService {
/**
* Redirect to the route navigated before the login
*/
public redirectToPreviousUrl() {
public redirectAfterLoginSuccess(isStandalonePage: boolean) {
this.getRedirectUrl().pipe(
take(1))
.subscribe((redirectUrl) => {
@@ -67,10 +67,15 @@ export class ServerAuthService extends AuthService {
const url = decodeURIComponent(redirectUrl);
this.router.navigateByUrl(url);
} else {
this.router.navigate(['/']);
// If redirectUrl is empty use history. For ssr the history array should contain the requested url.
this.routeService.getHistory().pipe(
filter((history) => history.length > 0),
take(1)
).subscribe((history) => {
this.navigateToRedirectUrl(history[history.length - 1] || '');
});
}
})
}
}

View File

@@ -82,8 +82,8 @@ export class RemoteDataBuildService {
toRemoteDataObservable<T>(requestEntry$: Observable<RequestEntry>, payload$: Observable<T>) {
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)) {

View File

@@ -23,7 +23,7 @@ export class NormalizedRelationshipType extends NormalizedObject<RelationshipTyp
* The label that describes the Relation to the left of this RelationshipType
*/
@autoserialize
leftLabel: string;
leftwardType: string;
/**
* The maximum amount of Relationships allowed to the left of this RelationshipType
@@ -41,7 +41,7 @@ export class NormalizedRelationshipType extends NormalizedObject<RelationshipTyp
* The label that describes the Relation to the right of this RelationshipType
*/
@autoserialize
rightLabel: string;
rightwardType: string;
/**
* The maximum amount of Relationships allowed to the right of this RelationshipType

View File

@@ -120,6 +120,8 @@ import { NormalizedSubmissionUploadsModel } from './config/models/normalized-con
import { NormalizedBrowseEntry } from './shared/normalized-browse-entry.model';
import { BrowseDefinition } from './shared/browse-definition.model';
import { BitstreamDataService } from './data/bitstream-data.service';
import { MappedCollectionsReponseParsingService } from './data/mapped-collections-reponse-parsing.service';
import { ObjectSelectService } from '../shared/object-select/object-select.service';
const IMPORTS = [
CommonModule,
@@ -167,6 +169,7 @@ const PROVIDERS = [
RegistryMetadataschemasResponseParsingService,
RegistryMetadatafieldsResponseParsingService,
RegistryBitstreamformatsResponseParsingService,
MappedCollectionsReponseParsingService,
DebugResponseParsingService,
SearchResponseParsingService,
MyDSpaceResponseParsingService,
@@ -198,6 +201,7 @@ const PROVIDERS = [
DSpaceObjectDataService,
DSOChangeAnalyzer,
DefaultChangeAnalyzer,
ObjectSelectService,
CSSVariableService,
MenuService,
ObjectUpdatesService,

View File

@@ -0,0 +1,44 @@
import { CollectionDataService } from './collection-data.service';
import { HALEndpointServiceStub } from '../../shared/testing/hal-endpoint-service-stub';
import { getMockRequestService } from '../../shared/mocks/mock-request.service';
import { HALEndpointService } from '../shared/hal-endpoint.service';
import { RequestService } from './request.service';
import { ObjectCacheService } from '../cache/object-cache.service';
import { GetRequest } from './request.models';
import { RemoteDataBuildService } from '../cache/builders/remote-data-build.service';
describe('CollectionDataService', () => {
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);
});
});
});

View File

@@ -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<Collection> {
@@ -40,6 +48,36 @@ export class CollectionDataService extends ComColDataService<Collection> {
super();
}
/**
* Get all collections the user is authorized to submit to
*
* @param options The [[FindAllOptions]] object
* @return Observable<RemoteData<PaginatedList<Collection>>>
* collection list
*/
getAuthorizedCollection(options: FindAllOptions = {}): Observable<RemoteData<PaginatedList<Collection>>> {
const searchHref = 'findAuthorized';
return this.searchBy(searchHref, options).pipe(
filter((collections: RemoteData<PaginatedList<Collection>>) => !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<RemoteData<PaginatedList<Collection>>>
* collection list
*/
getAuthorizedCollectionByCommunity(communityId: string, options: FindAllOptions = {}): Observable<RemoteData<PaginatedList<Collection>>> {
const searchHref = 'findAuthorizedByCommunity';
options.searchParams = [new SearchParam('uuid', communityId)];
return this.searchBy(searchHref, options).pipe(
filter((collections: RemoteData<PaginatedList<Collection>>) => !collections.isResponsePending));
}
/**
* Find whether there is a collection whom user has authorization to submit to
*
@@ -57,4 +95,46 @@ export class CollectionDataService extends ComColDataService<Collection> {
map((collections: RemoteData<PaginatedList<Collection>>) => 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<string> {
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<RemoteData<PaginatedList<DSpaceObject>>> {
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<ResponseParsingService> {
return DSOResponseParsingService;
}
});
}),
configureRequest(this.requestService)
).subscribe();
return this.rdbService.buildList(href$);
}
}

View File

@@ -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<CoreState>;
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));
});
});
});

View File

@@ -1,8 +1,8 @@
import { distinctUntilChanged, filter, map, switchMap, take, find } 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,21 +12,34 @@ 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, GetRequest, PatchRequest, PutRequest, RestRequest } from './request.models';
import {
DeleteRequest,
FindAllOptions, GetRequest,
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 { PaginatedSearchOptions } from '../../+search-page/paginated-search-options.model';
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';
import { PaginatedSearchOptions } from '../../+search-page/paginated-search-options.model';
import { Bitstream } from '../shared/bitstream.model';
import { Collection } from '../shared/collection.model';
import { RestResponse } from '../cache/response.models';
import { HttpOptions } from '../dspace-rest-v2/dspace-rest-v2.service';
@Injectable()
export class ItemDataService extends DataService<Item> {
@@ -64,6 +77,80 @@ export class ItemDataService extends DataService<Item> {
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<string> {
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<RestResponse> {
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<RestResponse> {
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<RemoteData<PaginatedList<Collection>>> {
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<PaginatedList<Collection>>) => response.payload)
);
return this.rdbService.toRemoteDataObservable(requestEntry$, payload$);
}
/**
* Get the endpoint for item withdrawal and reinstatement
* @param itemId

View File

@@ -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
)
);
}
}
}

View File

@@ -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);
});
});

View File

@@ -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;
}
});
}),

View File

@@ -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<ResponseParsingService> {
return MappedCollectionsReponseParsingService;
}
}
export class ConfigRequest extends GetRequest {
constructor(uuid: string, href: string, public options?: HttpOptions) {
super(uuid, href, null, options);

View File

@@ -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);
}
});
}
/**

View File

@@ -2,7 +2,6 @@ import {
catchError,
distinctUntilKeyChanged,
filter,
find,
first,
map,
take

View File

@@ -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

View File

@@ -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', () => {

View File

@@ -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])

View File

@@ -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%);
}
}
}

View File

@@ -3,7 +3,8 @@
<div ngbDropdown placement="bottom-right" class="d-inline-block" @fadeInOut>
<a href="#" id="dropdownLogin" (click)="$event.preventDefault()" ngbDropdownToggle class="px-1">{{ 'nav.login' | translate }}</a>
<div id="loginDropdownMenu" [ngClass]="{'pl-3 pr-3': (loading | async)}" ngbDropdownMenu aria-labelledby="dropdownLogin">
<ds-log-in></ds-log-in>
<ds-log-in
[isStandalonePage]="false"></ds-log-in>
</div>
</div>
</li>

View File

@@ -1,5 +1,5 @@
<ng-container *ngVar="(objects$ | async) as objects">
<h2 class="w-100">{{title | translate}}</h2>
<h3 [ngClass]="{'sr-only': parentname }" >{{title | translate}}</h3>
<ng-container *ngComponentOutlet="getStartsWithComponent(); injector: objectInjector;"></ng-container>
<div *ngIf="objects?.hasSucceeded && !objects?.isLoading && objects?.payload?.page.length > 0" @fadeIn>
<div *ngIf="!enableArrows">

View File

@@ -26,6 +26,10 @@ export class BrowseByComponent implements OnInit {
*/
@Input() title: string;
/**
* The parent name
*/
@Input() parentname: string;
/**
* The list of objects to display
*/

View File

@@ -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()
}
}

View File

@@ -1,6 +1,24 @@
<h3>{{'browse.comcol.head' | translate}}</h3>
<ul>
<li *ngFor="let config of types">
<a [routerLink]="['/browse/' + config.id]" [queryParams]="{scope: id}">{{'browse.comcol.by.' + config.id | translate}}</a>
</li>
</ul>
<h2 class="comcol-browse-label h5">{{'browse.comcol.head' | translate}}</h2>
<nav class="comcol-browse mb-4" aria-label="Browse Community or Collection">
<div class="d-none d-sm-block">
<div class="list-group list-group-horizontal">
<a *ngFor="let option of allOptions"
class="list-group-item"
[routerLink]="option.routerLink"
[queryParams]="option.params"
routerLinkActive="active">{{ option.label | translate }}</a>
</div>
</div>
<div class="d-block d-sm-none">
<select name="browse-type"
class="form-control"
aria-label="Browse Community or Collection"
(ngModelChange)="onSelectChange($event)" [ngModel]="currentOptionId$ | async">
<option *ngFor="let option of allOptions"
[ngValue]="option.id"
[attr.selected]="(currentOptionId$ | async) === option.id ? 'selected' : null">{{ option.label | translate }}</option>
</select>
</div>
</nav>

View File

@@ -1,6 +1,27 @@
import { Component, Inject, Input, OnInit } from '@angular/core';
import {
ChangeDetectionStrategy,
Component,
Inject,
Input, NgZone,
OnDestroy,
OnInit
} from '@angular/core';
import { Observable } from 'rxjs/internal/Observable';
import { Subscription } from 'rxjs/internal/Subscription';
import { filter, map, startWith, tap } from 'rxjs/operators';
import { getCollectionPageRoute } from '../../+collection-page/collection-page-routing.module';
import { getCommunityPageRoute } from '../../+community-page/community-page-routing.module';
import { GLOBAL_CONFIG, GlobalConfig } from '../../../config';
import { Router, ActivatedRoute, RouterModule, UrlSegment } from '@angular/router';
import { BrowseByTypeConfig } from '../../../config/browse-by-type-config.interface';
import { hasValue } from '../empty.util';
export interface ComColPageNavOption {
id: string;
label: string,
routerLink: string
params?: any;
};
/**
* A component to display the "Browse By" section of a Community or Collection page
@@ -8,24 +29,63 @@ import { BrowseByTypeConfig } from '../../../config/browse-by-type-config.interf
*/
@Component({
selector: 'ds-comcol-page-browse-by',
templateUrl: './comcol-page-browse-by.component.html',
styleUrls: ['./comcol-page-browse-by.component.scss'],
templateUrl: './comcol-page-browse-by.component.html'
})
export class ComcolPageBrowseByComponent implements OnInit {
/**
* The ID of the Community or Collection
*/
@Input() id: string;
@Input() contentType: string;
/**
* List of currently active browse configurations
*/
types: BrowseByTypeConfig[];
constructor(@Inject(GLOBAL_CONFIG) public config: GlobalConfig) {
allOptions: ComColPageNavOption[];
currentOptionId$: Observable<string>;
constructor(
@Inject(GLOBAL_CONFIG) public config: GlobalConfig,
private route: ActivatedRoute,
private router: Router) {
}
ngOnInit(): void {
this.types = this.config.browseBy.types;
this.allOptions = this.config.browseBy.types
.map((config: BrowseByTypeConfig) => ({
id: config.id,
label: `browse.comcol.by.${config.id}`,
routerLink: `/browse/${config.id}`,
params: { scope: this.id }
}));
if (this.contentType === 'collection') {
this.allOptions = [ {
id: this.id,
label: 'collection.page.browse.recent.head',
routerLink: getCollectionPageRoute(this.id)
}, ...this.allOptions ];
} else if (this.contentType === 'community') {
this.allOptions = [{
id: this.id,
label: 'community.all-lists.head',
routerLink: getCommunityPageRoute(this.id)
}, ...this.allOptions ];
}
this.currentOptionId$ = this.route.url.pipe(
filter((urlSegments: UrlSegment[]) => hasValue(urlSegments)),
map((urlSegments: UrlSegment[]) => urlSegments[urlSegments.length - 1].path)
);
}
onSelectChange(newId: string) {
const selectedOption = this.allOptions
.find((option: ComColPageNavOption) => option.id === newId);
this.router.navigate([selectedOption.routerLink], { queryParams: selectedOption.params });
}
}

View File

@@ -0,0 +1,4 @@
<div *ngIf="content" class="content-with-optional-title mb-2">
<h2 class="d-inline-block h6" *ngIf="title">{{ title | translate }}</h2>
<div class="d-inline-block "><a href="{{getHandle()}}">{{getHandle()}}</a></div>
</div>

View File

@@ -0,0 +1,5 @@
div {
word-break: break-word;
word-wrap: break-word;
overflow-wrap: break-word;
}

View File

@@ -0,0 +1,29 @@
import { Component, Input, Inject, Injectable } from '@angular/core';
import { GlobalConfig } from '../../../config/global-config.interface';
import { GLOBAL_CONFIG } from '../../../config';
import { UIURLCombiner } from '../../core/url-combiner/ui-url-combiner';
/**
* This component builds a URL from the value of "handle"
*/
@Component({
selector: 'ds-comcol-page-handle',
styleUrls: ['./comcol-page-handle.component.scss'],
templateUrl: './comcol-page-handle.component.html'
})
@Injectable()
export class ComcolPageHandleComponent {
// Optional title
@Input() title: string;
// The value of "handle"
@Input() content: string;
constructor(@Inject(GLOBAL_CONFIG) private EnvConfig: GlobalConfig) {
}
public getHandle(): string {
return new UIURLCombiner(this.EnvConfig, '/handle/', this.content).toString();
}
}

View File

@@ -14,7 +14,8 @@
<ng-container #componentViewContainer></ng-container>
<small *ngIf="hasHint" class="text-muted" [innerHTML]="model.hint | translate" [ngClass]="getClass('element', 'hint')"></small>
<small *ngIf="hasHint && (!showErrorMessages || errorMessages.length === 0)"
class="text-muted" [innerHTML]="model.hint | translate" [ngClass]="getClass('element', 'hint')"></small>
<div *ngIf="showErrorMessages" [ngClass]="[getClass('element', 'errors'), getClass('grid', 'errors')]">
<small *ngFor="let message of errorMessages" class="invalid-feedback d-block">{{ message | translate:model.validators }}</small>

View File

@@ -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<string>;
constructor(config: DynamicConcatModelConfig, layout?: DynamicFormControlLayout) {
super(config, layout);
this.separator = config.separator + ' ';
this.valueUpdates = new Subject<string>();
this.valueUpdates.subscribe((value: string) => this.value = value);
}
get value() {

View File

@@ -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 {

View File

@@ -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<string>;
@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() {

View File

@@ -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();">
</div>
<!--Lookup-name, second field-->
@@ -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();">
</div>
<div class="col-auto text-center">
<button ngbDropdownAnchor

View File

@@ -237,6 +237,12 @@ describe('Dynamic Lookup component', () => {
it('should init component properly', () => {
expect(lookupComp.firstInputValue).toBe('');
const de = lookupFixture.debugElement.queryAll(By.css('button'));
const searchBtnEl = de[0].nativeElement;
const editBtnEl = de[1].nativeElement;
expect(searchBtnEl.disabled).toBe(true);
expect(editBtnEl.disabled).toBe(true);
expect(editBtnEl.textContent.trim()).toBe('form.edit');
});
it('should return search results', fakeAsync(() => {
@@ -283,7 +289,7 @@ describe('Dynamic Lookup component', () => {
lookupComp.firstInputValue = 'test';
lookupFixture.detectChanges();
lookupComp.onInput(new Event('input'));
lookupComp.onChange(new Event('change'));
expect(lookupComp.model.value).toEqual(new FormFieldMetadataValueObject('test'))
}));
@@ -293,10 +299,11 @@ describe('Dynamic Lookup component', () => {
lookupComp.firstInputValue = 'test';
lookupFixture.detectChanges();
lookupComp.onInput(new Event('input'));
lookupComp.onChange(new Event('change'));
expect(lookupComp.model.value).not.toBeDefined();
});
});
describe('and init model value is not empty', () => {
@@ -318,6 +325,19 @@ describe('Dynamic Lookup component', () => {
it('should init component properly', () => {
expect(lookupComp.firstInputValue).toBe('test');
});
it('should have search button disabled on edit mode', () => {
lookupComp.editMode = true;
lookupFixture.detectChanges();
const de = lookupFixture.debugElement.queryAll(By.css('button'));
const searchBtnEl = de[0].nativeElement;
const saveBtnEl = de[1].nativeElement;
expect(searchBtnEl.disabled).toBe(true);
expect(saveBtnEl.disabled).toBe(false);
expect(saveBtnEl.textContent.trim()).toBe('form.save');
});
});
});
@@ -340,7 +360,14 @@ describe('Dynamic Lookup component', () => {
});
it('should render two input element', () => {
const de = lookupFixture.debugElement.queryAll(By.css('input.form-control'));
const deBtn = lookupFixture.debugElement.queryAll(By.css('button'));
const searchBtnEl = deBtn[0].nativeElement;
const editBtnEl = deBtn[1].nativeElement;
expect(de.length).toBe(2);
expect(searchBtnEl.disabled).toBe(true);
expect(editBtnEl.disabled).toBe(true);
expect(editBtnEl.textContent.trim()).toBe('form.edit');
});
});
@@ -418,6 +445,19 @@ describe('Dynamic Lookup component', () => {
expect(lookupComp.firstInputValue).toBe('Name');
expect(lookupComp.secondInputValue).toBe('Lastname');
});
it('should have search button disabled on edit mode', () => {
lookupComp.editMode = true;
lookupFixture.detectChanges();
const de = lookupFixture.debugElement.queryAll(By.css('button'));
const searchBtnEl = de[0].nativeElement;
const saveBtnEl = de[1].nativeElement;
expect(searchBtnEl.disabled).toBe(true);
expect(saveBtnEl.disabled).toBe(false);
expect(saveBtnEl.textContent.trim()).toBe('form.save');
});
});
});
});

View File

@@ -123,6 +123,15 @@ export class DsDynamicLookupComponent extends DynamicFormControlComponent implem
}
}
protected updateModel(value) {
this.group.markAsDirty();
this.model.valueUpdates.next(value);
this.setInputsValue(value);
this.change.emit(value);
this.optionsList = null;
this.pageInfo = null;
}
public formatItemForInput(item: any, field: number): string {
if (isUndefined(item) || isNull(item)) {
return '';
@@ -159,7 +168,7 @@ export class DsDynamicLookupComponent extends DynamicFormControlComponent implem
}
public isSearchDisabled() {
return isEmpty(this.firstInputValue);
return isEmpty(this.firstInputValue) || this.editMode;
}
public onBlurEvent(event: Event) {
@@ -170,12 +179,13 @@ export class DsDynamicLookupComponent extends DynamicFormControlComponent implem
this.focus.emit(event);
}
public onInput(event) {
public onChange(event) {
event.preventDefault();
if (!this.model.authorityOptions.closed) {
if (isNotEmpty(this.getCurrentValue())) {
const currentValue = new FormFieldMetadataValueObject(this.getCurrentValue());
if (!this.editMode) {
this.onSelect(currentValue);
this.updateModel(currentValue);
}
} else {
this.remove();
@@ -191,12 +201,7 @@ export class DsDynamicLookupComponent extends DynamicFormControlComponent implem
}
public onSelect(event) {
this.group.markAsDirty();
this.model.valueUpdates.next(event);
this.setInputsValue(event);
this.change.emit(event);
this.optionsList = null;
this.pageInfo = null;
this.updateModel(event);
}
public openChange(isOpened: boolean) {
@@ -219,7 +224,7 @@ export class DsDynamicLookupComponent extends DynamicFormControlComponent implem
display: this.getCurrentValue(),
value: this.getCurrentValue()
});
this.onSelect(newValue);
this.updateModel(newValue);
} else {
this.remove();
}

View File

@@ -129,9 +129,11 @@ export class DsDynamicRelationGroupComponent extends DynamicFormControlComponent
|| this.selectedChipItem.item[model.name].value === PLACEHOLDER_PARENT_METADATA)
? null
: this.selectedChipItem.item[model.name];
if (isNotNull(value)) {
model.valueUpdates.next(this.formBuilderService.isInputModel(model) ? value.value : value);
}
const nextValue = (this.formBuilderService.isInputModel(model) && isNotNull(value) && (typeof value !== 'string')) ?
value.value : value;
model.valueUpdates.next(nextValue);
});
});
@@ -229,7 +231,7 @@ export class DsDynamicRelationGroupComponent extends DynamicFormControlComponent
flatMap((valueModel) => {
const returnList: Array<Observable<any>> = [];
valueModel.forEach((valueObj) => {
const returnObj = Object.keys(valueObj).map((fieldName) => {
const returnObj = Object.keys(valueObj).map((fieldName) => {
let return$: Observable<any>;
if (isObject(valueObj[fieldName]) && valueObj[fieldName].hasAuthority() && isNotEmpty(valueObj[fieldName].authority)) {
const fieldId = fieldName.replace(/\./g, '_');
@@ -253,7 +255,7 @@ export class DsDynamicRelationGroupComponent extends DynamicFormControlComponent
} else {
return$ = observableOf(valueObj[fieldName]);
}
return return$.pipe(map((entry) => ({[fieldName]: entry})));
return return$.pipe(map((entry) => ({ [fieldName]: entry })));
});
returnList.push(combineLatest(returnObj));

View File

@@ -2,7 +2,7 @@ import { ChangeDetectorRef, Component, EventEmitter, Input, OnInit, Output } fro
import { FormGroup } from '@angular/forms';
import { Observable, of as observableOf } from 'rxjs';
import { catchError, first, tap } from 'rxjs/operators';
import { catchError, distinctUntilChanged, first, tap } from 'rxjs/operators';
import { NgbDropdown } from '@ng-bootstrap/ng-bootstrap';
import {
DynamicFormControlComponent,
@@ -71,7 +71,13 @@ export class DsDynamicScrollableDropdownComponent extends DynamicFormControlComp
}
this.pageInfo = object.pageInfo;
this.cdr.detectChanges();
})
});
this.group.get(this.model.id).valueChanges.pipe(distinctUntilChanged())
.subscribe((value) => {
this.setCurrentValue(value);
});
}
inputFormatter = (x: AuthorityValue): string => x.display || x.value;

View File

@@ -28,7 +28,8 @@
aria-hidden="true"
[authorityValue]="currentValue"
(whenClickOnConfidenceNotAccepted)="whenClickOnConfidenceNotAccepted($event)"></i>
<input class="form-control"
<input #instance="ngbTypeahead"
class="form-control"
[attr.autoComplete]="model.autoComplete"
[class.is-invalid]="showErrorMessages"
[dynamicId]="bindId && model.id"

View File

@@ -156,7 +156,7 @@ describe('DsDynamicTypeaheadComponent test suite', () => {
inputElement.value = 'test value';
inputElement.dispatchEvent(new Event('input'));
expect((typeaheadComp.model as any).value).toEqual(new FormFieldMetadataValueObject('test value'))
expect(typeaheadComp.inputValue).toEqual(new FormFieldMetadataValueObject('test value'))
});
@@ -173,19 +173,56 @@ describe('DsDynamicTypeaheadComponent test suite', () => {
});
it('should emit blur Event onBlur', () => {
it('should emit blur Event onBlur when popup is closed', () => {
spyOn(typeaheadComp.blur, 'emit');
spyOn(typeaheadComp.instance, 'isPopupOpen').and.returnValue(false);
typeaheadComp.onBlur(new Event('blur'));
expect(typeaheadComp.blur.emit).toHaveBeenCalled();
});
it('should emit change Event onBlur when AuthorityOptions.closed is false', () => {
it('should not emit blur Event onBlur when popup is opened', () => {
spyOn(typeaheadComp.blur, 'emit');
spyOn(typeaheadComp.instance, 'isPopupOpen').and.returnValue(true);
const input = typeaheadFixture.debugElement.query(By.css('input'));
input.nativeElement.blur();
expect(typeaheadComp.blur.emit).not.toHaveBeenCalled();
});
it('should emit change Event onBlur when AuthorityOptions.closed is false and inputValue is changed', () => {
typeaheadComp.inputValue = 'test value';
typeaheadFixture.detectChanges();
spyOn(typeaheadComp.blur, 'emit');
spyOn(typeaheadComp.change, 'emit');
typeaheadComp.onBlur(new Event('blur'));
// expect(typeaheadComp.change.emit).toHaveBeenCalled();
spyOn(typeaheadComp.instance, 'isPopupOpen').and.returnValue(false);
typeaheadComp.onBlur(new Event('blur', ));
expect(typeaheadComp.change.emit).toHaveBeenCalled();
expect(typeaheadComp.blur.emit).toHaveBeenCalled();
});
it('should not emit change Event onBlur when AuthorityOptions.closed is false and inputValue is not changed', () => {
typeaheadComp.inputValue = 'test value';
typeaheadComp.model = new DynamicTypeaheadModel(TYPEAHEAD_TEST_MODEL_CONFIG);
(typeaheadComp.model as any).value = 'test value';
typeaheadFixture.detectChanges();
spyOn(typeaheadComp.blur, 'emit');
spyOn(typeaheadComp.change, 'emit');
spyOn(typeaheadComp.instance, 'isPopupOpen').and.returnValue(false);
typeaheadComp.onBlur(new Event('blur', ));
expect(typeaheadComp.change.emit).not.toHaveBeenCalled();
expect(typeaheadComp.blur.emit).toHaveBeenCalled();
});
it('should not emit change Event onBlur when AuthorityOptions.closed is false and inputValue is null', () => {
typeaheadComp.inputValue = null;
typeaheadComp.model = new DynamicTypeaheadModel(TYPEAHEAD_TEST_MODEL_CONFIG);
(typeaheadComp.model as any).value = 'test value';
typeaheadFixture.detectChanges();
spyOn(typeaheadComp.blur, 'emit');
spyOn(typeaheadComp.change, 'emit');
spyOn(typeaheadComp.instance, 'isPopupOpen').and.returnValue(false);
typeaheadComp.onBlur(new Event('blur', ));
expect(typeaheadComp.change.emit).not.toHaveBeenCalled();
expect(typeaheadComp.blur.emit).toHaveBeenCalled();
});

View File

@@ -1,4 +1,4 @@
import { ChangeDetectorRef, Component, EventEmitter, Input, OnInit, Output } from '@angular/core';
import { ChangeDetectorRef, Component, EventEmitter, Input, OnInit, Output, ViewChild } from '@angular/core';
import { FormGroup } from '@angular/forms';
import {
@@ -8,14 +8,13 @@ import {
} from '@ng-dynamic-forms/core';
import { catchError, debounceTime, distinctUntilChanged, filter, map, merge, switchMap, tap } from 'rxjs/operators';
import { Observable, of as observableOf, Subject } from 'rxjs';
import { NgbTypeaheadSelectItemEvent } from '@ng-bootstrap/ng-bootstrap';
import { NgbTypeahead, NgbTypeaheadSelectItemEvent } from '@ng-bootstrap/ng-bootstrap';
import { AuthorityService } from '../../../../../../core/integration/authority.service';
import { DynamicTypeaheadModel } from './dynamic-typeahead.model';
import { IntegrationSearchOptions } from '../../../../../../core/integration/models/integration-options.model';
import { isEmpty, isNotEmpty } from '../../../../../empty.util';
import { isEmpty, isNotEmpty, isNotNull } from '../../../../../empty.util';
import { FormFieldMetadataValueObject } from '../../../models/form-field-metadata-value.model';
import { ConfidenceType } from '../../../../../../core/integration/models/confidence-type';
@Component({
@@ -32,6 +31,8 @@ export class DsDynamicTypeaheadComponent extends DynamicFormControlComponent imp
@Output() change: EventEmitter<any> = new EventEmitter<any>();
@Output() focus: EventEmitter<any> = new EventEmitter<any>();
@ViewChild('instance') instance: NgbTypeahead;
searching = false;
searchOptions: IntegrationSearchOptions;
searchFailed = false;
@@ -105,16 +106,26 @@ export class DsDynamicTypeaheadComponent extends DynamicFormControlComponent imp
onInput(event) {
if (!this.model.authorityOptions.closed && isNotEmpty(event.target.value)) {
this.inputValue = new FormFieldMetadataValueObject(event.target.value);
this.model.valueUpdates.next(this.inputValue);
}
}
onBlur(event: Event) {
if (!this.model.authorityOptions.closed && isNotEmpty(this.inputValue)) {
this.change.emit(this.inputValue);
this.inputValue = null;
if (!this.instance.isPopupOpen()) {
if (!this.model.authorityOptions.closed && isNotEmpty(this.inputValue)) {
if (isNotNull(this.inputValue) && this.model.value !== this.inputValue) {
this.model.valueUpdates.next(this.inputValue);
this.change.emit(this.inputValue);
}
this.inputValue = null;
}
this.blur.emit(event);
} else {
// prevent on blur propagation if typeahed suggestions are showed
event.preventDefault();
event.stopImmediatePropagation();
// set focus on input again, this is to avoid to lose changes when no suggestion is selected
(event.target as HTMLInputElement).focus();
}
this.blur.emit(event);
}
onChange(event: Event) {
@@ -141,4 +152,5 @@ export class DsDynamicTypeaheadComponent extends DynamicFormControlComponent imp
this.click$.next(this.formatter(this.currentValue));
}
}
}

View File

@@ -47,6 +47,7 @@ export class ConcatFieldParser extends FieldParser {
const input1ModelConfig: DynamicInputModelConfig = this.initModel(id + CONCAT_FIRST_INPUT_SUFFIX, label, false, false);
const input2ModelConfig: DynamicInputModelConfig = this.initModel(id + CONCAT_SECOND_INPUT_SUFFIX, label, true, false);
input2ModelConfig.hint = '&nbsp;';
if (this.configData.mandatory) {
input1ModelConfig.required = true;

View File

@@ -190,6 +190,8 @@ export abstract class FieldParser {
controlModel.placeholder = this.configData.label;
controlModel.hint = this.configData.hints;
if (this.configData.mandatory && setErrors) {
this.markAsRequired(controlModel);
}

View File

@@ -24,6 +24,7 @@ export class OneboxFieldParser extends FieldParser {
const clsGroup = {
element: {
control: 'form-row',
hint: 'ds-form-qualdrop-hint'
}
};
@@ -54,8 +55,10 @@ export class OneboxFieldParser extends FieldParser {
inputSelectGroup.id = newId.replace(/\./g, '_') + QUALDROP_GROUP_SUFFIX;
inputSelectGroup.group = [];
inputSelectGroup.legend = this.configData.label;
inputSelectGroup.hint = this.configData.hints;
const selectModelConfig: DynamicSelectModelConfig<any> = this.initModel(newId + QUALDROP_METADATA_SUFFIX, label);
selectModelConfig.hint = null;
this.setOptions(selectModelConfig);
if (isNotEmpty(fieldValue)) {
selectModelConfig.value = fieldValue.metadata;
@@ -63,6 +66,7 @@ export class OneboxFieldParser extends FieldParser {
inputSelectGroup.group.push(new DynamicSelectModel(selectModelConfig, clsSelect));
const inputModelConfig: DsDynamicInputModelConfig = this.initModel(newId + QUALDROP_VALUE_SUFFIX, label, true);
inputModelConfig.hint = null;
this.setValues(inputModelConfig, fieldValue);
inputSelectGroup.readOnly = selectModelConfig.disabled && inputModelConfig.readOnly;

View File

@@ -42,3 +42,8 @@
.right-addon input {
padding-right: $spacer * 2.25;
}
.ds-form-qualdrop-hint {
top: -$spacer;
position: relative;
}

View File

@@ -95,6 +95,7 @@ describe('LogInComponent', () => {
// verify Store.dispatch() is invoked
expect(page.navigateSpy.calls.any()).toBe(true, 'Store.dispatch not invoked');
});
});
/**

View File

@@ -1,5 +1,5 @@
import { filter, map, takeWhile } from 'rxjs/operators';
import { Component, OnDestroy, OnInit } from '@angular/core';
import { Component, Input, OnDestroy, OnInit } from '@angular/core';
import { FormBuilder, FormGroup, Validators } from '@angular/forms';
import { select, Store } from '@ngrx/store';
@@ -20,6 +20,7 @@ import { CoreState } from '../../core/core.reducers';
import { isNotEmpty } from '../empty.util';
import { fadeOut } from '../animations/fade';
import { AuthService } from '../../core/auth/auth.service';
import { Router } from '@angular/router';
/**
* /users/sign-in
@@ -81,10 +82,13 @@ export class LogInComponent implements OnDestroy, OnInit {
*/
private alive = true;
@Input() isStandalonePage: boolean;
/**
* @constructor
* @param {AuthService} authService
* @param {FormBuilder} formBuilder
* @param {Router} router
* @param {Store<State>} store
*/
constructor(
@@ -135,7 +139,7 @@ export class LogInComponent implements OnDestroy, OnInit {
takeWhile(() => this.alive),
filter((authenticated) => authenticated))
.subscribe(() => {
this.authService.redirectToPreviousUrl();
this.authService.redirectAfterLoginSuccess(this.isStandalonePage);
}
);
}
@@ -188,4 +192,5 @@ export class LogInComponent implements OnDestroy, OnInit {
// clear form
this.form.reset();
}
}

View File

@@ -2,7 +2,7 @@
<a [class.disabled]="!(object.workflowitem | async)?.hasSucceeded"
class="btn btn-primary mt-1 mb-3"
ngbTooltip="{{'submission.workflow.tasks.claimed.edit_help' | translate}}"
[routerLink]="['/workflowitems/' + (object.workflowitem | async)?.payload.id + '/' + object.id + '/edit']"
[routerLink]="['/workflowitems/' + (object.workflowitem | async)?.payload.id + '/edit']"
role="button">
<i class="fa fa-edit"></i> {{'submission.workflow.tasks.claimed.edit' | translate}}
</a>

View File

@@ -15,11 +15,11 @@
<ng-container *ngVar="(bitstreams$ | async) as bitstreams">
<ds-metadata-field-wrapper [label]="('item.page.files' | translate)">
<div *ngIf="bitstreams?.length > 0" class="file-section">
<a *ngFor="let file of bitstreams; let last=last;" [href]="file?.content" target="_blank" [download]="file?.name">
<button class="btn btn-link" *ngFor="let file of bitstreams; let last=last;" (click)="downloadBitstreamFile(file?.uuid)">
<span>{{file?.name}}</span>
<span>({{(file?.sizeBytes) | dsFileSize }})</span>
<span *ngIf="!last" innerHTML="{{separator}}"></span>
</a>
</button>
</div>
<ng-container *ngIf="bitstreams?.length === 0">
<span class="text-muted">{{('mydspace.results.no-files' | translate)}}</span>

View File

@@ -12,10 +12,20 @@ import { MockTranslateLoader } from '../../../mocks/mock-translate-loader';
import { ItemDetailPreviewFieldComponent } from './item-detail-preview-field/item-detail-preview-field.component';
import { FileSizePipe } from '../../../utils/file-size-pipe';
import { VarDirective } from '../../../utils/var.directive';
import { FileService } from '../../../../core/shared/file.service';
import { HALEndpointService } from '../../../../core/shared/hal-endpoint.service';
import { HALEndpointServiceStub } from '../../../testing/hal-endpoint-service-stub';
import { RemoteData } from '../../../../core/data/remote-data';
import { PaginatedList } from '../../../../core/data/paginated-list';
import { PageInfo } from '../../../../core/shared/page-info.model';
function getMockFileService(): FileService {
return jasmine.createSpyObj('FileService', {
downloadFile: jasmine.createSpy('downloadFile'),
getFileNameFromResponseContentDisposition: jasmine.createSpy('getFileNameFromResponseContentDisposition')
});
}
let component: ItemDetailPreviewComponent;
let fixture: ComponentFixture<ItemDetailPreviewComponent>;
@@ -62,6 +72,10 @@ describe('ItemDetailPreviewComponent', () => {
}),
],
declarations: [ItemDetailPreviewComponent, ItemDetailPreviewFieldComponent, TruncatePipe, FileSizePipe, VarDirective],
providers: [
{ provide: FileService, useValue: getMockFileService() },
{ provide: HALEndpointService, useValue: new HALEndpointServiceStub('workspaceitems') }
],
schemas: [NO_ERRORS_SCHEMA]
}).overrideComponent(ItemDetailPreviewComponent, {
set: { changeDetection: ChangeDetectionStrategy.Default }

View File

@@ -1,12 +1,15 @@
import { Component, Input } from '@angular/core';
import { Observable } from 'rxjs';
import { first } from 'rxjs/operators';
import { Item } from '../../../../core/shared/item.model';
import { MyDspaceItemStatusType } from '../../../object-collection/shared/mydspace-item-status/my-dspace-item-status-type';
import { fadeInOut } from '../../../animations/fade';
import { Bitstream } from '../../../../core/shared/bitstream.model';
import { MyDSpaceResult } from '../../../../+my-dspace-page/my-dspace-result.model';
import { FileService } from '../../../../core/shared/file.service';
import { HALEndpointService } from '../../../../core/shared/hal-endpoint.service';
/**
* This component show metadata for the given item object in the detail view.
@@ -54,6 +57,16 @@ export class ItemDetailPreviewComponent {
*/
public thumbnail$: Observable<Bitstream>;
/**
* Initialize instance variables
*
* @param {FileService} fileService
* @param {HALEndpointService} halService
*/
constructor(private fileService: FileService,
private halService: HALEndpointService) {
}
/**
* Initialize all instance variables
*/
@@ -62,4 +75,15 @@ export class ItemDetailPreviewComponent {
this.bitstreams$ = this.item.getFiles();
}
/**
* Perform bitstream download
*/
public downloadBitstreamFile(uuid: string) {
this.halService.getEndpoint('bitstreams').pipe(
first())
.subscribe((url) => {
const fileUrl = `${url}/${uuid}/content`;
this.fileService.downloadFile(fileUrl);
});
}
}

View File

@@ -0,0 +1,41 @@
<ng-container *ngVar="(dsoRD$ | async) as collectionsRD">
<ds-pagination
*ngIf="collectionsRD?.payload?.totalElements > 0 || collectionsRD?.payload?.page?.length > 0"
[paginationOptions]="paginationOptions"
[sortOptions]="sortOptions"
[pageInfoState]="collectionsRD?.payload"
[collectionSize]="collectionsRD?.payload?.totalElements"
[hidePagerWhenSinglePage]="true"
[hideGear]="true">
<div class="table-responsive mt-2">
<table id="collection-select" class="table table-striped table-hover">
<thead>
<tr>
<th></th>
<th scope="col">{{'collection.select.table.title' | translate}}</th>
</tr>
</thead>
<tbody>
<tr *ngFor="let collection of collectionsRD?.payload?.page">
<td><input class="collection-checkbox" [ngModel]="getSelected(collection.id) | async" (change)="switch(collection.id)" type="checkbox" name="{{collection.id}}"></td>
<td><a [routerLink]="['/collections', collection.id]">{{collection.name}}</a></td>
</tr>
</tbody>
</table>
</div>
</ds-pagination>
<div *ngIf="collectionsRD?.payload?.totalElements === 0 || collectionsRD?.payload?.page?.length === 0" class="alert alert-info w-100" role="alert">
{{'collection.select.empty' | translate}}
</div>
<ds-error *ngIf="collectionsRD?.hasFailed" message="{{'error.collections' | translate}}"></ds-error>
<ds-loading *ngIf="!collectionsRD || collectionsRD?.isLoading" message="{{'loading.collections' | translate}}"></ds-loading>
<div *ngVar="(selectedIds$ | async) as selectedIds">
<button class="btn btn-outline-secondary collection-cancel float-left" (click)="onCancel()">{{cancelButton | translate}}</button>
<button class="btn collection-confirm float-right"
[ngClass]="{'btn-danger': dangerConfirm, 'btn-primary': !dangerConfirm}"
[disabled]="selectedIds?.length === 0"
(click)="confirmSelected()">
{{confirmButton | translate}}
</button>
</div>
</ng-container>

View File

@@ -0,0 +1,118 @@
import { async, ComponentFixture, TestBed } from '@angular/core/testing';
import { RouterTestingModule } from '@angular/router/testing';
import { RemoteData } from '../../../core/data/remote-data';
import { PaginatedList } from '../../../core/data/paginated-list';
import { PageInfo } from '../../../core/shared/page-info.model';
import { PaginationComponentOptions } from '../../pagination/pagination-component-options.model';
import { TranslateModule } from '@ngx-translate/core';
import { SharedModule } from '../../shared.module';
import { ObjectSelectServiceStub } from '../../testing/object-select-service-stub';
import { ObjectSelectService } from '../object-select.service';
import { HostWindowService } from '../../host-window.service';
import { HostWindowServiceStub } from '../../testing/host-window-service-stub';
import { NO_ERRORS_SCHEMA } from '@angular/core';
import { By } from '@angular/platform-browser';
import { CollectionSelectComponent } from './collection-select.component';
import { Collection } from '../../../core/shared/collection.model';
import { of } from 'rxjs/internal/observable/of';
describe('CollectionSelectComponent', () => {
let comp: CollectionSelectComponent;
let fixture: ComponentFixture<CollectionSelectComponent>;
let objectSelectService: ObjectSelectService;
const mockCollectionList = [
Object.assign(new Collection(), {
id: 'id1',
name: 'name1'
}),
Object.assign(new Collection(), {
id: 'id2',
name: 'name2'
})
];
const mockCollections = of(new RemoteData(false, false, true, null, new PaginatedList(new PageInfo(), mockCollectionList)));
const mockPaginationOptions = Object.assign(new PaginationComponentOptions(), {
id: 'search-page-configuration',
pageSize: 10,
currentPage: 1
});
beforeEach(async(() => {
TestBed.configureTestingModule({
imports: [TranslateModule.forRoot(), SharedModule, RouterTestingModule.withRoutes([])],
declarations: [],
providers: [
{ provide: ObjectSelectService, useValue: new ObjectSelectServiceStub([mockCollectionList[1].id]) },
{ provide: HostWindowService, useValue: new HostWindowServiceStub(0) }
],
schemas: [NO_ERRORS_SCHEMA]
}).compileComponents();
}));
beforeEach(() => {
fixture = TestBed.createComponent(CollectionSelectComponent);
comp = fixture.componentInstance;
comp.dsoRD$ = mockCollections;
comp.paginationOptions = mockPaginationOptions;
fixture.detectChanges();
objectSelectService = (comp as any).objectSelectService;
});
it(`should show a list of ${mockCollectionList.length} collections`, () => {
const tbody: HTMLElement = fixture.debugElement.query(By.css('table#collection-select tbody')).nativeElement;
expect(tbody.children.length).toBe(mockCollectionList.length);
});
describe('checkboxes', () => {
let checkbox: HTMLInputElement;
beforeEach(() => {
checkbox = fixture.debugElement.query(By.css('input.collection-checkbox')).nativeElement;
});
it('should initially be unchecked',() => {
expect(checkbox.checked).toBeFalsy();
});
it('should be checked when clicked', () => {
checkbox.click();
fixture.detectChanges();
expect(checkbox.checked).toBeTruthy();
});
it('should switch the value through object-select-service', () => {
spyOn((comp as any).objectSelectService, 'switch').and.callThrough();
checkbox.click();
expect((comp as any).objectSelectService.switch).toHaveBeenCalled();
});
});
describe('when confirm is clicked', () => {
let confirmButton: HTMLButtonElement;
beforeEach(() => {
confirmButton = fixture.debugElement.query(By.css('button.collection-confirm')).nativeElement;
spyOn(comp.confirm, 'emit').and.callThrough();
});
it('should emit the selected collections',() => {
confirmButton.click();
expect(comp.confirm.emit).toHaveBeenCalled();
});
});
describe('when cancel is clicked', () => {
let cancelButton: HTMLButtonElement;
beforeEach(() => {
cancelButton = fixture.debugElement.query(By.css('button.collection-cancel')).nativeElement;
spyOn(comp.cancel, 'emit').and.callThrough();
});
it('should emit a cancel event',() => {
cancelButton.click();
expect(comp.cancel.emit).toHaveBeenCalled();
});
});
});

View File

@@ -0,0 +1,28 @@
import { Component } from '@angular/core';
import { Collection } from '../../../core/shared/collection.model';
import { ObjectSelectComponent } from '../object-select/object-select.component';
import { isNotEmpty } from '../../empty.util';
import { ObjectSelectService } from '../object-select.service';
@Component({
selector: 'ds-collection-select',
templateUrl: './collection-select.component.html'
})
/**
* A component used to select collections from a specific list and returning the UUIDs of the selected collections
*/
export class CollectionSelectComponent extends ObjectSelectComponent<Collection> {
constructor(protected objectSelectService: ObjectSelectService) {
super(objectSelectService);
}
ngOnInit(): void {
super.ngOnInit();
if (!isNotEmpty(this.confirmButton)) {
this.confirmButton = 'collection.select.confirm';
}
}
}

View File

@@ -0,0 +1,49 @@
<ng-container *ngVar="(dsoRD$ | async) as itemsRD">
<ds-pagination
*ngIf="itemsRD?.payload?.totalElements > 0"
[paginationOptions]="paginationOptions"
[sortOptions]="sortOptions"
[pageInfoState]="itemsRD?.payload"
[collectionSize]="itemsRD?.payload?.totalElements"
[hidePagerWhenSinglePage]="true"
[hideGear]="true">
<div class="table-responsive mt-2">
<table id="item-select" class="table table-striped table-hover">
<thead>
<tr>
<th></th>
<th *ngIf="!hideCollection" scope="col">{{'item.select.table.collection' | translate}}</th>
<th scope="col">{{'item.select.table.author' | translate}}</th>
<th scope="col">{{'item.select.table.title' | translate}}</th>
</tr>
</thead>
<tbody>
<tr *ngFor="let item of itemsRD?.payload?.page">
<td><input class="item-checkbox" [ngModel]="getSelected(item.id) | async" (change)="switch(item.id)" type="checkbox" name="{{item.id}}"></td>
<td *ngIf="!hideCollection">
<span *ngVar="(item.owningCollection | async)?.payload as collection">
<a *ngIf="collection" [routerLink]="['/collections', collection?.id]">{{collection?.name}}</a>
</span>
</td>
<td><span *ngIf="item.hasMetadata(['dc.contributor.author', 'dc.creator', 'dc.contributor.*'])">{{item.firstMetadataValue(['dc.contributor.author', 'dc.creator', 'dc.contributor.*'])}}</span></td>
<td><a [routerLink]="['/items', item.id]">{{item.firstMetadataValue("dc.title")}}</a></td>
</tr>
</tbody>
</table>
</div>
</ds-pagination>
<div *ngIf="itemsRD?.payload?.totalElements === 0 || itemsRD?.payload?.page?.length === 0" class="alert alert-info w-100" role="alert">
{{'item.select.empty' | translate}}
</div>
<ds-error *ngIf="itemsRD?.hasFailed" message="{{'error.items' | translate}}"></ds-error>
<ds-loading *ngIf="!itemsRD || itemsRD?.isLoading" message="{{'loading.items' | translate}}"></ds-loading>
<div *ngVar="(selectedIds$ | async) as selectedIds">
<button class="btn btn-outline-secondary item-cancel float-left" (click)="onCancel()">{{cancelButton | translate}}</button>
<button class="btn item-confirm float-right"
[ngClass]="{'btn-danger': dangerConfirm, 'btn-primary': !dangerConfirm}"
[disabled]="selectedIds?.length === 0"
(click)="confirmSelected()">
{{confirmButton | translate}}
</button>
</div>
</ng-container>

View File

@@ -0,0 +1,140 @@
import { ItemSelectComponent } from './item-select.component';
import { async, ComponentFixture, fakeAsync, TestBed, tick } from '@angular/core/testing';
import { RouterTestingModule } from '@angular/router/testing';
import { Item } from '../../../core/shared/item.model';
import { RemoteData } from '../../../core/data/remote-data';
import { PaginatedList } from '../../../core/data/paginated-list';
import { PageInfo } from '../../../core/shared/page-info.model';
import { PaginationComponentOptions } from '../../pagination/pagination-component-options.model';
import { TranslateModule } from '@ngx-translate/core';
import { SharedModule } from '../../shared.module';
import { ObjectSelectServiceStub } from '../../testing/object-select-service-stub';
import { ObjectSelectService } from '../object-select.service';
import { HostWindowService } from '../../host-window.service';
import { HostWindowServiceStub } from '../../testing/host-window-service-stub';
import { NO_ERRORS_SCHEMA } from '@angular/core';
import { By } from '@angular/platform-browser';
import { of } from 'rxjs/internal/observable/of';
describe('ItemSelectComponent', () => {
let comp: ItemSelectComponent;
let fixture: ComponentFixture<ItemSelectComponent>;
let objectSelectService: ObjectSelectService;
const mockItemList = [
Object.assign(new Item(), {
id: 'id1',
bitstreams: of({}),
metadata: [
{
key: 'dc.title',
language: 'en_US',
value: 'This is just a title'
},
{
key: 'dc.type',
language: null,
value: 'Article'
}]
}),
Object.assign(new Item(), {
id: 'id2',
bitstreams: of({}),
metadata: [
{
key: 'dc.title',
language: 'en_US',
value: 'This is just another title'
},
{
key: 'dc.type',
language: null,
value: 'Article'
}]
})
];
const mockItems = of(new RemoteData(false, false, true, null, new PaginatedList(new PageInfo(), mockItemList)));
const mockPaginationOptions = Object.assign(new PaginationComponentOptions(), {
id: 'search-page-configuration',
pageSize: 10,
currentPage: 1
});
beforeEach(async(() => {
TestBed.configureTestingModule({
imports: [TranslateModule.forRoot(), SharedModule, RouterTestingModule.withRoutes([])],
declarations: [],
providers: [
{ provide: ObjectSelectService, useValue: new ObjectSelectServiceStub([mockItemList[1].id]) },
{ provide: HostWindowService, useValue: new HostWindowServiceStub(0) }
],
schemas: [NO_ERRORS_SCHEMA]
}).compileComponents();
}));
beforeEach(() => {
fixture = TestBed.createComponent(ItemSelectComponent);
comp = fixture.componentInstance;
comp.dsoRD$ = mockItems;
comp.paginationOptions = mockPaginationOptions;
fixture.detectChanges();
objectSelectService = (comp as any).objectSelectService;
});
it(`should show a list of ${mockItemList.length} items`, () => {
const tbody: HTMLElement = fixture.debugElement.query(By.css('table#item-select tbody')).nativeElement;
expect(tbody.children.length).toBe(mockItemList.length);
});
describe('checkboxes', () => {
let checkbox: HTMLInputElement;
beforeEach(() => {
checkbox = fixture.debugElement.query(By.css('input.item-checkbox')).nativeElement;
});
it('should initially be unchecked',() => {
expect(checkbox.checked).toBeFalsy();
});
it('should be checked when clicked', () => {
checkbox.click();
fixture.detectChanges();
expect(checkbox.checked).toBeTruthy();
});
it('should switch the value through object-select-service', () => {
spyOn((comp as any).objectSelectService, 'switch').and.callThrough();
checkbox.click();
expect((comp as any).objectSelectService.switch).toHaveBeenCalled();
});
});
describe('when confirm is clicked', () => {
let confirmButton: HTMLButtonElement;
beforeEach(() => {
confirmButton = fixture.debugElement.query(By.css('button.item-confirm')).nativeElement;
spyOn(comp.confirm, 'emit').and.callThrough();
});
it('should emit the selected items',() => {
confirmButton.click();
expect(comp.confirm.emit).toHaveBeenCalled();
});
});
describe('when cancel is clicked', () => {
let cancelButton: HTMLButtonElement;
beforeEach(() => {
cancelButton = fixture.debugElement.query(By.css('button.item-cancel')).nativeElement;
spyOn(comp.cancel, 'emit').and.callThrough();
});
it('should emit a cancel event',() => {
cancelButton.click();
expect(comp.cancel.emit).toHaveBeenCalled();
});
});
});

View File

@@ -0,0 +1,34 @@
import { Component, Input } from '@angular/core';
import { Item } from '../../../core/shared/item.model';
import { ObjectSelectService } from '../object-select.service';
import { ObjectSelectComponent } from '../object-select/object-select.component';
import { isNotEmpty } from '../../empty.util';
@Component({
selector: 'ds-item-select',
templateUrl: './item-select.component.html'
})
/**
* A component used to select items from a specific list and returning the UUIDs of the selected items
*/
export class ItemSelectComponent extends ObjectSelectComponent<Item> {
/**
* Whether or not to hide the collection column
*/
@Input()
hideCollection = false;
constructor(protected objectSelectService: ObjectSelectService) {
super(objectSelectService);
}
ngOnInit(): void {
super.ngOnInit();
if (!isNotEmpty(this.confirmButton)) {
this.confirmButton = 'item.select.confirm';
}
}
}

View File

@@ -0,0 +1,82 @@
import { type } from '../ngrx/type';
import { Action } from '@ngrx/store';
export const ObjectSelectionActionTypes = {
INITIAL_DESELECT: type('dspace/object-select/INITIAL_DESELECT'),
INITIAL_SELECT: type('dspace/object-select/INITIAL_SELECT'),
SELECT: type('dspace/object-select/SELECT'),
DESELECT: type('dspace/object-select/DESELECT'),
SWITCH: type('dspace/object-select/SWITCH'),
RESET: type('dspace/object-select/RESET')
};
export class ObjectSelectionAction implements Action {
/**
* Key of the list (of selections) for which the action should be performed
*/
key: string;
/**
* UUID of the object a select action can be performed on
*/
id: string;
/**
* Type of action that will be performed
*/
type;
/**
* Initialize with the object's UUID
* @param {string} key of the list
* @param {string} id of the object
*/
constructor(key: string, id: string) {
this.key = key;
this.id = id;
}
}
/* tslint:disable:max-classes-per-file */
/**
* Used to set the initial state to deselected
*/
export class ObjectSelectionInitialDeselectAction extends ObjectSelectionAction {
type = ObjectSelectionActionTypes.INITIAL_DESELECT;
}
/**
* Used to set the initial state to selected
*/
export class ObjectSelectionInitialSelectAction extends ObjectSelectionAction {
type = ObjectSelectionActionTypes.INITIAL_SELECT;
}
/**
* Used to select an object
*/
export class ObjectSelectionSelectAction extends ObjectSelectionAction {
type = ObjectSelectionActionTypes.SELECT;
}
/**
* Used to deselect an object
*/
export class ObjectSelectionDeselectAction extends ObjectSelectionAction {
type = ObjectSelectionActionTypes.DESELECT;
}
/**
* Used to switch an object between selected and deselected
*/
export class ObjectSelectionSwitchAction extends ObjectSelectionAction {
type = ObjectSelectionActionTypes.SWITCH;
}
/**
* Used to reset all objects selected to be deselected
*/
export class ObjectSelectionResetAction extends ObjectSelectionAction {
type = ObjectSelectionActionTypes.RESET;
}
/* tslint:enable:max-classes-per-file */

Some files were not shown because too many files have changed in this diff Show More