Merge branch 'master' into w2p-64644_Removal-of-filtered-discovery-page

This commit is contained in:
Yana De Pauw
2019-09-09 18:26:01 +02:00
214 changed files with 5321 additions and 834 deletions

View File

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

View File

@@ -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 = {
}; };

View File

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

View File

@@ -2,16 +2,47 @@
"404.help": "We can't find the page you're looking for. The page may have been moved or deleted. You can use the button below to get back to the home page. ", "404.help": "We can't find the page you're looking for. The page may have been moved or deleted. You can use the button below to get back to the home page. ",
"404.link.home-page": "Take me to the home page", "404.link.home-page": "Take me to the home page",
"404.page-not-found": "page not found", "404.page-not-found": "page not found",
"admin.registries.bitstream-formats.create.failure.content": "An error occurred while creating the new bitstream format.",
"admin.registries.bitstream-formats.create.failure.head": "Failure",
"admin.registries.bitstream-formats.create.head": "Create Bitstream format",
"admin.registries.bitstream-formats.create.new": "Add a new bitstream format",
"admin.registries.bitstream-formats.create.success.content": "The new bitstream format was successfully created.",
"admin.registries.bitstream-formats.create.success.head": "Success",
"admin.registries.bitstream-formats.delete.failure.amount": "Failed to remove {{ amount }} format(s)",
"admin.registries.bitstream-formats.delete.failure.head": "Failure",
"admin.registries.bitstream-formats.delete.success.amount": "Successfully removed {{ amount }} format(s)",
"admin.registries.bitstream-formats.delete.success.head": "Success",
"admin.registries.bitstream-formats.description": "This list of bitstream formats provides information about known formats and their support level.", "admin.registries.bitstream-formats.description": "This list of bitstream formats provides information about known formats and their support level.",
"admin.registries.bitstream-formats.formats.no-items": "No bitstream formats to show.", "admin.registries.bitstream-formats.edit.description.hint": "",
"admin.registries.bitstream-formats.formats.table.internal": "internal", "admin.registries.bitstream-formats.edit.description.label": "Description",
"admin.registries.bitstream-formats.formats.table.mimetype": "MIME Type", "admin.registries.bitstream-formats.edit.extensions.hint": "Extensions are file extensions that are used to automatically identify the format of uploaded files. You can enter several extensions for each format.",
"admin.registries.bitstream-formats.formats.table.name": "Name", "admin.registries.bitstream-formats.edit.extensions.label": "File extensions",
"admin.registries.bitstream-formats.formats.table.supportLevel.0": "Unknown", "admin.registries.bitstream-formats.edit.extensions.placeholder": "Enter a file extenstion without the dot",
"admin.registries.bitstream-formats.formats.table.supportLevel.1": "Known", "admin.registries.bitstream-formats.edit.failure.content": "An error occurred while editing the bitstream format.",
"admin.registries.bitstream-formats.formats.table.supportLevel.2": "Support", "admin.registries.bitstream-formats.edit.failure.head": "Failure",
"admin.registries.bitstream-formats.formats.table.supportLevel.head": "Support Level", "admin.registries.bitstream-formats.edit.head": "Bitstream format: {{ format }}",
"admin.registries.bitstream-formats.edit.internal.hint": "Formats marked as internal are are hidden from the user, and used for administrative purposes.",
"admin.registries.bitstream-formats.edit.internal.label": "Internal",
"admin.registries.bitstream-formats.edit.mimetype.hint": "The MIME type associated with this format, does not have to be unique.",
"admin.registries.bitstream-formats.edit.mimetype.label": "MIME Type",
"admin.registries.bitstream-formats.edit.shortDescription.hint": "A unique name for this format, (e.g. Microsoft Word XP or Microsoft Word 2000)",
"admin.registries.bitstream-formats.edit.shortDescription.label": "Name",
"admin.registries.bitstream-formats.edit.success.content": "The bitstream format was successfully edited.",
"admin.registries.bitstream-formats.edit.success.head": "Success",
"admin.registries.bitstream-formats.edit.supportLevel.hint": "The level of support your institution pledges for this format.",
"admin.registries.bitstream-formats.edit.supportLevel.label": "Support level",
"admin.registries.bitstream-formats.head": "Bitstream Format Registry", "admin.registries.bitstream-formats.head": "Bitstream Format Registry",
"admin.registries.bitstream-formats.no-items": "No bitstream formats to show.",
"admin.registries.bitstream-formats.table.delete": "Delete selected",
"admin.registries.bitstream-formats.table.deselect-all": "Deselect all",
"admin.registries.bitstream-formats.table.internal": "internal",
"admin.registries.bitstream-formats.table.mimetype": "MIME Type",
"admin.registries.bitstream-formats.table.name": "Name",
"admin.registries.bitstream-formats.table.return": "Return",
"admin.registries.bitstream-formats.table.supportLevel.KNOWN": "Known",
"admin.registries.bitstream-formats.table.supportLevel.SUPPORTED": "Supported",
"admin.registries.bitstream-formats.table.supportLevel.UNKNOWN": "Unknown",
"admin.registries.bitstream-formats.table.supportLevel.head": "Support Level",
"admin.registries.bitstream-formats.title": "DSpace Angular :: Bitstream Format Registry", "admin.registries.bitstream-formats.title": "DSpace Angular :: Bitstream Format Registry",
"admin.registries.metadata.description": "The metadata registry maintains a list of all metadata fields available in the repository. These fields may be divided amongst multiple schemas. However, DSpace requires the qualified Dublin Core schema.", "admin.registries.metadata.description": "The metadata registry maintains a list of all metadata fields available in the repository. These fields may be divided amongst multiple schemas. However, DSpace requires the qualified Dublin Core schema.",
"admin.registries.metadata.form.create": "Create metadata schema", "admin.registries.metadata.form.create": "Create metadata schema",
@@ -101,6 +132,7 @@
"collection.form.tableofcontents": "News (HTML)", "collection.form.tableofcontents": "News (HTML)",
"collection.form.title": "Name", "collection.form.title": "Name",
"collection.page.browse.recent.head": "Recent Submissions", "collection.page.browse.recent.head": "Recent Submissions",
"collection.page.browse.recent.empty": "No items to show",
"collection.page.license": "License", "collection.page.license": "License",
"collection.page.news": "News", "collection.page.news": "News",
"community.create.head": "Create a Community", "community.create.head": "Create a Community",
@@ -224,12 +256,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",
@@ -659,4 +705,4 @@
"uploader.or": ", or", "uploader.or": ", or",
"uploader.processing": "Processing", "uploader.processing": "Processing",
"uploader.queue-lenght": "Queue length" "uploader.queue-lenght": "Queue length"
} }

View File

@@ -2,14 +2,29 @@ import { MetadataRegistryComponent } from './metadata-registry/metadata-registry
import { RouterModule } from '@angular/router'; import { RouterModule } from '@angular/router';
import { NgModule } from '@angular/core'; import { NgModule } from '@angular/core';
import { MetadataSchemaComponent } from './metadata-schema/metadata-schema.component'; import { MetadataSchemaComponent } from './metadata-schema/metadata-schema.component';
import { BitstreamFormatsComponent } from './bitstream-formats/bitstream-formats.component'; import { URLCombiner } from '../../core/url-combiner/url-combiner';
import { getRegistriesModulePath } from '../admin-routing.module';
const BITSTREAMFORMATS_MODULE_PATH = 'bitstream-formats';
export function getBitstreamFormatsModulePath() {
return new URLCombiner(getRegistriesModulePath(), BITSTREAMFORMATS_MODULE_PATH).toString();
}
@NgModule({ @NgModule({
imports: [ imports: [
RouterModule.forChild([ RouterModule.forChild([
{ path: 'metadata', component: MetadataRegistryComponent, data: { title: 'admin.registries.metadata.title' } }, {path: 'metadata', component: MetadataRegistryComponent, data: {title: 'admin.registries.metadata.title'}},
{ path: 'metadata/:schemaName', component: MetadataSchemaComponent, data: { title: 'admin.registries.schema.title' } }, {
{ path: 'bitstream-formats', component: BitstreamFormatsComponent, data: { title: 'admin.registries.bitstream-formats.title' } }, path: 'metadata/:schemaName',
component: MetadataSchemaComponent,
data: {title: 'admin.registries.schema.title'}
},
{
path: BITSTREAMFORMATS_MODULE_PATH,
loadChildren: './bitstream-formats/bitstream-formats.module#BitstreamFormatsModule',
data: {title: 'admin.registries.bitstream-formats.title'}
},
]) ])
] ]
}) })

View File

@@ -5,10 +5,10 @@ import { CommonModule } from '@angular/common';
import { MetadataSchemaComponent } from './metadata-schema/metadata-schema.component'; import { MetadataSchemaComponent } from './metadata-schema/metadata-schema.component';
import { RouterModule } from '@angular/router'; import { RouterModule } from '@angular/router';
import { TranslateModule } from '@ngx-translate/core'; import { TranslateModule } from '@ngx-translate/core';
import { BitstreamFormatsComponent } from './bitstream-formats/bitstream-formats.component';
import { SharedModule } from '../../shared/shared.module'; import { SharedModule } from '../../shared/shared.module';
import { MetadataSchemaFormComponent } from './metadata-registry/metadata-schema-form/metadata-schema-form.component'; import { MetadataSchemaFormComponent } from './metadata-registry/metadata-schema-form/metadata-schema-form.component';
import {MetadataFieldFormComponent} from './metadata-schema/metadata-field-form/metadata-field-form.component'; import { MetadataFieldFormComponent } from './metadata-schema/metadata-field-form/metadata-field-form.component';
import { BitstreamFormatsModule } from './bitstream-formats/bitstream-formats.module';
@NgModule({ @NgModule({
imports: [ imports: [
@@ -16,12 +16,12 @@ import {MetadataFieldFormComponent} from './metadata-schema/metadata-field-form/
SharedModule, SharedModule,
RouterModule, RouterModule,
TranslateModule, TranslateModule,
BitstreamFormatsModule,
AdminRegistriesRoutingModule AdminRegistriesRoutingModule
], ],
declarations: [ declarations: [
MetadataRegistryComponent, MetadataRegistryComponent,
MetadataSchemaComponent, MetadataSchemaComponent,
BitstreamFormatsComponent,
MetadataSchemaFormComponent, MetadataSchemaFormComponent,
MetadataFieldFormComponent MetadataFieldFormComponent
], ],

View File

@@ -0,0 +1,11 @@
<div class="container">
<div class="row">
<div class="col-12 mb-4">
<h2 id="sub-header"
class="border-bottom mb-2">{{ 'admin.registries.bitstream-formats.create.new' | translate }}</h2>
<ds-bitstream-format-form (updatedFormat)="createBitstreamFormat($event)"></ds-bitstream-format-form>
</div>
</div>
</div>

View File

@@ -0,0 +1,106 @@
import { async, ComponentFixture, TestBed } from '@angular/core/testing';
import { CommonModule } from '@angular/common';
import { RouterTestingModule } from '@angular/router/testing';
import { TranslateModule } from '@ngx-translate/core';
import { NgbModule } from '@ng-bootstrap/ng-bootstrap';
import { Router } from '@angular/router';
import { CUSTOM_ELEMENTS_SCHEMA } from '@angular/core';
import { RouterStub } from '../../../../shared/testing/router-stub';
import { of as observableOf } from 'rxjs';
import { NotificationsService } from '../../../../shared/notifications/notifications.service';
import { NotificationsServiceStub } from '../../../../shared/testing/notifications-service-stub';
import { BitstreamFormatDataService } from '../../../../core/data/bitstream-format-data.service';
import { RestResponse } from '../../../../core/cache/response.models';
import { BitstreamFormat } from '../../../../core/shared/bitstream-format.model';
import { BitstreamFormatSupportLevel } from '../../../../core/shared/bitstream-format-support-level';
import { ResourceType } from '../../../../core/shared/resource-type';
import { AddBitstreamFormatComponent } from './add-bitstream-format.component';
describe('AddBitstreamFormatComponent', () => {
let comp: AddBitstreamFormatComponent;
let fixture: ComponentFixture<AddBitstreamFormatComponent>;
const bitstreamFormat = new BitstreamFormat();
bitstreamFormat.uuid = 'test-uuid-1';
bitstreamFormat.id = 'test-uuid-1';
bitstreamFormat.shortDescription = 'Unknown';
bitstreamFormat.description = 'Unknown data format';
bitstreamFormat.mimetype = 'application/octet-stream';
bitstreamFormat.supportLevel = BitstreamFormatSupportLevel.Unknown;
bitstreamFormat.internal = false;
bitstreamFormat.extensions = null;
let router;
let notificationService: NotificationsServiceStub;
let bitstreamFormatDataService: BitstreamFormatDataService;
const initAsync = () => {
router = new RouterStub();
notificationService = new NotificationsServiceStub();
bitstreamFormatDataService = jasmine.createSpyObj('bitstreamFormatDataService', {
createBitstreamFormat: observableOf(new RestResponse(true, 200, 'Success')),
clearBitStreamFormatRequests: observableOf(null)
});
TestBed.configureTestingModule({
imports: [CommonModule, RouterTestingModule.withRoutes([]), TranslateModule.forRoot(), NgbModule.forRoot()],
declarations: [AddBitstreamFormatComponent],
providers: [
{provide: Router, useValue: router},
{provide: NotificationsService, useValue: notificationService},
{provide: BitstreamFormatDataService, useValue: bitstreamFormatDataService},
],
schemas: [CUSTOM_ELEMENTS_SCHEMA]
}).compileComponents();
};
const initBeforeEach = () => {
fixture = TestBed.createComponent(AddBitstreamFormatComponent);
comp = fixture.componentInstance;
fixture.detectChanges();
};
describe('createBitstreamFormat success', () => {
beforeEach(async(initAsync));
beforeEach(initBeforeEach);
it('should send the updated form to the service, show a notification and navigate to ', () => {
comp.createBitstreamFormat(bitstreamFormat);
expect(bitstreamFormatDataService.createBitstreamFormat).toHaveBeenCalledWith(bitstreamFormat);
expect(notificationService.success).toHaveBeenCalled();
expect(router.navigate).toHaveBeenCalledWith(['/admin/registries/bitstream-formats']);
});
});
describe('createBitstreamFormat error', () => {
beforeEach(async(() => {
router = new RouterStub();
notificationService = new NotificationsServiceStub();
bitstreamFormatDataService = jasmine.createSpyObj('bitstreamFormatDataService', {
createBitstreamFormat: observableOf(new RestResponse(false, 400, 'Bad Request')),
clearBitStreamFormatRequests: observableOf(null)
});
TestBed.configureTestingModule({
imports: [CommonModule, RouterTestingModule.withRoutes([]), TranslateModule.forRoot(), NgbModule.forRoot()],
declarations: [AddBitstreamFormatComponent],
providers: [
{provide: Router, useValue: router},
{provide: NotificationsService, useValue: notificationService},
{provide: BitstreamFormatDataService, useValue: bitstreamFormatDataService},
],
schemas: [CUSTOM_ELEMENTS_SCHEMA]
}).compileComponents();
}));
beforeEach(initBeforeEach);
it('should send the updated form to the service, show a notification and navigate to ', () => {
comp.createBitstreamFormat(bitstreamFormat);
expect(bitstreamFormatDataService.createBitstreamFormat).toHaveBeenCalledWith(bitstreamFormat);
expect(notificationService.error).toHaveBeenCalled();
expect(router.navigate).not.toHaveBeenCalled();
});
});
});

View File

@@ -0,0 +1,49 @@
import { take } from 'rxjs/operators';
import { Router } from '@angular/router';
import { Component } from '@angular/core';
import { BitstreamFormat } from '../../../../core/shared/bitstream-format.model';
import { BitstreamFormatDataService } from '../../../../core/data/bitstream-format-data.service';
import { RestResponse } from '../../../../core/cache/response.models';
import { NotificationsService } from '../../../../shared/notifications/notifications.service';
import { getBitstreamFormatsModulePath } from '../../admin-registries-routing.module';
import { TranslateService } from '@ngx-translate/core';
/**
* This component renders the page to create a new bitstream format.
*/
@Component({
selector: 'ds-add-bitstream-format',
templateUrl: './add-bitstream-format.component.html',
})
export class AddBitstreamFormatComponent {
constructor(
private router: Router,
private notificationService: NotificationsService,
private translateService: TranslateService,
private bitstreamFormatDataService: BitstreamFormatDataService,
) {
}
/**
* Creates a new bitstream format based on the provided bitstream format emitted by the form.
* When successful, a success notification will be shown and the user will be navigated back to the overview page.
* When failed, an error notification will be shown.
* @param bitstreamFormat
*/
createBitstreamFormat(bitstreamFormat: BitstreamFormat) {
this.bitstreamFormatDataService.createBitstreamFormat(bitstreamFormat).pipe(take(1)
).subscribe((response: RestResponse) => {
if (response.isSuccessful) {
this.notificationService.success(this.translateService.get('admin.registries.bitstream-formats.create.success.head'),
this.translateService.get('admin.registries.bitstream-formats.create.success.content'));
this.router.navigate([getBitstreamFormatsModulePath()]);
this.bitstreamFormatDataService.clearBitStreamFormatRequests().subscribe();
} else {
this.notificationService.error(this.translateService.get('admin.registries.bitstream-formats.create.failure.head'),
this.translateService.get('admin.registries.bitstream-formats.create.failure.content'));
}
}
);
}
}

View File

@@ -0,0 +1,64 @@
import { Action } from '@ngrx/store';
import { type } from '../../../shared/ngrx/type';
import { BitstreamFormat } from '../../../core/shared/bitstream-format.model';
/**
* For each action type in an action group, make a simple
* enum object for all of this group's action types.
*
* The 'type' utility function coerces strings into string
* literal types and runs a simple check to guarantee all
* action types in the application are unique.
*/
export const BitstreamFormatsRegistryActionTypes = {
SELECT_FORMAT: type('dspace/bitstream-formats-registry/SELECT_FORMAT'),
DESELECT_FORMAT: type('dspace/bitstream-formats-registry/DESELECT_FORMAT'),
DESELECT_ALL_FORMAT: type('dspace/bitstream-formats-registry/DESELECT_ALL_FORMAT')
};
/* tslint:disable:max-classes-per-file */
/**
* Used to select a single bitstream format in the bitstream format registry
*/
export class BitstreamFormatsRegistrySelectAction implements Action {
type = BitstreamFormatsRegistryActionTypes.SELECT_FORMAT;
bitstreamFormat: BitstreamFormat;
constructor(bitstreamFormat: BitstreamFormat) {
this.bitstreamFormat = bitstreamFormat;
}
}
/**
* Used to deselect a single bitstream format in the bitstream format registry
*/
export class BitstreamFormatsRegistryDeselectAction implements Action {
type = BitstreamFormatsRegistryActionTypes.DESELECT_FORMAT;
bitstreamFormat: BitstreamFormat;
constructor(bitstreamFormat: BitstreamFormat) {
this.bitstreamFormat = bitstreamFormat;
}
}
/**
* Used to deselect all bitstream formats in the bitstream format registry
*/
export class BitstreamFormatsRegistryDeselectAllAction implements Action {
type = BitstreamFormatsRegistryActionTypes.DESELECT_ALL_FORMAT;
}
/* tslint:enable:max-classes-per-file */
/**
* Export a type alias of all actions in this action group
* so that reducers can easily compose action types
* These are all the actions to perform on the bitstream format registry state
*/
export type BitstreamFormatsRegistryAction
= BitstreamFormatsRegistrySelectAction
| BitstreamFormatsRegistryDeselectAction
| BitstreamFormatsRegistryDeselectAllAction

View File

@@ -0,0 +1,83 @@
import { Action } from '@ngrx/store';
import { BitstreamFormat } from '../../../core/shared/bitstream-format.model';
import { bitstreamFormatReducer, BitstreamFormatRegistryState } from './bitstream-format.reducers';
import {
BitstreamFormatsRegistryDeselectAction,
BitstreamFormatsRegistryDeselectAllAction,
BitstreamFormatsRegistrySelectAction
} from './bitstream-format.actions';
const bitstreamFormat1: BitstreamFormat = new BitstreamFormat();
bitstreamFormat1.id = 'test-uuid-1';
bitstreamFormat1.shortDescription = 'test-short-1';
const bitstreamFormat2: BitstreamFormat = new BitstreamFormat();
bitstreamFormat2.id = 'test-uuid-2';
bitstreamFormat2.shortDescription = 'test-short-2';
const initialState: BitstreamFormatRegistryState = {
selectedBitstreamFormats: []
};
const bitstream1SelectedState: BitstreamFormatRegistryState = {
selectedBitstreamFormats: [bitstreamFormat1]
};
const bitstream1and2SelectedState: BitstreamFormatRegistryState = {
selectedBitstreamFormats: [bitstreamFormat1, bitstreamFormat2]
};
describe('BitstreamFormatReducer', () => {
describe('BitstreamFormatsRegistryActionTypes.SELECT_FORMAT', () => {
it('should add the format to the list of selected formats when initial list is empty', () => {
const state = initialState;
const action = new BitstreamFormatsRegistrySelectAction(bitstreamFormat1);
const newState = bitstreamFormatReducer(state, action);
expect(newState).toEqual(bitstream1SelectedState);
});
it('should add the format to the list of selected formats when formats are already present', () => {
const state = bitstream1SelectedState;
const action = new BitstreamFormatsRegistrySelectAction(bitstreamFormat2);
const newState = bitstreamFormatReducer(state, action);
expect(newState).toEqual(bitstream1and2SelectedState);
});
});
describe('BitstreamFormatsRegistryActionTypes.DESELECT_FORMAT', () => {
it('should deselect a format', () => {
const state = bitstream1and2SelectedState;
const action = new BitstreamFormatsRegistryDeselectAction(bitstreamFormat2);
const newState = bitstreamFormatReducer(state, action);
expect(newState).toEqual(bitstream1SelectedState);
});
});
describe('BitstreamFormatsRegistryActionTypes.DESELECT_ALL_FORMAT', () => {
it('should deselect all formats', () => {
const state = bitstream1and2SelectedState;
const action = new BitstreamFormatsRegistryDeselectAllAction();
const newState = bitstreamFormatReducer(state, action);
expect(newState).toEqual(initialState);
});
});
describe('Invalid action', () => {
it('should return the current state', () => {
const state = initialState;
const action = new NullAction();
const newState = bitstreamFormatReducer(state, action);
expect(newState).toEqual(state);
});
});
});
class NullAction implements Action {
type = null;
constructor() {
// empty constructor
}
}

View File

@@ -0,0 +1,55 @@
import { BitstreamFormat } from '../../../core/shared/bitstream-format.model';
import {
BitstreamFormatsRegistryAction,
BitstreamFormatsRegistryActionTypes,
BitstreamFormatsRegistryDeselectAction,
BitstreamFormatsRegistrySelectAction
} from './bitstream-format.actions';
/**
* The bitstream format registry state.
* @interface BitstreamFormatRegistryState
*/
export interface BitstreamFormatRegistryState {
selectedBitstreamFormats: BitstreamFormat[];
}
/**
* The initial state.
*/
const initialState: BitstreamFormatRegistryState = {
selectedBitstreamFormats: [],
};
/**
* Reducer that handles BitstreamFormatsRegistryActions to modify the bitstream format registry state
* @param state The current BitstreamFormatRegistryState
* @param action The BitstreamFormatsRegistryAction to perform on the state
*/
export function bitstreamFormatReducer(state = initialState, action: BitstreamFormatsRegistryAction): BitstreamFormatRegistryState {
switch (action.type) {
case BitstreamFormatsRegistryActionTypes.SELECT_FORMAT: {
return Object.assign({}, state, {
selectedBitstreamFormats: [...state.selectedBitstreamFormats, (action as BitstreamFormatsRegistrySelectAction).bitstreamFormat]
});
}
case BitstreamFormatsRegistryActionTypes.DESELECT_FORMAT: {
return Object.assign({}, state, {
selectedBitstreamFormats: state.selectedBitstreamFormats.filter(
(selectedBitstreamFormats) => selectedBitstreamFormats !== (action as BitstreamFormatsRegistryDeselectAction).bitstreamFormat
)
});
}
case BitstreamFormatsRegistryActionTypes.DESELECT_ALL_FORMAT: {
return Object.assign({}, state, {
selectedBitstreamFormats: []
});
}
default:
return state;
}
}

View File

@@ -0,0 +1,37 @@
import { NgModule } from '@angular/core';
import { RouterModule } from '@angular/router';
import { BitstreamFormatsResolver } from './bitstream-formats.resolver';
import { EditBitstreamFormatComponent } from './edit-bitstream-format/edit-bitstream-format.component';
import { BitstreamFormatsComponent } from './bitstream-formats.component';
import { AddBitstreamFormatComponent } from './add-bitstream-format/add-bitstream-format.component';
const BITSTREAMFORMAT_EDIT_PATH = ':id/edit';
const BITSTREAMFORMAT_ADD_PATH = 'add';
@NgModule({
imports: [
RouterModule.forChild([
{
path: '',
component: BitstreamFormatsComponent
},
{
path: BITSTREAMFORMAT_ADD_PATH,
component: AddBitstreamFormatComponent,
},
{
path: BITSTREAMFORMAT_EDIT_PATH,
component: EditBitstreamFormatComponent,
resolve: {
bitstreamFormat: BitstreamFormatsResolver
}
},
])
],
providers: [
BitstreamFormatsResolver,
]
})
export class BitstreamFormatsRoutingModule {
}

View File

@@ -2,13 +2,15 @@
<div class="bitstream-formats row"> <div class="bitstream-formats row">
<div class="col-12"> <div class="col-12">
<h2 id="header" class="border-bottom pb-2">{{'admin.registries.bitstream-formats.head' | translate}}</h2> <h2 id="header" class="border-bottom pb-2 ">{{'admin.registries.bitstream-formats.head' | translate}}</h2>
<p id="description">{{'admin.registries.bitstream-formats.description' | translate}}</p>
<p id="create-new" class="mb-2"><a [routerLink]="'add'" class="btn btn-success">{{'admin.registries.bitstream-formats.create.new' | translate}}</a></p>
<p id="description" class="pb-2">{{'admin.registries.bitstream-formats.description' | translate}}</p>
<ds-pagination <ds-pagination
*ngIf="(bitstreamFormats | async)?.payload?.totalElements > 0" *ngIf="(bitstreamFormats | async)?.payload?.totalElements > 0"
[paginationOptions]="config" [paginationOptions]="pageConfig"
[pageInfoState]="(bitstreamFormats | async)?.payload" [pageInfoState]="(bitstreamFormats | async)?.payload"
[collectionSize]="(bitstreamFormats | async)?.payload?.totalElements" [collectionSize]="(bitstreamFormats | async)?.payload?.totalElements"
[hideGear]="true" [hideGear]="true"
@@ -18,25 +20,38 @@
<table id="formats" class="table table-striped table-hover"> <table id="formats" class="table table-striped table-hover">
<thead> <thead>
<tr> <tr>
<th scope="col">{{'admin.registries.bitstream-formats.formats.table.name' | translate}}</th> <th scope="col"></th>
<th scope="col">{{'admin.registries.bitstream-formats.formats.table.mimetype' | translate}}</th> <th scope="col">{{'admin.registries.bitstream-formats.table.name' | translate}}</th>
<th scope="col">{{'admin.registries.bitstream-formats.formats.table.supportLevel.head' | translate}}</th> <th scope="col">{{'admin.registries.bitstream-formats.table.mimetype' | translate}}</th>
<th scope="col">{{'admin.registries.bitstream-formats.table.supportLevel.head' | translate}}</th>
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
<tr *ngFor="let bitstreamFormat of (bitstreamFormats | async)?.payload?.page"> <tr *ngFor="let bitstreamFormat of (bitstreamFormats | async)?.payload?.page">
<td>{{bitstreamFormat.shortDescription}}</td> <td>
<td>{{bitstreamFormat.mimetype}} <span *ngIf="bitstreamFormat.internal">({{'admin.registries.bitstream-formats.formats.table.internal' | translate}})</span></td> <label>
<td>{{'admin.registries.bitstream-formats.formats.table.supportLevel.'+bitstreamFormat.supportLevel | translate}}</td> <input type="checkbox"
[checked]="isSelected(bitstreamFormat) | async"
(change)="selectBitStreamFormat(bitstreamFormat, $event)"
>
</label>
</td>
<td><a [routerLink]="['/admin/registries/bitstream-formats', bitstreamFormat.id, 'edit']">{{bitstreamFormat.shortDescription}}</a></td>
<td><a [routerLink]="['/admin/registries/bitstream-formats', bitstreamFormat.id, 'edit']">{{bitstreamFormat.mimetype}} <span *ngIf="bitstreamFormat.internal">({{'admin.registries.bitstream-formats.table.internal' | translate}})</span></a></td>
<td><a [routerLink]="['/admin/registries/bitstream-formats', bitstreamFormat.id, 'edit']">{{'admin.registries.bitstream-formats.table.supportLevel.'+bitstreamFormat.supportLevel | translate}}</a></td>
</tr> </tr>
</tbody> </tbody>
</table> </table>
</div> </div>
</ds-pagination> </ds-pagination>
<div *ngIf="(bitstreamFormats | async)?.payload?.totalElements == 0" class="alert alert-info" role="alert"> <div *ngIf="(bitstreamFormats | async)?.payload?.totalElements == 0" class="alert alert-info" role="alert">
{{'admin.registries.bitstream-formats.formats.no-items' | translate}} {{'admin.registries.bitstream-formats.no-items' | translate}}
</div> </div>
<div>
<button *ngIf="(bitstreamFormats | async)?.payload?.page?.length > 0" class="btn btn-primary deselect" (click)="deselectAll()">{{'admin.registries.bitstream-formats.table.deselect-all' | translate}}</button>
<button *ngIf="(bitstreamFormats | async)?.payload?.page?.length > 0" type="submit" class="btn btn-danger float-right" (click)="deleteFormats()">{{'admin.registries.bitstream-formats.table.delete' | translate}}</button>
</div>
</div> </div>
</div> </div>
</div> </div>

View File

@@ -1,6 +1,5 @@
import { BitstreamFormatsComponent } from './bitstream-formats.component'; import { BitstreamFormatsComponent } from './bitstream-formats.component';
import { async, ComponentFixture, TestBed } from '@angular/core/testing'; import { async, ComponentFixture, TestBed } from '@angular/core/testing';
import { RegistryService } from '../../../core/registry/registry.service';
import { of as observableOf } from 'rxjs'; import { of as observableOf } from 'rxjs';
import { RemoteData } from '../../../core/data/remote-data'; import { RemoteData } from '../../../core/data/remote-data';
import { PaginatedList } from '../../../core/data/paginated-list'; import { PaginatedList } from '../../../core/data/paginated-list';
@@ -13,86 +12,278 @@ import { NgbModule } from '@ng-bootstrap/ng-bootstrap';
import { EnumKeysPipe } from '../../../shared/utils/enum-keys-pipe'; import { EnumKeysPipe } from '../../../shared/utils/enum-keys-pipe';
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 { createSuccessfulRemoteDataObject$ } from '../../../shared/testing/utils'; import { BitstreamFormatDataService } from '../../../core/data/bitstream-format-data.service';
import { NotificationsService } from '../../../shared/notifications/notifications.service';
import { NotificationsServiceStub } from '../../../shared/testing/notifications-service-stub';
import { BitstreamFormat } from '../../../core/shared/bitstream-format.model';
import { BitstreamFormatSupportLevel } from '../../../core/shared/bitstream-format-support-level';
import { cold, getTestScheduler, hot } from 'jasmine-marbles';
import { TestScheduler } from 'rxjs/testing';
describe('BitstreamFormatsComponent', () => { describe('BitstreamFormatsComponent', () => {
let comp: BitstreamFormatsComponent; let comp: BitstreamFormatsComponent;
let fixture: ComponentFixture<BitstreamFormatsComponent>; let fixture: ComponentFixture<BitstreamFormatsComponent>;
let registryService: RegistryService; let bitstreamFormatService;
const mockFormatsList = [ let scheduler: TestScheduler;
{ let notificationsServiceStub;
shortDescription: 'Unknown',
description: 'Unknown data format', const bitstreamFormat1 = new BitstreamFormat();
mimetype: 'application/octet-stream', bitstreamFormat1.uuid = 'test-uuid-1';
supportLevel: 0, bitstreamFormat1.id = 'test-uuid-1';
internal: false, bitstreamFormat1.shortDescription = 'Unknown';
extensions: null bitstreamFormat1.description = 'Unknown data format';
}, bitstreamFormat1.mimetype = 'application/octet-stream';
{ bitstreamFormat1.supportLevel = BitstreamFormatSupportLevel.Unknown;
shortDescription: 'License', bitstreamFormat1.internal = false;
description: 'Item-specific license agreed upon to submission', bitstreamFormat1.extensions = null;
mimetype: 'text/plain; charset=utf-8',
supportLevel: 1, const bitstreamFormat2 = new BitstreamFormat();
internal: true, bitstreamFormat2.uuid = 'test-uuid-2';
extensions: null bitstreamFormat2.id = 'test-uuid-2';
}, bitstreamFormat2.shortDescription = 'License';
{ bitstreamFormat2.description = 'Item-specific license agreed upon to submission';
shortDescription: 'CC License', bitstreamFormat2.mimetype = 'text/plain; charset=utf-8';
description: 'Item-specific Creative Commons license agreed upon to submission', bitstreamFormat2.supportLevel = BitstreamFormatSupportLevel.Known;
mimetype: 'text/html; charset=utf-8', bitstreamFormat2.internal = true;
supportLevel: 2, bitstreamFormat2.extensions = null;
internal: true,
extensions: null const bitstreamFormat3 = new BitstreamFormat();
}, bitstreamFormat3.uuid = 'test-uuid-3';
{ bitstreamFormat3.id = 'test-uuid-3';
shortDescription: 'Adobe PDF', bitstreamFormat3.shortDescription = 'CC License';
description: 'Adobe Portable Document Format', bitstreamFormat3.description = 'Item-specific Creative Commons license agreed upon to submission';
mimetype: 'application/pdf', bitstreamFormat3.mimetype = 'text/html; charset=utf-8';
supportLevel: 0, bitstreamFormat3.supportLevel = BitstreamFormatSupportLevel.Supported;
internal: false, bitstreamFormat3.internal = true;
extensions: null bitstreamFormat3.extensions = null;
}
]; const bitstreamFormat4 = new BitstreamFormat();
const mockFormats = createSuccessfulRemoteDataObject$(new PaginatedList(null, mockFormatsList)); bitstreamFormat4.uuid = 'test-uuid-4';
const registryServiceStub = { bitstreamFormat4.id = 'test-uuid-4';
getBitstreamFormats: () => mockFormats bitstreamFormat4.shortDescription = 'Adobe PDF';
}; bitstreamFormat4.description = 'Adobe Portable Document Format';
bitstreamFormat4.mimetype = 'application/pdf';
bitstreamFormat4.supportLevel = BitstreamFormatSupportLevel.Unknown;
bitstreamFormat4.internal = false;
bitstreamFormat4.extensions = null;
const mockFormatsList: BitstreamFormat[] = [
bitstreamFormat1,
bitstreamFormat2,
bitstreamFormat3,
bitstreamFormat4
];
const mockFormatsRD = new RemoteData(false, false, true, undefined, new PaginatedList(null, mockFormatsList));
const initAsync = () => {
notificationsServiceStub = new NotificationsServiceStub();
scheduler = getTestScheduler();
bitstreamFormatService = jasmine.createSpyObj('bitstreamFormatService', {
findAll: observableOf(mockFormatsRD),
find: observableOf(new RemoteData(false, false, true, undefined, mockFormatsList[0])),
getSelectedBitstreamFormats: hot('a', {a: mockFormatsList}),
selectBitstreamFormat: {},
deselectBitstreamFormat: {},
deselectAllBitstreamFormats: {},
delete: observableOf(true),
clearBitStreamFormatRequests: observableOf('cleared')
});
beforeEach(async(() => {
TestBed.configureTestingModule({ TestBed.configureTestingModule({
imports: [CommonModule, RouterTestingModule.withRoutes([]), TranslateModule.forRoot(), NgbModule.forRoot()], imports: [CommonModule, RouterTestingModule.withRoutes([]), TranslateModule.forRoot(), NgbModule.forRoot()],
declarations: [BitstreamFormatsComponent, PaginationComponent, EnumKeysPipe], declarations: [BitstreamFormatsComponent, PaginationComponent, EnumKeysPipe],
providers: [ providers: [
{ provide: RegistryService, useValue: registryServiceStub }, {provide: BitstreamFormatDataService, useValue: bitstreamFormatService},
{ provide: HostWindowService, useValue: new HostWindowServiceStub(0) } {provide: HostWindowService, useValue: new HostWindowServiceStub(0)},
{provide: NotificationsService, useValue: notificationsServiceStub}
] ]
}).compileComponents(); }).compileComponents();
})); };
beforeEach(() => { const initBeforeEach = () => {
fixture = TestBed.createComponent(BitstreamFormatsComponent); fixture = TestBed.createComponent(BitstreamFormatsComponent);
comp = fixture.componentInstance; comp = fixture.componentInstance;
fixture.detectChanges(); fixture.detectChanges();
registryService = (comp as any).service; };
describe('Bitstream format page content', () => {
beforeEach(async(initAsync));
beforeEach(initBeforeEach);
it('should contain four formats', () => {
const tbody: HTMLElement = fixture.debugElement.query(By.css('#formats>tbody')).nativeElement;
expect(tbody.children.length).toBe(4);
});
it('should contain the correct formats', () => {
const unknownName: HTMLElement = fixture.debugElement.query(By.css('#formats tr:nth-child(1) td:nth-child(2)')).nativeElement;
expect(unknownName.textContent).toBe('Unknown');
const licenseName: HTMLElement = fixture.debugElement.query(By.css('#formats tr:nth-child(2) td:nth-child(2)')).nativeElement;
expect(licenseName.textContent).toBe('License');
const ccLicenseName: HTMLElement = fixture.debugElement.query(By.css('#formats tr:nth-child(3) td:nth-child(2)')).nativeElement;
expect(ccLicenseName.textContent).toBe('CC License');
const adobeName: HTMLElement = fixture.debugElement.query(By.css('#formats tr:nth-child(4) td:nth-child(2)')).nativeElement;
expect(adobeName.textContent).toBe('Adobe PDF');
});
}); });
it('should contain four formats', () => { describe('selectBitStreamFormat', () => {
const tbody: HTMLElement = fixture.debugElement.query(By.css('#formats>tbody')).nativeElement; beforeEach(async(initAsync));
expect(tbody.children.length).toBe(4); beforeEach(initBeforeEach);
it('should select a bitstreamFormat if it was selected in the event', () => {
const event = {target: {checked: true}};
comp.selectBitStreamFormat(bitstreamFormat1, event);
expect(bitstreamFormatService.selectBitstreamFormat).toHaveBeenCalledWith(bitstreamFormat1);
});
it('should deselect a bitstreamFormat if it is deselected in the event', () => {
const event = {target: {checked: false}};
comp.selectBitStreamFormat(bitstreamFormat1, event);
expect(bitstreamFormatService.deselectBitstreamFormat).toHaveBeenCalledWith(bitstreamFormat1);
});
it('should be called when a user clicks a checkbox', () => {
spyOn(comp, 'selectBitStreamFormat');
const unknownFormat = fixture.debugElement.query(By.css('#formats tr:nth-child(1) input'));
const event = {target: {checked: true}};
unknownFormat.triggerEventHandler('change', event);
expect(comp.selectBitStreamFormat).toHaveBeenCalledWith(bitstreamFormat1, event);
});
}); });
it('should contain the correct formats', () => { describe('isSelected', () => {
const unknownName: HTMLElement = fixture.debugElement.query(By.css('#formats tr:nth-child(1) td:nth-child(1)')).nativeElement; beforeEach(async(initAsync));
expect(unknownName.textContent).toBe('Unknown'); beforeEach(initBeforeEach);
it('should return an observable of true if the provided bistream is in the list returned by the service', () => {
const result = comp.isSelected(bitstreamFormat1);
const licenseName: HTMLElement = fixture.debugElement.query(By.css('#formats tr:nth-child(2) td:nth-child(1)')).nativeElement; expect(result).toBeObservable(cold('b', {b: true}));
expect(licenseName.textContent).toBe('License'); });
it('should return an observable of false if the provided bistream is not in the list returned by the service', () => {
const format = new BitstreamFormat();
format.uuid = 'new';
const ccLicenseName: HTMLElement = fixture.debugElement.query(By.css('#formats tr:nth-child(3) td:nth-child(1)')).nativeElement; const result = comp.isSelected(format);
expect(ccLicenseName.textContent).toBe('CC License');
const adobeName: HTMLElement = fixture.debugElement.query(By.css('#formats tr:nth-child(4) td:nth-child(1)')).nativeElement; expect(result).toBeObservable(cold('b', {b: false}));
expect(adobeName.textContent).toBe('Adobe PDF'); });
}); });
describe('deselectAll', () => {
beforeEach(async(initAsync));
beforeEach(initBeforeEach);
it('should deselect all bitstreamFormats', () => {
comp.deselectAll();
expect(bitstreamFormatService.deselectAllBitstreamFormats).toHaveBeenCalled();
});
it('should be called when the deselect all button is clicked', () => {
spyOn(comp, 'deselectAll');
const deselectAllButton = fixture.debugElement.query(By.css('button.deselect'));
deselectAllButton.triggerEventHandler('click', null);
expect(comp.deselectAll).toHaveBeenCalled();
});
});
describe('deleteFormats success', () => {
beforeEach(async(() => {
notificationsServiceStub = new NotificationsServiceStub();
scheduler = getTestScheduler();
bitstreamFormatService = jasmine.createSpyObj('bitstreamFormatService', {
findAll: observableOf(mockFormatsRD),
find: observableOf(new RemoteData(false, false, true, undefined, mockFormatsList[0])),
getSelectedBitstreamFormats: observableOf(mockFormatsList),
selectBitstreamFormat: {},
deselectBitstreamFormat: {},
deselectAllBitstreamFormats: {},
delete: observableOf(true),
clearBitStreamFormatRequests: observableOf('cleared')
});
TestBed.configureTestingModule({
imports: [CommonModule, RouterTestingModule.withRoutes([]), TranslateModule.forRoot(), NgbModule.forRoot()],
declarations: [BitstreamFormatsComponent, PaginationComponent, EnumKeysPipe],
providers: [
{provide: BitstreamFormatDataService, useValue: bitstreamFormatService},
{provide: HostWindowService, useValue: new HostWindowServiceStub(0)},
{provide: NotificationsService, useValue: notificationsServiceStub}
]
}).compileComponents();
}
));
beforeEach(initBeforeEach);
it('should clear bitstream formats ', () => {
comp.deleteFormats();
expect(bitstreamFormatService.clearBitStreamFormatRequests).toHaveBeenCalled();
expect(bitstreamFormatService.delete).toHaveBeenCalledWith(bitstreamFormat1);
expect(bitstreamFormatService.delete).toHaveBeenCalledWith(bitstreamFormat2);
expect(bitstreamFormatService.delete).toHaveBeenCalledWith(bitstreamFormat3);
expect(bitstreamFormatService.delete).toHaveBeenCalledWith(bitstreamFormat4);
expect(notificationsServiceStub.success).toHaveBeenCalledWith('admin.registries.bitstream-formats.delete.success.head',
'admin.registries.bitstream-formats.delete.success.amount');
expect(notificationsServiceStub.error).not.toHaveBeenCalled();
});
});
describe('deleteFormats error', () => {
beforeEach(async(() => {
notificationsServiceStub = new NotificationsServiceStub();
scheduler = getTestScheduler();
bitstreamFormatService = jasmine.createSpyObj('bitstreamFormatService', {
findAll: observableOf(mockFormatsRD),
find: observableOf(new RemoteData(false, false, true, undefined, mockFormatsList[0])),
getSelectedBitstreamFormats: observableOf(mockFormatsList),
selectBitstreamFormat: {},
deselectBitstreamFormat: {},
deselectAllBitstreamFormats: {},
delete: observableOf(false),
clearBitStreamFormatRequests: observableOf('cleared')
});
TestBed.configureTestingModule({
imports: [CommonModule, RouterTestingModule.withRoutes([]), TranslateModule.forRoot(), NgbModule.forRoot()],
declarations: [BitstreamFormatsComponent, PaginationComponent, EnumKeysPipe],
providers: [
{provide: BitstreamFormatDataService, useValue: bitstreamFormatService},
{provide: HostWindowService, useValue: new HostWindowServiceStub(0)},
{provide: NotificationsService, useValue: notificationsServiceStub}
]
}).compileComponents();
}
));
beforeEach(initBeforeEach);
it('should clear bitstream formats ', () => {
comp.deleteFormats();
expect(bitstreamFormatService.clearBitStreamFormatRequests).toHaveBeenCalled();
expect(bitstreamFormatService.delete).toHaveBeenCalledWith(bitstreamFormat1);
expect(bitstreamFormatService.delete).toHaveBeenCalledWith(bitstreamFormat2);
expect(bitstreamFormatService.delete).toHaveBeenCalledWith(bitstreamFormat3);
expect(bitstreamFormatService.delete).toHaveBeenCalledWith(bitstreamFormat4);
expect(notificationsServiceStub.error).toHaveBeenCalledWith('admin.registries.bitstream-formats.delete.failure.head',
'admin.registries.bitstream-formats.delete.failure.amount');
expect(notificationsServiceStub.success).not.toHaveBeenCalled();
});
});
}); });

View File

@@ -1,10 +1,16 @@
import { Component } from '@angular/core'; import { Component, OnInit } from '@angular/core';
import { RegistryService } from '../../../core/registry/registry.service'; import { BehaviorSubject, combineLatest as observableCombineLatest, Observable, zip } from 'rxjs';
import { Observable } from 'rxjs';
import { RemoteData } from '../../../core/data/remote-data'; import { RemoteData } from '../../../core/data/remote-data';
import { PaginatedList } from '../../../core/data/paginated-list'; import { PaginatedList } from '../../../core/data/paginated-list';
import { BitstreamFormat } from '../../../core/registry/mock-bitstream-format.model';
import { PaginationComponentOptions } from '../../../shared/pagination/pagination-component-options.model'; import { PaginationComponentOptions } from '../../../shared/pagination/pagination-component-options.model';
import { BitstreamFormat } from '../../../core/shared/bitstream-format.model';
import { BitstreamFormatDataService } from '../../../core/data/bitstream-format-data.service';
import { FindAllOptions } from '../../../core/data/request.models';
import { map, switchMap, take } from 'rxjs/operators';
import { hasValue } from '../../../shared/empty.util';
import { NotificationsService } from '../../../shared/notifications/notifications.service';
import { Router } from '@angular/router';
import { TranslateService } from '@ngx-translate/core';
/** /**
* This component renders a list of bitstream formats * This component renders a list of bitstream formats
@@ -13,24 +19,125 @@ import { PaginationComponentOptions } from '../../../shared/pagination/paginatio
selector: 'ds-bitstream-formats', selector: 'ds-bitstream-formats',
templateUrl: './bitstream-formats.component.html' templateUrl: './bitstream-formats.component.html'
}) })
export class BitstreamFormatsComponent { export class BitstreamFormatsComponent implements OnInit {
/** /**
* A paginated list of bitstream formats to be shown on the page * A paginated list of bitstream formats to be shown on the page
*/ */
bitstreamFormats: Observable<RemoteData<PaginatedList<BitstreamFormat>>>; bitstreamFormats: Observable<RemoteData<PaginatedList<BitstreamFormat>>>;
/**
* A BehaviourSubject that keeps track of the pageState used to update the currently displayed bitstreamFormats
*/
pageState: BehaviorSubject<string>;
/**
* The current pagination configuration for the page used by the FindAll method
* Currently simply renders all bitstream formats
*/
config: FindAllOptions = Object.assign(new FindAllOptions(), {
elementsPerPage: 20
});
/** /**
* The current pagination configuration for the page * The current pagination configuration for the page
* Currently simply renders all bitstream formats * Currently simply renders all bitstream formats
*/ */
config: PaginationComponentOptions = Object.assign(new PaginationComponentOptions(), { pageConfig: PaginationComponentOptions = Object.assign(new PaginationComponentOptions(), {
id: 'registry-bitstreamformats-pagination', id: 'registry-bitstreamformats-pagination',
pageSize: 10000 pageSize: 20
}); });
constructor(private registryService: RegistryService) { constructor(private notificationsService: NotificationsService,
this.updateFormats(); private router: Router,
private translateService: TranslateService,
private bitstreamFormatService: BitstreamFormatDataService) {
}
/**
* Deletes the currently selected formats from the registry and updates the presented list
*/
deleteFormats() {
this.bitstreamFormatService.clearBitStreamFormatRequests().subscribe();
this.bitstreamFormatService.getSelectedBitstreamFormats().pipe(take(1)).subscribe(
(formats) => {
const tasks$ = [];
for (const format of formats) {
if (hasValue(format.id)) {
tasks$.push(this.bitstreamFormatService.delete(format));
}
}
zip(...tasks$).subscribe((results: boolean[]) => {
const successResponses = results.filter((result: boolean) => result);
const failedResponses = results.filter((result: boolean) => !result);
if (successResponses.length > 0) {
this.showNotification(true, successResponses.length);
}
if (failedResponses.length > 0) {
this.showNotification(false, failedResponses.length);
}
this.deselectAll();
this.router.navigate([], {
queryParams: Object.assign({}, { page: 1 }),
queryParamsHandling: 'merge'
}); });
}
);
}
/**
* Deselects all selecetd bitstream formats
*/
deselectAll() {
this.bitstreamFormatService.deselectAllBitstreamFormats();
}
/**
* Checks whether a given bitstream format is selected in the list (checkbox)
* @param bitstreamFormat
*/
isSelected(bitstreamFormat: BitstreamFormat): Observable<boolean> {
return this.bitstreamFormatService.getSelectedBitstreamFormats().pipe(
map((bitstreamFormats: BitstreamFormat[]) => {
return bitstreamFormats.find((selectedFormat) => selectedFormat.id === bitstreamFormat.id) != null;
})
);
}
/**
* Selects or deselects a bitstream format based on the checkbox state
* @param bitstreamFormat
* @param event
*/
selectBitStreamFormat(bitstreamFormat: BitstreamFormat, event) {
event.target.checked ?
this.bitstreamFormatService.selectBitstreamFormat(bitstreamFormat) :
this.bitstreamFormatService.deselectBitstreamFormat(bitstreamFormat);
}
/**
* Show notifications for an amount of deleted bitstream formats
* @param success Whether or not the notification should be a success message (error message when false)
* @param amount The amount of deleted bitstream formats
*/
private showNotification(success: boolean, amount: number) {
const prefix = 'admin.registries.bitstream-formats.delete';
const suffix = success ? 'success' : 'failure';
const messages = observableCombineLatest(
this.translateService.get(`${prefix}.${suffix}.head`),
this.translateService.get(`${prefix}.${suffix}.amount`, {amount: amount})
);
messages.subscribe(([head, content]) => {
if (success) {
this.notificationsService.success(head, content);
} else {
this.notificationsService.error(head, content);
}
});
} }
/** /**
@@ -38,14 +145,26 @@ export class BitstreamFormatsComponent {
* @param event The page change event * @param event The page change event
*/ */
onPageChange(event) { onPageChange(event) {
this.config.currentPage = event; this.config = Object.assign(new FindAllOptions(), this.config, {
this.updateFormats(); currentPage: event,
});
this.pageConfig.currentPage = event;
this.pageState.next('pageChange');
}
ngOnInit(): void {
this.pageState = new BehaviorSubject('init');
this.bitstreamFormats = this.pageState.pipe(
switchMap(() => {
return this.updateFormats()
;
}));
} }
/** /**
* Method to update the bitstream formats that are shown * Finds all formats based on the current config
*/ */
private updateFormats() { private updateFormats() {
this.bitstreamFormats = this.registryService.getBitstreamFormats(this.config); return this.bitstreamFormatService.findAll(this.config);
} }
} }

View File

@@ -0,0 +1,30 @@
import { NgModule } from '@angular/core';
import { CommonModule } from '@angular/common';
import { RouterModule } from '@angular/router';
import { TranslateModule } from '@ngx-translate/core';
import { BitstreamFormatsComponent } from './bitstream-formats.component';
import { SharedModule } from '../../../shared/shared.module';
import { FormatFormComponent } from './format-form/format-form.component';
import { EditBitstreamFormatComponent } from './edit-bitstream-format/edit-bitstream-format.component';
import { BitstreamFormatsRoutingModule } from './bitstream-formats-routing.module';
import { AddBitstreamFormatComponent } from './add-bitstream-format/add-bitstream-format.component';
@NgModule({
imports: [
CommonModule,
SharedModule,
RouterModule,
TranslateModule,
BitstreamFormatsRoutingModule
],
declarations: [
BitstreamFormatsComponent,
EditBitstreamFormatComponent,
AddBitstreamFormatComponent,
FormatFormComponent
],
entryComponents: []
})
export class BitstreamFormatsModule {
}

View File

@@ -0,0 +1,31 @@
import { Injectable } from '@angular/core';
import { ActivatedRouteSnapshot, Resolve, RouterStateSnapshot } from '@angular/router';
import { Observable } from 'rxjs';
import { find } from 'rxjs/operators';
import { RemoteData } from '../../../core/data/remote-data';
import { BitstreamFormat } from '../../../core/shared/bitstream-format.model';
import { BitstreamFormatDataService } from '../../../core/data/bitstream-format-data.service';
import { hasValue } from '../../../shared/empty.util';
/**
* This class represents a resolver that requests a specific bitstreamFormat before the route is activated
*/
@Injectable()
export class BitstreamFormatsResolver implements Resolve<RemoteData<BitstreamFormat>> {
constructor(private bitstreamFormatDataService: BitstreamFormatDataService) {
}
/**
* Method for resolving an bitstreamFormat based on the parameters in the current route
* @param {ActivatedRouteSnapshot} route The current ActivatedRouteSnapshot
* @param {RouterStateSnapshot} state The current RouterStateSnapshot
* @returns Observable<<RemoteData<BitstreamFormat>> Emits the found bitstreamFormat based on the parameters in the current route,
* or an error if something went wrong
*/
resolve(route: ActivatedRouteSnapshot, state: RouterStateSnapshot): Observable<RemoteData<BitstreamFormat>> {
return this.bitstreamFormatDataService.findById(route.params.id)
.pipe(
find((RD) => hasValue(RD.error) || RD.hasSucceeded),
);
}
}

View File

@@ -0,0 +1,11 @@
<div class="container">
<div class="row">
<div class="col-12 mb-4">
<h2 id="sub-header"
class="border-bottom mb-2">{{'admin.registries.bitstream-formats.edit.head' | translate:{format: (bitstreamFormatRD$ | async)?.payload.shortDescription} }}</h2>
<ds-bitstream-format-form [bitstreamFormat]="(bitstreamFormatRD$ | async)?.payload" (updatedFormat)="updateFormat($event)"></ds-bitstream-format-form>
</div>
</div>
</div>

View File

@@ -0,0 +1,123 @@
import { async, ComponentFixture, TestBed } from '@angular/core/testing';
import { CommonModule } from '@angular/common';
import { RouterTestingModule } from '@angular/router/testing';
import { TranslateModule } from '@ngx-translate/core';
import { NgbModule } from '@ng-bootstrap/ng-bootstrap';
import { ActivatedRoute, Router } from '@angular/router';
import { CUSTOM_ELEMENTS_SCHEMA } from '@angular/core';
import { RouterStub } from '../../../../shared/testing/router-stub';
import { of as observableOf } from 'rxjs';
import { RemoteData } from '../../../../core/data/remote-data';
import { EditBitstreamFormatComponent } from './edit-bitstream-format.component';
import { NotificationsService } from '../../../../shared/notifications/notifications.service';
import { NotificationsServiceStub } from '../../../../shared/testing/notifications-service-stub';
import { BitstreamFormatDataService } from '../../../../core/data/bitstream-format-data.service';
import { RestResponse } from '../../../../core/cache/response.models';
import { BitstreamFormat } from '../../../../core/shared/bitstream-format.model';
import { BitstreamFormatSupportLevel } from '../../../../core/shared/bitstream-format-support-level';
import { ResourceType } from '../../../../core/shared/resource-type';
describe('EditBitstreamFormatComponent', () => {
let comp: EditBitstreamFormatComponent;
let fixture: ComponentFixture<EditBitstreamFormatComponent>;
const bitstreamFormat = new BitstreamFormat();
bitstreamFormat.uuid = 'test-uuid-1';
bitstreamFormat.id = 'test-uuid-1';
bitstreamFormat.shortDescription = 'Unknown';
bitstreamFormat.description = 'Unknown data format';
bitstreamFormat.mimetype = 'application/octet-stream';
bitstreamFormat.supportLevel = BitstreamFormatSupportLevel.Unknown;
bitstreamFormat.internal = false;
bitstreamFormat.extensions = null;
const routeStub = {
data: observableOf({
bitstreamFormat: new RemoteData(false, false, true, null, bitstreamFormat)
})
};
let router;
let notificationService: NotificationsServiceStub;
let bitstreamFormatDataService: BitstreamFormatDataService;
const initAsync = () => {
router = new RouterStub();
notificationService = new NotificationsServiceStub();
bitstreamFormatDataService = jasmine.createSpyObj('bitstreamFormatDataService', {
updateBitstreamFormat: observableOf(new RestResponse(true, 200, 'Success'))
});
TestBed.configureTestingModule({
imports: [CommonModule, RouterTestingModule.withRoutes([]), TranslateModule.forRoot(), NgbModule.forRoot()],
declarations: [EditBitstreamFormatComponent],
providers: [
{provide: ActivatedRoute, useValue: routeStub},
{provide: Router, useValue: router},
{provide: NotificationsService, useValue: notificationService},
{provide: BitstreamFormatDataService, useValue: bitstreamFormatDataService},
],
schemas: [CUSTOM_ELEMENTS_SCHEMA]
}).compileComponents();
};
const initBeforeEach = () => {
fixture = TestBed.createComponent(EditBitstreamFormatComponent);
comp = fixture.componentInstance;
fixture.detectChanges();
};
describe('init', () => {
beforeEach(async(initAsync));
beforeEach(initBeforeEach);
it('should initialise the bitstreamFormat based on the route', () => {
comp.bitstreamFormatRD$.subscribe((format: RemoteData<BitstreamFormat>) => {
expect(format).toEqual(new RemoteData(false, false, true, null, bitstreamFormat));
});
});
});
describe('updateFormat success', () => {
beforeEach(async(initAsync));
beforeEach(initBeforeEach);
it('should send the updated form to the service, show a notification and navigate to ', () => {
comp.updateFormat(bitstreamFormat);
expect(bitstreamFormatDataService.updateBitstreamFormat).toHaveBeenCalledWith(bitstreamFormat);
expect(notificationService.success).toHaveBeenCalled();
expect(router.navigate).toHaveBeenCalledWith(['/admin/registries/bitstream-formats']);
});
});
describe('updateFormat error', () => {
beforeEach(async( () => {
router = new RouterStub();
notificationService = new NotificationsServiceStub();
bitstreamFormatDataService = jasmine.createSpyObj('bitstreamFormatDataService', {
updateBitstreamFormat: observableOf(new RestResponse(false, 400, 'Bad Request'))
});
TestBed.configureTestingModule({
imports: [CommonModule, RouterTestingModule.withRoutes([]), TranslateModule.forRoot(), NgbModule.forRoot()],
declarations: [EditBitstreamFormatComponent],
providers: [
{provide: ActivatedRoute, useValue: routeStub},
{provide: Router, useValue: router},
{provide: NotificationsService, useValue: notificationService},
{provide: BitstreamFormatDataService, useValue: bitstreamFormatDataService},
],
schemas: [CUSTOM_ELEMENTS_SCHEMA]
}).compileComponents();
}));
beforeEach(initBeforeEach);
it('should send the updated form to the service, show a notification and navigate to ', () => {
comp.updateFormat(bitstreamFormat);
expect(bitstreamFormatDataService.updateBitstreamFormat).toHaveBeenCalledWith(bitstreamFormat);
expect(notificationService.error).toHaveBeenCalled();
expect(router.navigate).not.toHaveBeenCalled();
});
});
});

View File

@@ -0,0 +1,62 @@
import { map, take } from 'rxjs/operators';
import { ActivatedRoute, Router } from '@angular/router';
import { Observable } from 'rxjs';
import { Component, OnInit } from '@angular/core';
import { RemoteData } from '../../../../core/data/remote-data';
import { BitstreamFormat } from '../../../../core/shared/bitstream-format.model';
import { BitstreamFormatDataService } from '../../../../core/data/bitstream-format-data.service';
import { RestResponse } from '../../../../core/cache/response.models';
import { NotificationsService } from '../../../../shared/notifications/notifications.service';
import { getBitstreamFormatsModulePath } from '../../admin-registries-routing.module';
import { TranslateService } from '@ngx-translate/core';
/**
* This component renders the edit page of a bitstream format.
* The route parameter 'id' is used to request the bitstream format.
*/
@Component({
selector: 'ds-edit-bitstream-format',
templateUrl: './edit-bitstream-format.component.html',
})
export class EditBitstreamFormatComponent implements OnInit {
/**
* The bitstream format wrapped in a remote-data object
*/
bitstreamFormatRD$: Observable<RemoteData<BitstreamFormat>>;
constructor(
private route: ActivatedRoute,
private router: Router,
private notificationService: NotificationsService,
private translateService: TranslateService,
private bitstreamFormatDataService: BitstreamFormatDataService,
) {
}
ngOnInit(): void {
this.bitstreamFormatRD$ = this.route.data.pipe(
map((data) => data.bitstreamFormat as RemoteData<BitstreamFormat>)
);
}
/**
* Updates the bitstream format based on the provided bitstream format emitted by the form.
* When successful, a success notification will be shown and the user will be navigated back to the overview page.
* When failed, an error notification will be shown.
*/
updateFormat(bitstreamFormat: BitstreamFormat) {
this.bitstreamFormatDataService.updateBitstreamFormat(bitstreamFormat).pipe(take(1)
).subscribe((response: RestResponse) => {
if (response.isSuccessful) {
this.notificationService.success(this.translateService.get('admin.registries.bitstream-formats.edit.success.head'),
this.translateService.get('admin.registries.bitstream-formats.edit.success.content'));
this.router.navigate([getBitstreamFormatsModulePath()]);
} else {
this.notificationService.error('admin.registries.bitstream-formats.edit.failure.head',
'admin.registries.bitstream-formats.create.edit.content');
}
}
);
}
}

View File

@@ -0,0 +1,3 @@
<ds-form *ngIf="formModel"
[formId]="'comcol-form-id'"
[formModel]="formModel" (submitForm)="onSubmit()" (cancel)="onCancel()"></ds-form>

View File

@@ -0,0 +1,104 @@
import { async, ComponentFixture, TestBed } from '@angular/core/testing';
import { CommonModule } from '@angular/common';
import { RouterTestingModule } from '@angular/router/testing';
import { TranslateModule } from '@ngx-translate/core';
import { NgbModule } from '@ng-bootstrap/ng-bootstrap';
import { RouterStub } from '../../../../shared/testing/router-stub';
import { FormsModule, ReactiveFormsModule } from '@angular/forms';
import { Router } from '@angular/router';
import { FormatFormComponent } from './format-form.component';
import { BitstreamFormat } from '../../../../core/shared/bitstream-format.model';
import { BitstreamFormatSupportLevel } from '../../../../core/shared/bitstream-format-support-level';
import { DynamicCheckboxModel, DynamicFormArrayModel, DynamicInputModel } from '@ng-dynamic-forms/core';
import { CUSTOM_ELEMENTS_SCHEMA } from '@angular/core';
import { isEmpty } from '../../../../shared/empty.util';
describe('FormatFormComponent', () => {
let comp: FormatFormComponent;
let fixture: ComponentFixture<FormatFormComponent>;
const router = new RouterStub();
const bitstreamFormat = new BitstreamFormat();
bitstreamFormat.uuid = 'test-uuid-1';
bitstreamFormat.id = 'test-uuid-1';
bitstreamFormat.shortDescription = 'Unknown';
bitstreamFormat.description = 'Unknown data format';
bitstreamFormat.mimetype = 'application/octet-stream';
bitstreamFormat.supportLevel = BitstreamFormatSupportLevel.Unknown;
bitstreamFormat.internal = false;
bitstreamFormat.extensions = [];
const submittedBitstreamFormat = new BitstreamFormat();
submittedBitstreamFormat.id = bitstreamFormat.id;
submittedBitstreamFormat.shortDescription = bitstreamFormat.shortDescription;
submittedBitstreamFormat.mimetype = bitstreamFormat.mimetype;
submittedBitstreamFormat.description = bitstreamFormat.description;
submittedBitstreamFormat.supportLevel = bitstreamFormat.supportLevel;
submittedBitstreamFormat.internal = bitstreamFormat.internal;
submittedBitstreamFormat.extensions = bitstreamFormat.extensions;
const initAsync = () => {
TestBed.configureTestingModule({
imports: [CommonModule, RouterTestingModule.withRoutes([]), ReactiveFormsModule, FormsModule, TranslateModule.forRoot(), NgbModule.forRoot()],
declarations: [FormatFormComponent],
providers: [
{provide: Router, useValue: router},
],
schemas: [CUSTOM_ELEMENTS_SCHEMA]
}).compileComponents();
};
const initBeforeEach = () => {
fixture = TestBed.createComponent(FormatFormComponent);
comp = fixture.componentInstance;
comp.bitstreamFormat = bitstreamFormat;
fixture.detectChanges();
};
describe('initialise', () => {
beforeEach(async(initAsync));
beforeEach(initBeforeEach);
it('should initialises the values in the form', () => {
expect((comp.formModel[0] as DynamicInputModel).value).toBe(bitstreamFormat.shortDescription);
expect((comp.formModel[1] as DynamicInputModel).value).toBe(bitstreamFormat.mimetype);
expect((comp.formModel[2] as DynamicInputModel).value).toBe(bitstreamFormat.description);
expect((comp.formModel[3] as DynamicInputModel).value).toBe(bitstreamFormat.supportLevel);
expect((comp.formModel[4] as DynamicCheckboxModel).value).toBe(bitstreamFormat.internal);
const formArray = (comp.formModel[5] as DynamicFormArrayModel);
const extensions = [];
for (let i = 0; i < formArray.groups.length; i++) {
const value = (formArray.get(i).get(0) as DynamicInputModel).value;
if (!isEmpty(value)) {
extensions.push((formArray.get(i).get(0) as DynamicInputModel).value);
}
}
expect(extensions).toEqual(bitstreamFormat.extensions);
});
});
describe('onSubmit', () => {
beforeEach(async(initAsync));
beforeEach(initBeforeEach);
it('should emit the bitstreamFormat currently present in the form', () => {
spyOn(comp.updatedFormat, 'emit');
comp.onSubmit();
expect(comp.updatedFormat.emit).toHaveBeenCalledWith(submittedBitstreamFormat);
});
});
describe('onCancel', () => {
beforeEach(async(initAsync));
beforeEach(initBeforeEach);
it('should navigate back to the bitstream overview', () => {
comp.onCancel();
expect(router.navigate).toHaveBeenCalledWith(['/admin/registries/bitstream-formats']);
});
});
});

View File

@@ -0,0 +1,194 @@
import { Component, EventEmitter, Input, OnInit, Output } from '@angular/core';
import { BitstreamFormat } from '../../../../core/shared/bitstream-format.model';
import { BitstreamFormatSupportLevel } from '../../../../core/shared/bitstream-format-support-level';
import {
DynamicCheckboxModel,
DynamicFormArrayModel,
DynamicFormControlLayout, DynamicFormControlLayoutConfig,
DynamicFormControlModel,
DynamicFormService,
DynamicInputModel,
DynamicSelectModel,
DynamicTextAreaModel
} from '@ng-dynamic-forms/core';
import { Router } from '@angular/router';
import { getBitstreamFormatsModulePath } from '../../admin-registries-routing.module';
import { hasValue, isEmpty } from '../../../../shared/empty.util';
import { TranslateService } from '@ngx-translate/core';
/**
* The component responsible for rendering the form to create/edit a bitstream format
*/
@Component({
selector: 'ds-bitstream-format-form',
templateUrl: './format-form.component.html'
})
export class FormatFormComponent implements OnInit {
/**
* The current bitstream format
* This can either be and existing one or a new one
*/
@Input() bitstreamFormat: BitstreamFormat = new BitstreamFormat();
/**
* EventEmitter that will emit the updated bitstream format
*/
@Output() updatedFormat: EventEmitter<BitstreamFormat> = new EventEmitter<BitstreamFormat>();
/**
* The different supported support level of the bitstream format
*/
supportLevelOptions = [{label: BitstreamFormatSupportLevel.Known, value: BitstreamFormatSupportLevel.Known},
{label: BitstreamFormatSupportLevel.Unknown, value: BitstreamFormatSupportLevel.Unknown},
{label: BitstreamFormatSupportLevel.Supported, value: BitstreamFormatSupportLevel.Supported}];
/**
* Styling element for repeatable field
*/
arrayElementLayout: DynamicFormControlLayout = {
grid: {
group: 'form-row',
},
};
/**
* Styling element for element of repeatable field
*/
arrayInputElementLayout: DynamicFormControlLayout = {
grid: {
host: 'col'
}
};
/**
* The form model representing the bitstream format
*/
formModel: DynamicFormControlModel[] = [
new DynamicInputModel({
id: 'shortDescription',
name: 'shortDescription',
label: 'admin.registries.bitstream-formats.edit.shortDescription.label',
hint: 'admin.registries.bitstream-formats.edit.shortDescription.hint',
required: true,
validators: {
required: null
},
errorMessages: {
required: 'Please enter a name for this bitstream format'
},
}),
new DynamicInputModel({
id: 'mimetype',
name: 'mimetype',
label: 'admin.registries.bitstream-formats.edit.mimetype.label',
hint: 'admin.registries.bitstream-formats.edit.mimetype.hint',
}),
new DynamicTextAreaModel({
id: 'description',
name: 'description',
label: 'admin.registries.bitstream-formats.edit.description.label',
hint: 'admin.registries.bitstream-formats.edit.description.hint',
}),
new DynamicSelectModel({
id: 'supportLevel',
name: 'supportLevel',
options: this.supportLevelOptions,
label: 'admin.registries.bitstream-formats.edit.supportLevel.label',
hint: 'admin.registries.bitstream-formats.edit.supportLevel.hint',
value: this.supportLevelOptions[0].value
}),
new DynamicCheckboxModel({
id: 'internal',
name: 'internal',
label: 'Internal',
hint: 'admin.registries.bitstream-formats.edit.internal.hint',
}),
new DynamicFormArrayModel({
id: 'extensions',
name: 'extensions',
label: 'admin.registries.bitstream-formats.edit.extensions.label',
groupFactory: () => [
new DynamicInputModel({
id: 'extension',
placeholder: 'admin.registries.bitstream-formats.edit.extensions.placeholder',
}, this.arrayInputElementLayout)
]
}, this.arrayElementLayout),
];
constructor(private dynamicFormService: DynamicFormService,
private translateService: TranslateService,
private router: Router) {
}
ngOnInit(): void {
this.initValues();
}
/**
* Initializes the form based on the provided bitstream format
*/
initValues() {
this.formModel.forEach(
(fieldModel: DynamicFormControlModel) => {
if (fieldModel.name === 'extensions') {
if (hasValue(this.bitstreamFormat.extensions)) {
const extenstions = this.bitstreamFormat.extensions;
const formArray = (fieldModel as DynamicFormArrayModel);
for (let i = 0; i < extenstions.length; i++) {
formArray.insertGroup(i).group[0] = new DynamicInputModel({
id: `extension-${i}`,
value: extenstions[i]
}, this.arrayInputElementLayout);
}
}
} else {
if (hasValue(this.bitstreamFormat[fieldModel.name])) {
(fieldModel as DynamicInputModel).value = this.bitstreamFormat[fieldModel.name];
}
}
});
}
/**
* Creates an updated bistream format based on the current values in the form
* Emits the updated bitstream format trouhg the updatedFormat emitter
*/
onSubmit() {
const updatedBitstreamFormat = Object.assign(new BitstreamFormat(),
{
id: this.bitstreamFormat.id
});
this.formModel.forEach(
(fieldModel: DynamicFormControlModel) => {
if (fieldModel.name === 'extensions') {
const formArray = (fieldModel as DynamicFormArrayModel);
const extensions = [];
for (let i = 0; i < formArray.groups.length; i++) {
const value = (formArray.get(i).get(0) as DynamicInputModel).value;
if (!isEmpty(value)) {
extensions.push((formArray.get(i).get(0) as DynamicInputModel).value);
}
}
updatedBitstreamFormat.extensions = extensions;
} else {
updatedBitstreamFormat[fieldModel.name] = (fieldModel as DynamicInputModel).value;
}
});
this.updatedFormat.emit(updatedBitstreamFormat);
}
/**
* Cancels the edit/create action of the bitstream format and navigates back to the bitstream format registry
*/
onCancel() {
this.router.navigate([getBitstreamFormatsModulePath()]);
}
}

View File

@@ -1,11 +1,19 @@
import { RouterModule } from '@angular/router'; import { RouterModule } from '@angular/router';
import { NgModule } from '@angular/core'; import { NgModule } from '@angular/core';
import { URLCombiner } from '../core/url-combiner/url-combiner';
import { getAdminModulePath } from '../app-routing.module';
const REGISTRIES_MODULE_PATH = 'registries';
export function getRegistriesModulePath() {
return new URLCombiner(getAdminModulePath(), REGISTRIES_MODULE_PATH).toString();
}
@NgModule({ @NgModule({
imports: [ imports: [
RouterModule.forChild([ RouterModule.forChild([
{ {
path: 'registries', path: REGISTRIES_MODULE_PATH,
loadChildren: './admin-registries/admin-registries.module#AdminRegistriesModule' loadChildren: './admin-registries/admin-registries.module#AdminRegistriesModule'
} }
]) ])

View File

@@ -52,6 +52,9 @@
message="{{'error.recent-submissions' | translate}}"></ds-error> message="{{'error.recent-submissions' | translate}}"></ds-error>
<ds-loading *ngIf="!itemRD || itemRD.isLoading" <ds-loading *ngIf="!itemRD || itemRD.isLoading"
message="{{'loading.recent-submissions' | translate}}"></ds-loading> message="{{'loading.recent-submissions' | translate}}"></ds-loading>
<div *ngIf="!itemRD?.isLoading && itemRD?.payload?.page.length === 0" class="alert alert-info w-100" role="alert">
{{'collection.page.browse.recent.empty' | translate}}
</div>
</ng-container> </ng-container>
</div> </div>
<ds-error *ngIf="collectionRD?.hasFailed" <ds-error *ngIf="collectionRD?.hasFailed"

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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">&nbsp;{{"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">&nbsp;{{"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">&nbsp;{{"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">&nbsp;{{"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">&nbsp;{{"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">&nbsp;{{"item.edit.metadata.save-button" | translate}}</span>
</button>
</div>
</div>
</div>

View File

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

View File

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

View File

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

View File

@@ -62,7 +62,8 @@ import { MetadataFieldWrapperComponent } from './field-components/metadata-field
GenericItemPageFieldComponent, GenericItemPageFieldComponent,
RelatedEntitiesSearchComponent, RelatedEntitiesSearchComponent,
RelatedItemsComponent, RelatedItemsComponent,
MetadataRepresentationListComponent MetadataRepresentationListComponent,
ItemPageTitleFieldComponent
], ],
entryComponents: [ entryComponents: [
PublicationComponent PublicationComponent

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,7 +1,5 @@
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 { 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 } from '../../../core/cache/response.models'; import { FilteredDiscoveryQueryResponse } from '../../../core/cache/response.models';
@@ -11,7 +9,6 @@ describe('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: () => {
@@ -22,9 +19,6 @@ describe('SearchFixedFilterService', () => {
response: new FilteredDiscoveryQueryResponse(filterQuery, 200, 'OK') response: new FilteredDiscoveryQueryResponse(filterQuery, 200, 'OK')
})) }))
}) as RequestService; }) as RequestService;
const halServiceStub = Object.assign(new HALEndpointService(requestServiceStub, undefined), {
getEndpoint: () => observableOf('fake-url')
});
beforeEach(() => { beforeEach(() => {
service = new SearchFixedFilterService(); service = new SearchFixedFilterService();

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -13,4 +13,5 @@ import { ConfigurationSearchPageComponent } from './configuration-search-page.co
]) ])
] ]
}) })
export class SearchPageRoutingModule { } export class SearchPageRoutingModule {
}

View File

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

View File

@@ -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';
@@ -197,7 +197,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();
}); });
@@ -212,7 +212,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();
}); });

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -16,6 +16,12 @@ const COMMUNITY_MODULE_PATH = 'communities';
export function getCommunityModulePath() { export function getCommunityModulePath() {
return `/${COMMUNITY_MODULE_PATH}`; return `/${COMMUNITY_MODULE_PATH}`;
} }
const ADMIN_MODULE_PATH = 'admin';
export function getAdminModulePath() {
return `/${ADMIN_MODULE_PATH}`;
}
@NgModule({ @NgModule({
imports: [ imports: [
RouterModule.forRoot([ RouterModule.forRoot([
@@ -27,7 +33,7 @@ export function getCommunityModulePath() {
{ path: 'mydspace', loadChildren: './+my-dspace-page/my-dspace-page.module#MyDSpacePageModule', canActivate: [AuthenticatedGuard] }, { path: 'mydspace', loadChildren: './+my-dspace-page/my-dspace-page.module#MyDSpacePageModule', canActivate: [AuthenticatedGuard] },
{ path: 'search', loadChildren: './+search-page/search-page.module#SearchPageModule' }, { path: 'search', loadChildren: './+search-page/search-page.module#SearchPageModule' },
{ path: 'browse', loadChildren: './+browse-by/browse-by.module#BrowseByModule' }, { path: 'browse', loadChildren: './+browse-by/browse-by.module#BrowseByModule' },
{ path: 'admin', loadChildren: './+admin/admin.module#AdminModule', canActivate: [AuthenticatedGuard] }, { path: ADMIN_MODULE_PATH, loadChildren: './+admin/admin.module#AdminModule', canActivate: [AuthenticatedGuard] },
{ path: 'login', loadChildren: './+login-page/login-page.module#LoginPageModule' }, { path: 'login', loadChildren: './+login-page/login-page.module#LoginPageModule' },
{ path: 'logout', loadChildren: './+logout-page/logout-page.module#LogoutPageModule' }, { path: 'logout', loadChildren: './+logout-page/logout-page.module#LogoutPageModule' },
{ path: 'submit', loadChildren: './+submit-page/submit-page.module#SubmitPageModule' }, { path: 'submit', loadChildren: './+submit-page/submit-page.module#SubmitPageModule' },

View File

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

View File

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

View File

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

View File

@@ -23,6 +23,10 @@ import { hasValue } from './shared/empty.util';
import { cssVariablesReducer, CSSVariablesState } from './shared/sass-helper/sass-helper.reducer'; import { cssVariablesReducer, CSSVariablesState } from './shared/sass-helper/sass-helper.reducer';
import { menusReducer, MenusState } from './shared/menu/menu.reducer'; import { menusReducer, MenusState } from './shared/menu/menu.reducer';
import { historyReducer, HistoryState } from './shared/history/history.reducer'; import { historyReducer, HistoryState } from './shared/history/history.reducer';
import {
bitstreamFormatReducer,
BitstreamFormatRegistryState
} from './+admin/admin-registries/bitstream-formats/bitstream-format.reducers';
export interface AppState { export interface AppState {
router: fromRouter.RouterReducerState; router: fromRouter.RouterReducerState;
@@ -30,6 +34,7 @@ export interface AppState {
hostWindow: HostWindowState; hostWindow: HostWindowState;
forms: FormState; forms: FormState;
metadataRegistry: MetadataRegistryState; metadataRegistry: MetadataRegistryState;
bitstreamFormats: BitstreamFormatRegistryState;
notifications: NotificationsState; notifications: NotificationsState;
searchSidebar: SearchSidebarState; searchSidebar: SearchSidebarState;
searchFilter: SearchFiltersState; searchFilter: SearchFiltersState;
@@ -44,6 +49,7 @@ export const appReducers: ActionReducerMap<AppState> = {
hostWindow: hostWindowReducer, hostWindow: hostWindowReducer,
forms: formReducer, forms: formReducer,
metadataRegistry: metadataRegistryReducer, metadataRegistry: metadataRegistryReducer,
bitstreamFormats: bitstreamFormatReducer,
notifications: notificationsReducer, notifications: notificationsReducer,
searchSidebar: sidebarReducer, searchSidebar: sidebarReducer,
searchFilter: filterReducer, searchFilter: filterReducer,

View File

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

View File

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

View File

@@ -4,7 +4,7 @@ import { BitstreamFormat } from '../../shared/bitstream-format.model';
import { mapsTo } from '../builders/build-decorators'; import { mapsTo } from '../builders/build-decorators';
import { IDToUUIDSerializer } from '../id-to-uuid-serializer'; import { IDToUUIDSerializer } from '../id-to-uuid-serializer';
import { NormalizedObject } from './normalized-object.model'; import { NormalizedObject } from './normalized-object.model';
import { SupportLevel } from './support-level.model'; import { BitstreamFormatSupportLevel } from '../../shared/bitstream-format-support-level';
/** /**
* Normalized model class for a Bitstream Format * Normalized model class for a Bitstream Format
@@ -34,7 +34,7 @@ export class NormalizedBitstreamFormat extends NormalizedObject<BitstreamFormat>
* The level of support the system offers for this Bitstream Format * The level of support the system offers for this Bitstream Format
*/ */
@autoserialize @autoserialize
supportLevel: SupportLevel; supportLevel: BitstreamFormatSupportLevel;
/** /**
* True if the Bitstream Format is used to store system information, rather than the content of items in the system * True if the Bitstream Format is used to store system information, rather than the content of items in the system
@@ -46,7 +46,7 @@ export class NormalizedBitstreamFormat extends NormalizedObject<BitstreamFormat>
* String representing this Bitstream Format's file extension * String representing this Bitstream Format's file extension
*/ */
@autoserialize @autoserialize
extensions: string; extensions: string[];
/** /**
* Identifier for this Bitstream Format * Identifier for this Bitstream Format

View File

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

View File

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

View File

@@ -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,12 +101,14 @@ 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';
import { ClaimedTaskDataService } from './tasks/claimed-task-data.service'; import { ClaimedTaskDataService } from './tasks/claimed-task-data.service';
import { PoolTaskDataService } from './tasks/pool-task-data.service'; import { PoolTaskDataService } from './tasks/pool-task-data.service';
import { TaskResponseParsingService } from './tasks/task-response-parsing.service'; import { TaskResponseParsingService } from './tasks/task-response-parsing.service';
import { BitstreamFormatDataService } from './data/bitstream-format-data.service';
import { NormalizedClaimedTask } from './tasks/models/normalized-claimed-task-object.model'; import { NormalizedClaimedTask } from './tasks/models/normalized-claimed-task-object.model';
import { NormalizedTaskObject } from './tasks/models/normalized-task-object.model'; import { NormalizedTaskObject } from './tasks/models/normalized-task-object.model';
import { NormalizedPoolTask } from './tasks/models/normalized-pool-task-object.model'; import { NormalizedPoolTask } from './tasks/models/normalized-pool-task-object.model';
@@ -153,6 +155,7 @@ const PROVIDERS = [
PaginationComponentOptions, PaginationComponentOptions,
ResourcePolicyService, ResourcePolicyService,
RegistryService, RegistryService,
BitstreamFormatDataService,
NormalizedObjectBuildService, NormalizedObjectBuildService,
RemoteDataBuildService, RemoteDataBuildService,
RequestService, RequestService,
@@ -198,6 +201,7 @@ const PROVIDERS = [
MenuService, MenuService,
ObjectUpdatesService, ObjectUpdatesService,
SearchService, SearchService,
RelationshipService,
MyDSpaceGuard, MyDSpaceGuard,
RoleService, RoleService,
TaskResponseParsingService, TaskResponseParsingService,

View File

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

View File

@@ -0,0 +1,293 @@
import { BitstreamFormatDataService } from './bitstream-format-data.service';
import { RequestEntry } from './request.reducer';
import { RestResponse } from '../cache/response.models';
import { Observable, of as observableOf } from 'rxjs';
import { Action, Store } from '@ngrx/store';
import { CoreState } from '../core.reducers';
import { ObjectCacheService } from '../cache/object-cache.service';
import { cold, getTestScheduler, hot } from 'jasmine-marbles';
import { HALEndpointService } from '../shared/hal-endpoint.service';
import { NotificationsService } from '../../shared/notifications/notifications.service';
import { HttpClient } from '@angular/common/http';
import { NormalizedObjectBuildService } from '../cache/builders/normalized-object-build.service';
import { RemoteDataBuildService } from '../cache/builders/remote-data-build.service';
import { BitstreamFormat } from '../shared/bitstream-format.model';
import { async } from '@angular/core/testing';
import {
BitstreamFormatsRegistryDeselectAction,
BitstreamFormatsRegistryDeselectAllAction,
BitstreamFormatsRegistrySelectAction
} from '../../+admin/admin-registries/bitstream-formats/bitstream-format.actions';
import { TestScheduler } from 'rxjs/testing';
describe('BitstreamFormatDataService', () => {
let service: BitstreamFormatDataService;
let requestService;
let scheduler: TestScheduler;
const bitstreamFormatsEndpoint = 'https://rest.api/core/bitstream-formats';
const bitstreamFormatsIdEndpoint = 'https://rest.api/core/bitstream-formats/format-id';
const responseCacheEntry = new RequestEntry();
responseCacheEntry.response = new RestResponse(true, 200, 'Success');
responseCacheEntry.completed = true;
const store = {
dispatch(action: Action) {
// Do Nothing
}
} as Store<CoreState>;
const objectCache = {} as ObjectCacheService;
const halEndpointService = {
getEndpoint(linkPath: string): Observable<string> {
return cold('a', {a: bitstreamFormatsEndpoint});
}
} as HALEndpointService;
const notificationsService = {} as NotificationsService;
const http = {} as HttpClient;
const comparator = {} as any;
const dataBuildService = {} as NormalizedObjectBuildService;
const rdbService = {} as RemoteDataBuildService;
function initTestService(halService) {
return new BitstreamFormatDataService(
requestService,
rdbService,
dataBuildService,
store,
objectCache,
halService,
notificationsService,
http,
comparator
);
}
describe('getBrowseEndpoint', () => {
beforeEach(async(() => {
scheduler = getTestScheduler();
requestService = jasmine.createSpyObj('requestService', {
configure: {},
getByHref: observableOf(responseCacheEntry),
getByUUID: cold('a', {a: responseCacheEntry}),
generateRequestId: 'request-id',
removeByHrefSubstring: {}
});
service = initTestService(halEndpointService);
}));
it('should get the browse endpoint', () => {
const result = service.getBrowseEndpoint();
const expected = cold('b', {b: bitstreamFormatsEndpoint});
expect(result).toBeObservable(expected);
});
});
describe('getUpdateEndpoint', () => {
beforeEach(async(() => {
scheduler = getTestScheduler();
requestService = jasmine.createSpyObj('requestService', {
configure: {},
getByHref: observableOf(responseCacheEntry),
getByUUID: cold('a', {a: responseCacheEntry}),
generateRequestId: 'request-id',
removeByHrefSubstring: {}
});
service = initTestService(halEndpointService);
}));
it('should get the update endpoint', () => {
const formatId = 'format-id';
const result = service.getUpdateEndpoint(formatId);
const expected = cold('b', {b: bitstreamFormatsIdEndpoint});
expect(result).toBeObservable(expected);
});
});
describe('getCreateEndpoint', () => {
beforeEach(async(() => {
scheduler = getTestScheduler();
requestService = jasmine.createSpyObj('requestService', {
configure: {},
getByHref: observableOf(responseCacheEntry),
getByUUID: cold('a', {a: responseCacheEntry}),
generateRequestId: 'request-id',
removeByHrefSubstring: {}
});
service = initTestService(halEndpointService);
}));
it('should get the create endpoint ', () => {
const result = service.getCreateEndpoint();
const expected = cold('b', {b: bitstreamFormatsEndpoint});
expect(result).toBeObservable(expected);
});
});
describe('updateBitstreamFormat', () => {
beforeEach(async(() => {
scheduler = getTestScheduler();
requestService = jasmine.createSpyObj('requestService', {
configure: {},
getByHref: observableOf(responseCacheEntry),
getByUUID: cold('a', {a: responseCacheEntry}),
generateRequestId: 'request-id',
removeByHrefSubstring: {}
});
service = initTestService(halEndpointService);
}));
it('should update the bitstream format', () => {
const updatedBistreamFormat = new BitstreamFormat();
updatedBistreamFormat.uuid = 'updated-uuid';
const expected = cold('(b)', {b: new RestResponse(true, 200, 'Success')});
const result = service.updateBitstreamFormat(updatedBistreamFormat);
expect(result).toBeObservable(expected);
});
});
describe('createBitstreamFormat', () => {
beforeEach(async(() => {
scheduler = getTestScheduler();
requestService = jasmine.createSpyObj('requestService', {
configure: {},
getByHref: observableOf(responseCacheEntry),
getByUUID: cold('a', {a: responseCacheEntry}),
generateRequestId: 'request-id',
removeByHrefSubstring: {}
});
service = initTestService(halEndpointService);
}));
it('should create a new bitstream format', () => {
const newFormat = new BitstreamFormat();
newFormat.uuid = 'new-uuid';
const expected = cold('(b)', {b: new RestResponse(true, 200, 'Success')});
const result = service.createBitstreamFormat(newFormat);
expect(result).toBeObservable(expected);
});
});
describe('clearBitStreamFormatRequests', () => {
beforeEach(async(() => {
scheduler = getTestScheduler();
requestService = jasmine.createSpyObj('requestService', {
configure: {},
getByHref: observableOf(responseCacheEntry),
getByUUID: cold('a', {a: responseCacheEntry}),
generateRequestId: 'request-id',
removeByHrefSubstring: {}
});
const halService = {
getEndpoint(linkPath: string): Observable<string> {
return observableOf(bitstreamFormatsEndpoint);
}
} as HALEndpointService;
service = initTestService(halService);
service.clearBitStreamFormatRequests().subscribe();
}));
it('should remove the bitstream format hrefs in the request service', () => {
expect(requestService.removeByHrefSubstring).toHaveBeenCalledWith(bitstreamFormatsEndpoint);
});
});
describe('selectBitstreamFormat', () => {
beforeEach(async(() => {
scheduler = getTestScheduler();
requestService = jasmine.createSpyObj('requestService', {
configure: {},
getByHref: observableOf(responseCacheEntry),
getByUUID: cold('a', {a: responseCacheEntry}),
generateRequestId: 'request-id',
removeByHrefSubstring: {}
});
service = initTestService(halEndpointService);
spyOn(store, 'dispatch');
}));
it('should add a selected bitstream to the store', () => {
const format = new BitstreamFormat();
format.uuid = 'uuid';
service.selectBitstreamFormat(format);
expect(store.dispatch).toHaveBeenCalledWith(new BitstreamFormatsRegistrySelectAction(format));
});
});
describe('deselectBitstreamFormat', () => {
beforeEach(async(() => {
scheduler = getTestScheduler();
requestService = jasmine.createSpyObj('requestService', {
configure: {},
getByHref: observableOf(responseCacheEntry),
getByUUID: cold('a', {a: responseCacheEntry}),
generateRequestId: 'request-id',
removeByHrefSubstring: {}
});
service = initTestService(halEndpointService);
spyOn(store, 'dispatch');
}));
it('should remove a bitstream from the store', () => {
const format = new BitstreamFormat();
format.uuid = 'uuid';
service.deselectBitstreamFormat(format);
expect(store.dispatch).toHaveBeenCalledWith(new BitstreamFormatsRegistryDeselectAction(format));
});
});
describe('deselectAllBitstreamFormats', () => {
beforeEach(async(() => {
scheduler = getTestScheduler();
requestService = jasmine.createSpyObj('requestService', {
configure: {},
getByHref: observableOf(responseCacheEntry),
getByUUID: cold('a', {a: responseCacheEntry}),
generateRequestId: 'request-id',
removeByHrefSubstring: {}
});
service = initTestService(halEndpointService);
spyOn(store, 'dispatch');
}));
it('should remove all bitstreamFormats from the store', () => {
service.deselectAllBitstreamFormats();
expect(store.dispatch).toHaveBeenCalledWith(new BitstreamFormatsRegistryDeselectAllAction());
});
});
describe('delete', () => {
beforeEach(async(() => {
scheduler = getTestScheduler();
requestService = jasmine.createSpyObj('requestService', {
configure: {},
getByHref: observableOf(responseCacheEntry),
getByUUID: hot('a', {a: responseCacheEntry}),
generateRequestId: 'request-id',
removeByHrefSubstring: {}
});
const halService = {
getEndpoint(linkPath: string): Observable<string> {
return observableOf(bitstreamFormatsEndpoint);
}
} as HALEndpointService;
service = initTestService(halService);
}));
it('should delete a bitstream format', () => {
const format = new BitstreamFormat();
format.uuid = 'format-uuid';
format.id = 'format-id';
const expected = cold('(b|)', {b: true});
const result = service.delete(format);
expect(result).toBeObservable(expected);
});
});
});

View File

@@ -0,0 +1,183 @@
import { Injectable } from '@angular/core';
import { DataService } from './data.service';
import { BitstreamFormat } from '../shared/bitstream-format.model';
import { RequestService } from './request.service';
import { RemoteDataBuildService } from '../cache/builders/remote-data-build.service';
import { NormalizedObjectBuildService } from '../cache/builders/normalized-object-build.service';
import { createSelector, select, Store } from '@ngrx/store';
import { CoreState } from '../core.reducers';
import { ObjectCacheService } from '../cache/object-cache.service';
import { HALEndpointService } from '../shared/hal-endpoint.service';
import { NotificationsService } from '../../shared/notifications/notifications.service';
import { HttpClient } from '@angular/common/http';
import { DefaultChangeAnalyzer } from './default-change-analyzer.service';
import { DeleteByIDRequest, FindAllOptions, PostRequest, PutRequest } from './request.models';
import { Observable } from 'rxjs';
import { find, map, tap } from 'rxjs/operators';
import { configureRequest, getResponseFromEntry } from '../shared/operators';
import { distinctUntilChanged } from 'rxjs/internal/operators/distinctUntilChanged';
import { RestResponse } from '../cache/response.models';
import { AppState } from '../../app.reducer';
import { BitstreamFormatRegistryState } from '../../+admin/admin-registries/bitstream-formats/bitstream-format.reducers';
import {
BitstreamFormatsRegistryDeselectAction,
BitstreamFormatsRegistryDeselectAllAction,
BitstreamFormatsRegistrySelectAction
} from '../../+admin/admin-registries/bitstream-formats/bitstream-format.actions';
import { hasValue } from '../../shared/empty.util';
import { RequestEntry } from './request.reducer';
const bitstreamFormatsStateSelector = (state: AppState) => state.bitstreamFormats;
const selectedBitstreamFormatSelector = createSelector(bitstreamFormatsStateSelector,
(bitstreamFormatRegistryState: BitstreamFormatRegistryState) => bitstreamFormatRegistryState.selectedBitstreamFormats);
/**
* A service responsible for fetching/sending data from/to the REST API on the bitstreamformats endpoint
*/
@Injectable()
export class BitstreamFormatDataService extends DataService<BitstreamFormat> {
protected linkPath = 'bitstreamformats';
protected forceBypassCache = false;
constructor(
protected requestService: RequestService,
protected rdbService: RemoteDataBuildService,
protected dataBuildService: NormalizedObjectBuildService,
protected store: Store<CoreState>,
protected objectCache: ObjectCacheService,
protected halService: HALEndpointService,
protected notificationsService: NotificationsService,
protected http: HttpClient,
protected comparator: DefaultChangeAnalyzer<BitstreamFormat>) {
super();
}
/**
* Get the endpoint for browsing bitstream formats
* @param {FindAllOptions} options
* @returns {Observable<string>}
*/
getBrowseEndpoint(options: FindAllOptions = {}, linkPath?: string): Observable<string> {
return this.halService.getEndpoint(this.linkPath);
}
/**
* Get the endpoint to update an existing bitstream format
* @param formatId
*/
public getUpdateEndpoint(formatId: string): Observable<string> {
return this.getBrowseEndpoint().pipe(
map((endpoint: string) => this.getIDHref(endpoint, formatId))
);
}
/**
* Get the endpoint to create a new bitstream format
*/
public getCreateEndpoint(): Observable<string> {
return this.getBrowseEndpoint();
}
/**
* Update an existing bitstreamFormat
* @param bitstreamFormat
*/
updateBitstreamFormat(bitstreamFormat: BitstreamFormat): Observable<RestResponse> {
const requestId = this.requestService.generateRequestId();
this.getUpdateEndpoint(bitstreamFormat.id).pipe(
distinctUntilChanged(),
map((endpointURL: string) =>
new PutRequest(requestId, endpointURL, bitstreamFormat)),
configureRequest(this.requestService)).subscribe();
return this.requestService.getByUUID(requestId).pipe(
getResponseFromEntry()
);
}
/**
* Create a new BitstreamFormat
* @param BitstreamFormat
*/
public createBitstreamFormat(bitstreamFormat: BitstreamFormat): Observable<RestResponse> {
const requestId = this.requestService.generateRequestId();
this.getCreateEndpoint().pipe(
map((endpointURL: string) => {
return new PostRequest(requestId, endpointURL, bitstreamFormat);
}),
configureRequest(this.requestService)
).subscribe();
return this.requestService.getByUUID(requestId).pipe(
getResponseFromEntry()
);
}
/**
* Clears the cache of the list of BitstreamFormats
*/
public clearBitStreamFormatRequests(): Observable<string> {
return this.getBrowseEndpoint().pipe(
tap((href: string) => this.requestService.removeByHrefSubstring(href))
);
}
/**
* Gets all the selected BitstreamFormats from the store
*/
public getSelectedBitstreamFormats(): Observable<BitstreamFormat[]> {
return this.store.pipe(select(selectedBitstreamFormatSelector));
}
/**
* Adds a BistreamFormat to the selected BitstreamFormats in the store
* @param bitstreamFormat
*/
public selectBitstreamFormat(bitstreamFormat: BitstreamFormat) {
this.store.dispatch(new BitstreamFormatsRegistrySelectAction(bitstreamFormat));
}
/**
* Removes a BistreamFormat from the list of selected BitstreamFormats in the store
* @param bitstreamFormat
*/
public deselectBitstreamFormat(bitstreamFormat: BitstreamFormat) {
this.store.dispatch(new BitstreamFormatsRegistryDeselectAction(bitstreamFormat));
}
/**
* Removes all BitstreamFormats from the list of selected BitstreamFormats in the store
*/
public deselectAllBitstreamFormats() {
this.store.dispatch(new BitstreamFormatsRegistryDeselectAllAction());
}
/**
* Delete an existing DSpace Object on the server
* @param format The DSpace Object to be removed
* Return an observable that emits true when the deletion was successful, false when it failed
*/
delete(format: BitstreamFormat): Observable<boolean> {
const requestId = this.requestService.generateRequestId();
const hrefObs = this.halService.getEndpoint(this.linkPath).pipe(
map((endpoint: string) => this.getIDHref(endpoint, format.id)));
hrefObs.pipe(
find((href: string) => hasValue(href)),
map((href: string) => {
const request = new DeleteByIDRequest(requestId, href, format.id);
this.requestService.configure(request);
})
).subscribe();
return this.requestService.getByUUID(requestId).pipe(
find((request: RequestEntry) => request.completed),
map((request: RequestEntry) => request.response.isSuccessful)
);
}
}

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