mirror of
https://github.com/DSpace/dspace-angular.git
synced 2025-10-17 15:03:07 +00:00
Merge branch 'master' into entities-master
Conflicts: src/app/+item-page/full/full-item-page.component.ts src/app/+item-page/simple/item-page.component.html src/app/+item-page/simple/item-page.component.ts src/app/+search-page/search-filters/search-filter/search-filter.service.spec.ts src/app/+search-page/search-filters/search-filter/search-filter.service.ts src/app/+search-page/search-options.model.ts src/app/+search-page/search-page.component.html src/app/+search-page/search-page.component.ts src/app/+search-page/search-page.module.ts src/app/+search-page/search-results/search-results.component.ts src/app/+search-page/search-service/search.service.spec.ts src/app/+search-page/search-service/search.service.ts src/app/+search-page/search-settings/search-settings.component.ts src/app/core/cache/response-cache.models.ts src/app/core/core.module.ts src/app/core/data/search-response-parsing.service.ts src/app/core/shared/operators.ts src/app/core/shared/resource-type.ts src/app/shared/object-collection/object-collection.component.spec.ts src/app/shared/object-collection/object-collection.component.ts src/app/shared/object-collection/shared/dso-element-decorator.spec.ts src/app/shared/object-collection/shared/dso-element-decorator.ts src/app/shared/object-grid/collection-grid-element/collection-grid-element.component.ts src/app/shared/object-grid/community-grid-element/community-grid-element.component.ts src/app/shared/object-grid/item-grid-element/item-grid-element.component.ts src/app/shared/object-grid/search-result-grid-element/collection-search-result/collection-search-result-grid-element.component.ts src/app/shared/object-grid/search-result-grid-element/community-search-result/community-search-result-grid-element.component.ts src/app/shared/object-grid/search-result-grid-element/item-search-result/item-search-result-grid-element.component.ts src/app/shared/object-grid/wrapper-grid-element/wrapper-grid-element.component.ts src/app/shared/object-list/collection-list-element/collection-list-element.component.ts src/app/shared/object-list/community-list-element/community-list-element.component.ts src/app/shared/object-list/item-list-element/item-list-element.component.html src/app/shared/object-list/item-list-element/item-list-element.component.ts src/app/shared/object-list/search-result-list-element/collection-search-result/collection-search-result-list-element.component.ts src/app/shared/object-list/search-result-list-element/community-search-result/community-search-result-list-element.component.ts src/app/shared/object-list/search-result-list-element/item-search-result/item-search-result-list-element.component.html src/app/shared/object-list/search-result-list-element/item-search-result/item-search-result-list-element.component.ts src/app/shared/object-list/wrapper-list-element/wrapper-list-element.component.ts src/app/shared/services/route.service.ts src/app/shared/shared.module.ts src/app/shared/testing/hal-endpoint-service-stub.ts src/app/shared/testing/search-service-stub.ts src/app/shared/view-mode-switch/view-mode-switch.component.spec.ts src/app/shared/view-mode-switch/view-mode-switch.component.ts src/app/thumbnail/thumbnail.component.html
This commit is contained in:
@@ -0,0 +1,18 @@
|
||||
import { MetadataRegistryComponent } from './metadata-registry/metadata-registry.component';
|
||||
import { RouterModule } from '@angular/router';
|
||||
import { NgModule } from '@angular/core';
|
||||
import { MetadataSchemaComponent } from './metadata-schema/metadata-schema.component';
|
||||
import { BitstreamFormatsComponent } from './bitstream-formats/bitstream-formats.component';
|
||||
|
||||
@NgModule({
|
||||
imports: [
|
||||
RouterModule.forChild([
|
||||
{ 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' } },
|
||||
])
|
||||
]
|
||||
})
|
||||
export class AdminRegistriesRoutingModule {
|
||||
|
||||
}
|
27
src/app/+admin/admin-registries/admin-registries.module.ts
Normal file
27
src/app/+admin/admin-registries/admin-registries.module.ts
Normal file
@@ -0,0 +1,27 @@
|
||||
import { NgModule } from '@angular/core';
|
||||
import { MetadataRegistryComponent } from './metadata-registry/metadata-registry.component';
|
||||
import { AdminRegistriesRoutingModule } from './admin-registries-routing.module';
|
||||
import { CommonModule } from '@angular/common';
|
||||
import { MetadataSchemaComponent } from './metadata-schema/metadata-schema.component';
|
||||
import { RouterModule } from '@angular/router';
|
||||
import { TranslateModule } from '@ngx-translate/core';
|
||||
import { BitstreamFormatsComponent } from './bitstream-formats/bitstream-formats.component';
|
||||
import { SharedModule } from '../../shared/shared.module';
|
||||
|
||||
@NgModule({
|
||||
imports: [
|
||||
CommonModule,
|
||||
SharedModule,
|
||||
RouterModule,
|
||||
TranslateModule,
|
||||
AdminRegistriesRoutingModule
|
||||
],
|
||||
declarations: [
|
||||
MetadataRegistryComponent,
|
||||
MetadataSchemaComponent,
|
||||
BitstreamFormatsComponent
|
||||
]
|
||||
})
|
||||
export class AdminRegistriesModule {
|
||||
|
||||
}
|
@@ -0,0 +1,42 @@
|
||||
<div class="container">
|
||||
<div class="bitstream-formats row">
|
||||
<div class="col-12">
|
||||
|
||||
<h2 id="header" class="border-bottom pb-2">{{'admin.registries.bitstream-formats.head' | translate}}</h2>
|
||||
|
||||
<p id="description" class="pb-2">{{'admin.registries.bitstream-formats.description' | translate}}</p>
|
||||
|
||||
<ds-pagination
|
||||
*ngIf="(bitstreamFormats | async)?.payload?.totalElements > 0"
|
||||
[paginationOptions]="config"
|
||||
[pageInfoState]="(bitstreamFormats | async)?.payload"
|
||||
[collectionSize]="(bitstreamFormats | async)?.payload?.totalElements"
|
||||
[hideGear]="true"
|
||||
[hidePagerWhenSinglePage]="true"
|
||||
(pageChange)="onPageChange($event)">
|
||||
<div class="table-responsive">
|
||||
<table id="formats" class="table table-striped table-hover">
|
||||
<thead>
|
||||
<tr>
|
||||
<th scope="col">{{'admin.registries.bitstream-formats.formats.table.name' | translate}}</th>
|
||||
<th scope="col">{{'admin.registries.bitstream-formats.formats.table.mimetype' | translate}}</th>
|
||||
<th scope="col">{{'admin.registries.bitstream-formats.formats.table.supportLevel.head' | translate}}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr *ngFor="let bitstreamFormat of (bitstreamFormats | async)?.payload?.page">
|
||||
<td>{{bitstreamFormat.shortDescription}}</td>
|
||||
<td>{{bitstreamFormat.mimetype}} <span *ngIf="bitstreamFormat.internal">({{'admin.registries.bitstream-formats.formats.table.internal' | translate}})</span></td>
|
||||
<td>{{'admin.registries.bitstream-formats.formats.table.supportLevel.'+bitstreamFormat.supportLevel | translate}}</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</ds-pagination>
|
||||
<div *ngIf="(bitstreamFormats | async)?.payload?.totalElements == 0" class="alert alert-info" role="alert">
|
||||
{{'admin.registries.bitstream-formats.formats.no-items' | translate}}
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
@@ -0,0 +1,98 @@
|
||||
import { BitstreamFormatsComponent } from './bitstream-formats.component';
|
||||
import { async, ComponentFixture, TestBed } from '@angular/core/testing';
|
||||
import { RegistryService } from '../../../core/registry/registry.service';
|
||||
import { Observable } from 'rxjs/Observable';
|
||||
import { RemoteData } from '../../../core/data/remote-data';
|
||||
import { PaginatedList } from '../../../core/data/paginated-list';
|
||||
import { CommonModule } from '@angular/common';
|
||||
import { RouterTestingModule } from '@angular/router/testing';
|
||||
import { TranslateModule } from '@ngx-translate/core';
|
||||
import { By } from '@angular/platform-browser';
|
||||
import { SharedModule } from '../../../shared/shared.module';
|
||||
import { PaginationComponent } from '../../../shared/pagination/pagination.component';
|
||||
import { NgbModule } from '@ng-bootstrap/ng-bootstrap';
|
||||
import { EnumKeysPipe } from '../../../shared/utils/enum-keys-pipe';
|
||||
import { HostWindowService } from '../../../shared/host-window.service';
|
||||
import { HostWindowServiceStub } from '../../../shared/testing/host-window-service-stub';
|
||||
|
||||
describe('BitstreamFormatsComponent', () => {
|
||||
let comp: BitstreamFormatsComponent;
|
||||
let fixture: ComponentFixture<BitstreamFormatsComponent>;
|
||||
let registryService: RegistryService;
|
||||
const mockFormatsList = [
|
||||
{
|
||||
shortDescription: 'Unknown',
|
||||
description: 'Unknown data format',
|
||||
mimetype: 'application/octet-stream',
|
||||
supportLevel: 0,
|
||||
internal: false,
|
||||
extensions: null
|
||||
},
|
||||
{
|
||||
shortDescription: 'License',
|
||||
description: 'Item-specific license agreed upon to submission',
|
||||
mimetype: 'text/plain; charset=utf-8',
|
||||
supportLevel: 1,
|
||||
internal: true,
|
||||
extensions: null
|
||||
},
|
||||
{
|
||||
shortDescription: 'CC License',
|
||||
description: 'Item-specific Creative Commons license agreed upon to submission',
|
||||
mimetype: 'text/html; charset=utf-8',
|
||||
supportLevel: 2,
|
||||
internal: true,
|
||||
extensions: null
|
||||
},
|
||||
{
|
||||
shortDescription: 'Adobe PDF',
|
||||
description: 'Adobe Portable Document Format',
|
||||
mimetype: 'application/pdf',
|
||||
supportLevel: 0,
|
||||
internal: false,
|
||||
extensions: null
|
||||
}
|
||||
];
|
||||
const mockFormats = Observable.of(new RemoteData(false, false, true, undefined, new PaginatedList(null, mockFormatsList)));
|
||||
const registryServiceStub = {
|
||||
getBitstreamFormats: () => mockFormats
|
||||
};
|
||||
|
||||
beforeEach(async(() => {
|
||||
TestBed.configureTestingModule({
|
||||
imports: [CommonModule, RouterTestingModule.withRoutes([]), TranslateModule.forRoot(), NgbModule.forRoot()],
|
||||
declarations: [BitstreamFormatsComponent, PaginationComponent, EnumKeysPipe],
|
||||
providers: [
|
||||
{ provide: RegistryService, useValue: registryServiceStub },
|
||||
{ provide: HostWindowService, useValue: new HostWindowServiceStub(0) }
|
||||
]
|
||||
}).compileComponents();
|
||||
}));
|
||||
|
||||
beforeEach(() => {
|
||||
fixture = TestBed.createComponent(BitstreamFormatsComponent);
|
||||
comp = fixture.componentInstance;
|
||||
fixture.detectChanges();
|
||||
registryService = (comp as any).service;
|
||||
});
|
||||
|
||||
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(1)')).nativeElement;
|
||||
expect(unknownName.textContent).toBe('Unknown');
|
||||
|
||||
const licenseName: HTMLElement = fixture.debugElement.query(By.css('#formats tr:nth-child(2) td:nth-child(1)')).nativeElement;
|
||||
expect(licenseName.textContent).toBe('License');
|
||||
|
||||
const ccLicenseName: HTMLElement = fixture.debugElement.query(By.css('#formats tr:nth-child(3) td:nth-child(1)')).nativeElement;
|
||||
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(adobeName.textContent).toBe('Adobe PDF');
|
||||
});
|
||||
|
||||
});
|
@@ -0,0 +1,33 @@
|
||||
import { Component } from '@angular/core';
|
||||
import { RegistryService } from '../../../core/registry/registry.service';
|
||||
import { Observable } from 'rxjs/Observable';
|
||||
import { RemoteData } from '../../../core/data/remote-data';
|
||||
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';
|
||||
|
||||
@Component({
|
||||
selector: 'ds-bitstream-formats',
|
||||
templateUrl: './bitstream-formats.component.html'
|
||||
})
|
||||
export class BitstreamFormatsComponent {
|
||||
|
||||
bitstreamFormats: Observable<RemoteData<PaginatedList<BitstreamFormat>>>;
|
||||
config: PaginationComponentOptions = Object.assign(new PaginationComponentOptions(), {
|
||||
id: 'registry-bitstreamformats-pagination',
|
||||
pageSize: 10000
|
||||
});
|
||||
|
||||
constructor(private registryService: RegistryService) {
|
||||
this.updateFormats();
|
||||
}
|
||||
|
||||
onPageChange(event) {
|
||||
this.config.currentPage = event;
|
||||
this.updateFormats();
|
||||
}
|
||||
|
||||
private updateFormats() {
|
||||
this.bitstreamFormats = this.registryService.getBitstreamFormats(this.config);
|
||||
}
|
||||
}
|
@@ -0,0 +1,42 @@
|
||||
<div class="container">
|
||||
<div class="metadata-registry row">
|
||||
<div class="col-12">
|
||||
|
||||
<h2 id="header" class="border-bottom pb-2">{{'admin.registries.metadata.head' | translate}}</h2>
|
||||
|
||||
<p id="description" class="pb-2">{{'admin.registries.metadata.description' | translate}}</p>
|
||||
|
||||
<ds-pagination
|
||||
*ngIf="(metadataSchemas | async)?.payload?.totalElements > 0"
|
||||
[paginationOptions]="config"
|
||||
[pageInfoState]="(metadataSchemas | async)?.payload"
|
||||
[collectionSize]="(metadataSchemas | async)?.payload?.totalElements"
|
||||
[hideGear]="true"
|
||||
[hidePagerWhenSinglePage]="true"
|
||||
(pageChange)="onPageChange($event)">
|
||||
<div class="table-responsive">
|
||||
<table id="metadata-schemas" class="table table-striped table-hover">
|
||||
<thead>
|
||||
<tr>
|
||||
<th scope="col">{{'admin.registries.metadata.schemas.table.id' | translate}}</th>
|
||||
<th scope="col">{{'admin.registries.metadata.schemas.table.namespace' | translate}}</th>
|
||||
<th scope="col">{{'admin.registries.metadata.schemas.table.name' | translate}}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr *ngFor="let schema of (metadataSchemas | async)?.payload?.page">
|
||||
<td><a [routerLink]="[schema.prefix]">{{schema.id}}</a></td>
|
||||
<td><a [routerLink]="[schema.prefix]">{{schema.namespace}}</a></td>
|
||||
<td><a [routerLink]="[schema.prefix]">{{schema.prefix}}</a></td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</ds-pagination>
|
||||
<div *ngIf="(metadataSchemas | async)?.payload?.totalElements == 0" class="alert alert-info" role="alert">
|
||||
{{'admin.registries.metadata.schemas.no-items' | translate}}
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
@@ -0,0 +1,72 @@
|
||||
import { MetadataRegistryComponent } from './metadata-registry.component';
|
||||
import { async, ComponentFixture, TestBed } from '@angular/core/testing';
|
||||
import { Observable } from 'rxjs/Observable';
|
||||
import { RemoteData } from '../../../core/data/remote-data';
|
||||
import { PaginatedList } from '../../../core/data/paginated-list';
|
||||
import { TranslateModule } from '@ngx-translate/core';
|
||||
import { By } from '@angular/platform-browser';
|
||||
import { CommonModule } from '@angular/common';
|
||||
import { RouterTestingModule } from '@angular/router/testing';
|
||||
import { RegistryService } from '../../../core/registry/registry.service';
|
||||
import { SharedModule } from '../../../shared/shared.module';
|
||||
import { NgbModule } from '@ng-bootstrap/ng-bootstrap';
|
||||
import { EnumKeysPipe } from '../../../shared/utils/enum-keys-pipe';
|
||||
import { PaginationComponent } from '../../../shared/pagination/pagination.component';
|
||||
import { HostWindowServiceStub } from '../../../shared/testing/host-window-service-stub';
|
||||
import { HostWindowService } from '../../../shared/host-window.service';
|
||||
|
||||
describe('MetadataRegistryComponent', () => {
|
||||
let comp: MetadataRegistryComponent;
|
||||
let fixture: ComponentFixture<MetadataRegistryComponent>;
|
||||
let registryService: RegistryService;
|
||||
const mockSchemasList = [
|
||||
{
|
||||
id: 1,
|
||||
self: 'https://dspace7.4science.it/dspace-spring-rest/api/core/metadataschemas/1',
|
||||
prefix: 'dc',
|
||||
namespace: 'http://dublincore.org/documents/dcmi-terms/'
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
self: 'https://dspace7.4science.it/dspace-spring-rest/api/core/metadataschemas/2',
|
||||
prefix: 'mock',
|
||||
namespace: 'http://dspace.org/mockschema'
|
||||
}
|
||||
];
|
||||
const mockSchemas = Observable.of(new RemoteData(false, false, true, undefined, new PaginatedList(null, mockSchemasList)));
|
||||
const registryServiceStub = {
|
||||
getMetadataSchemas: () => mockSchemas
|
||||
};
|
||||
|
||||
beforeEach(async(() => {
|
||||
TestBed.configureTestingModule({
|
||||
imports: [CommonModule, RouterTestingModule.withRoutes([]), TranslateModule.forRoot(), NgbModule.forRoot()],
|
||||
declarations: [MetadataRegistryComponent, PaginationComponent, EnumKeysPipe],
|
||||
providers: [
|
||||
{ provide: RegistryService, useValue: registryServiceStub },
|
||||
{ provide: HostWindowService, useValue: new HostWindowServiceStub(0) }
|
||||
]
|
||||
}).compileComponents();
|
||||
}));
|
||||
|
||||
beforeEach(() => {
|
||||
fixture = TestBed.createComponent(MetadataRegistryComponent);
|
||||
comp = fixture.componentInstance;
|
||||
fixture.detectChanges();
|
||||
registryService = (comp as any).service;
|
||||
});
|
||||
|
||||
it('should contain two schemas', () => {
|
||||
const tbody: HTMLElement = fixture.debugElement.query(By.css('#metadata-schemas>tbody')).nativeElement;
|
||||
expect(tbody.children.length).toBe(2);
|
||||
});
|
||||
|
||||
it('should contain the correct schemas', () => {
|
||||
const dcName: HTMLElement = fixture.debugElement.query(By.css('#metadata-schemas tr:nth-child(1) td:nth-child(3)')).nativeElement;
|
||||
expect(dcName.textContent).toBe('dc');
|
||||
|
||||
const mockName: HTMLElement = fixture.debugElement.query(By.css('#metadata-schemas tr:nth-child(2) td:nth-child(3)')).nativeElement;
|
||||
expect(mockName.textContent).toBe('mock');
|
||||
});
|
||||
|
||||
});
|
@@ -0,0 +1,34 @@
|
||||
import { Component } from '@angular/core';
|
||||
import { RegistryService } from '../../../core/registry/registry.service';
|
||||
import { Observable } from 'rxjs/Observable';
|
||||
import { RemoteData } from '../../../core/data/remote-data';
|
||||
import { PaginatedList } from '../../../core/data/paginated-list';
|
||||
import { MetadataSchema } from '../../../core/metadata/metadataschema.model';
|
||||
import { PaginationComponentOptions } from '../../../shared/pagination/pagination-component-options.model';
|
||||
|
||||
@Component({
|
||||
selector: 'ds-metadata-registry',
|
||||
templateUrl: './metadata-registry.component.html'
|
||||
})
|
||||
export class MetadataRegistryComponent {
|
||||
|
||||
metadataSchemas: Observable<RemoteData<PaginatedList<MetadataSchema>>>;
|
||||
config: PaginationComponentOptions = Object.assign(new PaginationComponentOptions(), {
|
||||
id: 'registry-metadataschemas-pagination',
|
||||
pageSize: 10000
|
||||
});
|
||||
|
||||
constructor(private registryService: RegistryService) {
|
||||
this.updateSchemas();
|
||||
}
|
||||
|
||||
onPageChange(event) {
|
||||
this.config.currentPage = event;
|
||||
this.updateSchemas();
|
||||
}
|
||||
|
||||
private updateSchemas() {
|
||||
this.metadataSchemas = this.registryService.getMetadataSchemas(this.config);
|
||||
}
|
||||
|
||||
}
|
@@ -0,0 +1,41 @@
|
||||
<div class="container">
|
||||
<div class="metadata-schema row">
|
||||
<div class="col-12">
|
||||
|
||||
<h2 id="header" class="border-bottom pb-2">{{'admin.registries.schema.head' | translate}}: "{{(metadataSchema | async)?.payload?.prefix}}"</h2>
|
||||
|
||||
<p id="description" class="pb-2">{{'admin.registries.schema.description' | translate:namespace }}</p>
|
||||
|
||||
<h3>{{'admin.registries.schema.fields.head' | translate}}</h3>
|
||||
<ds-pagination
|
||||
*ngIf="(metadataFields | async)?.payload?.totalElements > 0"
|
||||
[paginationOptions]="config"
|
||||
[pageInfoState]="(metadataFields | async)?.payload"
|
||||
[collectionSize]="(metadataFields | async)?.payload?.totalElements"
|
||||
[hideGear]="true"
|
||||
[hidePagerWhenSinglePage]="true"
|
||||
(pageChange)="onPageChange($event)">
|
||||
<div class="table-responsive">
|
||||
<table id="metadata-fields" class="table table-striped table-hover">
|
||||
<thead>
|
||||
<tr>
|
||||
<th scope="col">{{'admin.registries.schema.fields.table.field' | translate}}</th>
|
||||
<th scope="col">{{'admin.registries.schema.fields.table.scopenote' | translate}}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr *ngFor="let field of (metadataFields | async)?.payload?.page">
|
||||
<td>{{(metadataSchema | async)?.payload?.prefix}}.{{field.element}}<label *ngIf="field.qualifier">.</label>{{field.qualifier}}</td>
|
||||
<td>{{field.scopeNote}}</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</ds-pagination>
|
||||
<div *ngIf="(metadataFields | async)?.payload?.totalElements == 0" class="alert alert-info" role="alert">
|
||||
{{'admin.registries.schema.fields.no-items' | translate}}
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
@@ -0,0 +1,121 @@
|
||||
import { MetadataSchemaComponent } from './metadata-schema.component';
|
||||
import { async, ComponentFixture, TestBed } from '@angular/core/testing';
|
||||
import { Observable } from 'rxjs/Observable';
|
||||
import { RemoteData } from '../../../core/data/remote-data';
|
||||
import { PaginatedList } from '../../../core/data/paginated-list';
|
||||
import { MetadataSchema } from '../../../core/metadata/metadataschema.model';
|
||||
import { TranslateLoader, TranslateModule } from '@ngx-translate/core';
|
||||
import { CommonModule } from '@angular/common';
|
||||
import { ActivatedRoute, Router } from '@angular/router';
|
||||
import { By } from '@angular/platform-browser';
|
||||
import { MockTranslateLoader } from '../../../shared/testing/mock-translate-loader';
|
||||
import { RegistryService } from '../../../core/registry/registry.service';
|
||||
import { SharedModule } from '../../../shared/shared.module';
|
||||
import { NgbModule } from '@ng-bootstrap/ng-bootstrap';
|
||||
import { EnumKeysPipe } from '../../../shared/utils/enum-keys-pipe';
|
||||
import { PaginationComponent } from '../../../shared/pagination/pagination.component';
|
||||
import { HostWindowServiceStub } from '../../../shared/testing/host-window-service-stub';
|
||||
import { HostWindowService } from '../../../shared/host-window.service';
|
||||
import { RouterStub } from '../../../shared/testing/router-stub';
|
||||
import { RouterTestingModule } from '@angular/router/testing';
|
||||
import { ActivatedRouteStub } from '../../../shared/testing/active-router-stub';
|
||||
|
||||
describe('MetadataSchemaComponent', () => {
|
||||
let comp: MetadataSchemaComponent;
|
||||
let fixture: ComponentFixture<MetadataSchemaComponent>;
|
||||
let registryService: RegistryService;
|
||||
const mockSchemasList = [
|
||||
{
|
||||
id: 1,
|
||||
self: 'https://dspace7.4science.it/dspace-spring-rest/api/core/metadataschemas/1',
|
||||
prefix: 'dc',
|
||||
namespace: 'http://dublincore.org/documents/dcmi-terms/'
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
self: 'https://dspace7.4science.it/dspace-spring-rest/api/core/metadataschemas/2',
|
||||
prefix: 'mock',
|
||||
namespace: 'http://dspace.org/mockschema'
|
||||
}
|
||||
];
|
||||
const mockFieldsList = [
|
||||
{
|
||||
self: 'https://dspace7.4science.it/dspace-spring-rest/api/core/metadatafields/8',
|
||||
element: 'contributor',
|
||||
qualifier: 'advisor',
|
||||
scopenote: null,
|
||||
schema: mockSchemasList[0]
|
||||
},
|
||||
{
|
||||
self: 'https://dspace7.4science.it/dspace-spring-rest/api/core/metadatafields/9',
|
||||
element: 'contributor',
|
||||
qualifier: 'author',
|
||||
scopenote: null,
|
||||
schema: mockSchemasList[0]
|
||||
},
|
||||
{
|
||||
self: 'https://dspace7.4science.it/dspace-spring-rest/api/core/metadatafields/10',
|
||||
element: 'contributor',
|
||||
qualifier: 'editor',
|
||||
scopenote: 'test scope note',
|
||||
schema: mockSchemasList[1]
|
||||
},
|
||||
{
|
||||
self: 'https://dspace7.4science.it/dspace-spring-rest/api/core/metadatafields/11',
|
||||
element: 'contributor',
|
||||
qualifier: 'illustrator',
|
||||
scopenote: null,
|
||||
schema: mockSchemasList[1]
|
||||
}
|
||||
];
|
||||
const mockSchemas = Observable.of(new RemoteData(false, false, true, undefined, new PaginatedList(null, mockSchemasList)));
|
||||
const registryServiceStub = {
|
||||
getMetadataSchemas: () => mockSchemas,
|
||||
getMetadataFieldsBySchema: (schema: MetadataSchema) => Observable.of(new RemoteData(false, false, true, undefined, new PaginatedList(null, mockFieldsList.filter((value) => value.schema === schema)))),
|
||||
getMetadataSchemaByName: (schemaName: string) => Observable.of(new RemoteData(false, false, true, undefined, mockSchemasList.filter((value) => value.prefix === schemaName)[0]))
|
||||
};
|
||||
const schemaNameParam = 'mock';
|
||||
const activatedRouteStub = Object.assign(new ActivatedRouteStub(), {
|
||||
params: Observable.of({
|
||||
schemaName: schemaNameParam
|
||||
})
|
||||
});
|
||||
|
||||
beforeEach(async(() => {
|
||||
TestBed.configureTestingModule({
|
||||
imports: [CommonModule, RouterTestingModule.withRoutes([]), TranslateModule.forRoot(), NgbModule.forRoot()],
|
||||
declarations: [MetadataSchemaComponent, PaginationComponent, EnumKeysPipe],
|
||||
providers: [
|
||||
{ provide: RegistryService, useValue: registryServiceStub },
|
||||
{ provide: ActivatedRoute, useValue: activatedRouteStub },
|
||||
{ provide: HostWindowService, useValue: new HostWindowServiceStub(0) },
|
||||
{ provide: Router, useValue: new RouterStub() }
|
||||
]
|
||||
}).compileComponents();
|
||||
}));
|
||||
|
||||
beforeEach(() => {
|
||||
fixture = TestBed.createComponent(MetadataSchemaComponent);
|
||||
comp = fixture.componentInstance;
|
||||
fixture.detectChanges();
|
||||
registryService = (comp as any).service;
|
||||
});
|
||||
|
||||
it('should contain the schema prefix in the header', () => {
|
||||
const header: HTMLElement = fixture.debugElement.query(By.css('.metadata-schema #header')).nativeElement;
|
||||
expect(header.textContent).toContain('mock');
|
||||
});
|
||||
|
||||
it('should contain two fields', () => {
|
||||
const tbody: HTMLElement = fixture.debugElement.query(By.css('#metadata-fields>tbody')).nativeElement;
|
||||
expect(tbody.children.length).toBe(2);
|
||||
});
|
||||
|
||||
it('should contain the correct fields', () => {
|
||||
const editorField: HTMLElement = fixture.debugElement.query(By.css('#metadata-fields tr:nth-child(1) td:nth-child(1)')).nativeElement;
|
||||
expect(editorField.textContent).toBe('mock.contributor.editor');
|
||||
|
||||
const illustratorField: HTMLElement = fixture.debugElement.query(By.css('#metadata-fields tr:nth-child(2) td:nth-child(1)')).nativeElement;
|
||||
expect(illustratorField.textContent).toBe('mock.contributor.illustrator');
|
||||
});
|
||||
});
|
@@ -0,0 +1,55 @@
|
||||
import { Component, OnInit } from '@angular/core';
|
||||
import { RegistryService } from '../../../core/registry/registry.service';
|
||||
import { ActivatedRoute } from '@angular/router';
|
||||
import { Observable } from 'rxjs/Observable';
|
||||
import { RemoteData } from '../../../core/data/remote-data';
|
||||
import { PaginatedList } from '../../../core/data/paginated-list';
|
||||
import { MetadataField } from '../../../core/metadata/metadatafield.model';
|
||||
import { MetadataSchema } from '../../../core/metadata/metadataschema.model';
|
||||
import { PaginationComponentOptions } from '../../../shared/pagination/pagination-component-options.model';
|
||||
import { SortOptions } from '../../../core/cache/models/sort-options.model';
|
||||
|
||||
@Component({
|
||||
selector: 'ds-metadata-schema',
|
||||
templateUrl: './metadata-schema.component.html'
|
||||
})
|
||||
export class MetadataSchemaComponent implements OnInit {
|
||||
|
||||
namespace;
|
||||
|
||||
metadataSchema: Observable<RemoteData<MetadataSchema>>;
|
||||
metadataFields: Observable<RemoteData<PaginatedList<MetadataField>>>;
|
||||
config: PaginationComponentOptions = Object.assign(new PaginationComponentOptions(), {
|
||||
id: 'registry-metadatafields-pagination',
|
||||
pageSize: 10000
|
||||
});
|
||||
|
||||
constructor(private registryService: RegistryService, private route: ActivatedRoute) {
|
||||
|
||||
}
|
||||
|
||||
ngOnInit(): void {
|
||||
this.route.params.subscribe((params) => {
|
||||
this.initialize(params);
|
||||
});
|
||||
}
|
||||
|
||||
initialize(params) {
|
||||
this.metadataSchema = this.registryService.getMetadataSchemaByName(params.schemaName);
|
||||
this.updateFields();
|
||||
}
|
||||
|
||||
onPageChange(event) {
|
||||
this.config.currentPage = event;
|
||||
this.updateFields();
|
||||
}
|
||||
|
||||
private updateFields() {
|
||||
this.metadataSchema.subscribe((schemaData) => {
|
||||
const schema = schemaData.payload;
|
||||
this.metadataFields = this.registryService.getMetadataFieldsBySchema(schema, this.config);
|
||||
this.namespace = { namespace: schemaData.payload.namespace };
|
||||
});
|
||||
}
|
||||
|
||||
}
|
13
src/app/+admin/admin-routing.module.ts
Normal file
13
src/app/+admin/admin-routing.module.ts
Normal file
@@ -0,0 +1,13 @@
|
||||
import { RouterModule } from '@angular/router';
|
||||
import { NgModule } from '@angular/core';
|
||||
|
||||
@NgModule({
|
||||
imports: [
|
||||
RouterModule.forChild([
|
||||
{ path: 'registries', loadChildren: './admin-registries/admin-registries.module#AdminRegistriesModule' }
|
||||
])
|
||||
]
|
||||
})
|
||||
export class AdminRoutingModule {
|
||||
|
||||
}
|
13
src/app/+admin/admin.module.ts
Normal file
13
src/app/+admin/admin.module.ts
Normal file
@@ -0,0 +1,13 @@
|
||||
import { NgModule } from '@angular/core';
|
||||
import { AdminRegistriesModule } from './admin-registries/admin-registries.module';
|
||||
import { AdminRoutingModule } from './admin-routing.module';
|
||||
|
||||
@NgModule({
|
||||
imports: [
|
||||
AdminRegistriesModule,
|
||||
AdminRoutingModule
|
||||
]
|
||||
})
|
||||
export class AdminModule {
|
||||
|
||||
}
|
@@ -2,12 +2,23 @@ import { NgModule } from '@angular/core';
|
||||
import { RouterModule } from '@angular/router';
|
||||
|
||||
import { CollectionPageComponent } from './collection-page.component';
|
||||
import { CollectionPageResolver } from './collection-page.resolver';
|
||||
|
||||
@NgModule({
|
||||
imports: [
|
||||
RouterModule.forChild([
|
||||
{ path: ':id', component: CollectionPageComponent, pathMatch: 'full' }
|
||||
{
|
||||
path: ':id',
|
||||
component: CollectionPageComponent,
|
||||
pathMatch: 'full',
|
||||
resolve: {
|
||||
collection: CollectionPageResolver
|
||||
}
|
||||
}
|
||||
])
|
||||
],
|
||||
providers: [
|
||||
CollectionPageResolver,
|
||||
]
|
||||
})
|
||||
export class CollectionPageRoutingModule {
|
||||
|
@@ -1,6 +1,6 @@
|
||||
<div class="container">
|
||||
<div class="collection-page"
|
||||
*ngVar="(collectionRDObs | async) as collectionRD">
|
||||
*ngVar="(collectionRD$ | async) as collectionRD">
|
||||
<div *ngIf="collectionRD?.hasSucceeded" @fadeInOut>
|
||||
<div *ngIf="collectionRD?.payload as collection">
|
||||
<!-- Collection Name -->
|
||||
@@ -8,8 +8,8 @@
|
||||
[name]="collection.name">
|
||||
</ds-comcol-page-header>
|
||||
<!-- Collection logo -->
|
||||
<ds-comcol-page-logo *ngIf="logoRDObs"
|
||||
[logo]="(logoRDObs | async)?.payload"
|
||||
<ds-comcol-page-logo *ngIf="logoRD$"
|
||||
[logo]="(logoRD$ | async)?.payload"
|
||||
[alternateText]="'Collection Logo'">
|
||||
</ds-comcol-page-logo>
|
||||
<!-- Introductionary text -->
|
||||
@@ -38,14 +38,14 @@
|
||||
<ds-error *ngIf="collectionRD?.hasFailed" message="{{'error.collection' | translate}}"></ds-error>
|
||||
<ds-loading *ngIf="collectionRD?.isLoading" message="{{'loading.collection' | translate}}"></ds-loading>
|
||||
<br>
|
||||
<ng-container *ngVar="(itemRDObs | async) as itemRD">
|
||||
<ng-container *ngVar="(itemRD$ | async) as itemRD">
|
||||
<div *ngIf="itemRD?.hasSucceeded" @fadeIn>
|
||||
<h2>{{'collection.page.browse.recent.head' | translate}}</h2>
|
||||
<ds-viewable-collection
|
||||
[config]="paginationConfig"
|
||||
[sortConfig]="sortConfig"
|
||||
[objects]="itemRD"
|
||||
[hideGear]="false"
|
||||
[hideGear]="true"
|
||||
(paginationChange)="onPaginationChange($event)">
|
||||
</ds-viewable-collection>
|
||||
</div>
|
||||
|
@@ -5,7 +5,6 @@ import { Observable } from 'rxjs/Observable';
|
||||
import { Subscription } from 'rxjs/Subscription';
|
||||
import { SortDirection, SortOptions } from '../core/cache/models/sort-options.model';
|
||||
import { CollectionDataService } from '../core/data/collection-data.service';
|
||||
import { ItemDataService } from '../core/data/item-data.service';
|
||||
import { PaginatedList } from '../core/data/paginated-list';
|
||||
import { RemoteData } from '../core/data/remote-data';
|
||||
|
||||
@@ -18,6 +17,11 @@ import { Item } from '../core/shared/item.model';
|
||||
import { fadeIn, fadeInOut } from '../shared/animations/fade';
|
||||
import { hasValue, isNotEmpty } from '../shared/empty.util';
|
||||
import { PaginationComponentOptions } from '../shared/pagination/pagination-component-options.model';
|
||||
import { filter, flatMap, map } from 'rxjs/operators';
|
||||
import { SearchService } from '../+search-page/search-service/search.service';
|
||||
import { PaginatedSearchOptions } from '../+search-page/paginated-search-options.model';
|
||||
import { toDSpaceObjectListRD } from '../core/shared/operators';
|
||||
import { DSpaceObjectType } from '../core/shared/dspace-object-type.model';
|
||||
|
||||
@Component({
|
||||
selector: 'ds-collection-page',
|
||||
@@ -30,9 +34,9 @@ import { PaginationComponentOptions } from '../shared/pagination/pagination-comp
|
||||
]
|
||||
})
|
||||
export class CollectionPageComponent implements OnInit, OnDestroy {
|
||||
collectionRDObs: Observable<RemoteData<Collection>>;
|
||||
itemRDObs: Observable<RemoteData<PaginatedList<Item>>>;
|
||||
logoRDObs: Observable<RemoteData<Bitstream>>;
|
||||
collectionRD$: Observable<RemoteData<Collection>>;
|
||||
itemRD$: Observable<RemoteData<PaginatedList<Item>>>;
|
||||
logoRD$: Observable<RemoteData<Bitstream>>;
|
||||
paginationConfig: PaginationComponentOptions;
|
||||
sortConfig: SortOptions;
|
||||
private subs: Subscription[] = [];
|
||||
@@ -40,7 +44,7 @@ export class CollectionPageComponent implements OnInit, OnDestroy {
|
||||
|
||||
constructor(
|
||||
private collectionDataService: CollectionDataService,
|
||||
private itemDataService: ItemDataService,
|
||||
private searchService: SearchService,
|
||||
private metadata: MetadataService,
|
||||
private route: ActivatedRoute
|
||||
) {
|
||||
@@ -48,52 +52,41 @@ export class CollectionPageComponent implements OnInit, OnDestroy {
|
||||
this.paginationConfig.id = 'collection-page-pagination';
|
||||
this.paginationConfig.pageSize = 5;
|
||||
this.paginationConfig.currentPage = 1;
|
||||
this.sortConfig = new SortOptions('dc.title', SortDirection.ASC);
|
||||
this.sortConfig = new SortOptions('dc.date.accessioned', SortDirection.DESC);
|
||||
}
|
||||
|
||||
ngOnInit(): void {
|
||||
this.collectionRD$ = this.route.data.map((data) => data.collection);
|
||||
this.logoRD$ = this.collectionRD$.pipe(
|
||||
map((rd: RemoteData<Collection>) => rd.payload),
|
||||
filter((collection: Collection) => hasValue(collection)),
|
||||
flatMap((collection: Collection) => collection.logo)
|
||||
);
|
||||
this.subs.push(
|
||||
Observable.combineLatest(
|
||||
this.route.params,
|
||||
this.route.queryParams,
|
||||
(params, queryParams, ) => {
|
||||
return Object.assign({}, params, queryParams);
|
||||
})
|
||||
.subscribe((params) => {
|
||||
this.collectionId = params.id;
|
||||
this.collectionRDObs = this.collectionDataService.findById(this.collectionId);
|
||||
this.metadata.processRemoteData(this.collectionRDObs);
|
||||
this.subs.push(this.collectionRDObs
|
||||
.map((rd: RemoteData<Collection>) => rd.payload)
|
||||
.filter((collection: Collection) => hasValue(collection))
|
||||
.subscribe((collection: Collection) => this.logoRDObs = collection.logo));
|
||||
|
||||
const page = +params.page || this.paginationConfig.currentPage;
|
||||
const pageSize = +params.pageSize || this.paginationConfig.pageSize;
|
||||
const sortDirection = +params.page || this.sortConfig.direction;
|
||||
const pagination = Object.assign({},
|
||||
this.paginationConfig,
|
||||
{ currentPage: page, pageSize: pageSize }
|
||||
);
|
||||
const sort = Object.assign({},
|
||||
this.sortConfig,
|
||||
{ direction: sortDirection, field: params.sortField }
|
||||
);
|
||||
this.updatePage({
|
||||
pagination: pagination,
|
||||
sort: sort
|
||||
});
|
||||
}));
|
||||
this.route.queryParams.subscribe((params) => {
|
||||
this.metadata.processRemoteData(this.collectionRD$);
|
||||
const page = +params.page || this.paginationConfig.currentPage;
|
||||
const pageSize = +params.pageSize || this.paginationConfig.pageSize;
|
||||
const pagination = Object.assign({},
|
||||
this.paginationConfig,
|
||||
{ currentPage: page, pageSize: pageSize }
|
||||
);
|
||||
this.updatePage({
|
||||
pagination: pagination,
|
||||
sort: this.sortConfig
|
||||
});
|
||||
}));
|
||||
|
||||
}
|
||||
|
||||
updatePage(searchOptions) {
|
||||
this.itemRDObs = this.itemDataService.findAll({
|
||||
scopeID: this.collectionId,
|
||||
currentPage: searchOptions.pagination.currentPage,
|
||||
elementsPerPage: searchOptions.pagination.pageSize,
|
||||
sort: searchOptions.sort
|
||||
});
|
||||
this.itemRD$ = this.searchService.search(
|
||||
new PaginatedSearchOptions({
|
||||
scope: this.collectionId,
|
||||
pagination: searchOptions.pagination,
|
||||
sort: searchOptions.sort,
|
||||
dsoType: DSpaceObjectType.ITEM
|
||||
})).pipe(toDSpaceObjectListRD()) as Observable<RemoteData<PaginatedList<Item>>>;
|
||||
}
|
||||
|
||||
ngOnDestroy(): void {
|
||||
|
@@ -5,11 +5,13 @@ import { SharedModule } from '../shared/shared.module';
|
||||
|
||||
import { CollectionPageComponent } from './collection-page.component';
|
||||
import { CollectionPageRoutingModule } from './collection-page-routing.module';
|
||||
import { SearchPageModule } from '../+search-page/search-page.module';
|
||||
|
||||
@NgModule({
|
||||
imports: [
|
||||
CommonModule,
|
||||
SharedModule,
|
||||
SearchPageModule,
|
||||
CollectionPageRoutingModule
|
||||
],
|
||||
declarations: [
|
||||
|
28
src/app/+collection-page/collection-page.resolver.ts
Normal file
28
src/app/+collection-page/collection-page.resolver.ts
Normal file
@@ -0,0 +1,28 @@
|
||||
import { Injectable } from '@angular/core';
|
||||
import { ActivatedRouteSnapshot, Resolve, RouterStateSnapshot } from '@angular/router';
|
||||
import { Collection } from '../core/shared/collection.model';
|
||||
import { Observable } from 'rxjs/Observable';
|
||||
import { CollectionDataService } from '../core/data/collection-data.service';
|
||||
import { RemoteData } from '../core/data/remote-data';
|
||||
import { getSucceededRemoteData } from '../core/shared/operators';
|
||||
|
||||
/**
|
||||
* This class represents a resolver that requests a specific collection before the route is activated
|
||||
*/
|
||||
@Injectable()
|
||||
export class CollectionPageResolver implements Resolve<RemoteData<Collection>> {
|
||||
constructor(private collectionService: CollectionDataService) {
|
||||
}
|
||||
|
||||
/**
|
||||
* Method for resolving a collection based on the parameters in the current route
|
||||
* @param {ActivatedRouteSnapshot} route The current ActivatedRouteSnapshot
|
||||
* @param {RouterStateSnapshot} state The current RouterStateSnapshot
|
||||
* @returns Observable<<RemoteData<Collection>> Emits the found collection based on the parameters in the current route
|
||||
*/
|
||||
resolve(route: ActivatedRouteSnapshot, state: RouterStateSnapshot): Observable<RemoteData<Collection>> {
|
||||
return this.collectionService.findById(route.params.id).pipe(
|
||||
getSucceededRemoteData()
|
||||
);
|
||||
}
|
||||
}
|
@@ -2,12 +2,23 @@ import { NgModule } from '@angular/core';
|
||||
import { RouterModule } from '@angular/router';
|
||||
|
||||
import { CommunityPageComponent } from './community-page.component';
|
||||
import { CommunityPageResolver } from './community-page.resolver';
|
||||
|
||||
@NgModule({
|
||||
imports: [
|
||||
RouterModule.forChild([
|
||||
{ path: ':id', component: CommunityPageComponent, pathMatch: 'full' }
|
||||
{
|
||||
path: ':id',
|
||||
component: CommunityPageComponent,
|
||||
pathMatch: 'full',
|
||||
resolve: {
|
||||
community: CommunityPageResolver
|
||||
}
|
||||
}
|
||||
])
|
||||
],
|
||||
providers: [
|
||||
CommunityPageResolver,
|
||||
]
|
||||
})
|
||||
export class CommunityPageRoutingModule {
|
||||
|
@@ -1,11 +1,11 @@
|
||||
<div class="container" *ngVar="(communityRDObs | async) as communityRD">
|
||||
<div class="container" *ngVar="(communityRD$ | async) as communityRD">
|
||||
<div class="community-page" *ngIf="communityRD?.hasSucceeded" @fadeInOut>
|
||||
<div *ngIf="communityRD?.payload; let communityPayload">
|
||||
<!-- Community name -->
|
||||
<ds-comcol-page-header [name]="communityPayload.name"></ds-comcol-page-header>
|
||||
<!-- Community logo -->
|
||||
<ds-comcol-page-logo *ngIf="logoRDObs"
|
||||
[logo]="(logoRDObs | async)?.payload"
|
||||
<ds-comcol-page-logo *ngIf="logoRD$"
|
||||
[logo]="(logoRD$ | async)?.payload"
|
||||
[alternateText]="'Community Logo'">
|
||||
</ds-comcol-page-logo>
|
||||
<!-- Introductory text -->
|
||||
|
@@ -22,8 +22,8 @@ import { Observable } from 'rxjs/Observable';
|
||||
animations: [fadeInOut]
|
||||
})
|
||||
export class CommunityPageComponent implements OnInit, OnDestroy {
|
||||
communityRDObs: Observable<RemoteData<Community>>;
|
||||
logoRDObs: Observable<RemoteData<Bitstream>>;
|
||||
communityRD$: Observable<RemoteData<Community>>;
|
||||
logoRD$: Observable<RemoteData<Bitstream>>;
|
||||
private subs: Subscription[] = [];
|
||||
|
||||
constructor(
|
||||
@@ -35,14 +35,11 @@ export class CommunityPageComponent implements OnInit, OnDestroy {
|
||||
}
|
||||
|
||||
ngOnInit(): void {
|
||||
this.route.params.subscribe((params: Params) => {
|
||||
this.communityRDObs = this.communityDataService.findById(params.id);
|
||||
this.metadata.processRemoteData(this.communityRDObs);
|
||||
this.subs.push(this.communityRDObs
|
||||
.map((rd: RemoteData<Community>) => rd.payload)
|
||||
.filter((community: Community) => hasValue(community))
|
||||
.subscribe((community: Community) => this.logoRDObs = community.logo));
|
||||
});
|
||||
this.communityRD$ = this.route.data.map((data) => data.community);
|
||||
this.logoRD$ = this.communityRD$
|
||||
.map((rd: RemoteData<Community>) => rd.payload)
|
||||
.filter((community: Community) => hasValue(community))
|
||||
.flatMap((community: Community) => community.logo);
|
||||
}
|
||||
|
||||
ngOnDestroy(): void {
|
||||
|
28
src/app/+community-page/community-page.resolver.ts
Normal file
28
src/app/+community-page/community-page.resolver.ts
Normal file
@@ -0,0 +1,28 @@
|
||||
import { Injectable } from '@angular/core';
|
||||
import { ActivatedRouteSnapshot, Resolve, RouterStateSnapshot } from '@angular/router';
|
||||
import { Observable } from 'rxjs/Observable';
|
||||
import { RemoteData } from '../core/data/remote-data';
|
||||
import { getSucceededRemoteData } from '../core/shared/operators';
|
||||
import { Community } from '../core/shared/community.model';
|
||||
import { CommunityDataService } from '../core/data/community-data.service';
|
||||
|
||||
/**
|
||||
* This class represents a resolver that requests a specific community before the route is activated
|
||||
*/
|
||||
@Injectable()
|
||||
export class CommunityPageResolver implements Resolve<RemoteData<Community>> {
|
||||
constructor(private communityService: CommunityDataService) {
|
||||
}
|
||||
|
||||
/**
|
||||
* Method for resolving a community based on the parameters in the current route
|
||||
* @param {ActivatedRouteSnapshot} route The current ActivatedRouteSnapshot
|
||||
* @param {RouterStateSnapshot} state The current RouterStateSnapshot
|
||||
* @returns Observable<<RemoteData<Community>> Emits the found community based on the parameters in the current route
|
||||
*/
|
||||
resolve(route: ActivatedRouteSnapshot, state: RouterStateSnapshot): Observable<RemoteData<Community>> {
|
||||
return this.communityService.findById(route.params.id).pipe(
|
||||
getSucceededRemoteData()
|
||||
);
|
||||
}
|
||||
}
|
@@ -38,7 +38,7 @@ export class TopLevelCommunityListComponent {
|
||||
}
|
||||
|
||||
updatePage(data) {
|
||||
this.communitiesRDObs = this.cds.findAll({
|
||||
this.communitiesRDObs = this.cds.findTop({
|
||||
currentPage: data.page,
|
||||
elementsPerPage: data.pageSize,
|
||||
sort: { field: data.sortField, direction: data.sortDirection }
|
||||
|
@@ -60,7 +60,7 @@ describe('CollectionsComponent', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('When the requested item request has succeeded', () => {
|
||||
describe('When the requested item request has failed', () => {
|
||||
beforeEach(() => {
|
||||
collectionsComponent.item = failedMockItem;
|
||||
fixture.detectChanges();
|
||||
|
@@ -1,15 +1,15 @@
|
||||
<div class="container" *ngVar="(itemRDObs | async) as itemRD">
|
||||
<div class="container" *ngVar="(itemRD$ | async) as itemRD">
|
||||
<div class="item-page" *ngIf="itemRD?.hasSucceeded" @fadeInOut>
|
||||
<div *ngIf="itemRD?.payload as entity">
|
||||
<ds-item-page-title-field [item]="item"></ds-item-page-title-field>
|
||||
<div class="simple-view-link">
|
||||
<a class="btn btn-outline-primary col-4" [routerLink]="['/items/' + item.id]">
|
||||
<div class="simple-view-link my-3">
|
||||
<a class="btn btn-outline-primary" [routerLink]="['/items/' + item.id]">
|
||||
{{"item.page.link.simple" | translate}}
|
||||
</a>
|
||||
</div>
|
||||
<table class="table table-responsive table-striped">
|
||||
<tbody>
|
||||
<tr *ngFor="let metadatum of (metadataObs | async)">
|
||||
<tr *ngFor="let metadatum of (metadata$ | async)">
|
||||
<td>{{metadatum.key}}</td>
|
||||
<td>{{metadatum.value}}</td>
|
||||
<td>{{metadatum.language}}</td>
|
||||
|
@@ -1,7 +1,10 @@
|
||||
@import '../../../styles/variables.scss';
|
||||
|
||||
:host {
|
||||
div.simple-view-link {
|
||||
text-align: center;
|
||||
margin: 20px;
|
||||
a {
|
||||
min-width: 25%;
|
||||
}
|
||||
}
|
||||
}
|
@@ -31,9 +31,9 @@ import { BehaviorSubject } from 'rxjs/BehaviorSubject';
|
||||
})
|
||||
export class FullItemPageComponent extends ItemPageComponent implements OnInit {
|
||||
|
||||
itemRDObs: BehaviorSubject<RemoteData<Item>>;
|
||||
itemRD$: BehaviorSubject<RemoteData<Item>>;
|
||||
|
||||
metadataObs: Observable<Metadatum[]>;
|
||||
metadata$: Observable<Metadatum[]>;
|
||||
|
||||
constructor(route: ActivatedRoute, items: ItemDataService, metadataService: MetadataService) {
|
||||
super(route, items, metadataService);
|
||||
@@ -42,14 +42,9 @@ export class FullItemPageComponent extends ItemPageComponent implements OnInit {
|
||||
/*** AoT inheritance fix, will hopefully be resolved in the near future **/
|
||||
ngOnInit(): void {
|
||||
super.ngOnInit();
|
||||
}
|
||||
|
||||
initialize(params) {
|
||||
super.initialize(params);
|
||||
this.metadataObs = this.itemRDObs
|
||||
this.metadata$ = this.itemRD$
|
||||
.map((rd: RemoteData<Item>) => rd.payload)
|
||||
.filter((item: Item) => hasValue(item))
|
||||
.map((item: Item) => item.metadata);
|
||||
}
|
||||
|
||||
}
|
||||
|
@@ -3,13 +3,30 @@ import { RouterModule } from '@angular/router';
|
||||
|
||||
import { ItemPageComponent } from './simple/item-page.component';
|
||||
import { FullItemPageComponent } from './full/full-item-page.component';
|
||||
import { ItemPageResolver } from './item-page.resolver';
|
||||
|
||||
@NgModule({
|
||||
imports: [
|
||||
RouterModule.forChild([
|
||||
{ path: ':id', component: ItemPageComponent, pathMatch: 'full' },
|
||||
{ path: ':id/full', component: FullItemPageComponent }
|
||||
{
|
||||
path: ':id',
|
||||
component: ItemPageComponent,
|
||||
pathMatch: 'full',
|
||||
resolve: {
|
||||
item: ItemPageResolver
|
||||
}
|
||||
},
|
||||
{
|
||||
path: ':id/full',
|
||||
component: FullItemPageComponent,
|
||||
resolve: {
|
||||
item: ItemPageResolver
|
||||
}
|
||||
}
|
||||
])
|
||||
],
|
||||
providers: [
|
||||
ItemPageResolver,
|
||||
]
|
||||
})
|
||||
export class ItemPageRoutingModule {
|
||||
|
28
src/app/+item-page/item-page.resolver.ts
Normal file
28
src/app/+item-page/item-page.resolver.ts
Normal file
@@ -0,0 +1,28 @@
|
||||
import { Injectable } from '@angular/core';
|
||||
import { ActivatedRouteSnapshot, Resolve, RouterStateSnapshot } from '@angular/router';
|
||||
import { Observable } from 'rxjs/Observable';
|
||||
import { RemoteData } from '../core/data/remote-data';
|
||||
import { getSucceededRemoteData } from '../core/shared/operators';
|
||||
import { ItemDataService } from '../core/data/item-data.service';
|
||||
import { Item } from '../core/shared/item.model';
|
||||
|
||||
/**
|
||||
* This class represents a resolver that requests a specific item before the route is activated
|
||||
*/
|
||||
@Injectable()
|
||||
export class ItemPageResolver implements Resolve<RemoteData<Item>> {
|
||||
constructor(private itemService: ItemDataService) {
|
||||
}
|
||||
|
||||
/**
|
||||
* Method for resolving an item based on the parameters in the current route
|
||||
* @param {ActivatedRouteSnapshot} route The current ActivatedRouteSnapshot
|
||||
* @param {RouterStateSnapshot} state The current RouterStateSnapshot
|
||||
* @returns Observable<<RemoteData<Item>> Emits the found item based on the parameters in the current route
|
||||
*/
|
||||
resolve(route: ActivatedRouteSnapshot, state: RouterStateSnapshot): Observable<RemoteData<Item>> {
|
||||
return this.itemService.findById(route.params.id).pipe(
|
||||
getSucceededRemoteData()
|
||||
);
|
||||
}
|
||||
}
|
@@ -1,4 +1,4 @@
|
||||
<div class="container" *ngVar="(itemRDObs | async) as itemRD">
|
||||
<div class="container" *ngVar="(itemRD$ | async) as itemRD">
|
||||
<div class="item-page" *ngIf="itemRD?.hasSucceeded" @fadeInOut>
|
||||
<div *ngIf="itemRD?.payload as item">
|
||||
<ds-entity-type-switcher [object]="item" [viewMode]="ElementViewMode.Full"></ds-entity-type-switcher>
|
||||
|
@@ -1,4 +1,4 @@
|
||||
import { ChangeDetectionStrategy, ChangeDetectorRef, Component, OnInit } from '@angular/core';
|
||||
import { ChangeDetectionStrategy, Component, OnInit } from '@angular/core';
|
||||
import { ActivatedRoute } from '@angular/router';
|
||||
|
||||
import { Observable } from 'rxjs/Observable';
|
||||
@@ -12,9 +12,6 @@ import { MetadataService } from '../../core/metadata/metadata.service';
|
||||
|
||||
import { fadeInOut } from '../../shared/animations/fade';
|
||||
import { hasValue } from '../../shared/empty.util';
|
||||
import * as viewMode from '../../shared/view-mode';
|
||||
import { BehaviorSubject } from 'rxjs/BehaviorSubject';
|
||||
import { Subscription } from 'rxjs/Subscription';
|
||||
|
||||
/**
|
||||
* This component renders a simple item page.
|
||||
@@ -32,13 +29,11 @@ export class ItemPageComponent implements OnInit {
|
||||
|
||||
id: number;
|
||||
|
||||
private sub: Subscription;
|
||||
private itemSub: Subscription;
|
||||
thumbnailObs: Observable<Bitstream>;
|
||||
private sub: any;
|
||||
|
||||
itemRDObs: BehaviorSubject<RemoteData<Item>> = new BehaviorSubject(new RemoteData(true, true, undefined, null, null));
|
||||
itemRD$: Observable<RemoteData<Item>>;
|
||||
|
||||
ElementViewMode = viewMode.ElementViewMode;
|
||||
thumbnail$: Observable<Bitstream>;
|
||||
|
||||
constructor(
|
||||
private route: ActivatedRoute,
|
||||
@@ -47,20 +42,9 @@ export class ItemPageComponent implements OnInit {
|
||||
) { }
|
||||
|
||||
ngOnInit(): void {
|
||||
this.sub = this.route.params.subscribe((params) => {
|
||||
this.initialize(params);
|
||||
});
|
||||
|
||||
}
|
||||
|
||||
initialize(params) {
|
||||
this.id = +params.id;
|
||||
if (hasValue(this.itemSub)) {
|
||||
this.itemSub.unsubscribe();
|
||||
}
|
||||
this.itemSub = this.items.findById(params.id).subscribe((item) => this.itemRDObs.next(item));
|
||||
this.metadataService.processRemoteData(this.itemRDObs);
|
||||
this.thumbnailObs = this.itemRDObs
|
||||
this.itemRD$ = this.route.data.map((data) => data.item);
|
||||
this.metadataService.processRemoteData(this.itemRD$);
|
||||
this.thumbnail$ = this.itemRD$
|
||||
.map((rd: RemoteData<Item>) => rd.payload)
|
||||
.filter((item: Item) => hasValue(item))
|
||||
.flatMap((item: Item) => item.getThumbnail());
|
||||
|
13
src/app/+login-page/login-page-routing.module.ts
Normal file
13
src/app/+login-page/login-page-routing.module.ts
Normal file
@@ -0,0 +1,13 @@
|
||||
import { NgModule } from '@angular/core';
|
||||
import { RouterModule } from '@angular/router';
|
||||
|
||||
import { LoginPageComponent } from './login-page.component';
|
||||
|
||||
@NgModule({
|
||||
imports: [
|
||||
RouterModule.forChild([
|
||||
{ path: '', component: LoginPageComponent, data: { title: 'login.title' } }
|
||||
])
|
||||
]
|
||||
})
|
||||
export class LoginPageRoutingModule { }
|
9
src/app/+login-page/login-page.component.html
Normal file
9
src/app/+login-page/login-page.component.html
Normal file
@@ -0,0 +1,9 @@
|
||||
<div class="container w-100 h-100">
|
||||
<div class="text-center mt-5 row justify-content-center">
|
||||
<div>
|
||||
<img class="mb-4 login-logo" src="assets/images/dspace-logo.png">
|
||||
<h1 class="h3 mb-0 font-weight-normal">{{"login.form.header" | translate}}</h1>
|
||||
<ds-log-in></ds-log-in>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
6
src/app/+login-page/login-page.component.scss
Normal file
6
src/app/+login-page/login-page.component.scss
Normal file
@@ -0,0 +1,6 @@
|
||||
@import '../../styles/variables.scss';
|
||||
|
||||
.login-logo {
|
||||
height: $login-logo-height;
|
||||
width: $login-logo-width;
|
||||
}
|
47
src/app/+login-page/login-page.component.spec.ts
Normal file
47
src/app/+login-page/login-page.component.spec.ts
Normal file
@@ -0,0 +1,47 @@
|
||||
import { NO_ERRORS_SCHEMA } from '@angular/core';
|
||||
import { async, ComponentFixture, TestBed } from '@angular/core/testing';
|
||||
|
||||
import { Store } from '@ngrx/store';
|
||||
import { TranslateModule } from '@ngx-translate/core';
|
||||
import { Observable } from 'rxjs/Observable';
|
||||
import 'rxjs/add/observable/of';
|
||||
|
||||
import { LoginPageComponent } from './login-page.component';
|
||||
|
||||
describe('LoginPageComponent', () => {
|
||||
let comp: LoginPageComponent;
|
||||
let fixture: ComponentFixture<LoginPageComponent>;
|
||||
|
||||
const store: Store<LoginPageComponent> = jasmine.createSpyObj('store', {
|
||||
/* tslint:disable:no-empty */
|
||||
dispatch: {},
|
||||
/* tslint:enable:no-empty */
|
||||
select: Observable.of(true)
|
||||
});
|
||||
|
||||
beforeEach(async(() => {
|
||||
TestBed.configureTestingModule({
|
||||
imports: [
|
||||
TranslateModule.forRoot()
|
||||
],
|
||||
declarations: [LoginPageComponent],
|
||||
providers: [
|
||||
{
|
||||
provide: Store, useValue: store
|
||||
}
|
||||
],
|
||||
schemas: [NO_ERRORS_SCHEMA]
|
||||
}).compileComponents();
|
||||
}));
|
||||
|
||||
beforeEach(() => {
|
||||
fixture = TestBed.createComponent(LoginPageComponent);
|
||||
comp = fixture.componentInstance; // SearchPageComponent test instance
|
||||
fixture.detectChanges();
|
||||
});
|
||||
|
||||
it('should create instance', () => {
|
||||
expect(comp).toBeDefined()
|
||||
});
|
||||
|
||||
});
|
21
src/app/+login-page/login-page.component.ts
Normal file
21
src/app/+login-page/login-page.component.ts
Normal file
@@ -0,0 +1,21 @@
|
||||
import { Component, OnDestroy } from '@angular/core';
|
||||
|
||||
import { Store } from '@ngrx/store';
|
||||
|
||||
import { AppState } from '../app.reducer';
|
||||
import { ResetAuthenticationMessagesAction } from '../core/auth/auth.actions';
|
||||
|
||||
@Component({
|
||||
selector: 'ds-login-page',
|
||||
styleUrls: ['./login-page.component.scss'],
|
||||
templateUrl: './login-page.component.html'
|
||||
})
|
||||
export class LoginPageComponent implements OnDestroy {
|
||||
|
||||
constructor(private store: Store<AppState>) {}
|
||||
|
||||
ngOnDestroy() {
|
||||
// Clear all authentication messages when leaving login page
|
||||
this.store.dispatch(new ResetAuthenticationMessagesAction());
|
||||
}
|
||||
}
|
19
src/app/+login-page/login-page.module.ts
Normal file
19
src/app/+login-page/login-page.module.ts
Normal file
@@ -0,0 +1,19 @@
|
||||
import { CommonModule } from '@angular/common';
|
||||
import { NgModule } from '@angular/core';
|
||||
import { SharedModule } from '../shared/shared.module';
|
||||
import { LoginPageComponent } from './login-page.component';
|
||||
import { LoginPageRoutingModule } from './login-page-routing.module';
|
||||
|
||||
@NgModule({
|
||||
imports: [
|
||||
LoginPageRoutingModule,
|
||||
CommonModule,
|
||||
SharedModule,
|
||||
],
|
||||
declarations: [
|
||||
LoginPageComponent
|
||||
]
|
||||
})
|
||||
export class LoginPageModule {
|
||||
|
||||
}
|
19
src/app/+logout-page/logout-page-routing.module.ts
Normal file
19
src/app/+logout-page/logout-page-routing.module.ts
Normal file
@@ -0,0 +1,19 @@
|
||||
import { NgModule } from '@angular/core';
|
||||
import { RouterModule } from '@angular/router';
|
||||
|
||||
import { LogoutPageComponent } from './logout-page.component';
|
||||
import { AuthenticatedGuard } from '../core/auth/authenticated.guard';
|
||||
|
||||
@NgModule({
|
||||
imports: [
|
||||
RouterModule.forChild([
|
||||
{
|
||||
canActivate: [AuthenticatedGuard],
|
||||
path: '',
|
||||
component: LogoutPageComponent,
|
||||
data: { title: 'logout.title' }
|
||||
}
|
||||
])
|
||||
]
|
||||
})
|
||||
export class LogoutPageRoutingModule { }
|
9
src/app/+logout-page/logout-page.component.html
Normal file
9
src/app/+logout-page/logout-page.component.html
Normal file
@@ -0,0 +1,9 @@
|
||||
<div class="container w-100 h-100">
|
||||
<div class="text-center mt-5 row justify-content-md-center">
|
||||
<div>
|
||||
<img class="mb-4 login-logo" src="assets/images/dspace-logo.png">
|
||||
<h1 class="h3 mb-0 font-weight-normal">{{"logout.form.header" | translate}}</h1>
|
||||
<ds-log-out></ds-log-out>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
1
src/app/+logout-page/logout-page.component.scss
Normal file
1
src/app/+logout-page/logout-page.component.scss
Normal file
@@ -0,0 +1 @@
|
||||
@import '../+login-page/login-page.component.scss';
|
31
src/app/+logout-page/logout-page.component.spec.ts
Normal file
31
src/app/+logout-page/logout-page.component.spec.ts
Normal file
@@ -0,0 +1,31 @@
|
||||
import { NO_ERRORS_SCHEMA } from '@angular/core';
|
||||
import { async, ComponentFixture, TestBed } from '@angular/core/testing';
|
||||
import { TranslateModule } from '@ngx-translate/core';
|
||||
|
||||
import { LogoutPageComponent } from './logout-page.component';
|
||||
|
||||
describe('LogoutPageComponent', () => {
|
||||
let comp: LogoutPageComponent;
|
||||
let fixture: ComponentFixture<LogoutPageComponent>;
|
||||
|
||||
beforeEach(async(() => {
|
||||
TestBed.configureTestingModule({
|
||||
imports: [
|
||||
TranslateModule.forRoot()
|
||||
],
|
||||
declarations: [LogoutPageComponent],
|
||||
schemas: [NO_ERRORS_SCHEMA]
|
||||
}).compileComponents();
|
||||
}));
|
||||
|
||||
beforeEach(() => {
|
||||
fixture = TestBed.createComponent(LogoutPageComponent);
|
||||
comp = fixture.componentInstance; // SearchPageComponent test instance
|
||||
fixture.detectChanges();
|
||||
});
|
||||
|
||||
it('should create instance', () => {
|
||||
expect(comp).toBeDefined()
|
||||
});
|
||||
|
||||
});
|
10
src/app/+logout-page/logout-page.component.ts
Normal file
10
src/app/+logout-page/logout-page.component.ts
Normal file
@@ -0,0 +1,10 @@
|
||||
import { Component } from '@angular/core';
|
||||
|
||||
@Component({
|
||||
selector: 'ds-logout-page',
|
||||
styleUrls: ['./logout-page.component.scss'],
|
||||
templateUrl: './logout-page.component.html'
|
||||
})
|
||||
export class LogoutPageComponent {
|
||||
|
||||
}
|
19
src/app/+logout-page/logout-page.module.ts
Normal file
19
src/app/+logout-page/logout-page.module.ts
Normal file
@@ -0,0 +1,19 @@
|
||||
import { CommonModule } from '@angular/common';
|
||||
import { NgModule } from '@angular/core';
|
||||
import { SharedModule } from '../shared/shared.module';
|
||||
import { LogoutPageComponent } from './logout-page.component';
|
||||
import { LogoutPageRoutingModule } from './logout-page-routing.module';
|
||||
|
||||
@NgModule({
|
||||
imports: [
|
||||
LogoutPageRoutingModule,
|
||||
CommonModule,
|
||||
SharedModule,
|
||||
],
|
||||
declarations: [
|
||||
LogoutPageComponent
|
||||
]
|
||||
})
|
||||
export class LogoutPageModule {
|
||||
|
||||
}
|
@@ -4,9 +4,10 @@ import { SearchFilterService } from './search-filters/search-filter/search-filte
|
||||
import { SearchService } from './search-service/search.service';
|
||||
import { SearchSidebarService } from './search-sidebar/search-sidebar.service';
|
||||
import { SearchPageComponent } from './search-page.component';
|
||||
import { RouteService } from '../shared/route.service';
|
||||
import { ChangeDetectionStrategy, Component, Injectable } from '@angular/core';
|
||||
import { pushInOut } from '../shared/animations/push';
|
||||
import { RouteService } from '../shared/services/route.service';
|
||||
import { SearchConfigurationService } from './search-service/search-configuration.service';
|
||||
|
||||
/**
|
||||
* This component renders a simple item page.
|
||||
@@ -23,12 +24,11 @@ import { pushInOut } from '../shared/animations/push';
|
||||
export class FilteredSearchPageComponent extends SearchPageComponent {
|
||||
|
||||
constructor(protected service: SearchService,
|
||||
protected communityService: CommunityDataService,
|
||||
protected sidebarService: SearchSidebarService,
|
||||
protected windowService: HostWindowService,
|
||||
protected filterService: SearchFilterService,
|
||||
protected routeService: RouteService) {
|
||||
super(service, communityService, sidebarService, windowService, filterService, routeService);
|
||||
protected searchConfigService: SearchConfigurationService) {
|
||||
super(service, sidebarService, windowService, filterService, searchConfigService);
|
||||
}
|
||||
|
||||
}
|
||||
|
@@ -2,11 +2,19 @@ import { autoserialize } from 'cerialize';
|
||||
import { Metadatum } from '../core/shared/metadatum.model';
|
||||
import { ListableObject } from '../shared/object-collection/shared/listable-object.model';
|
||||
|
||||
/**
|
||||
* Represents a normalized version of a search result object of a certain DSpaceObject
|
||||
*/
|
||||
export class NormalizedSearchResult implements ListableObject {
|
||||
|
||||
/**
|
||||
* The UUID of the DSpaceObject that was found
|
||||
*/
|
||||
@autoserialize
|
||||
dspaceObject: string;
|
||||
|
||||
/**
|
||||
* The metadata that was used to find this item, hithighlighted
|
||||
*/
|
||||
@autoserialize
|
||||
hitHighlights: Metadatum[];
|
||||
|
||||
|
38
src/app/+search-page/paginated-search-options.model.spec.ts
Normal file
38
src/app/+search-page/paginated-search-options.model.spec.ts
Normal file
@@ -0,0 +1,38 @@
|
||||
import 'rxjs/add/observable/of';
|
||||
import { SortDirection, SortOptions } from '../core/cache/models/sort-options.model';
|
||||
import { PaginationComponentOptions } from '../shared/pagination/pagination-component-options.model';
|
||||
import { PaginatedSearchOptions } from './paginated-search-options.model';
|
||||
import { DSpaceObjectType } from '../core/shared/dspace-object-type.model';
|
||||
import { SearchFilter } from './search-filter.model';
|
||||
|
||||
describe('PaginatedSearchOptions', () => {
|
||||
let options: PaginatedSearchOptions;
|
||||
const sortOptions = new SortOptions('test.field', SortDirection.DESC);
|
||||
const pageOptions = Object.assign(new PaginationComponentOptions(), { pageSize: 40, page: 1 });
|
||||
const filters = [new SearchFilter('f.test', ['value']), new SearchFilter('f.example', ['another value', 'second value'])];
|
||||
const query = 'search query';
|
||||
const scope = '0fde1ecb-82cc-425a-b600-ac3576d76b47';
|
||||
const baseUrl = 'www.rest.com';
|
||||
beforeEach(() => {
|
||||
options = new PaginatedSearchOptions({sort: sortOptions, pagination: pageOptions, filters: filters, query: query, scope: scope, dsoType: DSpaceObjectType.ITEM});
|
||||
});
|
||||
|
||||
describe('when toRestUrl is called', () => {
|
||||
|
||||
it('should generate a string with all parameters that are present', () => {
|
||||
const outcome = options.toRestUrl(baseUrl);
|
||||
expect(outcome).toEqual('www.rest.com?' +
|
||||
'sort=test.field,DESC&' +
|
||||
'page=0&' +
|
||||
'size=40&' +
|
||||
'query=search query&' +
|
||||
'scope=0fde1ecb-82cc-425a-b600-ac3576d76b47&' +
|
||||
'dsoType=ITEM&' +
|
||||
'f.test=value,query&' +
|
||||
'f.example=another value,query&' +
|
||||
'f.example=second value,query'
|
||||
);
|
||||
});
|
||||
|
||||
});
|
||||
});
|
@@ -2,10 +2,28 @@ import { SortOptions } from '../core/cache/models/sort-options.model';
|
||||
import { PaginationComponentOptions } from '../shared/pagination/pagination-component-options.model';
|
||||
import { isNotEmpty } from '../shared/empty.util';
|
||||
import { SearchOptions } from './search-options.model';
|
||||
import { SearchFilter } from './search-filter.model';
|
||||
import { DSpaceObjectType } from '../core/shared/dspace-object-type.model';
|
||||
|
||||
/**
|
||||
* This model class represents all parameters needed to request information about a certain page of a search request, in a certain order
|
||||
*/
|
||||
export class PaginatedSearchOptions extends SearchOptions {
|
||||
pagination?: PaginationComponentOptions;
|
||||
sort?: SortOptions;
|
||||
|
||||
constructor(options: {scope?: string, query?: string, dsoType?: DSpaceObjectType, filters?: SearchFilter[], pagination?: PaginationComponentOptions, sort?: SortOptions}) {
|
||||
super(options);
|
||||
this.pagination = options.pagination;
|
||||
this.sort = options.sort;
|
||||
}
|
||||
|
||||
/**
|
||||
* Method to generate the URL that can be used to request a certain page with specific sort options
|
||||
* @param {string} url The URL to the REST endpoint
|
||||
* @param {string[]} args A list of query arguments that should be included in the URL
|
||||
* @returns {string} URL with all paginated search options and passed arguments as query parameters
|
||||
*/
|
||||
toRestUrl(url: string, args: string[] = []): string {
|
||||
if (isNotEmpty(this.sort)) {
|
||||
args.push(`sort=${this.sort.field},${this.sort.direction}`);
|
||||
|
20
src/app/+search-page/search-filter.model.ts
Normal file
20
src/app/+search-page/search-filter.model.ts
Normal file
@@ -0,0 +1,20 @@
|
||||
/**
|
||||
* Represents a search filter
|
||||
*/
|
||||
import { hasValue } from '../shared/empty.util';
|
||||
|
||||
export class SearchFilter {
|
||||
key: string;
|
||||
values: string[];
|
||||
operator: string;
|
||||
|
||||
constructor(key: string, values: string[], operator?: string) {
|
||||
this.key = key;
|
||||
this.values = values;
|
||||
if (hasValue(operator)) {
|
||||
this.operator = operator;
|
||||
} else {
|
||||
this.operator = 'query';
|
||||
}
|
||||
}
|
||||
}
|
@@ -0,0 +1,34 @@
|
||||
<div>
|
||||
<div class="filters py-2">
|
||||
<a *ngFor="let value of (selectedValues | async)" class="d-flex flex-row"
|
||||
[routerLink]="[getSearchLink()]"
|
||||
[queryParams]="getRemoveParams(value) | async" queryParamsHandling="merge">
|
||||
<input type="checkbox" [checked]="true" class="my-1 align-self-stretch"/>
|
||||
<span class="filter-value pl-1">{{value}}</span>
|
||||
</a>
|
||||
<ng-container *ngFor="let page of (filterValues$ | async)?.payload">
|
||||
<div [@facetLoad]="animationState">
|
||||
<ng-container *ngFor="let value of page.page; let i=index">
|
||||
<a *ngIf="!(selectedValues | async).includes(value.value)" class="d-flex flex-row"
|
||||
[routerLink]="[getSearchLink()]"
|
||||
[queryParams]="getAddParams(value.value) | async" queryParamsHandling="merge">
|
||||
<input type="checkbox" [checked]="false" class="my-1 align-self-stretch"/>
|
||||
<span class="filter-value px-1">{{value.value}}</span>
|
||||
<span class="float-right filter-value-count ml-auto">
|
||||
<span class="badge badge-secondary badge-pill">{{value.count}}</span>
|
||||
</span>
|
||||
</a>
|
||||
</ng-container>
|
||||
</div>
|
||||
</ng-container>
|
||||
<div class="clearfix toggle-more-filters">
|
||||
<a class="float-left" *ngIf="!(isLastPage$ | async)"
|
||||
(click)="showMore()">{{"search.filters.filter.show-more"
|
||||
| translate}}</a>
|
||||
<a class="float-right" *ngIf="(currentPage | async) > 1"
|
||||
(click)="showFirstPageOnly()">{{"search.filters.filter.show-less"
|
||||
| translate}}</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
@@ -0,0 +1,25 @@
|
||||
@import '../../../../../styles/variables.scss';
|
||||
@import '../../../../../styles/mixins.scss';
|
||||
|
||||
.filters {
|
||||
a {
|
||||
color: $body-color;
|
||||
&:hover, &focus {
|
||||
text-decoration: none;
|
||||
}
|
||||
span.badge {
|
||||
vertical-align: text-top;
|
||||
}
|
||||
}
|
||||
.toggle-more-filters a {
|
||||
color: $link-color;
|
||||
text-decoration: underline;
|
||||
cursor: pointer;
|
||||
}
|
||||
}
|
||||
::ng-deep em {
|
||||
font-weight: bold;
|
||||
font-style: normal;
|
||||
}
|
||||
|
||||
|
@@ -0,0 +1,21 @@
|
||||
import { Component, OnInit } from '@angular/core';
|
||||
import { FilterType } from '../../../search-service/filter-type.model';
|
||||
import { renderFacetFor } from '../search-filter-type-decorator';
|
||||
import {
|
||||
facetLoad,
|
||||
SearchFacetFilterComponent
|
||||
} from '../search-facet-filter/search-facet-filter.component';
|
||||
|
||||
@Component({
|
||||
selector: 'ds-search-boolean-filter',
|
||||
styleUrls: ['./search-boolean-filter.component.scss'],
|
||||
templateUrl: './search-boolean-filter.component.html',
|
||||
animations: [facetLoad]
|
||||
})
|
||||
|
||||
/**
|
||||
* Component that represents a boolean facet for a specific filter configuration
|
||||
*/
|
||||
@renderFacetFor(FilterType.boolean)
|
||||
export class SearchBooleanFilterComponent extends SearchFacetFilterComponent implements OnInit {
|
||||
}
|
@@ -0,0 +1 @@
|
||||
<ng-container *ngComponentOutlet="getSearchFilter(); injector: objectInjector;"></ng-container>
|
@@ -0,0 +1,48 @@
|
||||
import { Component, Injector, Input, OnInit } from '@angular/core';
|
||||
import { renderFilterType } from '../search-filter-type-decorator';
|
||||
import { FilterType } from '../../../search-service/filter-type.model';
|
||||
import { SearchFilterConfig } from '../../../search-service/search-filter-config.model';
|
||||
import { FILTER_CONFIG } from '../search-filter.service';
|
||||
|
||||
@Component({
|
||||
selector: 'ds-search-facet-filter-wrapper',
|
||||
templateUrl: './search-facet-filter-wrapper.component.html'
|
||||
})
|
||||
|
||||
/**
|
||||
* Wrapper component that renders a specific facet filter based on the filter config's type
|
||||
*/
|
||||
export class SearchFacetFilterWrapperComponent implements OnInit {
|
||||
/**
|
||||
* Configuration for the filter of this wrapper component
|
||||
*/
|
||||
@Input() filterConfig: SearchFilterConfig;
|
||||
|
||||
/**
|
||||
* Injector to inject a child component with the @Input parameters
|
||||
*/
|
||||
objectInjector: Injector;
|
||||
|
||||
constructor(private injector: Injector) {
|
||||
}
|
||||
|
||||
/**
|
||||
* Initialize and add the filter config to the injector
|
||||
*/
|
||||
ngOnInit(): void {
|
||||
this.objectInjector = Injector.create({
|
||||
providers: [
|
||||
{ provide: FILTER_CONFIG, useFactory: () => (this.filterConfig), deps: [] }
|
||||
],
|
||||
parent: this.injector
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Find the correct component based on the filter config's type
|
||||
*/
|
||||
getSearchFilter() {
|
||||
const type: FilterType = this.filterConfig.type;
|
||||
return renderFilterType(type);
|
||||
}
|
||||
}
|
@@ -1,38 +0,0 @@
|
||||
<div>
|
||||
<div class="filters">
|
||||
<a *ngFor="let value of selectedValues" class="d-block"
|
||||
[routerLink]="[getSearchLink()]"
|
||||
[queryParams]="getRemoveParams(value)" queryParamsHandling="merge">
|
||||
<input type="checkbox" [checked]="true"/>
|
||||
<span class="filter-value">{{value}}</span>
|
||||
</a>
|
||||
<ng-container *ngFor="let page of (filterValues$ | async)">
|
||||
<ng-container *ngFor="let value of (page | async)?.payload.page; let i=index">
|
||||
<a *ngIf="!selectedValues.includes(value.value)" class="d-block clearfix"
|
||||
[routerLink]="[getSearchLink()]"
|
||||
[queryParams]="getAddParams(value.value)" queryParamsHandling="merge" >
|
||||
<input type="checkbox" [checked]="false"/>
|
||||
<span class="filter-value">{{value.value}}</span>
|
||||
<span class="float-right filter-value-count">
|
||||
<span class="badge badge-secondary badge-pill">{{value.count}}</span>
|
||||
</span>
|
||||
</a>
|
||||
</ng-container>
|
||||
</ng-container>
|
||||
<div class="clearfix toggle-more-filters">
|
||||
<a class="float-left" *ngIf="!(isLastPage$ | async)"
|
||||
(click)="showMore()">{{"search.filters.filter.show-more"
|
||||
| translate}}</a>
|
||||
<a class="float-right" *ngIf="(currentPage | async) > 1"
|
||||
(click)="showFirstPageOnly()">{{"search.filters.filter.show-less"
|
||||
| translate}}</a>
|
||||
</div>
|
||||
</div>
|
||||
<form #form="ngForm" (ngSubmit)="onSubmit(form.value)" class="add-filter"
|
||||
[action]="getCurrentUrl()">
|
||||
<input type="text" [(ngModel)]="filter" [name]="filterConfig.paramName" class="form-control"
|
||||
aria-label="New filter input"
|
||||
[placeholder]="'search.filters.filter.' + filterConfig.name + '.placeholder'| translate" [ngModelOptions]="{standalone: true}"/>
|
||||
<input type="submit" class="d-none"/>
|
||||
</form>
|
||||
</div>
|
@@ -1,10 +1,8 @@
|
||||
import { ChangeDetectionStrategy, NO_ERRORS_SCHEMA } from '@angular/core';
|
||||
import { async, ComponentFixture, TestBed } from '@angular/core/testing';
|
||||
import { RouterTestingModule } from '@angular/router/testing';
|
||||
import { TranslateModule } from '@ngx-translate/core';
|
||||
import { NoopAnimationsModule } from '@angular/platform-browser/animations';
|
||||
import { SearchFacetFilterComponent } from './search-facet-filter.component';
|
||||
import { SearchFilterService } from '../search-filter.service';
|
||||
import { FILTER_CONFIG, SearchFilterService } from '../search-filter.service';
|
||||
import { SearchFilterConfig } from '../../../search-service/search-filter-config.model';
|
||||
import { FilterType } from '../../../search-service/filter-type.model';
|
||||
import { FacetValue } from '../../../search-service/facet-value.model';
|
||||
@@ -14,11 +12,12 @@ import { SearchService } from '../../../search-service/search.service';
|
||||
import { SearchServiceStub } from '../../../../shared/testing/search-service-stub';
|
||||
import { RemoteData } from '../../../../core/data/remote-data';
|
||||
import { PaginatedList } from '../../../../core/data/paginated-list';
|
||||
import { SearchOptions } from '../../../search-options.model';
|
||||
import { RouterStub } from '../../../../shared/testing/router-stub';
|
||||
import { Router } from '@angular/router';
|
||||
import { BehaviorSubject } from 'rxjs/BehaviorSubject';
|
||||
import { PageInfo } from '../../../../core/shared/page-info.model';
|
||||
import { SearchFacetFilterComponent } from './search-facet-filter.component';
|
||||
import { RemoteDataBuildService } from '../../../../core/cache/builders/remote-data-build.service';
|
||||
import { SearchConfigurationService } from '../../../search-service/search-configuration.service';
|
||||
|
||||
describe('SearchFacetFilterComponent', () => {
|
||||
let comp: SearchFacetFilterComponent;
|
||||
@@ -65,18 +64,21 @@ describe('SearchFacetFilterComponent', () => {
|
||||
providers: [
|
||||
{ provide: SearchService, useValue: new SearchServiceStub(searchLink) },
|
||||
{ provide: Router, useValue: new RouterStub() },
|
||||
{ provide: FILTER_CONFIG, useValue: new SearchFilterConfig() },
|
||||
{ provide: RemoteDataBuildService, useValue: {aggregate: () => Observable.of({})} },
|
||||
{ provide: SearchConfigurationService, useValue: {searchOptions: Observable.of({})} },
|
||||
{
|
||||
provide: SearchFilterService, useValue: {
|
||||
isFilterActiveWithValue: (paramName: string, filterValue: string) => true,
|
||||
getPage: (paramName: string) => page,
|
||||
/* tslint:disable:no-empty */
|
||||
incrementPage: (filterName: string) => {
|
||||
},
|
||||
resetPage: (filterName: string) => {
|
||||
},
|
||||
getSearchOptions: () => Observable.of({}),
|
||||
/* tslint:enable:no-empty */
|
||||
}
|
||||
getSelectedValuesForFilter: () => Observable.of(selectedValues),
|
||||
isFilterActiveWithValue: (paramName: string, filterValue: string) => true,
|
||||
getPage: (paramName: string) => page,
|
||||
/* tslint:disable:no-empty */
|
||||
incrementPage: (filterName: string) => {
|
||||
},
|
||||
resetPage: (filterName: string) => {
|
||||
}
|
||||
/* tslint:enable:no-empty */
|
||||
}
|
||||
}
|
||||
],
|
||||
schemas: [NO_ERRORS_SCHEMA]
|
||||
@@ -89,9 +91,6 @@ describe('SearchFacetFilterComponent', () => {
|
||||
fixture = TestBed.createComponent(SearchFacetFilterComponent);
|
||||
comp = fixture.componentInstance; // SearchPageComponent test instance
|
||||
comp.filterConfig = mockFilterConfig;
|
||||
comp.filterValues = [mockValues];
|
||||
comp.filterValues$ = new BehaviorSubject(comp.filterValues);
|
||||
comp.selectedValues = selectedValues;
|
||||
filterService = (comp as any).filterService;
|
||||
searchService = (comp as any).searchService;
|
||||
spyOn(searchService, 'getFacetValuesFor').and.returnValue(mockValues);
|
||||
@@ -124,14 +123,14 @@ describe('SearchFacetFilterComponent', () => {
|
||||
describe('when the getAddParams method is called wih a value', () => {
|
||||
it('should return the selectedValue list with the new parameter value', () => {
|
||||
const result = comp.getAddParams(value3);
|
||||
expect(result[mockFilterConfig.paramName]).toEqual([value1, value2, value3]);
|
||||
result.subscribe((r) => expect(r[mockFilterConfig.paramName]).toEqual([value1, value2, value3]));
|
||||
});
|
||||
});
|
||||
|
||||
describe('when the getRemoveParams method is called wih a value', () => {
|
||||
it('should return the selectedValue list with the parameter value left out', () => {
|
||||
const result = comp.getRemoveParams(value1);
|
||||
expect(result[mockFilterConfig.paramName]).toEqual([value2]);
|
||||
result.subscribe((r) => expect(r[mockFilterConfig.paramName]).toEqual([value2]));
|
||||
});
|
||||
});
|
||||
|
||||
@@ -169,7 +168,7 @@ describe('SearchFacetFilterComponent', () => {
|
||||
});
|
||||
|
||||
describe('when the getCurrentUrl method is called', () => {
|
||||
const url = 'test.url/test'
|
||||
const url = 'test.url/test';
|
||||
beforeEach(() => {
|
||||
router.navigateByUrl(url);
|
||||
});
|
||||
@@ -182,7 +181,7 @@ describe('SearchFacetFilterComponent', () => {
|
||||
describe('when the onSubmit method is called with data', () => {
|
||||
const searchUrl = '/search/path';
|
||||
const testValue = 'test';
|
||||
const data = { [mockFilterConfig.paramName]: testValue };
|
||||
const data = testValue;
|
||||
beforeEach(() => {
|
||||
spyOn(comp, 'getSearchLink').and.returnValue(searchUrl);
|
||||
comp.onSubmit(data);
|
||||
@@ -197,46 +196,26 @@ describe('SearchFacetFilterComponent', () => {
|
||||
});
|
||||
|
||||
describe('when updateFilterValueList is called', () => {
|
||||
const cPage = 10;
|
||||
const searchOptions = new SearchOptions();
|
||||
beforeEach(() => {
|
||||
// spyOn(searchService, 'getFacetValuesFor'); Already spied upon
|
||||
comp.currentPage = Observable.of(cPage);
|
||||
comp.updateFilterValueList(searchOptions);
|
||||
spyOn(comp, 'showFirstPageOnly');
|
||||
comp.updateFilterValueList()
|
||||
});
|
||||
|
||||
it('should call getFacetValuesFor on the searchService with the correct parameters', () => {
|
||||
expect(searchService.getFacetValuesFor).toHaveBeenCalledWith(mockFilterConfig, cPage, searchOptions);
|
||||
it('should call showFirstPageOnly and empty the filter', () => {
|
||||
expect(comp.animationState).toEqual('loading');
|
||||
expect((comp as any).collapseNextUpdate).toBeTruthy();
|
||||
expect(comp.filter).toEqual('');
|
||||
});
|
||||
});
|
||||
|
||||
describe('when updateFilterValueList is called and pageChange is set to true', () => {
|
||||
const searchOptions = new SearchOptions();
|
||||
describe('when findSuggestions is called with query \'test\'', () => {
|
||||
const query = 'test';
|
||||
beforeEach(() => {
|
||||
comp.pageChange = true;
|
||||
spyOn(comp, 'showFirstPageOnly');
|
||||
comp.updateFilterValueList(searchOptions);
|
||||
comp.findSuggestions(query);
|
||||
});
|
||||
|
||||
it('should not call showFirstPageOnly on the component', () => {
|
||||
expect(comp.showFirstPageOnly).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should set pageChange to false', () => {
|
||||
expect(comp.pageChange).toBeFalsy();
|
||||
});
|
||||
});
|
||||
|
||||
describe('when updateFilterValueList is called and pageChange is set to false', () => {
|
||||
const searchOptions = new SearchOptions();
|
||||
beforeEach(() => {
|
||||
comp.pageChange = false;
|
||||
spyOn(comp, 'showFirstPageOnly');
|
||||
comp.updateFilterValueList(searchOptions);
|
||||
});
|
||||
|
||||
it('should call showFirstPageOnly on the component', () => {
|
||||
expect(comp.showFirstPageOnly).toHaveBeenCalled();
|
||||
it('should call getFacetValuesFor on the component\'s SearchService with the right query', () => {
|
||||
expect((comp as any).searchService.getFacetValuesFor).toHaveBeenCalledWith(comp.filterConfig, 1, {}, query);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
@@ -1,125 +1,289 @@
|
||||
import { Component, Input, OnDestroy, OnInit } from '@angular/core';
|
||||
import { animate, state, style, transition, trigger } from '@angular/animations';
|
||||
import { Component, Inject, OnDestroy, OnInit } from '@angular/core';
|
||||
import { Router } from '@angular/router';
|
||||
import { BehaviorSubject } from 'rxjs/BehaviorSubject';
|
||||
import { Observable } from 'rxjs/Observable';
|
||||
import { Subject } from 'rxjs/Subject';
|
||||
import { Subscription } from 'rxjs/Subscription';
|
||||
import { RemoteDataBuildService } from '../../../../core/cache/builders/remote-data-build.service';
|
||||
import { PaginatedList } from '../../../../core/data/paginated-list';
|
||||
import { RemoteData } from '../../../../core/data/remote-data';
|
||||
import { hasNoValue, hasValue, isNotEmpty } from '../../../../shared/empty.util';
|
||||
import { EmphasizePipe } from '../../../../shared/utils/emphasize.pipe';
|
||||
import { SearchOptions } from '../../../search-options.model';
|
||||
import { FacetValue } from '../../../search-service/facet-value.model';
|
||||
import { SearchFilterConfig } from '../../../search-service/search-filter-config.model';
|
||||
import { Router } from '@angular/router';
|
||||
import { Observable } from 'rxjs/Observable';
|
||||
import { SearchFilterService } from '../search-filter.service';
|
||||
import { hasNoValue, hasValue, isNotEmpty } from '../../../../shared/empty.util';
|
||||
import { RemoteData } from '../../../../core/data/remote-data';
|
||||
import { PaginatedList } from '../../../../core/data/paginated-list';
|
||||
import { SearchService } from '../../../search-service/search.service';
|
||||
import { SearchOptions } from '../../../search-options.model';
|
||||
import { BehaviorSubject } from 'rxjs/BehaviorSubject';
|
||||
import { Subscription } from 'rxjs/Subscription';
|
||||
|
||||
/**
|
||||
* This component renders a simple item page.
|
||||
* The route parameter 'id' is used to request the item it represents.
|
||||
* All fields of the item that should be displayed, are defined in its template.
|
||||
*/
|
||||
import { FILTER_CONFIG, SearchFilterService } from '../search-filter.service';
|
||||
import { SearchConfigurationService } from '../../../search-service/search-configuration.service';
|
||||
import { getSucceededRemoteData } from '../../../../core/shared/operators';
|
||||
import { map } from 'rxjs/operators';
|
||||
|
||||
@Component({
|
||||
selector: 'ds-search-facet-filter',
|
||||
styleUrls: ['./search-facet-filter.component.scss'],
|
||||
templateUrl: './search-facet-filter.component.html'
|
||||
template: ``,
|
||||
})
|
||||
|
||||
/**
|
||||
* Super class for all different representations of facets
|
||||
*/
|
||||
export class SearchFacetFilterComponent implements OnInit, OnDestroy {
|
||||
@Input() filterConfig: SearchFilterConfig;
|
||||
@Input() selectedValues: string[];
|
||||
filterValues: Array<Observable<RemoteData<PaginatedList<FacetValue>>>> = [];
|
||||
filterValues$: BehaviorSubject<any> = new BehaviorSubject(this.filterValues);
|
||||
/**
|
||||
* Emits an array of pages with values found for this facet
|
||||
*/
|
||||
filterValues$: Subject<RemoteData<Array<PaginatedList<FacetValue>>>>;
|
||||
|
||||
/**
|
||||
* Emits the current last shown page of this facet's values
|
||||
*/
|
||||
currentPage: Observable<number>;
|
||||
|
||||
/**
|
||||
* Emits true if the current page is also the last page available
|
||||
*/
|
||||
isLastPage$: BehaviorSubject<boolean> = new BehaviorSubject(false);
|
||||
|
||||
/**
|
||||
* The value of the input field that is used to query for possible values for this filter
|
||||
*/
|
||||
filter: string;
|
||||
pageChange = false;
|
||||
sub: Subscription;
|
||||
|
||||
constructor(private searchService: SearchService, private filterService: SearchFilterService, private router: Router) {
|
||||
/**
|
||||
* List of subscriptions to unsubscribe from
|
||||
*/
|
||||
private subs: Subscription[] = [];
|
||||
|
||||
/**
|
||||
* Emits the result values for this filter found by the current filter query
|
||||
*/
|
||||
filterSearchResults: Observable<any[]> = Observable.of([]);
|
||||
|
||||
/**
|
||||
* Emits the active values for this filter
|
||||
*/
|
||||
selectedValues: Observable<string[]>;
|
||||
private collapseNextUpdate = true;
|
||||
|
||||
/**
|
||||
* State of the requested facets used to time the animation
|
||||
*/
|
||||
animationState = 'loading';
|
||||
|
||||
constructor(protected searchService: SearchService,
|
||||
protected filterService: SearchFilterService,
|
||||
protected searchConfigService: SearchConfigurationService,
|
||||
protected rdbs: RemoteDataBuildService,
|
||||
protected router: Router,
|
||||
@Inject(FILTER_CONFIG) public filterConfig: SearchFilterConfig) {
|
||||
}
|
||||
|
||||
/**
|
||||
* Initializes all observable instance variables and starts listening to them
|
||||
*/
|
||||
ngOnInit(): void {
|
||||
this.currentPage = this.getCurrentPage();
|
||||
this.currentPage.distinctUntilChanged().subscribe((page) => this.pageChange = true);
|
||||
this.filterService.getSearchOptions().distinctUntilChanged().subscribe((options) => this.updateFilterValueList(options));
|
||||
}
|
||||
|
||||
updateFilterValueList(options: SearchOptions) {
|
||||
if (!this.pageChange) {
|
||||
this.showFirstPageOnly();
|
||||
}
|
||||
this.pageChange = false;
|
||||
|
||||
this.unsubscribe();
|
||||
this.sub = this.currentPage.distinctUntilChanged().map((page) => {
|
||||
return this.searchService.getFacetValuesFor(this.filterConfig, page, options);
|
||||
}).subscribe((newValues$) => {
|
||||
this.filterValues = [...this.filterValues, newValues$];
|
||||
this.filterValues$.next(this.filterValues);
|
||||
newValues$.first().subscribe((rd) => this.isLastPage$.next(hasNoValue(rd.payload.next)));
|
||||
this.filterValues$ = new BehaviorSubject(new RemoteData(true, false, undefined, undefined, undefined));
|
||||
this.currentPage = this.getCurrentPage().distinctUntilChanged();
|
||||
this.selectedValues = this.filterService.getSelectedValuesForFilter(this.filterConfig);
|
||||
const searchOptions = this.searchConfigService.searchOptions;
|
||||
this.subs.push(this.searchConfigService.searchOptions.subscribe(() => this.updateFilterValueList()));
|
||||
const facetValues = Observable.combineLatest(searchOptions, this.currentPage, (options, page) => {
|
||||
return { options, page }
|
||||
}).switchMap(({ options, page }) => {
|
||||
return this.searchService.getFacetValuesFor(this.filterConfig, page, options)
|
||||
.pipe(
|
||||
getSucceededRemoteData(),
|
||||
map((results) => {
|
||||
return {
|
||||
values: Observable.of(results),
|
||||
page: page
|
||||
};
|
||||
}
|
||||
)
|
||||
)
|
||||
});
|
||||
let filterValues = [];
|
||||
this.subs.push(facetValues.subscribe((facetOutcome) => {
|
||||
const newValues$ = facetOutcome.values;
|
||||
|
||||
if (this.collapseNextUpdate) {
|
||||
this.showFirstPageOnly();
|
||||
facetOutcome.page = 1;
|
||||
this.collapseNextUpdate = false;
|
||||
}
|
||||
if (facetOutcome.page === 1) {
|
||||
filterValues = [];
|
||||
}
|
||||
|
||||
filterValues = [...filterValues, newValues$];
|
||||
|
||||
this.subs.push(this.rdbs.aggregate(filterValues).subscribe((rd: RemoteData<Array<PaginatedList<FacetValue>>>) => {
|
||||
this.animationState = 'ready';
|
||||
this.filterValues$.next(rd);
|
||||
}));
|
||||
this.subs.push(newValues$.first().subscribe((rd) => {
|
||||
this.isLastPage$.next(hasNoValue(rd.payload.next))
|
||||
}));
|
||||
}));
|
||||
|
||||
}
|
||||
|
||||
/**
|
||||
* Prepare for refreshing the values of this filter
|
||||
*/
|
||||
updateFilterValueList() {
|
||||
this.animationState = 'loading';
|
||||
this.collapseNextUpdate = true;
|
||||
this.filter = '';
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if a value for this filter is currently active
|
||||
*/
|
||||
isChecked(value: FacetValue): Observable<boolean> {
|
||||
return this.filterService.isFilterActiveWithValue(this.filterConfig.paramName, value.value);
|
||||
}
|
||||
|
||||
/**
|
||||
* @returns {string} The base path to the search page
|
||||
*/
|
||||
getSearchLink() {
|
||||
return this.searchService.getSearchLink();
|
||||
}
|
||||
|
||||
/**
|
||||
* Show the next page as well
|
||||
*/
|
||||
showMore() {
|
||||
this.filterService.incrementPage(this.filterConfig.name);
|
||||
}
|
||||
|
||||
/**
|
||||
* Make sure only the first page is shown
|
||||
*/
|
||||
showFirstPageOnly() {
|
||||
this.filterValues = [];
|
||||
this.filterService.resetPage(this.filterConfig.name);
|
||||
}
|
||||
|
||||
/**
|
||||
* @returns {Observable<number>} The current page of this filter
|
||||
*/
|
||||
getCurrentPage(): Observable<number> {
|
||||
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
|
||||
* @param data The string from the input field
|
||||
*/
|
||||
onSubmit(data: any) {
|
||||
if (isNotEmpty(data)) {
|
||||
this.router.navigate([this.getSearchLink()], {
|
||||
queryParams:
|
||||
{ [this.filterConfig.paramName]: [...this.selectedValues, data[this.filterConfig.paramName]] },
|
||||
queryParamsHandling: 'merge'
|
||||
});
|
||||
this.filter = '';
|
||||
}
|
||||
this.selectedValues.first().subscribe((selectedValues) => {
|
||||
if (isNotEmpty(data)) {
|
||||
this.router.navigate([this.getSearchLink()], {
|
||||
queryParams:
|
||||
{ [this.filterConfig.paramName]: [...selectedValues, data] },
|
||||
queryParamsHandling: 'merge'
|
||||
});
|
||||
this.filter = '';
|
||||
}
|
||||
this.filterSearchResults = Observable.of([]);
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
onClick(data: any) {
|
||||
this.filter = data;
|
||||
}
|
||||
|
||||
/**
|
||||
* For usage of the hasValue function in the template
|
||||
*/
|
||||
hasValue(o: any): boolean {
|
||||
return hasValue(o);
|
||||
}
|
||||
getRemoveParams(value: string) {
|
||||
return {
|
||||
[this.filterConfig.paramName]: this.selectedValues.filter((v) => v !== value),
|
||||
page: 1
|
||||
};
|
||||
|
||||
/**
|
||||
* Calculates the parameters that should change if a given value for this filter would be removed from the active filters
|
||||
* @param {string} value The value that is removed for this filter
|
||||
* @returns {Observable<any>} The changed filter parameters
|
||||
*/
|
||||
getRemoveParams(value: string): Observable<any> {
|
||||
return this.selectedValues.map((selectedValues) => {
|
||||
return {
|
||||
[this.filterConfig.paramName]: selectedValues.filter((v) => v !== value),
|
||||
page: 1
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
getAddParams(value: string) {
|
||||
return {
|
||||
[this.filterConfig.paramName]: [...this.selectedValues, value],
|
||||
page: 1
|
||||
};
|
||||
/**
|
||||
* Calculates the parameters that should change if a given value for this filter would be added to the active filters
|
||||
* @param {string} value The value that is added for this filter
|
||||
* @returns {Observable<any>} The changed filter parameters
|
||||
*/
|
||||
getAddParams(value: string): Observable<any> {
|
||||
return this.selectedValues.map((selectedValues) => {
|
||||
return {
|
||||
[this.filterConfig.paramName]: [...selectedValues, value],
|
||||
page: 1
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Unsubscribe from all subscriptions
|
||||
*/
|
||||
ngOnDestroy(): void {
|
||||
this.unsubscribe();
|
||||
this.subs
|
||||
.filter((sub) => hasValue(sub))
|
||||
.forEach((sub) => sub.unsubscribe());
|
||||
}
|
||||
|
||||
unsubscribe(): void {
|
||||
if (hasValue(this.sub)) {
|
||||
this.sub.unsubscribe();
|
||||
/**
|
||||
* Updates the found facet value suggestions for a given query
|
||||
* Transforms the found values into display values
|
||||
* @param data The query for which is being searched
|
||||
*/
|
||||
findSuggestions(data): void {
|
||||
if (isNotEmpty(data)) {
|
||||
this.searchConfigService.searchOptions.first().subscribe(
|
||||
(options) => {
|
||||
this.filterSearchResults = this.searchService.getFacetValuesFor(this.filterConfig, 1, options, data.toLowerCase())
|
||||
.pipe(
|
||||
getSucceededRemoteData(),
|
||||
map(
|
||||
(rd: RemoteData<PaginatedList<FacetValue>>) => {
|
||||
return rd.payload.page.map((facet) => {
|
||||
return { displayValue: this.getDisplayValue(facet, data), value: facet.value }
|
||||
})
|
||||
}
|
||||
))
|
||||
}
|
||||
)
|
||||
} else {
|
||||
this.filterSearchResults = Observable.of([]);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Transforms the facet value string, so if the query matches part of the value, it's emphasized in the value
|
||||
* @param {FacetValue} facet The value of the facet as returned by the server
|
||||
* @param {string} query The query that was used to search facet values
|
||||
* @returns {string} The facet value with the query part emphasized
|
||||
*/
|
||||
getDisplayValue(facet: FacetValue, query: string): string {
|
||||
return new EmphasizePipe().transform(facet.value, query) + ' (' + facet.count + ')';
|
||||
}
|
||||
}
|
||||
|
||||
export const facetLoad = trigger('facetLoad', [
|
||||
state('ready', style({ opacity: 1 })),
|
||||
state('loading', style({ opacity: 0 })),
|
||||
transition('loading <=> ready', animate(100)),
|
||||
]);
|
||||
|
@@ -0,0 +1,30 @@
|
||||
|
||||
import { FilterType } from '../../search-service/filter-type.model';
|
||||
|
||||
/**
|
||||
* Contains the mapping between a facet component and a FilterType
|
||||
*/
|
||||
const filterTypeMap = new Map();
|
||||
|
||||
/**
|
||||
* Sets the mapping for a facet component in relation to a filter type
|
||||
* @param {FilterType} type The type for which the matching component is mapped
|
||||
* @returns Decorator function that performs the actual mapping on initialization of the facet component
|
||||
*/
|
||||
export function renderFacetFor(type: FilterType) {
|
||||
return function decorator(objectElement: any) {
|
||||
if (!objectElement) {
|
||||
return;
|
||||
}
|
||||
filterTypeMap.set(type, objectElement);
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Requests the matching facet component based on a given filter type
|
||||
* @param {FilterType} type The filter type for which the facet component is requested
|
||||
* @returns The facet component's constructor that matches the given filter type
|
||||
*/
|
||||
export function renderFilterType(type: FilterType) {
|
||||
return filterTypeMap.get(type);
|
||||
}
|
@@ -22,41 +22,78 @@ export const SearchFilterActionTypes = {
|
||||
};
|
||||
|
||||
export class SearchFilterAction implements Action {
|
||||
/**
|
||||
* Name of the filter the action is performed on, used to identify the filter
|
||||
*/
|
||||
filterName: string;
|
||||
|
||||
/**
|
||||
* Type of action that will be performed
|
||||
*/
|
||||
type;
|
||||
|
||||
/**
|
||||
* Initialize with the filter's name
|
||||
* @param {string} name of the filter
|
||||
*/
|
||||
constructor(name: string) {
|
||||
this.filterName = name;
|
||||
}
|
||||
}
|
||||
|
||||
/* tslint:disable:max-classes-per-file */
|
||||
/**
|
||||
* Used to collapse a filter
|
||||
*/
|
||||
export class SearchFilterCollapseAction extends SearchFilterAction {
|
||||
type = SearchFilterActionTypes.COLLAPSE;
|
||||
}
|
||||
|
||||
/**
|
||||
* Used to expand a filter
|
||||
*/
|
||||
export class SearchFilterExpandAction extends SearchFilterAction {
|
||||
type = SearchFilterActionTypes.EXPAND;
|
||||
}
|
||||
|
||||
/**
|
||||
* Used to collapse a filter when it's expanded and expand it when it's collapsed
|
||||
*/
|
||||
export class SearchFilterToggleAction extends SearchFilterAction {
|
||||
type = SearchFilterActionTypes.TOGGLE;
|
||||
}
|
||||
|
||||
/**
|
||||
* Used to set the initial state of a filter to collapsed
|
||||
*/
|
||||
export class SearchFilterInitialCollapseAction extends SearchFilterAction {
|
||||
type = SearchFilterActionTypes.INITIAL_COLLAPSE;
|
||||
}
|
||||
|
||||
/**
|
||||
* Used to set the initial state of a filter to expanded
|
||||
*/
|
||||
export class SearchFilterInitialExpandAction extends SearchFilterAction {
|
||||
type = SearchFilterActionTypes.INITIAL_EXPAND;
|
||||
}
|
||||
|
||||
/**
|
||||
* Used to set the state of a filter to the previous page
|
||||
*/
|
||||
export class SearchFilterDecrementPageAction extends SearchFilterAction {
|
||||
type = SearchFilterActionTypes.DECREMENT_PAGE;
|
||||
}
|
||||
|
||||
/**
|
||||
* Used to set the state of a filter to the next page
|
||||
*/
|
||||
export class SearchFilterIncrementPageAction extends SearchFilterAction {
|
||||
type = SearchFilterActionTypes.INCREMENT_PAGE;
|
||||
}
|
||||
|
||||
/**
|
||||
* Used to set the state of a filter to the first page
|
||||
*/
|
||||
export class SearchFilterResetPageAction extends SearchFilterAction {
|
||||
type = SearchFilterActionTypes.RESET_PAGE;
|
||||
}
|
||||
|
@@ -1,7 +1,7 @@
|
||||
<div>
|
||||
<div (click)="toggle()" class="filter-name"><h5 class="d-inline-block mb-0">{{'search.filters.filter.' + filter.name + '.head'| translate}}</h5> <span class="filter-toggle fa float-right"
|
||||
[ngClass]="(isCollapsed() | async) ? 'fa-plus' : 'fa-minus'"></span></div>
|
||||
<div [@slide]="(isCollapsed() | async) ? 'collapsed' : 'expanded'" class="search-filter-wrapper">
|
||||
<ds-search-facet-filter [filterConfig]="filter" [selectedValues]="getSelectedValues() | async"></ds-search-facet-filter>
|
||||
<div [@slide]="(isCollapsed() | async) ? 'collapsed' : 'expanded'" (@slide.start)="startSlide($event)" (@slide.done)="finishSlide($event)" class="search-filter-wrapper" [ngClass]="{'closed' : collapsed}">
|
||||
<ds-search-facet-filter-wrapper [filterConfig]="filter"></ds-search-facet-filter-wrapper>
|
||||
</div>
|
||||
</div>
|
@@ -3,7 +3,7 @@
|
||||
|
||||
:host {
|
||||
border: 1px solid map-get($theme-colors, light);
|
||||
.search-filter-wrapper {
|
||||
.search-filter-wrapper.closed {
|
||||
overflow: hidden;
|
||||
}
|
||||
.filter-toggle {
|
||||
|
@@ -1,18 +1,9 @@
|
||||
import { Component, Input, OnInit } from '@angular/core';
|
||||
import { SearchFilterConfig } from '../../search-service/search-filter-config.model';
|
||||
import { SearchService } from '../../search-service/search.service';
|
||||
import { RemoteData } from '../../../core/data/remote-data';
|
||||
import { FacetValue } from '../../search-service/facet-value.model';
|
||||
import { SearchFilterService } from './search-filter.service';
|
||||
import { Observable } from 'rxjs/Observable';
|
||||
import { slide } from '../../../shared/animations/slide';
|
||||
import { PaginatedList } from '../../../core/data/paginated-list';
|
||||
|
||||
/**
|
||||
* This component renders a simple item page.
|
||||
* The route parameter 'id' is used to request the item it represents.
|
||||
* All fields of the item that should be displayed, are defined in its template.
|
||||
*/
|
||||
import { isNotEmpty } from '../../../shared/empty.util';
|
||||
|
||||
@Component({
|
||||
selector: 'ds-search-filter',
|
||||
@@ -21,15 +12,31 @@ import { PaginatedList } from '../../../core/data/paginated-list';
|
||||
animations: [slide],
|
||||
})
|
||||
|
||||
/**
|
||||
* Represents a part of the filter section for a single type of filter
|
||||
*/
|
||||
export class SearchFilterComponent implements OnInit {
|
||||
/**
|
||||
* The filter config for this component
|
||||
*/
|
||||
@Input() filter: SearchFilterConfig;
|
||||
|
||||
/**
|
||||
* True when the filter is 100% collapsed in the UI
|
||||
*/
|
||||
collapsed;
|
||||
|
||||
constructor(private filterService: SearchFilterService) {
|
||||
}
|
||||
|
||||
/**
|
||||
* Requests the current set values for this filter
|
||||
* If the filter config is open by default OR the filter has at least one value, the filter should be initially expanded
|
||||
* Else, the filter should initially be collapsed
|
||||
*/
|
||||
ngOnInit() {
|
||||
this.filterService.isFilterActive(this.filter.paramName).first().subscribe((isActive) => {
|
||||
if (this.filter.isOpenByDefault || isActive) {
|
||||
this.getSelectedValues().first().subscribe((isActive) => {
|
||||
if (this.filter.isOpenByDefault || isNotEmpty(isActive)) {
|
||||
this.initialExpand();
|
||||
} else {
|
||||
this.initialCollapse();
|
||||
@@ -37,23 +44,61 @@ export class SearchFilterComponent implements OnInit {
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Changes the state for this filter to collapsed when it's expanded and to expanded it when it's collapsed
|
||||
*/
|
||||
toggle() {
|
||||
this.filterService.toggle(this.filter.name);
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if the filter is currently collapsed
|
||||
* @returns {Observable<boolean>} Emits true when the current state of the filter is collapsed, false when it's expanded
|
||||
*/
|
||||
isCollapsed(): Observable<boolean> {
|
||||
return this.filterService.isCollapsed(this.filter.name);
|
||||
}
|
||||
|
||||
/**
|
||||
* Changes the initial state to collapsed
|
||||
*/
|
||||
initialCollapse() {
|
||||
this.filterService.initialCollapse(this.filter.name);
|
||||
this.collapsed = true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Changes the initial state to expanded
|
||||
*/
|
||||
initialExpand() {
|
||||
this.filterService.initialExpand(this.filter.name);
|
||||
this.collapsed = false;
|
||||
}
|
||||
|
||||
/**
|
||||
* @returns {Observable<string[]>} Emits a list of all values that are currently active for this filter
|
||||
*/
|
||||
getSelectedValues(): Observable<string[]> {
|
||||
return this.filterService.getSelectedValuesForFilter(this.filter);
|
||||
}
|
||||
|
||||
/**
|
||||
* Method to change this.collapsed to false when the slide animation ends and is sliding open
|
||||
* @param event The animation event
|
||||
*/
|
||||
finishSlide(event: any): void {
|
||||
if (event.fromState === 'collapsed') {
|
||||
this.collapsed = false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Method to change this.collapsed to true when the slide animation starts and is sliding closed
|
||||
* @param event The animation event
|
||||
*/
|
||||
startSlide(event: any): void {
|
||||
if (event.toState === 'collapsed') {
|
||||
this.collapsed = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@@ -1,17 +1,29 @@
|
||||
import { SearchFilterAction, SearchFilterActionTypes } from './search-filter.actions';
|
||||
import { isEmpty } from '../../../shared/empty.util';
|
||||
|
||||
/**
|
||||
* Interface that represents the state for a single filters
|
||||
*/
|
||||
export interface SearchFilterState {
|
||||
filterCollapsed: boolean,
|
||||
page: number
|
||||
}
|
||||
|
||||
/**
|
||||
* Interface that represents the state for all available filters
|
||||
*/
|
||||
export interface SearchFiltersState {
|
||||
[name: string]: SearchFilterState
|
||||
}
|
||||
|
||||
const initialState: SearchFiltersState = Object.create(null);
|
||||
|
||||
/**
|
||||
* Performs a search filter action on the current state
|
||||
* @param {SearchFiltersState} state The state before the action is performed
|
||||
* @param {SearchFilterAction} action The action that should be performed
|
||||
* @returns {SearchFiltersState} The state after the action is performed
|
||||
*/
|
||||
export function filterReducer(state = initialState, action: SearchFilterAction): SearchFiltersState {
|
||||
|
||||
switch (action.type) {
|
||||
|
@@ -11,6 +11,7 @@ import { SearchFiltersState } from './search-filter.reducer';
|
||||
import { SearchFilterConfig } from '../../search-service/search-filter-config.model';
|
||||
import { FilterType } from '../../search-service/filter-type.model';
|
||||
import { SearchFixedFilterService } from './search-fixed-filter.service';
|
||||
import { ActivatedRouteStub } from '../../../shared/testing/active-router-stub';
|
||||
|
||||
describe('SearchFilterService', () => {
|
||||
let service: SearchFilterService;
|
||||
@@ -48,10 +49,14 @@ describe('SearchFilterService', () => {
|
||||
addQueryParameterValue: (param: string, value: string) => {
|
||||
},
|
||||
getQueryParameterValues: (param: string) => {
|
||||
return Observable.of({});
|
||||
},
|
||||
getQueryParamsWithPrefix: (param: string) => {
|
||||
return Observable.of({});
|
||||
}
|
||||
/* tslint:enable:no-empty */
|
||||
};
|
||||
|
||||
const activatedRoute: any = new ActivatedRouteStub();
|
||||
const searchServiceStub: any = {
|
||||
uiSearchRoute: '/search'
|
||||
};
|
||||
|
@@ -1,29 +1,35 @@
|
||||
import { Injectable } from '@angular/core';
|
||||
import { debounceTime, distinctUntilChanged, map } from 'rxjs/operators';
|
||||
import { Injectable, InjectionToken } from '@angular/core';
|
||||
import { distinctUntilChanged, map } from 'rxjs/operators';
|
||||
import { SearchFiltersState, SearchFilterState } from './search-filter.reducer';
|
||||
import { createSelector, MemoizedSelector, Store } from '@ngrx/store';
|
||||
import { Observable } from 'rxjs/Observable';
|
||||
import {
|
||||
SearchFilterCollapseAction,
|
||||
SearchFilterDecrementPageAction, SearchFilterExpandAction,
|
||||
SearchFilterDecrementPageAction,
|
||||
SearchFilterExpandAction,
|
||||
SearchFilterIncrementPageAction,
|
||||
SearchFilterInitialCollapseAction,
|
||||
SearchFilterInitialExpandAction, SearchFilterResetPageAction,
|
||||
SearchFilterInitialExpandAction,
|
||||
SearchFilterResetPageAction,
|
||||
SearchFilterToggleAction
|
||||
} from './search-filter.actions';
|
||||
import { hasValue, isEmpty, isNotEmpty, } from '../../../shared/empty.util';
|
||||
import { SearchFilterConfig } from '../../search-service/search-filter-config.model';
|
||||
import { SearchService } from '../../search-service/search.service';
|
||||
import { RouteService } from '../../../shared/route.service';
|
||||
import ObjectExpression from 'rollup/dist/typings/ast/nodes/ObjectExpression';
|
||||
import { RouteService } from '../../../shared/services/route.service';
|
||||
import { SortDirection, SortOptions } from '../../../core/cache/models/sort-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 { ActivatedRoute, Params } from '@angular/router';
|
||||
import { SearchFixedFilterService } from './search-fixed-filter.service';
|
||||
|
||||
const filterStateSelector = (state: SearchFiltersState) => state.searchFilter;
|
||||
|
||||
export const FILTER_CONFIG: InjectionToken<SearchFilterConfig> = new InjectionToken<SearchFilterConfig>('filterConfig');
|
||||
|
||||
/**
|
||||
* Service that performs all actions that have to do with search filters and facets
|
||||
*/
|
||||
@Injectable()
|
||||
export class SearchFilterService {
|
||||
|
||||
@@ -32,10 +38,21 @@ export class SearchFilterService {
|
||||
private fixedFilterService: SearchFixedFilterService) {
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if a given filter is active with a given value
|
||||
* @param {string} paramName The parameter name of the filter's configuration for which to search
|
||||
* @param {string} filterValue The value for which to search
|
||||
* @returns {Observable<boolean>} Emit true when the filter is active with the given value
|
||||
*/
|
||||
isFilterActiveWithValue(paramName: string, filterValue: string): Observable<boolean> {
|
||||
return this.routeService.hasQueryParamWithValue(paramName, filterValue);
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if a given filter is active with any value
|
||||
* @param {string} paramName The parameter name of the filter's configuration for which to search
|
||||
* @returns {Observable<boolean>} Emit true when the filter is active with any value
|
||||
*/
|
||||
isFilterActive(paramName: string): Observable<boolean> {
|
||||
return this.routeService.hasQueryParam(paramName);
|
||||
}
|
||||
@@ -94,8 +111,7 @@ export class SearchFilterService {
|
||||
this.getCurrentFixedFilter()).pipe(
|
||||
distinctUntilChanged((a, b) => JSON.stringify(a) === JSON.stringify(b)),
|
||||
map(([pagination, sort, view, scope, query, filters, fixedFilter]) => {
|
||||
return Object.assign(new PaginatedSearchOptions(),
|
||||
defaults,
|
||||
return Object.assign(new PaginatedSearchOptions(defaults),
|
||||
{
|
||||
pagination: pagination,
|
||||
sort: sort,
|
||||
@@ -117,8 +133,7 @@ export class SearchFilterService {
|
||||
this.getCurrentFilters(),
|
||||
this.getCurrentFixedFilter(),
|
||||
(view, scope, query, filters, fixedFilter) => {
|
||||
return Object.assign(new SearchOptions(),
|
||||
defaults,
|
||||
return Object.assign(new SearchOptions(defaults),
|
||||
{
|
||||
view: view,
|
||||
scope: scope || defaults.scope,
|
||||
@@ -130,10 +145,27 @@ export class SearchFilterService {
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Requests the active filter values set for a given filter
|
||||
* @param {SearchFilterConfig} filterConfig The configuration for which the filters are active
|
||||
* @returns {Observable<string[]>} Emits the active filters for the given filter configuration
|
||||
*/
|
||||
getSelectedValuesForFilter(filterConfig: SearchFilterConfig): Observable<string[]> {
|
||||
return this.routeService.getQueryParameterValues(filterConfig.paramName);
|
||||
const values$ = this.routeService.getQueryParameterValues(filterConfig.paramName);
|
||||
const prefixValues$ = this.routeService.getQueryParamsWithPrefix(filterConfig.paramName + '.').map((params: Params) => [].concat(...Object.values(params)));
|
||||
return Observable.combineLatest(values$, prefixValues$, (values, prefixValues) => {
|
||||
if (isNotEmpty(values)) {
|
||||
return values;
|
||||
}
|
||||
return prefixValues;
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if the state of a given filter is currently collapsed or not
|
||||
* @param {string} filterName The filtername for which the collapsed state is checked
|
||||
* @returns {Observable<boolean>} Emits the current collapsed state of the given filter, if it's unavailable, return false
|
||||
*/
|
||||
isCollapsed(filterName: string): Observable<boolean> {
|
||||
return this.store.select(filterByNameSelector(filterName))
|
||||
.map((object: SearchFilterState) => {
|
||||
@@ -145,6 +177,11 @@ export class SearchFilterService {
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Request the current page of a given filter
|
||||
* @param {string} filterName The filtername for which the page state is checked
|
||||
* @returns {Observable<boolean>} Emits the current page state of the given filter, if it's unavailable, return 1
|
||||
*/
|
||||
getPage(filterName: string): Observable<number> {
|
||||
return this.store.select(filterByNameSelector(filterName))
|
||||
.map((object: SearchFilterState) => {
|
||||
@@ -156,34 +193,65 @@ export class SearchFilterService {
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Dispatches a collapse action to the store for a given filter
|
||||
* @param {string} filterName The filter for which the action is dispatched
|
||||
*/
|
||||
public collapse(filterName: string): void {
|
||||
this.store.dispatch(new SearchFilterCollapseAction(filterName));
|
||||
}
|
||||
|
||||
/**
|
||||
* Dispatches an expand action to the store for a given filter
|
||||
* @param {string} filterName The filter for which the action is dispatched
|
||||
*/
|
||||
public expand(filterName: string): void {
|
||||
this.store.dispatch(new SearchFilterExpandAction(filterName));
|
||||
}
|
||||
|
||||
/**
|
||||
* Dispatches a toggle action to the store for a given filter
|
||||
* @param {string} filterName The filter for which the action is dispatched
|
||||
*/
|
||||
public toggle(filterName: string): void {
|
||||
this.store.dispatch(new SearchFilterToggleAction(filterName));
|
||||
}
|
||||
|
||||
/**
|
||||
* Dispatches an initial collapse action to the store for a given filter
|
||||
* @param {string} filterName The filter for which the action is dispatched
|
||||
*/
|
||||
public initialCollapse(filterName: string): void {
|
||||
this.store.dispatch(new SearchFilterInitialCollapseAction(filterName));
|
||||
}
|
||||
|
||||
/**
|
||||
* Dispatches an initial expand action to the store for a given filter
|
||||
* @param {string} filterName The filter for which the action is dispatched
|
||||
*/
|
||||
public initialExpand(filterName: string): void {
|
||||
this.store.dispatch(new SearchFilterInitialExpandAction(filterName));
|
||||
}
|
||||
|
||||
/**
|
||||
* Dispatches a decrement action to the store for a given filter
|
||||
* @param {string} filterName The filter for which the action is dispatched
|
||||
*/
|
||||
public decrementPage(filterName: string): void {
|
||||
this.store.dispatch(new SearchFilterDecrementPageAction(filterName));
|
||||
}
|
||||
|
||||
/**
|
||||
* Dispatches an increment page action to the store for a given filter
|
||||
* @param {string} filterName The filter for which the action is dispatched
|
||||
*/
|
||||
public incrementPage(filterName: string): void {
|
||||
this.store.dispatch(new SearchFilterIncrementPageAction(filterName));
|
||||
}
|
||||
|
||||
/**
|
||||
* Dispatches a reset page action to the store for a given filter
|
||||
* @param {string} filterName The filter for which the action is dispatched
|
||||
*/
|
||||
public resetPage(filterName: string): void {
|
||||
this.store.dispatch(new SearchFilterResetPageAction(filterName));
|
||||
}
|
||||
|
@@ -1,7 +1,6 @@
|
||||
import { Injectable } from '@angular/core';
|
||||
import { flatMap, map } from 'rxjs/operators';
|
||||
import { Observable } from 'rxjs/Observable';
|
||||
import { RouteService } from '../../../shared/route.service';
|
||||
import { HALEndpointService } from '../../../core/shared/hal-endpoint.service';
|
||||
import { GetRequest, RestRequest } from '../../../core/data/request.models';
|
||||
import { FilteredDiscoveryQueryResponse } from '../../../core/cache/response-cache.models';
|
||||
@@ -13,6 +12,7 @@ import { GenericConstructor } from '../../../core/shared/generic-constructor';
|
||||
import { FilteredDiscoveryPageResponseParsingService } from '../../../core/data/filtered-discovery-page-response-parsing.service';
|
||||
import { hasValue } from '../../../shared/empty.util';
|
||||
import { configureRequest } from '../../../core/shared/operators';
|
||||
import { RouteService } from '../../../shared/services/route.service';
|
||||
|
||||
@Injectable()
|
||||
export class SearchFixedFilterService {
|
||||
|
@@ -0,0 +1,43 @@
|
||||
<div>
|
||||
<div class="filters py-2">
|
||||
<a *ngFor="let value of (selectedValues | async)" class="d-flex flex-row"
|
||||
[routerLink]="[getSearchLink()]"
|
||||
[queryParams]="getRemoveParams(value) | async" queryParamsHandling="merge">
|
||||
<input type="checkbox" [checked]="true" class="my-1 align-self-stretch"/>
|
||||
<span class="filter-value pl-1">{{value}}</span>
|
||||
</a>
|
||||
<ng-container *ngFor="let page of (filterValues$ | async)?.payload">
|
||||
<div [@facetLoad]="animationState">
|
||||
<ng-container *ngFor="let value of page.page; let i=index">
|
||||
<a *ngIf="!(selectedValues | async).includes(value.value)" class="d-flex flex-row"
|
||||
[routerLink]="[getSearchLink()]"
|
||||
[queryParams]="getAddParams(value.value) | async" queryParamsHandling="merge" >
|
||||
<input type="checkbox" [checked]="false" class="my-1 align-self-stretch"/>
|
||||
<span class="filter-value px-1">{{value.value}}</span>
|
||||
<span class="float-right filter-value-count ml-auto">
|
||||
<span class="badge badge-secondary badge-pill">{{value.count}}</span>
|
||||
</span>
|
||||
</a>
|
||||
</ng-container>
|
||||
</div>
|
||||
</ng-container>
|
||||
<div class="clearfix toggle-more-filters">
|
||||
<a class="float-left" *ngIf="!(isLastPage$ | async)"
|
||||
(click)="showMore()">{{"search.filters.filter.show-more"
|
||||
| translate}}</a>
|
||||
<a class="float-right" *ngIf="(currentPage | async) > 1"
|
||||
(click)="showFirstPageOnly()">{{"search.filters.filter.show-less"
|
||||
| translate}}</a>
|
||||
</div>
|
||||
</div>
|
||||
<ds-input-suggestions [suggestions]="(filterSearchResults | async)"
|
||||
[placeholder]="'search.filters.filter.' + filterConfig.name + '.placeholder'| translate"
|
||||
[action]="getCurrentUrl()"
|
||||
[name]="filterConfig.paramName"
|
||||
[(ngModel)]="filter"
|
||||
(submitSuggestion)="onSubmit($event)"
|
||||
(clickSuggestion)="onClick($event)"
|
||||
(findSuggestions)="findSuggestions($event)"
|
||||
ngDefaultControl
|
||||
></ds-input-suggestions>
|
||||
</div>
|
@@ -0,0 +1,23 @@
|
||||
@import '../../../../../styles/variables.scss';
|
||||
@import '../../../../../styles/mixins.scss';
|
||||
|
||||
.filters {
|
||||
a {
|
||||
color: $body-color;
|
||||
&:hover, &focus {
|
||||
text-decoration: none;
|
||||
}
|
||||
span.badge {
|
||||
vertical-align: text-top;
|
||||
}
|
||||
}
|
||||
.toggle-more-filters a {
|
||||
color: $link-color;
|
||||
text-decoration: underline;
|
||||
cursor: pointer;
|
||||
}
|
||||
}
|
||||
::ng-deep em {
|
||||
font-weight: bold;
|
||||
font-style: normal;
|
||||
}
|
@@ -0,0 +1,21 @@
|
||||
import { Component, OnInit } from '@angular/core';
|
||||
import { FilterType } from '../../../search-service/filter-type.model';
|
||||
import { renderFacetFor } from '../search-filter-type-decorator';
|
||||
import {
|
||||
facetLoad,
|
||||
SearchFacetFilterComponent
|
||||
} from '../search-facet-filter/search-facet-filter.component';
|
||||
|
||||
@Component({
|
||||
selector: 'ds-search-hierarchy-filter',
|
||||
styleUrls: ['./search-hierarchy-filter.component.scss'],
|
||||
templateUrl: './search-hierarchy-filter.component.html',
|
||||
animations: [facetLoad]
|
||||
})
|
||||
|
||||
/**
|
||||
* Component that represents a hierarchy facet for a specific filter configuration
|
||||
*/
|
||||
@renderFacetFor(FilterType.hierarchy)
|
||||
export class SearchHierarchyFilterComponent extends SearchFacetFilterComponent implements OnInit {
|
||||
}
|
@@ -0,0 +1,40 @@
|
||||
<div>
|
||||
<div class="filters py-2">
|
||||
<form #form="ngForm" (ngSubmit)="onSubmit()" class="add-filter row"
|
||||
[action]="getCurrentUrl()">
|
||||
<div class="col-6">
|
||||
<input type="text" [(ngModel)]="range[0]" [name]="filterConfig.paramName + '.min'"
|
||||
class="form-control" (blur)="onSubmit()"
|
||||
aria-label="Mininum value"
|
||||
[placeholder]="'search.filters.filter.' + filterConfig.name + '.min.placeholder'| translate"/>
|
||||
</div>
|
||||
<div class="col-6">
|
||||
<input type="text" [(ngModel)]="range[1]" [name]="filterConfig.paramName + '.max'"
|
||||
class="form-control" (blur)="onSubmit()"
|
||||
aria-label="Maximum value"
|
||||
[placeholder]="'search.filters.filter.' + filterConfig.name + '.max.placeholder'| translate"/>
|
||||
</div>
|
||||
<input type="submit" class="d-none"/>
|
||||
</form>
|
||||
|
||||
<ng-container *ngIf="shouldShowSlider()">
|
||||
<nouislider [connect]="true" [min]="min" [max]="max" [step]="1"
|
||||
[(ngModel)]="range" (change)="onSubmit()" ngDefaultControl></nouislider>
|
||||
|
||||
</ng-container>
|
||||
<ng-container *ngFor="let page of (filterValues$ | async)?.payload">
|
||||
<div [@facetLoad]="animationState">
|
||||
<ng-container *ngFor="let value of page.page; let i=index">
|
||||
<a *ngIf="!(selectedValues | async).includes(value.value)" class="d-flex flex-row"
|
||||
[routerLink]="[getSearchLink()]"
|
||||
[queryParams]="getChangeParams(value.value) | async" queryParamsHandling="merge">
|
||||
<span class="filter-value px-1">{{value.value}}</span>
|
||||
<span class="float-right filter-value-count ml-auto">
|
||||
<span class="badge badge-secondary badge-pill">{{value.count}}</span>
|
||||
</span>
|
||||
</a>
|
||||
</ng-container>
|
||||
</div>
|
||||
</ng-container>
|
||||
</div>
|
||||
</div>
|
@@ -0,0 +1,42 @@
|
||||
@import '../../../../../styles/variables.scss';
|
||||
@import '../../../../../styles/mixins.scss';
|
||||
|
||||
|
||||
.filters {
|
||||
a {
|
||||
color: $link-color;
|
||||
&:hover {
|
||||
text-decoration: underline;
|
||||
color: $link-hover-color;
|
||||
|
||||
}
|
||||
span.badge {
|
||||
vertical-align: text-top;
|
||||
}
|
||||
}
|
||||
.toggle-more-filters a {
|
||||
color: $link-color;
|
||||
text-decoration: underline;
|
||||
cursor: pointer;
|
||||
}
|
||||
}
|
||||
|
||||
$slider-handle-width: 18px;
|
||||
::ng-deep
|
||||
{
|
||||
html:not([dir=rtl]) .noUi-horizontal .noUi-handle {
|
||||
right: -$slider-handle-width/2;
|
||||
}
|
||||
.noUi-horizontal .noUi-handle {
|
||||
width: $slider-handle-width;
|
||||
&:before {
|
||||
left: ($slider-handle-width - 2)/2 - 2;
|
||||
}
|
||||
&:after {
|
||||
left: ($slider-handle-width - 2)/2 + 2;
|
||||
}
|
||||
&:focus {
|
||||
outline: none;
|
||||
}
|
||||
}
|
||||
}
|
@@ -0,0 +1,138 @@
|
||||
import { ChangeDetectionStrategy, NO_ERRORS_SCHEMA } from '@angular/core';
|
||||
import { async, ComponentFixture, TestBed } from '@angular/core/testing';
|
||||
import { TranslateModule } from '@ngx-translate/core';
|
||||
import { NoopAnimationsModule } from '@angular/platform-browser/animations';
|
||||
import { FILTER_CONFIG, SearchFilterService } from '../search-filter.service';
|
||||
import { SearchFilterConfig } from '../../../search-service/search-filter-config.model';
|
||||
import { FilterType } from '../../../search-service/filter-type.model';
|
||||
import { FacetValue } from '../../../search-service/facet-value.model';
|
||||
import { FormsModule } from '@angular/forms';
|
||||
import { Observable } from 'rxjs/Observable';
|
||||
import { SearchService } from '../../../search-service/search.service';
|
||||
import { SearchServiceStub } from '../../../../shared/testing/search-service-stub';
|
||||
import { RemoteData } from '../../../../core/data/remote-data';
|
||||
import { PaginatedList } from '../../../../core/data/paginated-list';
|
||||
import { RouterStub } from '../../../../shared/testing/router-stub';
|
||||
import { Router } from '@angular/router';
|
||||
import { PageInfo } from '../../../../core/shared/page-info.model';
|
||||
import { SearchRangeFilterComponent } from './search-range-filter.component';
|
||||
import { RouteService } from '../../../../shared/services/route.service';
|
||||
import { RemoteDataBuildService } from '../../../../core/cache/builders/remote-data-build.service';
|
||||
import { SearchConfigurationService } from '../../../search-service/search-configuration.service';
|
||||
|
||||
describe('SearchRangeFilterComponent', () => {
|
||||
let comp: SearchRangeFilterComponent;
|
||||
let fixture: ComponentFixture<SearchRangeFilterComponent>;
|
||||
const minSuffix = '.min';
|
||||
const maxSuffix = '.max';
|
||||
const dateFormats = ['YYYY', 'YYYY-MM', 'YYYY-MM-DD'];
|
||||
const filterName1 = 'test name';
|
||||
const value1 = '2000 - 2012';
|
||||
const value2 = '1992 - 2000';
|
||||
const value3 = '1990 - 1992';
|
||||
const mockFilterConfig: SearchFilterConfig = Object.assign(new SearchFilterConfig(), {
|
||||
name: filterName1,
|
||||
type: FilterType.range,
|
||||
hasFacets: false,
|
||||
isOpenByDefault: false,
|
||||
pageSize: 2,
|
||||
minValue: 200,
|
||||
maxValue: 3000,
|
||||
});
|
||||
const values: FacetValue[] = [
|
||||
{
|
||||
value: value1,
|
||||
count: 52,
|
||||
search: ''
|
||||
}, {
|
||||
value: value2,
|
||||
count: 20,
|
||||
search: ''
|
||||
}, {
|
||||
value: value3,
|
||||
count: 5,
|
||||
search: ''
|
||||
}
|
||||
];
|
||||
|
||||
const searchLink = '/search';
|
||||
const selectedValues = Observable.of([value1]);
|
||||
let filterService;
|
||||
let searchService;
|
||||
let router;
|
||||
const page = Observable.of(0);
|
||||
|
||||
const mockValues = Observable.of(new RemoteData(false, false, true, null, new PaginatedList(new PageInfo(), values)));
|
||||
beforeEach(async(() => {
|
||||
TestBed.configureTestingModule({
|
||||
imports: [TranslateModule.forRoot(), NoopAnimationsModule, FormsModule],
|
||||
declarations: [SearchRangeFilterComponent],
|
||||
providers: [
|
||||
{ provide: SearchService, useValue: new SearchServiceStub(searchLink) },
|
||||
{ provide: Router, useValue: new RouterStub() },
|
||||
{ provide: FILTER_CONFIG, useValue: mockFilterConfig },
|
||||
{ provide: RemoteDataBuildService, useValue: {aggregate: () => Observable.of({})} },
|
||||
{ provide: RouteService, useValue: {getQueryParameterValue: () => Observable.of({})} },
|
||||
{ provide: SearchConfigurationService, useValue: {
|
||||
searchOptions: Observable.of({}) }
|
||||
},
|
||||
{
|
||||
provide: SearchFilterService, useValue: {
|
||||
getSelectedValuesForFilter: () => selectedValues,
|
||||
isFilterActiveWithValue: (paramName: string, filterValue: string) => true,
|
||||
getPage: (paramName: string) => page,
|
||||
/* tslint:disable:no-empty */
|
||||
incrementPage: (filterName: string) => {
|
||||
},
|
||||
resetPage: (filterName: string) => {
|
||||
}
|
||||
/* tslint:enable:no-empty */
|
||||
}
|
||||
}
|
||||
],
|
||||
schemas: [NO_ERRORS_SCHEMA]
|
||||
}).overrideComponent(SearchRangeFilterComponent, {
|
||||
set: { changeDetection: ChangeDetectionStrategy.Default }
|
||||
}).compileComponents();
|
||||
}));
|
||||
|
||||
beforeEach(() => {
|
||||
fixture = TestBed.createComponent(SearchRangeFilterComponent);
|
||||
comp = fixture.componentInstance; // SearchPageComponent test instance
|
||||
filterService = (comp as any).filterService;
|
||||
searchService = (comp as any).searchService;
|
||||
spyOn(searchService, 'getFacetValuesFor').and.returnValue(mockValues);
|
||||
router = (comp as any).router;
|
||||
fixture.detectChanges();
|
||||
});
|
||||
|
||||
describe('when the getChangeParams method is called wih a value', () => {
|
||||
it('should return the selectedValue list with the new parameter value', () => {
|
||||
const result$ = comp.getChangeParams(value3);
|
||||
result$.subscribe((result) => {
|
||||
expect(result[mockFilterConfig.paramName + minSuffix]).toEqual(['1990']);
|
||||
expect(result[mockFilterConfig.paramName + maxSuffix]).toEqual(['1992']);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('when the onSubmit method is called with data', () => {
|
||||
const searchUrl = '/search/path';
|
||||
// const data = { [mockFilterConfig.paramName + minSuffix]: '1900', [mockFilterConfig.paramName + maxSuffix]: '1950' };
|
||||
beforeEach(() => {
|
||||
comp.range = [1900, 1950];
|
||||
spyOn(comp, 'getSearchLink').and.returnValue(searchUrl);
|
||||
comp.onSubmit();
|
||||
});
|
||||
|
||||
it('should call navigate on the router with the right searchlink and parameters', () => {
|
||||
expect(router.navigate).toHaveBeenCalledWith([searchUrl], {
|
||||
queryParams: {
|
||||
[mockFilterConfig.paramName + minSuffix]: [1900],
|
||||
[mockFilterConfig.paramName + maxSuffix]: [1950]
|
||||
},
|
||||
queryParamsHandling: 'merge'
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
@@ -0,0 +1,148 @@
|
||||
import { isPlatformBrowser } from '@angular/common';
|
||||
import { Component, Inject, OnDestroy, OnInit, PLATFORM_ID } from '@angular/core';
|
||||
import { RemoteDataBuildService } from '../../../../core/cache/builders/remote-data-build.service';
|
||||
import { FilterType } from '../../../search-service/filter-type.model';
|
||||
import { renderFacetFor } from '../search-filter-type-decorator';
|
||||
import {
|
||||
facetLoad,
|
||||
SearchFacetFilterComponent
|
||||
} from '../search-facet-filter/search-facet-filter.component';
|
||||
import { SearchFilterConfig } from '../../../search-service/search-filter-config.model';
|
||||
import { FILTER_CONFIG, SearchFilterService } from '../search-filter.service';
|
||||
import { SearchService } from '../../../search-service/search.service';
|
||||
import { Router } from '@angular/router';
|
||||
import * as moment from 'moment';
|
||||
import { Observable } from 'rxjs/Observable';
|
||||
import { RouteService } from '../../../../shared/services/route.service';
|
||||
import { hasValue } from '../../../../shared/empty.util';
|
||||
import { Subscription } from 'rxjs/Subscription';
|
||||
import { SearchConfigurationService } from '../../../search-service/search-configuration.service';
|
||||
|
||||
/**
|
||||
* This component renders a simple item page.
|
||||
* The route parameter 'id' is used to request the item it represents.
|
||||
* All fields of the item that should be displayed, are defined in its template.
|
||||
*/
|
||||
const minSuffix = '.min';
|
||||
const maxSuffix = '.max';
|
||||
const dateFormats = ['YYYY', 'YYYY-MM', 'YYYY-MM-DD'];
|
||||
const rangeDelimiter = '-';
|
||||
|
||||
@Component({
|
||||
selector: 'ds-search-range-filter',
|
||||
styleUrls: ['./search-range-filter.component.scss'],
|
||||
templateUrl: './search-range-filter.component.html',
|
||||
animations: [facetLoad]
|
||||
})
|
||||
|
||||
/**
|
||||
* Component that represents a range facet for a specific filter configuration
|
||||
*/
|
||||
@renderFacetFor(FilterType.range)
|
||||
export class SearchRangeFilterComponent extends SearchFacetFilterComponent implements OnInit, OnDestroy {
|
||||
/**
|
||||
* Fallback minimum for the range
|
||||
*/
|
||||
min = 1950;
|
||||
|
||||
/**
|
||||
* Fallback maximum for the range
|
||||
*/
|
||||
max = 2018;
|
||||
|
||||
/**
|
||||
* The current range of the filter
|
||||
*/
|
||||
range;
|
||||
|
||||
/**
|
||||
* Subscription to unsubscribe from
|
||||
*/
|
||||
sub: Subscription;
|
||||
|
||||
constructor(protected searchService: SearchService,
|
||||
protected filterService: SearchFilterService,
|
||||
protected searchConfigService: SearchConfigurationService,
|
||||
protected router: Router,
|
||||
protected rdbs: RemoteDataBuildService,
|
||||
@Inject(FILTER_CONFIG) public filterConfig: SearchFilterConfig,
|
||||
@Inject(PLATFORM_ID) private platformId: any,
|
||||
private route: RouteService) {
|
||||
super(searchService, filterService, searchConfigService, rdbs, router, filterConfig);
|
||||
|
||||
}
|
||||
|
||||
/**
|
||||
* Initialize with the min and max values as configured in the filter configuration
|
||||
* Set the initial values of the range
|
||||
*/
|
||||
ngOnInit(): void {
|
||||
super.ngOnInit();
|
||||
this.min = moment(this.filterConfig.minValue, dateFormats).year() || this.min;
|
||||
this.max = moment(this.filterConfig.maxValue, dateFormats).year() || this.max;
|
||||
const iniMin = this.route.getQueryParameterValue(this.filterConfig.paramName + minSuffix).startWith(undefined);
|
||||
const iniMax = this.route.getQueryParameterValue(this.filterConfig.paramName + maxSuffix).startWith(undefined);
|
||||
this.sub = Observable.combineLatest(iniMin, iniMax, (min, max) => {
|
||||
const minimum = hasValue(min) ? min : this.min;
|
||||
const maximum = hasValue(max) ? max : this.max;
|
||||
return [minimum, maximum]
|
||||
}).subscribe((minmax) => this.range = minmax);
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculates the parameters that should change if a given values for this range filter would be changed
|
||||
* @param {string} value The values that are changed for this filter
|
||||
* @returns {Observable<any>} The changed filter parameters
|
||||
*/
|
||||
getChangeParams(value: string) {
|
||||
const parts = value.split(rangeDelimiter);
|
||||
const min = parts.length > 1 ? parts[0].trim() : value;
|
||||
const max = parts.length > 1 ? parts[1].trim() : value;
|
||||
return Observable.of(
|
||||
{
|
||||
[this.filterConfig.paramName + minSuffix]: [min],
|
||||
[this.filterConfig.paramName + maxSuffix]: [max],
|
||||
page: 1
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Submits new custom range values to the range filter from the widget
|
||||
*/
|
||||
onSubmit() {
|
||||
const newMin = this.range[0] !== this.min ? [this.range[0]] : null;
|
||||
const newMax = this.range[1] !== this.max ? [this.range[1]] : null;
|
||||
this.router.navigate([this.getSearchLink()], {
|
||||
queryParams:
|
||||
{
|
||||
[this.filterConfig.paramName + minSuffix]: newMin,
|
||||
[this.filterConfig.paramName + maxSuffix]: newMax
|
||||
},
|
||||
queryParamsHandling: 'merge'
|
||||
});
|
||||
this.filter = '';
|
||||
}
|
||||
|
||||
/**
|
||||
* TODO when upgrading nouislider, verify that this check is still needed.
|
||||
* Prevents AoT bug
|
||||
* @returns {boolean} True if the platformId is a platform browser
|
||||
*/
|
||||
shouldShowSlider(): boolean {
|
||||
return isPlatformBrowser(this.platformId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Unsubscribe from all subscriptions
|
||||
*/
|
||||
ngOnDestroy() {
|
||||
super.ngOnDestroy();
|
||||
if (hasValue(this.sub)) {
|
||||
this.sub.unsubscribe();
|
||||
}
|
||||
}
|
||||
|
||||
out(call) {
|
||||
console.log(call);
|
||||
}
|
||||
}
|
@@ -0,0 +1,45 @@
|
||||
<div>
|
||||
<div class="filters py-2">
|
||||
<a *ngFor="let value of (selectedValues | async)" class="d-flex flex-row"
|
||||
[routerLink]="[getSearchLink()]"
|
||||
[queryParams]="getRemoveParams(value) | async" queryParamsHandling="merge">
|
||||
<input type="checkbox" [checked]="true" class="my-1 align-self-stretch"/>
|
||||
<span class="filter-value pl-1">{{value}}</span>
|
||||
</a>
|
||||
<ng-container *ngVar="(filterValues$ | async) as filterValuesRD">
|
||||
<div [@facetLoad]="animationState">
|
||||
<ng-container *ngFor="let page of filterValuesRD?.payload">
|
||||
<ng-container *ngFor="let value of page.page; let i=index">
|
||||
<a *ngIf="!(selectedValues | async).includes(value.value)" class="d-flex flex-row"
|
||||
[routerLink]="[getSearchLink()]"
|
||||
[queryParams]="getAddParams(value.value) | async" queryParamsHandling="merge" >
|
||||
<input type="checkbox" [checked]="false" class="my-1 align-self-stretch"/>
|
||||
<span class="filter-value px-1">{{value.value}}</span>
|
||||
<span class="float-right filter-value-count ml-auto">
|
||||
<span class="badge badge-secondary badge-pill">{{value.count}}</span>
|
||||
</span>
|
||||
</a>
|
||||
</ng-container>
|
||||
</ng-container>
|
||||
</div>
|
||||
</ng-container>
|
||||
<div class="clearfix toggle-more-filters">
|
||||
<a class="float-left" *ngIf="!(isLastPage$ | async)"
|
||||
(click)="showMore()">{{"search.filters.filter.show-more"
|
||||
| translate}}</a>
|
||||
<a class="float-right" *ngIf="(currentPage | async) > 1"
|
||||
(click)="showFirstPageOnly()">{{"search.filters.filter.show-less"
|
||||
| translate}}</a>
|
||||
</div>
|
||||
</div>
|
||||
<ds-input-suggestions [suggestions]="(filterSearchResults | async)"
|
||||
[placeholder]="'search.filters.filter.' + filterConfig.name + '.placeholder'| translate"
|
||||
[action]="getCurrentUrl()"
|
||||
[name]="filterConfig.paramName"
|
||||
[(ngModel)]="filter"
|
||||
(submitSuggestion)="onSubmit($event)"
|
||||
(clickSuggestion)="onClick($event)"
|
||||
(findSuggestions)="findSuggestions($event)"
|
||||
ngDefaultControl
|
||||
></ds-input-suggestions>
|
||||
</div>
|
@@ -2,17 +2,22 @@
|
||||
@import '../../../../../styles/mixins.scss';
|
||||
|
||||
.filters {
|
||||
margin-top: $spacer/2;
|
||||
margin-bottom: $spacer/2;
|
||||
a {
|
||||
color: $body-color;
|
||||
&:hover {
|
||||
&:hover, &focus {
|
||||
text-decoration: none;
|
||||
}
|
||||
span.badge {
|
||||
vertical-align: text-top;
|
||||
}
|
||||
}
|
||||
.toggle-more-filters a {
|
||||
color: $link-color;
|
||||
text-decoration: underline;
|
||||
cursor: pointer;
|
||||
}
|
||||
}
|
||||
}
|
||||
::ng-deep em {
|
||||
font-weight: bold;
|
||||
font-style: normal;
|
||||
}
|
@@ -0,0 +1,29 @@
|
||||
import { animate, state, style, transition, trigger } from '@angular/animations';
|
||||
import { Component, HostBinding, OnInit } from '@angular/core';
|
||||
import { Observable } from 'rxjs/Observable';
|
||||
import { FilterType } from '../../../search-service/filter-type.model';
|
||||
import {
|
||||
facetLoad,
|
||||
SearchFacetFilterComponent
|
||||
} from '../search-facet-filter/search-facet-filter.component';
|
||||
import { renderFacetFor } from '../search-filter-type-decorator';
|
||||
|
||||
/**
|
||||
* This component renders a simple item page.
|
||||
* The route parameter 'id' is used to request the item it represents.
|
||||
* All fields of the item that should be displayed, are defined in its template.
|
||||
*/
|
||||
|
||||
@Component({
|
||||
selector: 'ds-search-text-filter',
|
||||
styleUrls: ['./search-text-filter.component.scss'],
|
||||
templateUrl: './search-text-filter.component.html',
|
||||
animations: [facetLoad]
|
||||
})
|
||||
|
||||
/**
|
||||
* Component that represents a text facet for a specific filter configuration
|
||||
*/
|
||||
@renderFacetFor(FilterType.text)
|
||||
export class SearchTextFilterComponent extends SearchFacetFilterComponent implements OnInit {
|
||||
}
|
@@ -1,7 +1,7 @@
|
||||
<h3>{{"search.filters.head" | translate}}</h3>
|
||||
<div *ngIf="(filters | async)?.hasSucceeded">
|
||||
<div *ngFor="let filter of (filters | async).payload">
|
||||
<ds-search-filter class="d-block mb-3 p-3" [filter]="filter"></ds-search-filter>
|
||||
<div *ngFor="let filter of (filters | async)?.payload">
|
||||
<ds-search-filter *ngIf="isActive(filter) | async" class="d-block mb-3 p-3" [filter]="filter"></ds-search-filter>
|
||||
</div>
|
||||
</div>
|
||||
<a class="btn btn-primary" [routerLink]="[getSearchLink()]" [queryParams]="clearParams | async" queryParamsHandling="merge" role="button">{{"search.filters.reset" | translate}}</a>
|
@@ -8,6 +8,7 @@ import { SearchFilterService } from './search-filter/search-filter.service';
|
||||
import { SearchFiltersComponent } from './search-filters.component';
|
||||
import { SearchService } from '../search-service/search.service';
|
||||
import { Observable } from 'rxjs/Observable';
|
||||
import { SearchConfigurationService } from '../search-service/search-configuration.service';
|
||||
|
||||
describe('SearchFiltersComponent', () => {
|
||||
let comp: SearchFiltersComponent;
|
||||
@@ -23,8 +24,14 @@ describe('SearchFiltersComponent', () => {
|
||||
}
|
||||
/* tslint:enable:no-empty */
|
||||
};
|
||||
const searchFilterServiceStub = jasmine.createSpyObj('SearchFilterService', {
|
||||
getCurrentFilters: Observable.of({})
|
||||
|
||||
const searchFiltersStub = {
|
||||
getSelectedValuesForFilter: (filter) =>
|
||||
[]
|
||||
};
|
||||
|
||||
const searchConfigServiceStub = jasmine.createSpyObj('SearchConfigurationService', {
|
||||
getCurrentFrontendFilters: Observable.of({})
|
||||
});
|
||||
|
||||
beforeEach(async(() => {
|
||||
@@ -33,7 +40,8 @@ describe('SearchFiltersComponent', () => {
|
||||
declarations: [SearchFiltersComponent],
|
||||
providers: [
|
||||
{ provide: SearchService, useValue: searchServiceStub },
|
||||
{ provide: SearchFilterService, useValue: searchFilterServiceStub },
|
||||
{ provide: SearchConfigurationService, useValue: searchConfigServiceStub },
|
||||
{ provide: SearchFilterService, useValue: searchFiltersStub },
|
||||
|
||||
],
|
||||
schemas: [NO_ERRORS_SCHEMA]
|
||||
|
@@ -3,13 +3,10 @@ import { SearchService } from '../search-service/search.service';
|
||||
import { RemoteData } from '../../core/data/remote-data';
|
||||
import { SearchFilterConfig } from '../search-service/search-filter-config.model';
|
||||
import { Observable } from 'rxjs/Observable';
|
||||
import { SearchConfigurationService } from '../search-service/search-configuration.service';
|
||||
import { isNotEmpty } from '../../shared/empty.util';
|
||||
import { SearchFilterService } from './search-filter/search-filter.service';
|
||||
|
||||
/**
|
||||
* This component renders a simple item page.
|
||||
* The route parameter 'id' is used to request the item it represents.
|
||||
* All fields of the item that should be displayed, are defined in its template.
|
||||
*/
|
||||
import { getSucceededRemoteData } from '../../core/shared/operators';
|
||||
|
||||
@Component({
|
||||
selector: 'ds-search-filters',
|
||||
@@ -17,15 +14,64 @@ import { SearchFilterService } from './search-filter/search-filter.service';
|
||||
templateUrl: './search-filters.component.html',
|
||||
})
|
||||
|
||||
/**
|
||||
* This component represents the part of the search sidebar that contains filters.
|
||||
*/
|
||||
export class SearchFiltersComponent {
|
||||
/**
|
||||
* An observable containing configuration about which filters are shown and how they are shown
|
||||
*/
|
||||
filters: Observable<RemoteData<SearchFilterConfig[]>>;
|
||||
|
||||
/**
|
||||
* List of all filters that are currently active with their value set to null.
|
||||
* Used to reset all filters at once
|
||||
*/
|
||||
clearParams;
|
||||
constructor(private searchService: SearchService, private filterService: SearchFilterService) {
|
||||
this.filters = searchService.getConfig();
|
||||
this.clearParams = filterService.getCurrentFilters().map((filters) => {Object.keys(filters).forEach((f) => filters[f] = null); return filters;});
|
||||
|
||||
/**
|
||||
* Initialize instance variables
|
||||
* @param {SearchService} searchService
|
||||
* @param {SearchConfigurationService} searchConfigService
|
||||
* @param {SearchFilterService} filterService
|
||||
*/
|
||||
constructor(private searchService: SearchService, private searchConfigService: SearchConfigurationService, private filterService: SearchFilterService) {
|
||||
this.filters = searchService.getConfig().pipe(getSucceededRemoteData());
|
||||
this.clearParams = searchConfigService.getCurrentFrontendFilters().map((filters) => {
|
||||
Object.keys(filters).forEach((f) => filters[f] = null);
|
||||
return filters;
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* @returns {string} The base path to the search page
|
||||
*/
|
||||
getSearchLink() {
|
||||
return this.searchService.getSearchLink();
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a given filter is supposed to be shown or not
|
||||
* @param {SearchFilterConfig} filter The filter to check for
|
||||
* @returns {Observable<boolean>} Emits true whenever a given filter config should be shown
|
||||
*/
|
||||
isActive(filter: SearchFilterConfig): Observable<boolean> {
|
||||
// console.log(filter.name);
|
||||
return this.filterService.getSelectedValuesForFilter(filter)
|
||||
.flatMap((isActive) => {
|
||||
if (isNotEmpty(isActive)) {
|
||||
return Observable.of(true);
|
||||
} else {
|
||||
return this.searchConfigService.searchOptions
|
||||
.switchMap((options) => {
|
||||
return this.searchService.getFacetValuesFor(filter, 1, options)
|
||||
.filter((RD) => !RD.isLoading)
|
||||
.map((valuesRD) => {
|
||||
return valuesRD.payload.totalElements > 0
|
||||
})
|
||||
}
|
||||
)
|
||||
}
|
||||
}).startWith(true);
|
||||
}
|
||||
}
|
||||
|
@@ -0,0 +1,13 @@
|
||||
<div class="row mb-3 mb-md-1">
|
||||
<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
|
||||
--><a *ngFor="let values of (appliedFilters | async)[key]"
|
||||
class="badge badge-primary mr-1 mb-1"
|
||||
[routerLink]="getSearchLink()"
|
||||
[queryParams]="(getRemoveParams(key, values) | async)" queryParamsHandling="merge">
|
||||
{{('search.filters.applied.' + key) | translate}}: {{values}}
|
||||
<span> ×</span>
|
||||
</a><!--Do not remove this to prevent uneven spacing
|
||||
--></ng-container>
|
||||
</div>
|
||||
</div>
|
@@ -0,0 +1,3 @@
|
||||
:host {
|
||||
line-height: 1;
|
||||
}
|
@@ -0,0 +1,68 @@
|
||||
import { SearchLabelsComponent } from './search-labels.component';
|
||||
import { async, ComponentFixture, TestBed } from '@angular/core/testing';
|
||||
import { NoopAnimationsModule } from '@angular/platform-browser/animations';
|
||||
import { TranslateModule } from '@ngx-translate/core';
|
||||
import { SearchService } from '../search-service/search.service';
|
||||
import { ChangeDetectionStrategy, NO_ERRORS_SCHEMA } from '@angular/core';
|
||||
import { FormsModule } from '@angular/forms';
|
||||
import { SearchServiceStub } from '../../shared/testing/search-service-stub';
|
||||
import { Observable } from 'rxjs/Observable';
|
||||
import { Params } from '@angular/router';
|
||||
import { ObjectKeysPipe } from '../../shared/utils/object-keys-pipe';
|
||||
import { SearchConfigurationService } from '../search-service/search-configuration.service';
|
||||
|
||||
describe('SearchLabelsComponent', () => {
|
||||
let comp: SearchLabelsComponent;
|
||||
let fixture: ComponentFixture<SearchLabelsComponent>;
|
||||
|
||||
const searchLink = '/search';
|
||||
let searchService;
|
||||
|
||||
const field1 = 'author';
|
||||
const field2 = 'subject';
|
||||
const value1 = 'TestAuthor';
|
||||
const value2 = 'TestSubject';
|
||||
const filter1 = [field1, value1];
|
||||
const filter2 = [field2, value2];
|
||||
const mockFilters = [
|
||||
filter1,
|
||||
filter2
|
||||
];
|
||||
|
||||
beforeEach(async(() => {
|
||||
TestBed.configureTestingModule({
|
||||
imports: [TranslateModule.forRoot(), NoopAnimationsModule, FormsModule],
|
||||
declarations: [SearchLabelsComponent, ObjectKeysPipe],
|
||||
providers: [
|
||||
{ provide: SearchService, useValue: new SearchServiceStub(searchLink) },
|
||||
{ provide: SearchConfigurationService, useValue: {getCurrentFrontendFilters : () => Observable.of({})} }
|
||||
],
|
||||
schemas: [NO_ERRORS_SCHEMA]
|
||||
}).overrideComponent(SearchLabelsComponent, {
|
||||
set: { changeDetection: ChangeDetectionStrategy.Default }
|
||||
}).compileComponents();
|
||||
}));
|
||||
|
||||
beforeEach(() => {
|
||||
fixture = TestBed.createComponent(SearchLabelsComponent);
|
||||
comp = fixture.componentInstance;
|
||||
searchService = (comp as any).searchService;
|
||||
(comp as any).appliedFilters = Observable.of(mockFilters);
|
||||
fixture.detectChanges();
|
||||
});
|
||||
|
||||
describe('when getRemoveParams is called', () => {
|
||||
let obs: Observable<Params>;
|
||||
|
||||
beforeEach(() => {
|
||||
obs = comp.getRemoveParams(filter1[0], filter1[1]);
|
||||
});
|
||||
|
||||
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);
|
||||
});
|
||||
})
|
||||
});
|
||||
});
|
@@ -0,0 +1,56 @@
|
||||
import { Component } from '@angular/core';
|
||||
import { SearchService } from '../search-service/search.service';
|
||||
import { Observable } from 'rxjs/Observable';
|
||||
import { Params } from '@angular/router';
|
||||
import { map } from 'rxjs/operators';
|
||||
import { hasValue, isNotEmpty } from '../../shared/empty.util';
|
||||
import { SearchConfigurationService } from '../search-service/search-configuration.service';
|
||||
|
||||
@Component({
|
||||
selector: 'ds-search-labels',
|
||||
styleUrls: ['./search-labels.component.scss'],
|
||||
templateUrl: './search-labels.component.html',
|
||||
})
|
||||
|
||||
/**
|
||||
* Component that represents the labels containing the currently active filters
|
||||
*/
|
||||
export class SearchLabelsComponent {
|
||||
/**
|
||||
* Emits the currently active filters
|
||||
*/
|
||||
appliedFilters: Observable<Params>;
|
||||
|
||||
/**
|
||||
* Initialize the instance variable
|
||||
*/
|
||||
constructor(private searchService: SearchService, private searchConfigService: SearchConfigurationService) {
|
||||
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
|
||||
*/
|
||||
getSearchLink() {
|
||||
return this.searchService.getSearchLink();
|
||||
}
|
||||
}
|
32
src/app/+search-page/search-options.model.spec.ts
Normal file
32
src/app/+search-page/search-options.model.spec.ts
Normal file
@@ -0,0 +1,32 @@
|
||||
import 'rxjs/add/observable/of';
|
||||
import { PaginatedSearchOptions } from './paginated-search-options.model';
|
||||
import { SearchOptions } from './search-options.model';
|
||||
import { SearchFilter } from './search-filter.model';
|
||||
import { DSpaceObjectType } from '../core/shared/dspace-object-type.model';
|
||||
|
||||
describe('SearchOptions', () => {
|
||||
let options: PaginatedSearchOptions;
|
||||
const filters = [new SearchFilter('f.test', ['value']), new SearchFilter('f.example', ['another value', 'second value'])];
|
||||
const query = 'search query';
|
||||
const scope = '0fde1ecb-82cc-425a-b600-ac3576d76b47';
|
||||
const baseUrl = 'www.rest.com';
|
||||
beforeEach(() => {
|
||||
options = new SearchOptions({ filters: filters, query: query, scope: scope , dsoType: DSpaceObjectType.ITEM});
|
||||
});
|
||||
|
||||
describe('when toRestUrl is called', () => {
|
||||
|
||||
it('should generate a string with all parameters that are present', () => {
|
||||
const outcome = options.toRestUrl(baseUrl);
|
||||
expect(outcome).toEqual('www.rest.com?' +
|
||||
'query=search query&' +
|
||||
'scope=0fde1ecb-82cc-425a-b600-ac3576d76b47&' +
|
||||
'dsoType=ITEM&' +
|
||||
'f.test=value,query&' +
|
||||
'f.example=another value,query&' +
|
||||
'f.example=second value,query'
|
||||
);
|
||||
});
|
||||
|
||||
});
|
||||
});
|
@@ -1,15 +1,34 @@
|
||||
import 'core-js/fn/object/entries';
|
||||
import { URLCombiner } from '../core/url-combiner/url-combiner';
|
||||
import { isNotEmpty } from '../shared/empty.util';
|
||||
import { URLCombiner } from '../core/url-combiner/url-combiner';
|
||||
import 'core-js/library/fn/object/entries';
|
||||
import { SearchFilter } from './search-filter.model';
|
||||
import { DSpaceObjectType } from '../core/shared/dspace-object-type.model';
|
||||
import { SetViewMode } from '../shared/view-mode';
|
||||
|
||||
/**
|
||||
* This model class represents all parameters needed to request information about a certain search request
|
||||
*/
|
||||
export class SearchOptions {
|
||||
view?: SetViewMode = SetViewMode.List;
|
||||
scope?: string;
|
||||
query?: string;
|
||||
dsoType?: DSpaceObjectType;
|
||||
filters?: any;
|
||||
fixedFilter?: any;
|
||||
|
||||
constructor(options: {scope?: string, query?: string, dsoType?: DSpaceObjectType, filters?: SearchFilter[]}) {
|
||||
this.scope = options.scope;
|
||||
this.query = options.query;
|
||||
this.dsoType = options.dsoType;
|
||||
this.filters = options.filters;
|
||||
}
|
||||
|
||||
/**
|
||||
* Method to generate the URL that can be used request information about a search request
|
||||
* @param {string} url The URL to the REST endpoint
|
||||
* @param {string[]} args A list of query arguments that should be included in the URL
|
||||
* @returns {string} URL with all search options and passed arguments as query parameters
|
||||
*/
|
||||
toRestUrl(url: string, args: string[] = []): string {
|
||||
if (isNotEmpty(this.fixedFilter)) {
|
||||
args.push(this.fixedFilter);
|
||||
@@ -17,13 +36,15 @@ export class SearchOptions {
|
||||
if (isNotEmpty(this.query)) {
|
||||
args.push(`query=${this.query}`);
|
||||
}
|
||||
|
||||
if (isNotEmpty(this.scope)) {
|
||||
args.push(`scope=${this.scope}`);
|
||||
}
|
||||
if (isNotEmpty(this.dsoType)) {
|
||||
args.push(`dsoType=${this.dsoType}`);
|
||||
}
|
||||
if (isNotEmpty(this.filters)) {
|
||||
Object.entries(this.filters).forEach(([key, values]) => {
|
||||
values.forEach((value) => args.push(`${key}=${value},equals`));
|
||||
this.filters.forEach((filter: SearchFilter) => {
|
||||
filter.values.forEach((value) => args.push(`${filter.key}=${value},${filter.operator}`));
|
||||
});
|
||||
}
|
||||
if (isNotEmpty(args)) {
|
||||
|
@@ -1,39 +1,40 @@
|
||||
<div class="container">
|
||||
<div class="search-page row">
|
||||
<ds-search-sidebar *ngIf="!(isMobileView$ | async)" class="col-3 sidebar-md-sticky"
|
||||
id="search-sidebar"
|
||||
[resultCount]="(resultsRD$ | async)?.pageInfo?.totalElements"></ds-search-sidebar>
|
||||
<div class="search-page row">
|
||||
<ds-search-sidebar *ngIf="!(isXsOrSm$ | async)" class="col-3 sidebar-md-sticky"
|
||||
id="search-sidebar"
|
||||
[resultCount]="(resultsRD$ | async)?.payload.totalElements"></ds-search-sidebar>
|
||||
<div class="col-12 col-md-9">
|
||||
<ds-search-form id="search-form"
|
||||
[query]="(searchOptions$ | async)?.query"
|
||||
[scope]="(searchOptions$ | async)?.scope"
|
||||
[currentUrl]="getSearchLink()"
|
||||
[scopes]="(scopeListRD$ | async)?.payload?.page">
|
||||
</ds-search-form>
|
||||
<div class="row">
|
||||
<div id="search-body"
|
||||
class="row-offcanvas row-offcanvas-left"
|
||||
[@pushInOut]="(isSidebarCollapsed() | async) ? 'collapsed' : 'expanded'">
|
||||
<ds-search-sidebar *ngIf="(isMobileView$ | async)" class="col-12"
|
||||
id="search-sidebar-sm"
|
||||
[resultCount]="(resultsRD$ | async)?.pageInfo?.totalElements"
|
||||
(toggleSidebar)="closeSidebar()"
|
||||
[ngClass]="{'active': !(isSidebarCollapsed() | async)}">
|
||||
</ds-search-sidebar>
|
||||
<div id="search-content" class="col-12">
|
||||
<div class="d-block d-md-none search-controls clearfix">
|
||||
<ds-view-mode-switch></ds-view-mode-switch>
|
||||
<button (click)="openSidebar()" aria-controls="#search-body"
|
||||
class="btn btn-outline-primary float-right open-sidebar"><i
|
||||
class="fa fa-sliders"></i> {{"search.sidebar.open"
|
||||
| translate}}
|
||||
</button>
|
||||
<ds-search-form id="search-form"
|
||||
[query]="(searchOptions$ | async)?.query"
|
||||
[scope]="(searchOptions$ | async)?.scope"
|
||||
[currentUrl]="getSearchLink()"
|
||||
[scopes]="(scopeListRD$ | async)">
|
||||
</ds-search-form>
|
||||
<ds-search-labels></ds-search-labels>
|
||||
<div class="row">
|
||||
<div id="search-body"
|
||||
class="row-offcanvas row-offcanvas-left"
|
||||
[@pushInOut]="(isSidebarCollapsed() | async) ? 'collapsed' : 'expanded'">
|
||||
<ds-search-sidebar *ngIf="(isXsOrSm$ | async)" class="col-12"
|
||||
id="search-sidebar-sm"
|
||||
[resultCount]="(resultsRD$ | async)?.payload.totalElements"
|
||||
(toggleSidebar)="closeSidebar()"
|
||||
[ngClass]="{'active': !(isSidebarCollapsed() | async)}">
|
||||
</ds-search-sidebar>
|
||||
<div id="search-content" class="col-12">
|
||||
<div class="d-block d-md-none search-controls clearfix">
|
||||
<ds-view-mode-switch></ds-view-mode-switch>
|
||||
<button (click)="openSidebar()" aria-controls="#search-body"
|
||||
class="btn btn-outline-primary float-right open-sidebar"><i
|
||||
class="fa fa-sliders"></i> {{"search.sidebar.open"
|
||||
| translate}}
|
||||
</button>
|
||||
</div>
|
||||
<ds-search-results [searchResults]="resultsRD$ | async"
|
||||
[searchConfig]="searchOptions$ | async"></ds-search-results>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<ds-search-results [searchResults]="resultsRD$ | async"
|
||||
[searchConfig]="searchOptions$ | async" [sortConfig]="sortConfig" [fixedFilter]="fixedFilter | async"></ds-search-results>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
@@ -6,6 +6,7 @@ import { Store } from '@ngrx/store';
|
||||
import { TranslateModule } from '@ngx-translate/core';
|
||||
import { cold, hot } from 'jasmine-marbles';
|
||||
import { Observable } from 'rxjs/Observable';
|
||||
import 'rxjs/add/observable/of';
|
||||
import { SortDirection, SortOptions } from '../core/cache/models/sort-options.model';
|
||||
import { CommunityDataService } from '../core/data/community-data.service';
|
||||
import { HostWindowService } from '../shared/host-window.service';
|
||||
@@ -18,6 +19,8 @@ import { By } from '@angular/platform-browser';
|
||||
import { NgbCollapseModule } from '@ng-bootstrap/ng-bootstrap';
|
||||
import { SearchSidebarService } from './search-sidebar/search-sidebar.service';
|
||||
import { SearchFilterService } from './search-filters/search-filter/search-filter.service';
|
||||
import { SearchConfigurationService } from './search-service/search-configuration.service';
|
||||
import { RemoteData } from '../core/data/remote-data';
|
||||
|
||||
describe('SearchPageComponent', () => {
|
||||
let comp: SearchPageComponent;
|
||||
@@ -34,10 +37,11 @@ describe('SearchPageComponent', () => {
|
||||
pagination.currentPage = 1;
|
||||
pagination.pageSize = 10;
|
||||
const sort: SortOptions = new SortOptions('score', SortDirection.DESC);
|
||||
const mockResults = Observable.of(['test', 'data']);
|
||||
const mockResults = Observable.of(new RemoteData(false, false, true, null, ['test', 'data']));
|
||||
const searchServiceStub = jasmine.createSpyObj('SearchService', {
|
||||
search: mockResults,
|
||||
getSearchLink: '/search'
|
||||
getSearchLink: '/search',
|
||||
getScopes: Observable.of(['test-scope'])
|
||||
});
|
||||
const queryParam = 'test query';
|
||||
const scopeParam = '7669c72a-3f2a-451f-a3b9-9210e7a4c02f';
|
||||
@@ -75,10 +79,11 @@ describe('SearchPageComponent', () => {
|
||||
},
|
||||
{
|
||||
provide: HostWindowService, useValue: jasmine.createSpyObj('hostWindowService',
|
||||
{
|
||||
isXs: Observable.of(true),
|
||||
isSm: Observable.of(false)
|
||||
})
|
||||
{
|
||||
isXs: Observable.of(true),
|
||||
isSm: Observable.of(false),
|
||||
isXsOrSm: Observable.of(true)
|
||||
})
|
||||
},
|
||||
{
|
||||
provide: SearchSidebarService,
|
||||
@@ -86,16 +91,20 @@ describe('SearchPageComponent', () => {
|
||||
},
|
||||
{
|
||||
provide: SearchFilterService,
|
||||
useValue: jasmine.createSpyObj('SearchFilterService', {
|
||||
getPaginatedSearchOptions: hot('a', {
|
||||
useValue: {}
|
||||
}, {
|
||||
provide: SearchConfigurationService,
|
||||
useValue: {
|
||||
paginatedSearchOptions: hot('a', {
|
||||
a: paginatedSearchOptions
|
||||
})
|
||||
})
|
||||
}),
|
||||
getCurrentScope: (a) => Observable.of('test-id')
|
||||
}
|
||||
},
|
||||
],
|
||||
schemas: [NO_ERRORS_SCHEMA]
|
||||
}).overrideComponent(SearchPageComponent, {
|
||||
set: { changeDetection: ChangeDetectionStrategy.Default }
|
||||
set: { changeDetection: ChangeDetectionStrategy.Default }
|
||||
}).compileComponents();
|
||||
}));
|
||||
|
||||
@@ -169,4 +178,4 @@ describe('SearchPageComponent', () => {
|
||||
});
|
||||
|
||||
});
|
||||
});
|
||||
})
|
||||
|
@@ -1,11 +1,8 @@
|
||||
import { ChangeDetectionStrategy, Component, OnDestroy, OnInit } from '@angular/core';
|
||||
import { ChangeDetectionStrategy, Component, OnInit } from '@angular/core';
|
||||
import { Observable } from 'rxjs/Observable';
|
||||
import { flatMap, } from 'rxjs/operators';
|
||||
import { SortDirection, SortOptions } from '../core/cache/models/sort-options.model';
|
||||
import { CommunityDataService } from '../core/data/community-data.service';
|
||||
import { flatMap, switchMap, } from 'rxjs/operators';
|
||||
import { PaginatedList } from '../core/data/paginated-list';
|
||||
import { RemoteData } from '../core/data/remote-data';
|
||||
import { Community } from '../core/shared/community.model';
|
||||
import { DSpaceObject } from '../core/shared/dspace-object.model';
|
||||
import { pushInOut } from '../shared/animations/push';
|
||||
import { HostWindowService } from '../shared/host-window.service';
|
||||
@@ -14,7 +11,11 @@ import { SearchFilterService } from './search-filters/search-filter/search-filte
|
||||
import { SearchResult } from './search-result.model';
|
||||
import { SearchService } from './search-service/search.service';
|
||||
import { SearchSidebarService } from './search-sidebar/search-sidebar.service';
|
||||
import { RouteService } from '../shared/route.service';
|
||||
import { Subscription } from 'rxjs/Subscription';
|
||||
import { hasValue } from '../shared/empty.util';
|
||||
import { BehaviorSubject } from 'rxjs/BehaviorSubject';
|
||||
import { SearchConfigurationService } from './search-service/search-configuration.service';
|
||||
import { getSucceededRemoteData } from '../core/shared/operators';
|
||||
|
||||
/**
|
||||
* This component renders a simple item page.
|
||||
@@ -29,61 +30,99 @@ import { RouteService } from '../shared/route.service';
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
animations: [pushInOut]
|
||||
})
|
||||
|
||||
/**
|
||||
* This component represents the whole search page
|
||||
*/
|
||||
export class SearchPageComponent implements OnInit {
|
||||
|
||||
resultsRD$: Observable<RemoteData<PaginatedList<SearchResult<DSpaceObject>>>>;
|
||||
/**
|
||||
* The current search results
|
||||
*/
|
||||
resultsRD$: BehaviorSubject<RemoteData<PaginatedList<SearchResult<DSpaceObject>>>> = new BehaviorSubject(null);
|
||||
|
||||
/**
|
||||
* The current paginated search options
|
||||
*/
|
||||
searchOptions$: Observable<PaginatedSearchOptions>;
|
||||
sortConfig: SortOptions;
|
||||
scopeListRD$: Observable<RemoteData<PaginatedList<Community>>>;
|
||||
isMobileView$: Observable<boolean>;
|
||||
pageSize;
|
||||
pageSizeOptions;
|
||||
defaults = {
|
||||
pagination: {
|
||||
id: 'search-results-pagination',
|
||||
pageSize: 10
|
||||
},
|
||||
sort: new SortOptions('score', SortDirection.DESC),
|
||||
query: '',
|
||||
scope: ''
|
||||
};
|
||||
fixedFilter;
|
||||
|
||||
/**
|
||||
* The current relevant scopes
|
||||
*/
|
||||
scopeListRD$: Observable<DSpaceObject[]>;
|
||||
|
||||
/**
|
||||
* Emits true if were on a small screen
|
||||
*/
|
||||
isXsOrSm$: Observable<boolean>;
|
||||
|
||||
/**
|
||||
* Subscription to unsubscribe from
|
||||
*/
|
||||
sub: Subscription;
|
||||
|
||||
constructor(protected service: SearchService,
|
||||
protected communityService: CommunityDataService,
|
||||
protected sidebarService: SearchSidebarService,
|
||||
protected windowService: HostWindowService,
|
||||
protected filterService: SearchFilterService,
|
||||
protected routeService: RouteService) {
|
||||
this.isMobileView$ = Observable.combineLatest(
|
||||
this.windowService.isXs(),
|
||||
this.windowService.isSm(),
|
||||
((isXs, isSm) => isXs || isSm)
|
||||
);
|
||||
this.scopeListRD$ = communityService.findAll();
|
||||
protected searchConfigService: SearchConfigurationService) {
|
||||
this.isXsOrSm$ = this.windowService.isXsOrSm();
|
||||
}
|
||||
|
||||
/**
|
||||
* Listening to changes in the paginated search options
|
||||
* If something changes, update the search results
|
||||
*
|
||||
* Listen to changes in the scope
|
||||
* If something changes, update the list of scopes for the dropdown
|
||||
*/
|
||||
ngOnInit(): void {
|
||||
this.searchOptions$ = this.filterService.getPaginatedSearchOptions(this.defaults);
|
||||
this.resultsRD$ = this.searchOptions$.pipe(
|
||||
flatMap((searchOptions) => this.service.search(searchOptions))
|
||||
this.searchOptions$ = this.searchConfigService.paginatedSearchOptions;
|
||||
this.sub = this.searchOptions$
|
||||
.switchMap((options) => this.service.search(options).pipe(getSucceededRemoteData()))
|
||||
.subscribe((results) => {
|
||||
this.resultsRD$.next(results);
|
||||
});
|
||||
this.scopeListRD$ = this.searchConfigService.getCurrentScope('').pipe(
|
||||
switchMap((scopeId) => this.service.getScopes(scopeId))
|
||||
);
|
||||
this.fixedFilter = this.routeService.getRouteParameterValue('filter');
|
||||
}
|
||||
|
||||
/**
|
||||
* Set the sidebar to a collapsed state
|
||||
*/
|
||||
public closeSidebar(): void {
|
||||
this.sidebarService.collapse()
|
||||
}
|
||||
|
||||
/**
|
||||
* Set the sidebar to an expanded state
|
||||
*/
|
||||
public openSidebar(): void {
|
||||
this.sidebarService.expand();
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if the sidebar is collapsed
|
||||
* @returns {Observable<boolean>} emits true if the sidebar is currently collapsed, false if it is expanded
|
||||
*/
|
||||
public isSidebarCollapsed(): Observable<boolean> {
|
||||
return this.sidebarService.isCollapsed;
|
||||
}
|
||||
|
||||
/**
|
||||
* @returns {string} The base path to the search page
|
||||
*/
|
||||
public getSearchLink(): string {
|
||||
return this.service.getSearchLink();
|
||||
}
|
||||
|
||||
/**
|
||||
* Unsubscribe from the subscription
|
||||
*/
|
||||
ngOnDestroy(): void {
|
||||
if (hasValue(this.sub)) {
|
||||
this.sub.unsubscribe();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@@ -24,6 +24,13 @@ import { SearchFilterService } from './search-filters/search-filter/search-filte
|
||||
import { FilteredSearchPageComponent } from './filtered-search-page.component';
|
||||
import { SearchFixedFilterService } from './search-filters/search-filter/search-fixed-filter.service';
|
||||
import { FilteredSearchPageGuard } from './filtered-search-page.guard';
|
||||
import { SearchLabelsComponent } from './search-labels/search-labels.component';
|
||||
import { SearchRangeFilterComponent } from './search-filters/search-filter/search-range-filter/search-range-filter.component';
|
||||
import { SearchTextFilterComponent } from './search-filters/search-filter/search-text-filter/search-text-filter.component';
|
||||
import { SearchFacetFilterWrapperComponent } from './search-filters/search-filter/search-facet-filter-wrapper/search-facet-filter-wrapper.component';
|
||||
import { SearchBooleanFilterComponent } from './search-filters/search-filter/search-boolean-filter/search-boolean-filter.component';
|
||||
import { SearchHierarchyFilterComponent } from './search-filters/search-filter/search-hierarchy-filter/search-hierarchy-filter.component';
|
||||
import { SearchConfigurationService } from './search-service/search-configuration.service';
|
||||
|
||||
const effects = [
|
||||
SearchSidebarEffects
|
||||
@@ -52,14 +59,23 @@ const effects = [
|
||||
CommunitySearchResultListElementComponent,
|
||||
SearchFiltersComponent,
|
||||
SearchFilterComponent,
|
||||
SearchFacetFilterComponent
|
||||
SearchFacetFilterComponent,
|
||||
SearchLabelsComponent,
|
||||
SearchFacetFilterComponent,
|
||||
SearchFacetFilterWrapperComponent,
|
||||
SearchRangeFilterComponent,
|
||||
SearchTextFilterComponent,
|
||||
SearchHierarchyFilterComponent,
|
||||
SearchBooleanFilterComponent,
|
||||
],
|
||||
providers: [
|
||||
SearchService,
|
||||
SearchSidebarService,
|
||||
SearchFilterService,
|
||||
SearchFixedFilterService,
|
||||
FilteredSearchPageGuard
|
||||
FilteredSearchPageGuard,
|
||||
SearchFilterService,
|
||||
SearchConfigurationService
|
||||
],
|
||||
entryComponents: [
|
||||
ItemSearchResultListElementComponent,
|
||||
@@ -68,7 +84,16 @@ const effects = [
|
||||
ItemSearchResultGridElementComponent,
|
||||
CollectionSearchResultGridElementComponent,
|
||||
CommunitySearchResultGridElementComponent,
|
||||
SearchFacetFilterComponent,
|
||||
SearchRangeFilterComponent,
|
||||
SearchTextFilterComponent,
|
||||
SearchHierarchyFilterComponent,
|
||||
SearchBooleanFilterComponent,
|
||||
]
|
||||
})
|
||||
|
||||
/**
|
||||
* This module handles all components and pipes that are necessary for the search page
|
||||
*/
|
||||
export class SearchPageModule {
|
||||
}
|
||||
|
@@ -2,9 +2,18 @@ import { DSpaceObject } from '../core/shared/dspace-object.model';
|
||||
import { Metadatum } from '../core/shared/metadatum.model';
|
||||
import { ListableObject } from '../shared/object-collection/shared/listable-object.model';
|
||||
|
||||
/**
|
||||
* Represents a search result object of a certain (<T>) DSpaceObject
|
||||
*/
|
||||
export class SearchResult<T extends DSpaceObject> implements ListableObject {
|
||||
|
||||
/**
|
||||
* The DSpaceObject that was found
|
||||
*/
|
||||
dspaceObject: T;
|
||||
|
||||
/**
|
||||
* The metadata that was used to find this item, hithighlighted
|
||||
*/
|
||||
hitHighlights: Metadatum[];
|
||||
|
||||
}
|
||||
|
@@ -22,8 +22,19 @@ import { hasValue, isNotEmpty } from '../../shared/empty.util';
|
||||
fadeInOut
|
||||
]
|
||||
})
|
||||
|
||||
/**
|
||||
* Component that represents all results from a search
|
||||
*/
|
||||
export class SearchResultsComponent {
|
||||
/**
|
||||
* The actual search result objects
|
||||
*/
|
||||
@Input() searchResults: RemoteData<PaginatedList<SearchResult<DSpaceObject>>>;
|
||||
|
||||
/**
|
||||
* The current configuration of the search
|
||||
*/
|
||||
@Input() searchConfig: SearchOptions;
|
||||
@Input() sortConfig: SortOptions;
|
||||
@Input() viewMode: SetViewMode;
|
||||
|
@@ -1,13 +1,25 @@
|
||||
|
||||
import { autoserialize, autoserializeAs } from 'cerialize';
|
||||
|
||||
/**
|
||||
* Class representing possible values for a certain filter
|
||||
*/
|
||||
export class FacetValue {
|
||||
/**
|
||||
* The display value of the facet value
|
||||
*/
|
||||
@autoserializeAs(String, 'label')
|
||||
value: string;
|
||||
|
||||
/**
|
||||
* The number of results this facet value would have if selected
|
||||
*/
|
||||
@autoserialize
|
||||
count: number;
|
||||
|
||||
/**
|
||||
* The REST url to add this filter value
|
||||
*/
|
||||
@autoserialize
|
||||
search: string;
|
||||
}
|
||||
|
@@ -1,6 +1,24 @@
|
||||
/**
|
||||
* Enumeration containing all possible types for filters
|
||||
*/
|
||||
export enum FilterType {
|
||||
text,
|
||||
date,
|
||||
hierarchical,
|
||||
standard
|
||||
/**
|
||||
* Represents simple text facets
|
||||
*/
|
||||
text = 'text',
|
||||
|
||||
/**
|
||||
* Represents date facets
|
||||
*/
|
||||
range = 'date',
|
||||
|
||||
/**
|
||||
* Represents hierarchically structured facets
|
||||
*/
|
||||
hierarchy = 'hierarchical',
|
||||
|
||||
/**
|
||||
* Represents binary facets
|
||||
*/
|
||||
boolean = 'standard'
|
||||
}
|
||||
|
@@ -0,0 +1,146 @@
|
||||
import { SearchConfigurationService } from './search-configuration.service';
|
||||
import { ActivatedRouteStub } from '../../shared/testing/active-router-stub';
|
||||
import { PaginationComponentOptions } from '../../shared/pagination/pagination-component-options.model';
|
||||
import { SortDirection, SortOptions } from '../../core/cache/models/sort-options.model';
|
||||
import { PaginatedSearchOptions } from '../paginated-search-options.model';
|
||||
import { Observable } from 'rxjs/Observable';
|
||||
import { SearchFilter } from '../search-filter.model';
|
||||
|
||||
describe('SearchConfigurationService', () => {
|
||||
let service: SearchConfigurationService;
|
||||
const value1 = 'random value';
|
||||
const prefixFilter = {
|
||||
'f.author': ['another value'],
|
||||
'f.date.min': ['2013'],
|
||||
'f.date.max': ['2018']
|
||||
};
|
||||
const defaults = new PaginatedSearchOptions({
|
||||
pagination: Object.assign(new PaginationComponentOptions(), { currentPage: 1, pageSize: 20 }),
|
||||
sort: new SortOptions('score', SortDirection.DESC),
|
||||
query: '',
|
||||
scope: ''
|
||||
});
|
||||
|
||||
const backendFilters = [new SearchFilter('f.author', ['another value']), new SearchFilter('f.date', ['[2013 TO 2018]'])];
|
||||
|
||||
const spy = jasmine.createSpyObj('RouteService', {
|
||||
getQueryParameterValue: Observable.of(value1),
|
||||
getQueryParamsWithPrefix: Observable.of(prefixFilter)
|
||||
});
|
||||
|
||||
const activatedRoute: any = new ActivatedRouteStub();
|
||||
|
||||
beforeEach(() => {
|
||||
service = new SearchConfigurationService(spy, activatedRoute);
|
||||
});
|
||||
|
||||
describe('when the scope is called', () => {
|
||||
beforeEach(() => {
|
||||
service.getCurrentScope('');
|
||||
});
|
||||
it('should call getQueryParameterValue on the routeService with parameter name \'scope\'', () => {
|
||||
expect((service as any).routeService.getQueryParameterValue).toHaveBeenCalledWith('scope');
|
||||
});
|
||||
});
|
||||
|
||||
describe('when getCurrentQuery is called', () => {
|
||||
beforeEach(() => {
|
||||
service.getCurrentQuery('');
|
||||
});
|
||||
it('should call getQueryParameterValue on the routeService with parameter name \'query\'', () => {
|
||||
expect((service as any).routeService.getQueryParameterValue).toHaveBeenCalledWith('query');
|
||||
});
|
||||
});
|
||||
|
||||
describe('when getCurrentDSOType is called', () => {
|
||||
beforeEach(() => {
|
||||
service.getCurrentDSOType();
|
||||
});
|
||||
it('should call getQueryParameterValue on the routeService with parameter name \'dsoType\'', () => {
|
||||
expect((service as any).routeService.getQueryParameterValue).toHaveBeenCalledWith('dsoType');
|
||||
});
|
||||
});
|
||||
|
||||
describe('when getCurrentFrontendFilters is called', () => {
|
||||
beforeEach(() => {
|
||||
service.getCurrentFrontendFilters();
|
||||
});
|
||||
it('should call getQueryParamsWithPrefix on the routeService with parameter prefix \'f.\'', () => {
|
||||
expect((service as any).routeService.getQueryParamsWithPrefix).toHaveBeenCalledWith('f.');
|
||||
});
|
||||
});
|
||||
|
||||
describe('when getCurrentFilters is called', () => {
|
||||
let parsedValues$;
|
||||
beforeEach(() => {
|
||||
parsedValues$ = service.getCurrentFilters();
|
||||
});
|
||||
it('should call getQueryParamsWithPrefix on the routeService with parameter prefix \'f.\'', () => {
|
||||
expect((service as any).routeService.getQueryParamsWithPrefix).toHaveBeenCalledWith('f.');
|
||||
parsedValues$.subscribe((values) => {
|
||||
expect(values).toEqual(backendFilters);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('when getCurrentSort is called', () => {
|
||||
beforeEach(() => {
|
||||
service.getCurrentSort({} as any);
|
||||
});
|
||||
it('should call getQueryParameterValue on the routeService with parameter name \'sortDirection\'', () => {
|
||||
expect((service as any).routeService.getQueryParameterValue).toHaveBeenCalledWith('sortDirection');
|
||||
});
|
||||
it('should call getQueryParameterValue on the routeService with parameter name \'sortField\'', () => {
|
||||
expect((service as any).routeService.getQueryParameterValue).toHaveBeenCalledWith('sortField');
|
||||
});
|
||||
});
|
||||
describe('when getCurrentPagination is called', () => {
|
||||
beforeEach(() => {
|
||||
service.getCurrentPagination({ currentPage: 1, pageSize: 10 } as any);
|
||||
});
|
||||
it('should call getQueryParameterValue on the routeService with parameter name \'page\'', () => {
|
||||
expect((service as any).routeService.getQueryParameterValue).toHaveBeenCalledWith('page');
|
||||
});
|
||||
it('should call getQueryParameterValue on the routeService with parameter name \'pageSize\'', () => {
|
||||
expect((service as any).routeService.getQueryParameterValue).toHaveBeenCalledWith('pageSize');
|
||||
});
|
||||
});
|
||||
describe('when subscribeToSearchOptions or subscribeToPaginatedSearchOptions is called', () => {
|
||||
beforeEach(() => {
|
||||
spyOn(service, 'getCurrentPagination').and.callThrough();
|
||||
spyOn(service, 'getCurrentSort').and.callThrough();
|
||||
spyOn(service, 'getCurrentScope').and.callThrough();
|
||||
spyOn(service, 'getCurrentQuery').and.callThrough();
|
||||
spyOn(service, 'getCurrentDSOType').and.callThrough();
|
||||
spyOn(service, 'getCurrentFilters').and.callThrough();
|
||||
});
|
||||
|
||||
describe('when subscribeToSearchOptions is called', () => {
|
||||
beforeEach(() => {
|
||||
service.subscribeToSearchOptions(defaults)
|
||||
});
|
||||
it('should call all getters it needs, but not call any others', () => {
|
||||
expect(service.getCurrentPagination).not.toHaveBeenCalled();
|
||||
expect(service.getCurrentSort).not.toHaveBeenCalled();
|
||||
expect(service.getCurrentScope).toHaveBeenCalled();
|
||||
expect(service.getCurrentQuery).toHaveBeenCalled();
|
||||
expect(service.getCurrentDSOType).toHaveBeenCalled();
|
||||
expect(service.getCurrentFilters).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('when subscribeToPaginatedSearchOptions is called', () => {
|
||||
beforeEach(() => {
|
||||
service.subscribeToPaginatedSearchOptions(defaults);
|
||||
});
|
||||
it('should call all getters it needs', () => {
|
||||
expect(service.getCurrentPagination).toHaveBeenCalled();
|
||||
expect(service.getCurrentSort).toHaveBeenCalled();
|
||||
expect(service.getCurrentScope).toHaveBeenCalled();
|
||||
expect(service.getCurrentQuery).toHaveBeenCalled();
|
||||
expect(service.getCurrentDSOType).toHaveBeenCalled();
|
||||
expect(service.getCurrentFilters).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
@@ -0,0 +1,292 @@
|
||||
import { SortDirection, SortOptions } from '../../core/cache/models/sort-options.model';
|
||||
import { PaginationComponentOptions } from '../../shared/pagination/pagination-component-options.model';
|
||||
import { SearchOptions } from '../search-options.model';
|
||||
import { Observable } from 'rxjs/Observable';
|
||||
import { ActivatedRoute, Params } from '@angular/router';
|
||||
import { PaginatedSearchOptions } from '../paginated-search-options.model';
|
||||
import { Injectable, OnDestroy } from '@angular/core';
|
||||
import { RouteService } from '../../shared/services/route.service';
|
||||
import { hasNoValue, hasValue, isEmpty, isNotEmpty } from '../../shared/empty.util';
|
||||
import { RemoteData } from '../../core/data/remote-data';
|
||||
import { BehaviorSubject } from 'rxjs/BehaviorSubject';
|
||||
import { Subscription } from 'rxjs/Subscription';
|
||||
import { getSucceededRemoteData } from '../../core/shared/operators';
|
||||
import { SearchFilter } from '../search-filter.model';
|
||||
import { DSpaceObjectType } from '../../core/shared/dspace-object-type.model';
|
||||
|
||||
/**
|
||||
* Service that performs all actions that have to do with the current search configuration
|
||||
*/
|
||||
@Injectable()
|
||||
export class SearchConfigurationService implements OnDestroy {
|
||||
/**
|
||||
* Default pagination settings
|
||||
*/
|
||||
private defaultPagination = Object.assign(new PaginationComponentOptions(), {
|
||||
id: 'search-page-configuration',
|
||||
pageSize: 10,
|
||||
currentPage: 1
|
||||
});
|
||||
|
||||
/**
|
||||
* Default sort settings
|
||||
*/
|
||||
private defaultSort = new SortOptions('score', SortDirection.DESC);
|
||||
|
||||
/**
|
||||
* Default scope setting
|
||||
*/
|
||||
private defaultScope = '';
|
||||
|
||||
/**
|
||||
* Default query setting
|
||||
*/
|
||||
private defaultQuery = '';
|
||||
|
||||
/**
|
||||
* Emits the current default values
|
||||
*/
|
||||
private _defaults: Observable<RemoteData<PaginatedSearchOptions>>;
|
||||
|
||||
/**
|
||||
* Emits the current search options
|
||||
*/
|
||||
public searchOptions: BehaviorSubject<SearchOptions>;
|
||||
|
||||
/**
|
||||
* Emits the current search options including pagination and sort
|
||||
*/
|
||||
public paginatedSearchOptions: BehaviorSubject<PaginatedSearchOptions>;
|
||||
|
||||
/**
|
||||
* List of subscriptions to unsubscribe from on destroy
|
||||
*/
|
||||
private subs: Subscription[] = new Array();
|
||||
|
||||
/**
|
||||
* Initialize the search options
|
||||
* @param {RouteService} routeService
|
||||
* @param {ActivatedRoute} route
|
||||
*/
|
||||
constructor(private routeService: RouteService,
|
||||
private route: ActivatedRoute) {
|
||||
this.defaults
|
||||
.pipe(getSucceededRemoteData())
|
||||
.subscribe((defRD) => {
|
||||
const defs = defRD.payload;
|
||||
this.paginatedSearchOptions = new BehaviorSubject<SearchOptions>(defs);
|
||||
this.searchOptions = new BehaviorSubject<PaginatedSearchOptions>(defs);
|
||||
|
||||
this.subs.push(this.subscribeToSearchOptions(defs));
|
||||
this.subs.push(this.subscribeToPaginatedSearchOptions(defs));
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* @returns {Observable<string>} Emits the current scope's identifier
|
||||
*/
|
||||
getCurrentScope(defaultScope: string) {
|
||||
return this.routeService.getQueryParameterValue('scope').map((scope) => {
|
||||
return scope || defaultScope;
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* @returns {Observable<string>} Emits the current query string
|
||||
*/
|
||||
getCurrentQuery(defaultQuery: string) {
|
||||
return this.routeService.getQueryParameterValue('query').map((query) => {
|
||||
return query || defaultQuery;
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* @returns {Observable<number>} Emits the current DSpaceObject type as a number
|
||||
*/
|
||||
getCurrentDSOType(): Observable<DSpaceObjectType> {
|
||||
return this.routeService.getQueryParameterValue('dsoType')
|
||||
.filter((type) => hasValue(type) && hasValue(DSpaceObjectType[type.toUpperCase()]))
|
||||
.map((type) => DSpaceObjectType[type.toUpperCase()]);
|
||||
}
|
||||
|
||||
/**
|
||||
* @returns {Observable<string>} Emits the current pagination settings
|
||||
*/
|
||||
getCurrentPagination(defaultPagination: PaginationComponentOptions): Observable<PaginationComponentOptions> {
|
||||
const page$ = this.routeService.getQueryParameterValue('page');
|
||||
const size$ = this.routeService.getQueryParameterValue('pageSize');
|
||||
return Observable.combineLatest(page$, size$, (page, size) => {
|
||||
return Object.assign(new PaginationComponentOptions(), defaultPagination, {
|
||||
currentPage: page || defaultPagination.currentPage,
|
||||
pageSize: size || defaultPagination.pageSize
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* @returns {Observable<string>} Emits the current sorting settings
|
||||
*/
|
||||
getCurrentSort(defaultSort: SortOptions): Observable<SortOptions> {
|
||||
const sortDirection$ = this.routeService.getQueryParameterValue('sortDirection');
|
||||
const sortField$ = this.routeService.getQueryParameterValue('sortField');
|
||||
return Observable.combineLatest(sortDirection$, sortField$, (sortDirection, sortField) => {
|
||||
// Dirty fix because sometimes the observable value is null somehow
|
||||
sortField = this.route.snapshot.queryParamMap.get('sortField');
|
||||
|
||||
const field = sortField || defaultSort.field;
|
||||
const direction = SortDirection[sortDirection] || defaultSort.direction;
|
||||
return new SortOptions(field, direction)
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* @returns {Observable<Params>} Emits the current active filters with their values as they are sent to the backend
|
||||
*/
|
||||
getCurrentFilters(): Observable<SearchFilter[]> {
|
||||
return this.routeService.getQueryParamsWithPrefix('f.').map((filterParams) => {
|
||||
if (isNotEmpty(filterParams)) {
|
||||
const filters = [];
|
||||
Object.keys(filterParams).forEach((key) => {
|
||||
if (key.endsWith('.min') || key.endsWith('.max')) {
|
||||
const realKey = key.slice(0, -4);
|
||||
if (hasNoValue(filters.find((filter) => filter.key === realKey))) {
|
||||
const min = filterParams[realKey + '.min'] ? filterParams[realKey + '.min'][0] : '*';
|
||||
const max = filterParams[realKey + '.max'] ? filterParams[realKey + '.max'][0] : '*';
|
||||
filters.push(new SearchFilter(realKey, ['[' + min + ' TO ' + max + ']']));
|
||||
}
|
||||
} else {
|
||||
filters.push(new SearchFilter(key, filterParams[key]));
|
||||
}
|
||||
});
|
||||
return filters;
|
||||
}
|
||||
return [];
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* @returns {Observable<Params>} Emits the current active filters with their values as they are displayed in the frontend URL
|
||||
*/
|
||||
getCurrentFrontendFilters(): Observable<Params> {
|
||||
return this.routeService.getQueryParamsWithPrefix('f.');
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets up a subscription to all necessary parameters to make sure the searchOptions emits a new value every time they update
|
||||
* @param {SearchOptions} defaults Default values for when no parameters are available
|
||||
* @returns {Subscription} The subscription to unsubscribe from
|
||||
*/
|
||||
subscribeToSearchOptions(defaults: SearchOptions): Subscription {
|
||||
return Observable.merge(
|
||||
this.getScopePart(defaults.scope),
|
||||
this.getQueryPart(defaults.query),
|
||||
this.getDSOTypePart(),
|
||||
this.getFiltersPart()
|
||||
).subscribe((update) => {
|
||||
const currentValue: SearchOptions = this.searchOptions.getValue();
|
||||
const updatedValue: SearchOptions = Object.assign(currentValue, update);
|
||||
this.searchOptions.next(updatedValue);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets up a subscription to all necessary parameters to make sure the paginatedSearchOptions emits a new value every time they update
|
||||
* @param {PaginatedSearchOptions} defaults Default values for when no parameters are available
|
||||
* @returns {Subscription} The subscription to unsubscribe from
|
||||
*/
|
||||
subscribeToPaginatedSearchOptions(defaults: PaginatedSearchOptions): Subscription {
|
||||
return Observable.merge(
|
||||
this.getPaginationPart(defaults.pagination),
|
||||
this.getSortPart(defaults.sort),
|
||||
this.getScopePart(defaults.scope),
|
||||
this.getQueryPart(defaults.query),
|
||||
this.getDSOTypePart(),
|
||||
this.getFiltersPart()
|
||||
).subscribe((update) => {
|
||||
const currentValue: PaginatedSearchOptions = this.paginatedSearchOptions.getValue();
|
||||
const updatedValue: PaginatedSearchOptions = Object.assign(currentValue, update);
|
||||
this.paginatedSearchOptions.next(updatedValue);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Default values for the Search Options
|
||||
*/
|
||||
get defaults(): Observable<RemoteData<PaginatedSearchOptions>> {
|
||||
if (hasNoValue(this._defaults)) {
|
||||
const options = new PaginatedSearchOptions({
|
||||
pagination: this.defaultPagination,
|
||||
sort: this.defaultSort,
|
||||
scope: this.defaultScope,
|
||||
query: this.defaultQuery
|
||||
});
|
||||
this._defaults = Observable.of(new RemoteData(false, false, true, null, options));
|
||||
}
|
||||
return this._defaults;
|
||||
}
|
||||
|
||||
/**
|
||||
* Make sure to unsubscribe from all existing subscription to prevent memory leaks
|
||||
*/
|
||||
ngOnDestroy(): void {
|
||||
this.subs.forEach((sub) => {
|
||||
sub.unsubscribe();
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* @returns {Observable<string>} Emits the current scope's identifier
|
||||
*/
|
||||
private getScopePart(defaultScope: string): Observable<any> {
|
||||
return this.getCurrentScope(defaultScope).map((scope) => {
|
||||
return { scope }
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* @returns {Observable<string>} Emits the current query string as a partial SearchOptions object
|
||||
*/
|
||||
private getQueryPart(defaultQuery: string): Observable<any> {
|
||||
return this.getCurrentQuery(defaultQuery).map((query) => {
|
||||
return { query }
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* @returns {Observable<string>} Emits the current query string as a partial SearchOptions object
|
||||
*/
|
||||
private getDSOTypePart(): Observable<any> {
|
||||
return this.getCurrentDSOType().map((dsoType) => {
|
||||
return { dsoType }
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* @returns {Observable<string>} Emits the current pagination settings as a partial SearchOptions object
|
||||
*/
|
||||
private getPaginationPart(defaultPagination: PaginationComponentOptions): Observable<any> {
|
||||
return this.getCurrentPagination(defaultPagination).map((pagination) => {
|
||||
return { pagination }
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* @returns {Observable<string>} Emits the current sorting settings as a partial SearchOptions object
|
||||
*/
|
||||
private getSortPart(defaultSort: SortOptions): Observable<any> {
|
||||
return this.getCurrentSort(defaultSort).map((sort) => {
|
||||
return { sort }
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* @returns {Observable<Params>} Emits the current active filters as a partial SearchOptions object
|
||||
*/
|
||||
private getFiltersPart(): Observable<any> {
|
||||
return this.getCurrentFilters().map((filters) => {
|
||||
return { filters }
|
||||
});
|
||||
}
|
||||
}
|
@@ -1,22 +1,53 @@
|
||||
import { FilterType } from './filter-type.model';
|
||||
import { autoserialize, autoserializeAs } from 'cerialize';
|
||||
|
||||
/**
|
||||
* The configuration for a search filter
|
||||
*/
|
||||
export class SearchFilterConfig {
|
||||
|
||||
/**
|
||||
* The name of this filter
|
||||
*/
|
||||
@autoserialize
|
||||
name: string;
|
||||
|
||||
/**
|
||||
* The FilterType of this filter
|
||||
*/
|
||||
@autoserializeAs(String, 'facetType')
|
||||
type: FilterType;
|
||||
|
||||
/**
|
||||
* True if the filter has facets
|
||||
*/
|
||||
@autoserialize
|
||||
hasFacets: boolean;
|
||||
|
||||
// @autoserializeAs(String, 'facetLimit') - uncomment when fixed in rest
|
||||
/**
|
||||
* @type {number} The page size used for this facet
|
||||
*/
|
||||
@autoserializeAs(String, 'facetLimit')
|
||||
pageSize = 5;
|
||||
|
||||
/**
|
||||
* Defines if the item facet is collapsed by default or not on the search page
|
||||
*/
|
||||
@autoserialize
|
||||
isOpenByDefault: boolean;
|
||||
|
||||
/**
|
||||
* Minimum value possible for this facet in the repository
|
||||
*/
|
||||
@autoserialize
|
||||
maxValue: string;
|
||||
|
||||
/**
|
||||
* Maximum value possible for this facet in the repository
|
||||
*/
|
||||
@autoserialize
|
||||
minValue: string;
|
||||
|
||||
/**
|
||||
* Name of this configuration that can be used in a url
|
||||
* @returns Parameter name
|
||||
|
@@ -2,46 +2,88 @@ import { autoserialize, autoserializeAs } from 'cerialize';
|
||||
import { PageInfo } from '../../core/shared/page-info.model';
|
||||
import { NormalizedSearchResult } from '../normalized-search-result.model';
|
||||
|
||||
/**
|
||||
* Class representing the response returned by the server when performing a search request
|
||||
*/
|
||||
export class SearchQueryResponse {
|
||||
/**
|
||||
* The scope used in the search request represented by the UUID of a DSpaceObject
|
||||
*/
|
||||
@autoserialize
|
||||
scope: string;
|
||||
|
||||
/**
|
||||
* The search query used in the search request
|
||||
*/
|
||||
@autoserialize
|
||||
query: string;
|
||||
|
||||
/**
|
||||
* The currently active filters used in the search request
|
||||
*/
|
||||
@autoserialize
|
||||
appliedFilters: any[]; // TODO
|
||||
|
||||
/**
|
||||
* The sort parameters used in the search request
|
||||
*/
|
||||
@autoserialize
|
||||
sort: any; // TODO
|
||||
|
||||
/**
|
||||
* The sort parameters used in the search request
|
||||
*/
|
||||
@autoserialize
|
||||
configurationName: string;
|
||||
|
||||
/**
|
||||
* The sort parameters used in the search request
|
||||
*/
|
||||
@autoserialize
|
||||
public type: string;
|
||||
|
||||
/**
|
||||
* Pagination configuration for this response
|
||||
*/
|
||||
@autoserialize
|
||||
page: PageInfo;
|
||||
|
||||
/**
|
||||
* The results for this query
|
||||
*/
|
||||
@autoserializeAs(NormalizedSearchResult)
|
||||
objects: NormalizedSearchResult[];
|
||||
|
||||
@autoserialize
|
||||
facets: any; // TODO
|
||||
|
||||
/**
|
||||
* The REST url to retrieve the current response
|
||||
*/
|
||||
@autoserialize
|
||||
self: string;
|
||||
|
||||
/**
|
||||
* The REST url to retrieve the next response
|
||||
*/
|
||||
@autoserialize
|
||||
next: string;
|
||||
|
||||
/**
|
||||
* The REST url to retrieve the previous response
|
||||
*/
|
||||
@autoserialize
|
||||
previous: string;
|
||||
|
||||
/**
|
||||
* The REST url to retrieve the first response
|
||||
*/
|
||||
@autoserialize
|
||||
first: string;
|
||||
|
||||
/**
|
||||
* The REST url to retrieve the last response
|
||||
*/
|
||||
@autoserialize
|
||||
last: string;
|
||||
}
|
||||
|
@@ -1,8 +1,16 @@
|
||||
import { GenericConstructor } from '../../core/shared/generic-constructor';
|
||||
import { ListableObject } from '../../shared/object-collection/shared/listable-object.model';
|
||||
|
||||
/**
|
||||
* Contains the mapping between a search result component and a DSpaceObject
|
||||
*/
|
||||
const searchResultMap = new Map();
|
||||
|
||||
/**
|
||||
* Used to map Search Result components to their matching DSpaceObject
|
||||
* @param {GenericConstructor<ListableObject>} domainConstructor The constructor of the DSpaceObject
|
||||
* @returns Decorator function that performs the actual mapping on initialization of the component
|
||||
*/
|
||||
export function searchResultFor(domainConstructor: GenericConstructor<ListableObject>) {
|
||||
return function decorator(searchResult: any) {
|
||||
if (!searchResult) {
|
||||
@@ -12,6 +20,11 @@ export function searchResultFor(domainConstructor: GenericConstructor<ListableOb
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Requests the matching component based on a given DSpaceObject's constructor
|
||||
* @param {GenericConstructor<ListableObject>} domainConstructor The DSpaceObject's constructor for which the search result component is requested
|
||||
* @returns The component's constructor that matches the given DSpaceObject
|
||||
*/
|
||||
export function getSearchResultFor(domainConstructor: GenericConstructor<ListableObject>) {
|
||||
return searchResultMap.get(domainConstructor);
|
||||
}
|
||||
|
@@ -1,4 +1,4 @@
|
||||
import { TestBed, fakeAsync, tick } from '@angular/core/testing';
|
||||
import { TestBed } from '@angular/core/testing';
|
||||
import { RouterTestingModule } from '@angular/router/testing';
|
||||
|
||||
import { CommonModule } from '@angular/common';
|
||||
@@ -7,7 +7,6 @@ import { Component } from '@angular/core';
|
||||
import { SearchService } from './search.service';
|
||||
import { ItemDataService } from './../../core/data/item-data.service';
|
||||
import { SetViewMode } from '../../shared/view-mode';
|
||||
import { RouteService } from '../../shared/route.service';
|
||||
import { GLOBAL_CONFIG } from '../../../config';
|
||||
import { RemoteDataBuildService } from '../../core/cache/builders/remote-data-build.service';
|
||||
import { ActivatedRoute, Router, UrlTree } from '@angular/router';
|
||||
@@ -19,19 +18,19 @@ import { HALEndpointService } from '../../core/shared/hal-endpoint.service';
|
||||
import { Observable } from 'rxjs/Observable';
|
||||
import { PaginatedSearchOptions } from '../paginated-search-options.model';
|
||||
import { RemoteData } from '../../core/data/remote-data';
|
||||
import { PaginatedList } from '../../core/data/paginated-list';
|
||||
import { SearchResult } from '../search-result.model';
|
||||
import { DSpaceObject } from '../../core/shared/dspace-object.model';
|
||||
import { ResponseCacheEntry } from '../../core/cache/response-cache.reducer';
|
||||
import { RequestEntry } from '../../core/data/request.reducer';
|
||||
import { getMockRequestService } from '../../shared/mocks/mock-request.service';
|
||||
import { getMockResponseCacheService } from '../../shared/mocks/mock-response-cache.service';
|
||||
import {
|
||||
FacetConfigSuccessResponse, RestResponse,
|
||||
FacetConfigSuccessResponse,
|
||||
SearchSuccessResponse
|
||||
} from '../../core/cache/response-cache.models';
|
||||
import { SearchQueryResponse } from './search-query-response.model';
|
||||
import { SearchFilterConfig } from './search-filter-config.model';
|
||||
import { CommunityDataService } from '../../core/data/community-data.service';
|
||||
import { ViewMode } from '../../core/shared/view-mode.model';
|
||||
import { DSpaceObjectDataService } from '../../core/data/dspace-object-data.service';
|
||||
|
||||
@Component({ template: '' })
|
||||
class DummyComponent {
|
||||
@@ -60,6 +59,8 @@ describe('SearchService', () => {
|
||||
{ provide: RequestService, useValue: getMockRequestService() },
|
||||
{ provide: RemoteDataBuildService, useValue: {} },
|
||||
{ provide: HALEndpointService, useValue: {} },
|
||||
{ provide: CommunityDataService, useValue: {}},
|
||||
{ provide: DSpaceObjectDataService, useValue: {}},
|
||||
SearchService
|
||||
],
|
||||
});
|
||||
@@ -115,6 +116,8 @@ describe('SearchService', () => {
|
||||
{ provide: RequestService, useValue: getMockRequestService() },
|
||||
{ provide: RemoteDataBuildService, useValue: remoteDataBuildService },
|
||||
{ provide: HALEndpointService, useValue: halService },
|
||||
{ provide: CommunityDataService, useValue: {}},
|
||||
{ provide: DSpaceObjectDataService, useValue: {}},
|
||||
SearchService
|
||||
],
|
||||
});
|
||||
@@ -155,7 +158,7 @@ describe('SearchService', () => {
|
||||
|
||||
describe('when search is called', () => {
|
||||
const endPoint = 'http://endpoint.com/test/test';
|
||||
const searchOptions = new PaginatedSearchOptions();
|
||||
const searchOptions = new PaginatedSearchOptions({});
|
||||
const queryResponse = Object.assign(new SearchQueryResponse(), { objects: [] });
|
||||
const response = new SearchSuccessResponse(queryResponse, '200');
|
||||
const responseEntry = Object.assign(new ResponseCacheEntry(), { response: response });
|
||||
|
@@ -1,13 +1,15 @@
|
||||
import { Injectable, OnDestroy } from '@angular/core';
|
||||
import {
|
||||
ActivatedRoute, NavigationExtras, PRIMARY_OUTLET, Router,
|
||||
ActivatedRoute,
|
||||
NavigationExtras,
|
||||
PRIMARY_OUTLET,
|
||||
Router,
|
||||
UrlSegmentGroup
|
||||
} from '@angular/router';
|
||||
import { Observable } from 'rxjs/Observable';
|
||||
import { flatMap, map, tap } from 'rxjs/operators';
|
||||
import { flatMap, map, switchMap } from 'rxjs/operators';
|
||||
import { SetViewMode } from '../../shared/view-mode';
|
||||
import { RemoteDataBuildService } from '../../core/cache/builders/remote-data-build.service';
|
||||
import { SortDirection, SortOptions } from '../../core/cache/models/sort-options.model';
|
||||
import {
|
||||
FacetConfigSuccessResponse,
|
||||
FacetValueSuccessResponse,
|
||||
@@ -23,10 +25,9 @@ import { RequestService } from '../../core/data/request.service';
|
||||
import { DSpaceObject } from '../../core/shared/dspace-object.model';
|
||||
import { GenericConstructor } from '../../core/shared/generic-constructor';
|
||||
import { HALEndpointService } from '../../core/shared/hal-endpoint.service';
|
||||
import { configureRequest } from '../../core/shared/operators';
|
||||
import { configureRequest, getSucceededRemoteData } from '../../core/shared/operators';
|
||||
import { URLCombiner } from '../../core/url-combiner/url-combiner';
|
||||
import { hasValue, isEmpty, isNotEmpty } from '../../shared/empty.util';
|
||||
import { PaginationComponentOptions } from '../../shared/pagination/pagination-component-options.model';
|
||||
import { NormalizedSearchResult } from '../normalized-search-result.model';
|
||||
import { SearchOptions } from '../search-options.model';
|
||||
import { SearchResult } from '../search-result.model';
|
||||
@@ -40,32 +41,48 @@ import { ListableObject } from '../../shared/object-collection/shared/listable-o
|
||||
import { FacetValueResponseParsingService } from '../../core/data/facet-value-response-parsing.service';
|
||||
import { FacetConfigResponseParsingService } from '../../core/data/facet-config-response-parsing.service';
|
||||
import { PaginatedSearchOptions } from '../paginated-search-options.model';
|
||||
import { observable } from 'rxjs/symbol/observable';
|
||||
import { Community } from '../../core/shared/community.model';
|
||||
import { CommunityDataService } from '../../core/data/community-data.service';
|
||||
import { ViewMode } from '../../core/shared/view-mode.model';
|
||||
import { ResourceType } from '../../core/shared/resource-type';
|
||||
import { DSpaceObjectDataService } from '../../core/data/dspace-object-data.service';
|
||||
|
||||
/**
|
||||
* Service that performs all general actions that have to do with the search page
|
||||
*/
|
||||
@Injectable()
|
||||
export class SearchService implements OnDestroy {
|
||||
/**
|
||||
* Endpoint link path for retrieving general search results
|
||||
*/
|
||||
private searchLinkPath = 'discover/search/objects';
|
||||
private facetValueLinkPathPrefix = 'discover/facets/';
|
||||
private facetConfigLinkPath = 'discover/facets';
|
||||
|
||||
/**
|
||||
* Endpoint link path for retrieving facet config incl values
|
||||
*/
|
||||
private facetLinkPathPrefix = 'discover/facets/';
|
||||
|
||||
/**
|
||||
* Subscription to unsubscribe from
|
||||
*/
|
||||
private sub;
|
||||
|
||||
searchOptions: SearchOptions;
|
||||
|
||||
constructor(private router: Router,
|
||||
private route: ActivatedRoute,
|
||||
protected responseCache: ResponseCacheService,
|
||||
protected requestService: RequestService,
|
||||
private rdb: RemoteDataBuildService,
|
||||
private halService: HALEndpointService) {
|
||||
const pagination: PaginationComponentOptions = new PaginationComponentOptions();
|
||||
pagination.id = 'search-results-pagination';
|
||||
pagination.currentPage = 1;
|
||||
pagination.pageSize = 10;
|
||||
const sort: SortOptions = new SortOptions('score', SortDirection.DESC);
|
||||
this.searchOptions = Object.assign(new SearchOptions(), { pagination: pagination, sort: sort });
|
||||
private halService: HALEndpointService,
|
||||
private communityService: CommunityDataService,
|
||||
private dspaceObjectService: DSpaceObjectDataService
|
||||
) {
|
||||
}
|
||||
|
||||
/**
|
||||
* Method to retrieve a paginated list of search results from the server
|
||||
* @param {PaginatedSearchOptions} searchOptions The configuration necessary to perform this search
|
||||
* @returns {Observable<RemoteData<PaginatedList<SearchResult<DSpaceObject>>>>} Emits a paginated list with all search results found
|
||||
*/
|
||||
search(searchOptions?: PaginatedSearchOptions): Observable<RemoteData<PaginatedList<SearchResult<DSpaceObject>>>> {
|
||||
const requestObs = this.halService.getEndpoint(this.searchLinkPath).pipe(
|
||||
map((url: string) => {
|
||||
@@ -134,8 +151,13 @@ export class SearchService implements OnDestroy {
|
||||
return this.rdb.toRemoteDataObservable(requestEntryObs, responseCacheObs, payloadObs);
|
||||
}
|
||||
|
||||
/**
|
||||
* Request the filter configuration for a given scope or the whole repository
|
||||
* @param {string} scope UUID of the object for which config the filter config is requested, when no scope is provided the configuration for the whole repository is loaded
|
||||
* @returns {Observable<RemoteData<SearchFilterConfig[]>>} The found filter configuration
|
||||
*/
|
||||
getConfig(scope?: string): Observable<RemoteData<SearchFilterConfig[]>> {
|
||||
const requestObs = this.halService.getEndpoint(this.facetConfigLinkPath).pipe(
|
||||
const requestObs = this.halService.getEndpoint(this.facetLinkPathPrefix).pipe(
|
||||
map((url: string) => {
|
||||
const args: string[] = [];
|
||||
|
||||
@@ -175,13 +197,25 @@ export class SearchService implements OnDestroy {
|
||||
return this.rdb.toRemoteDataObservable(requestEntryObs, responseCacheObs, facetConfigObs);
|
||||
}
|
||||
|
||||
getFacetValuesFor(filterConfig: SearchFilterConfig, valuePage: number, searchOptions?: SearchOptions): Observable<RemoteData<PaginatedList<FacetValue>>> {
|
||||
const requestObs = this.halService.getEndpoint(this.facetValueLinkPathPrefix + filterConfig.name).pipe(
|
||||
/**
|
||||
* Method to request a single page of filter values for a given value
|
||||
* @param {SearchFilterConfig} filterConfig The filter config for which we want to request filter values
|
||||
* @param {number} valuePage The page number of the filter values
|
||||
* @param {SearchOptions} searchOptions The search configuration for the current search
|
||||
* @param {string} filterQuery The optional query used to filter out filter values
|
||||
* @returns {Observable<RemoteData<PaginatedList<FacetValue>>>} Emits the given page of facet values
|
||||
*/
|
||||
getFacetValuesFor(filterConfig: SearchFilterConfig, valuePage: number, searchOptions?: SearchOptions, filterQuery?: string): Observable<RemoteData<PaginatedList<FacetValue>>> {
|
||||
const requestObs = this.halService.getEndpoint(this.facetLinkPathPrefix + filterConfig.name).pipe(
|
||||
map((url: string) => {
|
||||
const args: string[] = [`page=${valuePage - 1}`, `size=${filterConfig.pageSize}`];
|
||||
if (hasValue(filterQuery)) {
|
||||
args.push(`prefix=${filterQuery}`);
|
||||
}
|
||||
if (hasValue(searchOptions)) {
|
||||
url = searchOptions.toRestUrl(url, args);
|
||||
}
|
||||
|
||||
const request = new GetRequest(this.requestService.generateRequestId(), url);
|
||||
return Object.assign(request, {
|
||||
getResponseParser(): GenericConstructor<ResponseParsingService> {
|
||||
@@ -218,6 +252,45 @@ export class SearchService implements OnDestroy {
|
||||
return this.rdb.toRemoteDataObservable(requestEntryObs, responseCacheObs, payloadObs);
|
||||
}
|
||||
|
||||
/**
|
||||
* Request a list of DSpaceObjects that can be used as a scope, based on the current scope
|
||||
* @param {string} scopeId UUID of the current scope, if the scope is empty, the repository wide scopes will be returned
|
||||
* @returns {Observable<DSpaceObject[]>} Emits a list of DSpaceObjects which represent possible scopes
|
||||
*/
|
||||
getScopes(scopeId?: string): Observable<DSpaceObject[]> {
|
||||
|
||||
if (isEmpty(scopeId)) {
|
||||
const top: Observable<Community[]> = this.communityService.findTop({ elementsPerPage: 9999 }).pipe(
|
||||
map(
|
||||
(communities: RemoteData<PaginatedList<Community>>) => communities.payload.page
|
||||
)
|
||||
);
|
||||
return top;
|
||||
}
|
||||
|
||||
const scopeObject: Observable<RemoteData<DSpaceObject>> = this.dspaceObjectService.findById(scopeId).pipe(getSucceededRemoteData());
|
||||
const scopeList: Observable<DSpaceObject[]> = scopeObject.pipe(
|
||||
switchMap((dsoRD: RemoteData<DSpaceObject>) => {
|
||||
if (dsoRD.payload.type === ResourceType.Community) {
|
||||
const community: Community = dsoRD.payload as Community;
|
||||
return Observable.combineLatest(community.subcommunities, community.collections, (subCommunities, collections) => {
|
||||
/*if this is a community, we also need to show the direct children*/
|
||||
return [community, ...subCommunities.payload.page, ...collections.payload.page]
|
||||
})
|
||||
} else {
|
||||
return Observable.of([dsoRD.payload]);
|
||||
}
|
||||
}
|
||||
));
|
||||
|
||||
return scopeList;
|
||||
|
||||
}
|
||||
|
||||
/**
|
||||
* Requests the current view mode based on the current URL
|
||||
* @returns {Observable<ViewMode>} The current view mode
|
||||
*/
|
||||
getViewMode(): Observable<SetViewMode> {
|
||||
return this.route.queryParams.map((params) => {
|
||||
if (isNotEmpty(params.view) && hasValue(params.view)) {
|
||||
@@ -228,6 +301,10 @@ export class SearchService implements OnDestroy {
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Changes the current view mode in the current URL
|
||||
* @param {ViewMode} viewMode Mode to switch to
|
||||
*/
|
||||
setViewMode(viewMode: SetViewMode) {
|
||||
const navigationExtras: NavigationExtras = {
|
||||
queryParams: { view: viewMode },
|
||||
@@ -237,12 +314,18 @@ export class SearchService implements OnDestroy {
|
||||
this.router.navigate([this.getSearchLink()], navigationExtras);
|
||||
}
|
||||
|
||||
/**
|
||||
* @returns {string} The base path to the search page
|
||||
*/
|
||||
getSearchLink(): string {
|
||||
const urlTree = this.router.parseUrl(this.router.url);
|
||||
const g: UrlSegmentGroup = urlTree.root.children[PRIMARY_OUTLET];
|
||||
return '/' + g.toString();
|
||||
}
|
||||
|
||||
/**
|
||||
* Unsubscribe from the subscription
|
||||
*/
|
||||
ngOnDestroy(): void {
|
||||
if (this.sub !== undefined) {
|
||||
this.sub.unsubscribe();
|
||||
|
@@ -1,22 +1,24 @@
|
||||
<h3>{{ 'search.sidebar.settings.title' | translate}}</h3>
|
||||
<div *ngIf="[searchOptions].sort" class="setting-option result-order-settings mb-3 p-3">
|
||||
<ng-container *ngVar="(searchOptions$ | async) as config">
|
||||
<h3>{{ 'search.sidebar.settings.title' | translate}}</h3>
|
||||
<div *ngIf="config?.sort" class="setting-option result-order-settings mb-3 p-3">
|
||||
<h5>{{ 'search.sidebar.settings.sort-by' | translate}}</h5>
|
||||
<select class="form-control" (change)="reloadOrder($event)">
|
||||
<option *ngFor="let sortDirection of (sortDirections | dsKeys)"
|
||||
[value]="sortDirection.value"
|
||||
[selected]="sortDirection.value === direction? 'selected': null">
|
||||
{{'sorting.' + sortDirection.key | translate}}
|
||||
<option *ngFor="let sortOption of searchOptionPossibilities"
|
||||
[value]="sortOption.field + ',' + sortOption.direction.toString()"
|
||||
[selected]="sortOption.field === config?.sort.field && sortOption.direction === (config?.sort.direction)? 'selected': null">
|
||||
{{'sorting.' + sortOption.field + '.' + sortOption.direction | translate}}
|
||||
</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="setting-option page-size-settings mb-3 p-3">
|
||||
<h5>{{ 'search.sidebar.settings.rpp' | translate}}</h5>
|
||||
|
||||
<select class="form-control" (change)="reloadRPP($event)">
|
||||
<option *ngFor="let pageSizeOption of pageSizeOptions" [value]="pageSizeOption"
|
||||
[selected]="pageSizeOption === pageSize ? 'selected': null">
|
||||
{{pageSizeOption}}
|
||||
</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="setting-option page-size-settings mb-3 p-3">
|
||||
<h5>{{ 'search.sidebar.settings.rpp' | translate}}</h5>
|
||||
<select class="form-control" (change)="reloadRPP($event)">
|
||||
<option *ngFor="let pageSizeOption of config?.pagination.pageSizeOptions"
|
||||
[value]="pageSizeOption"
|
||||
[selected]="pageSizeOption === +config?.pagination.pageSize ? 'selected': null">
|
||||
{{pageSizeOption}}
|
||||
</option>
|
||||
</select>
|
||||
</div>
|
||||
</ng-container>
|
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user