mirror of
https://github.com/DSpace/dspace-angular.git
synced 2025-10-07 01:54:15 +00:00
Merge branch 'master' into 460-coll-pages
This commit is contained in:
35
.travis.yml
35
.travis.yml
@@ -1,5 +1,37 @@
|
|||||||
sudo: required
|
sudo: required
|
||||||
dist: trusty
|
dist: trusty
|
||||||
|
|
||||||
|
env:
|
||||||
|
# Install the latest docker-compose version for ci testing.
|
||||||
|
# The default installation in travis is not compatible with the latest docker-compose file version.
|
||||||
|
COMPOSE_VERSION: 1.24.1
|
||||||
|
# The ci step will test the dspace-angular code against DSpace REST.
|
||||||
|
# Direct that step to utilize a DSpace REST service that has been started in docker.
|
||||||
|
DSPACE_REST_HOST: localhost
|
||||||
|
DSPACE_REST_PORT: 8080
|
||||||
|
DSPACE_REST_NAMESPACE: '/server/api'
|
||||||
|
DSPACE_REST_SSL: false
|
||||||
|
|
||||||
|
before_install:
|
||||||
|
# Docker Compose 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:
|
||||||
|
- docker-compose version
|
||||||
|
- docker-compose -f DSpace-Docker-Images/docker-compose-files/dspace-compose/d7.travis.yml up -d
|
||||||
|
- travis_retry yarn install
|
||||||
|
|
||||||
|
before_script:
|
||||||
|
# The following line could be enabled to verify that the rest server is responding.
|
||||||
|
# Currently, "yarn run build" takes enough time to run to allow the service to be available
|
||||||
|
#- curl http://localhost:8080/
|
||||||
|
|
||||||
|
after_script:
|
||||||
|
- docker-compose -f DSpace-Docker-Images/docker-compose-files/dspace-compose/d7.travis.yml down
|
||||||
|
|
||||||
addons:
|
addons:
|
||||||
apt:
|
apt:
|
||||||
sources:
|
sources:
|
||||||
@@ -18,9 +50,6 @@ cache:
|
|||||||
|
|
||||||
bundler_args: --retry 5
|
bundler_args: --retry 5
|
||||||
|
|
||||||
install:
|
|
||||||
- travis_retry yarn install
|
|
||||||
|
|
||||||
script:
|
script:
|
||||||
# Use Chromium instead of Chrome.
|
# Use Chromium instead of Chrome.
|
||||||
- export CHROME_BIN=chromium-browser
|
- export CHROME_BIN=chromium-browser
|
||||||
|
@@ -1,3 +1,4 @@
|
|||||||
|
// This configuration is currently only being used for unit tests, end-to-end tests use environment.dev.ts
|
||||||
module.exports = {
|
module.exports = {
|
||||||
|
|
||||||
};
|
};
|
||||||
|
@@ -22,10 +22,10 @@
|
|||||||
"clean:prod": "yarn run clean:coverage && yarn run clean:doc && yarn run clean:dist && yarn run clean:log && yarn run clean:json && yarn run clean:bld",
|
"clean:prod": "yarn run clean:coverage && yarn run clean:doc && yarn run clean:dist && yarn run clean:log && yarn run clean:json && yarn run clean:bld",
|
||||||
"clean": "yarn run clean:prod && yarn run clean:node",
|
"clean": "yarn run clean:prod && yarn run clean:node",
|
||||||
"prebuild": "yarn run clean:bld && yarn run clean:dist",
|
"prebuild": "yarn run clean:bld && yarn run clean:dist",
|
||||||
"prebuild:aot": "yarn run prebuild",
|
"prebuild:ci": "yarn run prebuild",
|
||||||
"prebuild:prod": "yarn run prebuild",
|
"prebuild:prod": "yarn run prebuild",
|
||||||
"build": "node ./scripts/webpack.js --progress --mode development",
|
"build": "node ./scripts/webpack.js --progress --mode development",
|
||||||
"build:aot": "yarn run syncbuilddir && node ./scripts/webpack.js --env.aot --env.server --mode development && node ./scripts/webpack.js --env.aot --env.client --mode development",
|
"build:ci": "yarn run syncbuilddir && node ./scripts/webpack.js --env.aot --env.server --mode development && node ./scripts/webpack.js --env.aot --env.client --mode development",
|
||||||
"build:prod": "yarn run syncbuilddir && node ./scripts/webpack.js --env.aot --env.server --mode production && node ./scripts/webpack.js --env.aot --env.client --mode production",
|
"build:prod": "yarn run syncbuilddir && node ./scripts/webpack.js --env.aot --env.server --mode production && node ./scripts/webpack.js --env.aot --env.client --mode production",
|
||||||
"postbuild:prod": "yarn run rollup",
|
"postbuild:prod": "yarn run rollup",
|
||||||
"rollup": "rollup -c rollup.config.js",
|
"rollup": "rollup -c rollup.config.js",
|
||||||
@@ -51,10 +51,13 @@
|
|||||||
"debug:server": "node-nightly --inspect --debug-brk dist/server.js",
|
"debug:server": "node-nightly --inspect --debug-brk dist/server.js",
|
||||||
"debug:build": "node-nightly --inspect --debug-brk node_modules/webpack/bin/webpack.js --mode development",
|
"debug:build": "node-nightly --inspect --debug-brk node_modules/webpack/bin/webpack.js --mode development",
|
||||||
"debug:build:prod": "node-nightly --inspect --debug-brk node_modules/webpack/bin/webpack.js --env.aot --env.client --env.server --mode production",
|
"debug:build:prod": "node-nightly --inspect --debug-brk node_modules/webpack/bin/webpack.js --env.aot --env.client --env.server --mode production",
|
||||||
"ci": "yarn run lint && yarn run build:aot && yarn run test:headless",
|
"ci": "yarn run lint && yarn run build:ci && yarn run test:headless && npm-run-all -p -r server e2e",
|
||||||
"protractor": "node node_modules/protractor/bin/protractor",
|
"protractor": "node node_modules/protractor/bin/protractor",
|
||||||
"pree2e": "yarn run webdriver:update",
|
"pree2e": "yarn run webdriver:update",
|
||||||
"e2e": "yarn run protractor",
|
"e2e": "yarn run protractor",
|
||||||
|
"pretest": "yarn run clean:bld",
|
||||||
|
"pretest:headless": "yarn run pretest",
|
||||||
|
"pretest:watch": "yarn run pretest",
|
||||||
"test": "karma start --single-run",
|
"test": "karma start --single-run",
|
||||||
"test:headless": "karma start --single-run --browsers ChromeHeadless",
|
"test:headless": "karma start --single-run --browsers ChromeHeadless",
|
||||||
"test:watch": "karma start --no-single-run --auto-watch",
|
"test:watch": "karma start --no-single-run --auto-watch",
|
||||||
|
@@ -258,12 +258,26 @@
|
|||||||
"item.edit.reinstate.error": "An error occurred while reinstating the item",
|
"item.edit.reinstate.error": "An error occurred while reinstating the item",
|
||||||
"item.edit.reinstate.header": "Reinstate item: {{ id }}",
|
"item.edit.reinstate.header": "Reinstate item: {{ id }}",
|
||||||
"item.edit.reinstate.success": "The item was reinstated successfully",
|
"item.edit.reinstate.success": "The item was reinstated successfully",
|
||||||
|
"item.edit.relationships.discard-button": "Discard",
|
||||||
|
"item.edit.relationships.edit.buttons.remove": "Remove",
|
||||||
|
"item.edit.relationships.edit.buttons.undo": "Undo changes",
|
||||||
|
"item.edit.relationships.notifications.discarded.content": "Your changes were discarded. To reinstate your changes click the 'Undo' button",
|
||||||
|
"item.edit.relationships.notifications.discarded.title": "Changes discarded",
|
||||||
|
"item.edit.relationships.notifications.failed.title": "Error deleting relationship",
|
||||||
|
"item.edit.relationships.notifications.outdated.content": "The item you're currently working on has been changed by another user. Your current changes are discarded to prevent conflicts",
|
||||||
|
"item.edit.relationships.notifications.outdated.title": "Changes outdated",
|
||||||
|
"item.edit.relationships.notifications.saved.content": "Your changes to this item's relationships were saved.",
|
||||||
|
"item.edit.relationships.notifications.saved.title": "Relationships saved",
|
||||||
|
"item.edit.relationships.reinstate-button": "Undo",
|
||||||
|
"item.edit.relationships.save-button": "Save",
|
||||||
"item.edit.tabs.bitstreams.head": "Item Bitstreams",
|
"item.edit.tabs.bitstreams.head": "Item Bitstreams",
|
||||||
"item.edit.tabs.bitstreams.title": "Item Edit - Bitstreams",
|
"item.edit.tabs.bitstreams.title": "Item Edit - Bitstreams",
|
||||||
"item.edit.tabs.curate.head": "Curate",
|
"item.edit.tabs.curate.head": "Curate",
|
||||||
"item.edit.tabs.curate.title": "Item Edit - Curate",
|
"item.edit.tabs.curate.title": "Item Edit - Curate",
|
||||||
"item.edit.tabs.metadata.head": "Item Metadata",
|
"item.edit.tabs.metadata.head": "Item Metadata",
|
||||||
"item.edit.tabs.metadata.title": "Item Edit - Metadata",
|
"item.edit.tabs.metadata.title": "Item Edit - Metadata",
|
||||||
|
"item.edit.tabs.relationships.head": "Item Relationships",
|
||||||
|
"item.edit.tabs.relationships.title": "Item Edit - Relationships",
|
||||||
"item.edit.tabs.status.buttons.authorizations.button": "Authorizations...",
|
"item.edit.tabs.status.buttons.authorizations.button": "Authorizations...",
|
||||||
"item.edit.tabs.status.buttons.authorizations.label": "Edit item's authorization policies",
|
"item.edit.tabs.status.buttons.authorizations.label": "Edit item's authorization policies",
|
||||||
"item.edit.tabs.status.buttons.delete.button": "Permanently delete",
|
"item.edit.tabs.status.buttons.delete.button": "Permanently delete",
|
||||||
|
@@ -4,7 +4,7 @@ import { TranslateModule } from '@ngx-translate/core';
|
|||||||
import { CommonModule } from '@angular/common';
|
import { CommonModule } from '@angular/common';
|
||||||
import { RouterTestingModule } from '@angular/router/testing';
|
import { RouterTestingModule } from '@angular/router/testing';
|
||||||
import { NO_ERRORS_SCHEMA } from '@angular/core';
|
import { NO_ERRORS_SCHEMA } from '@angular/core';
|
||||||
import { RouteService } from '../../shared/services/route.service';
|
import { RouteService } from '../../core/services/route.service';
|
||||||
import { SharedModule } from '../../shared/shared.module';
|
import { SharedModule } from '../../shared/shared.module';
|
||||||
import { CollectionDataService } from '../../core/data/collection-data.service';
|
import { CollectionDataService } from '../../core/data/collection-data.service';
|
||||||
import { of as observableOf } from 'rxjs';
|
import { of as observableOf } from 'rxjs';
|
||||||
|
@@ -1,6 +1,6 @@
|
|||||||
import { Component } from '@angular/core';
|
import { Component } from '@angular/core';
|
||||||
import { CommunityDataService } from '../../core/data/community-data.service';
|
import { CommunityDataService } from '../../core/data/community-data.service';
|
||||||
import { RouteService } from '../../shared/services/route.service';
|
import { RouteService } from '../../core/services/route.service';
|
||||||
import { Router } from '@angular/router';
|
import { Router } from '@angular/router';
|
||||||
import { CreateComColPageComponent } from '../../shared/comcol-forms/create-comcol-page/create-comcol-page.component';
|
import { CreateComColPageComponent } from '../../shared/comcol-forms/create-comcol-page/create-comcol-page.component';
|
||||||
import { Collection } from '../../core/shared/collection.model';
|
import { Collection } from '../../core/shared/collection.model';
|
||||||
|
@@ -4,7 +4,7 @@ import { TranslateModule } from '@ngx-translate/core';
|
|||||||
import { CommonModule } from '@angular/common';
|
import { CommonModule } from '@angular/common';
|
||||||
import { RouterTestingModule } from '@angular/router/testing';
|
import { RouterTestingModule } from '@angular/router/testing';
|
||||||
import { NO_ERRORS_SCHEMA } from '@angular/core';
|
import { NO_ERRORS_SCHEMA } from '@angular/core';
|
||||||
import { RouteService } from '../../shared/services/route.service';
|
import { RouteService } from '../../core/services/route.service';
|
||||||
import { SharedModule } from '../../shared/shared.module';
|
import { SharedModule } from '../../shared/shared.module';
|
||||||
import { CollectionDataService } from '../../core/data/collection-data.service';
|
import { CollectionDataService } from '../../core/data/collection-data.service';
|
||||||
import { of as observableOf } from 'rxjs';
|
import { of as observableOf } from 'rxjs';
|
||||||
|
@@ -1,7 +1,7 @@
|
|||||||
import { Component } from '@angular/core';
|
import { Component } from '@angular/core';
|
||||||
import { Community } from '../../core/shared/community.model';
|
import { Community } from '../../core/shared/community.model';
|
||||||
import { CommunityDataService } from '../../core/data/community-data.service';
|
import { CommunityDataService } from '../../core/data/community-data.service';
|
||||||
import { RouteService } from '../../shared/services/route.service';
|
import { RouteService } from '../../core/services/route.service';
|
||||||
import { Router } from '@angular/router';
|
import { Router } from '@angular/router';
|
||||||
import { CreateComColPageComponent } from '../../shared/comcol-forms/create-comcol-page/create-comcol-page.component';
|
import { CreateComColPageComponent } from '../../shared/comcol-forms/create-comcol-page/create-comcol-page.component';
|
||||||
|
|
||||||
|
@@ -4,7 +4,7 @@ import { TranslateModule } from '@ngx-translate/core';
|
|||||||
import { CommonModule } from '@angular/common';
|
import { CommonModule } from '@angular/common';
|
||||||
import { RouterTestingModule } from '@angular/router/testing';
|
import { RouterTestingModule } from '@angular/router/testing';
|
||||||
import { NO_ERRORS_SCHEMA } from '@angular/core';
|
import { NO_ERRORS_SCHEMA } from '@angular/core';
|
||||||
import { RouteService } from '../../shared/services/route.service';
|
import { RouteService } from '../../core/services/route.service';
|
||||||
import { SharedModule } from '../../shared/shared.module';
|
import { SharedModule } from '../../shared/shared.module';
|
||||||
import { of as observableOf } from 'rxjs';
|
import { of as observableOf } from 'rxjs';
|
||||||
import { NotificationsService } from '../../shared/notifications/notifications.service';
|
import { NotificationsService } from '../../shared/notifications/notifications.service';
|
||||||
|
@@ -0,0 +1,179 @@
|
|||||||
|
import { Component, Inject, Injectable, OnInit } from '@angular/core';
|
||||||
|
import { FieldUpdate, FieldUpdates } from '../../../core/data/object-updates/object-updates.reducer';
|
||||||
|
import { Observable } from 'rxjs/internal/Observable';
|
||||||
|
import { Item } from '../../../core/shared/item.model';
|
||||||
|
import { ItemDataService } from '../../../core/data/item-data.service';
|
||||||
|
import { ObjectUpdatesService } from '../../../core/data/object-updates/object-updates.service';
|
||||||
|
import { ActivatedRoute, Router } from '@angular/router';
|
||||||
|
import { NotificationsService } from '../../../shared/notifications/notifications.service';
|
||||||
|
import { TranslateService } from '@ngx-translate/core';
|
||||||
|
import { GLOBAL_CONFIG, GlobalConfig } from '../../../../config';
|
||||||
|
import { first, map } from 'rxjs/operators';
|
||||||
|
import { RemoteData } from '../../../core/data/remote-data';
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
/**
|
||||||
|
* Abstract component for managing object updates of an item
|
||||||
|
*/
|
||||||
|
export abstract class AbstractItemUpdateComponent implements OnInit {
|
||||||
|
/**
|
||||||
|
* The item to display the edit page for
|
||||||
|
*/
|
||||||
|
item: Item;
|
||||||
|
/**
|
||||||
|
* The current values and updates for all this item's fields
|
||||||
|
* Should be initialized in the initializeUpdates method of the child component
|
||||||
|
*/
|
||||||
|
updates$: Observable<FieldUpdates>;
|
||||||
|
/**
|
||||||
|
* The current url of this page
|
||||||
|
*/
|
||||||
|
url: string;
|
||||||
|
/**
|
||||||
|
* Prefix for this component's notification translate keys
|
||||||
|
* Should be initialized in the initializeNotificationsPrefix method of the child component
|
||||||
|
*/
|
||||||
|
notificationsPrefix;
|
||||||
|
/**
|
||||||
|
* The time span for being able to undo discarding changes
|
||||||
|
*/
|
||||||
|
discardTimeOut: number;
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
protected itemService: ItemDataService,
|
||||||
|
protected objectUpdatesService: ObjectUpdatesService,
|
||||||
|
protected router: Router,
|
||||||
|
protected notificationsService: NotificationsService,
|
||||||
|
protected translateService: TranslateService,
|
||||||
|
@Inject(GLOBAL_CONFIG) protected EnvConfig: GlobalConfig,
|
||||||
|
protected route: ActivatedRoute
|
||||||
|
) {
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Initialize common properties between item-update components
|
||||||
|
*/
|
||||||
|
ngOnInit(): void {
|
||||||
|
this.route.parent.data.pipe(map((data) => data.item))
|
||||||
|
.pipe(
|
||||||
|
first(),
|
||||||
|
map((data: RemoteData<Item>) => data.payload)
|
||||||
|
).subscribe((item: Item) => {
|
||||||
|
this.item = item;
|
||||||
|
});
|
||||||
|
|
||||||
|
this.discardTimeOut = this.EnvConfig.item.edit.undoTimeout;
|
||||||
|
this.url = this.router.url;
|
||||||
|
if (this.url.indexOf('?') > 0) {
|
||||||
|
this.url = this.url.substr(0, this.url.indexOf('?'));
|
||||||
|
}
|
||||||
|
this.hasChanges().pipe(first()).subscribe((hasChanges) => {
|
||||||
|
if (!hasChanges) {
|
||||||
|
this.initializeOriginalFields();
|
||||||
|
} else {
|
||||||
|
this.checkLastModified();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
this.initializeNotificationsPrefix();
|
||||||
|
this.initializeUpdates();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Initialize the values and updates of the current item's fields
|
||||||
|
*/
|
||||||
|
abstract initializeUpdates(): void;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Initialize the prefix for notification messages
|
||||||
|
*/
|
||||||
|
abstract initializeNotificationsPrefix(): void;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sends all initial values of this item to the object updates service
|
||||||
|
*/
|
||||||
|
abstract initializeOriginalFields(): void;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Prevent unnecessary rerendering so fields don't lose focus
|
||||||
|
*/
|
||||||
|
trackUpdate(index, update: FieldUpdate) {
|
||||||
|
return update && update.field ? update.field.uuid : undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Checks whether or not there are currently updates for this item
|
||||||
|
*/
|
||||||
|
hasChanges(): Observable<boolean> {
|
||||||
|
return this.objectUpdatesService.hasUpdates(this.url);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if the current page is entirely valid
|
||||||
|
*/
|
||||||
|
protected isValid() {
|
||||||
|
return this.objectUpdatesService.isValidPage(this.url);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Checks if the current item is still in sync with the version in the store
|
||||||
|
* If it's not, a notification is shown and the changes are removed
|
||||||
|
*/
|
||||||
|
private checkLastModified() {
|
||||||
|
const currentVersion = this.item.lastModified;
|
||||||
|
this.objectUpdatesService.getLastModified(this.url).pipe(first()).subscribe(
|
||||||
|
(updateVersion: Date) => {
|
||||||
|
if (updateVersion.getDate() !== currentVersion.getDate()) {
|
||||||
|
this.notificationsService.warning(this.getNotificationTitle('outdated'), this.getNotificationContent('outdated'));
|
||||||
|
this.initializeOriginalFields();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Submit the current changes
|
||||||
|
*/
|
||||||
|
abstract submit(): void;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Request the object updates service to discard all current changes to this item
|
||||||
|
* Shows a notification to remind the user that they can undo this
|
||||||
|
*/
|
||||||
|
discard() {
|
||||||
|
const undoNotification = this.notificationsService.info(this.getNotificationTitle('discarded'), this.getNotificationContent('discarded'), { timeOut: this.discardTimeOut });
|
||||||
|
this.objectUpdatesService.discardFieldUpdates(this.url, undoNotification);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Request the object updates service to undo discarding all changes to this item
|
||||||
|
*/
|
||||||
|
reinstate() {
|
||||||
|
this.objectUpdatesService.reinstateFieldUpdates(this.url);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Checks whether or not the item is currently reinstatable
|
||||||
|
*/
|
||||||
|
isReinstatable(): Observable<boolean> {
|
||||||
|
return this.objectUpdatesService.isReinstatable(this.url);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get translated notification title
|
||||||
|
* @param key
|
||||||
|
*/
|
||||||
|
protected getNotificationTitle(key: string) {
|
||||||
|
return this.translateService.instant(this.notificationsPrefix + key + '.title');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get translated notification content
|
||||||
|
* @param key
|
||||||
|
*/
|
||||||
|
protected getNotificationContent(key: string) {
|
||||||
|
return this.translateService.instant(this.notificationsPrefix + key + '.content');
|
||||||
|
|
||||||
|
}
|
||||||
|
}
|
@@ -15,6 +15,9 @@ import { ItemDeleteComponent } from './item-delete/item-delete.component';
|
|||||||
import { ItemMetadataComponent } from './item-metadata/item-metadata.component';
|
import { ItemMetadataComponent } from './item-metadata/item-metadata.component';
|
||||||
import { EditInPlaceFieldComponent } from './item-metadata/edit-in-place-field/edit-in-place-field.component';
|
import { EditInPlaceFieldComponent } from './item-metadata/edit-in-place-field/edit-in-place-field.component';
|
||||||
import { ItemBitstreamsComponent } from './item-bitstreams/item-bitstreams.component';
|
import { ItemBitstreamsComponent } from './item-bitstreams/item-bitstreams.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';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Module that contains all components related to the Edit Item page administrator functionality
|
* Module that contains all components related to the Edit Item page administrator functionality
|
||||||
@@ -37,8 +40,11 @@ import { ItemBitstreamsComponent } from './item-bitstreams/item-bitstreams.compo
|
|||||||
ItemDeleteComponent,
|
ItemDeleteComponent,
|
||||||
ItemStatusComponent,
|
ItemStatusComponent,
|
||||||
ItemMetadataComponent,
|
ItemMetadataComponent,
|
||||||
|
ItemRelationshipsComponent,
|
||||||
ItemBitstreamsComponent,
|
ItemBitstreamsComponent,
|
||||||
EditInPlaceFieldComponent
|
EditInPlaceFieldComponent,
|
||||||
|
EditRelationshipComponent,
|
||||||
|
EditRelationshipListComponent
|
||||||
]
|
]
|
||||||
})
|
})
|
||||||
export class EditItemPageModule {
|
export class EditItemPageModule {
|
||||||
|
@@ -10,6 +10,7 @@ import { ItemDeleteComponent } from './item-delete/item-delete.component';
|
|||||||
import { ItemStatusComponent } from './item-status/item-status.component';
|
import { ItemStatusComponent } from './item-status/item-status.component';
|
||||||
import { ItemMetadataComponent } from './item-metadata/item-metadata.component';
|
import { ItemMetadataComponent } from './item-metadata/item-metadata.component';
|
||||||
import { ItemBitstreamsComponent } from './item-bitstreams/item-bitstreams.component';
|
import { ItemBitstreamsComponent } from './item-bitstreams/item-bitstreams.component';
|
||||||
|
import { ItemRelationshipsComponent } from './item-relationships/item-relationships.component';
|
||||||
|
|
||||||
const ITEM_EDIT_WITHDRAW_PATH = 'withdraw';
|
const ITEM_EDIT_WITHDRAW_PATH = 'withdraw';
|
||||||
const ITEM_EDIT_REINSTATE_PATH = 'reinstate';
|
const ITEM_EDIT_REINSTATE_PATH = 'reinstate';
|
||||||
@@ -50,6 +51,11 @@ const ITEM_EDIT_DELETE_PATH = 'delete';
|
|||||||
component: ItemMetadataComponent,
|
component: ItemMetadataComponent,
|
||||||
data: { title: 'item.edit.tabs.metadata.title' }
|
data: { title: 'item.edit.tabs.metadata.title' }
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
path: 'relationships',
|
||||||
|
component: ItemRelationshipsComponent,
|
||||||
|
data: { title: 'item.edit.tabs.relationships.title' }
|
||||||
|
},
|
||||||
{
|
{
|
||||||
path: 'view',
|
path: 'view',
|
||||||
/* TODO - change when view page exists */
|
/* TODO - change when view page exists */
|
||||||
|
@@ -31,7 +31,7 @@ import {
|
|||||||
createSuccessfulRemoteDataObject$
|
createSuccessfulRemoteDataObject$
|
||||||
} from '../../../shared/testing/utils';
|
} from '../../../shared/testing/utils';
|
||||||
|
|
||||||
let comp: ItemMetadataComponent;
|
let comp: any;
|
||||||
let fixture: ComponentFixture<ItemMetadataComponent>;
|
let fixture: ComponentFixture<ItemMetadataComponent>;
|
||||||
let de: DebugElement;
|
let de: DebugElement;
|
||||||
let el: HTMLElement;
|
let el: HTMLElement;
|
||||||
|
@@ -1,4 +1,4 @@
|
|||||||
import { Component, Inject, Input, OnInit } from '@angular/core';
|
import { Component, Inject } from '@angular/core';
|
||||||
import { Item } from '../../../core/shared/item.model';
|
import { Item } from '../../../core/shared/item.model';
|
||||||
import { ItemDataService } from '../../../core/data/item-data.service';
|
import { ItemDataService } from '../../../core/data/item-data.service';
|
||||||
import { ObjectUpdatesService } from '../../../core/data/object-updates/object-updates.service';
|
import { ObjectUpdatesService } from '../../../core/data/object-updates/object-updates.service';
|
||||||
@@ -6,8 +6,6 @@ import { ActivatedRoute, Router } from '@angular/router';
|
|||||||
import { cloneDeep } from 'lodash';
|
import { cloneDeep } from 'lodash';
|
||||||
import { Observable } from 'rxjs';
|
import { Observable } from 'rxjs';
|
||||||
import {
|
import {
|
||||||
FieldUpdate,
|
|
||||||
FieldUpdates,
|
|
||||||
Identifiable
|
Identifiable
|
||||||
} from '../../../core/data/object-updates/object-updates.reducer';
|
} from '../../../core/data/object-updates/object-updates.reducer';
|
||||||
import { first, map, switchMap, take, tap } from 'rxjs/operators';
|
import { first, map, switchMap, take, tap } from 'rxjs/operators';
|
||||||
@@ -19,6 +17,7 @@ import { TranslateService } from '@ngx-translate/core';
|
|||||||
import { RegistryService } from '../../../core/registry/registry.service';
|
import { RegistryService } from '../../../core/registry/registry.service';
|
||||||
import { MetadatumViewModel } from '../../../core/shared/metadata.models';
|
import { MetadatumViewModel } from '../../../core/shared/metadata.models';
|
||||||
import { Metadata } from '../../../core/shared/metadata.utils';
|
import { Metadata } from '../../../core/shared/metadata.utils';
|
||||||
|
import { AbstractItemUpdateComponent } from '../abstract-item-update/abstract-item-update.component';
|
||||||
import { MetadataField } from '../../../core/metadata/metadata-field.model';
|
import { MetadataField } from '../../../core/metadata/metadata-field.model';
|
||||||
|
|
||||||
@Component({
|
@Component({
|
||||||
@@ -29,28 +28,7 @@ import { MetadataField } from '../../../core/metadata/metadata-field.model';
|
|||||||
/**
|
/**
|
||||||
* Component for displaying an item's metadata edit page
|
* Component for displaying an item's metadata edit page
|
||||||
*/
|
*/
|
||||||
export class ItemMetadataComponent implements OnInit {
|
export class ItemMetadataComponent extends AbstractItemUpdateComponent {
|
||||||
|
|
||||||
/**
|
|
||||||
* The item to display the edit page for
|
|
||||||
*/
|
|
||||||
item: Item;
|
|
||||||
/**
|
|
||||||
* The current values and updates for all this item's metadata fields
|
|
||||||
*/
|
|
||||||
updates$: Observable<FieldUpdates>;
|
|
||||||
/**
|
|
||||||
* The current url of this page
|
|
||||||
*/
|
|
||||||
url: string;
|
|
||||||
/**
|
|
||||||
* The time span for being able to undo discarding changes
|
|
||||||
*/
|
|
||||||
private discardTimeOut: number;
|
|
||||||
/**
|
|
||||||
* Prefix for this component's notification translate keys
|
|
||||||
*/
|
|
||||||
private notificationsPrefix = 'item.edit.metadata.notifications.';
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Observable with a list of strings with all existing metadata field keys
|
* Observable with a list of strings with all existing metadata field keys
|
||||||
@@ -58,44 +36,38 @@ export class ItemMetadataComponent implements OnInit {
|
|||||||
metadataFields$: Observable<string[]>;
|
metadataFields$: Observable<string[]>;
|
||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
private itemService: ItemDataService,
|
protected itemService: ItemDataService,
|
||||||
private objectUpdatesService: ObjectUpdatesService,
|
protected objectUpdatesService: ObjectUpdatesService,
|
||||||
private router: Router,
|
protected router: Router,
|
||||||
private notificationsService: NotificationsService,
|
protected notificationsService: NotificationsService,
|
||||||
private translateService: TranslateService,
|
protected translateService: TranslateService,
|
||||||
@Inject(GLOBAL_CONFIG) protected EnvConfig: GlobalConfig,
|
@Inject(GLOBAL_CONFIG) protected EnvConfig: GlobalConfig,
|
||||||
private route: ActivatedRoute,
|
protected route: ActivatedRoute,
|
||||||
private metadataFieldService: RegistryService,
|
protected metadataFieldService: RegistryService,
|
||||||
) {
|
) {
|
||||||
|
super(itemService, objectUpdatesService, router, notificationsService, translateService, EnvConfig, route);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Set up and initialize all fields
|
* Set up and initialize all fields
|
||||||
*/
|
*/
|
||||||
ngOnInit(): void {
|
ngOnInit(): void {
|
||||||
|
super.ngOnInit();
|
||||||
this.metadataFields$ = this.findMetadataFields();
|
this.metadataFields$ = this.findMetadataFields();
|
||||||
this.route.parent.data.pipe(map((data) => data.item))
|
}
|
||||||
.pipe(
|
|
||||||
first(),
|
|
||||||
map((data: RemoteData<Item>) => data.payload)
|
|
||||||
).subscribe((item: Item) => {
|
|
||||||
this.item = item;
|
|
||||||
});
|
|
||||||
|
|
||||||
this.discardTimeOut = this.EnvConfig.item.edit.undoTimeout;
|
/**
|
||||||
this.url = this.router.url;
|
* Initialize the values and updates of the current item's metadata fields
|
||||||
if (this.url.indexOf('?') > 0) {
|
*/
|
||||||
this.url = this.url.substr(0, this.url.indexOf('?'));
|
public initializeUpdates(): void {
|
||||||
}
|
this.updates$ = this.objectUpdatesService.getFieldUpdates(this.url, this.getMetadataAsListExcludingRelationships());
|
||||||
this.hasChanges().pipe(first()).subscribe((hasChanges) => {
|
}
|
||||||
if (!hasChanges) {
|
|
||||||
this.initializeOriginalFields();
|
/**
|
||||||
} else {
|
* Initialize the prefix for notification messages
|
||||||
this.checkLastModified();
|
*/
|
||||||
}
|
public initializeNotificationsPrefix(): void {
|
||||||
});
|
this.notificationsPrefix = 'item.edit.metadata.notifications.';
|
||||||
this.updates$ = this.objectUpdatesService.getFieldUpdates(this.url, this.item.metadataAsList);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -104,47 +76,23 @@ export class ItemMetadataComponent implements OnInit {
|
|||||||
*/
|
*/
|
||||||
add(metadata: MetadatumViewModel = new MetadatumViewModel()) {
|
add(metadata: MetadatumViewModel = new MetadatumViewModel()) {
|
||||||
this.objectUpdatesService.saveAddFieldUpdate(this.url, metadata);
|
this.objectUpdatesService.saveAddFieldUpdate(this.url, metadata);
|
||||||
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Request the object updates service to discard all current changes to this item
|
|
||||||
* Shows a notification to remind the user that they can undo this
|
|
||||||
*/
|
|
||||||
discard() {
|
|
||||||
const undoNotification = this.notificationsService.info(this.getNotificationTitle('discarded'), this.getNotificationContent('discarded'), { timeOut: this.discardTimeOut });
|
|
||||||
this.objectUpdatesService.discardFieldUpdates(this.url, undoNotification);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Request the object updates service to undo discarding all changes to this item
|
|
||||||
*/
|
|
||||||
reinstate() {
|
|
||||||
this.objectUpdatesService.reinstateFieldUpdates(this.url);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Sends all initial values of this item to the object updates service
|
* Sends all initial values of this item to the object updates service
|
||||||
*/
|
*/
|
||||||
private initializeOriginalFields() {
|
public initializeOriginalFields() {
|
||||||
this.objectUpdatesService.initialize(this.url, this.item.metadataAsList, this.item.lastModified);
|
this.objectUpdatesService.initialize(this.url, this.getMetadataAsListExcludingRelationships(), this.item.lastModified);
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Prevent unnecessary rerendering so fields don't lose focus
|
|
||||||
*/
|
|
||||||
trackUpdate(index, update: FieldUpdate) {
|
|
||||||
return update && update.field ? update.field.uuid : undefined;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Requests all current metadata for this item and requests the item service to update the item
|
* Requests all current metadata for this item and requests the item service to update the item
|
||||||
* Makes sure the new version of the item is rendered on the page
|
* Makes sure the new version of the item is rendered on the page
|
||||||
*/
|
*/
|
||||||
submit() {
|
public submit() {
|
||||||
this.isValid().pipe(first()).subscribe((isValid) => {
|
this.isValid().pipe(first()).subscribe((isValid) => {
|
||||||
if (isValid) {
|
if (isValid) {
|
||||||
const metadata$: Observable<Identifiable[]> = this.objectUpdatesService.getUpdatedFields(this.url, this.item.metadataAsList) as Observable<MetadatumViewModel[]>;
|
const metadata$: Observable<Identifiable[]> = this.objectUpdatesService.getUpdatedFields(this.url, this.getMetadataAsListExcludingRelationships()) as Observable<MetadatumViewModel[]>;
|
||||||
metadata$.pipe(
|
metadata$.pipe(
|
||||||
first(),
|
first(),
|
||||||
switchMap((metadata: MetadatumViewModel[]) => {
|
switchMap((metadata: MetadatumViewModel[]) => {
|
||||||
@@ -157,7 +105,7 @@ export class ItemMetadataComponent implements OnInit {
|
|||||||
(rd: RemoteData<Item>) => {
|
(rd: RemoteData<Item>) => {
|
||||||
this.item = rd.payload;
|
this.item = rd.payload;
|
||||||
this.initializeOriginalFields();
|
this.initializeOriginalFields();
|
||||||
this.updates$ = this.objectUpdatesService.getFieldUpdates(this.url, this.item.metadataAsList);
|
this.updates$ = this.objectUpdatesService.getFieldUpdates(this.url, this.getMetadataAsListExcludingRelationships());
|
||||||
this.notificationsService.success(this.getNotificationTitle('saved'), this.getNotificationContent('saved'));
|
this.notificationsService.success(this.getNotificationTitle('saved'), this.getNotificationContent('saved'));
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
@@ -167,60 +115,6 @@ export class ItemMetadataComponent implements OnInit {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Checks whether or not there are currently updates for this item
|
|
||||||
*/
|
|
||||||
hasChanges(): Observable<boolean> {
|
|
||||||
return this.objectUpdatesService.hasUpdates(this.url);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Checks whether or not the item is currently reinstatable
|
|
||||||
*/
|
|
||||||
isReinstatable(): Observable<boolean> {
|
|
||||||
return this.objectUpdatesService.isReinstatable(this.url);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Checks if the current item is still in sync with the version in the store
|
|
||||||
* If it's not, a notification is shown and the changes are removed
|
|
||||||
*/
|
|
||||||
private checkLastModified() {
|
|
||||||
const currentVersion = this.item.lastModified;
|
|
||||||
this.objectUpdatesService.getLastModified(this.url).pipe(first()).subscribe(
|
|
||||||
(updateVersion: Date) => {
|
|
||||||
if (updateVersion.getDate() !== currentVersion.getDate()) {
|
|
||||||
this.notificationsService.warning(this.getNotificationTitle('outdated'), this.getNotificationContent('outdated'));
|
|
||||||
this.initializeOriginalFields();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Check if the current page is entirely valid
|
|
||||||
*/
|
|
||||||
private isValid() {
|
|
||||||
return this.objectUpdatesService.isValidPage(this.url);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get translated notification title
|
|
||||||
* @param key
|
|
||||||
*/
|
|
||||||
private getNotificationTitle(key: string) {
|
|
||||||
return this.translateService.instant(this.notificationsPrefix + key + '.title');
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get translated notification content
|
|
||||||
* @param key
|
|
||||||
*/
|
|
||||||
private getNotificationContent(key: string) {
|
|
||||||
return this.translateService.instant(this.notificationsPrefix + key + '.content');
|
|
||||||
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Method to request all metadata fields and convert them to a list of strings
|
* Method to request all metadata fields and convert them to a list of strings
|
||||||
*/
|
*/
|
||||||
@@ -230,4 +124,8 @@ export class ItemMetadataComponent implements OnInit {
|
|||||||
take(1),
|
take(1),
|
||||||
map((remoteData$) => remoteData$.payload.page.map((field: MetadataField) => field.toString())));
|
map((remoteData$) => remoteData$.payload.page.map((field: MetadataField) => field.toString())));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
getMetadataAsListExcludingRelationships(): MetadatumViewModel[] {
|
||||||
|
return this.item.metadataAsList.filter((metadata: MetadatumViewModel) => !metadata.key.startsWith('relation.') && !metadata.key.startsWith('relationship.'));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
@@ -0,0 +1,15 @@
|
|||||||
|
<ng-container *ngVar="(updates$ | async) as updates">
|
||||||
|
<div *ngIf="updates">
|
||||||
|
<h5>{{getRelationshipMessageKey(relationshipLabel) | translate}}</h5>
|
||||||
|
<ng-container *ngVar="(updates | dsObjectValues) as updateValues">
|
||||||
|
<div *ngFor="let updateValue of updateValues; trackBy: trackUpdate"
|
||||||
|
ds-edit-relationship
|
||||||
|
class="relationship-row d-block"
|
||||||
|
[fieldUpdate]="updateValue || {}"
|
||||||
|
[url]="url"
|
||||||
|
[ngClass]="{'alert alert-danger': updateValue.changeType === 2}">
|
||||||
|
</div>
|
||||||
|
<ds-loading *ngIf="updateValues.length == 0" message="{{'loading.items' | translate}}"></ds-loading>
|
||||||
|
</ng-container>
|
||||||
|
</div>
|
||||||
|
</ng-container>
|
@@ -0,0 +1,12 @@
|
|||||||
|
@import '../../../../../styles/variables.scss';
|
||||||
|
|
||||||
|
.relationship-row:not(.alert-danger) {
|
||||||
|
padding: $alert-padding-y 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.relationship-row.alert-danger {
|
||||||
|
margin-left: -$alert-padding-x;
|
||||||
|
margin-right: -$alert-padding-x;
|
||||||
|
margin-top: -1px;
|
||||||
|
margin-bottom: -1px;
|
||||||
|
}
|
@@ -0,0 +1,136 @@
|
|||||||
|
import { EditRelationshipListComponent } from './edit-relationship-list.component';
|
||||||
|
import { async, ComponentFixture, TestBed } from '@angular/core/testing';
|
||||||
|
import { RelationshipType } from '../../../../core/shared/item-relationships/relationship-type.model';
|
||||||
|
import { ResourceType } from '../../../../core/shared/resource-type';
|
||||||
|
import { Relationship } from '../../../../core/shared/item-relationships/relationship.model';
|
||||||
|
import { of as observableOf } from 'rxjs/internal/observable/of';
|
||||||
|
import { RemoteData } from '../../../../core/data/remote-data';
|
||||||
|
import { Item } from '../../../../core/shared/item.model';
|
||||||
|
import { PaginatedList } from '../../../../core/data/paginated-list';
|
||||||
|
import { PageInfo } from '../../../../core/shared/page-info.model';
|
||||||
|
import { FieldChangeType } from '../../../../core/data/object-updates/object-updates.actions';
|
||||||
|
import { SharedModule } from '../../../../shared/shared.module';
|
||||||
|
import { TranslateModule } from '@ngx-translate/core';
|
||||||
|
import { ObjectUpdatesService } from '../../../../core/data/object-updates/object-updates.service';
|
||||||
|
import { RelationshipService } from '../../../../core/data/relationship.service';
|
||||||
|
import { DebugElement, NO_ERRORS_SCHEMA } from '@angular/core';
|
||||||
|
import { By } from '@angular/platform-browser';
|
||||||
|
|
||||||
|
let comp: EditRelationshipListComponent;
|
||||||
|
let fixture: ComponentFixture<EditRelationshipListComponent>;
|
||||||
|
let de: DebugElement;
|
||||||
|
|
||||||
|
let objectUpdatesService;
|
||||||
|
let relationshipService;
|
||||||
|
|
||||||
|
const url = 'http://test-url.com/test-url';
|
||||||
|
|
||||||
|
let item;
|
||||||
|
let author1;
|
||||||
|
let author2;
|
||||||
|
let fieldUpdate1;
|
||||||
|
let fieldUpdate2;
|
||||||
|
let relationships;
|
||||||
|
let relationshipType;
|
||||||
|
|
||||||
|
describe('EditRelationshipListComponent', () => {
|
||||||
|
beforeEach(async(() => {
|
||||||
|
relationshipType = Object.assign(new RelationshipType(), {
|
||||||
|
id: '1',
|
||||||
|
uuid: '1',
|
||||||
|
leftLabel: 'isAuthorOfPublication',
|
||||||
|
rightLabel: 'isPublicationOfAuthor'
|
||||||
|
});
|
||||||
|
|
||||||
|
relationships = [
|
||||||
|
Object.assign(new Relationship(), {
|
||||||
|
self: url + '/2',
|
||||||
|
id: '2',
|
||||||
|
uuid: '2',
|
||||||
|
leftId: 'author1',
|
||||||
|
rightId: 'publication',
|
||||||
|
relationshipType: observableOf(new RemoteData(false, false, true, undefined, relationshipType))
|
||||||
|
}),
|
||||||
|
Object.assign(new Relationship(), {
|
||||||
|
self: url + '/3',
|
||||||
|
id: '3',
|
||||||
|
uuid: '3',
|
||||||
|
leftId: 'author2',
|
||||||
|
rightId: 'publication',
|
||||||
|
relationshipType: observableOf(new RemoteData(false, false, true, undefined, relationshipType))
|
||||||
|
})
|
||||||
|
];
|
||||||
|
|
||||||
|
item = Object.assign(new Item(), {
|
||||||
|
self: 'fake-item-url/publication',
|
||||||
|
id: 'publication',
|
||||||
|
uuid: 'publication',
|
||||||
|
relationships: observableOf(new RemoteData(false, false, true, undefined, new PaginatedList(new PageInfo(), relationships)))
|
||||||
|
});
|
||||||
|
|
||||||
|
author1 = Object.assign(new Item(), {
|
||||||
|
id: 'author1',
|
||||||
|
uuid: 'author1'
|
||||||
|
});
|
||||||
|
author2 = Object.assign(new Item(), {
|
||||||
|
id: 'author2',
|
||||||
|
uuid: 'author2'
|
||||||
|
});
|
||||||
|
|
||||||
|
fieldUpdate1 = {
|
||||||
|
field: author1,
|
||||||
|
changeType: undefined
|
||||||
|
};
|
||||||
|
fieldUpdate2 = {
|
||||||
|
field: author2,
|
||||||
|
changeType: FieldChangeType.REMOVE
|
||||||
|
};
|
||||||
|
|
||||||
|
objectUpdatesService = jasmine.createSpyObj('objectUpdatesService',
|
||||||
|
{
|
||||||
|
getFieldUpdatesExclusive: observableOf({
|
||||||
|
[author1.uuid]: fieldUpdate1,
|
||||||
|
[author2.uuid]: fieldUpdate2
|
||||||
|
})
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
relationshipService = jasmine.createSpyObj('relationshipService',
|
||||||
|
{
|
||||||
|
getRelatedItemsByLabel: observableOf([author1, author2]),
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
TestBed.configureTestingModule({
|
||||||
|
imports: [SharedModule, TranslateModule.forRoot()],
|
||||||
|
declarations: [EditRelationshipListComponent],
|
||||||
|
providers: [
|
||||||
|
{ provide: ObjectUpdatesService, useValue: objectUpdatesService },
|
||||||
|
{ provide: RelationshipService, useValue: relationshipService }
|
||||||
|
], schemas: [
|
||||||
|
NO_ERRORS_SCHEMA
|
||||||
|
]
|
||||||
|
}).compileComponents();
|
||||||
|
}));
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
fixture = TestBed.createComponent(EditRelationshipListComponent);
|
||||||
|
comp = fixture.componentInstance;
|
||||||
|
de = fixture.debugElement;
|
||||||
|
comp.item = item;
|
||||||
|
comp.url = url;
|
||||||
|
comp.relationshipLabel = relationshipType.leftLabel;
|
||||||
|
fixture.detectChanges();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('changeType is REMOVE', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
fieldUpdate1.changeType = FieldChangeType.REMOVE;
|
||||||
|
fixture.detectChanges();
|
||||||
|
});
|
||||||
|
it('the div should have class alert-danger', () => {
|
||||||
|
const element = de.queryAll(By.css('.relationship-row'))[1].nativeElement;
|
||||||
|
expect(element.classList).toContain('alert-danger');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
@@ -0,0 +1,99 @@
|
|||||||
|
import { Component, Input, OnChanges, OnInit, SimpleChanges } from '@angular/core';
|
||||||
|
import { ObjectUpdatesService } from '../../../../core/data/object-updates/object-updates.service';
|
||||||
|
import { Observable } from 'rxjs/internal/Observable';
|
||||||
|
import { FieldUpdate, FieldUpdates } from '../../../../core/data/object-updates/object-updates.reducer';
|
||||||
|
import { RelationshipService } from '../../../../core/data/relationship.service';
|
||||||
|
import { Item } from '../../../../core/shared/item.model';
|
||||||
|
import { switchMap } from 'rxjs/operators';
|
||||||
|
import { hasValue } from '../../../../shared/empty.util';
|
||||||
|
|
||||||
|
@Component({
|
||||||
|
selector: 'ds-edit-relationship-list',
|
||||||
|
styleUrls: ['./edit-relationship-list.component.scss'],
|
||||||
|
templateUrl: './edit-relationship-list.component.html',
|
||||||
|
})
|
||||||
|
/**
|
||||||
|
* A component creating a list of editable relationships of a certain type
|
||||||
|
* The relationships are rendered as a list of related items
|
||||||
|
*/
|
||||||
|
export class EditRelationshipListComponent implements OnInit, OnChanges {
|
||||||
|
/**
|
||||||
|
* The item to display related items for
|
||||||
|
*/
|
||||||
|
@Input() item: Item;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The URL to the current page
|
||||||
|
* Used to fetch updates for the current item from the store
|
||||||
|
*/
|
||||||
|
@Input() url: string;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The label of the relationship-type we're rendering a list for
|
||||||
|
*/
|
||||||
|
@Input() relationshipLabel: string;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The FieldUpdates for the relationships in question
|
||||||
|
*/
|
||||||
|
updates$: Observable<FieldUpdates>;
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
protected objectUpdatesService: ObjectUpdatesService,
|
||||||
|
protected relationshipService: RelationshipService
|
||||||
|
) {
|
||||||
|
}
|
||||||
|
|
||||||
|
ngOnInit(): void {
|
||||||
|
this.initUpdates();
|
||||||
|
}
|
||||||
|
|
||||||
|
ngOnChanges(changes: SimpleChanges): void {
|
||||||
|
this.initUpdates();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Initialize the FieldUpdates using the related items
|
||||||
|
*/
|
||||||
|
initUpdates() {
|
||||||
|
this.updates$ = this.getUpdatesByLabel(this.relationshipLabel);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Transform the item's relationships of a specific type into related items
|
||||||
|
* @param label The relationship type's label
|
||||||
|
*/
|
||||||
|
public getRelatedItemsByLabel(label: string): Observable<Item[]> {
|
||||||
|
return this.relationshipService.getRelatedItemsByLabel(this.item, label);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get FieldUpdates for the relationships of a specific type
|
||||||
|
* @param label The relationship type's label
|
||||||
|
*/
|
||||||
|
public getUpdatesByLabel(label: string): Observable<FieldUpdates> {
|
||||||
|
return this.getRelatedItemsByLabel(label).pipe(
|
||||||
|
switchMap((items: Item[]) => this.objectUpdatesService.getFieldUpdatesExclusive(this.url, items))
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the i18n message key for a relationship
|
||||||
|
* @param label The relationship type's label
|
||||||
|
*/
|
||||||
|
public getRelationshipMessageKey(label: string): string {
|
||||||
|
if (hasValue(label) && label.indexOf('Of') > -1) {
|
||||||
|
return `relationships.${label.substring(0, label.indexOf('Of') + 2)}`
|
||||||
|
} else {
|
||||||
|
return label;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Prevent unnecessary rerendering so fields don't lose focus
|
||||||
|
*/
|
||||||
|
trackUpdate(index, update: FieldUpdate) {
|
||||||
|
return update && update.field ? update.field.uuid : undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
@@ -0,0 +1,19 @@
|
|||||||
|
<div class="row" *ngIf="item">
|
||||||
|
<div class="col-10 relationship">
|
||||||
|
<ds-item-type-switcher [object]="item" [viewMode]="viewMode"></ds-item-type-switcher>
|
||||||
|
</div>
|
||||||
|
<div class="col-2">
|
||||||
|
<div class="btn-group relationship-action-buttons">
|
||||||
|
<button [disabled]="!canRemove()" (click)="remove()"
|
||||||
|
class="btn btn-outline-danger btn-sm"
|
||||||
|
title="{{'item.edit.metadata.edit.buttons.remove' | translate}}">
|
||||||
|
<i class="fas fa-trash-alt fa-fw"></i>
|
||||||
|
</button>
|
||||||
|
<button [disabled]="!canUndo()" (click)="undo()"
|
||||||
|
class="btn btn-outline-warning btn-sm"
|
||||||
|
title="{{'item.edit.metadata.edit.buttons.undo' | translate}}">
|
||||||
|
<i class="fas fa-undo-alt fa-fw"></i>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
@@ -0,0 +1,15 @@
|
|||||||
|
@import '../../../../../styles/variables.scss';
|
||||||
|
|
||||||
|
.btn[disabled] {
|
||||||
|
color: $gray-600;
|
||||||
|
border-color: $gray-600;
|
||||||
|
z-index: 0; // prevent border colors jumping on hover
|
||||||
|
}
|
||||||
|
|
||||||
|
.relationship-action-buttons {
|
||||||
|
margin: 0;
|
||||||
|
position: absolute;
|
||||||
|
top: 50%;
|
||||||
|
left: 50%;
|
||||||
|
transform: translate(-50%, -50%);
|
||||||
|
}
|
@@ -0,0 +1,179 @@
|
|||||||
|
import { async, TestBed } from '@angular/core/testing';
|
||||||
|
import { of as observableOf } from 'rxjs/internal/observable/of';
|
||||||
|
import { TranslateModule } from '@ngx-translate/core';
|
||||||
|
import { ObjectUpdatesService } from '../../../../core/data/object-updates/object-updates.service';
|
||||||
|
import { NO_ERRORS_SCHEMA } from '@angular/core';
|
||||||
|
import { EditRelationshipComponent } from './edit-relationship.component';
|
||||||
|
import { RelationshipType } from '../../../../core/shared/item-relationships/relationship-type.model';
|
||||||
|
import { ResourceType } from '../../../../core/shared/resource-type';
|
||||||
|
import { Relationship } from '../../../../core/shared/item-relationships/relationship.model';
|
||||||
|
import { RemoteData } from '../../../../core/data/remote-data';
|
||||||
|
import { Item } from '../../../../core/shared/item.model';
|
||||||
|
import { PaginatedList } from '../../../../core/data/paginated-list';
|
||||||
|
import { PageInfo } from '../../../../core/shared/page-info.model';
|
||||||
|
import { FieldChangeType } from '../../../../core/data/object-updates/object-updates.actions';
|
||||||
|
|
||||||
|
let objectUpdatesService: ObjectUpdatesService;
|
||||||
|
const url = 'http://test-url.com/test-url';
|
||||||
|
|
||||||
|
let item;
|
||||||
|
let author1;
|
||||||
|
let author2;
|
||||||
|
let fieldUpdate1;
|
||||||
|
let fieldUpdate2;
|
||||||
|
let relationships;
|
||||||
|
let relationshipType;
|
||||||
|
|
||||||
|
let fixture;
|
||||||
|
let comp: EditRelationshipComponent;
|
||||||
|
let de;
|
||||||
|
let el;
|
||||||
|
|
||||||
|
describe('EditRelationshipComponent', () => {
|
||||||
|
beforeEach(async(() => {
|
||||||
|
relationshipType = Object.assign(new RelationshipType(), {
|
||||||
|
id: '1',
|
||||||
|
uuid: '1',
|
||||||
|
leftLabel: 'isAuthorOfPublication',
|
||||||
|
rightLabel: 'isPublicationOfAuthor'
|
||||||
|
});
|
||||||
|
|
||||||
|
relationships = [
|
||||||
|
Object.assign(new Relationship(), {
|
||||||
|
self: url + '/2',
|
||||||
|
id: '2',
|
||||||
|
uuid: '2',
|
||||||
|
leftId: 'author1',
|
||||||
|
rightId: 'publication',
|
||||||
|
relationshipType: observableOf(new RemoteData(false, false, true, undefined, relationshipType))
|
||||||
|
}),
|
||||||
|
Object.assign(new Relationship(), {
|
||||||
|
self: url + '/3',
|
||||||
|
id: '3',
|
||||||
|
uuid: '3',
|
||||||
|
leftId: 'author2',
|
||||||
|
rightId: 'publication',
|
||||||
|
relationshipType: observableOf(new RemoteData(false, false, true, undefined, relationshipType))
|
||||||
|
})
|
||||||
|
];
|
||||||
|
|
||||||
|
item = Object.assign(new Item(), {
|
||||||
|
self: 'fake-item-url/publication',
|
||||||
|
id: 'publication',
|
||||||
|
uuid: 'publication',
|
||||||
|
relationships: observableOf(new RemoteData(false, false, true, undefined, new PaginatedList(new PageInfo(), relationships)))
|
||||||
|
});
|
||||||
|
|
||||||
|
author1 = Object.assign(new Item(), {
|
||||||
|
id: 'author1',
|
||||||
|
uuid: 'author1'
|
||||||
|
});
|
||||||
|
author2 = Object.assign(new Item(), {
|
||||||
|
id: 'author2',
|
||||||
|
uuid: 'author2'
|
||||||
|
});
|
||||||
|
|
||||||
|
fieldUpdate1 = {
|
||||||
|
field: author1,
|
||||||
|
changeType: undefined
|
||||||
|
};
|
||||||
|
fieldUpdate2 = {
|
||||||
|
field: author2,
|
||||||
|
changeType: FieldChangeType.REMOVE
|
||||||
|
};
|
||||||
|
|
||||||
|
objectUpdatesService = jasmine.createSpyObj('objectUpdatesService',
|
||||||
|
{
|
||||||
|
saveChangeFieldUpdate: {},
|
||||||
|
saveRemoveFieldUpdate: {},
|
||||||
|
setEditableFieldUpdate: {},
|
||||||
|
setValidFieldUpdate: {},
|
||||||
|
removeSingleFieldUpdate: {},
|
||||||
|
isEditable: observableOf(false), // should always return something --> its in ngOnInit
|
||||||
|
isValid: observableOf(true) // should always return something --> its in ngOnInit
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
TestBed.configureTestingModule({
|
||||||
|
imports: [TranslateModule.forRoot()],
|
||||||
|
declarations: [EditRelationshipComponent],
|
||||||
|
providers: [
|
||||||
|
{ provide: ObjectUpdatesService, useValue: objectUpdatesService }
|
||||||
|
], schemas: [
|
||||||
|
NO_ERRORS_SCHEMA
|
||||||
|
]
|
||||||
|
}).compileComponents();
|
||||||
|
}));
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
fixture = TestBed.createComponent(EditRelationshipComponent);
|
||||||
|
comp = fixture.componentInstance;
|
||||||
|
de = fixture.debugElement;
|
||||||
|
el = de.nativeElement;
|
||||||
|
|
||||||
|
comp.url = url;
|
||||||
|
comp.fieldUpdate = fieldUpdate1;
|
||||||
|
comp.item = item;
|
||||||
|
|
||||||
|
fixture.detectChanges();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('when fieldUpdate has no changeType', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
comp.fieldUpdate = fieldUpdate1;
|
||||||
|
fixture.detectChanges();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('canRemove', () => {
|
||||||
|
it('should return true', () => {
|
||||||
|
expect(comp.canRemove()).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('canUndo', () => {
|
||||||
|
it('should return false', () => {
|
||||||
|
expect(comp.canUndo()).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('when fieldUpdate has DELETE as changeType', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
comp.fieldUpdate = fieldUpdate2;
|
||||||
|
fixture.detectChanges();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('canRemove', () => {
|
||||||
|
it('should return false', () => {
|
||||||
|
expect(comp.canRemove()).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('canUndo', () => {
|
||||||
|
it('should return true', () => {
|
||||||
|
expect(comp.canUndo()).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('remove', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
comp.remove();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should call saveRemoveFieldUpdate with the correct arguments', () => {
|
||||||
|
expect(objectUpdatesService.saveRemoveFieldUpdate).toHaveBeenCalledWith(url, item);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('undo', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
comp.undo();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should call removeSingleFieldUpdate with the correct arguments', () => {
|
||||||
|
expect(objectUpdatesService.removeSingleFieldUpdate).toHaveBeenCalledWith(url, item.uuid);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
});
|
@@ -0,0 +1,74 @@
|
|||||||
|
import { Component, Input, OnChanges } from '@angular/core';
|
||||||
|
import { FieldUpdate } from '../../../../core/data/object-updates/object-updates.reducer';
|
||||||
|
import { cloneDeep } from 'lodash';
|
||||||
|
import { Item } from '../../../../core/shared/item.model';
|
||||||
|
import { ObjectUpdatesService } from '../../../../core/data/object-updates/object-updates.service';
|
||||||
|
import { FieldChangeType } from '../../../../core/data/object-updates/object-updates.actions';
|
||||||
|
import { ItemViewMode } from '../../../../shared/items/item-type-decorator';
|
||||||
|
|
||||||
|
@Component({
|
||||||
|
// tslint:disable-next-line:component-selector
|
||||||
|
selector: '[ds-edit-relationship]',
|
||||||
|
styleUrls: ['./edit-relationship.component.scss'],
|
||||||
|
templateUrl: './edit-relationship.component.html',
|
||||||
|
})
|
||||||
|
export class EditRelationshipComponent implements OnChanges {
|
||||||
|
/**
|
||||||
|
* The current field, value and state of the relationship
|
||||||
|
*/
|
||||||
|
@Input() fieldUpdate: FieldUpdate;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The current url of this page
|
||||||
|
*/
|
||||||
|
@Input() url: string;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The related item of this relationship
|
||||||
|
*/
|
||||||
|
item: Item;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The view-mode we're currently on
|
||||||
|
*/
|
||||||
|
viewMode = ItemViewMode.Element;
|
||||||
|
|
||||||
|
constructor(private objectUpdatesService: ObjectUpdatesService) {
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sets the current relationship based on the fieldUpdate input field
|
||||||
|
*/
|
||||||
|
ngOnChanges(): void {
|
||||||
|
this.item = cloneDeep(this.fieldUpdate.field) as Item;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sends a new remove update for this field to the object updates service
|
||||||
|
*/
|
||||||
|
remove(): void {
|
||||||
|
this.objectUpdatesService.saveRemoveFieldUpdate(this.url, this.item);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Cancels the current update for this field in the object updates service
|
||||||
|
*/
|
||||||
|
undo(): void {
|
||||||
|
this.objectUpdatesService.removeSingleFieldUpdate(this.url, this.item.uuid);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if a user should be allowed to remove this field
|
||||||
|
*/
|
||||||
|
canRemove(): boolean {
|
||||||
|
return this.fieldUpdate.changeType !== FieldChangeType.REMOVE;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if a user should be allowed to cancel the update to this field
|
||||||
|
*/
|
||||||
|
canUndo(): boolean {
|
||||||
|
return this.fieldUpdate.changeType >= 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
@@ -0,0 +1,43 @@
|
|||||||
|
<div class="item-relationships">
|
||||||
|
<div class="button-row top d-flex">
|
||||||
|
<button class="btn btn-danger ml-auto" *ngIf="!(isReinstatable() | async)"
|
||||||
|
[disabled]="!(hasChanges() | async)"
|
||||||
|
(click)="discard()"><i
|
||||||
|
class="fas fa-times"></i>
|
||||||
|
<span class="d-none d-sm-inline"> {{"item.edit.metadata.discard-button" | translate}}</span>
|
||||||
|
</button>
|
||||||
|
<button class="btn btn-warning ml-auto" *ngIf="isReinstatable() | async"
|
||||||
|
(click)="reinstate()"><i
|
||||||
|
class="fas fa-undo-alt"></i>
|
||||||
|
<span class="d-none d-sm-inline"> {{"item.edit.metadata.reinstate-button" | translate}}</span>
|
||||||
|
</button>
|
||||||
|
<button class="btn btn-primary" [disabled]="!(hasChanges() | async)"
|
||||||
|
(click)="submit()"><i
|
||||||
|
class="fas fa-save"></i>
|
||||||
|
<span class="d-none d-sm-inline"> {{"item.edit.metadata.save-button" | translate}}</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div *ngFor="let label of relationLabels$ | async" class="mb-4">
|
||||||
|
<ds-edit-relationship-list [item]="item" [url]="url" [relationshipLabel]="label" ></ds-edit-relationship-list>
|
||||||
|
</div>
|
||||||
|
<div class="button-row bottom">
|
||||||
|
<div class="float-right">
|
||||||
|
<button class="btn btn-danger" *ngIf="!(isReinstatable() | async)"
|
||||||
|
[disabled]="!(hasChanges() | async)"
|
||||||
|
(click)="discard()"><i
|
||||||
|
class="fas fa-times"></i>
|
||||||
|
<span class="d-none d-sm-inline"> {{"item.edit.metadata.discard-button" | translate}}</span>
|
||||||
|
</button>
|
||||||
|
<button class="btn btn-warning" *ngIf="isReinstatable() | async"
|
||||||
|
(click)="reinstate()"><i
|
||||||
|
class="fas fa-undo-alt"></i>
|
||||||
|
<span class="d-none d-sm-inline"> {{"item.edit.metadata.reinstate-button" | translate}}</span>
|
||||||
|
</button>
|
||||||
|
<button class="btn btn-primary" [disabled]="!(hasChanges() | async)"
|
||||||
|
(click)="submit()"><i
|
||||||
|
class="fas fa-save"></i>
|
||||||
|
<span class="d-none d-sm-inline"> {{"item.edit.metadata.save-button" | translate}}</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
@@ -0,0 +1,22 @@
|
|||||||
|
@import '../../../../styles/variables.scss';
|
||||||
|
|
||||||
|
.button-row {
|
||||||
|
.btn {
|
||||||
|
margin-right: 0.5 * $spacer;
|
||||||
|
|
||||||
|
&:last-child {
|
||||||
|
margin-right: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media screen and (min-width: map-get($grid-breakpoints, sm)) {
|
||||||
|
min-width: $edit-item-button-min-width;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
&.top .btn {
|
||||||
|
margin-top: $spacer/2;
|
||||||
|
margin-bottom: $spacer/2;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
}
|
@@ -0,0 +1,233 @@
|
|||||||
|
import { async, ComponentFixture, TestBed } from '@angular/core/testing';
|
||||||
|
import { ItemRelationshipsComponent } from './item-relationships.component';
|
||||||
|
import { ChangeDetectorRef, DebugElement, NO_ERRORS_SCHEMA } from '@angular/core';
|
||||||
|
import { INotification, Notification } from '../../../shared/notifications/models/notification.model';
|
||||||
|
import { NotificationType } from '../../../shared/notifications/models/notification-type';
|
||||||
|
import { RouterStub } from '../../../shared/testing/router-stub';
|
||||||
|
import { TestScheduler } from 'rxjs/testing';
|
||||||
|
import { SharedModule } from '../../../shared/shared.module';
|
||||||
|
import { TranslateModule } from '@ngx-translate/core';
|
||||||
|
import { ItemDataService } from '../../../core/data/item-data.service';
|
||||||
|
import { ObjectUpdatesService } from '../../../core/data/object-updates/object-updates.service';
|
||||||
|
import { ActivatedRoute, Router } from '@angular/router';
|
||||||
|
import { NotificationsService } from '../../../shared/notifications/notifications.service';
|
||||||
|
import { GLOBAL_CONFIG } from '../../../../config';
|
||||||
|
import { RelationshipType } from '../../../core/shared/item-relationships/relationship-type.model';
|
||||||
|
import { ResourceType } from '../../../core/shared/resource-type';
|
||||||
|
import { Relationship } from '../../../core/shared/item-relationships/relationship.model';
|
||||||
|
import { of as observableOf, combineLatest as observableCombineLatest } from 'rxjs';
|
||||||
|
import { RemoteData } from '../../../core/data/remote-data';
|
||||||
|
import { Item } from '../../../core/shared/item.model';
|
||||||
|
import { PaginatedList } from '../../../core/data/paginated-list';
|
||||||
|
import { PageInfo } from '../../../core/shared/page-info.model';
|
||||||
|
import { FieldChangeType } from '../../../core/data/object-updates/object-updates.actions';
|
||||||
|
import { RelationshipService } from '../../../core/data/relationship.service';
|
||||||
|
import { ObjectCacheService } from '../../../core/cache/object-cache.service';
|
||||||
|
import { getTestScheduler } from 'jasmine-marbles';
|
||||||
|
import { RestResponse } from '../../../core/cache/response.models';
|
||||||
|
import { RequestService } from '../../../core/data/request.service';
|
||||||
|
|
||||||
|
let comp: any;
|
||||||
|
let fixture: ComponentFixture<ItemRelationshipsComponent>;
|
||||||
|
let de: DebugElement;
|
||||||
|
let el: HTMLElement;
|
||||||
|
let objectUpdatesService;
|
||||||
|
let relationshipService;
|
||||||
|
let requestService;
|
||||||
|
let objectCache;
|
||||||
|
const infoNotification: INotification = new Notification('id', NotificationType.Info, 'info');
|
||||||
|
const warningNotification: INotification = new Notification('id', NotificationType.Warning, 'warning');
|
||||||
|
const successNotification: INotification = new Notification('id', NotificationType.Success, 'success');
|
||||||
|
const notificationsService = jasmine.createSpyObj('notificationsService',
|
||||||
|
{
|
||||||
|
info: infoNotification,
|
||||||
|
warning: warningNotification,
|
||||||
|
success: successNotification
|
||||||
|
}
|
||||||
|
);
|
||||||
|
const router = new RouterStub();
|
||||||
|
let routeStub;
|
||||||
|
let itemService;
|
||||||
|
|
||||||
|
const url = 'http://test-url.com/test-url';
|
||||||
|
router.url = url;
|
||||||
|
|
||||||
|
let scheduler: TestScheduler;
|
||||||
|
let item;
|
||||||
|
let author1;
|
||||||
|
let author2;
|
||||||
|
let fieldUpdate1;
|
||||||
|
let fieldUpdate2;
|
||||||
|
let relationships;
|
||||||
|
let relationshipType;
|
||||||
|
|
||||||
|
describe('ItemRelationshipsComponent', () => {
|
||||||
|
beforeEach(async(() => {
|
||||||
|
const date = new Date();
|
||||||
|
|
||||||
|
relationshipType = Object.assign(new RelationshipType(), {
|
||||||
|
id: '1',
|
||||||
|
uuid: '1',
|
||||||
|
leftLabel: 'isAuthorOfPublication',
|
||||||
|
rightLabel: 'isPublicationOfAuthor'
|
||||||
|
});
|
||||||
|
|
||||||
|
relationships = [
|
||||||
|
Object.assign(new Relationship(), {
|
||||||
|
self: url + '/2',
|
||||||
|
id: '2',
|
||||||
|
uuid: '2',
|
||||||
|
relationshipType: observableOf(new RemoteData(false, false, true, undefined, relationshipType))
|
||||||
|
}),
|
||||||
|
Object.assign(new Relationship(), {
|
||||||
|
self: url + '/3',
|
||||||
|
id: '3',
|
||||||
|
uuid: '3',
|
||||||
|
relationshipType: observableOf(new RemoteData(false, false, true, undefined, relationshipType))
|
||||||
|
})
|
||||||
|
];
|
||||||
|
|
||||||
|
item = Object.assign(new Item(), {
|
||||||
|
self: 'fake-item-url/publication',
|
||||||
|
id: 'publication',
|
||||||
|
uuid: 'publication',
|
||||||
|
relationships: observableOf(new RemoteData(false, false, true, undefined, new PaginatedList(new PageInfo(), relationships))),
|
||||||
|
lastModified: date
|
||||||
|
});
|
||||||
|
|
||||||
|
author1 = Object.assign(new Item(), {
|
||||||
|
id: 'author1',
|
||||||
|
uuid: 'author1'
|
||||||
|
});
|
||||||
|
author2 = Object.assign(new Item(), {
|
||||||
|
id: 'author2',
|
||||||
|
uuid: 'author2'
|
||||||
|
});
|
||||||
|
|
||||||
|
relationships[0].leftItem = observableOf(new RemoteData(false, false, true, undefined, author1));
|
||||||
|
relationships[0].rightItem = observableOf(new RemoteData(false, false, true, undefined, item));
|
||||||
|
relationships[1].leftItem = observableOf(new RemoteData(false, false, true, undefined, author2));
|
||||||
|
relationships[1].rightItem = observableOf(new RemoteData(false, false, true, undefined, item));
|
||||||
|
|
||||||
|
fieldUpdate1 = {
|
||||||
|
field: author1,
|
||||||
|
changeType: undefined
|
||||||
|
};
|
||||||
|
fieldUpdate2 = {
|
||||||
|
field: author2,
|
||||||
|
changeType: FieldChangeType.REMOVE
|
||||||
|
};
|
||||||
|
|
||||||
|
itemService = jasmine.createSpyObj('itemService', {
|
||||||
|
findById: observableOf(new RemoteData(false, false, true, undefined, item))
|
||||||
|
});
|
||||||
|
routeStub = {
|
||||||
|
parent: {
|
||||||
|
data: observableOf({ item: new RemoteData(false, false, true, null, item) })
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
objectUpdatesService = jasmine.createSpyObj('objectUpdatesService',
|
||||||
|
{
|
||||||
|
getFieldUpdates: observableOf({
|
||||||
|
[author1.uuid]: fieldUpdate1,
|
||||||
|
[author2.uuid]: fieldUpdate2
|
||||||
|
}),
|
||||||
|
getFieldUpdatesExclusive: observableOf({
|
||||||
|
[author1.uuid]: fieldUpdate1,
|
||||||
|
[author2.uuid]: fieldUpdate2
|
||||||
|
}),
|
||||||
|
saveAddFieldUpdate: {},
|
||||||
|
discardFieldUpdates: {},
|
||||||
|
reinstateFieldUpdates: observableOf(true),
|
||||||
|
initialize: {},
|
||||||
|
getUpdatedFields: observableOf([author1, author2]),
|
||||||
|
getLastModified: observableOf(date),
|
||||||
|
hasUpdates: observableOf(true),
|
||||||
|
isReinstatable: observableOf(false), // should always return something --> its in ngOnInit
|
||||||
|
isValidPage: observableOf(true)
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
relationshipService = jasmine.createSpyObj('relationshipService',
|
||||||
|
{
|
||||||
|
getItemRelationshipLabels: observableOf(['isAuthorOfPublication']),
|
||||||
|
getRelatedItems: observableOf([author1, author2]),
|
||||||
|
getRelatedItemsByLabel: observableOf([author1, author2]),
|
||||||
|
getItemRelationshipsArray: observableOf(relationships),
|
||||||
|
deleteRelationship: observableOf(new RestResponse(true, 200, 'OK')),
|
||||||
|
getItemResolvedRelatedItemsAndRelationships: observableCombineLatest(observableOf([author1, author2]), observableOf([item, item]), observableOf(relationships))
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
requestService = jasmine.createSpyObj('requestService',
|
||||||
|
{
|
||||||
|
removeByHrefSubstring: {},
|
||||||
|
hasByHrefObservable: observableOf(false)
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
objectCache = jasmine.createSpyObj('objectCache', {
|
||||||
|
remove: undefined
|
||||||
|
});
|
||||||
|
|
||||||
|
scheduler = getTestScheduler();
|
||||||
|
TestBed.configureTestingModule({
|
||||||
|
imports: [SharedModule, TranslateModule.forRoot()],
|
||||||
|
declarations: [ItemRelationshipsComponent],
|
||||||
|
providers: [
|
||||||
|
{ provide: ItemDataService, useValue: itemService },
|
||||||
|
{ provide: ObjectUpdatesService, useValue: objectUpdatesService },
|
||||||
|
{ provide: Router, useValue: router },
|
||||||
|
{ provide: ActivatedRoute, useValue: routeStub },
|
||||||
|
{ provide: NotificationsService, useValue: notificationsService },
|
||||||
|
{ provide: GLOBAL_CONFIG, useValue: { item: { edit: { undoTimeout: 10 } } } as any },
|
||||||
|
{ provide: RelationshipService, useValue: relationshipService },
|
||||||
|
{ provide: ObjectCacheService, useValue: objectCache },
|
||||||
|
{ provide: RequestService, useValue: requestService },
|
||||||
|
ChangeDetectorRef
|
||||||
|
], schemas: [
|
||||||
|
NO_ERRORS_SCHEMA
|
||||||
|
]
|
||||||
|
}).compileComponents();
|
||||||
|
}));
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
fixture = TestBed.createComponent(ItemRelationshipsComponent);
|
||||||
|
comp = fixture.componentInstance;
|
||||||
|
de = fixture.debugElement;
|
||||||
|
el = de.nativeElement;
|
||||||
|
comp.url = url;
|
||||||
|
fixture.detectChanges();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('discard', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
comp.discard();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('it should call discardFieldUpdates on the objectUpdatesService with the correct url and notification', () => {
|
||||||
|
expect(objectUpdatesService.discardFieldUpdates).toHaveBeenCalledWith(url, infoNotification);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('reinstate', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
comp.reinstate();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('it should call reinstateFieldUpdates on the objectUpdatesService with the correct url', () => {
|
||||||
|
expect(objectUpdatesService.reinstateFieldUpdates).toHaveBeenCalledWith(url);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('submit', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
comp.submit();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('it should delete the correct relationship', () => {
|
||||||
|
expect(relationshipService.deleteRelationship).toHaveBeenCalledWith(relationships[1].uuid);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
@@ -0,0 +1,172 @@
|
|||||||
|
import { ChangeDetectorRef, Component, Inject, OnDestroy } from '@angular/core';
|
||||||
|
import { Item } from '../../../core/shared/item.model';
|
||||||
|
import { FieldUpdate, FieldUpdates } from '../../../core/data/object-updates/object-updates.reducer';
|
||||||
|
import { Observable } from 'rxjs/internal/Observable';
|
||||||
|
import { filter, map, switchMap, take, tap } from 'rxjs/operators';
|
||||||
|
import { combineLatest as observableCombineLatest, zip as observableZip } from 'rxjs';
|
||||||
|
import { AbstractItemUpdateComponent } from '../abstract-item-update/abstract-item-update.component';
|
||||||
|
import { ItemDataService } from '../../../core/data/item-data.service';
|
||||||
|
import { ObjectUpdatesService } from '../../../core/data/object-updates/object-updates.service';
|
||||||
|
import { ActivatedRoute, Router } from '@angular/router';
|
||||||
|
import { NotificationsService } from '../../../shared/notifications/notifications.service';
|
||||||
|
import { TranslateService } from '@ngx-translate/core';
|
||||||
|
import { GLOBAL_CONFIG, GlobalConfig } from '../../../../config';
|
||||||
|
import { RelationshipService } from '../../../core/data/relationship.service';
|
||||||
|
import { FieldChangeType } from '../../../core/data/object-updates/object-updates.actions';
|
||||||
|
import { Relationship } from '../../../core/shared/item-relationships/relationship.model';
|
||||||
|
import { ErrorResponse, RestResponse } from '../../../core/cache/response.models';
|
||||||
|
import { isNotEmptyOperator } from '../../../shared/empty.util';
|
||||||
|
import { RemoteData } from '../../../core/data/remote-data';
|
||||||
|
import { ObjectCacheService } from '../../../core/cache/object-cache.service';
|
||||||
|
import { getSucceededRemoteData } from '../../../core/shared/operators';
|
||||||
|
import { RequestService } from '../../../core/data/request.service';
|
||||||
|
import { Subscription } from 'rxjs/internal/Subscription';
|
||||||
|
import { getRelationsByRelatedItemIds } from '../../simple/item-types/shared/item-relationships-utils';
|
||||||
|
|
||||||
|
@Component({
|
||||||
|
selector: 'ds-item-relationships',
|
||||||
|
styleUrls: ['./item-relationships.component.scss'],
|
||||||
|
templateUrl: './item-relationships.component.html',
|
||||||
|
})
|
||||||
|
/**
|
||||||
|
* Component for displaying an item's relationships edit page
|
||||||
|
*/
|
||||||
|
export class ItemRelationshipsComponent extends AbstractItemUpdateComponent implements OnDestroy {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The labels of all different relations within this item
|
||||||
|
*/
|
||||||
|
relationLabels$: Observable<string[]>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A subscription that checks when the item is deleted in cache and reloads the item by sending a new request
|
||||||
|
* This is used to update the item in cache after relationships are deleted
|
||||||
|
*/
|
||||||
|
itemUpdateSubscription: Subscription;
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
protected itemService: ItemDataService,
|
||||||
|
protected objectUpdatesService: ObjectUpdatesService,
|
||||||
|
protected router: Router,
|
||||||
|
protected notificationsService: NotificationsService,
|
||||||
|
protected translateService: TranslateService,
|
||||||
|
@Inject(GLOBAL_CONFIG) protected EnvConfig: GlobalConfig,
|
||||||
|
protected route: ActivatedRoute,
|
||||||
|
protected relationshipService: RelationshipService,
|
||||||
|
protected objectCache: ObjectCacheService,
|
||||||
|
protected requestService: RequestService,
|
||||||
|
protected cdRef: ChangeDetectorRef
|
||||||
|
) {
|
||||||
|
super(itemService, objectUpdatesService, router, notificationsService, translateService, EnvConfig, route);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Set up and initialize all fields
|
||||||
|
*/
|
||||||
|
ngOnInit(): void {
|
||||||
|
super.ngOnInit();
|
||||||
|
this.relationLabels$ = this.relationshipService.getItemRelationshipLabels(this.item);
|
||||||
|
this.initializeItemUpdate();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Update the item (and view) when it's removed in the request cache
|
||||||
|
*/
|
||||||
|
public initializeItemUpdate(): void {
|
||||||
|
this.itemUpdateSubscription = this.requestService.hasByHrefObservable(this.item.self).pipe(
|
||||||
|
filter((exists: boolean) => !exists),
|
||||||
|
switchMap(() => this.itemService.findById(this.item.uuid)),
|
||||||
|
getSucceededRemoteData(),
|
||||||
|
).subscribe((itemRD: RemoteData<Item>) => {
|
||||||
|
this.item = itemRD.payload;
|
||||||
|
this.cdRef.detectChanges();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Initialize the values and updates of the current item's relationship fields
|
||||||
|
*/
|
||||||
|
public initializeUpdates(): void {
|
||||||
|
this.updates$ = this.relationshipService.getRelatedItems(this.item).pipe(
|
||||||
|
switchMap((items: Item[]) => this.objectUpdatesService.getFieldUpdates(this.url, items))
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Initialize the prefix for notification messages
|
||||||
|
*/
|
||||||
|
public initializeNotificationsPrefix(): void {
|
||||||
|
this.notificationsPrefix = 'item.edit.relationships.notifications.';
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resolve the currently selected related items back to relationships and send a delete request for each of the relationships found
|
||||||
|
* Make sure the lists are refreshed afterwards and notifications are sent for success and errors
|
||||||
|
*/
|
||||||
|
public submit(): void {
|
||||||
|
// Get all IDs of related items of which their relationship with the current item is about to be removed
|
||||||
|
const removedItemIds$ = this.relationshipService.getRelatedItems(this.item).pipe(
|
||||||
|
switchMap((items: Item[]) => this.objectUpdatesService.getFieldUpdatesExclusive(this.url, items) as Observable<FieldUpdates>),
|
||||||
|
map((fieldUpdates: FieldUpdates) => Object.values(fieldUpdates).filter((fieldUpdate: FieldUpdate) => fieldUpdate.changeType === FieldChangeType.REMOVE)),
|
||||||
|
map((fieldUpdates: FieldUpdate[]) => fieldUpdates.map((fieldUpdate: FieldUpdate) => fieldUpdate.field.uuid) as string[]),
|
||||||
|
isNotEmptyOperator()
|
||||||
|
);
|
||||||
|
// Get all the relationships that should be removed
|
||||||
|
const removedRelationships$ = removedItemIds$.pipe(
|
||||||
|
getRelationsByRelatedItemIds(this.item, this.relationshipService)
|
||||||
|
);
|
||||||
|
// Request a delete for every relationship found in the observable created above
|
||||||
|
removedRelationships$.pipe(
|
||||||
|
take(1),
|
||||||
|
map((removedRelationships: Relationship[]) => removedRelationships.map((rel: Relationship) => rel.id)),
|
||||||
|
switchMap((removedIds: string[]) => observableZip(...removedIds.map((uuid: string) => this.relationshipService.deleteRelationship(uuid))))
|
||||||
|
).subscribe((responses: RestResponse[]) => {
|
||||||
|
this.displayNotifications(responses);
|
||||||
|
this.reset();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Display notifications
|
||||||
|
* - Error notification for each failed response with their message
|
||||||
|
* - Success notification in case there's at least one successful response
|
||||||
|
* @param responses
|
||||||
|
*/
|
||||||
|
displayNotifications(responses: RestResponse[]) {
|
||||||
|
const failedResponses = responses.filter((response: RestResponse) => !response.isSuccessful);
|
||||||
|
const successfulResponses = responses.filter((response: RestResponse) => response.isSuccessful);
|
||||||
|
|
||||||
|
failedResponses.forEach((response: ErrorResponse) => {
|
||||||
|
this.notificationsService.error(this.getNotificationTitle('failed'), response.errorMessage);
|
||||||
|
});
|
||||||
|
if (successfulResponses.length > 0) {
|
||||||
|
this.notificationsService.success(this.getNotificationTitle('saved'), this.getNotificationContent('saved'));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Re-initialize fields and subscriptions
|
||||||
|
*/
|
||||||
|
reset() {
|
||||||
|
this.initializeOriginalFields();
|
||||||
|
this.initializeUpdates();
|
||||||
|
this.initializeItemUpdate();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sends all initial values of this item to the object updates service
|
||||||
|
*/
|
||||||
|
public initializeOriginalFields() {
|
||||||
|
this.relationshipService.getRelatedItems(this.item).pipe(take(1)).subscribe((items: Item[]) => {
|
||||||
|
this.objectUpdatesService.initialize(this.url, items, this.item.lastModified);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Unsubscribe from the item update when the component is destroyed
|
||||||
|
*/
|
||||||
|
ngOnDestroy(): void {
|
||||||
|
this.itemUpdateSubscription.unsubscribe();
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
@@ -62,7 +62,8 @@ import { MetadataFieldWrapperComponent } from './field-components/metadata-field
|
|||||||
GenericItemPageFieldComponent,
|
GenericItemPageFieldComponent,
|
||||||
RelatedEntitiesSearchComponent,
|
RelatedEntitiesSearchComponent,
|
||||||
RelatedItemsComponent,
|
RelatedItemsComponent,
|
||||||
MetadataRepresentationListComponent
|
MetadataRepresentationListComponent,
|
||||||
|
ItemPageTitleFieldComponent
|
||||||
],
|
],
|
||||||
entryComponents: [
|
entryComponents: [
|
||||||
PublicationComponent
|
PublicationComponent
|
||||||
|
@@ -7,10 +7,12 @@ import { hasNoValue, hasValue } from '../../../../shared/empty.util';
|
|||||||
import { Observable } from 'rxjs/internal/Observable';
|
import { Observable } from 'rxjs/internal/Observable';
|
||||||
import { Relationship } from '../../../../core/shared/item-relationships/relationship.model';
|
import { Relationship } from '../../../../core/shared/item-relationships/relationship.model';
|
||||||
import { RelationshipType } from '../../../../core/shared/item-relationships/relationship-type.model';
|
import { RelationshipType } from '../../../../core/shared/item-relationships/relationship-type.model';
|
||||||
import { distinctUntilChanged, flatMap, map, switchMap } from 'rxjs/operators';
|
import { distinctUntilChanged, filter, flatMap, map, switchMap, tap } from 'rxjs/operators';
|
||||||
import { of as observableOf, zip as observableZip, combineLatest as observableCombineLatest } from 'rxjs';
|
import { of as observableOf, zip as observableZip, combineLatest as observableCombineLatest } from 'rxjs';
|
||||||
import { ItemDataService } from '../../../../core/data/item-data.service';
|
import { ItemDataService } from '../../../../core/data/item-data.service';
|
||||||
import { Item } from '../../../../core/shared/item.model';
|
import { Item } from '../../../../core/shared/item.model';
|
||||||
|
import { RemoteData } from '../../../../core/data/remote-data';
|
||||||
|
import { RelationshipService } from '../../../../core/data/relationship.service';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Operator for comparing arrays using a mapping function
|
* Operator for comparing arrays using a mapping function
|
||||||
@@ -147,3 +149,17 @@ export const relationsToRepresentations = (parentId: string, itemType: string, m
|
|||||||
)
|
)
|
||||||
)
|
)
|
||||||
);
|
);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Operator for fetching an item's relationships, but filtered by related item IDs (essentially performing a reverse lookup)
|
||||||
|
* Only relationships where leftItem or rightItem's ID is present in the list provided will be returned
|
||||||
|
* @param item
|
||||||
|
* @param relationshipService
|
||||||
|
*/
|
||||||
|
export const getRelationsByRelatedItemIds = (item: Item, relationshipService: RelationshipService) =>
|
||||||
|
(source: Observable<string[]>): Observable<Relationship[]> =>
|
||||||
|
source.pipe(
|
||||||
|
flatMap((relatedItemIds: string[]) => relationshipService.getItemResolvedRelatedItemsAndRelationships(item).pipe(
|
||||||
|
map(([leftItems, rightItems, rels]) => rels.filter((rel: Relationship, index: number) => relatedItemIds.indexOf(leftItems[index].uuid) > -1 || relatedItemIds.indexOf(rightItems[index].uuid) > -1))
|
||||||
|
))
|
||||||
|
);
|
||||||
|
@@ -8,7 +8,7 @@ import { MyDSpaceConfigurationValueType } from './my-dspace-configuration-value-
|
|||||||
import { RoleService } from '../core/roles/role.service';
|
import { RoleService } from '../core/roles/role.service';
|
||||||
import { SearchConfigurationOption } from '../+search-page/search-switch-configuration/search-configuration-option.model';
|
import { SearchConfigurationOption } from '../+search-page/search-switch-configuration/search-configuration-option.model';
|
||||||
import { SearchConfigurationService } from '../+search-page/search-service/search-configuration.service';
|
import { SearchConfigurationService } from '../+search-page/search-service/search-configuration.service';
|
||||||
import { RouteService } from '../shared/services/route.service';
|
import { RouteService } from '../core/services/route.service';
|
||||||
import { PaginationComponentOptions } from '../shared/pagination/pagination-component-options.model';
|
import { PaginationComponentOptions } from '../shared/pagination/pagination-component-options.model';
|
||||||
import { SortDirection, SortOptions } from '../core/cache/models/sort-options.model';
|
import { SortDirection, SortOptions } from '../core/cache/models/sort-options.model';
|
||||||
import { SearchFixedFilterService } from '../+search-page/search-filters/search-filter/search-fixed-filter.service';
|
import { SearchFixedFilterService } from '../+search-page/search-filters/search-filter/search-fixed-filter.service';
|
||||||
|
@@ -17,7 +17,7 @@ import { HostWindowService } from '../shared/host-window.service';
|
|||||||
import { PaginationComponentOptions } from '../shared/pagination/pagination-component-options.model';
|
import { PaginationComponentOptions } from '../shared/pagination/pagination-component-options.model';
|
||||||
import { RemoteData } from '../core/data/remote-data';
|
import { RemoteData } from '../core/data/remote-data';
|
||||||
import { MyDSpacePageComponent, SEARCH_CONFIG_SERVICE } from './my-dspace-page.component';
|
import { MyDSpacePageComponent, SEARCH_CONFIG_SERVICE } from './my-dspace-page.component';
|
||||||
import { RouteService } from '../shared/services/route.service';
|
import { RouteService } from '../core/services/route.service';
|
||||||
import { routeServiceStub } from '../shared/testing/route-service-stub';
|
import { routeServiceStub } from '../shared/testing/route-service-stub';
|
||||||
import { SearchConfigurationServiceStub } from '../shared/testing/search-configuration-service-stub';
|
import { SearchConfigurationServiceStub } from '../shared/testing/search-configuration-service-stub';
|
||||||
import { SearchService } from '../+search-page/search-service/search.service';
|
import { SearchService } from '../+search-page/search-service/search.service';
|
||||||
|
@@ -4,12 +4,12 @@ import { SearchSidebarService } from './search-sidebar/search-sidebar.service';
|
|||||||
import { SearchPageComponent } from './search-page.component';
|
import { SearchPageComponent } from './search-page.component';
|
||||||
import { ChangeDetectionStrategy, Component, Inject, Input, OnInit } from '@angular/core';
|
import { ChangeDetectionStrategy, Component, Inject, Input, OnInit } from '@angular/core';
|
||||||
import { pushInOut } from '../shared/animations/push';
|
import { pushInOut } from '../shared/animations/push';
|
||||||
import { RouteService } from '../shared/services/route.service';
|
|
||||||
import { SearchConfigurationService } from './search-service/search-configuration.service';
|
import { SearchConfigurationService } from './search-service/search-configuration.service';
|
||||||
import { Observable } from 'rxjs';
|
import { Observable } from 'rxjs';
|
||||||
import { PaginatedSearchOptions } from './paginated-search-options.model';
|
import { PaginatedSearchOptions } from './paginated-search-options.model';
|
||||||
import { SEARCH_CONFIG_SERVICE } from '../+my-dspace-page/my-dspace-page.component';
|
import { SEARCH_CONFIG_SERVICE } from '../+my-dspace-page/my-dspace-page.component';
|
||||||
import { map } from 'rxjs/operators';
|
import { map } from 'rxjs/operators';
|
||||||
|
import { RouteService } from '../core/services/route.service';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* This component renders a search page using a configuration as input.
|
* This component renders a search page using a configuration as input.
|
||||||
|
@@ -4,12 +4,12 @@ import { SearchSidebarService } from './search-sidebar/search-sidebar.service';
|
|||||||
import { SearchPageComponent } from './search-page.component';
|
import { SearchPageComponent } from './search-page.component';
|
||||||
import { ChangeDetectionStrategy, Component, Inject, Input, OnInit } from '@angular/core';
|
import { ChangeDetectionStrategy, Component, Inject, Input, OnInit } from '@angular/core';
|
||||||
import { pushInOut } from '../shared/animations/push';
|
import { pushInOut } from '../shared/animations/push';
|
||||||
import { RouteService } from '../shared/services/route.service';
|
|
||||||
import { SearchConfigurationService } from './search-service/search-configuration.service';
|
import { SearchConfigurationService } from './search-service/search-configuration.service';
|
||||||
import { Observable } from 'rxjs';
|
import { Observable } from 'rxjs';
|
||||||
import { PaginatedSearchOptions } from './paginated-search-options.model';
|
import { PaginatedSearchOptions } from './paginated-search-options.model';
|
||||||
import { SEARCH_CONFIG_SERVICE } from '../+my-dspace-page/my-dspace-page.component';
|
import { SEARCH_CONFIG_SERVICE } from '../+my-dspace-page/my-dspace-page.component';
|
||||||
import { map } from 'rxjs/operators';
|
import { map } from 'rxjs/operators';
|
||||||
|
import { RouteService } from '../core/services/route.service';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* This component renders a simple item page.
|
* This component renders a simple item page.
|
||||||
|
@@ -17,7 +17,7 @@
|
|||||||
</div>
|
</div>
|
||||||
<ds-input-suggestions [suggestions]="(filterSearchResults | async)"
|
<ds-input-suggestions [suggestions]="(filterSearchResults | async)"
|
||||||
[placeholder]="'search.filters.filter.' + filterConfig.name + '.placeholder'| translate"
|
[placeholder]="'search.filters.filter.' + filterConfig.name + '.placeholder'| translate"
|
||||||
[action]="getCurrentUrl()"
|
[action]="currentUrl"
|
||||||
[name]="filterConfig.paramName"
|
[name]="filterConfig.paramName"
|
||||||
[(ngModel)]="filter"
|
[(ngModel)]="filter"
|
||||||
(submitSuggestion)="onSubmit($event)"
|
(submitSuggestion)="onSubmit($event)"
|
||||||
|
@@ -1,5 +1,5 @@
|
|||||||
<a *ngIf="isVisible | async" class="d-flex flex-row"
|
<a *ngIf="isVisible | async" class="d-flex flex-row"
|
||||||
[routerLink]="[getSearchLink()]"
|
[routerLink]="[searchLink]"
|
||||||
[queryParams]="addQueryParams" queryParamsHandling="merge">
|
[queryParams]="addQueryParams" queryParamsHandling="merge">
|
||||||
<input type="checkbox" [checked]="false" class="my-1 align-self-stretch"/>
|
<input type="checkbox" [checked]="false" class="my-1 align-self-stretch"/>
|
||||||
<span class="filter-value px-1">{{filterValue.value}}</span>
|
<span class="filter-value px-1">{{filterValue.value}}</span>
|
||||||
|
@@ -50,6 +50,10 @@ export class SearchFacetOptionComponent implements OnInit, OnDestroy {
|
|||||||
*/
|
*/
|
||||||
addQueryParams;
|
addQueryParams;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Link to the search page
|
||||||
|
*/
|
||||||
|
searchLink: string;
|
||||||
/**
|
/**
|
||||||
* Subscription to unsubscribe from on destroy
|
* Subscription to unsubscribe from on destroy
|
||||||
*/
|
*/
|
||||||
@@ -66,6 +70,7 @@ export class SearchFacetOptionComponent implements OnInit, OnDestroy {
|
|||||||
* Initializes all observable instance variables and starts listening to them
|
* Initializes all observable instance variables and starts listening to them
|
||||||
*/
|
*/
|
||||||
ngOnInit(): void {
|
ngOnInit(): void {
|
||||||
|
this.searchLink = this.getSearchLink();
|
||||||
this.isVisible = this.isChecked().pipe(map((checked: boolean) => !checked));
|
this.isVisible = this.isChecked().pipe(map((checked: boolean) => !checked));
|
||||||
this.sub = observableCombineLatest(this.selectedValues$, this.searchConfigService.searchOptions)
|
this.sub = observableCombineLatest(this.selectedValues$, this.searchConfigService.searchOptions)
|
||||||
.subscribe(([selectedValues, searchOptions]) => {
|
.subscribe(([selectedValues, searchOptions]) => {
|
||||||
@@ -83,7 +88,7 @@ export class SearchFacetOptionComponent implements OnInit, OnDestroy {
|
|||||||
/**
|
/**
|
||||||
* @returns {string} The base path to the search page, or the current page when inPlaceSearch is true
|
* @returns {string} The base path to the search page, or the current page when inPlaceSearch is true
|
||||||
*/
|
*/
|
||||||
public getSearchLink(): string {
|
private getSearchLink(): string {
|
||||||
if (this.inPlaceSearch) {
|
if (this.inPlaceSearch) {
|
||||||
return './';
|
return './';
|
||||||
}
|
}
|
||||||
|
@@ -1,5 +1,5 @@
|
|||||||
<a *ngIf="isVisible | async" class="d-flex flex-row"
|
<a *ngIf="isVisible | async" class="d-flex flex-row"
|
||||||
[routerLink]="[getSearchLink()]"
|
[routerLink]="[searchLink]"
|
||||||
[queryParams]="changeQueryParams" queryParamsHandling="merge">
|
[queryParams]="changeQueryParams" queryParamsHandling="merge">
|
||||||
<span class="filter-value px-1">{{filterValue.label}}</span>
|
<span class="filter-value px-1">{{filterValue.label}}</span>
|
||||||
<span class="float-right filter-value-count ml-auto">
|
<span class="float-right filter-value-count ml-auto">
|
||||||
|
@@ -56,6 +56,11 @@ export class SearchFacetRangeOptionComponent implements OnInit, OnDestroy {
|
|||||||
*/
|
*/
|
||||||
sub: Subscription;
|
sub: Subscription;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Link to the search page
|
||||||
|
*/
|
||||||
|
searchLink: string;
|
||||||
|
|
||||||
constructor(protected searchService: SearchService,
|
constructor(protected searchService: SearchService,
|
||||||
protected filterService: SearchFilterService,
|
protected filterService: SearchFilterService,
|
||||||
protected searchConfigService: SearchConfigurationService,
|
protected searchConfigService: SearchConfigurationService,
|
||||||
@@ -67,6 +72,7 @@ export class SearchFacetRangeOptionComponent implements OnInit, OnDestroy {
|
|||||||
* Initializes all observable instance variables and starts listening to them
|
* Initializes all observable instance variables and starts listening to them
|
||||||
*/
|
*/
|
||||||
ngOnInit(): void {
|
ngOnInit(): void {
|
||||||
|
this.searchLink = this.getSearchLink();
|
||||||
this.isVisible = this.isChecked().pipe(map((checked: boolean) => !checked));
|
this.isVisible = this.isChecked().pipe(map((checked: boolean) => !checked));
|
||||||
this.sub = this.searchConfigService.searchOptions.subscribe(() => {
|
this.sub = this.searchConfigService.searchOptions.subscribe(() => {
|
||||||
this.updateChangeParams()
|
this.updateChangeParams()
|
||||||
@@ -83,7 +89,7 @@ export class SearchFacetRangeOptionComponent implements OnInit, OnDestroy {
|
|||||||
/**
|
/**
|
||||||
* @returns {string} The base path to the search page, or the current page when inPlaceSearch is true
|
* @returns {string} The base path to the search page, or the current page when inPlaceSearch is true
|
||||||
*/
|
*/
|
||||||
public getSearchLink(): string {
|
private getSearchLink(): string {
|
||||||
if (this.inPlaceSearch) {
|
if (this.inPlaceSearch) {
|
||||||
return './';
|
return './';
|
||||||
}
|
}
|
||||||
|
@@ -1,5 +1,5 @@
|
|||||||
<a class="d-flex flex-row"
|
<a class="d-flex flex-row"
|
||||||
[routerLink]="[getSearchLink()]"
|
[routerLink]="[searchLink]"
|
||||||
[queryParams]="removeQueryParams" queryParamsHandling="merge">
|
[queryParams]="removeQueryParams" queryParamsHandling="merge">
|
||||||
<input type="checkbox" [checked]="true" class="my-1 align-self-stretch"/>
|
<input type="checkbox" [checked]="true" class="my-1 align-self-stretch"/>
|
||||||
<span class="filter-value pl-1 text-capitalize">{{selectedValue.label}}</span>
|
<span class="filter-value pl-1 text-capitalize">{{selectedValue.label}}</span>
|
||||||
|
@@ -49,6 +49,11 @@ export class SearchFacetSelectedOptionComponent implements OnInit, OnDestroy {
|
|||||||
*/
|
*/
|
||||||
sub: Subscription;
|
sub: Subscription;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Link to the search page
|
||||||
|
*/
|
||||||
|
searchLink: string;
|
||||||
|
|
||||||
constructor(protected searchService: SearchService,
|
constructor(protected searchService: SearchService,
|
||||||
protected filterService: SearchFilterService,
|
protected filterService: SearchFilterService,
|
||||||
protected searchConfigService: SearchConfigurationService,
|
protected searchConfigService: SearchConfigurationService,
|
||||||
@@ -64,12 +69,13 @@ export class SearchFacetSelectedOptionComponent implements OnInit, OnDestroy {
|
|||||||
.subscribe(([selectedValues, searchOptions]) => {
|
.subscribe(([selectedValues, searchOptions]) => {
|
||||||
this.updateRemoveParams(selectedValues)
|
this.updateRemoveParams(selectedValues)
|
||||||
});
|
});
|
||||||
|
this.searchLink = this.getSearchLink();
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @returns {string} The base path to the search page, or the current page when inPlaceSearch is true
|
* @returns {string} The base path to the search page, or the current page when inPlaceSearch is true
|
||||||
*/
|
*/
|
||||||
public getSearchLink(): string {
|
private getSearchLink(): string {
|
||||||
if (this.inPlaceSearch) {
|
if (this.inPlaceSearch) {
|
||||||
return './';
|
return './';
|
||||||
}
|
}
|
||||||
|
@@ -80,6 +80,11 @@ export class SearchFacetFilterComponent implements OnInit, OnDestroy {
|
|||||||
*/
|
*/
|
||||||
searchOptions$: Observable<SearchOptions>;
|
searchOptions$: Observable<SearchOptions>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The current URL
|
||||||
|
*/
|
||||||
|
currentUrl: string;
|
||||||
|
|
||||||
constructor(protected searchService: SearchService,
|
constructor(protected searchService: SearchService,
|
||||||
protected filterService: SearchFilterService,
|
protected filterService: SearchFilterService,
|
||||||
protected rdbs: RemoteDataBuildService,
|
protected rdbs: RemoteDataBuildService,
|
||||||
@@ -93,6 +98,7 @@ export class SearchFacetFilterComponent implements OnInit, OnDestroy {
|
|||||||
* Initializes all observable instance variables and starts listening to them
|
* Initializes all observable instance variables and starts listening to them
|
||||||
*/
|
*/
|
||||||
ngOnInit(): void {
|
ngOnInit(): void {
|
||||||
|
this.currentUrl = this.router.url;
|
||||||
this.filterValues$ = new BehaviorSubject(new RemoteData(true, false, undefined, undefined, undefined));
|
this.filterValues$ = new BehaviorSubject(new RemoteData(true, false, undefined, undefined, undefined));
|
||||||
this.currentPage = this.getCurrentPage().pipe(distinctUntilChanged());
|
this.currentPage = this.getCurrentPage().pipe(distinctUntilChanged());
|
||||||
|
|
||||||
@@ -215,13 +221,6 @@ export class SearchFacetFilterComponent implements OnInit, OnDestroy {
|
|||||||
return this.filterService.getPage(this.filterConfig.name);
|
return this.filterService.getPage(this.filterConfig.name);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* @returns {string} the current URL
|
|
||||||
*/
|
|
||||||
getCurrentUrl() {
|
|
||||||
return this.router.url;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Submits a new active custom value to the filter from the input field
|
* Submits a new active custom value to the filter from the input field
|
||||||
* @param data The string from the input field
|
* @param data The string from the input field
|
||||||
|
@@ -1,5 +1,5 @@
|
|||||||
import { combineLatest as observableCombineLatest, Observable } from 'rxjs';
|
import { combineLatest as observableCombineLatest, Observable } from 'rxjs';
|
||||||
import { mergeMap, map, distinctUntilChanged } from 'rxjs/operators';
|
import { distinctUntilChanged, map, mergeMap } from 'rxjs/operators';
|
||||||
import { Injectable, InjectionToken } from '@angular/core';
|
import { Injectable, InjectionToken } from '@angular/core';
|
||||||
import { SearchFiltersState, SearchFilterState } from './search-filter.reducer';
|
import { SearchFiltersState, SearchFilterState } from './search-filter.reducer';
|
||||||
import { createSelector, MemoizedSelector, select, Store } from '@ngrx/store';
|
import { createSelector, MemoizedSelector, select, Store } from '@ngrx/store';
|
||||||
@@ -14,16 +14,12 @@ import {
|
|||||||
} from './search-filter.actions';
|
} from './search-filter.actions';
|
||||||
import { hasValue, isNotEmpty, } from '../../../shared/empty.util';
|
import { hasValue, isNotEmpty, } from '../../../shared/empty.util';
|
||||||
import { SearchFilterConfig } from '../../search-service/search-filter-config.model';
|
import { SearchFilterConfig } from '../../search-service/search-filter-config.model';
|
||||||
import { RouteService } from '../../../shared/services/route.service';
|
import { RouteService } from '../../../core/services/route.service';
|
||||||
import { SortDirection, SortOptions } from '../../../core/cache/models/sort-options.model';
|
import { SortDirection, SortOptions } from '../../../core/cache/models/sort-options.model';
|
||||||
import { PaginationComponentOptions } from '../../../shared/pagination/pagination-component-options.model';
|
import { PaginationComponentOptions } from '../../../shared/pagination/pagination-component-options.model';
|
||||||
import { SearchOptions } from '../../search-options.model';
|
|
||||||
import { PaginatedSearchOptions } from '../../paginated-search-options.model';
|
|
||||||
import { SearchFixedFilterService } from './search-fixed-filter.service';
|
import { SearchFixedFilterService } from './search-fixed-filter.service';
|
||||||
import { Params } from '@angular/router';
|
import { Params } from '@angular/router';
|
||||||
import * as postcss from 'postcss';
|
|
||||||
import prefix = postcss.vendor.prefix;
|
|
||||||
// const spy = create();
|
|
||||||
const filterStateSelector = (state: SearchFiltersState) => state.searchFilter;
|
const filterStateSelector = (state: SearchFiltersState) => state.searchFilter;
|
||||||
|
|
||||||
export const FILTER_CONFIG: InjectionToken<SearchFilterConfig> = new InjectionToken<SearchFilterConfig>('filterConfig');
|
export const FILTER_CONFIG: InjectionToken<SearchFilterConfig> = new InjectionToken<SearchFilterConfig>('filterConfig');
|
||||||
|
@@ -1,20 +1,19 @@
|
|||||||
import { SearchFixedFilterService } from './search-fixed-filter.service';
|
import { SearchFixedFilterService } from './search-fixed-filter.service';
|
||||||
import { RouteService } from '../../../shared/services/route.service';
|
|
||||||
import { RequestService } from '../../../core/data/request.service';
|
import { RequestService } from '../../../core/data/request.service';
|
||||||
import { HALEndpointService } from '../../../core/shared/hal-endpoint.service';
|
import { HALEndpointService } from '../../../core/shared/hal-endpoint.service';
|
||||||
import { of as observableOf } from 'rxjs';
|
import { of as observableOf } from 'rxjs';
|
||||||
import { RequestEntry } from '../../../core/data/request.reducer';
|
import { RequestEntry } from '../../../core/data/request.reducer';
|
||||||
import { FilteredDiscoveryQueryResponse, RestResponse } from '../../../core/cache/response.models';
|
import { FilteredDiscoveryQueryResponse } from '../../../core/cache/response.models';
|
||||||
|
|
||||||
describe('SearchFixedFilterService', () => {
|
describe('SearchFixedFilterService', () => {
|
||||||
let service: SearchFixedFilterService;
|
let service: SearchFixedFilterService;
|
||||||
|
|
||||||
const filterQuery = 'filter:query';
|
const filterQuery = 'filter:query';
|
||||||
|
|
||||||
const routeServiceStub = {} as RouteService;
|
|
||||||
const requestServiceStub = Object.assign({
|
const requestServiceStub = Object.assign({
|
||||||
/* tslint:disable:no-empty */
|
/* tslint:disable:no-empty */
|
||||||
configure: () => {},
|
configure: () => {
|
||||||
|
},
|
||||||
/* tslint:enable:no-empty */
|
/* tslint:enable:no-empty */
|
||||||
generateRequestId: () => 'fake-id',
|
generateRequestId: () => 'fake-id',
|
||||||
getByHref: () => observableOf(Object.assign(new RequestEntry(), {
|
getByHref: () => observableOf(Object.assign(new RequestEntry(), {
|
||||||
@@ -26,7 +25,7 @@ describe('SearchFixedFilterService', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
service = new SearchFixedFilterService(routeServiceStub, requestServiceStub, halServiceStub);
|
service = new SearchFixedFilterService(requestServiceStub, halServiceStub);
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('when getQueryByFilterName is called with a filterName', () => {
|
describe('when getQueryByFilterName is called with a filterName', () => {
|
||||||
|
@@ -1,5 +1,5 @@
|
|||||||
import { Injectable } from '@angular/core';
|
import { Injectable } from '@angular/core';
|
||||||
import { flatMap, map, switchMap, tap } from 'rxjs/operators';
|
import { map, switchMap } from 'rxjs/operators';
|
||||||
import { Observable, of as observableOf } from 'rxjs';
|
import { Observable, of as observableOf } from 'rxjs';
|
||||||
import { HALEndpointService } from '../../../core/shared/hal-endpoint.service';
|
import { HALEndpointService } from '../../../core/shared/hal-endpoint.service';
|
||||||
import { GetRequest, RestRequest } from '../../../core/data/request.models';
|
import { GetRequest, RestRequest } from '../../../core/data/request.models';
|
||||||
@@ -9,7 +9,6 @@ import { GenericConstructor } from '../../../core/shared/generic-constructor';
|
|||||||
import { FilteredDiscoveryPageResponseParsingService } from '../../../core/data/filtered-discovery-page-response-parsing.service';
|
import { FilteredDiscoveryPageResponseParsingService } from '../../../core/data/filtered-discovery-page-response-parsing.service';
|
||||||
import { hasValue } from '../../../shared/empty.util';
|
import { hasValue } from '../../../shared/empty.util';
|
||||||
import { configureRequest, getResponseFromEntry } from '../../../core/shared/operators';
|
import { configureRequest, getResponseFromEntry } from '../../../core/shared/operators';
|
||||||
import { RouteService } from '../../../shared/services/route.service';
|
|
||||||
import { FilteredDiscoveryQueryResponse } from '../../../core/cache/response.models';
|
import { FilteredDiscoveryQueryResponse } from '../../../core/cache/response.models';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -19,8 +18,7 @@ import { FilteredDiscoveryQueryResponse } from '../../../core/cache/response.mod
|
|||||||
export class SearchFixedFilterService {
|
export class SearchFixedFilterService {
|
||||||
private queryByFilterPath = 'filtered-discovery-pages';
|
private queryByFilterPath = 'filtered-discovery-pages';
|
||||||
|
|
||||||
constructor(private routeService: RouteService,
|
constructor(protected requestService: RequestService,
|
||||||
protected requestService: RequestService,
|
|
||||||
private halService: HALEndpointService) {
|
private halService: HALEndpointService) {
|
||||||
|
|
||||||
}
|
}
|
||||||
|
@@ -17,7 +17,7 @@
|
|||||||
</div>
|
</div>
|
||||||
<ds-input-suggestions [suggestions]="(filterSearchResults | async)"
|
<ds-input-suggestions [suggestions]="(filterSearchResults | async)"
|
||||||
[placeholder]="'search.filters.filter.' + filterConfig.name + '.placeholder'| translate"
|
[placeholder]="'search.filters.filter.' + filterConfig.name + '.placeholder'| translate"
|
||||||
[action]="getCurrentUrl()"
|
[action]="currentUrl"
|
||||||
[name]="filterConfig.paramName"
|
[name]="filterConfig.paramName"
|
||||||
[(ngModel)]="filter"
|
[(ngModel)]="filter"
|
||||||
(submitSuggestion)="onSubmit($event)"
|
(submitSuggestion)="onSubmit($event)"
|
||||||
|
@@ -1,7 +1,7 @@
|
|||||||
<div>
|
<div>
|
||||||
<div class="filters py-2">
|
<div class="filters py-2">
|
||||||
<form #form="ngForm" (ngSubmit)="onSubmit()" class="add-filter row"
|
<form #form="ngForm" (ngSubmit)="onSubmit()" class="add-filter row"
|
||||||
[action]="getCurrentUrl()">
|
[action]="currentUrl">
|
||||||
<div class="col-6">
|
<div class="col-6">
|
||||||
<input type="text" [(ngModel)]="range[0]" [name]="filterConfig.paramName + '.min'"
|
<input type="text" [(ngModel)]="range[0]" [name]="filterConfig.paramName + '.min'"
|
||||||
class="form-control" (blur)="onSubmit()"
|
class="form-control" (blur)="onSubmit()"
|
||||||
|
@@ -16,7 +16,7 @@ import { RouterStub } from '../../../../shared/testing/router-stub';
|
|||||||
import { Router } from '@angular/router';
|
import { Router } from '@angular/router';
|
||||||
import { PageInfo } from '../../../../core/shared/page-info.model';
|
import { PageInfo } from '../../../../core/shared/page-info.model';
|
||||||
import { SearchRangeFilterComponent } from './search-range-filter.component';
|
import { SearchRangeFilterComponent } from './search-range-filter.component';
|
||||||
import { RouteService } from '../../../../shared/services/route.service';
|
import { RouteService } from '../../../../core/services/route.service';
|
||||||
import { RemoteDataBuildService } from '../../../../core/cache/builders/remote-data-build.service';
|
import { RemoteDataBuildService } from '../../../../core/cache/builders/remote-data-build.service';
|
||||||
import { SEARCH_CONFIG_SERVICE } from '../../../../+my-dspace-page/my-dspace-page.component';
|
import { SEARCH_CONFIG_SERVICE } from '../../../../+my-dspace-page/my-dspace-page.component';
|
||||||
import { SearchConfigurationServiceStub } from '../../../../shared/testing/search-configuration-service-stub';
|
import { SearchConfigurationServiceStub } from '../../../../shared/testing/search-configuration-service-stub';
|
||||||
|
@@ -14,7 +14,7 @@ import { FILTER_CONFIG, IN_PLACE_SEARCH, SearchFilterService } from '../search-f
|
|||||||
import { SearchService } from '../../../search-service/search.service';
|
import { SearchService } from '../../../search-service/search.service';
|
||||||
import { Router } from '@angular/router';
|
import { Router } from '@angular/router';
|
||||||
import * as moment from 'moment';
|
import * as moment from 'moment';
|
||||||
import { RouteService } from '../../../../shared/services/route.service';
|
import { RouteService } from '../../../../core/services/route.service';
|
||||||
import { hasValue } from '../../../../shared/empty.util';
|
import { hasValue } from '../../../../shared/empty.util';
|
||||||
import { SearchConfigurationService } from '../../../search-service/search-configuration.service';
|
import { SearchConfigurationService } from '../../../search-service/search-configuration.service';
|
||||||
import { SEARCH_CONFIG_SERVICE } from '../../../../+my-dspace-page/my-dspace-page.component';
|
import { SEARCH_CONFIG_SERVICE } from '../../../../+my-dspace-page/my-dspace-page.component';
|
||||||
|
@@ -17,7 +17,7 @@
|
|||||||
</div>
|
</div>
|
||||||
<ds-input-suggestions [suggestions]="(filterSearchResults | async)"
|
<ds-input-suggestions [suggestions]="(filterSearchResults | async)"
|
||||||
[placeholder]="'search.filters.filter.' + filterConfig.name + '.placeholder'| translate"
|
[placeholder]="'search.filters.filter.' + filterConfig.name + '.placeholder'| translate"
|
||||||
[action]="getCurrentUrl()"
|
[action]="currentUrl"
|
||||||
[name]="filterConfig.paramName"
|
[name]="filterConfig.paramName"
|
||||||
[(ngModel)]="filter"
|
[(ngModel)]="filter"
|
||||||
(submitSuggestion)="onSubmit($event)"
|
(submitSuggestion)="onSubmit($event)"
|
||||||
|
@@ -4,4 +4,4 @@
|
|||||||
<ds-search-filter [filter]="filter" [inPlaceSearch]="inPlaceSearch"></ds-search-filter>
|
<ds-search-filter [filter]="filter" [inPlaceSearch]="inPlaceSearch"></ds-search-filter>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<a class="btn btn-primary" [routerLink]="[getSearchLink()]" [queryParams]="clearParams | async" queryParamsHandling="merge" role="button">{{"search.filters.reset" | translate}}</a>
|
<a class="btn btn-primary" [routerLink]="[searchLink]" [queryParams]="clearParams | async" queryParamsHandling="merge" role="button">{{"search.filters.reset" | translate}}</a>
|
||||||
|
@@ -58,7 +58,7 @@ describe('SearchFiltersComponent', () => {
|
|||||||
describe('when the getSearchLink method is called', () => {
|
describe('when the getSearchLink method is called', () => {
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
spyOn(searchService, 'getSearchLink');
|
spyOn(searchService, 'getSearchLink');
|
||||||
comp.getSearchLink();
|
(comp as any).getSearchLink();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should call getSearchLink on the searchService', () => {
|
it('should call getSearchLink on the searchService', () => {
|
||||||
|
@@ -37,6 +37,11 @@ export class SearchFiltersComponent implements OnInit {
|
|||||||
*/
|
*/
|
||||||
@Input() inPlaceSearch;
|
@Input() inPlaceSearch;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Link to the search page
|
||||||
|
*/
|
||||||
|
searchLink: string;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Initialize instance variables
|
* Initialize instance variables
|
||||||
* @param {SearchService} searchService
|
* @param {SearchService} searchService
|
||||||
@@ -60,12 +65,13 @@ export class SearchFiltersComponent implements OnInit {
|
|||||||
Object.keys(filters).forEach((f) => filters[f] = null);
|
Object.keys(filters).forEach((f) => filters[f] = null);
|
||||||
return filters;
|
return filters;
|
||||||
}));
|
}));
|
||||||
|
this.searchLink = this.getSearchLink();
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @returns {string} The base path to the search page, or the current page when inPlaceSearch is true
|
* @returns {string} The base path to the search page, or the current page when inPlaceSearch is true
|
||||||
*/
|
*/
|
||||||
public getSearchLink(): string {
|
private getSearchLink(): string {
|
||||||
if (this.inPlaceSearch) {
|
if (this.inPlaceSearch) {
|
||||||
return './';
|
return './';
|
||||||
}
|
}
|
||||||
|
@@ -0,0 +1,6 @@
|
|||||||
|
<a class="badge badge-primary mr-1 mb-1 text-capitalize"
|
||||||
|
[routerLink]="searchLink"
|
||||||
|
[queryParams]="(removeParameters | async)" queryParamsHandling="merge">
|
||||||
|
{{('search.filters.applied.' + key) | translate}}: {{normalizeFilterValue(value)}}
|
||||||
|
<span> ×</span>
|
||||||
|
</a>
|
@@ -0,0 +1,87 @@
|
|||||||
|
import { async, ComponentFixture, TestBed } from '@angular/core/testing';
|
||||||
|
import { NoopAnimationsModule } from '@angular/platform-browser/animations';
|
||||||
|
import { TranslateModule } from '@ngx-translate/core';
|
||||||
|
import { ChangeDetectionStrategy, NO_ERRORS_SCHEMA } from '@angular/core';
|
||||||
|
import { FormsModule } from '@angular/forms';
|
||||||
|
import { Observable, of as observableOf } from 'rxjs';
|
||||||
|
import { Params } from '@angular/router';
|
||||||
|
import { SearchLabelComponent } from './search-label.component';
|
||||||
|
import { ObjectKeysPipe } from '../../../shared/utils/object-keys-pipe';
|
||||||
|
import { SearchService } from '../../search-service/search.service';
|
||||||
|
import { SEARCH_CONFIG_SERVICE } from '../../../+my-dspace-page/my-dspace-page.component';
|
||||||
|
import { SearchServiceStub } from '../../../shared/testing/search-service-stub';
|
||||||
|
import { SearchConfigurationServiceStub } from '../../../shared/testing/search-configuration-service-stub';
|
||||||
|
|
||||||
|
describe('SearchLabelComponent', () => {
|
||||||
|
let comp: SearchLabelComponent;
|
||||||
|
let fixture: ComponentFixture<SearchLabelComponent>;
|
||||||
|
|
||||||
|
const searchLink = '/search';
|
||||||
|
let searchService;
|
||||||
|
|
||||||
|
const key1 = 'author';
|
||||||
|
const key2 = 'subject';
|
||||||
|
const value1 = 'Test, Author';
|
||||||
|
const normValue1 = 'Test, Author';
|
||||||
|
const value2 = 'TestSubject';
|
||||||
|
const value3 = 'Test, Authority,authority';
|
||||||
|
const normValue3 = 'Test, Authority';
|
||||||
|
const filter1 = [key1, value1];
|
||||||
|
const filter2 = [key2, value2];
|
||||||
|
const mockFilters = [
|
||||||
|
filter1,
|
||||||
|
filter2
|
||||||
|
];
|
||||||
|
|
||||||
|
beforeEach(async(() => {
|
||||||
|
TestBed.configureTestingModule({
|
||||||
|
imports: [TranslateModule.forRoot(), NoopAnimationsModule, FormsModule],
|
||||||
|
declarations: [SearchLabelComponent, ObjectKeysPipe],
|
||||||
|
providers: [
|
||||||
|
{ provide: SearchService, useValue: new SearchServiceStub(searchLink) },
|
||||||
|
{ provide: SEARCH_CONFIG_SERVICE, useValue: new SearchConfigurationServiceStub() }
|
||||||
|
// { provide: SearchConfigurationService, useValue: {getCurrentFrontendFilters : () => observableOf({})} }
|
||||||
|
],
|
||||||
|
schemas: [NO_ERRORS_SCHEMA]
|
||||||
|
}).overrideComponent(SearchLabelComponent, {
|
||||||
|
set: { changeDetection: ChangeDetectionStrategy.Default }
|
||||||
|
}).compileComponents();
|
||||||
|
}));
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
fixture = TestBed.createComponent(SearchLabelComponent);
|
||||||
|
comp = fixture.componentInstance;
|
||||||
|
searchService = (comp as any).searchService;
|
||||||
|
comp.key = key1;
|
||||||
|
comp.value = value1;
|
||||||
|
(comp as any).appliedFilters = observableOf(mockFilters);
|
||||||
|
fixture.detectChanges();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('when getRemoveParams is called', () => {
|
||||||
|
let obs: Observable<Params>;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
obs = comp.getRemoveParams();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return all params but the provided filter', () => {
|
||||||
|
obs.subscribe((params) => {
|
||||||
|
// Should contain only filter2 and page: length == 2
|
||||||
|
expect(Object.keys(params).length).toBe(2);
|
||||||
|
});
|
||||||
|
})
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('when normalizeFilterValue is called', () => {
|
||||||
|
it('should return properly filter value', () => {
|
||||||
|
let result: string;
|
||||||
|
|
||||||
|
result = comp.normalizeFilterValue(value1);
|
||||||
|
expect(result).toBe(normValue1);
|
||||||
|
|
||||||
|
result = comp.normalizeFilterValue(value3);
|
||||||
|
expect(result).toBe(normValue3);
|
||||||
|
})
|
||||||
|
});
|
||||||
|
});
|
@@ -0,0 +1,75 @@
|
|||||||
|
import { Component, Input, OnInit } from '@angular/core';
|
||||||
|
import { Observable } from 'rxjs';
|
||||||
|
import { Params } from '@angular/router';
|
||||||
|
import { SearchService } from '../../search-service/search.service';
|
||||||
|
import { map } from 'rxjs/operators';
|
||||||
|
import { hasValue, isNotEmpty } from '../../../shared/empty.util';
|
||||||
|
|
||||||
|
@Component({
|
||||||
|
selector: 'ds-search-label',
|
||||||
|
templateUrl: './search-label.component.html',
|
||||||
|
})
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Component that represents the label containing the currently active filters
|
||||||
|
*/
|
||||||
|
export class SearchLabelComponent implements OnInit {
|
||||||
|
@Input() key: string;
|
||||||
|
@Input() value: string;
|
||||||
|
@Input() inPlaceSearch: boolean;
|
||||||
|
@Input() appliedFilters: Observable<Params>;
|
||||||
|
searchLink: string;
|
||||||
|
removeParameters: Observable<Params>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Initialize the instance variable
|
||||||
|
*/
|
||||||
|
constructor(
|
||||||
|
private searchService: SearchService) {
|
||||||
|
}
|
||||||
|
|
||||||
|
ngOnInit(): void {
|
||||||
|
this.searchLink = this.getSearchLink();
|
||||||
|
this.removeParameters = this.getRemoveParams();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Calculates the parameters that should change if a given value for the given filter would be removed from the active filters
|
||||||
|
* @returns {Observable<Params>} The changed filter parameters
|
||||||
|
*/
|
||||||
|
getRemoveParams(): Observable<Params> {
|
||||||
|
return this.appliedFilters.pipe(
|
||||||
|
map((filters) => {
|
||||||
|
const field: string = Object.keys(filters).find((f) => f === this.key);
|
||||||
|
const newValues = hasValue(filters[field]) ? filters[field].filter((v) => v !== this.value) : null;
|
||||||
|
return {
|
||||||
|
[field]: isNotEmpty(newValues) ? newValues : null,
|
||||||
|
page: 1
|
||||||
|
};
|
||||||
|
})
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @returns {string} The base path to the search page, or the current page when inPlaceSearch is true
|
||||||
|
*/
|
||||||
|
private getSearchLink(): string {
|
||||||
|
if (this.inPlaceSearch) {
|
||||||
|
return './';
|
||||||
|
}
|
||||||
|
return this.searchService.getSearchLink();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* TODO to review after https://github.com/DSpace/dspace-angular/issues/368 is resolved
|
||||||
|
* Strips authority operator from filter value
|
||||||
|
* e.g. 'test ,authority' => 'test'
|
||||||
|
*
|
||||||
|
* @param value
|
||||||
|
*/
|
||||||
|
normalizeFilterValue(value: string) {
|
||||||
|
// const pattern = /,[^,]*$/g;
|
||||||
|
const pattern = /,authority*$/g;
|
||||||
|
return value.replace(pattern, '');
|
||||||
|
}
|
||||||
|
}
|
@@ -1,13 +1,7 @@
|
|||||||
<div class="row mb-3 mb-md-1">
|
<div class="row mb-3 mb-md-1">
|
||||||
<div class="labels col-sm-9 offset-sm-3">
|
<div class="labels col-sm-9 offset-sm-3">
|
||||||
<ng-container *ngFor="let key of ((appliedFilters | async) | dsObjectKeys)"><!--Do not remove this to prevent uneven spacing
|
<ng-container *ngFor="let key of ((appliedFilters | async) | dsObjectKeys)">
|
||||||
--><a *ngFor="let values of (appliedFilters | async)[key]"
|
<ds-search-label *ngFor="let value of (appliedFilters | async)[key]" [inPlaceSearch]="inPlaceSearch" [key]="key" [value]="value" [appliedFilters]="appliedFilters"></ds-search-label>
|
||||||
class="badge badge-primary mr-1 mb-1 text-capitalize"
|
</ng-container>
|
||||||
[routerLink]="getSearchLink()"
|
|
||||||
[queryParams]="(getRemoveParams(key, values) | async)" queryParamsHandling="merge">
|
|
||||||
{{('search.filters.applied.' + key) | translate}}: {{normalizeFilterValue(values)}}
|
|
||||||
<span> ×</span>
|
|
||||||
</a><!--Do not remove this to prevent uneven spacing
|
|
||||||
--></ng-container>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
@@ -6,11 +6,9 @@ import { SearchService } from '../search-service/search.service';
|
|||||||
import { ChangeDetectionStrategy, NO_ERRORS_SCHEMA } from '@angular/core';
|
import { ChangeDetectionStrategy, NO_ERRORS_SCHEMA } from '@angular/core';
|
||||||
import { FormsModule } from '@angular/forms';
|
import { FormsModule } from '@angular/forms';
|
||||||
import { SearchServiceStub } from '../../shared/testing/search-service-stub';
|
import { SearchServiceStub } from '../../shared/testing/search-service-stub';
|
||||||
import { Observable, of as observableOf } from 'rxjs';
|
import { of as observableOf } from 'rxjs';
|
||||||
import { Params } from '@angular/router';
|
|
||||||
import { ObjectKeysPipe } from '../../shared/utils/object-keys-pipe';
|
import { ObjectKeysPipe } from '../../shared/utils/object-keys-pipe';
|
||||||
import { SEARCH_CONFIG_SERVICE } from '../../+my-dspace-page/my-dspace-page.component';
|
import { SEARCH_CONFIG_SERVICE } from '../../+my-dspace-page/my-dspace-page.component';
|
||||||
import { SearchConfigurationServiceStub } from '../../shared/testing/search-configuration-service-stub';
|
|
||||||
|
|
||||||
describe('SearchLabelsComponent', () => {
|
describe('SearchLabelsComponent', () => {
|
||||||
let comp: SearchLabelsComponent;
|
let comp: SearchLabelsComponent;
|
||||||
@@ -22,10 +20,7 @@ describe('SearchLabelsComponent', () => {
|
|||||||
const field1 = 'author';
|
const field1 = 'author';
|
||||||
const field2 = 'subject';
|
const field2 = 'subject';
|
||||||
const value1 = 'Test, Author';
|
const value1 = 'Test, Author';
|
||||||
const normValue1 = 'Test, Author';
|
|
||||||
const value2 = 'TestSubject';
|
const value2 = 'TestSubject';
|
||||||
const value3 = 'Test, Authority,authority';
|
|
||||||
const normValue3 = 'Test, Authority';
|
|
||||||
const filter1 = [field1, value1];
|
const filter1 = [field1, value1];
|
||||||
const filter2 = [field2, value2];
|
const filter2 = [field2, value2];
|
||||||
const mockFilters = [
|
const mockFilters = [
|
||||||
@@ -39,8 +34,7 @@ describe('SearchLabelsComponent', () => {
|
|||||||
declarations: [SearchLabelsComponent, ObjectKeysPipe],
|
declarations: [SearchLabelsComponent, ObjectKeysPipe],
|
||||||
providers: [
|
providers: [
|
||||||
{ provide: SearchService, useValue: new SearchServiceStub(searchLink) },
|
{ provide: SearchService, useValue: new SearchServiceStub(searchLink) },
|
||||||
{ provide: SEARCH_CONFIG_SERVICE, useValue: new SearchConfigurationServiceStub() }
|
{ provide: SEARCH_CONFIG_SERVICE, useValue: { getCurrentFrontendFilters: () => observableOf(mockFilters) } }
|
||||||
// { provide: SearchConfigurationService, useValue: {getCurrentFrontendFilters : () => observableOf({})} }
|
|
||||||
],
|
],
|
||||||
schemas: [NO_ERRORS_SCHEMA]
|
schemas: [NO_ERRORS_SCHEMA]
|
||||||
}).overrideComponent(SearchLabelsComponent, {
|
}).overrideComponent(SearchLabelsComponent, {
|
||||||
@@ -56,30 +50,11 @@ describe('SearchLabelsComponent', () => {
|
|||||||
fixture.detectChanges();
|
fixture.detectChanges();
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('when getRemoveParams is called', () => {
|
describe('when the component has been initialized', () => {
|
||||||
let obs: Observable<Params>;
|
|
||||||
|
|
||||||
beforeEach(() => {
|
|
||||||
obs = comp.getRemoveParams(filter1[0], filter1[1]);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should return all params but the provided filter', () => {
|
it('should return all params but the provided filter', () => {
|
||||||
obs.subscribe((params) => {
|
comp.appliedFilters.subscribe((filters) => {
|
||||||
// Should contain only filter2 and page: length == 2
|
expect(filters).toBe(mockFilters);
|
||||||
expect(Object.keys(params).length).toBe(2);
|
|
||||||
});
|
});
|
||||||
})
|
})
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('when normalizeFilterValue is called', () => {
|
|
||||||
it('should return properly filter value', () => {
|
|
||||||
let result: string;
|
|
||||||
|
|
||||||
result = comp.normalizeFilterValue(value1);
|
|
||||||
expect(result).toBe(normValue1);
|
|
||||||
|
|
||||||
result = comp.normalizeFilterValue(value3);
|
|
||||||
expect(result).toBe(normValue3);
|
|
||||||
})
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
@@ -1,4 +1,4 @@
|
|||||||
import { Component, Inject, Input } from '@angular/core';
|
import { Component, Inject, Input, OnInit } from '@angular/core';
|
||||||
import { SearchService } from '../search-service/search.service';
|
import { SearchService } from '../search-service/search.service';
|
||||||
import { Observable } from 'rxjs';
|
import { Observable } from 'rxjs';
|
||||||
import { Params } from '@angular/router';
|
import { Params } from '@angular/router';
|
||||||
@@ -31,50 +31,7 @@ export class SearchLabelsComponent {
|
|||||||
* Initialize the instance variable
|
* Initialize the instance variable
|
||||||
*/
|
*/
|
||||||
constructor(
|
constructor(
|
||||||
private searchService: SearchService,
|
|
||||||
@Inject(SEARCH_CONFIG_SERVICE) public searchConfigService: SearchConfigurationService) {
|
@Inject(SEARCH_CONFIG_SERVICE) public searchConfigService: SearchConfigurationService) {
|
||||||
this.appliedFilters = this.searchConfigService.getCurrentFrontendFilters();
|
this.appliedFilters = this.searchConfigService.getCurrentFrontendFilters();
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Calculates the parameters that should change if a given value for the given filter would be removed from the active filters
|
|
||||||
* @param {string} filterField The filter field parameter name from which the value should be removed
|
|
||||||
* @param {string} filterValue The value that is removed for this given filter field
|
|
||||||
* @returns {Observable<Params>} The changed filter parameters
|
|
||||||
*/
|
|
||||||
getRemoveParams(filterField: string, filterValue: string): Observable<Params> {
|
|
||||||
return this.appliedFilters.pipe(
|
|
||||||
map((filters) => {
|
|
||||||
const field: string = Object.keys(filters).find((f) => f === filterField);
|
|
||||||
const newValues = hasValue(filters[field]) ? filters[field].filter((v) => v !== filterValue) : null;
|
|
||||||
return {
|
|
||||||
[field]: isNotEmpty(newValues) ? newValues : null,
|
|
||||||
page: 1
|
|
||||||
};
|
|
||||||
})
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @returns {string} The base path to the search page, or the current page when inPlaceSearch is true
|
|
||||||
*/
|
|
||||||
public getSearchLink(): string {
|
|
||||||
if (this.inPlaceSearch) {
|
|
||||||
return './';
|
|
||||||
}
|
|
||||||
return this.searchService.getSearchLink();
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* TODO to review after https://github.com/DSpace/dspace-angular/issues/368 is resolved
|
|
||||||
* Strips authority operator from filter value
|
|
||||||
* e.g. 'test ,authority' => 'test'
|
|
||||||
*
|
|
||||||
* @param value
|
|
||||||
*/
|
|
||||||
normalizeFilterValue(value: string) {
|
|
||||||
// const pattern = /,[^,]*$/g;
|
|
||||||
const pattern = /,authority*$/g;
|
|
||||||
return value.replace(pattern, '');
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
@@ -13,4 +13,5 @@ import { ConfigurationSearchPageComponent } from './configuration-search-page.co
|
|||||||
])
|
])
|
||||||
]
|
]
|
||||||
})
|
})
|
||||||
export class SearchPageRoutingModule { }
|
export class SearchPageRoutingModule {
|
||||||
|
}
|
||||||
|
@@ -7,7 +7,7 @@
|
|||||||
<ds-search-form *ngIf="searchEnabled" id="search-form"
|
<ds-search-form *ngIf="searchEnabled" id="search-form"
|
||||||
[query]="(searchOptions$ | async)?.query"
|
[query]="(searchOptions$ | async)?.query"
|
||||||
[scope]="(searchOptions$ | async)?.scope"
|
[scope]="(searchOptions$ | async)?.scope"
|
||||||
[currentUrl]="getSearchLink()"
|
[currentUrl]="searchLink"
|
||||||
[scopes]="(scopeListRD$ | async)"
|
[scopes]="(scopeListRD$ | async)"
|
||||||
[inPlaceSearch]="inPlaceSearch">
|
[inPlaceSearch]="inPlaceSearch">
|
||||||
</ds-search-form>
|
</ds-search-form>
|
||||||
@@ -15,12 +15,12 @@
|
|||||||
<div class="row">
|
<div class="row">
|
||||||
<div id="search-body"
|
<div id="search-body"
|
||||||
class="row-offcanvas row-offcanvas-left"
|
class="row-offcanvas row-offcanvas-left"
|
||||||
[@pushInOut]="(isSidebarCollapsed() | async) ? 'collapsed' : 'expanded'">
|
[@pushInOut]="(isSidebarCollapsed$ | async) ? 'collapsed' : 'expanded'">
|
||||||
<ds-search-sidebar *ngIf="(isXsOrSm$ | async)" class="col-12"
|
<ds-search-sidebar *ngIf="(isXsOrSm$ | async)" class="col-12"
|
||||||
id="search-sidebar-sm"
|
id="search-sidebar-sm"
|
||||||
[resultCount]="(resultsRD$ | async)?.payload.totalElements"
|
[resultCount]="(resultsRD$ | async)?.payload.totalElements"
|
||||||
(toggleSidebar)="closeSidebar()"
|
(toggleSidebar)="closeSidebar()"
|
||||||
[ngClass]="{'active': !(isSidebarCollapsed() | async)}">
|
[ngClass]="{'active': !(isSidebarCollapsed$ | async)}">
|
||||||
</ds-search-sidebar>
|
</ds-search-sidebar>
|
||||||
<div id="search-content" class="col-12">
|
<div id="search-content" class="col-12">
|
||||||
<div class="d-block d-md-none search-controls clearfix">
|
<div class="d-block d-md-none search-controls clearfix">
|
||||||
|
@@ -21,7 +21,7 @@ import { SearchFilterService } from './search-filters/search-filter/search-filte
|
|||||||
import { SearchConfigurationService } from './search-service/search-configuration.service';
|
import { SearchConfigurationService } from './search-service/search-configuration.service';
|
||||||
import { RemoteData } from '../core/data/remote-data';
|
import { RemoteData } from '../core/data/remote-data';
|
||||||
import { SEARCH_CONFIG_SERVICE } from '../+my-dspace-page/my-dspace-page.component';
|
import { SEARCH_CONFIG_SERVICE } from '../+my-dspace-page/my-dspace-page.component';
|
||||||
import { RouteService } from '../shared/services/route.service';
|
import { RouteService } from '../core/services/route.service';
|
||||||
import { SearchConfigurationServiceStub } from '../shared/testing/search-configuration-service-stub';
|
import { SearchConfigurationServiceStub } from '../shared/testing/search-configuration-service-stub';
|
||||||
import { PaginatedSearchOptions } from './paginated-search-options.model';
|
import { PaginatedSearchOptions } from './paginated-search-options.model';
|
||||||
import { SearchFixedFilterService } from './search-filters/search-filter/search-fixed-filter.service';
|
import { SearchFixedFilterService } from './search-filters/search-filter/search-fixed-filter.service';
|
||||||
@@ -201,7 +201,7 @@ describe('SearchPageComponent', () => {
|
|||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
menu = fixture.debugElement.query(By.css('#search-sidebar-sm')).nativeElement;
|
menu = fixture.debugElement.query(By.css('#search-sidebar-sm')).nativeElement;
|
||||||
comp.isSidebarCollapsed = () => observableOf(true);
|
(comp as any).isSidebarCollapsed$ = observableOf(true);
|
||||||
fixture.detectChanges();
|
fixture.detectChanges();
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -216,7 +216,7 @@ describe('SearchPageComponent', () => {
|
|||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
menu = fixture.debugElement.query(By.css('#search-sidebar-sm')).nativeElement;
|
menu = fixture.debugElement.query(By.css('#search-sidebar-sm')).nativeElement;
|
||||||
comp.isSidebarCollapsed = () => observableOf(false);
|
(comp as any).isSidebarCollapsed$ = observableOf(false);
|
||||||
fixture.detectChanges();
|
fixture.detectChanges();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
@@ -13,7 +13,7 @@ import { SearchSidebarService } from './search-sidebar/search-sidebar.service';
|
|||||||
import { hasValue, isNotEmpty } from '../shared/empty.util';
|
import { hasValue, isNotEmpty } from '../shared/empty.util';
|
||||||
import { SearchConfigurationService } from './search-service/search-configuration.service';
|
import { SearchConfigurationService } from './search-service/search-configuration.service';
|
||||||
import { getSucceededRemoteData } from '../core/shared/operators';
|
import { getSucceededRemoteData } from '../core/shared/operators';
|
||||||
import { RouteService } from '../shared/services/route.service';
|
import { RouteService } from '../core/services/route.service';
|
||||||
import { SEARCH_CONFIG_SERVICE } from '../+my-dspace-page/my-dspace-page.component';
|
import { SEARCH_CONFIG_SERVICE } from '../+my-dspace-page/my-dspace-page.component';
|
||||||
|
|
||||||
export const SEARCH_ROUTE = '/search';
|
export const SEARCH_ROUTE = '/search';
|
||||||
@@ -91,6 +91,16 @@ export class SearchPageComponent implements OnInit {
|
|||||||
@Input()
|
@Input()
|
||||||
configuration$: Observable<string>;
|
configuration$: Observable<string>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Link to the search page
|
||||||
|
*/
|
||||||
|
searchLink: string;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Observable for whether or not the sidebar is currently collapsed
|
||||||
|
*/
|
||||||
|
isSidebarCollapsed$: Observable<boolean>;
|
||||||
|
|
||||||
constructor(protected service: SearchService,
|
constructor(protected service: SearchService,
|
||||||
protected sidebarService: SearchSidebarService,
|
protected sidebarService: SearchSidebarService,
|
||||||
protected windowService: HostWindowService,
|
protected windowService: HostWindowService,
|
||||||
@@ -107,9 +117,11 @@ export class SearchPageComponent implements OnInit {
|
|||||||
* If something changes, update the list of scopes for the dropdown
|
* If something changes, update the list of scopes for the dropdown
|
||||||
*/
|
*/
|
||||||
ngOnInit(): void {
|
ngOnInit(): void {
|
||||||
|
this.isSidebarCollapsed$ = this.isSidebarCollapsed();
|
||||||
|
this.searchLink = this.getSearchLink();
|
||||||
this.searchOptions$ = this.getSearchOptions();
|
this.searchOptions$ = this.getSearchOptions();
|
||||||
this.sub = this.searchOptions$.pipe(
|
this.sub = this.searchOptions$.pipe(
|
||||||
switchMap((options) => this.service.search(options).pipe(getSucceededRemoteData(), startWith(observableOf(undefined)))))
|
switchMap((options) => this.service.search(options).pipe(getSucceededRemoteData(), startWith(undefined))))
|
||||||
.subscribe((results) => {
|
.subscribe((results) => {
|
||||||
this.resultsRD$.next(results);
|
this.resultsRD$.next(results);
|
||||||
});
|
});
|
||||||
@@ -147,14 +159,14 @@ export class SearchPageComponent implements OnInit {
|
|||||||
* Check if the sidebar is collapsed
|
* Check if the sidebar is collapsed
|
||||||
* @returns {Observable<boolean>} emits true if the sidebar is currently collapsed, false if it is expanded
|
* @returns {Observable<boolean>} emits true if the sidebar is currently collapsed, false if it is expanded
|
||||||
*/
|
*/
|
||||||
public isSidebarCollapsed(): Observable<boolean> {
|
private isSidebarCollapsed(): Observable<boolean> {
|
||||||
return this.sidebarService.isCollapsed;
|
return this.sidebarService.isCollapsed;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @returns {string} The base path to the search page, or the current page when inPlaceSearch is true
|
* @returns {string} The base path to the search page, or the current page when inPlaceSearch is true
|
||||||
*/
|
*/
|
||||||
public getSearchLink(): string {
|
private getSearchLink(): string {
|
||||||
if (this.inPlaceSearch) {
|
if (this.inPlaceSearch) {
|
||||||
return './';
|
return './';
|
||||||
}
|
}
|
||||||
|
@@ -30,6 +30,7 @@ import { SearchFacetSelectedOptionComponent } from './search-filters/search-filt
|
|||||||
import { SearchFacetRangeOptionComponent } from './search-filters/search-filter/search-facet-filter-options/search-facet-range-option/search-facet-range-option.component';
|
import { SearchFacetRangeOptionComponent } from './search-filters/search-filter/search-facet-filter-options/search-facet-range-option/search-facet-range-option.component';
|
||||||
import { SearchSwitchConfigurationComponent } from './search-switch-configuration/search-switch-configuration.component';
|
import { SearchSwitchConfigurationComponent } from './search-switch-configuration/search-switch-configuration.component';
|
||||||
import { SearchAuthorityFilterComponent } from './search-filters/search-filter/search-authority-filter/search-authority-filter.component';
|
import { SearchAuthorityFilterComponent } from './search-filters/search-filter/search-authority-filter/search-authority-filter.component';
|
||||||
|
import { SearchLabelComponent } from './search-labels/search-label/search-label.component';
|
||||||
import { ConfigurationSearchPageComponent } from './configuration-search-page.component';
|
import { ConfigurationSearchPageComponent } from './configuration-search-page.component';
|
||||||
import { ConfigurationSearchPageGuard } from './configuration-search-page.guard';
|
import { ConfigurationSearchPageGuard } from './configuration-search-page.guard';
|
||||||
import { FilteredSearchPageComponent } from './filtered-search-page.component';
|
import { FilteredSearchPageComponent } from './filtered-search-page.component';
|
||||||
@@ -50,6 +51,7 @@ const components = [
|
|||||||
SearchFilterComponent,
|
SearchFilterComponent,
|
||||||
SearchFacetFilterComponent,
|
SearchFacetFilterComponent,
|
||||||
SearchLabelsComponent,
|
SearchLabelsComponent,
|
||||||
|
SearchLabelComponent,
|
||||||
SearchFacetFilterComponent,
|
SearchFacetFilterComponent,
|
||||||
SearchFacetFilterWrapperComponent,
|
SearchFacetFilterWrapperComponent,
|
||||||
SearchRangeFilterComponent,
|
SearchRangeFilterComponent,
|
||||||
|
@@ -14,7 +14,7 @@ import { SortDirection, SortOptions } from '../../core/cache/models/sort-options
|
|||||||
import { PaginationComponentOptions } from '../../shared/pagination/pagination-component-options.model';
|
import { PaginationComponentOptions } from '../../shared/pagination/pagination-component-options.model';
|
||||||
import { SearchOptions } from '../search-options.model';
|
import { SearchOptions } from '../search-options.model';
|
||||||
import { PaginatedSearchOptions } from '../paginated-search-options.model';
|
import { PaginatedSearchOptions } from '../paginated-search-options.model';
|
||||||
import { RouteService } from '../../shared/services/route.service';
|
import { RouteService } from '../../core/services/route.service';
|
||||||
import { hasNoValue, hasValue, isNotEmpty, isNotEmptyOperator } from '../../shared/empty.util';
|
import { hasNoValue, hasValue, isNotEmpty, isNotEmptyOperator } from '../../shared/empty.util';
|
||||||
import { RemoteData } from '../../core/data/remote-data';
|
import { RemoteData } from '../../core/data/remote-data';
|
||||||
import { getSucceededRemoteData } from '../../core/shared/operators';
|
import { getSucceededRemoteData } from '../../core/shared/operators';
|
||||||
|
@@ -26,7 +26,7 @@ import { CommunityDataService } from '../../core/data/community-data.service';
|
|||||||
import { ViewMode } from '../../core/shared/view-mode.model';
|
import { ViewMode } from '../../core/shared/view-mode.model';
|
||||||
import { DSpaceObjectDataService } from '../../core/data/dspace-object-data.service';
|
import { DSpaceObjectDataService } from '../../core/data/dspace-object-data.service';
|
||||||
import { map } from 'rxjs/operators';
|
import { map } from 'rxjs/operators';
|
||||||
import { RouteService } from '../../shared/services/route.service';
|
import { RouteService } from '../../core/services/route.service';
|
||||||
import { routeServiceStub } from '../../shared/testing/route-service-stub';
|
import { routeServiceStub } from '../../shared/testing/route-service-stub';
|
||||||
import { createSuccessfulRemoteDataObject$ } from '../../shared/testing/utils';
|
import { createSuccessfulRemoteDataObject$ } from '../../shared/testing/utils';
|
||||||
|
|
||||||
|
@@ -1,7 +1,7 @@
|
|||||||
import { combineLatest as observableCombineLatest, Observable, of as observableOf } from 'rxjs';
|
import { combineLatest as observableCombineLatest, Observable, of as observableOf, zip as observableZip } from 'rxjs';
|
||||||
import { Injectable, OnDestroy } from '@angular/core';
|
import { Injectable, OnDestroy } from '@angular/core';
|
||||||
import { NavigationExtras, Router } from '@angular/router';
|
import { NavigationExtras, PRIMARY_OUTLET, Router, UrlSegmentGroup } from '@angular/router';
|
||||||
import { first, map, switchMap } from 'rxjs/operators';
|
import { first, map, switchMap, tap } from 'rxjs/operators';
|
||||||
import { RemoteDataBuildService } from '../../core/cache/builders/remote-data-build.service';
|
import { RemoteDataBuildService } from '../../core/cache/builders/remote-data-build.service';
|
||||||
import {
|
import {
|
||||||
FacetConfigSuccessResponse,
|
FacetConfigSuccessResponse,
|
||||||
@@ -23,7 +23,7 @@ import {
|
|||||||
getSucceededRemoteData
|
getSucceededRemoteData
|
||||||
} from '../../core/shared/operators';
|
} from '../../core/shared/operators';
|
||||||
import { URLCombiner } from '../../core/url-combiner/url-combiner';
|
import { URLCombiner } from '../../core/url-combiner/url-combiner';
|
||||||
import { hasValue, isEmpty, isNotEmpty, isNotUndefined } from '../../shared/empty.util';
|
import { hasValue, hasValueOperator, isEmpty, isNotEmpty, isNotUndefined } from '../../shared/empty.util';
|
||||||
import { NormalizedSearchResult } from '../normalized-search-result.model';
|
import { NormalizedSearchResult } from '../normalized-search-result.model';
|
||||||
import { SearchOptions } from '../search-options.model';
|
import { SearchOptions } from '../search-options.model';
|
||||||
import { SearchResult } from '../search-result.model';
|
import { SearchResult } from '../search-result.model';
|
||||||
@@ -41,7 +41,7 @@ import { Community } from '../../core/shared/community.model';
|
|||||||
import { CommunityDataService } from '../../core/data/community-data.service';
|
import { CommunityDataService } from '../../core/data/community-data.service';
|
||||||
import { ViewMode } from '../../core/shared/view-mode.model';
|
import { ViewMode } from '../../core/shared/view-mode.model';
|
||||||
import { DSpaceObjectDataService } from '../../core/data/dspace-object-data.service';
|
import { DSpaceObjectDataService } from '../../core/data/dspace-object-data.service';
|
||||||
import { RouteService } from '../../shared/services/route.service';
|
import { RouteService } from '../../core/services/route.service';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Service that performs all general actions that have to do with the search page
|
* Service that performs all general actions that have to do with the search page
|
||||||
@@ -103,11 +103,18 @@ export class SearchService implements OnDestroy {
|
|||||||
* @returns {Observable<RemoteData<PaginatedList<SearchResult<DSpaceObject>>>>} Emits a paginated list with all search results found
|
* @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): Observable<RemoteData<PaginatedList<SearchResult<DSpaceObject>>>> {
|
||||||
const requestObs = this.halService.getEndpoint(this.searchLinkPath).pipe(
|
const hrefObs = this.halService.getEndpoint(this.searchLinkPath).pipe(
|
||||||
map((url: string) => {
|
map((url: string) => {
|
||||||
if (hasValue(searchOptions)) {
|
if (hasValue(searchOptions)) {
|
||||||
url = (searchOptions as PaginatedSearchOptions).toRestUrl(url);
|
return (searchOptions as PaginatedSearchOptions).toRestUrl(url);
|
||||||
|
} else {
|
||||||
|
return url;
|
||||||
}
|
}
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
const requestObs = hrefObs.pipe(
|
||||||
|
map((url: string) => {
|
||||||
const request = new this.request(this.requestService.generateRequestId(), url);
|
const request = new this.request(this.requestService.generateRequestId(), url);
|
||||||
|
|
||||||
const getResponseParserFn: () => GenericConstructor<ResponseParsingService> = () => {
|
const getResponseParserFn: () => GenericConstructor<ResponseParsingService> = () => {
|
||||||
@@ -136,10 +143,11 @@ export class SearchService implements OnDestroy {
|
|||||||
map((sqr: SearchQueryResponse) => {
|
map((sqr: SearchQueryResponse) => {
|
||||||
return sqr.objects
|
return sqr.objects
|
||||||
.filter((nsr: NormalizedSearchResult) => isNotUndefined(nsr.indexableObject))
|
.filter((nsr: NormalizedSearchResult) => isNotUndefined(nsr.indexableObject))
|
||||||
.map((nsr: NormalizedSearchResult) => {
|
.map((nsr: NormalizedSearchResult) => new GetRequest(this.requestService.generateRequestId(), nsr.indexableObject))
|
||||||
return this.rdb.buildSingle(nsr.indexableObject);
|
|
||||||
})
|
|
||||||
}),
|
}),
|
||||||
|
// Send a request for each item to ensure fresh cache
|
||||||
|
tap((reqs: RestRequest[]) => reqs.forEach((req: RestRequest) => this.requestService.configure(req))),
|
||||||
|
map((reqs: RestRequest[]) => reqs.map((req: RestRequest) => this.rdb.buildSingle(req.href))),
|
||||||
switchMap((input: Array<Observable<RemoteData<DSpaceObject>>>) => this.rdb.aggregate(input)),
|
switchMap((input: Array<Observable<RemoteData<DSpaceObject>>>) => this.rdb.aggregate(input)),
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -168,11 +176,20 @@ export class SearchService implements OnDestroy {
|
|||||||
|
|
||||||
const payloadObs = observableCombineLatest(tDomainListObs, pageInfoObs).pipe(
|
const payloadObs = observableCombineLatest(tDomainListObs, pageInfoObs).pipe(
|
||||||
map(([tDomainList, pageInfo]) => {
|
map(([tDomainList, pageInfo]) => {
|
||||||
return new PaginatedList(pageInfo, tDomainList);
|
return new PaginatedList(pageInfo, tDomainList.filter((obj) => hasValue(obj)));
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
|
|
||||||
return this.rdb.toRemoteDataObservable(requestEntryObs, payloadObs);
|
return observableCombineLatest(hrefObs, tDomainListObs, requestEntryObs).pipe(
|
||||||
|
switchMap(([href, tDomainList, requestEntry]) => {
|
||||||
|
if (tDomainList.indexOf(undefined) > -1 && requestEntry && requestEntry.completed) {
|
||||||
|
this.requestService.removeByHrefSubstring(href);
|
||||||
|
return this.search(searchOptions)
|
||||||
|
} else {
|
||||||
|
return this.rdb.toRemoteDataObservable(requestEntryObs, payloadObs);
|
||||||
|
}
|
||||||
|
})
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
@@ -26,7 +26,7 @@ import { HostWindowResizeAction } from './shared/host-window.actions';
|
|||||||
import { MetadataService } from './core/metadata/metadata.service';
|
import { MetadataService } from './core/metadata/metadata.service';
|
||||||
|
|
||||||
import { GLOBAL_CONFIG, ENV_CONFIG } from '../config';
|
import { GLOBAL_CONFIG, ENV_CONFIG } from '../config';
|
||||||
import { NativeWindowRef, NativeWindowService } from './shared/services/window.service';
|
import { NativeWindowRef, NativeWindowService } from './core/services/window.service';
|
||||||
|
|
||||||
import { MockTranslateLoader } from './shared/mocks/mock-translate-loader';
|
import { MockTranslateLoader } from './shared/mocks/mock-translate-loader';
|
||||||
import { MockMetadataService } from './shared/mocks/mock-metadata-service';
|
import { MockMetadataService } from './shared/mocks/mock-metadata-service';
|
||||||
@@ -41,9 +41,11 @@ import { MenuServiceStub } from './shared/testing/menu-service-stub';
|
|||||||
import { HostWindowService } from './shared/host-window.service';
|
import { HostWindowService } from './shared/host-window.service';
|
||||||
import { HostWindowServiceStub } from './shared/testing/host-window-service-stub';
|
import { HostWindowServiceStub } from './shared/testing/host-window-service-stub';
|
||||||
import { ActivatedRoute, Router } from '@angular/router';
|
import { ActivatedRoute, Router } from '@angular/router';
|
||||||
import { RouteService } from './shared/services/route.service';
|
import { RouteService } from './core/services/route.service';
|
||||||
import { MockActivatedRoute } from './shared/mocks/mock-active-router';
|
import { MockActivatedRoute } from './shared/mocks/mock-active-router';
|
||||||
import { MockRouter } from './shared/mocks/mock-router';
|
import { MockRouter } from './shared/mocks/mock-router';
|
||||||
|
import { MockCookieService } from './shared/mocks/mock-cookie.service';
|
||||||
|
import { CookieService } from './core/services/cookie.service';
|
||||||
|
|
||||||
let comp: AppComponent;
|
let comp: AppComponent;
|
||||||
let fixture: ComponentFixture<AppComponent>;
|
let fixture: ComponentFixture<AppComponent>;
|
||||||
@@ -78,6 +80,7 @@ describe('App component', () => {
|
|||||||
{ provide: MenuService, useValue: menuService },
|
{ provide: MenuService, useValue: menuService },
|
||||||
{ provide: CSSVariableService, useClass: CSSVariableServiceStub },
|
{ provide: CSSVariableService, useClass: CSSVariableServiceStub },
|
||||||
{ provide: HostWindowService, useValue: new HostWindowServiceStub(800) },
|
{ provide: HostWindowService, useValue: new HostWindowServiceStub(800) },
|
||||||
|
{ provide: CookieService, useValue: new MockCookieService()},
|
||||||
AppComponent,
|
AppComponent,
|
||||||
RouteService
|
RouteService
|
||||||
],
|
],
|
||||||
|
@@ -19,11 +19,11 @@ import { GLOBAL_CONFIG, GlobalConfig } from '../config';
|
|||||||
import { MetadataService } from './core/metadata/metadata.service';
|
import { MetadataService } from './core/metadata/metadata.service';
|
||||||
import { HostWindowResizeAction } from './shared/host-window.actions';
|
import { HostWindowResizeAction } from './shared/host-window.actions';
|
||||||
import { HostWindowState } from './shared/host-window.reducer';
|
import { HostWindowState } from './shared/host-window.reducer';
|
||||||
import { NativeWindowRef, NativeWindowService } from './shared/services/window.service';
|
import { NativeWindowRef, NativeWindowService } from './core/services/window.service';
|
||||||
import { isAuthenticated } from './core/auth/selectors';
|
import { isAuthenticated } from './core/auth/selectors';
|
||||||
import { AuthService } from './core/auth/auth.service';
|
import { AuthService } from './core/auth/auth.service';
|
||||||
import { Angulartics2GoogleAnalytics } from 'angulartics2/ga';
|
import { Angulartics2GoogleAnalytics } from 'angulartics2/ga';
|
||||||
import { RouteService } from './shared/services/route.service';
|
import { RouteService } from './core/services/route.service';
|
||||||
import variables from '../styles/_exposed_variables.scss';
|
import variables from '../styles/_exposed_variables.scss';
|
||||||
import { CSSVariableService } from './shared/sass-helper/sass-helper.service';
|
import { CSSVariableService } from './shared/sass-helper/sass-helper.service';
|
||||||
import { MenuService } from './shared/menu/menu.service';
|
import { MenuService } from './shared/menu/menu.service';
|
||||||
@@ -32,6 +32,10 @@ import { combineLatest as combineLatestObservable, Observable, of } from 'rxjs';
|
|||||||
import { slideSidebarPadding } from './shared/animations/slide';
|
import { slideSidebarPadding } from './shared/animations/slide';
|
||||||
import { HostWindowService } from './shared/host-window.service';
|
import { HostWindowService } from './shared/host-window.service';
|
||||||
import { Theme } from '../config/theme.inferface';
|
import { Theme } from '../config/theme.inferface';
|
||||||
|
import { isNotEmpty } from './shared/empty.util';
|
||||||
|
import { CookieService } from './core/services/cookie.service';
|
||||||
|
|
||||||
|
export const LANG_COOKIE = 'language_cookie';
|
||||||
|
|
||||||
@Component({
|
@Component({
|
||||||
selector: 'ds-app',
|
selector: 'ds-app',
|
||||||
@@ -61,6 +65,7 @@ export class AppComponent implements OnInit, AfterViewInit {
|
|||||||
private cssService: CSSVariableService,
|
private cssService: CSSVariableService,
|
||||||
private menuService: MenuService,
|
private menuService: MenuService,
|
||||||
private windowService: HostWindowService,
|
private windowService: HostWindowService,
|
||||||
|
private cookie: CookieService
|
||||||
) {
|
) {
|
||||||
// Load all the languages that are defined as active from the config file
|
// Load all the languages that are defined as active from the config file
|
||||||
translate.addLangs(config.languages.filter((LangConfig) => LangConfig.active === true).map((a) => a.code));
|
translate.addLangs(config.languages.filter((LangConfig) => LangConfig.active === true).map((a) => a.code));
|
||||||
@@ -68,11 +73,20 @@ export class AppComponent implements OnInit, AfterViewInit {
|
|||||||
// Load the default language from the config file
|
// Load the default language from the config file
|
||||||
translate.setDefaultLang(config.defaultLanguage);
|
translate.setDefaultLang(config.defaultLanguage);
|
||||||
|
|
||||||
// Attempt to get the browser language from the user
|
// Attempt to get the language from a cookie
|
||||||
if (translate.getLangs().includes(translate.getBrowserLang())) {
|
const lang = cookie.get(LANG_COOKIE);
|
||||||
translate.use(translate.getBrowserLang());
|
if (isNotEmpty(lang)) {
|
||||||
|
// Cookie found
|
||||||
|
// Use the language from the cookie
|
||||||
|
translate.use(lang);
|
||||||
} else {
|
} else {
|
||||||
translate.use(config.defaultLanguage);
|
// Cookie not found
|
||||||
|
// Attempt to get the browser language from the user
|
||||||
|
if (translate.getLangs().includes(translate.getBrowserLang())) {
|
||||||
|
translate.use(translate.getBrowserLang());
|
||||||
|
} else {
|
||||||
|
translate.use(config.defaultLanguage);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
metadata.listenForRouteChange();
|
metadata.listenForRouteChange();
|
||||||
|
@@ -39,6 +39,7 @@ import { ExpandableAdminSidebarSectionComponent } from './+admin/admin-sidebar/e
|
|||||||
import { NavbarModule } from './navbar/navbar.module';
|
import { NavbarModule } from './navbar/navbar.module';
|
||||||
import { JournalEntitiesModule } from './entity-groups/journal-entities/journal-entities.module';
|
import { JournalEntitiesModule } from './entity-groups/journal-entities/journal-entities.module';
|
||||||
import { ResearchEntitiesModule } from './entity-groups/research-entities/research-entities.module';
|
import { ResearchEntitiesModule } from './entity-groups/research-entities/research-entities.module';
|
||||||
|
import { ClientCookieService } from './core/services/client-cookie.service';
|
||||||
|
|
||||||
export function getConfig() {
|
export function getConfig() {
|
||||||
return ENV_CONFIG;
|
return ENV_CONFIG;
|
||||||
@@ -97,7 +98,8 @@ const PROVIDERS = [
|
|||||||
{
|
{
|
||||||
provide: RouterStateSerializer,
|
provide: RouterStateSerializer,
|
||||||
useClass: DSpaceRouterStateSerializer
|
useClass: DSpaceRouterStateSerializer
|
||||||
}
|
},
|
||||||
|
ClientCookieService
|
||||||
];
|
];
|
||||||
|
|
||||||
const DECLARATIONS = [
|
const DECLARATIONS = [
|
||||||
|
@@ -7,12 +7,12 @@ import { REQUEST } from '@nguniversal/express-engine/tokens';
|
|||||||
import { of as observableOf } from 'rxjs';
|
import { of as observableOf } from 'rxjs';
|
||||||
|
|
||||||
import { authReducer, AuthState } from './auth.reducer';
|
import { authReducer, AuthState } from './auth.reducer';
|
||||||
import { NativeWindowRef, NativeWindowService } from '../../shared/services/window.service';
|
import { NativeWindowRef, NativeWindowService } from '../services/window.service';
|
||||||
import { AuthService } from './auth.service';
|
import { AuthService } from './auth.service';
|
||||||
import { RouterStub } from '../../shared/testing/router-stub';
|
import { RouterStub } from '../../shared/testing/router-stub';
|
||||||
import { ActivatedRouteStub } from '../../shared/testing/active-router-stub';
|
import { ActivatedRouteStub } from '../../shared/testing/active-router-stub';
|
||||||
|
|
||||||
import { CookieService } from '../../shared/services/cookie.service';
|
import { CookieService } from '../services/cookie.service';
|
||||||
import { AuthRequestServiceStub } from '../../shared/testing/auth-request-service-stub';
|
import { AuthRequestServiceStub } from '../../shared/testing/auth-request-service-stub';
|
||||||
import { AuthRequestService } from './auth-request.service';
|
import { AuthRequestService } from './auth-request.service';
|
||||||
import { AuthStatus } from './models/auth-status.model';
|
import { AuthStatus } from './models/auth-status.model';
|
||||||
@@ -20,7 +20,7 @@ import { AuthTokenInfo } from './models/auth-token-info.model';
|
|||||||
import { EPerson } from '../eperson/models/eperson.model';
|
import { EPerson } from '../eperson/models/eperson.model';
|
||||||
import { EPersonMock } from '../../shared/testing/eperson-mock';
|
import { EPersonMock } from '../../shared/testing/eperson-mock';
|
||||||
import { AppState } from '../../app.reducer';
|
import { AppState } from '../../app.reducer';
|
||||||
import { ClientCookieService } from '../../shared/services/client-cookie.service';
|
import { ClientCookieService } from '../services/client-cookie.service';
|
||||||
import { RemoteDataBuildService } from '../cache/builders/remote-data-build.service';
|
import { RemoteDataBuildService } from '../cache/builders/remote-data-build.service';
|
||||||
import { getMockRemoteDataBuildService } from '../../shared/mocks/mock-remote-data-build.service';
|
import { getMockRemoteDataBuildService } from '../../shared/mocks/mock-remote-data-build.service';
|
||||||
|
|
||||||
|
@@ -15,11 +15,11 @@ import { HttpOptions } from '../dspace-rest-v2/dspace-rest-v2.service';
|
|||||||
import { AuthStatus } from './models/auth-status.model';
|
import { AuthStatus } from './models/auth-status.model';
|
||||||
import { AuthTokenInfo, TOKENITEM } from './models/auth-token-info.model';
|
import { AuthTokenInfo, TOKENITEM } from './models/auth-token-info.model';
|
||||||
import { isEmpty, isNotEmpty, isNotNull, isNotUndefined } from '../../shared/empty.util';
|
import { isEmpty, isNotEmpty, isNotNull, isNotUndefined } from '../../shared/empty.util';
|
||||||
import { CookieService } from '../../shared/services/cookie.service';
|
import { CookieService } from '../services/cookie.service';
|
||||||
import { getAuthenticationToken, getRedirectUrl, isAuthenticated, isTokenRefreshing } from './selectors';
|
import { getAuthenticationToken, getRedirectUrl, isAuthenticated, isTokenRefreshing } from './selectors';
|
||||||
import { AppState, routerStateSelector } from '../../app.reducer';
|
import { AppState, routerStateSelector } from '../../app.reducer';
|
||||||
import { ResetAuthenticationMessagesAction, SetRedirectUrlAction } from './auth.actions';
|
import { ResetAuthenticationMessagesAction, SetRedirectUrlAction } from './auth.actions';
|
||||||
import { NativeWindowRef, NativeWindowService } from '../../shared/services/window.service';
|
import { NativeWindowRef, NativeWindowService } from '../services/window.service';
|
||||||
import { Base64EncodeUrl } from '../../shared/utils/encode-decode.util';
|
import { Base64EncodeUrl } from '../../shared/utils/encode-decode.util';
|
||||||
import { RemoteDataBuildService } from '../cache/builders/remote-data-build.service';
|
import { RemoteDataBuildService } from '../cache/builders/remote-data-build.service';
|
||||||
|
|
||||||
|
18
src/app/core/cache/object-cache.service.ts
vendored
18
src/app/core/cache/object-cache.service.ts
vendored
@@ -4,7 +4,7 @@ import { applyPatch, Operation } from 'fast-json-patch';
|
|||||||
import { combineLatest as observableCombineLatest, Observable } from 'rxjs';
|
import { combineLatest as observableCombineLatest, Observable } from 'rxjs';
|
||||||
|
|
||||||
import { distinctUntilChanged, filter, map, mergeMap, take, } from 'rxjs/operators';
|
import { distinctUntilChanged, filter, map, mergeMap, take, } from 'rxjs/operators';
|
||||||
import { hasNoValue, isNotEmpty } from '../../shared/empty.util';
|
import { hasNoValue, hasValue, isNotEmpty } from '../../shared/empty.util';
|
||||||
import { CoreState } from '../core.reducers';
|
import { CoreState } from '../core.reducers';
|
||||||
import { coreSelector } from '../core.selectors';
|
import { coreSelector } from '../core.selectors';
|
||||||
import { RestRequestMethod } from '../data/rest-request-method';
|
import { RestRequestMethod } from '../data/rest-request-method';
|
||||||
@@ -68,8 +68,8 @@ export class ObjectCacheService {
|
|||||||
* @param href
|
* @param href
|
||||||
* The unique href of the object to be removed
|
* The unique href of the object to be removed
|
||||||
*/
|
*/
|
||||||
remove(uuid: string): void {
|
remove(href: string): void {
|
||||||
this.store.dispatch(new RemoveFromObjectCacheAction(uuid));
|
this.store.dispatch(new RemoveFromObjectCacheAction(href));
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -224,6 +224,18 @@ export class ObjectCacheService {
|
|||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create an observable that emits a new value whenever the availability of the cached object changes.
|
||||||
|
* The value it emits is a boolean stating if the object exists in cache or not.
|
||||||
|
* @param selfLink The self link of the object to observe
|
||||||
|
*/
|
||||||
|
hasBySelfLinkObservable(selfLink: string): Observable<boolean> {
|
||||||
|
return this.store.pipe(
|
||||||
|
select(entryFromSelfLinkSelector(selfLink)),
|
||||||
|
map((entry: ObjectCacheEntry) => this.isValid(entry))
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Check whether an ObjectCacheEntry should still be cached
|
* Check whether an ObjectCacheEntry should still be cached
|
||||||
*
|
*
|
||||||
|
@@ -5,7 +5,7 @@ import { AuthEffects } from './auth/auth.effects';
|
|||||||
import { JsonPatchOperationsEffects } from './json-patch/json-patch-operations.effects';
|
import { JsonPatchOperationsEffects } from './json-patch/json-patch-operations.effects';
|
||||||
import { ServerSyncBufferEffects } from './cache/server-sync-buffer.effects';
|
import { ServerSyncBufferEffects } from './cache/server-sync-buffer.effects';
|
||||||
import { ObjectUpdatesEffects } from './data/object-updates/object-updates.effects';
|
import { ObjectUpdatesEffects } from './data/object-updates/object-updates.effects';
|
||||||
import { RouteEffects } from '../shared/services/route.effects';
|
import { RouteEffects } from './services/route.effects';
|
||||||
|
|
||||||
export const coreEffects = [
|
export const coreEffects = [
|
||||||
RequestEffects,
|
RequestEffects,
|
||||||
|
@@ -14,7 +14,7 @@ import { coreReducers } from './core.reducers';
|
|||||||
|
|
||||||
import { isNotEmpty } from '../shared/empty.util';
|
import { isNotEmpty } from '../shared/empty.util';
|
||||||
|
|
||||||
import { ApiService } from '../shared/services/api.service';
|
import { ApiService } from './services/api.service';
|
||||||
import { BrowseEntriesResponseParsingService } from './data/browse-entries-response-parsing.service';
|
import { BrowseEntriesResponseParsingService } from './data/browse-entries-response-parsing.service';
|
||||||
import { CollectionDataService } from './data/collection-data.service';
|
import { CollectionDataService } from './data/collection-data.service';
|
||||||
import { CommunityDataService } from './data/community-data.service';
|
import { CommunityDataService } from './data/community-data.service';
|
||||||
@@ -34,12 +34,12 @@ import { PaginationComponentOptions } from '../shared/pagination/pagination-comp
|
|||||||
import { RemoteDataBuildService } from './cache/builders/remote-data-build.service';
|
import { RemoteDataBuildService } from './cache/builders/remote-data-build.service';
|
||||||
import { RequestService } from './data/request.service';
|
import { RequestService } from './data/request.service';
|
||||||
import { EndpointMapResponseParsingService } from './data/endpoint-map-response-parsing.service';
|
import { EndpointMapResponseParsingService } from './data/endpoint-map-response-parsing.service';
|
||||||
import { ServerResponseService } from '../shared/services/server-response.service';
|
import { ServerResponseService } from './services/server-response.service';
|
||||||
import { NativeWindowFactory, NativeWindowService } from '../shared/services/window.service';
|
import { NativeWindowFactory, NativeWindowService } from './services/window.service';
|
||||||
import { BrowseService } from './browse/browse.service';
|
import { BrowseService } from './browse/browse.service';
|
||||||
import { BrowseResponseParsingService } from './data/browse-response-parsing.service';
|
import { BrowseResponseParsingService } from './data/browse-response-parsing.service';
|
||||||
import { ConfigResponseParsingService } from './config/config-response-parsing.service';
|
import { ConfigResponseParsingService } from './config/config-response-parsing.service';
|
||||||
import { RouteService } from '../shared/services/route.service';
|
import { RouteService } from './services/route.service';
|
||||||
import { SubmissionDefinitionsConfigService } from './config/submission-definitions-config.service';
|
import { SubmissionDefinitionsConfigService } from './config/submission-definitions-config.service';
|
||||||
import { SubmissionFormsConfigService } from './config/submission-forms-config.service';
|
import { SubmissionFormsConfigService } from './config/submission-forms-config.service';
|
||||||
import { SubmissionSectionsConfigService } from './config/submission-sections-config.service';
|
import { SubmissionSectionsConfigService } from './config/submission-sections-config.service';
|
||||||
@@ -101,6 +101,7 @@ import { NormalizedSubmissionFormsModel } from './config/models/normalized-confi
|
|||||||
import { NormalizedSubmissionSectionModel } from './config/models/normalized-config-submission-section.model';
|
import { NormalizedSubmissionSectionModel } from './config/models/normalized-config-submission-section.model';
|
||||||
import { NormalizedAuthStatus } from './auth/models/normalized-auth-status.model';
|
import { NormalizedAuthStatus } from './auth/models/normalized-auth-status.model';
|
||||||
import { NormalizedAuthorityValue } from './integration/models/normalized-authority-value.model';
|
import { NormalizedAuthorityValue } from './integration/models/normalized-authority-value.model';
|
||||||
|
import { RelationshipService } from './data/relationship.service';
|
||||||
import { RoleService } from './roles/role.service';
|
import { RoleService } from './roles/role.service';
|
||||||
import { MyDSpaceGuard } from '../+my-dspace-page/my-dspace.guard';
|
import { MyDSpaceGuard } from '../+my-dspace-page/my-dspace.guard';
|
||||||
import { MyDSpaceResponseParsingService } from './data/mydspace-response-parsing.service';
|
import { MyDSpaceResponseParsingService } from './data/mydspace-response-parsing.service';
|
||||||
@@ -200,6 +201,7 @@ const PROVIDERS = [
|
|||||||
MenuService,
|
MenuService,
|
||||||
ObjectUpdatesService,
|
ObjectUpdatesService,
|
||||||
SearchService,
|
SearchService,
|
||||||
|
RelationshipService,
|
||||||
MyDSpaceGuard,
|
MyDSpaceGuard,
|
||||||
RoleService,
|
RoleService,
|
||||||
TaskResponseParsingService,
|
TaskResponseParsingService,
|
||||||
|
@@ -13,7 +13,7 @@ import {
|
|||||||
objectUpdatesReducer,
|
objectUpdatesReducer,
|
||||||
ObjectUpdatesState
|
ObjectUpdatesState
|
||||||
} from './data/object-updates/object-updates.reducer';
|
} from './data/object-updates/object-updates.reducer';
|
||||||
import { routeReducer, RouteState } from '../shared/services/route.reducer';
|
import { routeReducer, RouteState } from './services/route.reducer';
|
||||||
|
|
||||||
export interface CoreState {
|
export interface CoreState {
|
||||||
'cache/object': ObjectCacheState,
|
'cache/object': ObjectCacheState,
|
||||||
|
@@ -105,6 +105,27 @@ export class ObjectUpdatesService {
|
|||||||
}))
|
}))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Method that combines the state's updates (excluding updates that aren't part of the initialFields) with
|
||||||
|
* the initial values (when there's no update) to create a FieldUpdates object
|
||||||
|
* @param url The URL of the page for which the FieldUpdates should be requested
|
||||||
|
* @param initialFields The initial values of the fields
|
||||||
|
*/
|
||||||
|
getFieldUpdatesExclusive(url: string, initialFields: Identifiable[]): Observable<FieldUpdates> {
|
||||||
|
const objectUpdates = this.getObjectEntry(url);
|
||||||
|
return objectUpdates.pipe(map((objectEntry) => {
|
||||||
|
const fieldUpdates: FieldUpdates = {};
|
||||||
|
for (const object of initialFields) {
|
||||||
|
let fieldUpdate = objectEntry.fieldUpdates[object.uuid];
|
||||||
|
if (isEmpty(fieldUpdate)) {
|
||||||
|
fieldUpdate = { field: object, changeType: undefined };
|
||||||
|
}
|
||||||
|
fieldUpdates[object.uuid] = fieldUpdate;
|
||||||
|
}
|
||||||
|
return fieldUpdates;
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Method to check if a specific field is currently editable in the store
|
* Method to check if a specific field is currently editable in the store
|
||||||
* @param url The URL of the page on which the field resides
|
* @param url The URL of the page on which the field resides
|
||||||
|
157
src/app/core/data/relationship.service.spec.ts
Normal file
157
src/app/core/data/relationship.service.spec.ts
Normal file
@@ -0,0 +1,157 @@
|
|||||||
|
import { RelationshipService } from './relationship.service';
|
||||||
|
import { RequestService } from './request.service';
|
||||||
|
import { HALEndpointServiceStub } from '../../shared/testing/hal-endpoint-service-stub';
|
||||||
|
import { getMockRemoteDataBuildService } from '../../shared/mocks/mock-remote-data-build.service';
|
||||||
|
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';
|
||||||
|
import { Item } from '../shared/item.model';
|
||||||
|
import { PaginatedList } from './paginated-list';
|
||||||
|
import { PageInfo } from '../shared/page-info.model';
|
||||||
|
import { DeleteRequest } from './request.models';
|
||||||
|
import { ObjectCacheService } from '../cache/object-cache.service';
|
||||||
|
import { Observable } from 'rxjs/internal/Observable';
|
||||||
|
|
||||||
|
describe('RelationshipService', () => {
|
||||||
|
let service: RelationshipService;
|
||||||
|
let requestService: RequestService;
|
||||||
|
|
||||||
|
const restEndpointURL = 'https://rest.api/';
|
||||||
|
const relationshipsEndpointURL = `${restEndpointURL}/relationships`;
|
||||||
|
const halService: any = new HALEndpointServiceStub(restEndpointURL);
|
||||||
|
const rdbService = getMockRemoteDataBuildService();
|
||||||
|
const objectCache = Object.assign({
|
||||||
|
/* tslint:disable:no-empty */
|
||||||
|
remove: () => {}
|
||||||
|
/* tslint:enable:no-empty */
|
||||||
|
}) as ObjectCacheService;
|
||||||
|
|
||||||
|
const relationshipType = Object.assign(new RelationshipType(), {
|
||||||
|
id: '1',
|
||||||
|
uuid: '1',
|
||||||
|
leftLabel: 'isAuthorOfPublication',
|
||||||
|
rightLabel: 'isPublicationOfAuthor'
|
||||||
|
});
|
||||||
|
|
||||||
|
const relationship1 = Object.assign(new Relationship(), {
|
||||||
|
self: relationshipsEndpointURL + '/2',
|
||||||
|
id: '2',
|
||||||
|
uuid: '2',
|
||||||
|
relationshipType: observableOf(new RemoteData(false, false, true, undefined, relationshipType))
|
||||||
|
});
|
||||||
|
const relationship2 = Object.assign(new Relationship(), {
|
||||||
|
self: relationshipsEndpointURL + '/3',
|
||||||
|
id: '3',
|
||||||
|
uuid: '3',
|
||||||
|
relationshipType: observableOf(new RemoteData(false, false, true, undefined, relationshipType))
|
||||||
|
});
|
||||||
|
|
||||||
|
const relationships = [ relationship1, relationship2 ];
|
||||||
|
|
||||||
|
const item = Object.assign(new Item(), {
|
||||||
|
self: 'fake-item-url/publication',
|
||||||
|
id: 'publication',
|
||||||
|
uuid: 'publication',
|
||||||
|
relationships: observableOf(new RemoteData(false, false, true, undefined, new PaginatedList(new PageInfo(), relationships)))
|
||||||
|
});
|
||||||
|
|
||||||
|
const relatedItem1 = Object.assign(new Item(), {
|
||||||
|
id: 'author1',
|
||||||
|
uuid: 'author1'
|
||||||
|
});
|
||||||
|
const relatedItem2 = Object.assign(new Item(), {
|
||||||
|
id: 'author2',
|
||||||
|
uuid: 'author2'
|
||||||
|
});
|
||||||
|
relationship1.leftItem = getRemotedataObservable(relatedItem1);
|
||||||
|
relationship1.rightItem = getRemotedataObservable(item);
|
||||||
|
relationship2.leftItem = getRemotedataObservable(relatedItem2);
|
||||||
|
relationship2.rightItem = getRemotedataObservable(item);
|
||||||
|
const relatedItems = [relatedItem1, relatedItem2];
|
||||||
|
|
||||||
|
const itemService = jasmine.createSpyObj('itemService', {
|
||||||
|
findById: (uuid) => new RemoteData(false, false, true, undefined, relatedItems.filter((relatedItem) => relatedItem.id === uuid)[0])
|
||||||
|
});
|
||||||
|
|
||||||
|
function initTestService() {
|
||||||
|
return new RelationshipService(
|
||||||
|
requestService,
|
||||||
|
halService,
|
||||||
|
rdbService,
|
||||||
|
itemService,
|
||||||
|
objectCache
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const getRequestEntry$ = (successful: boolean) => {
|
||||||
|
return observableOf({
|
||||||
|
response: { isSuccessful: successful, payload: relationships } as any
|
||||||
|
} as RequestEntry)
|
||||||
|
};
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
requestService = getMockRequestService(getRequestEntry$(true));
|
||||||
|
service = initTestService();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('deleteRelationship', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
spyOn(service, 'findById').and.returnValue(getRemotedataObservable(relationship1));
|
||||||
|
spyOn(objectCache, 'remove');
|
||||||
|
service.deleteRelationship(relationships[0].uuid).subscribe();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should send a DeleteRequest', () => {
|
||||||
|
const expected = new DeleteRequest(requestService.generateRequestId(), relationshipsEndpointURL + '/' + relationship1.uuid);
|
||||||
|
expect(requestService.configure).toHaveBeenCalledWith(expected, undefined);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should clear the related items their cache', () => {
|
||||||
|
expect(objectCache.remove).toHaveBeenCalledWith(relatedItem1.self);
|
||||||
|
expect(objectCache.remove).toHaveBeenCalledWith(item.self);
|
||||||
|
expect(requestService.removeByHrefSubstring).toHaveBeenCalledWith(relatedItem1.self);
|
||||||
|
expect(requestService.removeByHrefSubstring).toHaveBeenCalledWith(item.self);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('getItemRelationshipsArray', () => {
|
||||||
|
it('should return the item\'s relationships in the form of an array', () => {
|
||||||
|
service.getItemRelationshipsArray(item).subscribe((result) => {
|
||||||
|
expect(result).toEqual(relationships);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('getItemRelationshipLabels', () => {
|
||||||
|
it('should return the correct labels', () => {
|
||||||
|
service.getItemRelationshipLabels(item).subscribe((result) => {
|
||||||
|
expect(result).toEqual([relationshipType.rightLabel]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('getRelatedItems', () => {
|
||||||
|
it('should return the related items', () => {
|
||||||
|
service.getRelatedItems(item).subscribe((result) => {
|
||||||
|
expect(result).toEqual(relatedItems);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('getRelatedItemsByLabel', () => {
|
||||||
|
it('should return the related items by label', () => {
|
||||||
|
service.getRelatedItemsByLabel(item, relationshipType.rightLabel).subscribe((result) => {
|
||||||
|
expect(result).toEqual(relatedItems);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
})
|
||||||
|
|
||||||
|
});
|
||||||
|
|
||||||
|
function getRemotedataObservable(obj: any): Observable<RemoteData<any>> {
|
||||||
|
return observableOf(new RemoteData(false, false, true, undefined, obj));
|
||||||
|
}
|
235
src/app/core/data/relationship.service.ts
Normal file
235
src/app/core/data/relationship.service.ts
Normal file
@@ -0,0 +1,235 @@
|
|||||||
|
import { Injectable } from '@angular/core';
|
||||||
|
import { RequestService } from './request.service';
|
||||||
|
import { HALEndpointService } from '../shared/hal-endpoint.service';
|
||||||
|
import { RemoteDataBuildService } from '../cache/builders/remote-data-build.service';
|
||||||
|
import { hasValue, hasValueOperator, isNotEmptyOperator } from '../../shared/empty.util';
|
||||||
|
import { distinctUntilChanged, filter, flatMap, map, switchMap, take, tap } from 'rxjs/operators';
|
||||||
|
import {
|
||||||
|
configureRequest,
|
||||||
|
filterSuccessfulResponses,
|
||||||
|
getRemoteDataPayload, getResponseFromEntry,
|
||||||
|
getSucceededRemoteData
|
||||||
|
} from '../shared/operators';
|
||||||
|
import { DeleteRequest, RestRequest } from './request.models';
|
||||||
|
import { Observable } from 'rxjs/internal/Observable';
|
||||||
|
import { RestResponse } from '../cache/response.models';
|
||||||
|
import { Item } from '../shared/item.model';
|
||||||
|
import { Relationship } from '../shared/item-relationships/relationship.model';
|
||||||
|
import { RelationshipType } from '../shared/item-relationships/relationship-type.model';
|
||||||
|
import { RemoteData } from './remote-data';
|
||||||
|
import { combineLatest as observableCombineLatest } from 'rxjs/internal/observable/combineLatest';
|
||||||
|
import { zip as observableZip } from 'rxjs';
|
||||||
|
import { PaginatedList } from './paginated-list';
|
||||||
|
import { ItemDataService } from './item-data.service';
|
||||||
|
import {
|
||||||
|
compareArraysUsingIds, filterRelationsByTypeLabel,
|
||||||
|
relationsToItems
|
||||||
|
} from '../../+item-page/simple/item-types/shared/item-relationships-utils';
|
||||||
|
import { ObjectCacheService } from '../cache/object-cache.service';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The service handling all relationship requests
|
||||||
|
*/
|
||||||
|
@Injectable()
|
||||||
|
export class RelationshipService {
|
||||||
|
protected linkPath = 'relationships';
|
||||||
|
|
||||||
|
constructor(protected requestService: RequestService,
|
||||||
|
protected halService: HALEndpointService,
|
||||||
|
protected rdbService: RemoteDataBuildService,
|
||||||
|
protected itemService: ItemDataService,
|
||||||
|
protected objectCache: ObjectCacheService) {
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the endpoint for a relationship by ID
|
||||||
|
* @param uuid
|
||||||
|
*/
|
||||||
|
getRelationshipEndpoint(uuid: string) {
|
||||||
|
return this.halService.getEndpoint(this.linkPath).pipe(
|
||||||
|
map((href: string) => `${href}/${uuid}`)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Find a relationship by its UUID
|
||||||
|
* @param uuid
|
||||||
|
*/
|
||||||
|
findById(uuid: string): Observable<RemoteData<Relationship>> {
|
||||||
|
const href$ = this.getRelationshipEndpoint(uuid);
|
||||||
|
return this.rdbService.buildSingle<Relationship>(href$);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Send a delete request for a relationship by ID
|
||||||
|
* @param uuid
|
||||||
|
*/
|
||||||
|
deleteRelationship(uuid: string): Observable<RestResponse> {
|
||||||
|
return this.getRelationshipEndpoint(uuid).pipe(
|
||||||
|
isNotEmptyOperator(),
|
||||||
|
distinctUntilChanged(),
|
||||||
|
map((endpointURL: string) => new DeleteRequest(this.requestService.generateRequestId(), endpointURL)),
|
||||||
|
configureRequest(this.requestService),
|
||||||
|
switchMap((restRequest: RestRequest) => this.requestService.getByUUID(restRequest.uuid)),
|
||||||
|
getResponseFromEntry(),
|
||||||
|
tap(() => this.clearRelatedCache(uuid))
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get a combined observable containing an array of all relationships in an item, as well as an array of the relationships their types
|
||||||
|
* This is used for easier access of a relationship's type because they exist as observables
|
||||||
|
* @param item
|
||||||
|
*/
|
||||||
|
getItemResolvedRelsAndTypes(item: Item): Observable<[Relationship[], RelationshipType[]]> {
|
||||||
|
return observableCombineLatest(
|
||||||
|
this.getItemRelationshipsArray(item),
|
||||||
|
this.getItemRelationshipTypesArray(item)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get a combined observable containing an array of all the item's relationship's left- and right-side items, as well as an array of the relationships their types
|
||||||
|
* This is used for easier access of a relationship's type and left and right items because they exist as observables
|
||||||
|
* @param item
|
||||||
|
*/
|
||||||
|
getItemResolvedRelatedItemsAndTypes(item: Item): Observable<[Item[], Item[], RelationshipType[]]> {
|
||||||
|
return observableCombineLatest(
|
||||||
|
this.getItemLeftRelatedItemArray(item),
|
||||||
|
this.getItemRightRelatedItemArray(item),
|
||||||
|
this.getItemRelationshipTypesArray(item)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get a combined observable containing an array of all the item's relationship's left- and right-side items, as well as an array of the relationships themselves
|
||||||
|
* This is used for easier access of the relationship and their left and right items because they exist as observables
|
||||||
|
* @param item
|
||||||
|
*/
|
||||||
|
getItemResolvedRelatedItemsAndRelationships(item: Item): Observable<[Item[], Item[], Relationship[]]> {
|
||||||
|
return observableCombineLatest(
|
||||||
|
this.getItemLeftRelatedItemArray(item),
|
||||||
|
this.getItemRightRelatedItemArray(item),
|
||||||
|
this.getItemRelationshipsArray(item)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get an item their relationships in the form of an array
|
||||||
|
* @param item
|
||||||
|
*/
|
||||||
|
getItemRelationshipsArray(item: Item): Observable<Relationship[]> {
|
||||||
|
return item.relationships.pipe(
|
||||||
|
getSucceededRemoteData(),
|
||||||
|
getRemoteDataPayload(),
|
||||||
|
map((rels: PaginatedList<Relationship>) => rels.page),
|
||||||
|
hasValueOperator(),
|
||||||
|
distinctUntilChanged(compareArraysUsingIds())
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get an item their relationship types in the form of an array
|
||||||
|
* @param item
|
||||||
|
*/
|
||||||
|
getItemRelationshipTypesArray(item: Item): Observable<RelationshipType[]> {
|
||||||
|
return this.getItemRelationshipsArray(item).pipe(
|
||||||
|
flatMap((rels: Relationship[]) =>
|
||||||
|
observableZip(...rels.map((rel: Relationship) => rel.relationshipType)).pipe(
|
||||||
|
map(([...arr]: Array<RemoteData<RelationshipType>>) => arr.map((d: RemoteData<RelationshipType>) => d.payload).filter((type) => hasValue(type))),
|
||||||
|
filter((arr) => arr.length === rels.length)
|
||||||
|
)
|
||||||
|
),
|
||||||
|
distinctUntilChanged(compareArraysUsingIds())
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get an item his relationship's left-side related items in the form of an array
|
||||||
|
* @param item
|
||||||
|
*/
|
||||||
|
getItemLeftRelatedItemArray(item: Item): Observable<Item[]> {
|
||||||
|
return this.getItemRelationshipsArray(item).pipe(
|
||||||
|
flatMap((rels: Relationship[]) => observableZip(...rels.map((rel: Relationship) => rel.leftItem)).pipe(
|
||||||
|
map(([...arr]: Array<RemoteData<Item>>) => arr.map((rd: RemoteData<Item>) => rd.payload).filter((i) => hasValue(i))),
|
||||||
|
filter((arr) => arr.length === rels.length)
|
||||||
|
)),
|
||||||
|
distinctUntilChanged(compareArraysUsingIds())
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get an item his relationship's right-side related items in the form of an array
|
||||||
|
* @param item
|
||||||
|
*/
|
||||||
|
getItemRightRelatedItemArray(item: Item): Observable<Item[]> {
|
||||||
|
return this.getItemRelationshipsArray(item).pipe(
|
||||||
|
flatMap((rels: Relationship[]) => observableZip(...rels.map((rel: Relationship) => rel.rightItem)).pipe(
|
||||||
|
map(([...arr]: Array<RemoteData<Item>>) => arr.map((rd: RemoteData<Item>) => rd.payload).filter((i) => hasValue(i))),
|
||||||
|
filter((arr) => arr.length === rels.length)
|
||||||
|
)),
|
||||||
|
distinctUntilChanged(compareArraysUsingIds())
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get an array of an item their unique relationship type's labels
|
||||||
|
* The array doesn't contain any duplicate labels
|
||||||
|
* @param item
|
||||||
|
*/
|
||||||
|
getItemRelationshipLabels(item: Item): Observable<string[]> {
|
||||||
|
return this.getItemResolvedRelatedItemsAndTypes(item).pipe(
|
||||||
|
map(([leftItems, rightItems, relTypesCurrentPage]) => {
|
||||||
|
return relTypesCurrentPage.map((type, index) => {
|
||||||
|
if (leftItems[index].uuid === item.uuid) {
|
||||||
|
return type.leftLabel;
|
||||||
|
} else {
|
||||||
|
return type.rightLabel;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}),
|
||||||
|
map((labels: string[]) => Array.from(new Set(labels)))
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resolve a given item's relationships into related items and return the items as an array
|
||||||
|
* @param item
|
||||||
|
*/
|
||||||
|
getRelatedItems(item: Item): Observable<Item[]> {
|
||||||
|
return this.getItemRelationshipsArray(item).pipe(
|
||||||
|
relationsToItems(item.uuid)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resolve a given item's relationships into related items, filtered by a relationship label
|
||||||
|
* and return the items as an array
|
||||||
|
* @param item
|
||||||
|
* @param label
|
||||||
|
*/
|
||||||
|
getRelatedItemsByLabel(item: Item, label: string): Observable<Item[]> {
|
||||||
|
return this.getItemResolvedRelsAndTypes(item).pipe(
|
||||||
|
filterRelationsByTypeLabel(label),
|
||||||
|
relationsToItems(item.uuid)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Clear object and request caches of the items related to a relationship (left and right items)
|
||||||
|
* @param uuid
|
||||||
|
*/
|
||||||
|
clearRelatedCache(uuid: string) {
|
||||||
|
this.findById(uuid).pipe(
|
||||||
|
getSucceededRemoteData(),
|
||||||
|
flatMap((rd: RemoteData<Relationship>) => observableCombineLatest(rd.payload.leftItem.pipe(getSucceededRemoteData()), rd.payload.rightItem.pipe(getSucceededRemoteData()))),
|
||||||
|
take(1)
|
||||||
|
).subscribe(([leftItem, rightItem]) => {
|
||||||
|
this.objectCache.remove(leftItem.payload.self);
|
||||||
|
this.objectCache.remove(rightItem.payload.self);
|
||||||
|
this.requestService.removeByHrefSubstring(leftItem.payload.self);
|
||||||
|
this.requestService.removeByHrefSubstring(rightItem.payload.self);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
@@ -65,8 +65,7 @@ const uuidsFromHrefSubstringSelector =
|
|||||||
const getUuidsFromHrefSubstring = (state: IndexState, href: string): string[] => {
|
const getUuidsFromHrefSubstring = (state: IndexState, href: string): string[] => {
|
||||||
let result = [];
|
let result = [];
|
||||||
if (isNotEmpty(state)) {
|
if (isNotEmpty(state)) {
|
||||||
result = Object.values(state)
|
result = Object.keys(state).filter((key) => key.startsWith(href)).map((key) => state[key]);
|
||||||
.filter((value: string) => value.startsWith(href));
|
|
||||||
}
|
}
|
||||||
return result;
|
return result;
|
||||||
};
|
};
|
||||||
@@ -315,4 +314,15 @@ export class RequestService {
|
|||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create an observable that emits a new value whenever the availability of the cached request changes.
|
||||||
|
* The value it emits is a boolean stating if the request exists in cache or not.
|
||||||
|
* @param href The href of the request to observe
|
||||||
|
*/
|
||||||
|
hasByHrefObservable(href: string): Observable<boolean> {
|
||||||
|
return this.getByHref(href).pipe(
|
||||||
|
map((requestEntry: RequestEntry) => this.isValid(requestEntry))
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
@@ -6,9 +6,9 @@ import { Store } from '@ngrx/store';
|
|||||||
import { getTestScheduler, hot } from 'jasmine-marbles';
|
import { getTestScheduler, hot } from 'jasmine-marbles';
|
||||||
|
|
||||||
import { RouteService } from './route.service';
|
import { RouteService } from './route.service';
|
||||||
import { MockRouter } from '../mocks/mock-router';
|
import { MockRouter } from '../../shared/mocks/mock-router';
|
||||||
import { TestScheduler } from 'rxjs/testing';
|
import { TestScheduler } from 'rxjs/testing';
|
||||||
import { AddUrlToHistoryAction } from '../history/history.actions';
|
import { AddUrlToHistoryAction } from '../../shared/history/history.actions';
|
||||||
|
|
||||||
describe('RouteService', () => {
|
describe('RouteService', () => {
|
||||||
let scheduler: TestScheduler;
|
let scheduler: TestScheduler;
|
@@ -12,12 +12,12 @@ import { combineLatest, Observable } from 'rxjs';
|
|||||||
import { createSelector, MemoizedSelector, select, Store } from '@ngrx/store';
|
import { createSelector, MemoizedSelector, select, Store } from '@ngrx/store';
|
||||||
import { isEqual } from 'lodash';
|
import { isEqual } from 'lodash';
|
||||||
|
|
||||||
import { AddUrlToHistoryAction } from '../history/history.actions';
|
import { AddUrlToHistoryAction } from '../../shared/history/history.actions';
|
||||||
import { historySelector } from '../history/selectors';
|
import { historySelector } from '../../shared/history/selectors';
|
||||||
import { SetParametersAction, SetQueryParametersAction } from './route.actions';
|
import { SetParametersAction, SetQueryParametersAction } from './route.actions';
|
||||||
import { CoreState } from '../../core/core.reducers';
|
import { CoreState } from '../core.reducers';
|
||||||
import { hasValue } from '../empty.util';
|
import { hasValue } from '../../shared/empty.util';
|
||||||
import { coreSelector } from '../../core/core.selectors';
|
import { coreSelector } from '../core.selectors';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Selector to select all route parameters from the store
|
* Selector to select all route parameters from the store
|
@@ -5,10 +5,11 @@ import { DSpaceObject } from './dspace-object.model';
|
|||||||
import { Collection } from './collection.model';
|
import { Collection } from './collection.model';
|
||||||
import { RemoteData } from '../data/remote-data';
|
import { RemoteData } from '../data/remote-data';
|
||||||
import { Bitstream } from './bitstream.model';
|
import { Bitstream } from './bitstream.model';
|
||||||
import { hasValue, isNotEmpty, isNotUndefined } from '../../shared/empty.util';
|
import { hasValue, isNotEmpty } from '../../shared/empty.util';
|
||||||
import { PaginatedList } from '../data/paginated-list';
|
import { PaginatedList } from '../data/paginated-list';
|
||||||
import { Relationship } from './item-relationships/relationship.model';
|
import { Relationship } from './item-relationships/relationship.model';
|
||||||
import { ResourceType } from './resource-type';
|
import { ResourceType } from './resource-type';
|
||||||
|
import { getSucceededRemoteData } from './operators';
|
||||||
|
|
||||||
export class Item extends DSpaceObject {
|
export class Item extends DSpaceObject {
|
||||||
static type = new ResourceType('item');
|
static type = new ResourceType('item');
|
||||||
@@ -97,7 +98,7 @@ export class Item extends DSpaceObject {
|
|||||||
*/
|
*/
|
||||||
getBitstreamsByBundleName(bundleName: string): Observable<Bitstream[]> {
|
getBitstreamsByBundleName(bundleName: string): Observable<Bitstream[]> {
|
||||||
return this.bitstreams.pipe(
|
return this.bitstreams.pipe(
|
||||||
filter((rd: RemoteData<PaginatedList<Bitstream>>) => !rd.isResponsePending && isNotUndefined(rd.payload)),
|
getSucceededRemoteData(),
|
||||||
map((rd: RemoteData<PaginatedList<Bitstream>>) => rd.payload.page),
|
map((rd: RemoteData<PaginatedList<Bitstream>>) => rd.payload.page),
|
||||||
filter((bitstreams: Bitstream[]) => hasValue(bitstreams)),
|
filter((bitstreams: Bitstream[]) => hasValue(bitstreams)),
|
||||||
take(1),
|
take(1),
|
||||||
|
@@ -91,7 +91,7 @@ export const toDSpaceObjectListRD = () =>
|
|||||||
source.pipe(
|
source.pipe(
|
||||||
filter((rd: RemoteData<PaginatedList<SearchResult<T>>>) => rd.hasSucceeded),
|
filter((rd: RemoteData<PaginatedList<SearchResult<T>>>) => rd.hasSucceeded),
|
||||||
map((rd: RemoteData<PaginatedList<SearchResult<T>>>) => {
|
map((rd: RemoteData<PaginatedList<SearchResult<T>>>) => {
|
||||||
const dsoPage: T[] = rd.payload.page.map((searchResult: SearchResult<T>) => searchResult.indexableObject);
|
const dsoPage: T[] = rd.payload.page.filter((result) => hasValue(result)).map((searchResult: SearchResult<T>) => searchResult.indexableObject);
|
||||||
const payload = Object.assign(rd.payload, { page: dsoPage }) as PaginatedList<T>;
|
const payload = Object.assign(rd.payload, { page: dsoPage }) as PaginatedList<T>;
|
||||||
return Object.assign(rd, { payload: payload });
|
return Object.assign(rd, { payload: payload });
|
||||||
})
|
})
|
||||||
|
@@ -0,0 +1,30 @@
|
|||||||
|
<ds-truncatable [id]="dso.id">
|
||||||
|
<div class="card" [@focusShadow]="(isCollapsed$ | async)?'blur':'focus'">
|
||||||
|
<a [routerLink]="['/items/' + dso.id]" class="card-img-top full-width">
|
||||||
|
<div>
|
||||||
|
<ds-grid-thumbnail [thumbnail]="this.item.getThumbnail() | async">
|
||||||
|
</ds-grid-thumbnail>
|
||||||
|
</div>
|
||||||
|
</a>
|
||||||
|
<div class="card-body">
|
||||||
|
<ds-item-type-badge [object]="object"></ds-item-type-badge>
|
||||||
|
<ds-truncatable-part [id]="dso.id" [minLines]="3" type="h4">
|
||||||
|
<h4 class="card-title" [innerHTML]="dso.firstMetadataValue('dc.title')"></h4>
|
||||||
|
</ds-truncatable-part>
|
||||||
|
<p *ngIf="dso.hasMetadata('creativework.datePublished')" class="item-date card-text text-muted">
|
||||||
|
<ds-truncatable-part [id]="dso.id" [minLines]="1">
|
||||||
|
<span [innerHTML]="firstMetadataValue('creativework.datePublished')"></span>
|
||||||
|
</ds-truncatable-part>
|
||||||
|
</p>
|
||||||
|
<p *ngIf="dso.hasMetadata('journal.title')" class="item-journal-title card-text">
|
||||||
|
<ds-truncatable-part [id]="dso.id" [minLines]="3">
|
||||||
|
<span [innerHTML]="firstMetadataValue('journal.title')"></span>
|
||||||
|
</ds-truncatable-part>
|
||||||
|
</p>
|
||||||
|
<div class="text-center">
|
||||||
|
<a [routerLink]="['/items/' + dso.id]"
|
||||||
|
class="lead btn btn-primary viewButton">View</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</ds-truncatable>
|
@@ -0,0 +1,50 @@
|
|||||||
|
import { ItemSearchResult } from '../../../../shared/object-collection/shared/item-search-result.model';
|
||||||
|
import { Item } from '../../../../core/shared/item.model';
|
||||||
|
import { of as observableOf } from 'rxjs/internal/observable/of';
|
||||||
|
import { getEntityGridElementTestComponent } from '../../../../shared/object-grid/item-grid-element/item-types/publication/publication-grid-element.component.spec';
|
||||||
|
import { JournalIssueGridElementComponent } from './journal-issue-grid-element.component';
|
||||||
|
import { createSuccessfulRemoteDataObject$ } from '../../../../shared/testing/utils';
|
||||||
|
import { PaginatedList } from '../../../../core/data/paginated-list';
|
||||||
|
import { PageInfo } from '../../../../core/shared/page-info.model';
|
||||||
|
|
||||||
|
const mockItemWithMetadata: ItemSearchResult = new ItemSearchResult();
|
||||||
|
mockItemWithMetadata.hitHighlights = {};
|
||||||
|
mockItemWithMetadata.indexableObject = Object.assign(new Item(), {
|
||||||
|
bitstreams: createSuccessfulRemoteDataObject$(new PaginatedList(new PageInfo(), [])),
|
||||||
|
metadata: {
|
||||||
|
'dc.title': [
|
||||||
|
{
|
||||||
|
language: 'en_US',
|
||||||
|
value: 'This is just another title'
|
||||||
|
}
|
||||||
|
],
|
||||||
|
'creativework.datePublished': [
|
||||||
|
{
|
||||||
|
language: null,
|
||||||
|
value: '2015-06-26'
|
||||||
|
}
|
||||||
|
],
|
||||||
|
'journal.title': [
|
||||||
|
{
|
||||||
|
language: 'en_US',
|
||||||
|
value: 'The journal title'
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const mockItemWithoutMetadata: ItemSearchResult = new ItemSearchResult();
|
||||||
|
mockItemWithoutMetadata.hitHighlights = {};
|
||||||
|
mockItemWithoutMetadata.indexableObject = Object.assign(new Item(), {
|
||||||
|
bitstreams: createSuccessfulRemoteDataObject$(new PaginatedList(new PageInfo(), [])),
|
||||||
|
metadata: {
|
||||||
|
'dc.title': [
|
||||||
|
{
|
||||||
|
language: 'en_US',
|
||||||
|
value: 'This is just another title'
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('JournalIssueGridElementComponent', getEntityGridElementTestComponent(JournalIssueGridElementComponent, mockItemWithMetadata, mockItemWithoutMetadata, ['date', 'journal-title']));
|
@@ -0,0 +1,17 @@
|
|||||||
|
import { ItemViewMode, rendersItemType } from '../../../../shared/items/item-type-decorator';
|
||||||
|
import { Component } from '@angular/core';
|
||||||
|
import { focusShadow } from '../../../../shared/animations/focus';
|
||||||
|
import { TypedItemSearchResultGridElementComponent } from '../../../../shared/object-grid/item-grid-element/item-types/typed-item-search-result-grid-element.component';
|
||||||
|
|
||||||
|
@rendersItemType('JournalIssue', ItemViewMode.Card)
|
||||||
|
@Component({
|
||||||
|
selector: 'ds-journal-issue-grid-element',
|
||||||
|
styleUrls: ['./journal-issue-grid-element.component.scss'],
|
||||||
|
templateUrl: './journal-issue-grid-element.component.html',
|
||||||
|
animations: [focusShadow]
|
||||||
|
})
|
||||||
|
/**
|
||||||
|
* The component for displaying a grid element for an item of the type Journal Issue
|
||||||
|
*/
|
||||||
|
export class JournalIssueGridElementComponent extends TypedItemSearchResultGridElementComponent {
|
||||||
|
}
|
@@ -0,0 +1,30 @@
|
|||||||
|
<ds-truncatable [id]="dso.id">
|
||||||
|
<div class="card" [@focusShadow]="(isCollapsed$ | async)?'blur':'focus'">
|
||||||
|
<a [routerLink]="['/items/' + dso.id]" class="card-img-top full-width">
|
||||||
|
<div>
|
||||||
|
<ds-grid-thumbnail [thumbnail]="this.item.getThumbnail() | async">
|
||||||
|
</ds-grid-thumbnail>
|
||||||
|
</div>
|
||||||
|
</a>
|
||||||
|
<div class="card-body">
|
||||||
|
<ds-item-type-badge [object]="object"></ds-item-type-badge>
|
||||||
|
<ds-truncatable-part [id]="dso.id" [minLines]="3" type="h4">
|
||||||
|
<h4 class="card-title" [innerHTML]="dso.firstMetadataValue('dc.title')"></h4>
|
||||||
|
</ds-truncatable-part>
|
||||||
|
<p *ngIf="dso.hasMetadata('creativework.datePublished')" class="item-date card-text text-muted">
|
||||||
|
<ds-truncatable-part [id]="dso.id" [minLines]="1">
|
||||||
|
<span [innerHTML]="firstMetadataValue('creativework.datePublished')"></span>
|
||||||
|
</ds-truncatable-part>
|
||||||
|
</p>
|
||||||
|
<p *ngIf="dso.hasMetadata('dc.description')" class="item-description card-text">
|
||||||
|
<ds-truncatable-part [id]="dso.id" [minLines]="3">
|
||||||
|
<span [innerHTML]="firstMetadataValue('dc.description')"></span>
|
||||||
|
</ds-truncatable-part>
|
||||||
|
</p>
|
||||||
|
<div class="text-center">
|
||||||
|
<a [routerLink]="['/items/' + dso.id]"
|
||||||
|
class="lead btn btn-primary viewButton">View</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</ds-truncatable>
|
@@ -0,0 +1,50 @@
|
|||||||
|
import { ItemSearchResult } from '../../../../shared/object-collection/shared/item-search-result.model';
|
||||||
|
import { Item } from '../../../../core/shared/item.model';
|
||||||
|
import { of as observableOf } from 'rxjs/internal/observable/of';
|
||||||
|
import { getEntityGridElementTestComponent } from '../../../../shared/object-grid/item-grid-element/item-types/publication/publication-grid-element.component.spec';
|
||||||
|
import { JournalVolumeGridElementComponent } from './journal-volume-grid-element.component';
|
||||||
|
import { createSuccessfulRemoteDataObject$ } from '../../../../shared/testing/utils';
|
||||||
|
import { PaginatedList } from '../../../../core/data/paginated-list';
|
||||||
|
import { PageInfo } from '../../../../core/shared/page-info.model';
|
||||||
|
|
||||||
|
const mockItemWithMetadata: ItemSearchResult = new ItemSearchResult();
|
||||||
|
mockItemWithMetadata.hitHighlights = {};
|
||||||
|
mockItemWithMetadata.indexableObject = Object.assign(new Item(), {
|
||||||
|
bitstreams: createSuccessfulRemoteDataObject$(new PaginatedList(new PageInfo(), [])),
|
||||||
|
metadata: {
|
||||||
|
'dc.title': [
|
||||||
|
{
|
||||||
|
language: 'en_US',
|
||||||
|
value: 'This is just another title'
|
||||||
|
}
|
||||||
|
],
|
||||||
|
'creativework.datePublished': [
|
||||||
|
{
|
||||||
|
language: null,
|
||||||
|
value: '2015-06-26'
|
||||||
|
}
|
||||||
|
],
|
||||||
|
'dc.description': [
|
||||||
|
{
|
||||||
|
language: 'en_US',
|
||||||
|
value: 'A description for the journal volume'
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const mockItemWithoutMetadata: ItemSearchResult = new ItemSearchResult();
|
||||||
|
mockItemWithoutMetadata.hitHighlights = {};
|
||||||
|
mockItemWithoutMetadata.indexableObject = Object.assign(new Item(), {
|
||||||
|
bitstreams: createSuccessfulRemoteDataObject$(new PaginatedList(new PageInfo(), [])),
|
||||||
|
metadata: {
|
||||||
|
'dc.title': [
|
||||||
|
{
|
||||||
|
language: 'en_US',
|
||||||
|
value: 'This is just another title'
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('JournalVolumeGridElementComponent', getEntityGridElementTestComponent(JournalVolumeGridElementComponent, mockItemWithMetadata, mockItemWithoutMetadata, ['date', 'description']));
|
@@ -0,0 +1,17 @@
|
|||||||
|
import { ItemViewMode, rendersItemType } from '../../../../shared/items/item-type-decorator';
|
||||||
|
import { Component } from '@angular/core';
|
||||||
|
import { focusShadow } from '../../../../shared/animations/focus';
|
||||||
|
import { TypedItemSearchResultGridElementComponent } from '../../../../shared/object-grid/item-grid-element/item-types/typed-item-search-result-grid-element.component';
|
||||||
|
|
||||||
|
@rendersItemType('JournalVolume', ItemViewMode.Card)
|
||||||
|
@Component({
|
||||||
|
selector: 'ds-journal-volume-grid-element',
|
||||||
|
styleUrls: ['./journal-volume-grid-element.component.scss'],
|
||||||
|
templateUrl: './journal-volume-grid-element.component.html',
|
||||||
|
animations: [focusShadow]
|
||||||
|
})
|
||||||
|
/**
|
||||||
|
* The component for displaying a grid element for an item of the type Journal Volume
|
||||||
|
*/
|
||||||
|
export class JournalVolumeGridElementComponent extends TypedItemSearchResultGridElementComponent {
|
||||||
|
}
|
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user