mirror of
https://github.com/DSpace/dspace-angular.git
synced 2025-10-18 15:33:04 +00:00
Merge remote-tracking branch 'upstream/main' into fix-specs-without-expectations_contribute-main
# Conflicts: # src/app/shared/search/search-filters/search-filter/search-hierarchy-filter/search-hierarchy-filter.component.ts
This commit is contained in:
@@ -136,7 +136,7 @@ submission:
|
||||
# NOTE: example of configuration
|
||||
# # NOTE: metadata name
|
||||
# - name: dc.author
|
||||
# # NOTE: fontawesome (v5.x) icon classes and bootstrap utility classes can be used
|
||||
# # NOTE: fontawesome (v6.x) icon classes and bootstrap utility classes can be used
|
||||
# style: fas fa-user
|
||||
- name: dc.author
|
||||
style: fas fa-user
|
||||
@@ -147,18 +147,40 @@ submission:
|
||||
confidence:
|
||||
# NOTE: example of configuration
|
||||
# # NOTE: confidence value
|
||||
# - name: dc.author
|
||||
# # NOTE: fontawesome (v5.x) icon classes and bootstrap utility classes can be used
|
||||
# style: fa-user
|
||||
# - value: 600
|
||||
# # NOTE: fontawesome (v6.x) icon classes and bootstrap utility classes can be used
|
||||
# style: text-success
|
||||
# icon: fa-circle-check
|
||||
# # NOTE: the class configured in property style is used by default, the icon property could be used in component
|
||||
# configured to use a 'icon mode' display (mainly in edit-item page)
|
||||
- value: 600
|
||||
style: text-success
|
||||
icon: fa-circle-check
|
||||
- value: 500
|
||||
style: text-info
|
||||
icon: fa-gear
|
||||
- value: 400
|
||||
style: text-warning
|
||||
icon: fa-circle-question
|
||||
- value: 300
|
||||
style: text-muted
|
||||
icon: fa-thumbs-down
|
||||
- value: 200
|
||||
style: text-muted
|
||||
icon: fa-circle-exclamation
|
||||
- value: 100
|
||||
style: text-muted
|
||||
icon: fa-circle-stop
|
||||
- value: 0
|
||||
style: text-muted
|
||||
icon: fa-ban
|
||||
- value: -1
|
||||
style: text-muted
|
||||
icon: fa-circle-xmark
|
||||
# default configuration
|
||||
- value: default
|
||||
style: text-muted
|
||||
icon: fa-circle-xmark
|
||||
|
||||
# Default Language in which the UI will be rendered if the user's browser language is not an active language
|
||||
defaultLanguage: en
|
||||
@@ -272,6 +294,8 @@ homePage:
|
||||
# No. of communities to list per page on the home page
|
||||
# This will always round to the nearest number from the list of page sizes. e.g. if you set it to 7 it'll use 10
|
||||
pageSize: 5
|
||||
# Enable or disable the Discover filters on the homepage
|
||||
showDiscoverFilters: false
|
||||
|
||||
# Item Config
|
||||
item:
|
||||
@@ -285,8 +309,17 @@ item:
|
||||
# settings menu. See pageSizeOptions in 'pagination-component-options.model.ts'.
|
||||
pageSize: 5
|
||||
|
||||
# Community Page Config
|
||||
community:
|
||||
# Search tab config
|
||||
searchSection:
|
||||
showSidebar: true
|
||||
|
||||
# Collection Page Config
|
||||
collection:
|
||||
# Search tab config
|
||||
searchSection:
|
||||
showSidebar: true
|
||||
edit:
|
||||
undoTimeout: 10000 # 10 seconds
|
||||
|
||||
@@ -391,4 +424,3 @@ comcolSelectionSort:
|
||||
# suggestion:
|
||||
# - collectionId: 8f7df5ca-f9c2-47a4-81ec-8a6393d6e5af
|
||||
# source: "openaire"
|
||||
|
||||
|
@@ -1,12 +0,0 @@
|
||||
:host {
|
||||
::ng-deep {
|
||||
.switch {
|
||||
position: absolute;
|
||||
top: calc(var(--bs-spacer) * 2.5);
|
||||
}
|
||||
}
|
||||
}
|
||||
:host ::ng-deep ds-dynamic-form-control-container > div > label {
|
||||
margin-top: 1.75rem;
|
||||
}
|
||||
|
||||
|
@@ -2,14 +2,35 @@ import { ChangeDetectionStrategy, ChangeDetectorRef, Component, OnDestroy, OnIni
|
||||
import { Bitstream } from '../../core/shared/bitstream.model';
|
||||
import { ActivatedRoute, Router } from '@angular/router';
|
||||
import { filter, map, switchMap, tap } from 'rxjs/operators';
|
||||
import { combineLatest, combineLatest as observableCombineLatest, Observable, of as observableOf, Subscription } from 'rxjs';
|
||||
import { DynamicFormControlModel, DynamicFormGroupModel, DynamicFormLayout, DynamicFormService, DynamicInputModel, DynamicSelectModel } from '@ng-dynamic-forms/core';
|
||||
import {
|
||||
combineLatest,
|
||||
combineLatest as observableCombineLatest,
|
||||
Observable,
|
||||
of as observableOf,
|
||||
Subscription
|
||||
} from 'rxjs';
|
||||
import {
|
||||
DynamicFormControlModel,
|
||||
DynamicFormGroupModel,
|
||||
DynamicFormLayout,
|
||||
DynamicFormService,
|
||||
DynamicInputModel,
|
||||
DynamicSelectModel
|
||||
} from '@ng-dynamic-forms/core';
|
||||
import { UntypedFormGroup } from '@angular/forms';
|
||||
import { TranslateService } from '@ngx-translate/core';
|
||||
import { DynamicCustomSwitchModel } from '../../shared/form/builder/ds-dynamic-form-ui/models/custom-switch/custom-switch.model';
|
||||
import {
|
||||
DynamicCustomSwitchModel
|
||||
} from '../../shared/form/builder/ds-dynamic-form-ui/models/custom-switch/custom-switch.model';
|
||||
import cloneDeep from 'lodash/cloneDeep';
|
||||
import { BitstreamDataService } from '../../core/data/bitstream-data.service';
|
||||
import { getAllSucceededRemoteDataPayload, getFirstCompletedRemoteData, getFirstSucceededRemoteData, getFirstSucceededRemoteDataPayload, getRemoteDataPayload } from '../../core/shared/operators';
|
||||
import {
|
||||
getAllSucceededRemoteDataPayload,
|
||||
getFirstCompletedRemoteData,
|
||||
getFirstSucceededRemoteData,
|
||||
getFirstSucceededRemoteDataPayload,
|
||||
getRemoteDataPayload
|
||||
} from '../../core/shared/operators';
|
||||
import { NotificationsService } from '../../shared/notifications/notifications.service';
|
||||
import { BitstreamFormatDataService } from '../../core/data/bitstream-format-data.service';
|
||||
import { BitstreamFormat } from '../../core/shared/bitstream-format.model';
|
||||
@@ -245,7 +266,7 @@ export class EditBitstreamPageComponent implements OnInit, OnDestroy {
|
||||
/**
|
||||
* All input models in a simple array for easier iterations
|
||||
*/
|
||||
inputModels = [this.fileNameModel, this.primaryBitstreamModel, this.descriptionModel, this.selectedFormatModel,
|
||||
inputModels = [this.primaryBitstreamModel, this.fileNameModel, this.descriptionModel, this.selectedFormatModel,
|
||||
this.newFormatModel];
|
||||
|
||||
/**
|
||||
@@ -256,8 +277,8 @@ export class EditBitstreamPageComponent implements OnInit, OnDestroy {
|
||||
new DynamicFormGroupModel({
|
||||
id: 'fileNamePrimaryContainer',
|
||||
group: [
|
||||
this.fileNameModel,
|
||||
this.primaryBitstreamModel
|
||||
this.primaryBitstreamModel,
|
||||
this.fileNameModel
|
||||
]
|
||||
}, {
|
||||
grid: {
|
||||
@@ -295,7 +316,10 @@ export class EditBitstreamPageComponent implements OnInit, OnDestroy {
|
||||
},
|
||||
primaryBitstream: {
|
||||
grid: {
|
||||
host: 'col col-sm-4 d-inline-block switch border-0'
|
||||
container: 'col-12'
|
||||
},
|
||||
element: {
|
||||
container: 'text-right'
|
||||
}
|
||||
},
|
||||
description: {
|
||||
|
@@ -25,7 +25,7 @@ import { DSOEditMenuResolver } from '../shared/dso-page/dso-edit-menu.resolver';
|
||||
import { ComcolBrowseByComponent } from '../shared/comcol/sections/comcol-browse-by/comcol-browse-by.component';
|
||||
import { BrowseByGuard } from '../browse-by/browse-by-guard';
|
||||
import { BrowseByI18nBreadcrumbResolver } from '../browse-by/browse-by-i18n-breadcrumb.resolver';
|
||||
import { CollectionRecentlyAddedComponent } from './sections/recently-added/collection-recently-added.component';
|
||||
import { ComcolSearchSectionComponent } from '../shared/comcol/sections/comcol-search-section/comcol-search-section.component';
|
||||
|
||||
@NgModule({
|
||||
imports: [
|
||||
@@ -73,7 +73,7 @@ import { CollectionRecentlyAddedComponent } from './sections/recently-added/coll
|
||||
{
|
||||
path: '',
|
||||
pathMatch: 'full',
|
||||
component: CollectionRecentlyAddedComponent,
|
||||
component: ComcolSearchSectionComponent,
|
||||
},
|
||||
{
|
||||
path: 'browse/:id',
|
||||
|
@@ -19,7 +19,6 @@ import { ComcolModule } from '../shared/comcol/comcol.module';
|
||||
import { DsoSharedModule } from '../dso-shared/dso-shared.module';
|
||||
import { DsoPageModule } from '../shared/dso-page/dso-page.module';
|
||||
import { BrowseByPageModule } from '../browse-by/browse-by-page.module';
|
||||
import { CollectionRecentlyAddedComponent } from './sections/recently-added/collection-recently-added.component';
|
||||
|
||||
const DECLARATIONS = [
|
||||
CollectionPageComponent,
|
||||
@@ -29,7 +28,6 @@ const DECLARATIONS = [
|
||||
EditItemTemplatePageComponent,
|
||||
ThemedEditItemTemplatePageComponent,
|
||||
CollectionItemMapperComponent,
|
||||
CollectionRecentlyAddedComponent,
|
||||
];
|
||||
|
||||
@NgModule({
|
||||
|
@@ -88,7 +88,7 @@ describe('CollectionSourceControlsComponent', () => {
|
||||
invoke: createSuccessfulRemoteDataObject$(process),
|
||||
});
|
||||
processDataService = jasmine.createSpyObj('processDataService', {
|
||||
findById: createSuccessfulRemoteDataObject$(process),
|
||||
autoRefreshUntilCompletion: createSuccessfulRemoteDataObject$(process),
|
||||
});
|
||||
bitstreamService = jasmine.createSpyObj('bitstreamService', {
|
||||
findByHref: createSuccessfulRemoteDataObject$(bitstream),
|
||||
@@ -137,7 +137,7 @@ describe('CollectionSourceControlsComponent', () => {
|
||||
{name: '-i', value: new ContentSourceSetSerializer().Serialize(contentSource.oaiSetId)},
|
||||
], []);
|
||||
|
||||
expect(processDataService.findById).toHaveBeenCalledWith(process.processId, false);
|
||||
expect(processDataService.autoRefreshUntilCompletion).toHaveBeenCalledWith(process.processId);
|
||||
expect(bitstreamService.findByHref).toHaveBeenCalledWith(process._links.output.href);
|
||||
expect(notificationsService.info).toHaveBeenCalledWith(jasmine.anything() as any, 'Script text');
|
||||
});
|
||||
@@ -151,7 +151,7 @@ describe('CollectionSourceControlsComponent', () => {
|
||||
{name: '-r', value: null},
|
||||
{name: '-c', value: collection.uuid},
|
||||
], []);
|
||||
expect(processDataService.findById).toHaveBeenCalledWith(process.processId, false);
|
||||
expect(processDataService.autoRefreshUntilCompletion).toHaveBeenCalledWith(process.processId);
|
||||
expect(notificationsService.success).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -164,7 +164,7 @@ describe('CollectionSourceControlsComponent', () => {
|
||||
{name: '-o', value: null},
|
||||
{name: '-c', value: collection.uuid},
|
||||
], []);
|
||||
expect(processDataService.findById).toHaveBeenCalledWith(process.processId, false);
|
||||
expect(processDataService.autoRefreshUntilCompletion).toHaveBeenCalledWith(process.processId);
|
||||
expect(notificationsService.success).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
@@ -1,15 +1,14 @@
|
||||
import { Component, Input, OnDestroy } from '@angular/core';
|
||||
import { Component, Input, OnDestroy, OnInit } from '@angular/core';
|
||||
import { ScriptDataService } from '../../../../core/data/processes/script-data.service';
|
||||
import { ContentSource } from '../../../../core/shared/content-source.model';
|
||||
import { ProcessDataService } from '../../../../core/data/processes/process-data.service';
|
||||
import {
|
||||
getAllCompletedRemoteData,
|
||||
getAllSucceededRemoteDataPayload,
|
||||
getFirstCompletedRemoteData,
|
||||
getFirstSucceededRemoteDataPayload
|
||||
} from '../../../../core/shared/operators';
|
||||
import { filter, map, switchMap, tap } from 'rxjs/operators';
|
||||
import { hasValue, hasValueOperator } from '../../../../shared/empty.util';
|
||||
import { hasValue } from '../../../../shared/empty.util';
|
||||
import { ProcessStatus } from '../../../../process-page/processes/process-status.model';
|
||||
import { BehaviorSubject, Observable, Subscription } from 'rxjs';
|
||||
import { RequestService } from '../../../../core/data/request.service';
|
||||
@@ -30,7 +29,7 @@ import { ContentSourceSetSerializer } from '../../../../core/shared/content-sour
|
||||
styleUrls: ['./collection-source-controls.component.scss'],
|
||||
templateUrl: './collection-source-controls.component.html',
|
||||
})
|
||||
export class CollectionSourceControlsComponent implements OnDestroy {
|
||||
export class CollectionSourceControlsComponent implements OnInit, OnDestroy {
|
||||
|
||||
/**
|
||||
* Should the controls be enabled.
|
||||
@@ -49,6 +48,7 @@ export class CollectionSourceControlsComponent implements OnDestroy {
|
||||
|
||||
contentSource$: Observable<ContentSource>;
|
||||
private subs: Subscription[] = [];
|
||||
private autoRefreshIDs: string[] = [];
|
||||
|
||||
testConfigRunning$ = new BehaviorSubject(false);
|
||||
importRunning$ = new BehaviorSubject(false);
|
||||
@@ -95,36 +95,28 @@ export class CollectionSourceControlsComponent implements OnDestroy {
|
||||
}),
|
||||
// filter out responses that aren't successful since the pinging of the process only needs to happen when the invocation was successful.
|
||||
filter((rd) => rd.hasSucceeded && hasValue(rd.payload)),
|
||||
switchMap((rd) => this.processDataService.findById(rd.payload.processId, false)),
|
||||
getAllCompletedRemoteData(),
|
||||
filter((rd) => !rd.isStale && (rd.hasSucceeded || rd.hasFailed)),
|
||||
map((rd) => rd.payload),
|
||||
hasValueOperator(),
|
||||
switchMap((rd) => {
|
||||
this.autoRefreshIDs.push(rd.payload.processId);
|
||||
return this.processDataService.autoRefreshUntilCompletion(rd.payload.processId);
|
||||
}),
|
||||
map((rd) => rd.payload)
|
||||
).subscribe((process: Process) => {
|
||||
if (process.processStatus.toString() !== ProcessStatus[ProcessStatus.COMPLETED].toString() &&
|
||||
process.processStatus.toString() !== ProcessStatus[ProcessStatus.FAILED].toString()) {
|
||||
// Ping the current process state every 5s
|
||||
setTimeout(() => {
|
||||
this.requestService.setStaleByHrefSubstring(process._links.self.href);
|
||||
}, 5000);
|
||||
}
|
||||
if (process.processStatus.toString() === ProcessStatus[ProcessStatus.FAILED].toString()) {
|
||||
this.notificationsService.error(this.translateService.get('collection.source.controls.test.failed'));
|
||||
this.testConfigRunning$.next(false);
|
||||
}
|
||||
if (process.processStatus.toString() === ProcessStatus[ProcessStatus.COMPLETED].toString()) {
|
||||
this.bitstreamService.findByHref(process._links.output.href).pipe(getFirstSucceededRemoteDataPayload()).subscribe((bitstream) => {
|
||||
this.httpClient.get(bitstream._links.content.href, {responseType: 'text'}).subscribe((data: any) => {
|
||||
const output = data.replaceAll(new RegExp('.*\\@(.*)', 'g'), '$1')
|
||||
.replaceAll('The script has started', '')
|
||||
.replaceAll('The script has completed', '');
|
||||
this.notificationsService.info(this.translateService.get('collection.source.controls.test.completed'), output);
|
||||
});
|
||||
});
|
||||
this.testConfigRunning$.next(false);
|
||||
}
|
||||
if (process.processStatus.toString() === ProcessStatus[ProcessStatus.FAILED].toString()) {
|
||||
this.notificationsService.error(this.translateService.get('collection.source.controls.test.failed'));
|
||||
this.testConfigRunning$.next(false);
|
||||
}
|
||||
));
|
||||
if (process.processStatus.toString() === ProcessStatus[ProcessStatus.COMPLETED].toString()) {
|
||||
this.bitstreamService.findByHref(process._links.output.href).pipe(getFirstSucceededRemoteDataPayload()).subscribe((bitstream) => {
|
||||
this.httpClient.get(bitstream._links.content.href, {responseType: 'text'}).subscribe((data: any) => {
|
||||
const output = data.replaceAll(new RegExp('.*\\@(.*)', 'g'), '$1')
|
||||
.replaceAll('The script has started', '')
|
||||
.replaceAll('The script has completed', '');
|
||||
this.notificationsService.info(this.translateService.get('collection.source.controls.test.completed'), output);
|
||||
});
|
||||
});
|
||||
this.testConfigRunning$.next(false);
|
||||
}
|
||||
}));
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -147,31 +139,22 @@ export class CollectionSourceControlsComponent implements OnDestroy {
|
||||
}
|
||||
}),
|
||||
filter((rd) => rd.hasSucceeded && hasValue(rd.payload)),
|
||||
switchMap((rd) => this.processDataService.findById(rd.payload.processId, false)),
|
||||
getAllCompletedRemoteData(),
|
||||
filter((rd) => !rd.isStale && (rd.hasSucceeded || rd.hasFailed)),
|
||||
map((rd) => rd.payload),
|
||||
hasValueOperator(),
|
||||
switchMap((rd) => {
|
||||
this.autoRefreshIDs.push(rd.payload.processId);
|
||||
return this.processDataService.autoRefreshUntilCompletion(rd.payload.processId);
|
||||
}),
|
||||
map((rd) => rd.payload)
|
||||
).subscribe((process) => {
|
||||
if (process.processStatus.toString() !== ProcessStatus[ProcessStatus.COMPLETED].toString() &&
|
||||
process.processStatus.toString() !== ProcessStatus[ProcessStatus.FAILED].toString()) {
|
||||
// Ping the current process state every 5s
|
||||
setTimeout(() => {
|
||||
this.requestService.setStaleByHrefSubstring(process._links.self.href);
|
||||
this.requestService.setStaleByHrefSubstring(this.collection._links.self.href);
|
||||
}, 5000);
|
||||
}
|
||||
if (process.processStatus.toString() === ProcessStatus[ProcessStatus.FAILED].toString()) {
|
||||
this.notificationsService.error(this.translateService.get('collection.source.controls.import.failed'));
|
||||
this.importRunning$.next(false);
|
||||
}
|
||||
if (process.processStatus.toString() === ProcessStatus[ProcessStatus.COMPLETED].toString()) {
|
||||
this.notificationsService.success(this.translateService.get('collection.source.controls.import.completed'));
|
||||
this.requestService.setStaleByHrefSubstring(this.collection._links.self.href);
|
||||
this.importRunning$.next(false);
|
||||
}
|
||||
if (process.processStatus.toString() === ProcessStatus[ProcessStatus.FAILED].toString()) {
|
||||
this.notificationsService.error(this.translateService.get('collection.source.controls.import.failed'));
|
||||
this.importRunning$.next(false);
|
||||
}
|
||||
));
|
||||
if (process.processStatus.toString() === ProcessStatus[ProcessStatus.COMPLETED].toString()) {
|
||||
this.notificationsService.success(this.translateService.get('collection.source.controls.import.completed'));
|
||||
this.requestService.setStaleByHrefSubstring(this.collection._links.self.href);
|
||||
this.importRunning$.next(false);
|
||||
}
|
||||
}));
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -194,31 +177,22 @@ export class CollectionSourceControlsComponent implements OnDestroy {
|
||||
}
|
||||
}),
|
||||
filter((rd) => rd.hasSucceeded && hasValue(rd.payload)),
|
||||
switchMap((rd) => this.processDataService.findById(rd.payload.processId, false)),
|
||||
getAllCompletedRemoteData(),
|
||||
filter((rd) => !rd.isStale && (rd.hasSucceeded || rd.hasFailed)),
|
||||
map((rd) => rd.payload),
|
||||
hasValueOperator(),
|
||||
switchMap((rd) => {
|
||||
this.autoRefreshIDs.push(rd.payload.processId);
|
||||
return this.processDataService.autoRefreshUntilCompletion(rd.payload.processId);
|
||||
}),
|
||||
map((rd) => rd.payload)
|
||||
).subscribe((process) => {
|
||||
if (process.processStatus.toString() !== ProcessStatus[ProcessStatus.COMPLETED].toString() &&
|
||||
process.processStatus.toString() !== ProcessStatus[ProcessStatus.FAILED].toString()) {
|
||||
// Ping the current process state every 5s
|
||||
setTimeout(() => {
|
||||
this.requestService.setStaleByHrefSubstring(process._links.self.href);
|
||||
this.requestService.setStaleByHrefSubstring(this.collection._links.self.href);
|
||||
}, 5000);
|
||||
}
|
||||
if (process.processStatus.toString() === ProcessStatus[ProcessStatus.FAILED].toString()) {
|
||||
this.notificationsService.error(this.translateService.get('collection.source.controls.reset.failed'));
|
||||
this.reImportRunning$.next(false);
|
||||
}
|
||||
if (process.processStatus.toString() === ProcessStatus[ProcessStatus.COMPLETED].toString()) {
|
||||
this.notificationsService.success(this.translateService.get('collection.source.controls.reset.completed'));
|
||||
this.requestService.setStaleByHrefSubstring(this.collection._links.self.href);
|
||||
this.reImportRunning$.next(false);
|
||||
}
|
||||
if (process.processStatus.toString() === ProcessStatus[ProcessStatus.FAILED].toString()) {
|
||||
this.notificationsService.error(this.translateService.get('collection.source.controls.reset.failed'));
|
||||
this.reImportRunning$.next(false);
|
||||
}
|
||||
));
|
||||
if (process.processStatus.toString() === ProcessStatus[ProcessStatus.COMPLETED].toString()) {
|
||||
this.notificationsService.success(this.translateService.get('collection.source.controls.reset.completed'));
|
||||
this.requestService.setStaleByHrefSubstring(this.collection._links.self.href);
|
||||
this.reImportRunning$.next(false);
|
||||
}
|
||||
}));
|
||||
}
|
||||
|
||||
ngOnDestroy(): void {
|
||||
@@ -227,5 +201,9 @@ export class CollectionSourceControlsComponent implements OnDestroy {
|
||||
sub.unsubscribe();
|
||||
}
|
||||
});
|
||||
|
||||
this.autoRefreshIDs.forEach((id) => {
|
||||
this.processDataService.stopAutoRefreshing(id);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
@@ -1,18 +0,0 @@
|
||||
<ng-container *ngVar="(itemRD$ | async) as itemRD">
|
||||
<div class="mt-4" *ngIf="itemRD?.hasSucceeded" @fadeIn>
|
||||
<h3 class="sr-only">{{'collection.page.browse.recent.head' | translate}}</h3>
|
||||
<ds-viewable-collection
|
||||
[config]="paginationConfig"
|
||||
[sortConfig]="sortConfig"
|
||||
[objects]="itemRD"
|
||||
[hideGear]="true">
|
||||
</ds-viewable-collection>
|
||||
</div>
|
||||
<ds-error *ngIf="itemRD?.hasFailed"
|
||||
message="{{'error.recent-submissions' | translate}}"></ds-error>
|
||||
<ds-themed-loading *ngIf="!itemRD || itemRD.isLoading"
|
||||
message="{{'loading.recent-submissions' | translate}}"></ds-themed-loading>
|
||||
<div *ngIf="!itemRD?.isLoading && itemRD?.payload?.page.length === 0" class="alert alert-info w-100" role="alert">
|
||||
{{'collection.page.browse.recent.empty' | translate}}
|
||||
</div>
|
||||
</ng-container>
|
@@ -1,53 +0,0 @@
|
||||
import { ComponentFixture, TestBed } from '@angular/core/testing';
|
||||
import { CollectionRecentlyAddedComponent } from './collection-recently-added.component';
|
||||
import { APP_CONFIG } from '../../../../config/app-config.interface';
|
||||
import { environment } from '../../../../environments/environment.test';
|
||||
import { ActivatedRoute } from '@angular/router';
|
||||
import { ActivatedRouteStub } from '../../../shared/testing/active-router.stub';
|
||||
import { PaginationService } from '../../../core/pagination/pagination.service';
|
||||
import { PaginationServiceStub } from '../../../shared/testing/pagination-service.stub';
|
||||
import { SearchServiceStub } from '../../../shared/testing/search-service.stub';
|
||||
import { SearchService } from '../../../core/shared/search/search.service';
|
||||
import { VarDirective } from '../../../shared/utils/var.directive';
|
||||
import { TranslateModule } from '@ngx-translate/core';
|
||||
import { CUSTOM_ELEMENTS_SCHEMA } from '@angular/core';
|
||||
|
||||
describe('CollectionRecentlyAddedComponent', () => {
|
||||
let component: CollectionRecentlyAddedComponent;
|
||||
let fixture: ComponentFixture<CollectionRecentlyAddedComponent>;
|
||||
|
||||
let activatedRoute: ActivatedRouteStub;
|
||||
let paginationService: PaginationServiceStub;
|
||||
let searchService: SearchServiceStub;
|
||||
|
||||
beforeEach(async () => {
|
||||
activatedRoute = new ActivatedRouteStub();
|
||||
paginationService = new PaginationServiceStub();
|
||||
searchService = new SearchServiceStub();
|
||||
|
||||
await TestBed.configureTestingModule({
|
||||
declarations: [
|
||||
CollectionRecentlyAddedComponent,
|
||||
VarDirective,
|
||||
],
|
||||
imports: [
|
||||
TranslateModule.forRoot(),
|
||||
],
|
||||
providers: [
|
||||
{ provide: ActivatedRoute, useValue: activatedRoute },
|
||||
{ provide: APP_CONFIG, useValue: environment },
|
||||
{ provide: PaginationService, useValue: paginationService },
|
||||
{ provide: SearchService, useValue: SearchServiceStub },
|
||||
],
|
||||
schemas: [CUSTOM_ELEMENTS_SCHEMA],
|
||||
}).compileComponents();
|
||||
|
||||
fixture = TestBed.createComponent(CollectionRecentlyAddedComponent);
|
||||
component = fixture.componentInstance;
|
||||
fixture.detectChanges();
|
||||
});
|
||||
|
||||
it('should create', () => {
|
||||
expect(component).toBeTruthy();
|
||||
});
|
||||
});
|
@@ -1,82 +0,0 @@
|
||||
import { Component, OnInit, Inject, OnDestroy } from '@angular/core';
|
||||
import { Observable, combineLatest as observableCombineLatest } from 'rxjs';
|
||||
import { RemoteData } from '../../../core/data/remote-data';
|
||||
import { PaginatedList } from '../../../core/data/paginated-list.model';
|
||||
import { Item } from '../../../core/shared/item.model';
|
||||
import { switchMap, map, startWith, take } from 'rxjs/operators';
|
||||
import { getFirstSucceededRemoteData, toDSpaceObjectListRD } from '../../../core/shared/operators';
|
||||
import { PaginatedSearchOptions } from '../../../shared/search/models/paginated-search-options.model';
|
||||
import { DSpaceObjectType } from '../../../core/shared/dspace-object-type.model';
|
||||
import { BROWSE_LINKS_TO_FOLLOW } from '../../../core/browse/browse.service';
|
||||
import { PaginationService } from '../../../core/pagination/pagination.service';
|
||||
import { PaginationComponentOptions } from '../../../shared/pagination/pagination-component-options.model';
|
||||
import { SortOptions, SortDirection } from '../../../core/cache/models/sort-options.model';
|
||||
import { APP_CONFIG, AppConfig } from '../../../../config/app-config.interface';
|
||||
import { SearchService } from '../../../core/shared/search/search.service';
|
||||
import { Collection } from '../../../core/shared/collection.model';
|
||||
import { ActivatedRoute, Data } from '@angular/router';
|
||||
import { fadeIn } from '../../../shared/animations/fade';
|
||||
|
||||
@Component({
|
||||
selector: 'ds-collection-recently-added',
|
||||
templateUrl: './collection-recently-added.component.html',
|
||||
styleUrls: ['./collection-recently-added.component.scss'],
|
||||
animations: [fadeIn],
|
||||
})
|
||||
export class CollectionRecentlyAddedComponent implements OnInit, OnDestroy {
|
||||
|
||||
paginationConfig: PaginationComponentOptions;
|
||||
|
||||
sortConfig: SortOptions;
|
||||
|
||||
collectionRD$: Observable<RemoteData<Collection>>;
|
||||
|
||||
itemRD$: Observable<RemoteData<PaginatedList<Item>>>;
|
||||
|
||||
constructor(
|
||||
@Inject(APP_CONFIG) protected appConfig: AppConfig,
|
||||
protected paginationService: PaginationService,
|
||||
protected route: ActivatedRoute,
|
||||
protected searchService: SearchService,
|
||||
) {
|
||||
this.paginationConfig = Object.assign(new PaginationComponentOptions(), {
|
||||
id: 'cp',
|
||||
currentPage: 1,
|
||||
pageSize: this.appConfig.browseBy.pageSize,
|
||||
});
|
||||
|
||||
this.sortConfig = new SortOptions('dc.date.accessioned', SortDirection.DESC);
|
||||
}
|
||||
|
||||
ngOnInit(): void {
|
||||
this.collectionRD$ = this.route.data.pipe(
|
||||
map((data: Data) => data.dso as RemoteData<Collection>),
|
||||
take(1),
|
||||
);
|
||||
|
||||
this.itemRD$ = observableCombineLatest([
|
||||
this.paginationService.getCurrentPagination(this.paginationConfig.id, this.paginationConfig),
|
||||
this.paginationService.getCurrentSort(this.paginationConfig.id, this.sortConfig),
|
||||
]).pipe(
|
||||
switchMap(([currentPagination, currentSort]: [PaginationComponentOptions, SortOptions]) => this.collectionRD$.pipe(
|
||||
getFirstSucceededRemoteData(),
|
||||
map((rd: RemoteData<Collection>) => rd.payload.id),
|
||||
switchMap((id: string) => this.searchService.search<Item>(
|
||||
new PaginatedSearchOptions({
|
||||
scope: id,
|
||||
pagination: currentPagination,
|
||||
sort: currentSort,
|
||||
dsoTypes: [DSpaceObjectType.ITEM]
|
||||
}), null, true, true, ...BROWSE_LINKS_TO_FOLLOW).pipe(
|
||||
toDSpaceObjectListRD()
|
||||
) as Observable<RemoteData<PaginatedList<Item>>>),
|
||||
startWith(undefined), // Make sure switching pages shows loading component
|
||||
)),
|
||||
);
|
||||
}
|
||||
|
||||
ngOnDestroy(): void {
|
||||
this.paginationService.clearPagination(this.paginationConfig.id);
|
||||
}
|
||||
|
||||
}
|
@@ -19,6 +19,8 @@ import { SubComColSectionComponent } from './sections/sub-com-col-section/sub-co
|
||||
import { BrowseByI18nBreadcrumbResolver } from '../browse-by/browse-by-i18n-breadcrumb.resolver';
|
||||
import { BrowseByGuard } from '../browse-by/browse-by-guard';
|
||||
import { ComcolBrowseByComponent } from '../shared/comcol/sections/comcol-browse-by/comcol-browse-by.component';
|
||||
import { ComcolSearchSectionComponent } from '../shared/comcol/sections/comcol-search-section/comcol-search-section.component';
|
||||
import { I18nBreadcrumbResolver } from '../core/breadcrumbs/i18n-breadcrumb.resolver';
|
||||
|
||||
@NgModule({
|
||||
imports: [
|
||||
@@ -56,7 +58,16 @@ import { ComcolBrowseByComponent } from '../shared/comcol/sections/comcol-browse
|
||||
{
|
||||
path: '',
|
||||
pathMatch: 'full',
|
||||
component: ComcolSearchSectionComponent,
|
||||
},
|
||||
{
|
||||
path: 'subcoms-cols',
|
||||
pathMatch: 'full',
|
||||
component: SubComColSectionComponent,
|
||||
resolve: {
|
||||
breadcrumb: I18nBreadcrumbResolver,
|
||||
},
|
||||
data: { breadcrumbKey: 'community.subcoms-cols' },
|
||||
},
|
||||
{
|
||||
path: 'browse/:id',
|
||||
|
@@ -7,7 +7,7 @@ import { Bitstream } from '../core/shared/bitstream.model';
|
||||
import { Community } from '../core/shared/community.model';
|
||||
import { fadeInOut } from '../shared/animations/fade';
|
||||
import { hasValue } from '../shared/empty.util';
|
||||
import { getAllSucceededRemoteDataPayload} from '../core/shared/operators';
|
||||
import { getAllSucceededRemoteDataPayload } from '../core/shared/operators';
|
||||
import { AuthService } from '../core/auth/auth.service';
|
||||
import { AuthorizationDataService } from '../core/data/feature-authorization/authorization-data.service';
|
||||
import { FeatureID } from '../core/data/feature-authorization/feature-id';
|
||||
|
@@ -11,6 +11,7 @@ describe('SubComColSectionComponent', () => {
|
||||
|
||||
beforeEach(async () => {
|
||||
activatedRoute = new ActivatedRouteStub();
|
||||
activatedRoute.parent = new ActivatedRouteStub();
|
||||
|
||||
await TestBed.configureTestingModule({
|
||||
declarations: [
|
||||
|
@@ -20,7 +20,7 @@ export class SubComColSectionComponent implements OnInit {
|
||||
}
|
||||
|
||||
ngOnInit(): void {
|
||||
this.community$ = this.route.data.pipe(
|
||||
this.community$ = this.route.parent.data.pipe(
|
||||
map((data: Data) => (data.dso as RemoteData<Community>).payload),
|
||||
);
|
||||
}
|
||||
|
@@ -273,12 +273,13 @@ export class RemoteDataBuildService {
|
||||
return isStale(r2.state) ? r1 : r2;
|
||||
}
|
||||
}),
|
||||
distinctUntilKeyChanged('lastUpdated')
|
||||
);
|
||||
|
||||
const payload$ = this.buildPayload<T>(requestEntry$, href$, ...linksToFollow);
|
||||
|
||||
return this.toRemoteDataObservable<T>(requestEntry$, payload$);
|
||||
return this.toRemoteDataObservable<T>(requestEntry$, payload$).pipe(
|
||||
distinctUntilKeyChanged('lastUpdated'),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
|
@@ -21,6 +21,10 @@ import { RequestEntryState } from '../request-entry-state.model';
|
||||
import { fakeAsync, tick } from '@angular/core/testing';
|
||||
import { BaseDataService } from './base-data.service';
|
||||
import { createFailedRemoteDataObject$, createSuccessfulRemoteDataObject$ } from '../../../shared/remote-data.utils';
|
||||
import { ObjectCacheServiceStub } from '../../../shared/testing/object-cache-service.stub';
|
||||
import { ObjectCacheEntry } from '../../cache/object-cache.reducer';
|
||||
import { HALLink } from '../../shared/hal-link.model';
|
||||
import { createPaginatedList } from '../../../shared/testing/utils.test';
|
||||
|
||||
const endpoint = 'https://rest.api/core';
|
||||
|
||||
@@ -46,34 +50,18 @@ describe('BaseDataService', () => {
|
||||
let requestService;
|
||||
let halService;
|
||||
let rdbService;
|
||||
let objectCache;
|
||||
let objectCache: ObjectCacheServiceStub;
|
||||
let selfLink;
|
||||
let linksToFollow;
|
||||
let testScheduler;
|
||||
let remoteDataMocks;
|
||||
let remoteDataMocks: { [responseType: string]: RemoteData<any> };
|
||||
let remoteDataPageMocks: { [responseType: string]: RemoteData<any> };
|
||||
|
||||
function initTestService(): TestService {
|
||||
requestService = getMockRequestService();
|
||||
halService = new HALEndpointServiceStub('url') as any;
|
||||
rdbService = getMockRemoteDataBuildService();
|
||||
objectCache = {
|
||||
|
||||
addPatch: () => {
|
||||
/* empty */
|
||||
},
|
||||
getObjectBySelfLink: () => {
|
||||
/* empty */
|
||||
},
|
||||
getByHref: () => {
|
||||
/* empty */
|
||||
},
|
||||
addDependency: () => {
|
||||
/* empty */
|
||||
},
|
||||
removeDependents: () => {
|
||||
/* empty */
|
||||
},
|
||||
} as any;
|
||||
objectCache = new ObjectCacheServiceStub();
|
||||
selfLink = 'https://rest.api/endpoint/1698f1d3-be98-4c51-9fd8-6bfedcbd59b7';
|
||||
linksToFollow = [
|
||||
followLink('a'),
|
||||
@@ -88,7 +76,27 @@ describe('BaseDataService', () => {
|
||||
|
||||
const timeStamp = new Date().getTime();
|
||||
const msToLive = 15 * 60 * 1000;
|
||||
const payload = { foo: 'bar' };
|
||||
const payload = {
|
||||
foo: 'bar',
|
||||
followLink1: {},
|
||||
followLink2: {},
|
||||
_links: {
|
||||
self: Object.assign(new HALLink(), {
|
||||
href: 'self-test-link',
|
||||
}),
|
||||
followLink1: Object.assign(new HALLink(), {
|
||||
href: 'follow-link-1',
|
||||
}),
|
||||
followLink2: [
|
||||
Object.assign(new HALLink(), {
|
||||
href: 'follow-link-2-1',
|
||||
}),
|
||||
Object.assign(new HALLink(), {
|
||||
href: 'follow-link-2-2',
|
||||
}),
|
||||
],
|
||||
}
|
||||
};
|
||||
const statusCodeSuccess = 200;
|
||||
const statusCodeError = 404;
|
||||
const errorMessage = 'not found';
|
||||
@@ -101,11 +109,20 @@ describe('BaseDataService', () => {
|
||||
Error: new RemoteData(timeStamp, msToLive, timeStamp, RequestEntryState.Error, errorMessage, undefined, statusCodeError),
|
||||
ErrorStale: new RemoteData(timeStamp, msToLive, timeStamp, RequestEntryState.ErrorStale, errorMessage, undefined, statusCodeError),
|
||||
};
|
||||
remoteDataPageMocks = {
|
||||
RequestPending: new RemoteData(undefined, msToLive, timeStamp, RequestEntryState.RequestPending, undefined, undefined, undefined),
|
||||
ResponsePending: new RemoteData(undefined, msToLive, timeStamp, RequestEntryState.ResponsePending, undefined, undefined, undefined),
|
||||
ResponsePendingStale: new RemoteData(undefined, msToLive, timeStamp, RequestEntryState.ResponsePendingStale, undefined, undefined, undefined),
|
||||
Success: new RemoteData(timeStamp, msToLive, timeStamp, RequestEntryState.Success, undefined, createPaginatedList([payload]), statusCodeSuccess),
|
||||
SuccessStale: new RemoteData(timeStamp, msToLive, timeStamp, RequestEntryState.SuccessStale, undefined, createPaginatedList([payload]), statusCodeSuccess),
|
||||
Error: new RemoteData(timeStamp, msToLive, timeStamp, RequestEntryState.Error, errorMessage, undefined, statusCodeError),
|
||||
ErrorStale: new RemoteData(timeStamp, msToLive, timeStamp, RequestEntryState.ErrorStale, errorMessage, undefined, statusCodeError),
|
||||
};
|
||||
|
||||
return new TestService(
|
||||
requestService,
|
||||
rdbService,
|
||||
objectCache,
|
||||
objectCache as ObjectCacheService,
|
||||
halService,
|
||||
);
|
||||
}
|
||||
@@ -380,6 +397,27 @@ describe('BaseDataService', () => {
|
||||
|
||||
});
|
||||
|
||||
it('should link all the followLinks of a cached object by calling addDependency', () => {
|
||||
spyOn(objectCache, 'addDependency').and.callThrough();
|
||||
testScheduler.run(({ cold, expectObservable, flush }) => {
|
||||
spyOn(rdbService, 'buildSingle').and.returnValue(cold('a-b-c-d', {
|
||||
a: remoteDataMocks.Success,
|
||||
b: remoteDataMocks.RequestPending,
|
||||
c: remoteDataMocks.ResponsePending,
|
||||
d: remoteDataMocks.Success,
|
||||
}));
|
||||
const expected = '--b-c-d';
|
||||
const values = {
|
||||
b: remoteDataMocks.RequestPending,
|
||||
c: remoteDataMocks.ResponsePending,
|
||||
d: remoteDataMocks.Success,
|
||||
};
|
||||
|
||||
expectObservable(service.findByHref(selfLink, false, false, ...linksToFollow)).toBe(expected, values);
|
||||
flush();
|
||||
expect(objectCache.addDependency).toHaveBeenCalledTimes(3);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe(`findListByHref`, () => {
|
||||
@@ -392,8 +430,8 @@ describe('BaseDataService', () => {
|
||||
it(`should call buildHrefFromFindOptions with href and linksToFollow`, () => {
|
||||
testScheduler.run(({ cold }) => {
|
||||
spyOn(service, 'buildHrefFromFindOptions').and.returnValue(selfLink);
|
||||
spyOn(rdbService, 'buildList').and.returnValue(cold('a', { a: remoteDataMocks.Success }));
|
||||
spyOn(service as any, 'reRequestStaleRemoteData').and.returnValue(() => cold('a', { a: remoteDataMocks.Success }));
|
||||
spyOn(rdbService, 'buildList').and.returnValue(cold('a', { a: remoteDataPageMocks.Success }));
|
||||
spyOn(service as any, 'reRequestStaleRemoteData').and.returnValue(() => cold('a', { a: remoteDataPageMocks.Success }));
|
||||
|
||||
service.findListByHref(selfLink, findListOptions, true, true, ...linksToFollow);
|
||||
expect(service.buildHrefFromFindOptions).toHaveBeenCalledWith(selfLink, findListOptions, [], ...linksToFollow);
|
||||
@@ -403,8 +441,8 @@ describe('BaseDataService', () => {
|
||||
it(`should call createAndSendGetRequest with the result from buildHrefFromFindOptions and useCachedVersionIfAvailable`, () => {
|
||||
testScheduler.run(({ cold, expectObservable }) => {
|
||||
spyOn(service, 'buildHrefFromFindOptions').and.returnValue('bingo!');
|
||||
spyOn(rdbService, 'buildList').and.returnValue(cold('a', { a: remoteDataMocks.Success }));
|
||||
spyOn(service as any, 'reRequestStaleRemoteData').and.returnValue(() => cold('a', { a: remoteDataMocks.Success }));
|
||||
spyOn(rdbService, 'buildList').and.returnValue(cold('a', { a: remoteDataPageMocks.Success }));
|
||||
spyOn(service as any, 'reRequestStaleRemoteData').and.returnValue(() => cold('a', { a: remoteDataPageMocks.Success }));
|
||||
|
||||
service.findListByHref(selfLink, findListOptions, true, true, ...linksToFollow);
|
||||
expect((service as any).createAndSendGetRequest).toHaveBeenCalledWith(jasmine.anything(), true);
|
||||
@@ -419,8 +457,8 @@ describe('BaseDataService', () => {
|
||||
it(`should call rdbService.buildList with the result from buildHrefFromFindOptions and linksToFollow`, () => {
|
||||
testScheduler.run(({ cold, expectObservable }) => {
|
||||
spyOn(service, 'buildHrefFromFindOptions').and.returnValue('bingo!');
|
||||
spyOn(rdbService, 'buildList').and.returnValue(cold('a', { a: remoteDataMocks.Success }));
|
||||
spyOn(service as any, 'reRequestStaleRemoteData').and.returnValue(() => cold('a', { a: remoteDataMocks.Success }));
|
||||
spyOn(rdbService, 'buildList').and.returnValue(cold('a', { a: remoteDataPageMocks.Success }));
|
||||
spyOn(service as any, 'reRequestStaleRemoteData').and.returnValue(() => cold('a', { a: remoteDataPageMocks.Success }));
|
||||
|
||||
service.findListByHref(selfLink, findListOptions, true, true, ...linksToFollow);
|
||||
expect(rdbService.buildList).toHaveBeenCalledWith(jasmine.anything() as any, ...linksToFollow);
|
||||
@@ -431,12 +469,12 @@ describe('BaseDataService', () => {
|
||||
it(`should call reRequestStaleRemoteData with reRequestOnStale and the exact same findListByHref call as a callback`, () => {
|
||||
testScheduler.run(({ cold, expectObservable }) => {
|
||||
spyOn(service, 'buildHrefFromFindOptions').and.returnValue('bingo!');
|
||||
spyOn(rdbService, 'buildList').and.returnValue(cold('a', { a: remoteDataMocks.SuccessStale }));
|
||||
spyOn(service as any, 'reRequestStaleRemoteData').and.returnValue(() => cold('a', { a: remoteDataMocks.SuccessStale }));
|
||||
spyOn(rdbService, 'buildList').and.returnValue(cold('a', { a: remoteDataPageMocks.SuccessStale }));
|
||||
spyOn(service as any, 'reRequestStaleRemoteData').and.returnValue(() => cold('a', { a: remoteDataPageMocks.SuccessStale }));
|
||||
|
||||
service.findListByHref(selfLink, findListOptions, true, true, ...linksToFollow);
|
||||
expect((service as any).reRequestStaleRemoteData.calls.argsFor(0)[0]).toBeTrue();
|
||||
spyOn(service, 'findListByHref').and.returnValue(cold('a', { a: remoteDataMocks.SuccessStale }));
|
||||
spyOn(service, 'findListByHref').and.returnValue(cold('a', { a: remoteDataPageMocks.SuccessStale }));
|
||||
// prove that the spy we just added hasn't been called yet
|
||||
expect(service.findListByHref).not.toHaveBeenCalled();
|
||||
// call the callback passed to reRequestStaleRemoteData
|
||||
@@ -451,7 +489,7 @@ describe('BaseDataService', () => {
|
||||
it(`should return a the output from reRequestStaleRemoteData`, () => {
|
||||
testScheduler.run(({ cold, expectObservable }) => {
|
||||
spyOn(service, 'buildHrefFromFindOptions').and.returnValue(selfLink);
|
||||
spyOn(rdbService, 'buildList').and.returnValue(cold('a', { a: remoteDataMocks.Success }));
|
||||
spyOn(rdbService, 'buildList').and.returnValue(cold('a', { a: remoteDataPageMocks.Success }));
|
||||
spyOn(service as any, 'reRequestStaleRemoteData').and.returnValue(() => cold('a', { a: 'bingo!' }));
|
||||
const expected = 'a';
|
||||
const values = {
|
||||
@@ -471,19 +509,19 @@ describe('BaseDataService', () => {
|
||||
it(`should emit a cached completed RemoteData immediately, and keep emitting if it gets rerequested`, () => {
|
||||
testScheduler.run(({ cold, expectObservable }) => {
|
||||
spyOn(rdbService, 'buildList').and.returnValue(cold('a-b-c-d-e', {
|
||||
a: remoteDataMocks.Success,
|
||||
b: remoteDataMocks.RequestPending,
|
||||
c: remoteDataMocks.ResponsePending,
|
||||
d: remoteDataMocks.Success,
|
||||
e: remoteDataMocks.SuccessStale,
|
||||
a: remoteDataPageMocks.Success,
|
||||
b: remoteDataPageMocks.RequestPending,
|
||||
c: remoteDataPageMocks.ResponsePending,
|
||||
d: remoteDataPageMocks.Success,
|
||||
e: remoteDataPageMocks.SuccessStale,
|
||||
}));
|
||||
const expected = 'a-b-c-d-e';
|
||||
const values = {
|
||||
a: remoteDataMocks.Success,
|
||||
b: remoteDataMocks.RequestPending,
|
||||
c: remoteDataMocks.ResponsePending,
|
||||
d: remoteDataMocks.Success,
|
||||
e: remoteDataMocks.SuccessStale,
|
||||
a: remoteDataPageMocks.Success,
|
||||
b: remoteDataPageMocks.RequestPending,
|
||||
c: remoteDataPageMocks.ResponsePending,
|
||||
d: remoteDataPageMocks.Success,
|
||||
e: remoteDataPageMocks.SuccessStale,
|
||||
};
|
||||
|
||||
expectObservable(service.findListByHref(selfLink, findListOptions, true, true, ...linksToFollow)).toBe(expected, values);
|
||||
@@ -493,20 +531,20 @@ describe('BaseDataService', () => {
|
||||
it(`should not emit a cached stale RemoteData, but only start emitting after the state first changes to RequestPending`, () => {
|
||||
testScheduler.run(({ cold, expectObservable }) => {
|
||||
spyOn(rdbService, 'buildList').and.returnValue(cold('a-b-c-d-e-f-g', {
|
||||
a: remoteDataMocks.ResponsePendingStale,
|
||||
b: remoteDataMocks.SuccessStale,
|
||||
c: remoteDataMocks.ErrorStale,
|
||||
d: remoteDataMocks.RequestPending,
|
||||
e: remoteDataMocks.ResponsePending,
|
||||
f: remoteDataMocks.Success,
|
||||
g: remoteDataMocks.SuccessStale,
|
||||
a: remoteDataPageMocks.ResponsePendingStale,
|
||||
b: remoteDataPageMocks.SuccessStale,
|
||||
c: remoteDataPageMocks.ErrorStale,
|
||||
d: remoteDataPageMocks.RequestPending,
|
||||
e: remoteDataPageMocks.ResponsePending,
|
||||
f: remoteDataPageMocks.Success,
|
||||
g: remoteDataPageMocks.SuccessStale,
|
||||
}));
|
||||
const expected = '------d-e-f-g';
|
||||
const values = {
|
||||
d: remoteDataMocks.RequestPending,
|
||||
e: remoteDataMocks.ResponsePending,
|
||||
f: remoteDataMocks.Success,
|
||||
g: remoteDataMocks.SuccessStale,
|
||||
d: remoteDataPageMocks.RequestPending,
|
||||
e: remoteDataPageMocks.ResponsePending,
|
||||
f: remoteDataPageMocks.Success,
|
||||
g: remoteDataPageMocks.SuccessStale,
|
||||
};
|
||||
|
||||
expectObservable(service.findListByHref(selfLink, findListOptions, true, true, ...linksToFollow)).toBe(expected, values);
|
||||
@@ -525,18 +563,18 @@ describe('BaseDataService', () => {
|
||||
it(`should not emit a cached completed RemoteData, but only start emitting after the state first changes to RequestPending`, () => {
|
||||
testScheduler.run(({ cold, expectObservable }) => {
|
||||
spyOn(rdbService, 'buildList').and.returnValue(cold('a-b-c-d-e', {
|
||||
a: remoteDataMocks.Success,
|
||||
b: remoteDataMocks.RequestPending,
|
||||
c: remoteDataMocks.ResponsePending,
|
||||
d: remoteDataMocks.Success,
|
||||
e: remoteDataMocks.SuccessStale,
|
||||
a: remoteDataPageMocks.Success,
|
||||
b: remoteDataPageMocks.RequestPending,
|
||||
c: remoteDataPageMocks.ResponsePending,
|
||||
d: remoteDataPageMocks.Success,
|
||||
e: remoteDataPageMocks.SuccessStale,
|
||||
}));
|
||||
const expected = '--b-c-d-e';
|
||||
const values = {
|
||||
b: remoteDataMocks.RequestPending,
|
||||
c: remoteDataMocks.ResponsePending,
|
||||
d: remoteDataMocks.Success,
|
||||
e: remoteDataMocks.SuccessStale,
|
||||
b: remoteDataPageMocks.RequestPending,
|
||||
c: remoteDataPageMocks.ResponsePending,
|
||||
d: remoteDataPageMocks.Success,
|
||||
e: remoteDataPageMocks.SuccessStale,
|
||||
};
|
||||
|
||||
expectObservable(service.findListByHref(selfLink, findListOptions, false, true, ...linksToFollow)).toBe(expected, values);
|
||||
@@ -546,20 +584,20 @@ describe('BaseDataService', () => {
|
||||
it(`should not emit a cached stale RemoteData, but only start emitting after the state first changes to RequestPending`, () => {
|
||||
testScheduler.run(({ cold, expectObservable }) => {
|
||||
spyOn(rdbService, 'buildList').and.returnValue(cold('a-b-c-d-e-f-g', {
|
||||
a: remoteDataMocks.ResponsePendingStale,
|
||||
b: remoteDataMocks.SuccessStale,
|
||||
c: remoteDataMocks.ErrorStale,
|
||||
d: remoteDataMocks.RequestPending,
|
||||
e: remoteDataMocks.ResponsePending,
|
||||
f: remoteDataMocks.Success,
|
||||
g: remoteDataMocks.SuccessStale,
|
||||
a: remoteDataPageMocks.ResponsePendingStale,
|
||||
b: remoteDataPageMocks.SuccessStale,
|
||||
c: remoteDataPageMocks.ErrorStale,
|
||||
d: remoteDataPageMocks.RequestPending,
|
||||
e: remoteDataPageMocks.ResponsePending,
|
||||
f: remoteDataPageMocks.Success,
|
||||
g: remoteDataPageMocks.SuccessStale,
|
||||
}));
|
||||
const expected = '------d-e-f-g';
|
||||
const values = {
|
||||
d: remoteDataMocks.RequestPending,
|
||||
e: remoteDataMocks.ResponsePending,
|
||||
f: remoteDataMocks.Success,
|
||||
g: remoteDataMocks.SuccessStale,
|
||||
d: remoteDataPageMocks.RequestPending,
|
||||
e: remoteDataPageMocks.ResponsePending,
|
||||
f: remoteDataPageMocks.Success,
|
||||
g: remoteDataPageMocks.SuccessStale,
|
||||
};
|
||||
|
||||
|
||||
@@ -567,6 +605,27 @@ describe('BaseDataService', () => {
|
||||
});
|
||||
});
|
||||
|
||||
it('should link all the followLinks of the cached objects by calling addDependency', () => {
|
||||
spyOn(objectCache, 'addDependency').and.callThrough();
|
||||
testScheduler.run(({ cold, expectObservable, flush }) => {
|
||||
spyOn(rdbService, 'buildList').and.returnValue(cold('a-b-c-d', {
|
||||
a: remoteDataPageMocks.SuccessStale,
|
||||
b: remoteDataPageMocks.RequestPending,
|
||||
c: remoteDataPageMocks.ResponsePending,
|
||||
d: remoteDataPageMocks.Success,
|
||||
}));
|
||||
const expected = '--b-c-d';
|
||||
const values = {
|
||||
b: remoteDataPageMocks.RequestPending,
|
||||
c: remoteDataPageMocks.ResponsePending,
|
||||
d: remoteDataPageMocks.Success,
|
||||
};
|
||||
|
||||
expectObservable(service.findListByHref(selfLink, findListOptions, false, false, ...linksToFollow)).toBe(expected, values);
|
||||
flush();
|
||||
expect(objectCache.addDependency).toHaveBeenCalledTimes(3);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -577,7 +636,7 @@ describe('BaseDataService', () => {
|
||||
getByHrefSpy = spyOn(objectCache, 'getByHref').and.returnValue(observableOf({
|
||||
requestUUIDs: ['request1', 'request2', 'request3'],
|
||||
dependentRequestUUIDs: ['request4', 'request5']
|
||||
}));
|
||||
} as ObjectCacheEntry));
|
||||
|
||||
});
|
||||
|
||||
|
@@ -24,6 +24,7 @@ import { ObjectCacheEntry } from '../../cache/object-cache.reducer';
|
||||
import { ObjectCacheService } from '../../cache/object-cache.service';
|
||||
import { HALDataService } from './hal-data-service.interface';
|
||||
import { getFirstCompletedRemoteData } from '../../shared/operators';
|
||||
import { HALLink } from '../../shared/hal-link.model';
|
||||
|
||||
export const EMBED_SEPARATOR = '%2F';
|
||||
/**
|
||||
@@ -268,7 +269,7 @@ export class BaseDataService<T extends CacheableObject> implements HALDataServic
|
||||
|
||||
this.createAndSendGetRequest(requestHref$, useCachedVersionIfAvailable);
|
||||
|
||||
return this.rdbService.buildSingle<T>(requestHref$, ...linksToFollow).pipe(
|
||||
const response$: Observable<RemoteData<T>> = this.rdbService.buildSingle<T>(requestHref$, ...linksToFollow).pipe(
|
||||
// This skip ensures that if a stale object is present in the cache when you do a
|
||||
// call it isn't immediately returned, but we wait until the remote data for the new request
|
||||
// is created. If useCachedVersionIfAvailable is false it also ensures you don't get a
|
||||
@@ -277,6 +278,25 @@ export class BaseDataService<T extends CacheableObject> implements HALDataServic
|
||||
this.reRequestStaleRemoteData(reRequestOnStale, () =>
|
||||
this.findByHref(href$, useCachedVersionIfAvailable, reRequestOnStale, ...linksToFollow)),
|
||||
);
|
||||
return response$.pipe(
|
||||
// Ensure all followLinks from the cached object are automatically invalidated when invalidating the cached object
|
||||
tap((remoteDataObject: RemoteData<T>) => {
|
||||
if (hasValue(remoteDataObject?.payload?._links)) {
|
||||
for (const followLinkName of Object.keys(remoteDataObject.payload._links)) {
|
||||
// only add the followLinks if they are embedded
|
||||
if (hasValue(remoteDataObject.payload[followLinkName]) && followLinkName !== 'self') {
|
||||
// followLink can be either an individual HALLink or a HALLink[]
|
||||
const followLinksList: HALLink[] = [].concat(remoteDataObject.payload._links[followLinkName]);
|
||||
for (const individualFollowLink of followLinksList) {
|
||||
if (hasValue(individualFollowLink?.href)) {
|
||||
this.addDependency(response$, individualFollowLink.href);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -302,7 +322,7 @@ export class BaseDataService<T extends CacheableObject> implements HALDataServic
|
||||
|
||||
this.createAndSendGetRequest(requestHref$, useCachedVersionIfAvailable);
|
||||
|
||||
return this.rdbService.buildList<T>(requestHref$, ...linksToFollow).pipe(
|
||||
const response$: Observable<RemoteData<PaginatedList<T>>> = this.rdbService.buildList<T>(requestHref$, ...linksToFollow).pipe(
|
||||
// This skip ensures that if a stale object is present in the cache when you do a
|
||||
// call it isn't immediately returned, but we wait until the remote data for the new request
|
||||
// is created. If useCachedVersionIfAvailable is false it also ensures you don't get a
|
||||
@@ -311,6 +331,29 @@ export class BaseDataService<T extends CacheableObject> implements HALDataServic
|
||||
this.reRequestStaleRemoteData(reRequestOnStale, () =>
|
||||
this.findListByHref(href$, options, useCachedVersionIfAvailable, reRequestOnStale, ...linksToFollow)),
|
||||
);
|
||||
return response$.pipe(
|
||||
// Ensure all followLinks from the cached object are automatically invalidated when invalidating the cached object
|
||||
tap((remoteDataObject: RemoteData<PaginatedList<T>>) => {
|
||||
if (hasValue(remoteDataObject?.payload?.page)) {
|
||||
for (const object of remoteDataObject.payload.page) {
|
||||
if (hasValue(object?._links)) {
|
||||
for (const followLinkName of Object.keys(object._links)) {
|
||||
// only add the followLinks if they are embedded
|
||||
if (hasValue(object[followLinkName]) && followLinkName !== 'self') {
|
||||
// followLink can be either an individual HALLink or a HALLink[]
|
||||
const followLinksList: HALLink[] = [].concat(object._links[followLinkName]);
|
||||
for (const individualFollowLink of followLinksList) {
|
||||
if (hasValue(individualFollowLink?.href)) {
|
||||
this.addDependency(response$, individualFollowLink.href);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
|
@@ -21,6 +21,11 @@ import { NotificationsService } from '../../shared/notifications/notifications.s
|
||||
import objectContaining = jasmine.objectContaining;
|
||||
import { RemoteData } from './remote-data';
|
||||
import { FollowLinkConfig } from '../../shared/utils/follow-link-config.model';
|
||||
import { BundleDataService } from './bundle-data.service';
|
||||
import { ItemMock } from 'src/app/shared/mocks/item.mock';
|
||||
import { createFailedRemoteDataObject, createSuccessfulRemoteDataObject } from 'src/app/shared/remote-data.utils';
|
||||
import { Bundle } from '../shared/bundle.model';
|
||||
import { cold } from 'jasmine-marbles';
|
||||
|
||||
describe('BitstreamDataService', () => {
|
||||
let service: BitstreamDataService;
|
||||
@@ -29,6 +34,7 @@ describe('BitstreamDataService', () => {
|
||||
let halService: HALEndpointService;
|
||||
let bitstreamFormatService: BitstreamFormatDataService;
|
||||
let rdbService: RemoteDataBuildService;
|
||||
let bundleDataService: BundleDataService;
|
||||
const bitstreamFormatHref = 'rest-api/bitstreamformats';
|
||||
|
||||
const bitstream1 = Object.assign(new Bitstream(), {
|
||||
@@ -62,6 +68,7 @@ describe('BitstreamDataService', () => {
|
||||
bitstreamFormatService = jasmine.createSpyObj('bistreamFormatService', {
|
||||
getBrowseEndpoint: observableOf(bitstreamFormatHref)
|
||||
});
|
||||
|
||||
rdbService = getMockRemoteDataBuildService();
|
||||
|
||||
TestBed.configureTestingModule({
|
||||
@@ -76,6 +83,7 @@ describe('BitstreamDataService', () => {
|
||||
],
|
||||
});
|
||||
service = TestBed.inject(BitstreamDataService);
|
||||
bundleDataService = TestBed.inject(BundleDataService);
|
||||
});
|
||||
|
||||
describe('composition', () => {
|
||||
@@ -118,6 +126,32 @@ describe('BitstreamDataService', () => {
|
||||
expect(service.invalidateByHref).toHaveBeenCalledWith('fake-bitstream1-self');
|
||||
});
|
||||
|
||||
describe('findPrimaryBitstreamByItemAndName', () => {
|
||||
it('should return primary bitstream', () => {
|
||||
const exprected$ = cold('(a|)', { a: bitstream1} );
|
||||
const bundle = Object.assign(new Bundle(), {
|
||||
primaryBitstream: observableOf(createSuccessfulRemoteDataObject(bitstream1)),
|
||||
});
|
||||
spyOn(bundleDataService, 'findByItemAndName').and.returnValue(observableOf(createSuccessfulRemoteDataObject(bundle)));
|
||||
expect(service.findPrimaryBitstreamByItemAndName(ItemMock, 'ORIGINAL')).toBeObservable(exprected$);
|
||||
});
|
||||
|
||||
it('should return null if primary bitstream has not be succeeded ', () => {
|
||||
const exprected$ = cold('(a|)', { a: null} );
|
||||
const bundle = Object.assign(new Bundle(), {
|
||||
primaryBitstream: observableOf(createFailedRemoteDataObject()),
|
||||
});
|
||||
spyOn(bundleDataService, 'findByItemAndName').and.returnValue(observableOf(createSuccessfulRemoteDataObject(bundle)));
|
||||
expect(service.findPrimaryBitstreamByItemAndName(ItemMock, 'ORIGINAL')).toBeObservable(exprected$);
|
||||
});
|
||||
|
||||
it('should return EMPTY if nothing where found', () => {
|
||||
const exprected$ = cold('(|)', {} );
|
||||
spyOn(bundleDataService, 'findByItemAndName').and.returnValue(observableOf(createFailedRemoteDataObject<Bundle>()));
|
||||
expect(service.findPrimaryBitstreamByItemAndName(ItemMock, 'ORIGINAL')).toBeObservable(exprected$);
|
||||
});
|
||||
});
|
||||
|
||||
it('should be able to delete multiple bitstreams', () => {
|
||||
service.removeMultiple([bitstream1, bitstream2]);
|
||||
|
||||
|
@@ -1,9 +1,9 @@
|
||||
import { HttpHeaders } from '@angular/common/http';
|
||||
import { Injectable } from '@angular/core';
|
||||
import { combineLatest as observableCombineLatest, Observable } from 'rxjs';
|
||||
import { combineLatest as observableCombineLatest, Observable, EMPTY } from 'rxjs';
|
||||
import { find, map, switchMap, take } from 'rxjs/operators';
|
||||
import { hasValue } from '../../shared/empty.util';
|
||||
import { FollowLinkConfig } from '../../shared/utils/follow-link-config.model';
|
||||
import { FollowLinkConfig, followLink } from '../../shared/utils/follow-link-config.model';
|
||||
import { RemoteDataBuildService } from '../cache/builders/remote-data-build.service';
|
||||
import { ObjectCacheService } from '../cache/object-cache.service';
|
||||
import { Bitstream } from '../shared/bitstream.model';
|
||||
@@ -34,6 +34,7 @@ import { NoContent } from '../shared/NoContent.model';
|
||||
import { IdentifiableDataService } from './base/identifiable-data.service';
|
||||
import { dataService } from './base/data-service.decorator';
|
||||
import { Operation, RemoveOperation } from 'fast-json-patch';
|
||||
import { getFirstCompletedRemoteData } from '../shared/operators';
|
||||
|
||||
/**
|
||||
* A service to retrieve {@link Bitstream}s from the REST API
|
||||
@@ -201,6 +202,37 @@ export class BitstreamDataService extends IdentifiableDataService<Bitstream> imp
|
||||
return this.searchData.getSearchByHref(searchMethod, options, ...linksToFollow);
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
*
|
||||
* Make a request to get primary bitstream
|
||||
* in all current use cases, and having it simplifies this method
|
||||
*
|
||||
* @param item the {@link Item} the {@link Bundle} is a part of
|
||||
* @param bundleName the name of the {@link Bundle} we want to find
|
||||
* {@link Bitstream}s for
|
||||
* @param useCachedVersionIfAvailable If this is true, the request will only be sent if there's
|
||||
* no valid cached version. Defaults to true
|
||||
* @param reRequestOnStale Whether or not the request should automatically be re-
|
||||
* requested after the response becomes stale
|
||||
* @return {Observable<Bitstream | null>}
|
||||
* Return an observable that constains primary bitstream information or null
|
||||
*/
|
||||
public findPrimaryBitstreamByItemAndName(item: Item, bundleName: string, useCachedVersionIfAvailable = true, reRequestOnStale = true): Observable<Bitstream | null> {
|
||||
return this.bundleService.findByItemAndName(item, bundleName, useCachedVersionIfAvailable, reRequestOnStale, followLink('primaryBitstream')).pipe(
|
||||
getFirstCompletedRemoteData(),
|
||||
switchMap((rd: RemoteData<Bundle>) => {
|
||||
if (!rd.hasSucceeded) {
|
||||
return EMPTY;
|
||||
}
|
||||
return rd.payload.primaryBitstream.pipe(
|
||||
getFirstCompletedRemoteData(),
|
||||
map((rdb: RemoteData<Bitstream>) => rdb.hasSucceeded ? rdb.payload : null)
|
||||
);
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Make a new FindListRequest with given search method
|
||||
*
|
||||
|
@@ -24,6 +24,7 @@ import { testFindAllDataImplementation } from './base/find-all-data.spec';
|
||||
import { testSearchDataImplementation } from './base/search-data.spec';
|
||||
import { testPatchDataImplementation } from './base/patch-data.spec';
|
||||
import { testDeleteDataImplementation } from './base/delete-data.spec';
|
||||
import { ObjectCacheServiceStub } from '../../shared/testing/object-cache-service.stub';
|
||||
|
||||
const url = 'fake-url';
|
||||
const collectionId = 'fake-collection-id';
|
||||
@@ -35,7 +36,7 @@ describe('CollectionDataService', () => {
|
||||
let translate: TranslateService;
|
||||
let notificationsService: any;
|
||||
let rdbService: RemoteDataBuildService;
|
||||
let objectCache: ObjectCacheService;
|
||||
let objectCache: ObjectCacheServiceStub;
|
||||
let halService: any;
|
||||
|
||||
const mockCollection1: Collection = Object.assign(new Collection(), {
|
||||
@@ -205,14 +206,12 @@ describe('CollectionDataService', () => {
|
||||
buildFromRequestUUID: buildResponse$,
|
||||
buildSingle: buildResponse$
|
||||
});
|
||||
objectCache = jasmine.createSpyObj('objectCache', {
|
||||
remove: jasmine.createSpy('remove')
|
||||
});
|
||||
objectCache = new ObjectCacheServiceStub();
|
||||
halService = new HALEndpointServiceStub(url);
|
||||
notificationsService = new NotificationsServiceStub();
|
||||
translate = getMockTranslateService();
|
||||
|
||||
service = new CollectionDataService(requestService, rdbService, objectCache, halService, null, notificationsService, null, null, translate);
|
||||
service = new CollectionDataService(requestService, rdbService, objectCache as ObjectCacheService, halService, null, notificationsService, null, null, translate);
|
||||
}
|
||||
|
||||
});
|
||||
|
@@ -7,13 +7,187 @@
|
||||
*/
|
||||
|
||||
import { testFindAllDataImplementation } from '../base/find-all-data.spec';
|
||||
import { ProcessDataService } from './process-data.service';
|
||||
import { ProcessDataService, TIMER_FACTORY } from './process-data.service';
|
||||
import { testDeleteDataImplementation } from '../base/delete-data.spec';
|
||||
import { waitForAsync, TestBed, fakeAsync, tick } from '@angular/core/testing';
|
||||
import { RequestService } from '../request.service';
|
||||
import { RemoteData } from '../remote-data';
|
||||
import { RequestEntryState } from '../request-entry-state.model';
|
||||
import { Process } from '../../../process-page/processes/process.model';
|
||||
import { ProcessStatus } from '../../../process-page/processes/process-status.model';
|
||||
import { RemoteDataBuildService } from '../../cache/builders/remote-data-build.service';
|
||||
import { ObjectCacheService } from '../../cache/object-cache.service';
|
||||
import { ReducerManager } from '@ngrx/store';
|
||||
import { HALEndpointService } from '../../shared/hal-endpoint.service';
|
||||
import { DSOChangeAnalyzer } from '../dso-change-analyzer.service';
|
||||
import { BitstreamFormatDataService } from '../bitstream-format-data.service';
|
||||
import { NotificationsService } from '../../../shared/notifications/notifications.service';
|
||||
import { TestScheduler } from 'rxjs/testing';
|
||||
import { testSearchDataImplementation } from '../base/search-data.spec';
|
||||
import { PaginatedList } from '../paginated-list.model';
|
||||
import { FindListOptions } from '../find-list-options.model';
|
||||
import { of } from 'rxjs';
|
||||
import { getMockRequestService } from '../../../shared/mocks/request.service.mock';
|
||||
|
||||
describe('ProcessDataService', () => {
|
||||
let testScheduler;
|
||||
|
||||
const mockTimer = (fn: () => {}, interval: number) => {
|
||||
fn();
|
||||
return 555;
|
||||
};
|
||||
|
||||
describe('composition', () => {
|
||||
const initService = () => new ProcessDataService(null, null, null, null, null, null);
|
||||
const initService = () => new ProcessDataService(null, null, null, null, null, null, null, null);
|
||||
testFindAllDataImplementation(initService);
|
||||
testDeleteDataImplementation(initService);
|
||||
testSearchDataImplementation(initService);
|
||||
});
|
||||
|
||||
let requestService = getMockRequestService();
|
||||
let processDataService;
|
||||
let remoteDataBuildService;
|
||||
|
||||
describe('autoRefreshUntilCompletion', () => {
|
||||
beforeEach(waitForAsync(() => {
|
||||
testScheduler = new TestScheduler((actual, expected) => {
|
||||
expect(actual).toEqual(expected);
|
||||
});
|
||||
TestBed.configureTestingModule({
|
||||
imports: [],
|
||||
providers: [
|
||||
ProcessDataService,
|
||||
{ provide: RequestService, useValue: null },
|
||||
{ provide: RemoteDataBuildService, useValue: null },
|
||||
{ provide: ObjectCacheService, useValue: null },
|
||||
{ provide: ReducerManager, useValue: null },
|
||||
{ provide: HALEndpointService, useValue: null },
|
||||
{ provide: DSOChangeAnalyzer, useValue: null },
|
||||
{ provide: BitstreamFormatDataService, useValue: null },
|
||||
{ provide: NotificationsService, useValue: null },
|
||||
{ provide: TIMER_FACTORY, useValue: mockTimer },
|
||||
]
|
||||
});
|
||||
|
||||
processDataService = TestBed.inject(ProcessDataService);
|
||||
spyOn(processDataService, 'invalidateByHref');
|
||||
}));
|
||||
|
||||
it('should not do any polling when the process is already completed', () => {
|
||||
testScheduler.run(({ cold, expectObservable }) => {
|
||||
let completedProcess = new Process();
|
||||
completedProcess.processStatus = ProcessStatus.COMPLETED;
|
||||
|
||||
const completedProcessRD = new RemoteData(0, 0, 0, RequestEntryState.Success, null, completedProcess);
|
||||
|
||||
spyOn(processDataService, 'findById').and.returnValue(
|
||||
cold('c', {
|
||||
'c': completedProcessRD
|
||||
})
|
||||
);
|
||||
|
||||
let process$ = processDataService.autoRefreshUntilCompletion('instantly');
|
||||
expectObservable(process$).toBe('c', {
|
||||
c: completedProcessRD
|
||||
});
|
||||
});
|
||||
|
||||
expect(processDataService.findById).toHaveBeenCalledTimes(1);
|
||||
expect(processDataService.invalidateByHref).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should poll until a process completes', () => {
|
||||
testScheduler.run(({ cold, expectObservable }) => {
|
||||
const runningProcess = Object.assign(new Process(), {
|
||||
_links: {
|
||||
self: {
|
||||
href: 'https://rest.api/processes/123'
|
||||
}
|
||||
}
|
||||
});
|
||||
runningProcess.processStatus = ProcessStatus.RUNNING;
|
||||
const completedProcess = new Process();
|
||||
completedProcess.processStatus = ProcessStatus.COMPLETED;
|
||||
const runningProcessRD = new RemoteData(0, 0, 0, RequestEntryState.Success, null, runningProcess);
|
||||
const completedProcessRD = new RemoteData(0, 0, 0, RequestEntryState.Success, null, completedProcess);
|
||||
|
||||
spyOn(processDataService, 'findById').and.returnValue(
|
||||
cold('r 150ms c', {
|
||||
'r': runningProcessRD,
|
||||
'c': completedProcessRD
|
||||
})
|
||||
);
|
||||
|
||||
let process$ = processDataService.autoRefreshUntilCompletion('foo', 100);
|
||||
expectObservable(process$).toBe('r 150ms c', {
|
||||
'r': runningProcessRD,
|
||||
'c': completedProcessRD
|
||||
});
|
||||
});
|
||||
|
||||
expect(processDataService.findById).toHaveBeenCalledTimes(1);
|
||||
expect(processDataService.invalidateByHref).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('autoRefreshingSearchBy', () => {
|
||||
beforeEach(waitForAsync(() => {
|
||||
|
||||
TestBed.configureTestingModule({
|
||||
imports: [],
|
||||
providers: [
|
||||
ProcessDataService,
|
||||
{ provide: RequestService, useValue: requestService },
|
||||
{ provide: RemoteDataBuildService, useValue: null },
|
||||
{ provide: ObjectCacheService, useValue: null },
|
||||
{ provide: ReducerManager, useValue: null },
|
||||
{ provide: HALEndpointService, useValue: null },
|
||||
{ provide: DSOChangeAnalyzer, useValue: null },
|
||||
{ provide: BitstreamFormatDataService, useValue: null },
|
||||
{ provide: NotificationsService, useValue: null },
|
||||
{ provide: TIMER_FACTORY, useValue: mockTimer },
|
||||
]
|
||||
});
|
||||
|
||||
processDataService = TestBed.inject(ProcessDataService);
|
||||
}));
|
||||
|
||||
it('should refresh after the specified interval', fakeAsync(() => {
|
||||
const runningProcess = Object.assign(new Process(), {
|
||||
_links: {
|
||||
self: {
|
||||
href: 'https://rest.api/processes/123'
|
||||
}
|
||||
}
|
||||
});
|
||||
runningProcess.processStatus = ProcessStatus.RUNNING;
|
||||
|
||||
const runningProcessPagination: PaginatedList<Process> = Object.assign(new PaginatedList(), {
|
||||
page: [runningProcess],
|
||||
_links: {
|
||||
self: {
|
||||
href: 'https://rest.api/processesList/456'
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
const runningProcessRD = new RemoteData(0, 0, 0, RequestEntryState.Success, null, runningProcessPagination);
|
||||
|
||||
spyOn(processDataService, 'searchBy').and.returnValue(
|
||||
of(runningProcessRD)
|
||||
);
|
||||
|
||||
expect(processDataService.searchBy).toHaveBeenCalledTimes(0);
|
||||
expect(requestService.setStaleByHrefSubstring).toHaveBeenCalledTimes(0);
|
||||
|
||||
let sub = processDataService.autoRefreshingSearchBy('id', 'byProperty', new FindListOptions(), 200).subscribe();
|
||||
expect(processDataService.searchBy).toHaveBeenCalledTimes(1);
|
||||
|
||||
tick(250);
|
||||
|
||||
expect(requestService.setStaleByHrefSubstring).toHaveBeenCalledTimes(1);
|
||||
|
||||
sub.unsubscribe();
|
||||
}));
|
||||
});
|
||||
});
|
||||
|
@@ -1,12 +1,12 @@
|
||||
import { Injectable } from '@angular/core';
|
||||
import { Injectable, NgZone, Inject, InjectionToken } from '@angular/core';
|
||||
import { RequestService } from '../request.service';
|
||||
import { RemoteDataBuildService } from '../../cache/builders/remote-data-build.service';
|
||||
import { ObjectCacheService } from '../../cache/object-cache.service';
|
||||
import { HALEndpointService } from '../../shared/hal-endpoint.service';
|
||||
import { Process } from '../../../process-page/processes/process.model';
|
||||
import { PROCESS } from '../../../process-page/processes/process.resource-type';
|
||||
import { Observable } from 'rxjs';
|
||||
import { switchMap } from 'rxjs/operators';
|
||||
import { Observable, Subscription } from 'rxjs';
|
||||
import { switchMap, filter, distinctUntilChanged, find } from 'rxjs/operators';
|
||||
import { PaginatedList } from '../paginated-list.model';
|
||||
import { Bitstream } from '../../shared/bitstream.model';
|
||||
import { RemoteData } from '../remote-data';
|
||||
@@ -19,12 +19,29 @@ import { dataService } from '../base/data-service.decorator';
|
||||
import { DeleteData, DeleteDataImpl } from '../base/delete-data';
|
||||
import { NotificationsService } from '../../../shared/notifications/notifications.service';
|
||||
import { NoContent } from '../../shared/NoContent.model';
|
||||
import { getAllCompletedRemoteData } from '../../shared/operators';
|
||||
import { ProcessStatus } from 'src/app/process-page/processes/process-status.model';
|
||||
import { hasValue } from '../../../shared/empty.util';
|
||||
import { SearchData, SearchDataImpl } from '../base/search-data';
|
||||
|
||||
/**
|
||||
* Create an InjectionToken for the default JS setTimeout function, purely so we can mock it during
|
||||
* testing. (fakeAsync isn't working for this case)
|
||||
*/
|
||||
export const TIMER_FACTORY = new InjectionToken<(callback: (...args: any[]) => void, ms?: number, ...args: any[]) => NodeJS.Timeout>('timer', {
|
||||
providedIn: 'root',
|
||||
factory: () => setTimeout
|
||||
});
|
||||
|
||||
@Injectable()
|
||||
@dataService(PROCESS)
|
||||
export class ProcessDataService extends IdentifiableDataService<Process> implements FindAllData<Process>, DeleteData<Process> {
|
||||
export class ProcessDataService extends IdentifiableDataService<Process> implements FindAllData<Process>, DeleteData<Process>, SearchData<Process> {
|
||||
|
||||
private findAllData: FindAllData<Process>;
|
||||
private deleteData: DeleteData<Process>;
|
||||
private searchData: SearchData<Process>;
|
||||
protected activelyBeingPolled: Map<string, NodeJS.Timeout> = new Map();
|
||||
protected subs: Map<string, Subscription> = new Map();
|
||||
|
||||
constructor(
|
||||
protected requestService: RequestService,
|
||||
@@ -33,11 +50,30 @@ export class ProcessDataService extends IdentifiableDataService<Process> impleme
|
||||
protected halService: HALEndpointService,
|
||||
protected bitstreamDataService: BitstreamDataService,
|
||||
protected notificationsService: NotificationsService,
|
||||
protected zone: NgZone,
|
||||
@Inject(TIMER_FACTORY) protected timer: (callback: (...args: any[]) => void, ms?: number, ...args: any[]) => NodeJS.Timeout
|
||||
) {
|
||||
super('processes', requestService, rdbService, objectCache, halService);
|
||||
|
||||
this.findAllData = new FindAllDataImpl(this.linkPath, requestService, rdbService, objectCache, halService, this.responseMsToLive);
|
||||
this.deleteData = new DeleteDataImpl(this.linkPath, requestService, rdbService, objectCache, halService, notificationsService, this.responseMsToLive, this.constructIdEndpoint);
|
||||
this.searchData = new SearchDataImpl(this.linkPath, requestService, rdbService, objectCache, halService, this.responseMsToLive);
|
||||
}
|
||||
|
||||
/**
|
||||
* Return true if the given process has the given status
|
||||
* @protected
|
||||
*/
|
||||
protected static statusIs(process: Process, status: ProcessStatus): boolean {
|
||||
return hasValue(process) && process.processStatus === status;
|
||||
}
|
||||
|
||||
/**
|
||||
* Return true if the given process has the status COMPLETED or FAILED
|
||||
*/
|
||||
public static hasCompletedOrFailed(process: Process): boolean {
|
||||
return ProcessDataService.statusIs(process, ProcessStatus.COMPLETED) ||
|
||||
ProcessDataService.statusIs(process, ProcessStatus.FAILED);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -77,6 +113,71 @@ export class ProcessDataService extends IdentifiableDataService<Process> impleme
|
||||
return this.findAllData.findAll(options, useCachedVersionIfAvailable, reRequestOnStale, ...linksToFollow);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param searchMethod The search method for the Process
|
||||
* @param options The FindListOptions object
|
||||
* @param useCachedVersionIfAvailable If this is true, the request will only be sent if there's
|
||||
* no valid cached version. Defaults to true.
|
||||
* @param reRequestOnStale Whether the request should automatically be re-
|
||||
* requested after the response becomes stale.
|
||||
* @param linksToFollow List of {@link FollowLinkConfig} that indicate which
|
||||
* {@link HALLink}s should automatically be resolved.
|
||||
* @return {Observable<RemoteData<PaginatedList<Process>>>}
|
||||
* Return an observable that emits a paginated list of processes
|
||||
*/
|
||||
searchBy(searchMethod: string, options?: FindListOptions, useCachedVersionIfAvailable?: boolean, reRequestOnStale?: boolean, ...linksToFollow: FollowLinkConfig<Process>[]): Observable<RemoteData<PaginatedList<Process>>> {
|
||||
return this.searchData.searchBy(searchMethod, options, useCachedVersionIfAvailable, reRequestOnStale, ...linksToFollow);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param id The id for this auto-refreshing search. Used to stop
|
||||
* auto-refreshing afterwards, and ensure we're not
|
||||
* auto-refreshing the same thing multiple times.
|
||||
* @param searchMethod The search method for the Process
|
||||
* @param options The FindListOptions object
|
||||
* @param pollingIntervalInMs The interval by which the search will be repeated
|
||||
* @param linksToFollow List of {@link FollowLinkConfig} that indicate which
|
||||
* {@link HALLink}s should automatically be resolved.
|
||||
* @return {Observable<RemoteData<PaginatedList<Process>>>}
|
||||
* Return an observable that emits a paginated list of processes every interval
|
||||
*/
|
||||
autoRefreshingSearchBy(id: string, searchMethod: string, options?: FindListOptions, pollingIntervalInMs: number = 5000, ...linksToFollow: FollowLinkConfig<Process>[]): Observable<RemoteData<PaginatedList<Process>>> {
|
||||
|
||||
const result$ = this.searchBy(searchMethod, options, true, true, ...linksToFollow).pipe(
|
||||
getAllCompletedRemoteData()
|
||||
);
|
||||
|
||||
const sub = result$.pipe(
|
||||
filter(() =>
|
||||
!this.activelyBeingPolled.has(id)
|
||||
)
|
||||
).subscribe((processListRd: RemoteData<PaginatedList<Process>>) => {
|
||||
this.clearCurrentTimeout(id);
|
||||
const nextTimeout = this.timer(() => {
|
||||
this.activelyBeingPolled.delete(id);
|
||||
this.requestService.setStaleByHrefSubstring(processListRd.payload._links.self.href);
|
||||
}, pollingIntervalInMs);
|
||||
|
||||
this.activelyBeingPolled.set(id, nextTimeout);
|
||||
});
|
||||
|
||||
this.subs.set(id, sub);
|
||||
|
||||
return result$;
|
||||
}
|
||||
|
||||
/**
|
||||
* Stop auto-refreshing the request with the given id
|
||||
* @param id the id of the request to stop automatically refreshing
|
||||
*/
|
||||
stopAutoRefreshing(id: string) {
|
||||
this.clearCurrentTimeout(id);
|
||||
if (hasValue(this.subs.get(id))) {
|
||||
this.subs.get(id).unsubscribe();
|
||||
this.subs.delete(id);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete an existing object on the server
|
||||
* @param objectId The id of the object to be removed
|
||||
@@ -101,4 +202,74 @@ export class ProcessDataService extends IdentifiableDataService<Process> impleme
|
||||
public deleteByHref(href: string, copyVirtualMetadata?: string[]): Observable<RemoteData<NoContent>> {
|
||||
return this.deleteData.deleteByHref(href, copyVirtualMetadata);
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear the timeout for the given id, if that timeout exists
|
||||
* @protected
|
||||
*/
|
||||
protected clearCurrentTimeout(id: string): void {
|
||||
const timeout = this.activelyBeingPolled.get(id);
|
||||
if (hasValue(timeout)) {
|
||||
clearTimeout(timeout);
|
||||
}
|
||||
this.activelyBeingPolled.delete(id);
|
||||
}
|
||||
|
||||
/**
|
||||
* Poll the process with the given ID, using the given interval, until that process either
|
||||
* completes successfully or fails
|
||||
*
|
||||
* Return an Observable<RemoteData> for the Process. Note that this will also emit while the
|
||||
* process is still running. It will only emit again when the process (not the RemoteData!) changes
|
||||
* status. That makes it more convenient to retrieve that process for a component: you can replace
|
||||
* a findByID call with this method, rather than having to do a separate findById, and then call
|
||||
* this method
|
||||
*
|
||||
* @param processId The ID of the {@link Process} to poll
|
||||
* @param pollingIntervalInMs The interval for how often the request needs to be polled
|
||||
* @param linksToFollow List of {@link FollowLinkConfig} that indicate which {@link HALLink}s should be
|
||||
* automatically resolved
|
||||
*/
|
||||
public autoRefreshUntilCompletion(processId: string, pollingIntervalInMs = 5000, ...linksToFollow: FollowLinkConfig<Process>[]): Observable<RemoteData<Process>> {
|
||||
const process$: Observable<RemoteData<Process>> = this.findById(processId, true, true, ...linksToFollow)
|
||||
.pipe(
|
||||
getAllCompletedRemoteData(),
|
||||
);
|
||||
|
||||
// Create a subscription that marks the data as stale if the process hasn't been completed and
|
||||
// the polling interval time has been exceeded.
|
||||
const sub = process$.pipe(
|
||||
filter((processRD: RemoteData<Process>) =>
|
||||
!ProcessDataService.hasCompletedOrFailed(processRD.payload) &&
|
||||
!this.activelyBeingPolled.has(processId)
|
||||
)
|
||||
).subscribe((processRD: RemoteData<Process>) => {
|
||||
this.clearCurrentTimeout(processId);
|
||||
if (processRD.hasSucceeded) {
|
||||
const nextTimeout = this.timer(() => {
|
||||
this.activelyBeingPolled.delete(processId);
|
||||
this.invalidateByHref(processRD.payload._links.self.href);
|
||||
}, pollingIntervalInMs);
|
||||
|
||||
this.activelyBeingPolled.set(processId, nextTimeout);
|
||||
}
|
||||
});
|
||||
|
||||
this.subs.set(processId, sub);
|
||||
|
||||
// When the process completes create a one off subscription (the `find` completes the
|
||||
// observable) that unsubscribes the previous one, removes the processId from the list of
|
||||
// processes being polled and clears any running timeouts
|
||||
process$.pipe(
|
||||
find((processRD: RemoteData<Process>) => ProcessDataService.hasCompletedOrFailed(processRD.payload))
|
||||
).subscribe(() => {
|
||||
this.stopAutoRefreshing(processId);
|
||||
});
|
||||
|
||||
return process$.pipe(
|
||||
distinctUntilChanged((previous: RemoteData<Process>, current: RemoteData<Process>) =>
|
||||
previous.payload?.processStatus === current.payload?.processStatus,
|
||||
)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
@@ -23,6 +23,7 @@ import { FindListOptions } from './find-list-options.model';
|
||||
import { testSearchDataImplementation } from './base/search-data.spec';
|
||||
import { MetadataValue } from '../shared/metadata.models';
|
||||
import { MetadataRepresentationType } from '../shared/metadata-representation/metadata-representation.model';
|
||||
import { ObjectCacheServiceStub } from '../../shared/testing/object-cache-service.stub';
|
||||
|
||||
describe('RelationshipDataService', () => {
|
||||
let service: RelationshipDataService;
|
||||
@@ -114,14 +115,7 @@ describe('RelationshipDataService', () => {
|
||||
'href': buildList$,
|
||||
'https://rest.api/core/publication/relationships': relationships$
|
||||
});
|
||||
const objectCache = Object.assign({
|
||||
/* eslint-disable no-empty,@typescript-eslint/no-empty-function */
|
||||
remove: () => {
|
||||
},
|
||||
hasBySelfLinkObservable: () => observableOf(false),
|
||||
hasByHref$: () => observableOf(false)
|
||||
/* eslint-enable no-empty, @typescript-eslint/no-empty-function */
|
||||
}) as ObjectCacheService;
|
||||
const objectCache = new ObjectCacheServiceStub();
|
||||
|
||||
const itemService = jasmine.createSpyObj('itemService', {
|
||||
findById: (uuid) => createSuccessfulRemoteDataObject(relatedItems.find((relatedItem) => relatedItem.id === uuid)),
|
||||
@@ -133,7 +127,7 @@ describe('RelationshipDataService', () => {
|
||||
requestService,
|
||||
rdbService,
|
||||
halService,
|
||||
objectCache,
|
||||
objectCache as ObjectCacheService,
|
||||
itemService,
|
||||
null,
|
||||
jasmine.createSpy('paginatedRelationsToItems').and.returnValue((v) => v),
|
||||
|
@@ -10,6 +10,7 @@ import { RequestService } from './request.service';
|
||||
import { createPaginatedList } from '../../shared/testing/utils.test';
|
||||
import { hasValueOperator } from '../../shared/empty.util';
|
||||
import { ObjectCacheService } from '../cache/object-cache.service';
|
||||
import { ObjectCacheServiceStub } from '../../shared/testing/object-cache-service.stub';
|
||||
|
||||
describe('RelationshipTypeDataService', () => {
|
||||
let service: RelationshipTypeDataService;
|
||||
@@ -28,7 +29,7 @@ describe('RelationshipTypeDataService', () => {
|
||||
|
||||
let buildList;
|
||||
let rdbService;
|
||||
let objectCache;
|
||||
let objectCache: ObjectCacheServiceStub;
|
||||
|
||||
function init() {
|
||||
restEndpointURL = 'https://rest.api/relationshiptypes';
|
||||
@@ -60,21 +61,14 @@ describe('RelationshipTypeDataService', () => {
|
||||
|
||||
buildList = createSuccessfulRemoteDataObject(createPaginatedList([relationshipType1, relationshipType2]));
|
||||
rdbService = getMockRemoteDataBuildService(undefined, observableOf(buildList));
|
||||
objectCache = Object.assign({
|
||||
/* eslint-disable no-empty,@typescript-eslint/no-empty-function */
|
||||
remove: () => {
|
||||
},
|
||||
hasBySelfLinkObservable: () => observableOf(false)
|
||||
/* eslint-enable no-empty, @typescript-eslint/no-empty-function */
|
||||
}) as ObjectCacheService;
|
||||
|
||||
objectCache = new ObjectCacheServiceStub();
|
||||
}
|
||||
|
||||
function initTestService() {
|
||||
return new RelationshipTypeDataService(
|
||||
requestService,
|
||||
rdbService,
|
||||
objectCache,
|
||||
objectCache as ObjectCacheService,
|
||||
halService,
|
||||
);
|
||||
}
|
||||
|
@@ -22,6 +22,7 @@ import {
|
||||
import { ReplaceOperation } from 'fast-json-patch';
|
||||
import { RequestEntry } from '../../../data/request-entry.model';
|
||||
import { FindListOptions } from '../../../data/find-list-options.model';
|
||||
import { ObjectCacheServiceStub } from '../../../../shared/testing/object-cache-service.stub';
|
||||
|
||||
describe('QualityAssuranceEventDataService', () => {
|
||||
let scheduler: TestScheduler;
|
||||
@@ -32,7 +33,7 @@ describe('QualityAssuranceEventDataService', () => {
|
||||
let responseCacheEntryC: RequestEntry;
|
||||
let requestService: RequestService;
|
||||
let rdbService: RemoteDataBuildService;
|
||||
let objectCache: ObjectCacheService;
|
||||
let objectCache: ObjectCacheServiceStub;
|
||||
let halService: HALEndpointService;
|
||||
let notificationsService: NotificationsService;
|
||||
let http: HttpClient;
|
||||
@@ -91,7 +92,7 @@ describe('QualityAssuranceEventDataService', () => {
|
||||
buildFromRequestUUIDAndAwait: jasmine.createSpy('buildFromRequestUUIDAndAwait')
|
||||
});
|
||||
|
||||
objectCache = {} as ObjectCacheService;
|
||||
objectCache = new ObjectCacheServiceStub();
|
||||
halService = jasmine.createSpyObj('halService', {
|
||||
getEndpoint: cold('a|', { a: endpointURL })
|
||||
});
|
||||
@@ -103,7 +104,7 @@ describe('QualityAssuranceEventDataService', () => {
|
||||
service = new QualityAssuranceEventDataService(
|
||||
requestService,
|
||||
rdbService,
|
||||
objectCache,
|
||||
objectCache as ObjectCacheService,
|
||||
halService,
|
||||
notificationsService,
|
||||
comparator
|
||||
|
@@ -19,6 +19,7 @@ import {
|
||||
} from '../../../../shared/mocks/notifications.mock';
|
||||
import { RequestEntry } from '../../../data/request-entry.model';
|
||||
import { QualityAssuranceSourceDataService } from './quality-assurance-source-data.service';
|
||||
import { ObjectCacheServiceStub } from '../../../../shared/testing/object-cache-service.stub';
|
||||
|
||||
describe('QualityAssuranceSourceDataService', () => {
|
||||
let scheduler: TestScheduler;
|
||||
@@ -26,7 +27,7 @@ describe('QualityAssuranceSourceDataService', () => {
|
||||
let responseCacheEntry: RequestEntry;
|
||||
let requestService: RequestService;
|
||||
let rdbService: RemoteDataBuildService;
|
||||
let objectCache: ObjectCacheService;
|
||||
let objectCache: ObjectCacheServiceStub;
|
||||
let halService: HALEndpointService;
|
||||
let notificationsService: NotificationsService;
|
||||
let http: HttpClient;
|
||||
@@ -63,7 +64,7 @@ describe('QualityAssuranceSourceDataService', () => {
|
||||
}),
|
||||
});
|
||||
|
||||
objectCache = {} as ObjectCacheService;
|
||||
objectCache = new ObjectCacheServiceStub();
|
||||
halService = jasmine.createSpyObj('halService', {
|
||||
getEndpoint: cold('a|', { a: endpointURL })
|
||||
});
|
||||
@@ -75,7 +76,7 @@ describe('QualityAssuranceSourceDataService', () => {
|
||||
service = new QualityAssuranceSourceDataService(
|
||||
requestService,
|
||||
rdbService,
|
||||
objectCache,
|
||||
objectCache as ObjectCacheService,
|
||||
halService,
|
||||
notificationsService
|
||||
);
|
||||
|
@@ -19,6 +19,7 @@ import {
|
||||
qualityAssuranceTopicObjectMorePid
|
||||
} from '../../../../shared/mocks/notifications.mock';
|
||||
import { RequestEntry } from '../../../data/request-entry.model';
|
||||
import { ObjectCacheServiceStub } from '../../../../shared/testing/object-cache-service.stub';
|
||||
|
||||
describe('QualityAssuranceTopicDataService', () => {
|
||||
let scheduler: TestScheduler;
|
||||
@@ -26,7 +27,7 @@ describe('QualityAssuranceTopicDataService', () => {
|
||||
let responseCacheEntry: RequestEntry;
|
||||
let requestService: RequestService;
|
||||
let rdbService: RemoteDataBuildService;
|
||||
let objectCache: ObjectCacheService;
|
||||
let objectCache: ObjectCacheServiceStub;
|
||||
let halService: HALEndpointService;
|
||||
let notificationsService: NotificationsService;
|
||||
let http: HttpClient;
|
||||
@@ -63,7 +64,7 @@ describe('QualityAssuranceTopicDataService', () => {
|
||||
}),
|
||||
});
|
||||
|
||||
objectCache = {} as ObjectCacheService;
|
||||
objectCache = new ObjectCacheServiceStub();
|
||||
halService = jasmine.createSpyObj('halService', {
|
||||
getEndpoint: cold('a|', { a: endpointURL })
|
||||
});
|
||||
@@ -75,7 +76,7 @@ describe('QualityAssuranceTopicDataService', () => {
|
||||
service = new QualityAssuranceTopicDataService(
|
||||
requestService,
|
||||
rdbService,
|
||||
objectCache,
|
||||
objectCache as ObjectCacheService,
|
||||
halService,
|
||||
notificationsService
|
||||
);
|
||||
|
@@ -20,13 +20,14 @@ import { FindListOptions } from '../data/find-list-options.model';
|
||||
import { EPersonDataService } from '../eperson/eperson-data.service';
|
||||
import { GroupDataService } from '../eperson/group-data.service';
|
||||
import { RestRequestMethod } from '../data/rest-request-method';
|
||||
import { ObjectCacheServiceStub } from '../../shared/testing/object-cache-service.stub';
|
||||
|
||||
describe('ResourcePolicyService', () => {
|
||||
let scheduler: TestScheduler;
|
||||
let service: ResourcePolicyDataService;
|
||||
let requestService: RequestService;
|
||||
let rdbService: RemoteDataBuildService;
|
||||
let objectCache: ObjectCacheService;
|
||||
let objectCache: ObjectCacheServiceStub;
|
||||
let halService: HALEndpointService;
|
||||
let responseCacheEntry: RequestEntry;
|
||||
let ePersonService: EPersonDataService;
|
||||
@@ -139,14 +140,14 @@ describe('ResourcePolicyService', () => {
|
||||
a: 'https://rest.api/rest/api/eperson/groups/' + groupUUID
|
||||
}),
|
||||
});
|
||||
objectCache = {} as ObjectCacheService;
|
||||
objectCache = new ObjectCacheServiceStub();
|
||||
const notificationsService = {} as NotificationsService;
|
||||
const comparator = {} as any;
|
||||
|
||||
service = new ResourcePolicyDataService(
|
||||
requestService,
|
||||
rdbService,
|
||||
objectCache,
|
||||
objectCache as ObjectCacheService,
|
||||
halService,
|
||||
notificationsService,
|
||||
comparator,
|
||||
|
@@ -4,7 +4,10 @@ import { WorkspaceitemSectionUploadFileObject } from './workspaceitem-section-up
|
||||
* An interface to represent submission's upload section data.
|
||||
*/
|
||||
export interface WorkspaceitemSectionUploadObject {
|
||||
|
||||
/**
|
||||
* Primary bitstream flag
|
||||
*/
|
||||
primary: string | null;
|
||||
/**
|
||||
* A list of [[WorkspaceitemSectionUploadFileObject]]
|
||||
*/
|
||||
|
@@ -7,8 +7,16 @@
|
||||
*/
|
||||
import { VocabularyDataService } from './vocabulary.data.service';
|
||||
import { testFindAllDataImplementation } from '../../data/base/find-all-data.spec';
|
||||
import { FindListOptions } from '../../data/find-list-options.model';
|
||||
import { RequestParam } from '../../cache/models/request-param.model';
|
||||
import { createSuccessfulRemoteDataObject$ } from 'src/app/shared/remote-data.utils';
|
||||
|
||||
describe('VocabularyDataService', () => {
|
||||
let service: VocabularyDataService;
|
||||
service = initTestService();
|
||||
let restEndpointURL = 'https://rest.api/server/api/submission/vocabularies';
|
||||
let vocabularyByMetadataAndCollectionEndpoint = `${restEndpointURL}/search/byMetadataAndCollection?metadata=dc.contributor.author&collection=1234-1234`;
|
||||
|
||||
function initTestService() {
|
||||
return new VocabularyDataService(null, null, null, null);
|
||||
}
|
||||
@@ -17,4 +25,18 @@ describe('VocabularyDataService', () => {
|
||||
const initService = () => new VocabularyDataService(null, null, null, null);
|
||||
testFindAllDataImplementation(initService);
|
||||
});
|
||||
|
||||
describe('getVocabularyByMetadataAndCollection', () => {
|
||||
it('search vocabulary by metadata and collection calls expected methods', () => {
|
||||
spyOn((service as any).searchData, 'getSearchByHref').and.returnValue(vocabularyByMetadataAndCollectionEndpoint);
|
||||
spyOn(service, 'findByHref').and.returnValue(createSuccessfulRemoteDataObject$(null));
|
||||
service.getVocabularyByMetadataAndCollection('dc.contributor.author', '1234-1234');
|
||||
const options = Object.assign(new FindListOptions(), {
|
||||
searchParams: [Object.assign(new RequestParam('metadata', encodeURIComponent('dc.contributor.author'))),
|
||||
Object.assign(new RequestParam('collection', encodeURIComponent('1234-1234')))]
|
||||
});
|
||||
expect((service as any).searchData.getSearchByHref).toHaveBeenCalledWith('byMetadataAndCollection', options);
|
||||
expect(service.findByHref).toHaveBeenCalledWith(vocabularyByMetadataAndCollectionEndpoint, true, true);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
@@ -20,6 +20,8 @@ import { PaginatedList } from '../../data/paginated-list.model';
|
||||
import { Injectable } from '@angular/core';
|
||||
import { VOCABULARY } from './models/vocabularies.resource-type';
|
||||
import { dataService } from '../../data/base/data-service.decorator';
|
||||
import { SearchDataImpl } from '../../data/base/search-data';
|
||||
import { RequestParam } from '../../cache/models/request-param.model';
|
||||
|
||||
/**
|
||||
* Data service to retrieve vocabularies from the REST server.
|
||||
@@ -27,7 +29,10 @@ import { dataService } from '../../data/base/data-service.decorator';
|
||||
@Injectable()
|
||||
@dataService(VOCABULARY)
|
||||
export class VocabularyDataService extends IdentifiableDataService<Vocabulary> implements FindAllData<Vocabulary> {
|
||||
protected searchByMetadataAndCollectionPath = 'byMetadataAndCollection';
|
||||
|
||||
private findAllData: FindAllData<Vocabulary>;
|
||||
private searchData: SearchDataImpl<Vocabulary>;
|
||||
|
||||
constructor(
|
||||
protected requestService: RequestService,
|
||||
@@ -38,6 +43,7 @@ export class VocabularyDataService extends IdentifiableDataService<Vocabulary> i
|
||||
super('vocabularies', requestService, rdbService, objectCache, halService);
|
||||
|
||||
this.findAllData = new FindAllDataImpl(this.linkPath, requestService, rdbService, objectCache, halService, this.responseMsToLive);
|
||||
this.searchData = new SearchDataImpl(this.linkPath, requestService, rdbService, objectCache, halService, this.responseMsToLive);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -57,4 +63,23 @@ export class VocabularyDataService extends IdentifiableDataService<Vocabulary> i
|
||||
public findAll(options?: FindListOptions, useCachedVersionIfAvailable?: boolean, reRequestOnStale?: boolean, ...linksToFollow: FollowLinkConfig<Vocabulary>[]): Observable<RemoteData<PaginatedList<Vocabulary>>> {
|
||||
return this.findAllData.findAll(options, useCachedVersionIfAvailable, reRequestOnStale, ...linksToFollow);
|
||||
}
|
||||
|
||||
/**
|
||||
* Return the controlled vocabulary configured for the specified metadata and collection if any (/submission/vocabularies/search/{@link searchByMetadataAndCollectionPath}?metadata=<>&collection=<>)
|
||||
* @param metadataField metadata field to search
|
||||
* @param collectionUUID collection UUID where is configured the vocabulary
|
||||
* @param useCachedVersionIfAvailable If this is true, the request will only be sent if there's
|
||||
* no valid cached version. Defaults to true
|
||||
* @param reRequestOnStale Whether or not the request should automatically be re-
|
||||
* requested after the response becomes stale
|
||||
* @param linksToFollow List of {@link FollowLinkConfig} that indicate which
|
||||
* {@link HALLink}s should be automatically resolved
|
||||
*/
|
||||
public getVocabularyByMetadataAndCollection(metadataField: string, collectionUUID: string, useCachedVersionIfAvailable = true, reRequestOnStale = true, ...linksToFollow: FollowLinkConfig<Vocabulary>[]): Observable<RemoteData<Vocabulary>> {
|
||||
const findListOptions = new FindListOptions();
|
||||
findListOptions.searchParams = [new RequestParam('metadata', encodeURIComponent(metadataField)),
|
||||
new RequestParam('collection', encodeURIComponent(collectionUUID))];
|
||||
const href$ = this.searchData.getSearchByHref(this.searchByMetadataAndCollectionPath, findListOptions, ...linksToFollow);
|
||||
return this.findByHref(href$, useCachedVersionIfAvailable, reRequestOnStale, ...linksToFollow);
|
||||
}
|
||||
}
|
||||
|
@@ -25,6 +25,7 @@ import { createPaginatedList } from '../../../shared/testing/utils.test';
|
||||
import { RequestEntry } from '../../data/request-entry.model';
|
||||
import { VocabularyDataService } from './vocabulary.data.service';
|
||||
import { VocabularyEntryDetailsDataService } from './vocabulary-entry-details.data.service';
|
||||
import { ObjectCacheServiceStub } from '../../../shared/testing/object-cache-service.stub';
|
||||
|
||||
describe('VocabularyService', () => {
|
||||
let scheduler: TestScheduler;
|
||||
@@ -205,6 +206,7 @@ describe('VocabularyService', () => {
|
||||
|
||||
function initTestService() {
|
||||
hrefOnlyDataService = getMockHrefOnlyDataService();
|
||||
objectCache = new ObjectCacheServiceStub() as ObjectCacheService;
|
||||
|
||||
return new VocabularyService(
|
||||
requestService,
|
||||
@@ -253,7 +255,9 @@ describe('VocabularyService', () => {
|
||||
spyOn((service as any).vocabularyDataService, 'findById').and.callThrough();
|
||||
spyOn((service as any).vocabularyDataService, 'findAll').and.callThrough();
|
||||
spyOn((service as any).vocabularyDataService, 'findByHref').and.callThrough();
|
||||
spyOn((service as any).vocabularyDataService, 'getVocabularyByMetadataAndCollection').and.callThrough();
|
||||
spyOn((service as any).vocabularyDataService.findAllData, 'getFindAllHref').and.returnValue(observableOf(entriesRequestURL));
|
||||
spyOn((service as any).vocabularyDataService.searchData, 'getSearchByHref').and.returnValue(observableOf(searchRequestURL));
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
@@ -310,6 +314,23 @@ describe('VocabularyService', () => {
|
||||
expect(result).toBeObservable(expected);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getVocabularyByMetadataAndCollection', () => {
|
||||
it('should proxy the call to vocabularyDataService.getVocabularyByMetadataAndCollection', () => {
|
||||
scheduler.schedule(() => service.getVocabularyByMetadataAndCollection(metadata, collectionUUID));
|
||||
scheduler.flush();
|
||||
|
||||
expect((service as any).vocabularyDataService.getVocabularyByMetadataAndCollection).toHaveBeenCalledWith(metadata, collectionUUID, true, true);
|
||||
});
|
||||
|
||||
it('should return a RemoteData<Vocabulary> for the object with the given metadata and collection', () => {
|
||||
const result = service.getVocabularyByMetadataAndCollection(metadata, collectionUUID);
|
||||
const expected = cold('a|', {
|
||||
a: vocabularyRD
|
||||
});
|
||||
expect(result).toBeObservable(expected);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('vocabulary entries', () => {
|
||||
|
@@ -87,6 +87,23 @@ export class VocabularyService {
|
||||
return this.vocabularyDataService.findAll(options, useCachedVersionIfAvailable, reRequestOnStale, ...linksToFollow);
|
||||
}
|
||||
|
||||
/**
|
||||
* Return the controlled vocabulary configured for the specified metadata and collection if any
|
||||
* @param metadataField metadata field to search
|
||||
* @param collectionUUID collection UUID where is configured the vocabulary
|
||||
* @param useCachedVersionIfAvailable If this is true, the request will only be sent if there's
|
||||
* no valid cached version. Defaults to true
|
||||
* @param reRequestOnStale Whether or not the request should automatically be re-
|
||||
* requested after the response becomes stale
|
||||
* @param linksToFollow List of {@link FollowLinkConfig} that indicate which
|
||||
* {@link HALLink}s should be automatically resolved
|
||||
* @return {Observable<RemoteData<Vocabulary>>}
|
||||
* Return an observable that emits vocabulary object
|
||||
*/
|
||||
getVocabularyByMetadataAndCollection(metadataField: string, collectionUUID: string, useCachedVersionIfAvailable = true, reRequestOnStale = true, ...linksToFollow: FollowLinkConfig<Vocabulary>[]): Observable<RemoteData<Vocabulary>> {
|
||||
return this.vocabularyDataService.getVocabularyByMetadataAndCollection(metadataField, collectionUUID, useCachedVersionIfAvailable, reRequestOnStale, ...linksToFollow);
|
||||
}
|
||||
|
||||
/**
|
||||
* Return the {@link VocabularyEntry} list for a given {@link Vocabulary}
|
||||
*
|
||||
|
@@ -17,13 +17,14 @@ import { RestResponse } from '../cache/response.models';
|
||||
import { RequestEntry } from '../data/request-entry.model';
|
||||
import { FindListOptions } from '../data/find-list-options.model';
|
||||
import { GroupDataService } from '../eperson/group-data.service';
|
||||
import { ObjectCacheServiceStub } from '../../shared/testing/object-cache-service.stub';
|
||||
|
||||
describe('SupervisionOrderService', () => {
|
||||
let scheduler: TestScheduler;
|
||||
let service: SupervisionOrderDataService;
|
||||
let requestService: RequestService;
|
||||
let rdbService: RemoteDataBuildService;
|
||||
let objectCache: ObjectCacheService;
|
||||
let objectCache: ObjectCacheServiceStub;
|
||||
let halService: HALEndpointService;
|
||||
let responseCacheEntry: RequestEntry;
|
||||
let groupService: GroupDataService;
|
||||
@@ -127,14 +128,14 @@ describe('SupervisionOrderService', () => {
|
||||
a: 'https://rest.api/rest/api/group/groups/' + groupUUID
|
||||
}),
|
||||
});
|
||||
objectCache = {} as ObjectCacheService;
|
||||
objectCache = new ObjectCacheServiceStub();
|
||||
const notificationsService = {} as NotificationsService;
|
||||
const comparator = {} as any;
|
||||
|
||||
service = new SupervisionOrderDataService(
|
||||
requestService,
|
||||
rdbService,
|
||||
objectCache,
|
||||
objectCache as ObjectCacheService,
|
||||
halService,
|
||||
notificationsService,
|
||||
comparator,
|
||||
|
@@ -3,6 +3,7 @@
|
||||
<ds-dso-edit-metadata-value *ngFor="let mdValue of form.fields[mdField]; let idx = index" role="presentation"
|
||||
[dso]="dso"
|
||||
[mdValue]="mdValue"
|
||||
[mdField]="mdField"
|
||||
[dsoType]="dsoType"
|
||||
[saving$]="saving$"
|
||||
[isOnlyValue]="form.fields[mdField].length === 1"
|
||||
|
@@ -75,7 +75,8 @@ export class DsoEditMetadataValue {
|
||||
confirmChanges(finishEditing = false) {
|
||||
this.reordered = this.originalValue.place !== this.newValue.place;
|
||||
if (hasNoValue(this.change) || this.change === DsoEditMetadataChangeType.UPDATE) {
|
||||
if ((this.originalValue.value !== this.newValue.value || this.originalValue.language !== this.newValue.language)) {
|
||||
if (this.originalValue.value !== this.newValue.value || this.originalValue.language !== this.newValue.language
|
||||
|| this.originalValue.authority !== this.newValue.authority || this.originalValue.confidence !== this.newValue.confidence) {
|
||||
this.change = DsoEditMetadataChangeType.UPDATE;
|
||||
} else {
|
||||
this.change = undefined;
|
||||
@@ -404,10 +405,13 @@ export class DsoEditMetadataForm {
|
||||
if (hasValue(value.change)) {
|
||||
if (value.change === DsoEditMetadataChangeType.UPDATE) {
|
||||
// Only changes to value or language are considered "replace" operations. Changes to place are considered "move", which is processed below.
|
||||
if (value.originalValue.value !== value.newValue.value || value.originalValue.language !== value.newValue.language) {
|
||||
if (value.originalValue.value !== value.newValue.value || value.originalValue.language !== value.newValue.language
|
||||
|| value.originalValue.authority !== value.newValue.authority || value.originalValue.confidence !== value.newValue.confidence) {
|
||||
replaceOperations.push(new MetadataPatchReplaceOperation(field, value.originalValue.place, {
|
||||
value: value.newValue.value,
|
||||
language: value.newValue.language,
|
||||
authority: value.newValue.authority,
|
||||
confidence: value.newValue.confidence
|
||||
}));
|
||||
}
|
||||
} else if (value.change === DsoEditMetadataChangeType.REMOVE) {
|
||||
@@ -416,6 +420,8 @@ export class DsoEditMetadataForm {
|
||||
addOperations.push(new MetadataPatchAddOperation(field, {
|
||||
value: value.newValue.value,
|
||||
language: value.newValue.language,
|
||||
authority: value.newValue.authority,
|
||||
confidence: value.newValue.confidence
|
||||
}));
|
||||
} else {
|
||||
console.warn('Illegal metadata change state detected for', value);
|
||||
|
@@ -1,11 +1,58 @@
|
||||
<div class="d-flex flex-row ds-value-row" *ngVar="mdValue.newValue.isVirtual as isVirtual" role="row"
|
||||
cdkDrag (cdkDragStarted)="dragging.emit(true)" (cdkDragEnded)="dragging.emit(false)"
|
||||
[ngClass]="{ 'ds-warning': mdValue.reordered || mdValue.change === DsoEditMetadataChangeTypeEnum.UPDATE, 'ds-danger': mdValue.change === DsoEditMetadataChangeTypeEnum.REMOVE, 'ds-success': mdValue.change === DsoEditMetadataChangeTypeEnum.ADD, 'h-100': isOnlyValue }">
|
||||
<div class="flex-grow-1 ds-flex-cell ds-value-cell d-flex align-items-center" *ngVar="(mdRepresentation$ | async) as mdRepresentation" role="cell">
|
||||
<div class="flex-grow-1 ds-flex-cell ds-value-cell d-flex flex-column" *ngVar="(mdRepresentation$ | async) as mdRepresentation" role="cell">
|
||||
<div class="dont-break-out preserve-line-breaks" *ngIf="!mdValue.editing && !mdRepresentation">{{ mdValue.newValue.value }}</div>
|
||||
<textarea class="form-control" rows="5" *ngIf="mdValue.editing && !mdRepresentation" [(ngModel)]="mdValue.newValue.value"
|
||||
<textarea class="form-control" rows="5" *ngIf="mdValue.editing && !mdRepresentation && !(isAuthorityControlled() | async)" [(ngModel)]="mdValue.newValue.value"
|
||||
[attr.aria-label]="(dsoType + '.edit.metadata.edit.value') | translate"
|
||||
[dsDebounce]="300" (onDebounce)="confirm.emit(false)"></textarea>
|
||||
<ds-dynamic-scrollable-dropdown *ngIf="mdValue.editing && (isScrollableVocabulary() | async)"
|
||||
[bindId]="mdField"
|
||||
[group]="group"
|
||||
[model]="getModel() | async"
|
||||
(change)="onChangeAuthorityField($event)">
|
||||
</ds-dynamic-scrollable-dropdown>
|
||||
<ds-dynamic-onebox *ngIf="mdValue.editing && ((isHierarchicalVocabulary() | async) || (isSuggesterVocabulary() | async))"
|
||||
[group]="group"
|
||||
[model]="getModel() | async"
|
||||
(change)="onChangeAuthorityField($event)">
|
||||
</ds-dynamic-onebox>
|
||||
<div *ngIf="!isVirtual && !mdValue.editing && mdValue.newValue.authority && mdValue.newValue.confidence !== ConfidenceTypeEnum.CF_UNSET && mdValue.newValue.confidence !== ConfidenceTypeEnum.CF_NOVALUE">
|
||||
<span class="badge badge-light border" >
|
||||
<i dsAuthorityConfidenceState
|
||||
class="fas fa-fw p-0"
|
||||
aria-hidden="true"
|
||||
[authorityValue]="mdValue.newValue"
|
||||
[iconMode]="true"
|
||||
></i>
|
||||
{{ dsoType + '.edit.metadata.authority.label' | translate }} {{ mdValue.newValue.authority }}
|
||||
</span>
|
||||
</div>
|
||||
<div class="mt-2" *ngIf=" mdValue.editing && (isAuthorityControlled() | async) && (isSuggesterVocabulary() | async)">
|
||||
<div class="btn-group w-75">
|
||||
<i dsAuthorityConfidenceState
|
||||
class="fas fa-fw p-0 mr-1 mt-auto mb-auto"
|
||||
aria-hidden="true"
|
||||
[authorityValue]="mdValue.newValue.confidence"
|
||||
[iconMode]="true"
|
||||
></i>
|
||||
<input class="form-control form-outline" [(ngModel)]="mdValue.newValue.authority" [disabled]="!editingAuthority"
|
||||
[attr.aria-label]="(dsoType + '.edit.metadata.edit.authority.key') | translate"
|
||||
(change)="onChangeAuthorityKey()" />
|
||||
<button class="btn btn-outline-secondary btn-sm ng-star-inserted" id="metadata-confirm-btn" *ngIf="!editingAuthority"
|
||||
[title]="dsoType + '.edit.metadata.edit.buttons.open-authority-edition' | translate"
|
||||
ngbTooltip="{{ dsoType + '.edit.metadata.edit.buttons.open-authority-edition' | translate }}"
|
||||
(click)="onChangeEditingAuthorityStatus(true)">
|
||||
<i class="fas fa-lock fa-fw"></i>
|
||||
</button>
|
||||
<button class="btn btn-outline-success btn-sm ng-star-inserted" id="metadata-confirm-btn" *ngIf="editingAuthority"
|
||||
[title]="dsoType + '.edit.metadata.edit.buttons.close-authority-edition' | translate"
|
||||
ngbTooltip="{{ dsoType + '.edit.metadata.edit.buttons.close-authority-edition' | translate }}"
|
||||
(click)="onChangeEditingAuthorityStatus(false)">
|
||||
<i class="fas fa-lock-open fa-fw"></i>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="d-flex" *ngIf="mdRepresentation">
|
||||
<a class="mr-2" target="_blank" [routerLink]="mdRepresentationItemRoute$ | async">{{ mdRepresentationName$ | async }}</a>
|
||||
<ds-themed-type-badge [object]="mdRepresentation"></ds-themed-type-badge>
|
||||
@@ -45,14 +92,14 @@
|
||||
[disabled]="isVirtual || (!mdValue.change && mdValue.reordered) || (!mdValue.change && !mdValue.editing) || (saving$ | async)" (click)="undo.emit()">
|
||||
<i class="fas fa-undo-alt fa-fw"></i>
|
||||
</button>
|
||||
<button class="btn btn-outline-secondary ds-drag-handle btn-sm" data-test="metadata-drag-btn" *ngVar="(isOnlyValue || (saving$ | async)) as disabled"
|
||||
cdkDragHandle [cdkDragHandleDisabled]="disabled" [ngClass]="{'disabled': disabled}" [disabled]="disabled"
|
||||
[title]="dsoType + '.edit.metadata.edit.buttons.drag' | translate"
|
||||
ngbTooltip="{{ dsoType + '.edit.metadata.edit.buttons.drag' | translate }}">
|
||||
<i class="fas fa-grip-vertical fa-fw"></i>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<button class="btn btn-outline-secondary ds-drag-handle btn-sm" data-test="metadata-drag-btn" *ngVar="(isOnlyValue || (saving$ | async)) as disabled"
|
||||
cdkDragHandle [cdkDragHandleDisabled]="disabled" [ngClass]="{'disabled': disabled}" [disabled]="disabled"
|
||||
[title]="dsoType + '.edit.metadata.edit.buttons.drag' | translate"
|
||||
ngbTooltip="{{ dsoType + '.edit.metadata.edit.buttons.drag' | translate }}">
|
||||
<i class="fas fa-grip-vertical fa-fw"></i>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
@@ -11,6 +11,23 @@ import { ItemMetadataRepresentation } from '../../../core/shared/metadata-repres
|
||||
import { MetadataValue, VIRTUAL_METADATA_PREFIX } from '../../../core/shared/metadata.models';
|
||||
import { DsoEditMetadataChangeType, DsoEditMetadataValue } from '../dso-edit-metadata-form';
|
||||
import { By } from '@angular/platform-browser';
|
||||
import { ItemDataService } from '../../../core/data/item-data.service';
|
||||
import { Item } from '../../../core/shared/item.model';
|
||||
import { createSuccessfulRemoteDataObject$ } from '../../../shared/remote-data.utils';
|
||||
import { Collection } from '../../../core/shared/collection.model';
|
||||
import { DSpaceObject } from '../../../core/shared/dspace-object.model';
|
||||
import { Vocabulary } from 'src/app/core/submission/vocabularies/models/vocabulary.model';
|
||||
import { VocabularyServiceStub } from 'src/app/shared/testing/vocabulary-service.stub';
|
||||
import { VocabularyService } from 'src/app/core/submission/vocabularies/vocabulary.service';
|
||||
import { ConfidenceType } from 'src/app/core/shared/confidence-type';
|
||||
import { DynamicOneboxModel } from 'src/app/shared/form/builder/ds-dynamic-form-ui/models/onebox/dynamic-onebox.model';
|
||||
import { Observable } from 'rxjs';
|
||||
import { DynamicScrollableDropdownModel } from 'src/app/shared/form/builder/ds-dynamic-form-ui/models/scrollable-dropdown/dynamic-scrollable-dropdown.model';
|
||||
import { RegistryService } from 'src/app/core/registry/registry.service';
|
||||
import { NotificationsService } from 'src/app/shared/notifications/notifications.service';
|
||||
import { createPaginatedList } from 'src/app/shared/testing/utils.test';
|
||||
import { MetadataField } from 'src/app/core/metadata/metadata-field.model';
|
||||
import { MetadataSchema } from 'src/app/core/metadata/metadata-schema.model';
|
||||
|
||||
const EDIT_BTN = 'edit';
|
||||
const CONFIRM_BTN = 'confirm';
|
||||
@@ -24,17 +41,111 @@ describe('DsoEditMetadataValueComponent', () => {
|
||||
|
||||
let relationshipService: RelationshipDataService;
|
||||
let dsoNameService: DSONameService;
|
||||
let vocabularyServiceStub: any;
|
||||
let itemService: ItemDataService;
|
||||
let registryService: RegistryService;
|
||||
let notificationsService: NotificationsService;
|
||||
|
||||
let editMetadataValue: DsoEditMetadataValue;
|
||||
let metadataValue: MetadataValue;
|
||||
let dso: DSpaceObject;
|
||||
|
||||
const collection = Object.assign(new Collection(), {
|
||||
uuid: 'fake-uuid'
|
||||
});
|
||||
|
||||
const item = Object.assign(new Item(), {
|
||||
_links: {
|
||||
self: { href: 'fake-item-url/item' }
|
||||
},
|
||||
id: 'item',
|
||||
uuid: 'item',
|
||||
owningCollection: createSuccessfulRemoteDataObject$(collection)
|
||||
});
|
||||
|
||||
const mockVocabularyScrollable: Vocabulary = {
|
||||
id: 'scrollable',
|
||||
name: 'scrollable',
|
||||
scrollable: true,
|
||||
hierarchical: false,
|
||||
preloadLevel: 0,
|
||||
type: 'vocabulary',
|
||||
_links: {
|
||||
self: {
|
||||
href: 'self'
|
||||
},
|
||||
entries: {
|
||||
href: 'entries'
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const mockVocabularyHierarchical: Vocabulary = {
|
||||
id: 'hierarchical',
|
||||
name: 'hierarchical',
|
||||
scrollable: false,
|
||||
hierarchical: true,
|
||||
preloadLevel: 2,
|
||||
type: 'vocabulary',
|
||||
_links: {
|
||||
self: {
|
||||
href: 'self'
|
||||
},
|
||||
entries: {
|
||||
href: 'entries'
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const mockVocabularySuggester: Vocabulary = {
|
||||
id: 'suggester',
|
||||
name: 'suggester',
|
||||
scrollable: false,
|
||||
hierarchical: false,
|
||||
preloadLevel: 0,
|
||||
type: 'vocabulary',
|
||||
_links: {
|
||||
self: {
|
||||
href: 'self'
|
||||
},
|
||||
entries: {
|
||||
href: 'entries'
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
let metadataSchema: MetadataSchema;
|
||||
let metadataFields: MetadataField[];
|
||||
|
||||
function initServices(): void {
|
||||
metadataSchema = Object.assign(new MetadataSchema(), {
|
||||
id: 0,
|
||||
prefix: 'metadata',
|
||||
namespace: 'http://example.com/',
|
||||
});
|
||||
metadataFields = [
|
||||
Object.assign(new MetadataField(), {
|
||||
id: 0,
|
||||
element: 'regular',
|
||||
qualifier: null,
|
||||
schema: createSuccessfulRemoteDataObject$(metadataSchema),
|
||||
}),
|
||||
];
|
||||
|
||||
relationshipService = jasmine.createSpyObj('relationshipService', {
|
||||
resolveMetadataRepresentation: of(new ItemMetadataRepresentation(metadataValue)),
|
||||
});
|
||||
dsoNameService = jasmine.createSpyObj('dsoNameService', {
|
||||
getName: 'Related Name',
|
||||
});
|
||||
itemService = jasmine.createSpyObj('itemService', {
|
||||
findByHref: createSuccessfulRemoteDataObject$(item)
|
||||
});
|
||||
vocabularyServiceStub = new VocabularyServiceStub();
|
||||
registryService = jasmine.createSpyObj('registryService', {
|
||||
queryMetadataFields: createSuccessfulRemoteDataObject$(createPaginatedList(metadataFields)),
|
||||
});
|
||||
notificationsService = jasmine.createSpyObj('notificationsService', ['error', 'success']);
|
||||
}
|
||||
|
||||
beforeEach(waitForAsync(() => {
|
||||
@@ -45,6 +156,11 @@ describe('DsoEditMetadataValueComponent', () => {
|
||||
authority: undefined,
|
||||
});
|
||||
editMetadataValue = new DsoEditMetadataValue(metadataValue);
|
||||
dso = Object.assign(new DSpaceObject(), {
|
||||
_links: {
|
||||
self: { href: 'fake-dso-url/dso' }
|
||||
},
|
||||
});
|
||||
|
||||
initServices();
|
||||
|
||||
@@ -54,6 +170,10 @@ describe('DsoEditMetadataValueComponent', () => {
|
||||
providers: [
|
||||
{ provide: RelationshipDataService, useValue: relationshipService },
|
||||
{ provide: DSONameService, useValue: dsoNameService },
|
||||
{ provide: VocabularyService, useValue: vocabularyServiceStub },
|
||||
{ provide: ItemDataService, useValue: itemService },
|
||||
{ provide: RegistryService, useValue: registryService },
|
||||
{ provide: NotificationsService, useValue: notificationsService },
|
||||
],
|
||||
schemas: [NO_ERRORS_SCHEMA]
|
||||
}).compileComponents();
|
||||
@@ -63,6 +183,7 @@ describe('DsoEditMetadataValueComponent', () => {
|
||||
fixture = TestBed.createComponent(DsoEditMetadataValueComponent);
|
||||
component = fixture.componentInstance;
|
||||
component.mdValue = editMetadataValue;
|
||||
component.dso = dso;
|
||||
component.saving$ = of(false);
|
||||
fixture.detectChanges();
|
||||
});
|
||||
@@ -144,6 +265,222 @@ describe('DsoEditMetadataValueComponent', () => {
|
||||
assertButton(DRAG_BTN, true, false);
|
||||
});
|
||||
|
||||
describe('when the metadata field not uses a vocabulary and is editing', () => {
|
||||
beforeEach(waitForAsync(() => {
|
||||
spyOn(vocabularyServiceStub, 'getVocabularyByMetadataAndCollection').and.returnValue(createSuccessfulRemoteDataObject$(null, 204));
|
||||
metadataValue = Object.assign(new MetadataValue(), {
|
||||
value: 'Regular value',
|
||||
language: 'en',
|
||||
place: 0,
|
||||
authority: null,
|
||||
});
|
||||
editMetadataValue = new DsoEditMetadataValue(metadataValue);
|
||||
editMetadataValue.editing = true;
|
||||
component.mdValue = editMetadataValue;
|
||||
component.mdField = 'metadata.regular';
|
||||
component.ngOnInit();
|
||||
fixture.detectChanges();
|
||||
}));
|
||||
|
||||
it('should render a textarea', () => {
|
||||
expect(vocabularyServiceStub.getVocabularyByMetadataAndCollection).toHaveBeenCalled();
|
||||
expect(fixture.debugElement.query(By.css('textarea'))).toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
||||
describe('when the metadata field uses a scrollable vocabulary and is editing', () => {
|
||||
beforeEach(waitForAsync(() => {
|
||||
spyOn(vocabularyServiceStub, 'getVocabularyByMetadataAndCollection').and.returnValue(createSuccessfulRemoteDataObject$(mockVocabularyScrollable));
|
||||
metadataValue = Object.assign(new MetadataValue(), {
|
||||
value: 'Authority Controlled value',
|
||||
language: 'en',
|
||||
place: 0,
|
||||
authority: null,
|
||||
});
|
||||
editMetadataValue = new DsoEditMetadataValue(metadataValue);
|
||||
editMetadataValue.editing = true;
|
||||
component.mdValue = editMetadataValue;
|
||||
component.mdField = 'metadata.scrollable';
|
||||
component.ngOnInit();
|
||||
fixture.detectChanges();
|
||||
}));
|
||||
|
||||
it('should render the DsDynamicScrollableDropdownComponent', () => {
|
||||
expect(vocabularyServiceStub.getVocabularyByMetadataAndCollection).toHaveBeenCalled();
|
||||
expect(fixture.debugElement.query(By.css('ds-dynamic-scrollable-dropdown'))).toBeTruthy();
|
||||
});
|
||||
|
||||
it('getModel should return a DynamicScrollableDropdownModel', () => {
|
||||
const result = component.getModel();
|
||||
|
||||
expect(result instanceof Observable).toBe(true);
|
||||
|
||||
result.subscribe((model) => {
|
||||
expect(model instanceof DynamicScrollableDropdownModel).toBe(true);
|
||||
expect(model.vocabularyOptions.name).toBe(mockVocabularyScrollable.name);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('when the metadata field uses a hierarchical vocabulary and is editing', () => {
|
||||
beforeEach(waitForAsync(() => {
|
||||
spyOn(vocabularyServiceStub, 'getVocabularyByMetadataAndCollection').and.returnValue(createSuccessfulRemoteDataObject$(mockVocabularyHierarchical));
|
||||
metadataValue = Object.assign(new MetadataValue(), {
|
||||
value: 'Authority Controlled value',
|
||||
language: 'en',
|
||||
place: 0,
|
||||
authority: null,
|
||||
});
|
||||
editMetadataValue = new DsoEditMetadataValue(metadataValue);
|
||||
editMetadataValue.editing = true;
|
||||
component.mdValue = editMetadataValue;
|
||||
component.mdField = 'metadata.hierarchical';
|
||||
component.ngOnInit();
|
||||
fixture.detectChanges();
|
||||
}));
|
||||
|
||||
it('should render the DsDynamicOneboxComponent', () => {
|
||||
expect(vocabularyServiceStub.getVocabularyByMetadataAndCollection).toHaveBeenCalled();
|
||||
expect(fixture.debugElement.query(By.css('ds-dynamic-onebox'))).toBeTruthy();
|
||||
});
|
||||
|
||||
it('getModel should return a DynamicOneboxModel', () => {
|
||||
const result = component.getModel();
|
||||
|
||||
expect(result instanceof Observable).toBe(true);
|
||||
|
||||
result.subscribe((model) => {
|
||||
expect(model instanceof DynamicOneboxModel).toBe(true);
|
||||
expect(model.vocabularyOptions.name).toBe(mockVocabularyHierarchical.name);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('when the metadata field uses a suggester vocabulary and is editing', () => {
|
||||
beforeEach(waitForAsync(() => {
|
||||
spyOn(vocabularyServiceStub, 'getVocabularyByMetadataAndCollection').and.returnValue(createSuccessfulRemoteDataObject$(mockVocabularySuggester));
|
||||
spyOn(component.confirm, 'emit');
|
||||
metadataValue = Object.assign(new MetadataValue(), {
|
||||
value: 'Authority Controlled value',
|
||||
language: 'en',
|
||||
place: 0,
|
||||
authority: 'authority-key',
|
||||
confidence: ConfidenceType.CF_UNCERTAIN
|
||||
});
|
||||
editMetadataValue = new DsoEditMetadataValue(metadataValue);
|
||||
editMetadataValue.editing = true;
|
||||
component.mdValue = editMetadataValue;
|
||||
component.mdField = 'metadata.suggester';
|
||||
component.ngOnInit();
|
||||
fixture.detectChanges();
|
||||
}));
|
||||
|
||||
it('should render the DsDynamicOneboxComponent', () => {
|
||||
expect(vocabularyServiceStub.getVocabularyByMetadataAndCollection).toHaveBeenCalled();
|
||||
expect(fixture.debugElement.query(By.css('ds-dynamic-onebox'))).toBeTruthy();
|
||||
});
|
||||
|
||||
it('getModel should return a DynamicOneboxModel', () => {
|
||||
const result = component.getModel();
|
||||
|
||||
expect(result instanceof Observable).toBe(true);
|
||||
|
||||
result.subscribe((model) => {
|
||||
expect(model instanceof DynamicOneboxModel).toBe(true);
|
||||
expect(model.vocabularyOptions.name).toBe(mockVocabularySuggester.name);
|
||||
});
|
||||
});
|
||||
|
||||
describe('authority key edition', () => {
|
||||
|
||||
it('should update confidence to CF_NOVALUE when authority is cleared', () => {
|
||||
component.mdValue.newValue.authority = '';
|
||||
|
||||
component.onChangeAuthorityKey();
|
||||
|
||||
expect(component.mdValue.newValue.confidence).toBe(ConfidenceType.CF_NOVALUE);
|
||||
expect(component.confirm.emit).toHaveBeenCalledWith(false);
|
||||
});
|
||||
|
||||
it('should update confidence to CF_ACCEPTED when authority key is edited', () => {
|
||||
component.mdValue.newValue.authority = 'newAuthority';
|
||||
component.mdValue.originalValue.authority = 'oldAuthority';
|
||||
|
||||
component.onChangeAuthorityKey();
|
||||
|
||||
expect(component.mdValue.newValue.confidence).toBe(ConfidenceType.CF_ACCEPTED);
|
||||
expect(component.confirm.emit).toHaveBeenCalledWith(false);
|
||||
});
|
||||
|
||||
it('should not update confidence when authority key remains the same', () => {
|
||||
component.mdValue.newValue.authority = 'sameAuthority';
|
||||
component.mdValue.originalValue.authority = 'sameAuthority';
|
||||
|
||||
component.onChangeAuthorityKey();
|
||||
|
||||
expect(component.mdValue.newValue.confidence).toBe(ConfidenceType.CF_UNCERTAIN);
|
||||
expect(component.confirm.emit).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should call onChangeEditingAuthorityStatus with true when clicking the lock button', () => {
|
||||
spyOn(component, 'onChangeEditingAuthorityStatus');
|
||||
const lockButton = fixture.nativeElement.querySelector('#metadata-confirm-btn');
|
||||
|
||||
lockButton.click();
|
||||
|
||||
expect(component.onChangeEditingAuthorityStatus).toHaveBeenCalledWith(true);
|
||||
});
|
||||
|
||||
it('should disable the input when editingAuthority is false', () => {
|
||||
component.editingAuthority = false;
|
||||
|
||||
fixture.detectChanges();
|
||||
|
||||
const inputElement = fixture.nativeElement.querySelector('input');
|
||||
expect(inputElement.disabled).toBe(true);
|
||||
});
|
||||
|
||||
it('should enable the input when editingAuthority is true', () => {
|
||||
component.editingAuthority = true;
|
||||
|
||||
fixture.detectChanges();
|
||||
|
||||
const inputElement = fixture.nativeElement.querySelector('input');
|
||||
expect(inputElement.disabled).toBe(false);
|
||||
});
|
||||
|
||||
it('should update mdValue.newValue properties when authority is present', () => {
|
||||
const event = {
|
||||
value: 'Some value',
|
||||
authority: 'Some authority',
|
||||
};
|
||||
|
||||
component.onChangeAuthorityField(event);
|
||||
|
||||
expect(component.mdValue.newValue.value).toBe(event.value);
|
||||
expect(component.mdValue.newValue.authority).toBe(event.authority);
|
||||
expect(component.mdValue.newValue.confidence).toBe(ConfidenceType.CF_ACCEPTED);
|
||||
expect(component.confirm.emit).toHaveBeenCalledWith(false);
|
||||
});
|
||||
|
||||
it('should update mdValue.newValue properties when authority is not present', () => {
|
||||
const event = {
|
||||
value: 'Some value',
|
||||
authority: null,
|
||||
};
|
||||
|
||||
component.onChangeAuthorityField(event);
|
||||
|
||||
expect(component.mdValue.newValue.value).toBe(event.value);
|
||||
expect(component.mdValue.newValue.authority).toBeNull();
|
||||
expect(component.mdValue.newValue.confidence).toBe(ConfidenceType.CF_UNSET);
|
||||
expect(component.confirm.emit).toHaveBeenCalledWith(false);
|
||||
});
|
||||
|
||||
});
|
||||
|
||||
});
|
||||
|
||||
function assertButton(name: string, exists: boolean, disabled: boolean = false): void {
|
||||
describe(`${name} button`, () => {
|
||||
let btn: DebugElement;
|
||||
|
@@ -1,4 +1,4 @@
|
||||
import { Component, EventEmitter, Input, OnInit, Output } from '@angular/core';
|
||||
import { ChangeDetectorRef, Component, EventEmitter, Input, OnChanges, OnInit, Output, SimpleChanges } from '@angular/core';
|
||||
import { DsoEditMetadataChangeType, DsoEditMetadataValue } from '../dso-edit-metadata-form';
|
||||
import { Observable } from 'rxjs/internal/Observable';
|
||||
import {
|
||||
@@ -8,10 +8,28 @@ import {
|
||||
import { RelationshipDataService } from '../../../core/data/relationship-data.service';
|
||||
import { DSpaceObject } from '../../../core/shared/dspace-object.model';
|
||||
import { ItemMetadataRepresentation } from '../../../core/shared/metadata-representation/item/item-metadata-representation.model';
|
||||
import { map } from 'rxjs/operators';
|
||||
import { map, switchMap, take } from 'rxjs/operators';
|
||||
import { getItemPageRoute } from '../../../item-page/item-page-routing-paths';
|
||||
import { DSONameService } from '../../../core/breadcrumbs/dso-name.service';
|
||||
import { EMPTY } from 'rxjs/internal/observable/empty';
|
||||
import { VocabularyService } from 'src/app/core/submission/vocabularies/vocabulary.service';
|
||||
import { Vocabulary } from '../../../core/submission/vocabularies/models/vocabulary.model';
|
||||
import { UntypedFormControl, UntypedFormGroup } from '@angular/forms';
|
||||
import { VocabularyOptions } from '../../../core/submission/vocabularies/models/vocabulary-options.model';
|
||||
import { ConfidenceType } from '../../../core/shared/confidence-type';
|
||||
import { getFirstCompletedRemoteData, getFirstSucceededRemoteData, getFirstSucceededRemoteDataPayload, getRemoteDataPayload, metadataFieldsToString } from '../../../core/shared/operators';
|
||||
import { DsDynamicOneboxModelConfig, DynamicOneboxModel } from '../../../shared/form/builder/ds-dynamic-form-ui/models/onebox/dynamic-onebox.model';
|
||||
import { DynamicScrollableDropdownModel, DynamicScrollableDropdownModelConfig } from '../../../shared/form/builder/ds-dynamic-form-ui/models/scrollable-dropdown/dynamic-scrollable-dropdown.model';
|
||||
import { ItemDataService } from '../../../core/data/item-data.service';
|
||||
import { followLink } from '../../../shared/utils/follow-link-config.model';
|
||||
import { Item } from '../../../core/shared/item.model';
|
||||
import { Collection } from '../../../core/shared/collection.model';
|
||||
import { FormFieldMetadataValueObject } from '../../../shared/form/builder/models/form-field-metadata-value.model';
|
||||
import { isNotEmpty } from '../../../shared/empty.util';
|
||||
import { of as observableOf } from 'rxjs';
|
||||
import { RegistryService } from 'src/app/core/registry/registry.service';
|
||||
import { TranslateService } from '@ngx-translate/core';
|
||||
import { NotificationsService } from 'src/app/shared/notifications/notifications.service';
|
||||
|
||||
@Component({
|
||||
selector: 'ds-dso-edit-metadata-value',
|
||||
@@ -21,7 +39,7 @@ import { EMPTY } from 'rxjs/internal/observable/empty';
|
||||
/**
|
||||
* Component displaying a single editable row for a metadata value
|
||||
*/
|
||||
export class DsoEditMetadataValueComponent implements OnInit {
|
||||
export class DsoEditMetadataValueComponent implements OnInit, OnChanges {
|
||||
/**
|
||||
* The parent {@link DSpaceObject} to display a metadata form for
|
||||
* Also used to determine metadata-representations in case of virtual metadata
|
||||
@@ -51,6 +69,11 @@ export class DsoEditMetadataValueComponent implements OnInit {
|
||||
*/
|
||||
@Input() isOnlyValue = false;
|
||||
|
||||
/**
|
||||
* MetadataField to edit
|
||||
*/
|
||||
@Input() mdField?: string;
|
||||
|
||||
/**
|
||||
* Emits when the user clicked edit
|
||||
*/
|
||||
@@ -82,6 +105,12 @@ export class DsoEditMetadataValueComponent implements OnInit {
|
||||
*/
|
||||
public DsoEditMetadataChangeTypeEnum = DsoEditMetadataChangeType;
|
||||
|
||||
/**
|
||||
* The ConfidenceType enumeration for access in the component's template
|
||||
* @type {ConfidenceType}
|
||||
*/
|
||||
public ConfidenceTypeEnum = ConfidenceType;
|
||||
|
||||
/**
|
||||
* The item this metadata value represents in case it's virtual (if any, otherwise null)
|
||||
*/
|
||||
@@ -97,12 +126,48 @@ export class DsoEditMetadataValueComponent implements OnInit {
|
||||
*/
|
||||
mdRepresentationName$: Observable<string | null>;
|
||||
|
||||
/**
|
||||
* Whether or not the authority field is currently being edited
|
||||
*/
|
||||
public editingAuthority = false;
|
||||
|
||||
/**
|
||||
* Field group used by authority field
|
||||
* @type {UntypedFormGroup}
|
||||
*/
|
||||
group = new UntypedFormGroup({ authorityField : new UntypedFormControl()});
|
||||
|
||||
/**
|
||||
* Observable property of the model to use for editinf authorities values
|
||||
*/
|
||||
private model$: Observable<DynamicOneboxModel | DynamicScrollableDropdownModel>;
|
||||
|
||||
/**
|
||||
* Observable with information about the authority vocabulary used
|
||||
*/
|
||||
private vocabulary$: Observable<Vocabulary>;
|
||||
|
||||
/**
|
||||
* Observables with information about the authority vocabulary type used
|
||||
*/
|
||||
private isAuthorityControlled$: Observable<boolean>;
|
||||
private isHierarchicalVocabulary$: Observable<boolean>;
|
||||
private isScrollableVocabulary$: Observable<boolean>;
|
||||
private isSuggesterVocabulary$: Observable<boolean>;
|
||||
|
||||
constructor(protected relationshipService: RelationshipDataService,
|
||||
protected dsoNameService: DSONameService) {
|
||||
protected dsoNameService: DSONameService,
|
||||
protected vocabularyService: VocabularyService,
|
||||
protected itemService: ItemDataService,
|
||||
protected cdr: ChangeDetectorRef,
|
||||
protected registryService: RegistryService,
|
||||
protected notificationsService: NotificationsService,
|
||||
protected translate: TranslateService) {
|
||||
}
|
||||
|
||||
ngOnInit(): void {
|
||||
this.initVirtualProperties();
|
||||
this.initAuthorityProperties();
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -123,4 +188,223 @@ export class DsoEditMetadataValueComponent implements OnInit {
|
||||
map((mdRepresentation: ItemMetadataRepresentation) => mdRepresentation ? this.dsoNameService.getName(mdRepresentation) : null),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Initialise potential properties of a authority controlled metadata field
|
||||
*/
|
||||
initAuthorityProperties(): void {
|
||||
|
||||
if (isNotEmpty(this.mdField)) {
|
||||
|
||||
const owningCollection$: Observable<Collection> = this.itemService.findByHref(this.dso._links.self.href, true, true, followLink('owningCollection'))
|
||||
.pipe(
|
||||
getFirstSucceededRemoteData(),
|
||||
getRemoteDataPayload(),
|
||||
switchMap((item: Item) => item.owningCollection),
|
||||
getFirstSucceededRemoteData(),
|
||||
getRemoteDataPayload()
|
||||
);
|
||||
|
||||
this.vocabulary$ = owningCollection$.pipe(
|
||||
switchMap((c: Collection) => this.vocabularyService
|
||||
.getVocabularyByMetadataAndCollection(this.mdField, c.uuid)
|
||||
.pipe(
|
||||
getFirstSucceededRemoteDataPayload()
|
||||
))
|
||||
);
|
||||
} else {
|
||||
this.vocabulary$ = observableOf(undefined);
|
||||
}
|
||||
|
||||
this.isAuthorityControlled$ = this.vocabulary$.pipe(
|
||||
map((result: Vocabulary) => isNotEmpty(result))
|
||||
);
|
||||
|
||||
this.isHierarchicalVocabulary$ = this.vocabulary$.pipe(
|
||||
map((result: Vocabulary) => isNotEmpty(result) && result.hierarchical)
|
||||
);
|
||||
|
||||
this.isScrollableVocabulary$ = this.vocabulary$.pipe(
|
||||
map((result: Vocabulary) => isNotEmpty(result) && result.scrollable)
|
||||
);
|
||||
|
||||
this.isSuggesterVocabulary$ = this.vocabulary$.pipe(
|
||||
map((result: Vocabulary) => isNotEmpty(result) && !result.hierarchical && !result.scrollable)
|
||||
);
|
||||
|
||||
this.model$ = this.vocabulary$.pipe(
|
||||
map((vocabulary: Vocabulary) => {
|
||||
let formFieldValue;
|
||||
if (isNotEmpty(this.mdValue.newValue.value)) {
|
||||
formFieldValue = new FormFieldMetadataValueObject();
|
||||
formFieldValue.value = this.mdValue.newValue.value;
|
||||
formFieldValue.display = this.mdValue.newValue.value;
|
||||
if (this.mdValue.newValue.authority) {
|
||||
formFieldValue.authority = this.mdValue.newValue.authority;
|
||||
formFieldValue.confidence = this.mdValue.newValue.confidence;
|
||||
}
|
||||
} else {
|
||||
formFieldValue = this.mdValue.newValue.value;
|
||||
}
|
||||
|
||||
let vocabularyOptions = vocabulary ? {
|
||||
closed: false,
|
||||
name: vocabulary.name
|
||||
} as VocabularyOptions : null;
|
||||
|
||||
if (!vocabulary.scrollable) {
|
||||
let model: DsDynamicOneboxModelConfig = {
|
||||
id: 'authorityField',
|
||||
label: `${this.dsoType}.edit.metadata.edit.value`,
|
||||
vocabularyOptions: vocabularyOptions,
|
||||
metadataFields: [this.mdField],
|
||||
value: formFieldValue,
|
||||
repeatable: false,
|
||||
submissionId: 'edit-metadata',
|
||||
hasSelectableMetadata: false,
|
||||
};
|
||||
return new DynamicOneboxModel(model);
|
||||
} else {
|
||||
let model: DynamicScrollableDropdownModelConfig = {
|
||||
id: 'authorityField',
|
||||
label: `${this.dsoType}.edit.metadata.edit.value`,
|
||||
placeholder: `${this.dsoType}.edit.metadata.edit.value`,
|
||||
vocabularyOptions: vocabularyOptions,
|
||||
metadataFields: [this.mdField],
|
||||
value: formFieldValue,
|
||||
repeatable: false,
|
||||
submissionId: 'edit-metadata',
|
||||
hasSelectableMetadata: false,
|
||||
maxOptions: 10
|
||||
};
|
||||
return new DynamicScrollableDropdownModel(model);
|
||||
}
|
||||
}));
|
||||
}
|
||||
|
||||
/**
|
||||
* Change callback for the component. Check if the mdField has changed to retrieve whether it is metadata
|
||||
* that uses a controlled vocabulary and update the related properties
|
||||
*
|
||||
* @param {SimpleChanges} changes
|
||||
*/
|
||||
ngOnChanges(changes: SimpleChanges): void {
|
||||
if (isNotEmpty(changes.mdField) && !changes.mdField.firstChange) {
|
||||
if (isNotEmpty(changes.mdField.currentValue) ) {
|
||||
if (isNotEmpty(changes.mdField.previousValue) &&
|
||||
changes.mdField.previousValue !== changes.mdField.currentValue) {
|
||||
// Clear authority value in case it has been assigned with the previous metadataField used
|
||||
this.mdValue.newValue.authority = null;
|
||||
this.mdValue.newValue.confidence = ConfidenceType.CF_UNSET;
|
||||
}
|
||||
|
||||
// Only ask if the current mdField have a period character to reduce request
|
||||
if (changes.mdField.currentValue.includes('.')) {
|
||||
this.validateMetadataField().subscribe((isValid: boolean) => {
|
||||
if (isValid) {
|
||||
this.initAuthorityProperties();
|
||||
this.cdr.detectChanges();
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate the metadata field to check if it exists on the server and return an observable boolean for success/error
|
||||
*/
|
||||
validateMetadataField(): Observable<boolean> {
|
||||
return this.registryService.queryMetadataFields(this.mdField, null, true, false, followLink('schema')).pipe(
|
||||
getFirstCompletedRemoteData(),
|
||||
switchMap((rd) => {
|
||||
if (rd.hasSucceeded) {
|
||||
return observableOf(rd).pipe(
|
||||
metadataFieldsToString(),
|
||||
take(1),
|
||||
map((fields: string[]) => fields.indexOf(this.mdField) > -1)
|
||||
);
|
||||
} else {
|
||||
this.notificationsService.error(this.translate.instant(`${this.dsoType}.edit.metadata.metadatafield.error`), rd.errorMessage);
|
||||
return [false];
|
||||
}
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if this field use a authority vocabulary
|
||||
*/
|
||||
isAuthorityControlled(): Observable<boolean> {
|
||||
return this.isAuthorityControlled$;
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if configured vocabulary is Hierarchical or not
|
||||
*/
|
||||
isHierarchicalVocabulary(): Observable<boolean> {
|
||||
return this.isHierarchicalVocabulary$;
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if configured vocabulary is Scrollable or not
|
||||
*/
|
||||
isScrollableVocabulary(): Observable<boolean> {
|
||||
return this.isScrollableVocabulary$;
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if configured vocabulary is Suggester or not
|
||||
* (a vocabulary not Scrollable and not Hierarchical that uses an autocomplete field)
|
||||
*/
|
||||
isSuggesterVocabulary(): Observable<boolean> {
|
||||
return this.isSuggesterVocabulary$;
|
||||
}
|
||||
|
||||
/**
|
||||
* Process the change of authority field value updating the authority key and confidence as necessary
|
||||
*/
|
||||
onChangeAuthorityField(event): void {
|
||||
this.mdValue.newValue.value = event.value;
|
||||
if (event.authority) {
|
||||
this.mdValue.newValue.authority = event.authority;
|
||||
this.mdValue.newValue.confidence = ConfidenceType.CF_ACCEPTED;
|
||||
} else {
|
||||
this.mdValue.newValue.authority = null;
|
||||
this.mdValue.newValue.confidence = ConfidenceType.CF_UNSET;
|
||||
}
|
||||
this.confirm.emit(false);
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns an observable with the {@link DynamicOneboxModel} or {@link DynamicScrollableDropdownModel} model used
|
||||
* for the authority field
|
||||
*/
|
||||
getModel(): Observable<DynamicOneboxModel | DynamicScrollableDropdownModel> {
|
||||
return this.model$;
|
||||
}
|
||||
|
||||
/**
|
||||
* Change the status of the editingAuthority property
|
||||
* @param status
|
||||
*/
|
||||
onChangeEditingAuthorityStatus(status: boolean) {
|
||||
this.editingAuthority = status;
|
||||
}
|
||||
|
||||
/**
|
||||
* Processes the change in authority value, updating the confidence as necessary.
|
||||
* If the authority key is cleared, the confidence is set to {@link ConfidenceType.CF_NOVALUE}.
|
||||
* If the authority key is edited and differs from the original, the confidence is set to {@link ConfidenceType.CF_ACCEPTED}.
|
||||
*/
|
||||
onChangeAuthorityKey() {
|
||||
if (this.mdValue.newValue.authority === '') {
|
||||
this.mdValue.newValue.confidence = ConfidenceType.CF_NOVALUE;
|
||||
this.confirm.emit(false);
|
||||
} else if (this.mdValue.newValue.authority !== this.mdValue.originalValue.authority) {
|
||||
this.mdValue.newValue.confidence = ConfidenceType.CF_ACCEPTED;
|
||||
this.confirm.emit(false);
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
@@ -40,6 +40,7 @@
|
||||
[dsoType]="dsoType"
|
||||
[saving$]="savingOrLoadingFieldValidation$"
|
||||
[isOnlyValue]="true"
|
||||
[mdField]="newMdField"
|
||||
(confirm)="confirmNewValue($event)"
|
||||
(remove)="form.newValue = undefined"
|
||||
(undo)="form.newValue = undefined">
|
||||
|
@@ -7,10 +7,12 @@ import { DsoEditMetadataValueComponent } from './dso-edit-metadata/dso-edit-meta
|
||||
import { DsoEditMetadataHeadersComponent } from './dso-edit-metadata/dso-edit-metadata-headers/dso-edit-metadata-headers.component';
|
||||
import { DsoEditMetadataValueHeadersComponent } from './dso-edit-metadata/dso-edit-metadata-value-headers/dso-edit-metadata-value-headers.component';
|
||||
import { ThemedDsoEditMetadataComponent } from './dso-edit-metadata/themed-dso-edit-metadata.component';
|
||||
import { FormModule } from '../shared/form/form.module';
|
||||
|
||||
@NgModule({
|
||||
imports: [
|
||||
SharedModule,
|
||||
FormModule
|
||||
],
|
||||
declarations: [
|
||||
DsoEditMetadataComponent,
|
||||
|
@@ -1,10 +1,19 @@
|
||||
<ds-themed-home-news></ds-themed-home-news>
|
||||
<div class="container">
|
||||
<ng-container *ngIf="(site$ | async) as site">
|
||||
<ds-view-tracker [object]="site"></ds-view-tracker>
|
||||
</ng-container>
|
||||
<ds-themed-search-form [inPlaceSearch]="false" [searchPlaceholder]="'home.search-form.placeholder' | translate"></ds-themed-search-form>
|
||||
<ds-themed-top-level-community-list></ds-themed-top-level-community-list>
|
||||
<ds-recent-item-list *ngIf="recentSubmissionspageSize>0"></ds-recent-item-list>
|
||||
<div [ngClass]="appConfig.homePage.showDiscoverFilters ? 'container-fluid' : 'container'">
|
||||
<div class="row m-5">
|
||||
<div class="col-sm-3" *ngIf="appConfig.homePage.showDiscoverFilters">
|
||||
<ds-configuration-search-page [sideBarWidth]="12" [showViewModes]="false" [searchEnabled]="false"
|
||||
[inPlaceSearch]="false" [showScopeSelector]="false"></ds-configuration-search-page>
|
||||
</div>
|
||||
<div [ngClass]="appConfig.homePage.showDiscoverFilters ? 'col-sm-9' : 'col-sm-12'">
|
||||
<ng-container *ngIf="(site$ | async) as site">
|
||||
<ds-view-tracker [object]="site"></ds-view-tracker>
|
||||
</ng-container>
|
||||
<ds-themed-search-form [inPlaceSearch]="false"
|
||||
[searchPlaceholder]="'home.search-form.placeholder' | translate"></ds-themed-search-form>
|
||||
<ds-themed-top-level-community-list></ds-themed-top-level-community-list>
|
||||
<ds-recent-item-list *ngIf="recentSubmissionspageSize>0"></ds-recent-item-list>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<ds-suggestions-popup></ds-suggestions-popup>
|
||||
|
@@ -1,9 +1,10 @@
|
||||
import { Component, OnInit } from '@angular/core';
|
||||
import { Component, Inject, OnInit } from '@angular/core';
|
||||
import { map } from 'rxjs/operators';
|
||||
import { ActivatedRoute } from '@angular/router';
|
||||
import { Observable } from 'rxjs';
|
||||
import { Site } from '../core/shared/site.model';
|
||||
import { environment } from '../../environments/environment';
|
||||
import { APP_CONFIG, AppConfig } from 'src/config/app-config.interface';
|
||||
@Component({
|
||||
selector: 'ds-home-page',
|
||||
styleUrls: ['./home-page.component.scss'],
|
||||
@@ -14,6 +15,7 @@ export class HomePageComponent implements OnInit {
|
||||
site$: Observable<Site>;
|
||||
recentSubmissionspageSize: number;
|
||||
constructor(
|
||||
@Inject(APP_CONFIG) protected appConfig: AppConfig,
|
||||
private route: ActivatedRoute,
|
||||
) {
|
||||
this.recentSubmissionspageSize = environment.homePage.recentSubmissions.pageSize;
|
||||
|
@@ -3,7 +3,6 @@ import { NgModule } from '@angular/core';
|
||||
import { SharedModule } from '../shared/shared.module';
|
||||
import { HomeNewsComponent } from './home-news/home-news.component';
|
||||
import { HomePageRoutingModule } from './home-page-routing.module';
|
||||
|
||||
import { HomePageComponent } from './home-page.component';
|
||||
import { TopLevelCommunityListComponent } from './top-level-community-list/top-level-community-list.component';
|
||||
import { StatisticsModule } from '../statistics/statistics.module';
|
||||
@@ -13,6 +12,7 @@ import { RecentItemListComponent } from './recent-item-list/recent-item-list.com
|
||||
import { JournalEntitiesModule } from '../entity-groups/journal-entities/journal-entities.module';
|
||||
import { ResearchEntitiesModule } from '../entity-groups/research-entities/research-entities.module';
|
||||
import { ThemedTopLevelCommunityListComponent } from './top-level-community-list/themed-top-level-community-list.component';
|
||||
import { SearchModule } from '../shared/search/search.module';
|
||||
import { NotificationsModule } from '../notifications/notifications.module';
|
||||
|
||||
const DECLARATIONS = [
|
||||
@@ -29,6 +29,7 @@ const DECLARATIONS = [
|
||||
imports: [
|
||||
CommonModule,
|
||||
SharedModule.withEntryComponents(),
|
||||
SearchModule,
|
||||
JournalEntitiesModule.withEntryComponents(),
|
||||
ResearchEntitiesModule.withEntryComponents(),
|
||||
HomePageRoutingModule,
|
||||
|
@@ -2,7 +2,10 @@
|
||||
<ds-metadata-field-wrapper *ngIf="bitstreams?.length > 0" [label]="label | translate">
|
||||
<div class="file-section">
|
||||
<ds-themed-file-download-link *ngFor="let file of bitstreams; let last=last;" [bitstream]="file" [item]="item">
|
||||
<span>{{ dsoNameService.getName(file) }}</span>
|
||||
<span>
|
||||
<span *ngIf="primaryBitsreamId === file.id" class="badge badge-primary">{{ 'item.page.bitstreams.primary' | translate }}</span>
|
||||
{{ dsoNameService.getName(file) }}
|
||||
</span>
|
||||
<span> ({{(file?.sizeBytes) | dsFileSize }})</span>
|
||||
<span *ngIf="!last" innerHTML="{{separator}}"></span>
|
||||
</ds-themed-file-download-link>
|
||||
|
@@ -25,7 +25,8 @@ describe('FileSectionComponent', () => {
|
||||
let fixture: ComponentFixture<FileSectionComponent>;
|
||||
|
||||
const bitstreamDataService = jasmine.createSpyObj('bitstreamDataService', {
|
||||
findAllByItemAndBundleName: createSuccessfulRemoteDataObject$(createPaginatedList([]))
|
||||
findAllByItemAndBundleName: createSuccessfulRemoteDataObject$(createPaginatedList([])),
|
||||
findPrimaryBitstreamByItemAndName: observableOf(null)
|
||||
});
|
||||
|
||||
const mockBitstream: Bitstream = Object.assign(new Bitstream(),
|
||||
@@ -81,6 +82,20 @@ describe('FileSectionComponent', () => {
|
||||
fixture.detectChanges();
|
||||
}));
|
||||
|
||||
it('should set the id of primary bitstream', () => {
|
||||
comp.primaryBitsreamId = undefined;
|
||||
bitstreamDataService.findPrimaryBitstreamByItemAndName.and.returnValue(observableOf(mockBitstream));
|
||||
comp.ngOnInit();
|
||||
expect(comp.primaryBitsreamId).toBe(mockBitstream.id);
|
||||
});
|
||||
|
||||
it('should not set the id of primary bitstream', () => {
|
||||
comp.primaryBitsreamId = undefined;
|
||||
bitstreamDataService.findPrimaryBitstreamByItemAndName.and.returnValue(observableOf(null));
|
||||
comp.ngOnInit();
|
||||
expect(comp.primaryBitsreamId).toBeUndefined();
|
||||
});
|
||||
|
||||
describe('when the bitstreams are loading', () => {
|
||||
beforeEach(() => {
|
||||
comp.bitstreams$.next([mockBitstream]);
|
||||
|
@@ -39,6 +39,8 @@ export class FileSectionComponent implements OnInit {
|
||||
|
||||
pageSize: number;
|
||||
|
||||
primaryBitsreamId: string;
|
||||
|
||||
constructor(
|
||||
protected bitstreamDataService: BitstreamDataService,
|
||||
protected notificationsService: NotificationsService,
|
||||
@@ -50,9 +52,19 @@ export class FileSectionComponent implements OnInit {
|
||||
}
|
||||
|
||||
ngOnInit(): void {
|
||||
this.getPrimaryBitstreamId();
|
||||
this.getNextPage();
|
||||
}
|
||||
|
||||
private getPrimaryBitstreamId() {
|
||||
this.bitstreamDataService.findPrimaryBitstreamByItemAndName(this.item, 'ORIGINAL', true, true).subscribe((primaryBitstream: Bitstream | null) => {
|
||||
if (!primaryBitstream) {
|
||||
return;
|
||||
}
|
||||
this.primaryBitsreamId = primaryBitstream?.id;
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* This method will retrieve the next page of Bitstreams from the external BitstreamDataService call.
|
||||
* It'll retrieve the currentPage from the class variables and it'll add the next page of bitstreams with the
|
||||
|
@@ -5,8 +5,8 @@
|
||||
{{ 'process.detail.title' | translate:{ id: process?.processId, name: process?.scriptName } }}
|
||||
</h1>
|
||||
</div>
|
||||
<div *ngIf="refreshCounter$ | async as seconds" class="col-2 refresh-counter">
|
||||
Refreshing in {{ seconds }}s <i class="fas fa-sync-alt fa-spin"></i>
|
||||
<div *ngIf="isRefreshing$ | async" class="col-2 refresh-counter">
|
||||
{{ 'process.detail.refreshing' | translate }} <i class="fas fa-sync-alt fa-spin"></i>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
@@ -27,7 +27,6 @@ import { ProcessDataService } from '../../core/data/processes/process-data.servi
|
||||
import { DSONameService } from '../../core/breadcrumbs/dso-name.service';
|
||||
import {
|
||||
createFailedRemoteDataObject$,
|
||||
createSuccessfulRemoteDataObject,
|
||||
createSuccessfulRemoteDataObject$
|
||||
} from '../../shared/remote-data.utils';
|
||||
import { createPaginatedList } from '../../shared/testing/utils.test';
|
||||
@@ -35,7 +34,10 @@ import { NotificationsServiceStub } from '../../shared/testing/notifications-ser
|
||||
import { NgbModal } from '@ng-bootstrap/ng-bootstrap';
|
||||
import { NotificationsService } from '../../shared/notifications/notifications.service';
|
||||
import { getProcessListRoute } from '../process-page-routing.paths';
|
||||
import {ProcessStatus} from '../processes/process-status.model';
|
||||
import { PaginatedList } from '../../core/data/paginated-list.model';
|
||||
import { RouterTestingModule } from '@angular/router/testing';
|
||||
import { RouterStub } from '../../shared/testing/router.stub';
|
||||
import { ActivatedRouteStub } from '../../shared/testing/active-router.stub';
|
||||
|
||||
describe('ProcessDetailComponent', () => {
|
||||
let component: ProcessDetailComponent;
|
||||
@@ -45,44 +47,18 @@ describe('ProcessDetailComponent', () => {
|
||||
let nameService: DSONameService;
|
||||
let bitstreamDataService: BitstreamDataService;
|
||||
let httpClient: HttpClient;
|
||||
let route: ActivatedRoute;
|
||||
let route: ActivatedRouteStub;
|
||||
let router: RouterStub;
|
||||
let modalService;
|
||||
let notificationsService: NotificationsServiceStub;
|
||||
|
||||
let process: Process;
|
||||
let fileName: string;
|
||||
let files: Bitstream[];
|
||||
|
||||
let processOutput;
|
||||
|
||||
let modalService;
|
||||
let notificationsService;
|
||||
|
||||
let router;
|
||||
let processOutput: string;
|
||||
|
||||
function init() {
|
||||
processOutput = 'Process Started';
|
||||
process = Object.assign(new Process(), {
|
||||
processId: 1,
|
||||
scriptName: 'script-name',
|
||||
processStatus: 'COMPLETED',
|
||||
parameters: [
|
||||
{
|
||||
name: '-f',
|
||||
value: 'file.xml'
|
||||
},
|
||||
{
|
||||
name: '-i',
|
||||
value: 'identifier'
|
||||
}
|
||||
],
|
||||
_links: {
|
||||
self: {
|
||||
href: 'https://rest.api/processes/1'
|
||||
},
|
||||
output: {
|
||||
href: 'https://rest.api/processes/1/output'
|
||||
}
|
||||
}
|
||||
});
|
||||
fileName = 'fake-file-name';
|
||||
files = [
|
||||
Object.assign(new Bitstream(), {
|
||||
@@ -100,6 +76,33 @@ describe('ProcessDetailComponent', () => {
|
||||
}
|
||||
})
|
||||
];
|
||||
processOutput = 'Process Started';
|
||||
process = Object.assign(new Process(), {
|
||||
processId: 1,
|
||||
scriptName: 'script-name',
|
||||
processStatus: 'COMPLETED',
|
||||
parameters: [
|
||||
{
|
||||
name: '-f',
|
||||
value: 'file.xml'
|
||||
},
|
||||
{
|
||||
name: '-i',
|
||||
value: 'identifier'
|
||||
}
|
||||
],
|
||||
files: createSuccessfulRemoteDataObject$(Object.assign(new PaginatedList(), {
|
||||
page: files,
|
||||
})),
|
||||
_links: {
|
||||
self: {
|
||||
href: 'https://rest.api/processes/1'
|
||||
},
|
||||
output: {
|
||||
href: 'https://rest.api/processes/1/output'
|
||||
}
|
||||
},
|
||||
});
|
||||
const logBitstream = Object.assign(new Bitstream(), {
|
||||
id: 'output.log',
|
||||
_links: {
|
||||
@@ -110,6 +113,7 @@ describe('ProcessDetailComponent', () => {
|
||||
getFiles: createSuccessfulRemoteDataObject$(createPaginatedList(files)),
|
||||
delete: createSuccessfulRemoteDataObject$(null),
|
||||
findById: createSuccessfulRemoteDataObject$(process),
|
||||
autoRefreshUntilCompletion: createSuccessfulRemoteDataObject$(process)
|
||||
});
|
||||
bitstreamDataService = jasmine.createSpyObj('bitstreamDataService', {
|
||||
findByHref: createSuccessfulRemoteDataObject$(logBitstream)
|
||||
@@ -127,28 +131,22 @@ describe('ProcessDetailComponent', () => {
|
||||
|
||||
notificationsService = new NotificationsServiceStub();
|
||||
|
||||
router = jasmine.createSpyObj('router', {
|
||||
navigateByUrl:{}
|
||||
});
|
||||
router = new RouterStub();
|
||||
|
||||
route = jasmine.createSpyObj('route', {
|
||||
data: observableOf({ process: createSuccessfulRemoteDataObject(process) }),
|
||||
snapshot: {
|
||||
params: { id: process.processId }
|
||||
}
|
||||
route = new ActivatedRouteStub({
|
||||
id: process.processId,
|
||||
}, {
|
||||
process: createSuccessfulRemoteDataObject$(process),
|
||||
});
|
||||
}
|
||||
|
||||
beforeEach(waitForAsync(() => {
|
||||
init();
|
||||
TestBed.configureTestingModule({
|
||||
void TestBed.configureTestingModule({
|
||||
declarations: [ProcessDetailComponent, ProcessDetailFieldComponent, VarDirective, FileSizePipe],
|
||||
imports: [TranslateModule.forRoot()],
|
||||
imports: [TranslateModule.forRoot(), RouterTestingModule],
|
||||
providers: [
|
||||
{
|
||||
provide: ActivatedRoute,
|
||||
useValue: { data: observableOf({ process: createSuccessfulRemoteDataObject(process) }), snapshot: { params: { id: 1 } } },
|
||||
},
|
||||
{ provide: ActivatedRoute, useValue: route },
|
||||
{ provide: ProcessDataService, useValue: processService },
|
||||
{ provide: BitstreamDataService, useValue: bitstreamDataService },
|
||||
{ provide: DSONameService, useValue: nameService },
|
||||
@@ -253,6 +251,8 @@ describe('ProcessDetailComponent', () => {
|
||||
describe('deleteProcess', () => {
|
||||
it('should delete the process and navigate back to the overview page on success', () => {
|
||||
spyOn(component, 'closeModal');
|
||||
spyOn(router, 'navigateByUrl').and.callThrough();
|
||||
|
||||
component.deleteProcess(process);
|
||||
|
||||
expect(processService.delete).toHaveBeenCalledWith(process.processId);
|
||||
@@ -263,6 +263,7 @@ describe('ProcessDetailComponent', () => {
|
||||
it('should delete the process and not navigate on error', () => {
|
||||
(processService.delete as jasmine.Spy).and.returnValue(createFailedRemoteDataObject$());
|
||||
spyOn(component, 'closeModal');
|
||||
spyOn(router, 'navigateByUrl').and.callThrough();
|
||||
|
||||
component.deleteProcess(process);
|
||||
|
||||
@@ -272,98 +273,4 @@ describe('ProcessDetailComponent', () => {
|
||||
expect(router.navigateByUrl).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('refresh counter', () => {
|
||||
const queryRefreshCounter = () => fixture.debugElement.query(By.css('.refresh-counter'));
|
||||
|
||||
describe('if process is completed', () => {
|
||||
beforeEach(() => {
|
||||
process.processStatus = ProcessStatus.COMPLETED;
|
||||
route.data = observableOf({process: createSuccessfulRemoteDataObject(process)});
|
||||
});
|
||||
|
||||
it('should not show', () => {
|
||||
spyOn(component, 'startRefreshTimer');
|
||||
|
||||
const refreshCounter = queryRefreshCounter();
|
||||
expect(refreshCounter).toBeNull();
|
||||
|
||||
expect(component.startRefreshTimer).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('if process is not finished', () => {
|
||||
beforeEach(() => {
|
||||
process.processStatus = ProcessStatus.RUNNING;
|
||||
route.data = observableOf({process: createSuccessfulRemoteDataObject(process)});
|
||||
fixture.detectChanges();
|
||||
component.stopRefreshTimer();
|
||||
});
|
||||
|
||||
it('should call startRefreshTimer', () => {
|
||||
spyOn(component, 'startRefreshTimer');
|
||||
|
||||
component.ngOnInit();
|
||||
fixture.detectChanges(); // subscribe to process observable with async pipe
|
||||
|
||||
expect(component.startRefreshTimer).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should call refresh method every 5 seconds, until process is completed', fakeAsync(() => {
|
||||
spyOn(component, 'refresh').and.callThrough();
|
||||
spyOn(component, 'stopRefreshTimer').and.callThrough();
|
||||
|
||||
// start off with a running process in order for the refresh counter starts counting up
|
||||
process.processStatus = ProcessStatus.RUNNING;
|
||||
// set findbyId to return a completed process
|
||||
(processService.findById as jasmine.Spy).and.returnValue(observableOf(createSuccessfulRemoteDataObject(process)));
|
||||
|
||||
component.ngOnInit();
|
||||
fixture.detectChanges(); // subscribe to process observable with async pipe
|
||||
|
||||
expect(component.refresh).not.toHaveBeenCalled();
|
||||
|
||||
expect(component.refreshCounter$.value).toBe(0);
|
||||
|
||||
tick(1001); // 1 second + 1 ms by the setTimeout
|
||||
expect(component.refreshCounter$.value).toBe(5); // 5 - 0
|
||||
|
||||
tick(2001); // 2 seconds + 1 ms by the setTimeout
|
||||
expect(component.refreshCounter$.value).toBe(3); // 5 - 2
|
||||
|
||||
tick(2001); // 2 seconds + 1 ms by the setTimeout
|
||||
expect(component.refreshCounter$.value).toBe(1); // 3 - 2
|
||||
|
||||
tick(1001); // 1 second + 1 ms by the setTimeout
|
||||
expect(component.refreshCounter$.value).toBe(0); // 1 - 1
|
||||
|
||||
// set the process to completed right before the counter checks the process
|
||||
process.processStatus = ProcessStatus.COMPLETED;
|
||||
(processService.findById as jasmine.Spy).and.returnValue(observableOf(createSuccessfulRemoteDataObject(process)));
|
||||
|
||||
tick(1000); // 1 second
|
||||
|
||||
expect(component.refresh).toHaveBeenCalledTimes(1);
|
||||
expect(component.stopRefreshTimer).toHaveBeenCalled();
|
||||
|
||||
expect(component.refreshCounter$.value).toBe(0);
|
||||
|
||||
tick(1001); // 1 second + 1 ms by the setTimeout
|
||||
// startRefreshTimer not called again
|
||||
expect(component.refreshCounter$.value).toBe(0);
|
||||
|
||||
discardPeriodicTasks(); // discard any periodic tasks that have not yet executed
|
||||
}));
|
||||
|
||||
it('should show if refreshCounter is different from 0', () => {
|
||||
component.refreshCounter$.next(1);
|
||||
fixture.detectChanges();
|
||||
|
||||
const refreshCounter = queryRefreshCounter();
|
||||
expect(refreshCounter).not.toBeNull();
|
||||
});
|
||||
|
||||
});
|
||||
|
||||
});
|
||||
});
|
||||
|
@@ -1,8 +1,8 @@
|
||||
import { HttpClient } from '@angular/common/http';
|
||||
import { Component, Inject, NgZone, OnDestroy, OnInit, PLATFORM_ID } from '@angular/core';
|
||||
import { Component, Inject, NgZone, OnInit, PLATFORM_ID, OnDestroy } from '@angular/core';
|
||||
import { ActivatedRoute, Router } from '@angular/router';
|
||||
import { BehaviorSubject, interval, Observable, shareReplay, Subscription } from 'rxjs';
|
||||
import { finalize, map, switchMap, take, tap } from 'rxjs/operators';
|
||||
import { BehaviorSubject, Observable } from 'rxjs';
|
||||
import { finalize, map, switchMap, take, tap, find, startWith, filter } from 'rxjs/operators';
|
||||
import { AuthService } from '../../core/auth/auth.service';
|
||||
import { DSONameService } from '../../core/breadcrumbs/dso-name.service';
|
||||
import { BitstreamDataService } from '../../core/data/bitstream-data.service';
|
||||
@@ -14,7 +14,7 @@ import { DSpaceObject } from '../../core/shared/dspace-object.model';
|
||||
import {
|
||||
getFirstCompletedRemoteData,
|
||||
getFirstSucceededRemoteData,
|
||||
getFirstSucceededRemoteDataPayload
|
||||
getFirstSucceededRemoteDataPayload, getAllSucceededRemoteDataPayload
|
||||
} from '../../core/shared/operators';
|
||||
import { URLCombiner } from '../../core/url-combiner/url-combiner';
|
||||
import { AlertType } from '../../shared/alert/alert-type';
|
||||
@@ -26,8 +26,8 @@ import { NgbModal, NgbModalRef } from '@ng-bootstrap/ng-bootstrap';
|
||||
import { getProcessListRoute } from '../process-page-routing.paths';
|
||||
import { NotificationsService } from '../../shared/notifications/notifications.service';
|
||||
import { TranslateService } from '@ngx-translate/core';
|
||||
import { followLink } from '../../shared/utils/follow-link-config.model';
|
||||
import { isPlatformBrowser } from '@angular/common';
|
||||
import { PROCESS_PAGE_FOLLOW_LINKS } from '../process-page.resolver';
|
||||
|
||||
@Component({
|
||||
selector: 'ds-process-detail',
|
||||
@@ -78,15 +78,17 @@ export class ProcessDetailComponent implements OnInit, OnDestroy {
|
||||
*/
|
||||
dateFormat = 'yyyy-MM-dd HH:mm:ss ZZZZ';
|
||||
|
||||
refreshCounter$ = new BehaviorSubject(0);
|
||||
isRefreshing$: Observable<boolean>;
|
||||
|
||||
isDeleting: boolean;
|
||||
|
||||
protected autoRefreshingID: string;
|
||||
|
||||
/**
|
||||
* Reference to NgbModal
|
||||
*/
|
||||
protected modalRef: NgbModalRef;
|
||||
|
||||
private refreshTimerSub?: Subscription;
|
||||
|
||||
constructor(
|
||||
@Inject(PLATFORM_ID) protected platformId: object,
|
||||
protected route: ActivatedRoute,
|
||||
@@ -108,69 +110,36 @@ export class ProcessDetailComponent implements OnInit, OnDestroy {
|
||||
*/
|
||||
ngOnInit(): void {
|
||||
this.processRD$ = this.route.data.pipe(
|
||||
map((data) => {
|
||||
switchMap((data) => {
|
||||
if (isPlatformBrowser(this.platformId)) {
|
||||
if (!this.isProcessFinished(data.process.payload)) {
|
||||
this.startRefreshTimer();
|
||||
}
|
||||
}
|
||||
|
||||
return data.process as RemoteData<Process>;
|
||||
}),
|
||||
redirectOn4xx(this.router, this.authService),
|
||||
shareReplay(1)
|
||||
);
|
||||
|
||||
this.filesRD$ = this.processRD$.pipe(
|
||||
getFirstSucceededRemoteDataPayload(),
|
||||
switchMap((process: Process) => this.processService.getFiles(process.processId))
|
||||
);
|
||||
}
|
||||
|
||||
refresh() {
|
||||
this.processRD$ = this.processService.findById(
|
||||
this.route.snapshot.params.id,
|
||||
false,
|
||||
true,
|
||||
followLink('script')
|
||||
).pipe(
|
||||
getFirstSucceededRemoteData(),
|
||||
redirectOn4xx(this.router, this.authService),
|
||||
tap((processRemoteData: RemoteData<Process>) => {
|
||||
if (!this.isProcessFinished(processRemoteData.payload)) {
|
||||
this.startRefreshTimer();
|
||||
}
|
||||
}),
|
||||
shareReplay(1)
|
||||
);
|
||||
|
||||
this.filesRD$ = this.processRD$.pipe(
|
||||
getFirstSucceededRemoteDataPayload(),
|
||||
switchMap((process: Process) => this.processService.getFiles(process.processId))
|
||||
);
|
||||
}
|
||||
|
||||
startRefreshTimer() {
|
||||
this.refreshCounter$.next(0);
|
||||
|
||||
this.refreshTimerSub = interval(1000).subscribe(
|
||||
value => {
|
||||
if (value > 5) {
|
||||
setTimeout(() => {
|
||||
this.refresh();
|
||||
this.stopRefreshTimer();
|
||||
this.refreshCounter$.next(0);
|
||||
}, 1);
|
||||
this.autoRefreshingID = this.route.snapshot.params.id;
|
||||
return this.processService.autoRefreshUntilCompletion(this.autoRefreshingID, 5000, ...PROCESS_PAGE_FOLLOW_LINKS);
|
||||
} else {
|
||||
this.refreshCounter$.next(5 - value);
|
||||
return [data.process as RemoteData<Process>];
|
||||
}
|
||||
});
|
||||
}),
|
||||
filter(() => !this.isDeleting),
|
||||
redirectOn4xx(this.router, this.authService),
|
||||
);
|
||||
|
||||
this.isRefreshing$ = this.processRD$.pipe(
|
||||
find((processRD: RemoteData<Process>) => ProcessDataService.hasCompletedOrFailed(processRD.payload)),
|
||||
map(() => false),
|
||||
startWith(true)
|
||||
);
|
||||
|
||||
this.filesRD$ = this.processRD$.pipe(
|
||||
getAllSucceededRemoteDataPayload(),
|
||||
switchMap((process: Process) => process.files),
|
||||
);
|
||||
}
|
||||
|
||||
stopRefreshTimer() {
|
||||
if (hasValue(this.refreshTimerSub)) {
|
||||
this.refreshTimerSub.unsubscribe();
|
||||
this.refreshTimerSub = undefined;
|
||||
/**
|
||||
* Make sure the autoRefreshUntilCompletion is cleaned up properly
|
||||
*/
|
||||
ngOnDestroy() {
|
||||
if (hasValue(this.autoRefreshingID)) {
|
||||
this.processService.stopAutoRefreshing(this.autoRefreshingID);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -249,15 +218,17 @@ export class ProcessDetailComponent implements OnInit, OnDestroy {
|
||||
* @param process
|
||||
*/
|
||||
deleteProcess(process: Process) {
|
||||
this.isDeleting = true;
|
||||
this.processService.delete(process.processId).pipe(
|
||||
getFirstCompletedRemoteData()
|
||||
).subscribe((rd) => {
|
||||
if (rd.hasSucceeded) {
|
||||
this.notificationsService.success(this.translateService.get('process.detail.delete.success'));
|
||||
this.closeModal();
|
||||
this.router.navigateByUrl(getProcessListRoute());
|
||||
void this.router.navigateByUrl(getProcessListRoute());
|
||||
} else {
|
||||
this.notificationsService.error(this.translateService.get('process.detail.delete.error'));
|
||||
this.isDeleting = false;
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -276,8 +247,4 @@ export class ProcessDetailComponent implements OnInit, OnDestroy {
|
||||
closeModal() {
|
||||
this.modalRef.close();
|
||||
}
|
||||
|
||||
ngOnDestroy(): void {
|
||||
this.stopRefreshTimer();
|
||||
}
|
||||
}
|
||||
|
@@ -7,8 +7,7 @@ import { ControlContainer, NgForm } from '@angular/forms';
|
||||
import { ScriptParameter } from '../scripts/script-parameter.model';
|
||||
import { NotificationsService } from '../../shared/notifications/notifications.service';
|
||||
import { TranslateService } from '@ngx-translate/core';
|
||||
import { RequestService } from '../../core/data/request.service';
|
||||
import { Router } from '@angular/router';
|
||||
import { Router, NavigationExtras } from '@angular/router';
|
||||
import { getFirstCompletedRemoteData } from '../../core/shared/operators';
|
||||
import { RemoteData } from '../../core/data/remote-data';
|
||||
import { getProcessListRoute } from '../process-page-routing.paths';
|
||||
@@ -57,7 +56,6 @@ export class ProcessFormComponent implements OnInit {
|
||||
private scriptService: ScriptDataService,
|
||||
private notificationsService: NotificationsService,
|
||||
private translationService: TranslateService,
|
||||
private requestService: RequestService,
|
||||
private router: Router) {
|
||||
}
|
||||
|
||||
@@ -91,7 +89,7 @@ export class ProcessFormComponent implements OnInit {
|
||||
const title = this.translationService.get('process.new.notification.success.title');
|
||||
const content = this.translationService.get('process.new.notification.success.content');
|
||||
this.notificationsService.success(title, content);
|
||||
this.sendBack();
|
||||
this.sendBack(rd.payload);
|
||||
} else {
|
||||
const title = this.translationService.get('process.new.notification.error.title');
|
||||
const content = this.translationService.get('process.new.notification.error.content');
|
||||
@@ -143,11 +141,17 @@ export class ProcessFormComponent implements OnInit {
|
||||
return this.missingParameters.length > 0;
|
||||
}
|
||||
|
||||
private sendBack() {
|
||||
this.requestService.removeByHrefSubstring('/processes');
|
||||
/* should subscribe on the previous method to know the action is finished and then navigate,
|
||||
will fix this when the removeByHrefSubstring changes are merged */
|
||||
this.router.navigateByUrl(getProcessListRoute());
|
||||
/**
|
||||
* Redirect the user to the processes overview page with the new process' ID,
|
||||
* so it can be highlighted in the overview table.
|
||||
* @param newProcess The newly created process
|
||||
* @private
|
||||
*/
|
||||
private sendBack(newProcess: Process) {
|
||||
const extras: NavigationExtras = {
|
||||
queryParams: { new_process_id: newProcess.processId },
|
||||
};
|
||||
void this.router.navigate([getProcessListRoute()], extras);
|
||||
}
|
||||
}
|
||||
|
||||
|
@@ -2,60 +2,46 @@
|
||||
<div class="d-flex">
|
||||
<h1 class="flex-grow-1">{{'process.overview.title' | translate}}</h1>
|
||||
</div>
|
||||
<div class="d-flex justify-content-end">
|
||||
<ng-container *ngTemplateOutlet="buttons"></ng-container>
|
||||
|
||||
<div class="sections">
|
||||
<ds-process-overview-table
|
||||
[processStatus]="ProcessStatus.RUNNING"
|
||||
[useAutoRefreshingSearchBy]="true"
|
||||
[getInfoValueMethod]="processOverviewService.timeStarted"/>
|
||||
<ds-process-overview-table
|
||||
[processStatus]="ProcessStatus.SCHEDULED"
|
||||
[useAutoRefreshingSearchBy]="true"
|
||||
[getInfoValueMethod]="processOverviewService.timeCreated"/>
|
||||
<ds-process-overview-table
|
||||
[processStatus]="ProcessStatus.COMPLETED"
|
||||
[sortField]="ProcessSortField.endTime"
|
||||
[useAutoRefreshingSearchBy]="true"
|
||||
[getInfoValueMethod]="processOverviewService.timeCompleted"/>
|
||||
<ds-process-overview-table
|
||||
[processStatus]="ProcessStatus.FAILED"
|
||||
[sortField]="ProcessSortField.endTime"
|
||||
[useAutoRefreshingSearchBy]="true"
|
||||
[getInfoValueMethod]="processOverviewService.timeCompleted"/>
|
||||
</div>
|
||||
|
||||
<ng-container *ngTemplateOutlet="buttons"></ng-container>
|
||||
</div>
|
||||
|
||||
<ng-template #buttons>
|
||||
<div class="d-flex justify-content-end mb-2">
|
||||
<button *ngIf="processBulkDeleteService.hasSelected()" class="btn btn-primary mr-2"
|
||||
(click)="processBulkDeleteService.clearAllProcesses()"><i
|
||||
class="fas fa-undo pr-2"></i>{{'process.overview.delete.clear' | translate }}
|
||||
class="fas fa-undo pr-2"></i>{{'process.overview.delete.clear' | translate }}
|
||||
</button>
|
||||
<button *ngIf="processBulkDeleteService.hasSelected()" class="btn btn-danger mr-2"
|
||||
(click)="openDeleteModal(deleteModal)"><i
|
||||
class="fas fa-trash pr-2"></i>{{'process.overview.delete' | translate: {count: processBulkDeleteService.getAmountOfSelectedProcesses()} }}
|
||||
class="fas fa-trash pr-2"></i>{{'process.overview.delete' | translate: {count: processBulkDeleteService.getAmountOfSelectedProcesses()} }}
|
||||
</button>
|
||||
<button class="btn btn-success" routerLink="/processes/new"><i
|
||||
class="fas fa-plus pr-2"></i>{{'process.overview.new' | translate}}</button>
|
||||
|
||||
class="fas fa-plus pr-2"></i>{{'process.overview.new' | translate}}</button>
|
||||
</div>
|
||||
<ds-pagination *ngIf="(processesRD$ | async)?.payload?.totalElements > 0"
|
||||
[paginationOptions]="pageConfig"
|
||||
[pageInfoState]="(processesRD$ | async)?.payload"
|
||||
[collectionSize]="(processesRD$ | async)?.payload?.totalElements"
|
||||
[hideGear]="true"
|
||||
[hidePagerWhenSinglePage]="true">
|
||||
<div class="table-responsive">
|
||||
<table class="table table-striped table-hover">
|
||||
<thead>
|
||||
<tr>
|
||||
<th scope="col">{{'process.overview.table.id' | translate}}</th>
|
||||
<th scope="col">{{'process.overview.table.name' | translate}}</th>
|
||||
<th scope="col">{{'process.overview.table.user' | translate}}</th>
|
||||
<th scope="col">{{'process.overview.table.start' | translate}}</th>
|
||||
<th scope="col">{{'process.overview.table.finish' | translate}}</th>
|
||||
<th scope="col">{{'process.overview.table.status' | translate}}</th>
|
||||
<th scope="col">{{'process.overview.table.actions' | translate}}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr *ngFor="let process of (processesRD$ | async)?.payload?.page"
|
||||
[class.table-danger]="processBulkDeleteService.isToBeDeleted(process.processId)">
|
||||
<td><a [routerLink]="['/processes/', process.processId]">{{process.processId}}</a></td>
|
||||
<td><a [routerLink]="['/processes/', process.processId]">{{process.scriptName}}</a></td>
|
||||
<td *ngVar="(getEpersonName(process.userId) | async) as ePersonName">{{ePersonName}}</td>
|
||||
<td>{{process.startTime | date:dateFormat:'UTC'}}</td>
|
||||
<td>{{process.endTime | date:dateFormat:'UTC'}}</td>
|
||||
<td>{{process.processStatus}}</td>
|
||||
<td>
|
||||
<button [attr.aria-label]="'process.overview.delete-process' | translate"
|
||||
(click)="processBulkDeleteService.toggleDelete(process.processId)"
|
||||
class="btn btn-outline-danger">
|
||||
<i class="fas fa-trash"></i>
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</ds-pagination>
|
||||
</div>
|
||||
</ng-template>
|
||||
|
||||
<ng-template #deleteModal>
|
||||
|
||||
@@ -90,4 +76,3 @@
|
||||
|
||||
|
||||
</ng-template>
|
||||
|
||||
|
@@ -3,86 +3,27 @@ import { ComponentFixture, TestBed, waitForAsync } from '@angular/core/testing';
|
||||
import { VarDirective } from '../../shared/utils/var.directive';
|
||||
import { TranslateModule } from '@ngx-translate/core';
|
||||
import { RouterTestingModule } from '@angular/router/testing';
|
||||
import { NO_ERRORS_SCHEMA } from '@angular/core';
|
||||
import { NO_ERRORS_SCHEMA, TemplateRef } from '@angular/core';
|
||||
import { ProcessDataService } from '../../core/data/processes/process-data.service';
|
||||
import { Process } from '../processes/process.model';
|
||||
import { EPersonDataService } from '../../core/eperson/eperson-data.service';
|
||||
import { EPerson } from '../../core/eperson/models/eperson.model';
|
||||
import { By } from '@angular/platform-browser';
|
||||
import { ProcessStatus } from '../processes/process-status.model';
|
||||
import { createSuccessfulRemoteDataObject$ } from '../../shared/remote-data.utils';
|
||||
import { createPaginatedList } from '../../shared/testing/utils.test';
|
||||
import { PaginationService } from '../../core/pagination/pagination.service';
|
||||
import { PaginationServiceStub } from '../../shared/testing/pagination-service.stub';
|
||||
import { DatePipe } from '@angular/common';
|
||||
import { BehaviorSubject } from 'rxjs';
|
||||
import { ProcessBulkDeleteService } from './process-bulk-delete.service';
|
||||
import { NgbModal } from '@ng-bootstrap/ng-bootstrap';
|
||||
import { ProcessOverviewService } from './process-overview.service';
|
||||
|
||||
describe('ProcessOverviewComponent', () => {
|
||||
let component: ProcessOverviewComponent;
|
||||
let fixture: ComponentFixture<ProcessOverviewComponent>;
|
||||
|
||||
let processService: ProcessDataService;
|
||||
let ePersonService: EPersonDataService;
|
||||
let paginationService;
|
||||
|
||||
let processes: Process[];
|
||||
let ePerson: EPerson;
|
||||
|
||||
let processBulkDeleteService;
|
||||
let modalService;
|
||||
|
||||
const pipe = new DatePipe('en-US');
|
||||
|
||||
function init() {
|
||||
processes = [
|
||||
Object.assign(new Process(), {
|
||||
processId: 1,
|
||||
scriptName: 'script-name',
|
||||
startTime: '2020-03-19 00:30:00',
|
||||
endTime: '2020-03-19 23:30:00',
|
||||
processStatus: ProcessStatus.COMPLETED
|
||||
}),
|
||||
Object.assign(new Process(), {
|
||||
processId: 2,
|
||||
scriptName: 'script-name',
|
||||
startTime: '2020-03-20 00:30:00',
|
||||
endTime: '2020-03-20 23:30:00',
|
||||
processStatus: ProcessStatus.FAILED
|
||||
}),
|
||||
Object.assign(new Process(), {
|
||||
processId: 3,
|
||||
scriptName: 'another-script-name',
|
||||
startTime: '2020-03-21 00:30:00',
|
||||
endTime: '2020-03-21 23:30:00',
|
||||
processStatus: ProcessStatus.RUNNING
|
||||
})
|
||||
];
|
||||
ePerson = Object.assign(new EPerson(), {
|
||||
metadata: {
|
||||
'eperson.firstname': [
|
||||
{
|
||||
value: 'John',
|
||||
language: null
|
||||
}
|
||||
],
|
||||
'eperson.lastname': [
|
||||
{
|
||||
value: 'Doe',
|
||||
language: null
|
||||
}
|
||||
]
|
||||
}
|
||||
processService = jasmine.createSpyObj('processOverviewService', {
|
||||
timeStarted: '2024-02-05 16:43:32',
|
||||
});
|
||||
processService = jasmine.createSpyObj('processService', {
|
||||
findAll: createSuccessfulRemoteDataObject$(createPaginatedList(processes))
|
||||
});
|
||||
ePersonService = jasmine.createSpyObj('ePersonService', {
|
||||
findById: createSuccessfulRemoteDataObject$(ePerson)
|
||||
});
|
||||
|
||||
paginationService = new PaginationServiceStub();
|
||||
|
||||
processBulkDeleteService = jasmine.createSpyObj('processBulkDeleteService', {
|
||||
clearAllProcesses: {},
|
||||
@@ -96,11 +37,7 @@ describe('ProcessOverviewComponent', () => {
|
||||
});
|
||||
|
||||
(processBulkDeleteService.isToBeDeleted as jasmine.Spy).and.callFake((id) => {
|
||||
if (id === 2) {
|
||||
return true;
|
||||
} else {
|
||||
return false;
|
||||
}
|
||||
return id === 2;
|
||||
});
|
||||
|
||||
modalService = jasmine.createSpyObj('modalService', {
|
||||
@@ -114,9 +51,7 @@ describe('ProcessOverviewComponent', () => {
|
||||
declarations: [ProcessOverviewComponent, VarDirective],
|
||||
imports: [TranslateModule.forRoot(), RouterTestingModule.withRoutes([])],
|
||||
providers: [
|
||||
{ provide: ProcessDataService, useValue: processService },
|
||||
{ provide: EPersonDataService, useValue: ePersonService },
|
||||
{ provide: PaginationService, useValue: paginationService },
|
||||
{ provide: ProcessOverviewService, useValue: processService },
|
||||
{ provide: ProcessBulkDeleteService, useValue: processBulkDeleteService },
|
||||
{ provide: NgbModal, useValue: modalService },
|
||||
],
|
||||
@@ -130,73 +65,6 @@ describe('ProcessOverviewComponent', () => {
|
||||
fixture.detectChanges();
|
||||
});
|
||||
|
||||
describe('table structure', () => {
|
||||
let rowElements;
|
||||
|
||||
beforeEach(() => {
|
||||
rowElements = fixture.debugElement.queryAll(By.css('tbody tr'));
|
||||
});
|
||||
|
||||
it(`should contain 3 rows`, () => {
|
||||
expect(rowElements.length).toEqual(3);
|
||||
});
|
||||
|
||||
it('should display the process IDs in the first column', () => {
|
||||
rowElements.forEach((rowElement, index) => {
|
||||
const el = rowElement.query(By.css('td:nth-child(1)')).nativeElement;
|
||||
expect(el.textContent).toContain(processes[index].processId);
|
||||
});
|
||||
});
|
||||
|
||||
it('should display the script names in the second column', () => {
|
||||
rowElements.forEach((rowElement, index) => {
|
||||
const el = rowElement.query(By.css('td:nth-child(2)')).nativeElement;
|
||||
expect(el.textContent).toContain(processes[index].scriptName);
|
||||
});
|
||||
});
|
||||
|
||||
it('should display the eperson\'s name in the third column', () => {
|
||||
rowElements.forEach((rowElement, index) => {
|
||||
const el = rowElement.query(By.css('td:nth-child(3)')).nativeElement;
|
||||
expect(el.textContent).toContain(ePerson.name);
|
||||
});
|
||||
});
|
||||
|
||||
it('should display the start time in the fourth column', () => {
|
||||
rowElements.forEach((rowElement, index) => {
|
||||
const el = rowElement.query(By.css('td:nth-child(4)')).nativeElement;
|
||||
expect(el.textContent).toContain(pipe.transform(processes[index].startTime, component.dateFormat, 'UTC'));
|
||||
});
|
||||
});
|
||||
|
||||
it('should display the end time in the fifth column', () => {
|
||||
rowElements.forEach((rowElement, index) => {
|
||||
const el = rowElement.query(By.css('td:nth-child(5)')).nativeElement;
|
||||
expect(el.textContent).toContain(pipe.transform(processes[index].endTime, component.dateFormat, 'UTC'));
|
||||
});
|
||||
});
|
||||
|
||||
it('should display the status in the sixth column', () => {
|
||||
rowElements.forEach((rowElement, index) => {
|
||||
const el = rowElement.query(By.css('td:nth-child(6)')).nativeElement;
|
||||
expect(el.textContent).toContain(processes[index].processStatus);
|
||||
});
|
||||
});
|
||||
it('should display a delete button in the seventh column', () => {
|
||||
rowElements.forEach((rowElement, index) => {
|
||||
const el = rowElement.query(By.css('td:nth-child(7)'));
|
||||
expect(el.nativeElement.innerHTML).toContain('fas fa-trash');
|
||||
|
||||
el.query(By.css('button')).triggerEventHandler('click', null);
|
||||
expect(processBulkDeleteService.toggleDelete).toHaveBeenCalledWith(processes[index].processId);
|
||||
});
|
||||
});
|
||||
it('should indicate a row that has been selected for deletion', () => {
|
||||
const deleteRow = fixture.debugElement.query(By.css('.table-danger'));
|
||||
expect(deleteRow.nativeElement.innerHTML).toContain('/processes/' + processes[1].processId);
|
||||
});
|
||||
});
|
||||
|
||||
describe('overview buttons', () => {
|
||||
it('should show a button to clear selected processes when there are selected processes', () => {
|
||||
const clearButton = fixture.debugElement.query(By.css('.btn-primary'));
|
||||
@@ -232,7 +100,7 @@ describe('ProcessOverviewComponent', () => {
|
||||
|
||||
describe('openDeleteModal', () => {
|
||||
it('should open the modal', () => {
|
||||
component.openDeleteModal({});
|
||||
component.openDeleteModal({} as TemplateRef<any>);
|
||||
expect(modalService.open).toHaveBeenCalledWith({});
|
||||
});
|
||||
});
|
||||
@@ -240,13 +108,11 @@ describe('ProcessOverviewComponent', () => {
|
||||
describe('deleteSelected', () => {
|
||||
it('should call the deleteSelectedProcesses method on the processBulkDeleteService and close the modal when processing is done', () => {
|
||||
spyOn(component, 'closeModal');
|
||||
spyOn(component, 'setProcesses');
|
||||
|
||||
component.deleteSelected();
|
||||
|
||||
expect(processBulkDeleteService.deleteSelectedProcesses).toHaveBeenCalled();
|
||||
expect(component.closeModal).toHaveBeenCalled();
|
||||
expect(component.setProcesses).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
@@ -1,20 +1,10 @@
|
||||
import { Component, OnDestroy, OnInit } from '@angular/core';
|
||||
import { Observable, Subscription } from 'rxjs';
|
||||
import { RemoteData } from '../../core/data/remote-data';
|
||||
import { PaginatedList } from '../../core/data/paginated-list.model';
|
||||
import { Process } from '../processes/process.model';
|
||||
import { PaginationComponentOptions } from '../../shared/pagination/pagination-component-options.model';
|
||||
import { EPersonDataService } from '../../core/eperson/eperson-data.service';
|
||||
import { getFirstSucceededRemoteDataPayload } from '../../core/shared/operators';
|
||||
import { EPerson } from '../../core/eperson/models/eperson.model';
|
||||
import { map, switchMap } from 'rxjs/operators';
|
||||
import { ProcessDataService } from '../../core/data/processes/process-data.service';
|
||||
import { PaginationService } from '../../core/pagination/pagination.service';
|
||||
import { FindListOptions } from '../../core/data/find-list-options.model';
|
||||
import { Component, OnDestroy, OnInit, TemplateRef } from '@angular/core';
|
||||
import { Subscription } from 'rxjs';
|
||||
import { ProcessBulkDeleteService } from './process-bulk-delete.service';
|
||||
import { NgbModal } from '@ng-bootstrap/ng-bootstrap';
|
||||
import { hasValue } from '../../shared/empty.util';
|
||||
import { DSONameService } from '../../core/breadcrumbs/dso-name.service';
|
||||
import { ProcessOverviewService, ProcessSortField } from './process-overview.service';
|
||||
import { ProcessStatus } from '../processes/process-status.model';
|
||||
|
||||
@Component({
|
||||
selector: 'ds-process-overview',
|
||||
@@ -25,72 +15,25 @@ import { DSONameService } from '../../core/breadcrumbs/dso-name.service';
|
||||
*/
|
||||
export class ProcessOverviewComponent implements OnInit, OnDestroy {
|
||||
|
||||
/**
|
||||
* List of all processes
|
||||
*/
|
||||
processesRD$: Observable<RemoteData<PaginatedList<Process>>>;
|
||||
// Enums are redeclared here so they can be used in the template
|
||||
protected readonly ProcessStatus = ProcessStatus;
|
||||
protected readonly ProcessSortField = ProcessSortField;
|
||||
|
||||
/**
|
||||
* The current pagination configuration for the page used by the FindAll method
|
||||
*/
|
||||
config: FindListOptions = Object.assign(new FindListOptions(), {
|
||||
elementsPerPage: 20
|
||||
});
|
||||
|
||||
/**
|
||||
* The current pagination configuration for the page
|
||||
*/
|
||||
pageConfig: PaginationComponentOptions = Object.assign(new PaginationComponentOptions(), {
|
||||
id: 'po',
|
||||
pageSize: 20
|
||||
});
|
||||
|
||||
/**
|
||||
* Date format to use for start and end time of processes
|
||||
*/
|
||||
dateFormat = 'yyyy-MM-dd HH:mm:ss';
|
||||
|
||||
processesToDelete: string[] = [];
|
||||
private modalRef: any;
|
||||
|
||||
isProcessingSub: Subscription;
|
||||
|
||||
constructor(protected processService: ProcessDataService,
|
||||
protected paginationService: PaginationService,
|
||||
protected ePersonService: EPersonDataService,
|
||||
constructor(protected processOverviewService: ProcessOverviewService,
|
||||
protected modalService: NgbModal,
|
||||
public processBulkDeleteService: ProcessBulkDeleteService,
|
||||
protected dsoNameService: DSONameService,
|
||||
) {
|
||||
}
|
||||
|
||||
ngOnInit(): void {
|
||||
this.setProcesses();
|
||||
this.processBulkDeleteService.clearAllProcesses();
|
||||
}
|
||||
|
||||
/**
|
||||
* Send a request to fetch all processes for the current page
|
||||
*/
|
||||
setProcesses() {
|
||||
this.processesRD$ = this.paginationService.getFindListOptions(this.pageConfig.id, this.config).pipe(
|
||||
switchMap((config) => this.processService.findAll(config, true, false))
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the name of an EPerson by ID
|
||||
* @param id ID of the EPerson
|
||||
*/
|
||||
getEpersonName(id: string): Observable<string> {
|
||||
return this.ePersonService.findById(id).pipe(
|
||||
getFirstSucceededRemoteDataPayload(),
|
||||
map((eperson: EPerson) => this.dsoNameService.getName(eperson)),
|
||||
);
|
||||
}
|
||||
|
||||
ngOnDestroy(): void {
|
||||
this.paginationService.clearPagination(this.pageConfig.id);
|
||||
if (hasValue(this.isProcessingSub)) {
|
||||
this.isProcessingSub.unsubscribe();
|
||||
}
|
||||
@@ -100,7 +43,7 @@ export class ProcessOverviewComponent implements OnInit, OnDestroy {
|
||||
* Open a given modal.
|
||||
* @param content - the modal content.
|
||||
*/
|
||||
openDeleteModal(content) {
|
||||
openDeleteModal(content: TemplateRef<any>) {
|
||||
this.modalRef = this.modalService.open(content);
|
||||
}
|
||||
|
||||
@@ -126,7 +69,6 @@ export class ProcessOverviewComponent implements OnInit, OnDestroy {
|
||||
.subscribe((isProcessing) => {
|
||||
if (!isProcessing) {
|
||||
this.closeModal();
|
||||
this.setProcesses();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
97
src/app/process-page/overview/process-overview.service.ts
Normal file
97
src/app/process-page/overview/process-overview.service.ts
Normal file
@@ -0,0 +1,97 @@
|
||||
import { Injectable } from '@angular/core';
|
||||
import { ProcessDataService } from '../../core/data/processes/process-data.service';
|
||||
import { FindListOptions } from '../../core/data/find-list-options.model';
|
||||
import { Observable } from 'rxjs';
|
||||
import { RemoteData } from '../../core/data/remote-data';
|
||||
import { PaginatedList } from '../../core/data/paginated-list.model';
|
||||
import { Process } from '../processes/process.model';
|
||||
import { RequestParam } from '../../core/cache/models/request-param.model';
|
||||
import { ProcessStatus } from '../processes/process-status.model';
|
||||
import { DatePipe } from '@angular/common';
|
||||
import { PaginationComponentOptions } from '../../shared/pagination/pagination-component-options.model';
|
||||
import { SortOptions, SortDirection } from '../../core/cache/models/sort-options.model';
|
||||
import { hasValue } from '../../shared/empty.util';
|
||||
|
||||
/**
|
||||
* The sortable fields for processes
|
||||
* See [the endpoint documentation]{@link https://github.com/DSpace/RestContract/blob/main/processes-endpoint.md#search-processes-by-property}
|
||||
* for details.
|
||||
*/
|
||||
export enum ProcessSortField {
|
||||
creationTime = 'creationTime',
|
||||
startTime = 'startTime',
|
||||
endTime = 'endTime',
|
||||
}
|
||||
|
||||
/**
|
||||
* Service to manage the processes displayed in the
|
||||
* {@Link ProcessOverviewComponent} and the {@Link ProcessOverviewTableComponent}
|
||||
*/
|
||||
@Injectable({
|
||||
providedIn: 'root',
|
||||
})
|
||||
export class ProcessOverviewService {
|
||||
|
||||
constructor(protected processDataService: ProcessDataService) {
|
||||
}
|
||||
|
||||
/**
|
||||
* Date format to use for start and end time of processes
|
||||
*/
|
||||
dateFormat = 'yyyy-MM-dd HH:mm:ss';
|
||||
|
||||
datePipe = new DatePipe('en-US');
|
||||
|
||||
|
||||
timeCreated = (process: Process) => this.datePipe.transform(process.creationTime, this.dateFormat, 'UTC');
|
||||
timeCompleted = (process: Process) => this.datePipe.transform(process.endTime, this.dateFormat, 'UTC');
|
||||
timeStarted = (process: Process) => this.datePipe.transform(process.startTime, this.dateFormat, 'UTC');
|
||||
|
||||
/**
|
||||
* Retrieve processes by their status
|
||||
* @param processStatus The status for which to retrieve processes
|
||||
* @param findListOptions The FindListOptions object
|
||||
* @param autoRefreshingIntervalInMs Optional: The interval by which to automatically refresh the retrieved processes.
|
||||
* Leave empty or set to null to only retrieve the processes once.
|
||||
*/
|
||||
getProcessesByProcessStatus(processStatus: ProcessStatus, findListOptions?: FindListOptions, autoRefreshingIntervalInMs: number = null): Observable<RemoteData<PaginatedList<Process>>> {
|
||||
let requestParam = new RequestParam('processStatus', processStatus);
|
||||
let options: FindListOptions = Object.assign(new FindListOptions(), {
|
||||
searchParams: [requestParam],
|
||||
elementsPerPage: 5,
|
||||
}, findListOptions);
|
||||
|
||||
if (hasValue(autoRefreshingIntervalInMs) && autoRefreshingIntervalInMs > 0) {
|
||||
this.processDataService.stopAutoRefreshing(processStatus);
|
||||
return this.processDataService.autoRefreshingSearchBy(processStatus, 'byProperty', options, autoRefreshingIntervalInMs);
|
||||
} else {
|
||||
return this.processDataService.searchBy('byProperty', options);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Stop auto-refreshing the process with the given status
|
||||
* @param processStatus the processStatus of the request to stop automatically refreshing
|
||||
*/
|
||||
stopAutoRefreshing(processStatus: ProcessStatus) {
|
||||
this.processDataService.stopAutoRefreshing(processStatus);
|
||||
}
|
||||
|
||||
/**
|
||||
* Map the provided paginationOptions to FindListOptions
|
||||
* @param paginationOptions the PaginationComponentOptions to map
|
||||
* @param sortField the field on which the processes are sorted
|
||||
*/
|
||||
getFindListOptions(paginationOptions: PaginationComponentOptions, sortField: ProcessSortField): FindListOptions {
|
||||
let sortOptions = new SortOptions(sortField, SortDirection.DESC);
|
||||
return Object.assign(
|
||||
new FindListOptions(),
|
||||
{
|
||||
currentPage: paginationOptions.currentPage,
|
||||
elementsPerPage: paginationOptions.pageSize,
|
||||
sort: sortOptions,
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
}
|
@@ -0,0 +1,66 @@
|
||||
<div class="mb-4">
|
||||
<div class="d-flex" (click)="collapse.toggle()" [attr.aria-expanded]="!collapse.collapsed" role="button">
|
||||
<h2 class="flex-grow-1">
|
||||
{{'process.overview.table.' + processStatus.toLowerCase() + '.title' | translate}}
|
||||
<span class="badge badge-pill badge-primary badge-nb-processes"
|
||||
*ngIf="(processesRD$ | async) as processesRD">
|
||||
{{processesRD?.payload?.totalElements}}
|
||||
</span>
|
||||
<span class="ml-2 toggle-icon">
|
||||
<i class="fas" [ngClass]="collapse.collapsed ? 'fa-angle-right' : 'fa-angle-down'"></i>
|
||||
</span>
|
||||
</h2>
|
||||
</div>
|
||||
|
||||
<div ngbCollapse #collapse="ngbCollapse" [ngbCollapse]="isCollapsed">
|
||||
|
||||
<ng-container *ngVar="(processesRD$ | async) as processesRD">
|
||||
<ds-themed-loading *ngIf="!processesRD || processesRD.isLoading"/>
|
||||
|
||||
<ds-pagination *ngIf="processesRD?.payload?.totalElements > 0"
|
||||
[paginationOptions]="(paginationOptions$ | async)"
|
||||
[collectionSize]="processesRD?.payload?.totalElements"
|
||||
[retainScrollPosition]="true"
|
||||
[hideGear]="true">
|
||||
|
||||
<div class="table-responsive mt-1">
|
||||
<table class="table table-striped table-hover">
|
||||
<thead>
|
||||
<tr>
|
||||
<th scope="col" class="id-header">{{'process.overview.table.id' | translate}}</th>
|
||||
<th scope="col" class="name-header">{{'process.overview.table.name' | translate}}</th>
|
||||
<th scope="col" class="user-header">{{'process.overview.table.user' | translate}}</th>
|
||||
<th scope="col" class="info-header">{{'process.overview.table.' + processStatus.toLowerCase() + '.info' | translate}}</th>
|
||||
<th scope="col" class="actions-header">{{'process.overview.table.actions' | translate}}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
|
||||
<tbody>
|
||||
<tr *ngFor="let tableEntry of processesRD?.payload?.page"
|
||||
[class]="getRowClass(tableEntry.process)">
|
||||
<td><a [routerLink]="['/processes/', tableEntry.process.processId]">{{tableEntry.process.processId}}</a></td>
|
||||
<td><a [routerLink]="['/processes/', tableEntry.process.processId]">{{tableEntry.process.scriptName}}</a></td>
|
||||
<td>{{tableEntry.user}}</td>
|
||||
<td>{{tableEntry.info}}</td>
|
||||
<td>
|
||||
<button [attr.aria-label]="'process.overview.delete-process' | translate"
|
||||
aria-hidden="true"
|
||||
(click)="processBulkDeleteService.toggleDelete(tableEntry.process.processId)"
|
||||
class="btn btn-outline-danger">
|
||||
<i class="fas fa-trash"></i>
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
</ds-pagination>
|
||||
|
||||
<div *ngIf="processesRD?.payload?.totalElements == 0">
|
||||
<p>{{'process.overview.table.empty' | translate}}</p>
|
||||
</div>
|
||||
</ng-container>
|
||||
|
||||
</div>
|
||||
</div>
|
@@ -0,0 +1,28 @@
|
||||
.toggle-icon {
|
||||
font-size: calc(var(--bs-small-font-size) * 0.6);
|
||||
}
|
||||
|
||||
.badge-nb-processes {
|
||||
font-size: var(--ds-process-overview-table-nb-processes-badge-size);
|
||||
vertical-align: middle;
|
||||
}
|
||||
|
||||
.id-header {
|
||||
width: var(--ds-process-overview-table-id-column-width);
|
||||
}
|
||||
|
||||
.name-header {
|
||||
width: var(--ds-process-overview-table-name-column-width);
|
||||
}
|
||||
|
||||
.user-header {
|
||||
width: var(--ds-process-overview-table-user-column-width);
|
||||
}
|
||||
|
||||
.info-header {
|
||||
width: var(--ds-process-overview-table-info-column-width);
|
||||
}
|
||||
|
||||
.actions-header {
|
||||
width: var(--ds-process-overview-table-actions-column-width);
|
||||
}
|
@@ -0,0 +1,205 @@
|
||||
import { ProcessOverviewTableComponent } from './process-overview-table.component';
|
||||
import { ComponentFixture, waitForAsync, TestBed } from '@angular/core/testing';
|
||||
import { ProcessDataService } from '../../../core/data/processes/process-data.service';
|
||||
import { EPersonDataService } from '../../../core/eperson/eperson-data.service';
|
||||
import { Process } from '../../processes/process.model';
|
||||
import { EPerson } from '../../../core/eperson/models/eperson.model';
|
||||
import { ProcessBulkDeleteService } from '../process-bulk-delete.service';
|
||||
import { ProcessStatus } from '../../processes/process-status.model';
|
||||
import { createSuccessfulRemoteDataObject$ } from '../../../shared/remote-data.utils';
|
||||
import { createPaginatedList } from '../../../shared/testing/utils.test';
|
||||
import { PaginationServiceStub } from '../../../shared/testing/pagination-service.stub';
|
||||
import { BehaviorSubject } from 'rxjs';
|
||||
import { NgbModal, NgbCollapse } from '@ng-bootstrap/ng-bootstrap';
|
||||
import { VarDirective } from '../../../shared/utils/var.directive';
|
||||
import { TranslateModule } from '@ngx-translate/core';
|
||||
import { RouterTestingModule } from '@angular/router/testing';
|
||||
import { PaginationService } from '../../../core/pagination/pagination.service';
|
||||
import { By } from '@angular/platform-browser';
|
||||
import { AuthService } from '../../../core/auth/auth.service';
|
||||
import { AuthServiceMock } from '../../../shared/mocks/auth.service.mock';
|
||||
import { RouteService } from '../../../core/services/route.service';
|
||||
import { routeServiceStub } from '../../../shared/testing/route-service.stub';
|
||||
import { ProcessOverviewService } from '../process-overview.service';
|
||||
import { take } from 'rxjs/operators';
|
||||
|
||||
|
||||
describe('ProcessOverviewTableComponent', () => {
|
||||
let component: ProcessOverviewTableComponent;
|
||||
let fixture: ComponentFixture<ProcessOverviewTableComponent>;
|
||||
|
||||
let processOverviewService: ProcessOverviewService;
|
||||
let processService: ProcessDataService;
|
||||
let ePersonService: EPersonDataService;
|
||||
let paginationService; // : PaginationService; Not typed as the stub does not fully implement PaginationService
|
||||
let processBulkDeleteService: ProcessBulkDeleteService;
|
||||
let modalService: NgbModal;
|
||||
let authService; // : AuthService; Not typed as the mock does not fully implement AuthService
|
||||
let routeService: RouteService;
|
||||
|
||||
let processes: Process[];
|
||||
let ePerson: EPerson;
|
||||
|
||||
function init() {
|
||||
processes = [
|
||||
Object.assign(new Process(), {
|
||||
processId: 1,
|
||||
scriptName: 'script-a',
|
||||
startTime: '2020-03-19 00:30:00',
|
||||
endTime: '2020-03-19 23:30:00',
|
||||
processStatus: ProcessStatus.COMPLETED
|
||||
}),
|
||||
Object.assign(new Process(), {
|
||||
processId: 2,
|
||||
scriptName: 'script-b',
|
||||
startTime: '2020-03-20 00:30:00',
|
||||
endTime: '2020-03-20 23:30:00',
|
||||
processStatus: ProcessStatus.FAILED
|
||||
}),
|
||||
Object.assign(new Process(), {
|
||||
processId: 3,
|
||||
scriptName: 'script-c',
|
||||
startTime: '2020-03-21 00:30:00',
|
||||
endTime: '2020-03-21 23:30:00',
|
||||
processStatus: ProcessStatus.RUNNING
|
||||
}),
|
||||
];
|
||||
ePerson = Object.assign(new EPerson(), {
|
||||
metadata: {
|
||||
'eperson.firstname': [
|
||||
{
|
||||
value: 'John',
|
||||
language: null
|
||||
}
|
||||
],
|
||||
'eperson.lastname': [
|
||||
{
|
||||
value: 'Doe',
|
||||
language: null
|
||||
}
|
||||
]
|
||||
}
|
||||
});
|
||||
processOverviewService = jasmine.createSpyObj('processOverviewService', {
|
||||
getFindListOptions: {
|
||||
currentPage: 1,
|
||||
elementsPerPage: 5,
|
||||
sort: 'creationTime'
|
||||
},
|
||||
getProcessesByProcessStatus: createSuccessfulRemoteDataObject$(createPaginatedList(processes)).pipe(take(1))
|
||||
});
|
||||
processService = jasmine.createSpyObj('processService', {
|
||||
searchBy: createSuccessfulRemoteDataObject$(createPaginatedList(processes)).pipe(take(1))
|
||||
});
|
||||
ePersonService = jasmine.createSpyObj('ePersonService', {
|
||||
findById: createSuccessfulRemoteDataObject$(ePerson)
|
||||
});
|
||||
|
||||
paginationService = new PaginationServiceStub();
|
||||
|
||||
processBulkDeleteService = jasmine.createSpyObj('processBulkDeleteService', {
|
||||
clearAllProcesses: {},
|
||||
deleteSelectedProcesses: {},
|
||||
isProcessing$: new BehaviorSubject(false),
|
||||
hasSelected: true,
|
||||
isToBeDeleted: true,
|
||||
toggleDelete: {},
|
||||
getAmountOfSelectedProcesses: 5
|
||||
|
||||
});
|
||||
|
||||
(processBulkDeleteService.isToBeDeleted as jasmine.Spy).and.callFake((id) => {
|
||||
return id === 2;
|
||||
});
|
||||
|
||||
modalService = jasmine.createSpyObj('modalService', {
|
||||
open: {}
|
||||
});
|
||||
|
||||
authService = new AuthServiceMock();
|
||||
routeService = routeServiceStub;
|
||||
}
|
||||
|
||||
beforeEach(waitForAsync(() => {
|
||||
init();
|
||||
|
||||
void TestBed.configureTestingModule({
|
||||
declarations: [ProcessOverviewTableComponent, VarDirective, NgbCollapse],
|
||||
imports: [TranslateModule.forRoot(), RouterTestingModule.withRoutes([])],
|
||||
providers: [
|
||||
{ provide: ProcessOverviewService, useValue: processOverviewService },
|
||||
{ provide: ProcessDataService, useValue: processService },
|
||||
{ provide: EPersonDataService, useValue: ePersonService },
|
||||
{ provide: PaginationService, useValue: paginationService },
|
||||
{ provide: ProcessBulkDeleteService, useValue: processBulkDeleteService },
|
||||
{ provide: NgbModal, useValue: modalService },
|
||||
{ provide: AuthService, useValue: authService },
|
||||
{ provide: RouteService, useValue: routeService },
|
||||
],
|
||||
}).compileComponents();
|
||||
}));
|
||||
|
||||
beforeEach(() => {
|
||||
fixture = TestBed.createComponent(ProcessOverviewTableComponent);
|
||||
component = fixture.componentInstance;
|
||||
component.getInfoValueMethod = (_process: Process) => 'process info';
|
||||
component.processStatus = ProcessStatus.COMPLETED;
|
||||
fixture.detectChanges();
|
||||
});
|
||||
|
||||
describe('table structure', () => {
|
||||
let rowElements;
|
||||
|
||||
beforeEach(() => {
|
||||
rowElements = fixture.debugElement.queryAll(By.css('tbody tr'));
|
||||
});
|
||||
|
||||
it('should contain 3 rows', () => {
|
||||
expect(rowElements.length).toEqual(3);
|
||||
});
|
||||
|
||||
it('should display the process\' ID in the first column', () => {
|
||||
rowElements.forEach((rowElement, index) => {
|
||||
const el = rowElement.query(By.css('td:nth-child(1)')).nativeElement;
|
||||
expect(el.textContent).toContain(processes[index].processId);
|
||||
});
|
||||
});
|
||||
|
||||
it('should display the scripts name in the second column', () => {
|
||||
rowElements.forEach((rowElement, index) => {
|
||||
const el = rowElement.query(By.css('td:nth-child(2)')).nativeElement;
|
||||
expect(el.textContent).toContain(processes[index].scriptName);
|
||||
});
|
||||
});
|
||||
|
||||
it('should display the eperson\'s name in the third column', () => {
|
||||
rowElements.forEach((rowElement, _index) => {
|
||||
const el = rowElement.query(By.css('td:nth-child(3)')).nativeElement;
|
||||
expect(el.textContent).toContain(ePerson.name);
|
||||
});
|
||||
});
|
||||
|
||||
it('should display the requested info in the fourth column', () => {
|
||||
rowElements.forEach((rowElement, _index) => {
|
||||
const el = rowElement.query(By.css('td:nth-child(4)')).nativeElement;
|
||||
expect(el.textContent).toContain('process info');
|
||||
});
|
||||
});
|
||||
|
||||
it('should display a delete button in the fifth column', () => {
|
||||
rowElements.forEach((rowElement, index) => {
|
||||
const el = rowElement.query(By.css('td:nth-child(5)'));
|
||||
expect(el.nativeElement.innerHTML).toContain('fas fa-trash');
|
||||
|
||||
el.query(By.css('button')).triggerEventHandler('click', null);
|
||||
expect(processBulkDeleteService.toggleDelete).toHaveBeenCalledWith(processes[index].processId);
|
||||
});
|
||||
});
|
||||
|
||||
it('should indicate a row that has been selected for deletion', () => {
|
||||
const deleteRow = fixture.debugElement.query(By.css('.table-danger'));
|
||||
expect(deleteRow.nativeElement.innerHTML).toContain('/processes/' + processes[1].processId);
|
||||
});
|
||||
|
||||
});
|
||||
});
|
@@ -0,0 +1,249 @@
|
||||
import { Component, Input, OnInit, Inject, PLATFORM_ID, OnDestroy } from '@angular/core';
|
||||
import { ProcessStatus } from '../../processes/process-status.model';
|
||||
import { Observable, mergeMap, from as observableFrom, BehaviorSubject, Subscription } from 'rxjs';
|
||||
import { RemoteData } from '../../../core/data/remote-data';
|
||||
import { PaginatedList } from '../../../core/data/paginated-list.model';
|
||||
import { Process } from '../../processes/process.model';
|
||||
import {
|
||||
PaginationComponentOptions
|
||||
} from '../../../shared/pagination/pagination-component-options.model';
|
||||
import { ProcessOverviewService, ProcessSortField } from '../process-overview.service';
|
||||
import { ProcessBulkDeleteService } from '../process-bulk-delete.service';
|
||||
import { EPersonDataService } from '../../../core/eperson/eperson-data.service';
|
||||
import { DSONameService } from '../../../core/breadcrumbs/dso-name.service';
|
||||
import {
|
||||
getFirstSucceededRemoteDataPayload,
|
||||
getAllCompletedRemoteData
|
||||
} from '../../../core/shared/operators';
|
||||
import { map, switchMap, toArray, take, filter } from 'rxjs/operators';
|
||||
import { EPerson } from '../../../core/eperson/models/eperson.model';
|
||||
import { PaginationService } from 'src/app/core/pagination/pagination.service';
|
||||
import { FindListOptions } from '../../../core/data/find-list-options.model';
|
||||
import { redirectOn4xx } from '../../../core/shared/authorized.operators';
|
||||
import { Router } from '@angular/router';
|
||||
import { AuthService } from '../../../core/auth/auth.service';
|
||||
import { isPlatformBrowser } from '@angular/common';
|
||||
import { RouteService } from '../../../core/services/route.service';
|
||||
import { hasValue } from '../../../shared/empty.util';
|
||||
|
||||
const NEW_PROCESS_PARAM = 'new_process_id';
|
||||
|
||||
/**
|
||||
* An interface to store a process and extra information related to the process
|
||||
* that is displayed in the overview table.
|
||||
*/
|
||||
export interface ProcessOverviewTableEntry {
|
||||
process: Process,
|
||||
user: string,
|
||||
info: string,
|
||||
}
|
||||
|
||||
@Component({
|
||||
selector: 'ds-process-overview-table',
|
||||
styleUrls: ['./process-overview-table.component.scss'],
|
||||
templateUrl: './process-overview-table.component.html'
|
||||
})
|
||||
export class ProcessOverviewTableComponent implements OnInit, OnDestroy {
|
||||
|
||||
/**
|
||||
* The status of the processes this sections should show
|
||||
*/
|
||||
@Input() processStatus: ProcessStatus;
|
||||
|
||||
/**
|
||||
* The field on which the processes in this table are sorted
|
||||
* {@link ProcessSortField.creationTime} by default as every single process has a creation time,
|
||||
* but not every process has a start or end time
|
||||
*/
|
||||
@Input() sortField: ProcessSortField = ProcessSortField.creationTime;
|
||||
|
||||
/**
|
||||
* Whether to use auto refresh for the processes shown in this table.
|
||||
*/
|
||||
@Input() useAutoRefreshingSearchBy = false;
|
||||
|
||||
/**
|
||||
* The interval by which to refresh if autoRefreshing is enabled
|
||||
*/
|
||||
@Input() autoRefreshInterval = 5000;
|
||||
|
||||
/**
|
||||
* The function used to retrieve the value that will be shown in the 'info' column of the table.
|
||||
* {@Link ProcessOverviewService} contains some predefined functions.
|
||||
*/
|
||||
@Input() getInfoValueMethod: (process: Process) => string;
|
||||
|
||||
/**
|
||||
* List of processes and their info to be shown in this table
|
||||
*/
|
||||
processesRD$: BehaviorSubject<RemoteData<PaginatedList<ProcessOverviewTableEntry>>>;
|
||||
|
||||
/**
|
||||
* The pagination ID for this overview section
|
||||
*/
|
||||
paginationId: string;
|
||||
|
||||
/**
|
||||
* The current pagination options for the overview section
|
||||
*/
|
||||
paginationOptions$: Observable<PaginationComponentOptions>;
|
||||
|
||||
/**
|
||||
* Whether the table is collapsed
|
||||
*/
|
||||
isCollapsed = false;
|
||||
|
||||
/**
|
||||
* The id of the process to highlight
|
||||
*/
|
||||
newProcessId: string;
|
||||
|
||||
/**
|
||||
* List of subscriptions
|
||||
*/
|
||||
subs: Subscription[] = [];
|
||||
|
||||
constructor(protected processOverviewService: ProcessOverviewService,
|
||||
protected processBulkDeleteService: ProcessBulkDeleteService,
|
||||
protected ePersonDataService: EPersonDataService,
|
||||
protected dsoNameService: DSONameService,
|
||||
protected paginationService: PaginationService,
|
||||
protected routeService: RouteService,
|
||||
protected router: Router,
|
||||
protected auth: AuthService,
|
||||
@Inject(PLATFORM_ID) protected platformId: object,
|
||||
) {
|
||||
}
|
||||
|
||||
ngOnInit() {
|
||||
// Only auto refresh on browsers
|
||||
if (!isPlatformBrowser(this.platformId)) {
|
||||
this.useAutoRefreshingSearchBy = false;
|
||||
}
|
||||
|
||||
this.routeService.getQueryParameterValue(NEW_PROCESS_PARAM).pipe(take(1)).subscribe((id) => {
|
||||
this.newProcessId = id;
|
||||
});
|
||||
|
||||
// Creates an ID from the first 2 characters of the process status.
|
||||
// Should two process status values ever start with the same substring,
|
||||
// increase the number of characters until the ids are distinct.
|
||||
this.paginationId = this.processStatus.toLowerCase().substring(0,2);
|
||||
|
||||
let defaultPaginationOptions = Object.assign(new PaginationComponentOptions(), {
|
||||
id: this.paginationId,
|
||||
pageSize: 5,
|
||||
});
|
||||
|
||||
// Get the current pagination from the route
|
||||
this.paginationOptions$ = this.paginationService.getCurrentPagination(this.paginationId, defaultPaginationOptions);
|
||||
|
||||
this.processesRD$ = new BehaviorSubject(undefined);
|
||||
|
||||
// Once we have the pagination, retrieve the processes matching the process type and the pagination
|
||||
//
|
||||
// Reasoning why this monstrosity is the way it is:
|
||||
// To avoid having to recalculate the names of the submitters every time the page reloads, these have to be
|
||||
// retrieved beforehand and stored with the process. This is where the ProcessOverviewTableEntry interface comes in.
|
||||
// By storing the process together with the submitters name and the additional information to be shown in the table,
|
||||
// the template can be as dumb as possible. As the retrieval of the name also is done through an observable, this
|
||||
// complicates the construction of the data a bit though.
|
||||
// The reason why we store these as RemoteData<PaginatedList<ProcessOverviewTableEntry>> and not simply as
|
||||
// ProcessOverviewTableEntry[] is as follows:
|
||||
// When storing the PaginatedList<Process> and ProcessOverviewTableEntry[] separately, there is a small delay
|
||||
// between the update of the paginatedList and the entryArray. This results in the processOverviewPage showing
|
||||
// no processes for a split second every time the processes are updated which in turn causes the different
|
||||
// sections of the page to jump around. By combining these and causing the page to update only once this is avoided.
|
||||
this.subs.push(this.paginationOptions$
|
||||
.pipe(
|
||||
// Map the paginationOptions to findListOptions
|
||||
map((paginationOptions: PaginationComponentOptions) =>
|
||||
this.processOverviewService.getFindListOptions(paginationOptions, this.sortField)),
|
||||
// Use the findListOptions to retrieve the relevant processes every interval
|
||||
switchMap((findListOptions: FindListOptions) =>
|
||||
this.processOverviewService.getProcessesByProcessStatus(
|
||||
this.processStatus, findListOptions, this.useAutoRefreshingSearchBy ? this.autoRefreshInterval : null)
|
||||
),
|
||||
// Redirect the user when he is logged out
|
||||
redirectOn4xx(this.router, this.auth),
|
||||
getAllCompletedRemoteData(),
|
||||
// Map RemoteData<PaginatedList<Process>> to RemoteData<PaginatedList<ProcessOverviewTableEntry>>
|
||||
switchMap((processesRD: RemoteData<PaginatedList<Process>>) => {
|
||||
// Create observable emitting all processes one by one
|
||||
return observableFrom(processesRD.payload.page).pipe(
|
||||
// Map every Process to ProcessOverviewTableEntry
|
||||
mergeMap((process: Process) => {
|
||||
return this.getEPersonName(process.userId).pipe(
|
||||
map((name) => {
|
||||
return {
|
||||
process: process,
|
||||
user: name,
|
||||
info: this.getInfoValueMethod(process),
|
||||
};
|
||||
}),
|
||||
);
|
||||
}),
|
||||
// Collect processOverviewTableEntries into array
|
||||
toArray(),
|
||||
// Create RemoteData<PaginatedList<ProcessOverviewTableEntry>>
|
||||
map((entries: ProcessOverviewTableEntry[]) => {
|
||||
const entriesPL: PaginatedList<ProcessOverviewTableEntry> =
|
||||
Object.assign(new PaginatedList(), processesRD.payload, { page: entries });
|
||||
const entriesRD: RemoteData<PaginatedList<ProcessOverviewTableEntry>> =
|
||||
Object.assign({}, processesRD, { payload: entriesPL });
|
||||
return entriesRD;
|
||||
}),
|
||||
);
|
||||
}),
|
||||
|
||||
).subscribe((next: RemoteData<PaginatedList<ProcessOverviewTableEntry>>) => {
|
||||
this.processesRD$.next(next);
|
||||
}));
|
||||
|
||||
// Collapse this section when the number of processes is zero the first time processes are retrieved
|
||||
this.subs.push(this.processesRD$.pipe(
|
||||
filter((processListRd: RemoteData<PaginatedList<ProcessOverviewTableEntry>>) => hasValue(processListRd)),
|
||||
take(1),
|
||||
).subscribe(
|
||||
(processesRD: RemoteData<PaginatedList<ProcessOverviewTableEntry>>) => {
|
||||
if (!(processesRD.payload.totalElements > 0)) {
|
||||
this.isCollapsed = true;
|
||||
}
|
||||
}
|
||||
));
|
||||
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the name of an EPerson by ID
|
||||
* @param id ID of the EPerson
|
||||
*/
|
||||
getEPersonName(id: string): Observable<string> {
|
||||
return this.ePersonDataService.findById(id).pipe(
|
||||
getFirstSucceededRemoteDataPayload(),
|
||||
map((eperson: EPerson) => this.dsoNameService.getName(eperson)),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the css class for a row depending on the state of the process
|
||||
* @param process
|
||||
*/
|
||||
getRowClass(process: Process): string {
|
||||
if (this.processBulkDeleteService.isToBeDeleted(process.processId)) {
|
||||
return 'table-danger';
|
||||
} else if (this.newProcessId === process.processId) {
|
||||
return 'table-info';
|
||||
} else {
|
||||
return '';
|
||||
}
|
||||
}
|
||||
|
||||
ngOnDestroy(): void {
|
||||
this.subs
|
||||
.filter((sub) => hasValue(sub))
|
||||
.forEach((sub) => sub.unsubscribe());
|
||||
this.processOverviewService.stopAutoRefreshing(this.processStatus);
|
||||
}
|
||||
|
||||
}
|
@@ -6,8 +6,9 @@ import { Process } from './processes/process.model';
|
||||
import { followLink } from '../shared/utils/follow-link-config.model';
|
||||
import { ProcessDataService } from '../core/data/processes/process-data.service';
|
||||
import { BreadcrumbConfig } from '../breadcrumbs/breadcrumb/breadcrumb-config.model';
|
||||
import { getRemoteDataPayload, getFirstSucceededRemoteData } from '../core/shared/operators';
|
||||
import { getFirstCompletedRemoteData } from '../core/shared/operators';
|
||||
import { ProcessBreadcrumbsService } from './process-breadcrumbs.service';
|
||||
import { RemoteData } from '../core/data/remote-data';
|
||||
|
||||
/**
|
||||
* This class represents a resolver that requests a specific process before the route is activated
|
||||
@@ -28,12 +29,11 @@ export class ProcessBreadcrumbResolver implements Resolve<BreadcrumbConfig<Proce
|
||||
const id = route.params.id;
|
||||
|
||||
return this.processService.findById(route.params.id, true, false, followLink('script')).pipe(
|
||||
getFirstSucceededRemoteData(),
|
||||
getRemoteDataPayload(),
|
||||
map((object: Process) => {
|
||||
getFirstCompletedRemoteData(),
|
||||
map((object: RemoteData<Process>) => {
|
||||
const fullPath = state.url;
|
||||
const url = fullPath.substr(0, fullPath.indexOf(id)) + id;
|
||||
return { provider: this.breadcrumbService, key: object, url: url };
|
||||
return { provider: this.breadcrumbService, key: object.payload, url: url };
|
||||
})
|
||||
);
|
||||
}
|
||||
|
@@ -3,6 +3,7 @@ import { Injectable } from '@angular/core';
|
||||
import { BreadcrumbsProviderService } from '../core/breadcrumbs/breadcrumbsProviderService';
|
||||
import { Breadcrumb } from '../breadcrumbs/breadcrumb/breadcrumb.model';
|
||||
import { Process } from './processes/process.model';
|
||||
import { hasValue } from '../shared/empty.util';
|
||||
|
||||
/**
|
||||
* Service to calculate process breadcrumbs for a single part of the route
|
||||
@@ -16,6 +17,10 @@ export class ProcessBreadcrumbsService implements BreadcrumbsProviderService<Pro
|
||||
* @param url The url to use as a link for this breadcrumb
|
||||
*/
|
||||
getBreadcrumbs(key: Process, url: string): Observable<Breadcrumb[]> {
|
||||
return observableOf([new Breadcrumb(key.processId + ' - ' + key.scriptName, url)]);
|
||||
if (hasValue(key)) {
|
||||
return observableOf([new Breadcrumb(key.processId + ' - ' + key.scriptName, url)]);
|
||||
} else {
|
||||
return observableOf([]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@@ -16,10 +16,14 @@ import { ProcessDetailFieldComponent } from './detail/process-detail-field/proce
|
||||
import { ProcessBreadcrumbsService } from './process-breadcrumbs.service';
|
||||
import { ProcessBreadcrumbResolver } from './process-breadcrumb.resolver';
|
||||
import { ProcessFormComponent } from './form/process-form.component';
|
||||
import { NgbCollapseModule } from '@ng-bootstrap/ng-bootstrap';
|
||||
import { ProcessOverviewTableComponent } from './overview/table/process-overview-table.component';
|
||||
import { DatePipe } from '@angular/common';
|
||||
|
||||
@NgModule({
|
||||
imports: [
|
||||
SharedModule,
|
||||
NgbCollapseModule,
|
||||
],
|
||||
declarations: [
|
||||
NewProcessComponent,
|
||||
@@ -33,13 +37,15 @@ import { ProcessFormComponent } from './form/process-form.component';
|
||||
BooleanValueInputComponent,
|
||||
DateValueInputComponent,
|
||||
ProcessOverviewComponent,
|
||||
ProcessOverviewTableComponent,
|
||||
ProcessDetailComponent,
|
||||
ProcessDetailFieldComponent,
|
||||
ProcessFormComponent
|
||||
],
|
||||
providers: [
|
||||
ProcessBreadcrumbResolver,
|
||||
ProcessBreadcrumbsService
|
||||
ProcessBreadcrumbsService,
|
||||
DatePipe,
|
||||
]
|
||||
})
|
||||
|
||||
|
@@ -7,6 +7,10 @@ import { followLink } from '../shared/utils/follow-link-config.model';
|
||||
import { ProcessDataService } from '../core/data/processes/process-data.service';
|
||||
import { getFirstCompletedRemoteData } from '../core/shared/operators';
|
||||
|
||||
export const PROCESS_PAGE_FOLLOW_LINKS = [
|
||||
followLink('files'),
|
||||
];
|
||||
|
||||
/**
|
||||
* This class represents a resolver that requests a specific process before the route is activated
|
||||
*/
|
||||
@@ -23,7 +27,7 @@ export class ProcessPageResolver implements Resolve<RemoteData<Process>> {
|
||||
* or an error if something went wrong
|
||||
*/
|
||||
resolve(route: ActivatedRouteSnapshot, state: RouterStateSnapshot): Observable<RemoteData<Process>> {
|
||||
return this.processService.findById(route.params.id, false, true, followLink('script')).pipe(
|
||||
return this.processService.findById(route.params.id, false, true, ...PROCESS_PAGE_FOLLOW_LINKS).pipe(
|
||||
getFirstCompletedRemoteData(),
|
||||
);
|
||||
}
|
||||
|
34
src/app/process-page/processes/filetypes.model.ts
Normal file
34
src/app/process-page/processes/filetypes.model.ts
Normal file
@@ -0,0 +1,34 @@
|
||||
import { typedObject } from '../../core/cache/builders/build-decorators';
|
||||
import { excludeFromEquals } from '../../core/utilities/equals.decorators';
|
||||
import { autoserialize } from 'cerialize';
|
||||
import { ResourceType } from '../../core/shared/resource-type';
|
||||
import { FILETYPES } from './filetypes.resource-type';
|
||||
|
||||
/**
|
||||
* Object representing the file types of the {@link Bitstream}s of a {@link Process}
|
||||
*/
|
||||
@typedObject
|
||||
export class Filetypes {
|
||||
|
||||
static type = FILETYPES;
|
||||
|
||||
/**
|
||||
* The id of this {@link Filetypes}
|
||||
*/
|
||||
@autoserialize
|
||||
id: string;
|
||||
|
||||
/**
|
||||
* The values of this {@link Filetypes}
|
||||
*/
|
||||
@autoserialize
|
||||
values: string[];
|
||||
|
||||
/**
|
||||
* The object type
|
||||
*/
|
||||
@excludeFromEquals
|
||||
@autoserialize
|
||||
type: ResourceType;
|
||||
|
||||
}
|
@@ -0,0 +1,8 @@
|
||||
/**
|
||||
* The resource type for {@link Filetypes}
|
||||
*
|
||||
* Needs to be in a separate file to prevent circular dependencies in webpack.
|
||||
*/
|
||||
import { ResourceType } from '../../core/shared/resource-type';
|
||||
|
||||
export const FILETYPES = new ResourceType('filetypes');
|
@@ -2,8 +2,8 @@
|
||||
* List of process statuses
|
||||
*/
|
||||
export enum ProcessStatus {
|
||||
SCHEDULED,
|
||||
RUNNING,
|
||||
COMPLETED,
|
||||
FAILED
|
||||
SCHEDULED = 'SCHEDULED',
|
||||
RUNNING = 'RUNNING',
|
||||
COMPLETED = 'COMPLETED',
|
||||
FAILED = 'FAILED'
|
||||
}
|
||||
|
@@ -3,7 +3,7 @@ import { PROCESS_OUTPUT_TYPE } from '../../core/shared/process-output.resource-t
|
||||
import { ProcessStatus } from './process-status.model';
|
||||
import { ProcessParameter } from './process-parameter.model';
|
||||
import { HALLink } from '../../core/shared/hal-link.model';
|
||||
import { autoserialize, deserialize } from 'cerialize';
|
||||
import { autoserialize, deserialize, autoserializeAs } from 'cerialize';
|
||||
import { PROCESS } from './process.resource-type';
|
||||
import { excludeFromEquals } from '../../core/utilities/equals.decorators';
|
||||
import { ResourceType } from '../../core/shared/resource-type';
|
||||
@@ -13,6 +13,10 @@ import { RemoteData } from '../../core/data/remote-data';
|
||||
import { SCRIPT } from '../scripts/script.resource-type';
|
||||
import { Script } from '../scripts/script.model';
|
||||
import { CacheableObject } from '../../core/cache/cacheable-object.model';
|
||||
import { BITSTREAM } from '../../core/shared/bitstream.resource-type';
|
||||
import { PaginatedList } from '../../core/data/paginated-list.model';
|
||||
import { Filetypes } from './filetypes.model';
|
||||
import { FILETYPES } from './filetypes.resource-type';
|
||||
|
||||
/**
|
||||
* Object representing a process
|
||||
@@ -31,7 +35,7 @@ export class Process implements CacheableObject {
|
||||
/**
|
||||
* The identifier for this process
|
||||
*/
|
||||
@autoserialize
|
||||
@autoserializeAs(String)
|
||||
processId: string;
|
||||
|
||||
/**
|
||||
@@ -40,6 +44,12 @@ export class Process implements CacheableObject {
|
||||
@autoserialize
|
||||
userId: string;
|
||||
|
||||
/**
|
||||
* The creation time for this process
|
||||
*/
|
||||
@autoserialize
|
||||
creationTime: string;
|
||||
|
||||
/**
|
||||
* The start time for this process
|
||||
*/
|
||||
@@ -78,7 +88,8 @@ export class Process implements CacheableObject {
|
||||
self: HALLink,
|
||||
script: HALLink,
|
||||
output: HALLink,
|
||||
files: HALLink
|
||||
files: HALLink,
|
||||
filetypes: HALLink,
|
||||
};
|
||||
|
||||
/**
|
||||
@@ -94,4 +105,19 @@ export class Process implements CacheableObject {
|
||||
*/
|
||||
@link(PROCESS_OUTPUT_TYPE)
|
||||
output?: Observable<RemoteData<Bitstream>>;
|
||||
|
||||
/**
|
||||
* The files created by this Process
|
||||
* Will be undefined unless the output {@link HALLink} has been resolved.
|
||||
*/
|
||||
@link(BITSTREAM, true)
|
||||
files?: Observable<RemoteData<PaginatedList<Bitstream>>>;
|
||||
|
||||
/**
|
||||
* The filetypes present in this Process
|
||||
* Will be undefined unless the output {@link HALLink} has been resolved.
|
||||
*/
|
||||
@link(FILETYPES)
|
||||
filetypes?: Observable<RemoteData<Filetypes>>;
|
||||
|
||||
}
|
||||
|
@@ -88,7 +88,7 @@ describe('SearchNavbarComponent', () => {
|
||||
fixture.detectChanges();
|
||||
}));
|
||||
it('to search page with empty query', () => {
|
||||
const extras: NavigationExtras = { queryParams: { query: '' }, queryParamsHandling: 'merge' };
|
||||
const extras: NavigationExtras = { queryParams: { query: '' } };
|
||||
expect(component.onSubmit).toHaveBeenCalledWith({ query: '' });
|
||||
expect(router.navigate).toHaveBeenCalledWith(['search'], extras);
|
||||
});
|
||||
@@ -113,7 +113,7 @@ describe('SearchNavbarComponent', () => {
|
||||
fixture.detectChanges();
|
||||
}));
|
||||
it('to search page with query', async () => {
|
||||
const extras: NavigationExtras = { queryParams: { query: 'test' }, queryParamsHandling: 'merge' };
|
||||
const extras: NavigationExtras = { queryParams: { query: 'test' } };
|
||||
expect(component.onSubmit).toHaveBeenCalledWith({ query: 'test' });
|
||||
|
||||
expect(router.navigate).toHaveBeenCalledWith(['search'], extras);
|
||||
|
@@ -66,8 +66,7 @@ export class SearchNavbarComponent {
|
||||
this.searchForm.reset();
|
||||
|
||||
this.router.navigate(linkToNavigateTo, {
|
||||
queryParams: queryParams,
|
||||
queryParamsHandling: 'merge'
|
||||
queryParams: queryParams
|
||||
});
|
||||
}
|
||||
}
|
||||
|
@@ -8,6 +8,7 @@ import { SearchConfigurationService } from '../core/shared/search/search-configu
|
||||
import { RouteService } from '../core/services/route.service';
|
||||
import { SearchService } from '../core/shared/search/search.service';
|
||||
import { Router } from '@angular/router';
|
||||
import { APP_CONFIG, AppConfig } from '../../config/app-config.interface';
|
||||
|
||||
/**
|
||||
* This component renders a search page using a configuration as input.
|
||||
@@ -32,7 +33,9 @@ export class ConfigurationSearchPageComponent extends SearchComponent {
|
||||
protected windowService: HostWindowService,
|
||||
@Inject(SEARCH_CONFIG_SERVICE) public searchConfigService: SearchConfigurationService,
|
||||
protected routeService: RouteService,
|
||||
protected router: Router) {
|
||||
super(service, sidebarService, windowService, searchConfigService, routeService, router);
|
||||
protected router: Router,
|
||||
@Inject(APP_CONFIG) protected appConfig: AppConfig,
|
||||
) {
|
||||
super(service, sidebarService, windowService, searchConfigService, routeService, router, appConfig);
|
||||
}
|
||||
}
|
||||
|
@@ -55,16 +55,21 @@ export class ComcolPageBrowseByComponent implements OnDestroy, OnInit {
|
||||
if (this.contentType === 'collection') {
|
||||
comColRoute = getCollectionPageRoute(this.id);
|
||||
allOptions.push({
|
||||
id: 'recent_submissions',
|
||||
label: 'collection.page.browse.recent.head',
|
||||
id: 'search',
|
||||
label: 'collection.page.browse.search.head',
|
||||
routerLink: comColRoute,
|
||||
});
|
||||
} else if (this.contentType === 'community') {
|
||||
comColRoute = getCommunityPageRoute(this.id);
|
||||
allOptions.push({
|
||||
id: 'search',
|
||||
label: 'collection.page.browse.search.head',
|
||||
routerLink: comColRoute,
|
||||
});
|
||||
allOptions.push({
|
||||
id: 'comcols',
|
||||
label: 'community.all-lists.head',
|
||||
routerLink: comColRoute,
|
||||
routerLink: `${comColRoute}/subcoms-cols`,
|
||||
});
|
||||
}
|
||||
|
||||
|
@@ -18,6 +18,8 @@ import { FormModule } from '../form/form.module';
|
||||
import { UploadModule } from '../upload/upload.module';
|
||||
import { ComcolBrowseByComponent } from './sections/comcol-browse-by/comcol-browse-by.component';
|
||||
import { BrowseByModule } from '../../browse-by/browse-by.module';
|
||||
import { SearchModule } from '../search/search.module';
|
||||
import { ComcolSearchSectionComponent } from './sections/comcol-search-section/comcol-search-section.component';
|
||||
|
||||
const COMPONENTS = [
|
||||
ComcolPageContentComponent,
|
||||
@@ -33,6 +35,7 @@ const COMPONENTS = [
|
||||
ComcolRoleComponent,
|
||||
ThemedComcolPageHandleComponent,
|
||||
ComcolBrowseByComponent,
|
||||
ComcolSearchSectionComponent,
|
||||
];
|
||||
|
||||
@NgModule({
|
||||
@@ -45,6 +48,7 @@ const COMPONENTS = [
|
||||
SharedModule,
|
||||
UploadModule,
|
||||
BrowseByModule,
|
||||
SearchModule,
|
||||
],
|
||||
exports: [
|
||||
...COMPONENTS,
|
||||
|
@@ -0,0 +1,7 @@
|
||||
<ds-themed-search
|
||||
[configuration]="(comcol$ | async)?.type"
|
||||
[showSidebar]="showSidebar$ | async"
|
||||
[showScopeSelector]="false"
|
||||
[hideScopeInUrl]="true"
|
||||
[scope]="(comcol$ | async)?.id">
|
||||
</ds-themed-search>
|
@@ -0,0 +1,35 @@
|
||||
import { ComponentFixture, TestBed } from '@angular/core/testing';
|
||||
import { ComcolSearchSectionComponent } from './comcol-search-section.component';
|
||||
import { ActivatedRoute } from '@angular/router';
|
||||
import { ActivatedRouteStub } from '../../../testing/active-router.stub';
|
||||
import { APP_CONFIG } from '../../../../../config/app-config.interface';
|
||||
import { environment } from '../../../../../environments/environment.test';
|
||||
|
||||
describe('ComcolSearchSectionComponent', () => {
|
||||
let component: ComcolSearchSectionComponent;
|
||||
let fixture: ComponentFixture<ComcolSearchSectionComponent>;
|
||||
|
||||
let route: ActivatedRouteStub;
|
||||
|
||||
beforeEach(async () => {
|
||||
route = new ActivatedRouteStub();
|
||||
|
||||
await TestBed.configureTestingModule({
|
||||
declarations: [
|
||||
ComcolSearchSectionComponent,
|
||||
],
|
||||
providers: [
|
||||
{ provide: APP_CONFIG, useValue: environment },
|
||||
{ provide: ActivatedRoute, useValue: route },
|
||||
],
|
||||
}).compileComponents();
|
||||
|
||||
fixture = TestBed.createComponent(ComcolSearchSectionComponent);
|
||||
component = fixture.componentInstance;
|
||||
fixture.detectChanges();
|
||||
});
|
||||
|
||||
it('should create', () => {
|
||||
expect(component).toBeTruthy();
|
||||
});
|
||||
});
|
@@ -0,0 +1,48 @@
|
||||
import { Component, OnInit, Inject } from '@angular/core';
|
||||
import { Observable } from 'rxjs';
|
||||
import { ActivatedRoute, Data } from '@angular/router';
|
||||
import { map } from 'rxjs/operators';
|
||||
import { SEARCH_CONFIG_SERVICE } from '../../../../my-dspace-page/my-dspace-page.component';
|
||||
import { SearchConfigurationService } from '../../../../core/shared/search/search-configuration.service';
|
||||
import { RemoteData } from '../../../../core/data/remote-data';
|
||||
import { Community } from '../../../../core/shared/community.model';
|
||||
import { Collection } from '../../../../core/shared/collection.model';
|
||||
import { APP_CONFIG, AppConfig } from '../../../../../config/app-config.interface';
|
||||
import { hasValue } from '../../../empty.util';
|
||||
|
||||
/**
|
||||
* The search tab on community & collection pages
|
||||
*/
|
||||
@Component({
|
||||
selector: 'ds-comcol-search-section',
|
||||
templateUrl: './comcol-search-section.component.html',
|
||||
styleUrls: ['./comcol-search-section.component.scss'],
|
||||
providers: [
|
||||
{
|
||||
provide: SEARCH_CONFIG_SERVICE,
|
||||
useClass: SearchConfigurationService,
|
||||
},
|
||||
],
|
||||
})
|
||||
export class ComcolSearchSectionComponent implements OnInit {
|
||||
|
||||
comcol$: Observable<Community | Collection>;
|
||||
|
||||
showSidebar$: Observable<boolean>;
|
||||
|
||||
constructor(
|
||||
@Inject(APP_CONFIG) public appConfig: AppConfig,
|
||||
protected route: ActivatedRoute,
|
||||
) {
|
||||
}
|
||||
|
||||
ngOnInit(): void {
|
||||
this.comcol$ = this.route.data.pipe(
|
||||
map((data: Data) => (data.dso as RemoteData<Community | Collection>).payload),
|
||||
);
|
||||
this.showSidebar$ = this.comcol$.pipe(
|
||||
map((comcol: Community | Collection) => hasValue(comcol) && this.appConfig[comcol.type as any].searchSection.showSidebar),
|
||||
);
|
||||
}
|
||||
|
||||
}
|
@@ -1,4 +1,4 @@
|
||||
<div [formGroup]="group" class="form-check custom-control custom-switch" [class.disabled]="model.disabled">
|
||||
<div [formGroup]="group" [ngClass]="getClass('element', 'container')" class="form-check custom-control custom-switch" [class.disabled]="model.disabled">
|
||||
<input type="checkbox" class="form-check-input custom-control-input"
|
||||
[checked]="model.checked"
|
||||
[class.is-invalid]="showErrorMessages"
|
||||
@@ -14,7 +14,7 @@
|
||||
(change)="onChange($event)"
|
||||
(focus)="onFocus($event)"/>
|
||||
<label class="form-check-label custom-control-label" [for]="bindId && model.id">
|
||||
<span [innerHTML]="model.label"
|
||||
<span [innerHTML]="model.label | translate"
|
||||
[ngClass]="[getClass('element', 'label'), getClass('grid', 'label')]"></span>
|
||||
</label>
|
||||
</div>
|
||||
|
@@ -0,0 +1,16 @@
|
||||
div.custom-switch {
|
||||
&.custom-control-right {
|
||||
margin-left: 0;
|
||||
margin-right: 0;
|
||||
|
||||
&::after {
|
||||
right: -1.5rem;
|
||||
left: auto;
|
||||
}
|
||||
|
||||
&::before {
|
||||
right: -2.35rem;
|
||||
left: auto;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@@ -1,11 +1,12 @@
|
||||
import { DynamicFormsCoreModule, DynamicFormService } from '@ng-dynamic-forms/core';
|
||||
import { UntypedFormGroup, ReactiveFormsModule } from '@angular/forms';
|
||||
import { ComponentFixture, inject, TestBed, waitForAsync } from '@angular/core/testing';
|
||||
import { DebugElement } from '@angular/core';
|
||||
import { DebugElement} from '@angular/core';
|
||||
import { NoopAnimationsModule } from '@angular/platform-browser/animations';
|
||||
import { By } from '@angular/platform-browser';
|
||||
import { DynamicCustomSwitchModel } from './custom-switch.model';
|
||||
import { CustomSwitchComponent } from './custom-switch.component';
|
||||
import { TranslateModule } from '@ngx-translate/core';
|
||||
|
||||
describe('CustomSwitchComponent', () => {
|
||||
|
||||
@@ -20,9 +21,10 @@ describe('CustomSwitchComponent', () => {
|
||||
beforeEach(waitForAsync(() => {
|
||||
TestBed.configureTestingModule({
|
||||
imports: [
|
||||
TranslateModule.forRoot(),
|
||||
ReactiveFormsModule,
|
||||
NoopAnimationsModule,
|
||||
DynamicFormsCoreModule.forRoot()
|
||||
DynamicFormsCoreModule.forRoot(),
|
||||
],
|
||||
declarations: [CustomSwitchComponent]
|
||||
|
||||
|
@@ -50,8 +50,9 @@ export abstract class DsDynamicVocabularyComponent extends DynamicFormControlCom
|
||||
|
||||
/**
|
||||
* Retrieves the init form value from model
|
||||
* @param preserveConfidence if the original model confidence value should be used after retrieving the vocabulary's entry
|
||||
*/
|
||||
getInitValueFromModel(): Observable<FormFieldMetadataValueObject> {
|
||||
getInitValueFromModel(preserveConfidence = false): Observable<FormFieldMetadataValueObject> {
|
||||
let initValue$: Observable<FormFieldMetadataValueObject>;
|
||||
if (isNotEmpty(this.model.value) && (this.model.value instanceof FormFieldMetadataValueObject) && !this.model.value.hasAuthorityToGenerate()) {
|
||||
let initEntry$: Observable<VocabularyEntry>;
|
||||
@@ -63,7 +64,7 @@ export abstract class DsDynamicVocabularyComponent extends DynamicFormControlCom
|
||||
initValue$ = initEntry$.pipe(map((initEntry: VocabularyEntry) => {
|
||||
if (isNotEmpty(initEntry)) {
|
||||
// Integrate FormFieldMetadataValueObject with retrieved information
|
||||
return new FormFieldMetadataValueObject(
|
||||
let formField = new FormFieldMetadataValueObject(
|
||||
initEntry.value,
|
||||
null,
|
||||
initEntry.authority,
|
||||
@@ -72,6 +73,11 @@ export abstract class DsDynamicVocabularyComponent extends DynamicFormControlCom
|
||||
null,
|
||||
initEntry.otherInformation || null
|
||||
);
|
||||
// Preserve the original confidence
|
||||
if (preserveConfidence) {
|
||||
formField.confidence = (this.model.value as any).confidence;
|
||||
}
|
||||
return formField;
|
||||
} else {
|
||||
return this.model.value as any;
|
||||
}
|
||||
|
@@ -21,8 +21,8 @@
|
||||
</ng-template>
|
||||
|
||||
<div *ngIf="!(isHierarchicalVocabulary() | async)" class="position-relative right-addon">
|
||||
<i *ngIf="searching" class="fas fa-circle-notch fa-spin fa-2x fa-fw text-primary position-absolute mt-1 p-0" aria-hidden="true"></i>
|
||||
<i *ngIf="!searching"
|
||||
<i *ngIf="searching || loadingInitialValue" class="fas fa-circle-notch fa-spin fa-2x fa-fw text-primary position-absolute mt-1 p-0" aria-hidden="true"></i>
|
||||
<i *ngIf="!searching && !loadingInitialValue"
|
||||
dsAuthorityConfidenceState
|
||||
class="far fa-circle fa-2x fa-fw position-absolute mt-1 p-0"
|
||||
aria-hidden="true"
|
||||
@@ -32,6 +32,7 @@
|
||||
class="form-control"
|
||||
[attr.aria-labelledby]="'label_' + model.id"
|
||||
[attr.autoComplete]="model.autoComplete"
|
||||
[attr.aria-label]="model.label | translate"
|
||||
[class.is-invalid]="showErrorMessages"
|
||||
[id]="model.id"
|
||||
[inputFormatter]="formatter"
|
||||
@@ -58,6 +59,7 @@
|
||||
<input class="form-control"
|
||||
[attr.aria-labelledby]="'label_' + model.id"
|
||||
[attr.autoComplete]="model.autoComplete"
|
||||
[attr.aria-label]="model.label | translate"
|
||||
[class.is-invalid]="showErrorMessages"
|
||||
[class.tree-input]="!model.readOnly"
|
||||
[id]="id"
|
||||
|
@@ -55,6 +55,7 @@ export class DsDynamicOneboxComponent extends DsDynamicVocabularyComponent imple
|
||||
|
||||
pageInfo: PageInfo = new PageInfo();
|
||||
searching = false;
|
||||
loadingInitialValue = false;
|
||||
searchFailed = false;
|
||||
hideSearchingWhenUnsubscribed$ = new Observable(() => () => this.changeSearchingStatus(false));
|
||||
click$ = new Subject<string>();
|
||||
@@ -151,6 +152,15 @@ export class DsDynamicOneboxComponent extends DsDynamicVocabularyComponent imple
|
||||
this.cdr.detectChanges();
|
||||
}
|
||||
|
||||
/**
|
||||
* Changes the loadingInitialValue status
|
||||
* @param status
|
||||
*/
|
||||
changeLoadingInitialValueStatus(status: boolean) {
|
||||
this.loadingInitialValue = status;
|
||||
this.cdr.detectChanges();
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if configured vocabulary is Hierarchical or not
|
||||
*/
|
||||
@@ -185,8 +195,13 @@ export class DsDynamicOneboxComponent extends DsDynamicVocabularyComponent imple
|
||||
// prevent on blur propagation if typeahed suggestions are showed
|
||||
event.preventDefault();
|
||||
event.stopImmediatePropagation();
|
||||
// set focus on input again, this is to avoid to lose changes when no suggestion is selected
|
||||
(event.target as HTMLInputElement).focus();
|
||||
// update the value with the searched text if the user hasn't selected any suggestion
|
||||
if (!this.model.vocabularyOptions.closed && isNotEmpty(this.inputValue)) {
|
||||
if (isNotNull(this.inputValue) && this.model.value !== this.inputValue) {
|
||||
this.dispatchUpdate(this.inputValue);
|
||||
}
|
||||
this.inputValue = null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -257,8 +272,10 @@ export class DsDynamicOneboxComponent extends DsDynamicVocabularyComponent imple
|
||||
setCurrentValue(value: any, init = false): void {
|
||||
let result: string;
|
||||
if (init) {
|
||||
this.getInitValueFromModel()
|
||||
this.changeLoadingInitialValueStatus(true);
|
||||
this.getInitValueFromModel(true)
|
||||
.subscribe((formValue: FormFieldMetadataValueObject) => {
|
||||
this.changeLoadingInitialValueStatus(false);
|
||||
this.currentValue = formValue;
|
||||
this.cdr.detectChanges();
|
||||
});
|
||||
|
@@ -11,7 +11,7 @@ import {
|
||||
import { UntypedFormGroup } from '@angular/forms';
|
||||
|
||||
import { Observable, of as observableOf } from 'rxjs';
|
||||
import { catchError, map, tap } from 'rxjs/operators';
|
||||
import { catchError, distinctUntilChanged, map, tap } from 'rxjs/operators';
|
||||
import { NgbDropdown } from '@ng-bootstrap/ng-bootstrap';
|
||||
import { DynamicFormLayoutService, DynamicFormValidationService } from '@ng-dynamic-forms/core';
|
||||
|
||||
@@ -68,10 +68,14 @@ export class DsDynamicScrollableDropdownComponent extends DsDynamicVocabularyCom
|
||||
*/
|
||||
ngOnInit() {
|
||||
this.updatePageInfo(this.model.maxOptions, 1);
|
||||
this.loadOptions();
|
||||
this.loadOptions(true);
|
||||
this.group.get(this.model.id).valueChanges.pipe(distinctUntilChanged())
|
||||
.subscribe((value) => {
|
||||
this.setCurrentValue(value);
|
||||
});
|
||||
}
|
||||
|
||||
loadOptions() {
|
||||
loadOptions(fromInit: boolean) {
|
||||
this.loading = true;
|
||||
this.vocabularyService.getVocabularyEntriesByValue(this.inputText, false, this.model.vocabularyOptions, this.pageInfo).pipe(
|
||||
getFirstSucceededRemoteDataPayload(),
|
||||
@@ -79,6 +83,10 @@ export class DsDynamicScrollableDropdownComponent extends DsDynamicVocabularyCom
|
||||
tap(() => this.loading = false)
|
||||
).subscribe((list: PaginatedList<VocabularyEntry>) => {
|
||||
this.optionsList = list.page;
|
||||
if (fromInit && this.model.value) {
|
||||
this.setCurrentValue(this.model.value, true);
|
||||
}
|
||||
|
||||
this.updatePageInfo(
|
||||
list.pageInfo.elementsPerPage,
|
||||
list.pageInfo.currentPage,
|
||||
@@ -104,7 +112,7 @@ export class DsDynamicScrollableDropdownComponent extends DsDynamicVocabularyCom
|
||||
this.group.markAsUntouched();
|
||||
this.inputText = null;
|
||||
this.updatePageInfo(this.model.maxOptions, 1);
|
||||
this.loadOptions();
|
||||
this.loadOptions(false);
|
||||
sdRef.open();
|
||||
}
|
||||
}
|
||||
@@ -161,7 +169,7 @@ export class DsDynamicScrollableDropdownComponent extends DsDynamicVocabularyCom
|
||||
this.inputText += keyName;
|
||||
// When a new key is added, we need to reset the page info
|
||||
this.updatePageInfo(this.model.maxOptions, 1);
|
||||
this.loadOptions();
|
||||
this.loadOptions(false);
|
||||
}
|
||||
|
||||
removeKeyFromInput() {
|
||||
@@ -170,7 +178,7 @@ export class DsDynamicScrollableDropdownComponent extends DsDynamicVocabularyCom
|
||||
if (this.inputText === '') {
|
||||
this.inputText = null;
|
||||
}
|
||||
this.loadOptions();
|
||||
this.loadOptions(false);
|
||||
}
|
||||
}
|
||||
|
||||
|
@@ -196,6 +196,7 @@ export class FormBuilderService extends DynamicFormService {
|
||||
return new FormFieldMetadataValueObject((controlValue as any).value, controlLanguage, authority, (controlValue as any).display, place, (controlValue as any).confidence);
|
||||
}
|
||||
}
|
||||
return controlValue;
|
||||
};
|
||||
|
||||
const iterateControlModels = (findGroupModel: DynamicFormControlModel[], controlModelIndex: number = 0): void => {
|
||||
|
@@ -28,6 +28,8 @@ import { isNotEmpty, isNull } from '../../empty.util';
|
||||
import { ConfidenceIconConfig } from '../../../../config/submission-config.interface';
|
||||
import { environment } from '../../../../environments/environment';
|
||||
import { VocabularyEntryDetail } from '../../../core/submission/vocabularies/models/vocabulary-entry-detail.model';
|
||||
import { MetadataValue } from '../../../core/shared/metadata.models';
|
||||
import { TranslateService } from '@ngx-translate/core';
|
||||
|
||||
/**
|
||||
* Directive to add to the element a bootstrap utility class based on metadata confidence value
|
||||
@@ -40,13 +42,19 @@ export class AuthorityConfidenceStateDirective implements OnChanges, AfterViewIn
|
||||
/**
|
||||
* The metadata value
|
||||
*/
|
||||
@Input() authorityValue: VocabularyEntry | FormFieldMetadataValueObject | string;
|
||||
@Input() authorityValue: VocabularyEntry | FormFieldMetadataValueObject | MetadataValue | string;
|
||||
|
||||
/**
|
||||
* A boolean representing if to show html icon if authority value is empty
|
||||
*/
|
||||
@Input() visibleWhenAuthorityEmpty = true;
|
||||
|
||||
/**
|
||||
* A boolean to configure the display of icons instead of default style configuration
|
||||
* When true, the class configured in {@link ConfidenceIconConfig.icon} will be used, by default {@link ConfidenceIconConfig.style} is used
|
||||
*/
|
||||
@Input() iconMode = false;
|
||||
|
||||
/**
|
||||
* The css class applied before directive changes
|
||||
*/
|
||||
@@ -79,7 +87,8 @@ export class AuthorityConfidenceStateDirective implements OnChanges, AfterViewIn
|
||||
*/
|
||||
constructor(
|
||||
private elem: ElementRef,
|
||||
private renderer: Renderer2
|
||||
private renderer: Renderer2,
|
||||
private translate: TranslateService
|
||||
) {
|
||||
}
|
||||
|
||||
@@ -93,12 +102,19 @@ export class AuthorityConfidenceStateDirective implements OnChanges, AfterViewIn
|
||||
this.previousClass = this.getClassByConfidence(this.getConfidenceByValue(changes.authorityValue.previousValue));
|
||||
}
|
||||
this.newClass = this.getClassByConfidence(this.getConfidenceByValue(changes.authorityValue.currentValue));
|
||||
let confidenceName = this.getNameByConfidence(this.getConfidenceByValue(changes.authorityValue.currentValue));
|
||||
|
||||
if (isNull(this.previousClass)) {
|
||||
this.renderer.addClass(this.elem.nativeElement, this.newClass);
|
||||
if (this.iconMode) {
|
||||
this.renderer.setAttribute(this.elem.nativeElement, 'title', this.translate.instant(`confidence.indicator.help-text.${confidenceName}`));
|
||||
}
|
||||
} else if (this.previousClass !== this.newClass) {
|
||||
this.renderer.removeClass(this.elem.nativeElement, this.previousClass);
|
||||
this.renderer.addClass(this.elem.nativeElement, this.newClass);
|
||||
if (this.iconMode) {
|
||||
this.renderer.setAttribute(this.elem.nativeElement, 'title', this.translate.instant(`confidence.indicator.help-text.${confidenceName}`));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -131,6 +147,14 @@ export class AuthorityConfidenceStateDirective implements OnChanges, AfterViewIn
|
||||
confidence = value.confidence;
|
||||
}
|
||||
|
||||
if (isNotEmpty(value) && value instanceof MetadataValue) {
|
||||
confidence = value.confidence;
|
||||
}
|
||||
|
||||
if (isNotEmpty(value) && Object.values(ConfidenceType).includes(value)) {
|
||||
confidence = value;
|
||||
}
|
||||
|
||||
return confidence;
|
||||
}
|
||||
|
||||
@@ -149,9 +173,29 @@ export class AuthorityConfidenceStateDirective implements OnChanges, AfterViewIn
|
||||
const confidenceIndex: number = findIndex(confidenceIcons, {value: confidence});
|
||||
|
||||
const defaultconfidenceIndex: number = findIndex(confidenceIcons, {value: 'default' as any});
|
||||
const defaultClass: string = (defaultconfidenceIndex !== -1) ? confidenceIcons[defaultconfidenceIndex].style : '';
|
||||
|
||||
return (confidenceIndex !== -1) ? confidenceIcons[confidenceIndex].style : defaultClass;
|
||||
if (this.iconMode) {
|
||||
const defaultClass: string = (defaultconfidenceIndex !== -1) ? confidenceIcons[defaultconfidenceIndex].icon : '';
|
||||
return (confidenceIndex !== -1) ? confidenceIcons[confidenceIndex].icon : defaultClass;
|
||||
} else {
|
||||
const defaultClass: string = (defaultconfidenceIndex !== -1) ? confidenceIcons[defaultconfidenceIndex].style : '';
|
||||
return (confidenceIndex !== -1) ? confidenceIcons[confidenceIndex].style : defaultClass;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Return the confidence value name
|
||||
*
|
||||
* @param confidence
|
||||
* @returns
|
||||
*/
|
||||
private getNameByConfidence(confidence: any): string {
|
||||
let confidenceText = ConfidenceType[confidence];
|
||||
if (isNotEmpty(confidenceText)) {
|
||||
return confidenceText.replace('CF_', '').toLowerCase();
|
||||
} else {
|
||||
return 'unknown';
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
@@ -5,6 +5,9 @@ import { SubmissionFormsConfigDataService } from '../../core/config/submission-f
|
||||
*/
|
||||
export function getMockSectionUploadService(): SubmissionFormsConfigDataService {
|
||||
return jasmine.createSpyObj('SectionUploadService', {
|
||||
updatePrimaryBitstreamOperation: jasmine.createSpy('updatePrimaryBitstreamOperation'),
|
||||
updateFilePrimaryBitstream: jasmine.createSpy('updateFilePrimaryBitstream'),
|
||||
getUploadedFilesData: jasmine.createSpy('getUploadedFilesData'),
|
||||
getUploadedFileList: jasmine.createSpy('getUploadedFileList'),
|
||||
getFileData: jasmine.createSpy('getFileData'),
|
||||
getDefaultPolicies: jasmine.createSpy('getDefaultPolicies'),
|
||||
|
@@ -1612,7 +1612,13 @@ export const mockUploadFiles = [
|
||||
}
|
||||
];
|
||||
|
||||
export const mockUploadFilesData = {
|
||||
primary: null,
|
||||
files: JSON.parse(JSON.stringify(mockUploadFiles))
|
||||
};
|
||||
|
||||
export const mockFileFormData = {
|
||||
primary: [true],
|
||||
metadata: {
|
||||
'dc.title': [
|
||||
{
|
||||
|
@@ -1,7 +1,7 @@
|
||||
import { Component, EventEmitter, Input, Output, OnChanges } from '@angular/core';
|
||||
import { DSpaceObject } from '../../core/shared/dspace-object.model';
|
||||
import { Router } from '@angular/router';
|
||||
import { isNotEmpty } from '../empty.util';
|
||||
import { isNotEmpty, hasValue } from '../empty.util';
|
||||
import { SearchService } from '../../core/shared/search/search.service';
|
||||
import { currentPath } from '../utils/route.utils';
|
||||
import { PaginationService } from '../../core/pagination/pagination.service';
|
||||
@@ -39,6 +39,11 @@ export class SearchFormComponent implements OnChanges {
|
||||
@Input()
|
||||
scope = '';
|
||||
|
||||
/**
|
||||
* Hides the scope in the url, this can be useful when you hardcode the scope in another way
|
||||
*/
|
||||
@Input() hideScopeInUrl = false;
|
||||
|
||||
selectedScope: BehaviorSubject<DSpaceObject> = new BehaviorSubject<DSpaceObject>(undefined);
|
||||
|
||||
@Input() currentUrl: string;
|
||||
@@ -122,6 +127,9 @@ export class SearchFormComponent implements OnChanges {
|
||||
},
|
||||
data
|
||||
);
|
||||
if (hasValue(data.scope) && this.hideScopeInUrl) {
|
||||
delete queryParams.scope;
|
||||
}
|
||||
|
||||
void this.router.navigate(this.getSearchLinkParts(), {
|
||||
queryParams: queryParams,
|
||||
|
@@ -18,6 +18,8 @@ export class ThemedSearchFormComponent extends ThemedComponent<SearchFormCompone
|
||||
|
||||
@Input() scope: string;
|
||||
|
||||
@Input() hideScopeInUrl: boolean;
|
||||
|
||||
@Input() currentUrl: string;
|
||||
|
||||
@Input() large: boolean;
|
||||
@@ -31,7 +33,15 @@ export class ThemedSearchFormComponent extends ThemedComponent<SearchFormCompone
|
||||
@Output() submitSearch: EventEmitter<any> = new EventEmitter();
|
||||
|
||||
protected inAndOutputNames: (keyof SearchFormComponent & keyof this)[] = [
|
||||
'query', 'inPlaceSearch', 'scope', 'currentUrl', 'large', 'brandColor', 'searchPlaceholder', 'showScopeSelector',
|
||||
'query',
|
||||
'inPlaceSearch',
|
||||
'scope',
|
||||
'hideScopeInUrl',
|
||||
'currentUrl',
|
||||
'large',
|
||||
'brandColor',
|
||||
'searchPlaceholder',
|
||||
'showScopeSelector',
|
||||
'submitSearch',
|
||||
];
|
||||
|
||||
|
@@ -157,11 +157,20 @@ export class SearchFilterComponent implements OnInit {
|
||||
}
|
||||
|
||||
get regionId(): string {
|
||||
return `search-filter-region-${this.sequenceId}`;
|
||||
if (this.inPlaceSearch) {
|
||||
return `search-filter-region-${this.sequenceId}`;
|
||||
} else {
|
||||
return `search-filter-region-home-${this.sequenceId}`;
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
get toggleId(): string {
|
||||
return `search-filter-toggle-${this.sequenceId}`;
|
||||
if (this.inPlaceSearch) {
|
||||
return `search-filter-toggle-${this.sequenceId}`;
|
||||
} else {
|
||||
return `search-filter-toggle-home-${this.sequenceId}`;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
|
@@ -27,6 +27,8 @@ import { SearchConfigurationServiceStub } from '../../../../testing/search-confi
|
||||
import { VocabularyEntryDetail } from '../../../../../core/submission/vocabularies/models/vocabulary-entry-detail.model';
|
||||
import { FacetValue} from '../../../models/facet-value.model';
|
||||
import { SearchFilterConfig } from '../../../models/search-filter-config.model';
|
||||
import { APP_CONFIG } from '../../../../../../config/app-config.interface';
|
||||
import { environment } from '../../../../../../environments/environment.test';
|
||||
|
||||
describe('SearchHierarchyFilterComponent', () => {
|
||||
|
||||
@@ -34,7 +36,7 @@ describe('SearchHierarchyFilterComponent', () => {
|
||||
let showVocabularyTreeLink: DebugElement;
|
||||
|
||||
const testSearchLink = 'test-search';
|
||||
const testSearchFilter = 'test-search-filter';
|
||||
const testSearchFilter = 'subject';
|
||||
const VocabularyTreeViewComponent = {
|
||||
select: new EventEmitter<VocabularyEntryDetail>(),
|
||||
};
|
||||
@@ -73,6 +75,7 @@ describe('SearchHierarchyFilterComponent', () => {
|
||||
{ provide: Router, useValue: router },
|
||||
{ provide: NgbModal, useValue: ngbModal },
|
||||
{ provide: VocabularyService, useValue: vocabularyService },
|
||||
{ provide: APP_CONFIG, useValue: environment },
|
||||
{ provide: SEARCH_CONFIG_SERVICE, useValue: new SearchConfigurationServiceStub() },
|
||||
{ provide: IN_PLACE_SEARCH, useValue: false },
|
||||
{ provide: FILTER_CONFIG, useValue: Object.assign(new SearchFilterConfig(), { name: testSearchFilter }) },
|
||||
@@ -86,7 +89,7 @@ describe('SearchHierarchyFilterComponent', () => {
|
||||
function init() {
|
||||
fixture = TestBed.createComponent(SearchHierarchyFilterComponent);
|
||||
fixture.detectChanges();
|
||||
showVocabularyTreeLink = fixture.debugElement.query(By.css('a#show-test-search-filter-tree'));
|
||||
showVocabularyTreeLink = fixture.debugElement.query(By.css(`a#show-${testSearchFilter}-tree`));
|
||||
}
|
||||
|
||||
describe('if the vocabulary doesn\'t exist', () => {
|
||||
@@ -125,10 +128,10 @@ describe('SearchHierarchyFilterComponent', () => {
|
||||
const newSelectedValue = 'new-selected-value';
|
||||
|
||||
beforeEach(async () => {
|
||||
showVocabularyTreeLink.nativeElement.click();
|
||||
fixture.componentInstance.selectedValues$ = observableOf(
|
||||
alreadySelectedValues.map(value => Object.assign(new FacetValue(), { value }))
|
||||
);
|
||||
showVocabularyTreeLink.nativeElement.click();
|
||||
VocabularyTreeViewComponent.select.emit(Object.assign(new VocabularyEntryDetail(), {
|
||||
value: newSelectedValue,
|
||||
}));
|
||||
|
@@ -22,11 +22,13 @@ import { FacetValue } from '../../../models/facet-value.model';
|
||||
import { getFacetValueForType } from '../../../search.utils';
|
||||
import { filter, map, take } from 'rxjs/operators';
|
||||
import { VocabularyService } from '../../../../../core/submission/vocabularies/vocabulary.service';
|
||||
import { Observable, BehaviorSubject, Subscription } from 'rxjs';
|
||||
import { Observable, BehaviorSubject, Subscription, combineLatest } from 'rxjs';
|
||||
import { PageInfo } from '../../../../../core/shared/page-info.model';
|
||||
import { environment } from '../../../../../../environments/environment';
|
||||
import { addOperatorToFilterValue } from '../../../search.utils';
|
||||
import { VocabularyTreeviewModalComponent } from '../../../../form/vocabulary-treeview-modal/vocabulary-treeview-modal.component';
|
||||
import { hasValue } from '../../../../empty.util';
|
||||
import { APP_CONFIG, AppConfig } from '../../../../../../config/app-config.interface';
|
||||
import { FilterVocabularyConfig } from '../../../../../../config/filter-vocabulary-config';
|
||||
|
||||
@Component({
|
||||
selector: 'ds-search-hierarchy-filter',
|
||||
@@ -49,6 +51,7 @@ export class SearchHierarchyFilterComponent extends SearchFacetFilterComponent i
|
||||
protected router: Router,
|
||||
protected modalService: NgbModal,
|
||||
protected vocabularyService: VocabularyService,
|
||||
@Inject(APP_CONFIG) protected appConfig: AppConfig,
|
||||
@Inject(SEARCH_CONFIG_SERVICE) public searchConfigService: SearchConfigurationService,
|
||||
@Inject(IN_PLACE_SEARCH) public inPlaceSearch: boolean,
|
||||
@Inject(FILTER_CONFIG) public filterConfig: SearchFilterConfig,
|
||||
@@ -74,17 +77,20 @@ export class SearchHierarchyFilterComponent extends SearchFacetFilterComponent i
|
||||
super.onSubmit(addOperatorToFilterValue(data, 'query'));
|
||||
}
|
||||
|
||||
ngOnInit() {
|
||||
ngOnInit(): void {
|
||||
super.ngOnInit();
|
||||
this.vocabularyExists$ = this.vocabularyService.searchTopEntries(
|
||||
this.getVocabularyEntry(), new PageInfo(), true, false,
|
||||
).pipe(
|
||||
filter(rd => rd.hasCompleted),
|
||||
take(1),
|
||||
map(rd => {
|
||||
return rd.hasSucceeded;
|
||||
}),
|
||||
);
|
||||
const vocabularyName: string = this.getVocabularyEntry();
|
||||
if (hasValue(vocabularyName)) {
|
||||
this.vocabularyExists$ = this.vocabularyService.searchTopEntries(
|
||||
vocabularyName, new PageInfo(), true, false,
|
||||
).pipe(
|
||||
filter(rd => rd.hasCompleted),
|
||||
take(1),
|
||||
map(rd => {
|
||||
return rd.hasSucceeded;
|
||||
}),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -100,21 +106,20 @@ export class SearchHierarchyFilterComponent extends SearchFacetFilterComponent i
|
||||
name: this.getVocabularyEntry(),
|
||||
closed: true
|
||||
};
|
||||
this.subscriptions.push(modalRef.componentInstance.select.subscribe((detail: VocabularyEntryDetail) => {
|
||||
this.selectedValues$
|
||||
.pipe(take(1))
|
||||
.subscribe((selectedValues) => {
|
||||
void this.router.navigate(
|
||||
[this.searchService.getSearchLink()],
|
||||
{
|
||||
queryParams: {
|
||||
[this.filterConfig.paramName]: [...selectedValues, {value: detail.value}]
|
||||
.map((facetValue: FacetValue) => getFacetValueForType(facetValue, this.filterConfig)),
|
||||
},
|
||||
queryParamsHandling: 'merge',
|
||||
},
|
||||
);
|
||||
});
|
||||
this.subscriptions.push(combineLatest([
|
||||
(modalRef.componentInstance as VocabularyTreeviewModalComponent).select,
|
||||
this.selectedValues$.pipe(take(1)),
|
||||
]).subscribe(([detail, selectedValues]: [VocabularyEntryDetail, FacetValue[]]) => {
|
||||
void this.router.navigate(
|
||||
[this.searchService.getSearchLink()],
|
||||
{
|
||||
queryParams: {
|
||||
[this.filterConfig.paramName]: [...selectedValues, {value: detail.value}]
|
||||
.map((facetValue: FacetValue) => getFacetValueForType(facetValue, this.filterConfig)),
|
||||
},
|
||||
queryParamsHandling: 'merge',
|
||||
},
|
||||
);
|
||||
}));
|
||||
}
|
||||
|
||||
@@ -122,8 +127,8 @@ export class SearchHierarchyFilterComponent extends SearchFacetFilterComponent i
|
||||
* Returns the matching vocabulary entry for the given search filter.
|
||||
* These are configurable in the config file.
|
||||
*/
|
||||
getVocabularyEntry() {
|
||||
const foundVocabularyConfig = environment.vocabularies.filter((v) => v.filter === this.filterConfig.name);
|
||||
getVocabularyEntry(): string {
|
||||
const foundVocabularyConfig: FilterVocabularyConfig[] = this.appConfig.vocabularies.filter((v: FilterVocabularyConfig) => v.filter === this.filterConfig.name);
|
||||
if (foundVocabularyConfig.length > 0 && foundVocabularyConfig[0].enabled === true) {
|
||||
return foundVocabularyConfig[0].vocabulary;
|
||||
}
|
||||
|
@@ -1,4 +1,5 @@
|
||||
<h3>{{"search.filters.head" | translate}}</h3>
|
||||
<h3 *ngIf="inPlaceSearch">{{filterLabel+'.filters.head' | translate}}</h3>
|
||||
<h2 *ngIf="!inPlaceSearch">{{filterLabel+'.filters.head' | translate}}</h2>
|
||||
<div *ngIf="(filters | async)?.hasSucceeded">
|
||||
<div *ngFor="let filter of (filters | async)?.payload; trackBy: trackUpdate">
|
||||
<ds-search-filter [scope]="currentScope" [filter]="filter" [inPlaceSearch]="inPlaceSearch" [refreshFilters]="refreshFilters"></ds-search-filter>
|
||||
|
@@ -20,7 +20,8 @@ describe('SearchFiltersComponent', () => {
|
||||
getClearFiltersQueryParams: () => {
|
||||
},
|
||||
getSearchLink: () => {
|
||||
}
|
||||
},
|
||||
getConfigurationSearchConfig: () => { },
|
||||
/* eslint-enable no-empty, @typescript-eslint/no-empty-function */
|
||||
};
|
||||
|
||||
|
@@ -61,6 +61,7 @@ export class SearchFiltersComponent implements OnInit, OnDestroy {
|
||||
searchLink: string;
|
||||
|
||||
subs = [];
|
||||
filterLabel = 'search';
|
||||
|
||||
/**
|
||||
* Initialize instance variables
|
||||
@@ -77,6 +78,9 @@ export class SearchFiltersComponent implements OnInit, OnDestroy {
|
||||
}
|
||||
|
||||
ngOnInit(): void {
|
||||
if (!this.inPlaceSearch) {
|
||||
this.filterLabel = 'discover';
|
||||
}
|
||||
this.clearParams = this.searchConfigService.getCurrentFrontendFilters().pipe(map((filters) => {
|
||||
Object.keys(filters).forEach((f) => filters[f] = null);
|
||||
return filters;
|
||||
|
@@ -23,7 +23,7 @@
|
||||
[filters]="filters"
|
||||
[refreshFilters]="refreshFilters"
|
||||
[inPlaceSearch]="inPlaceSearch"></ds-themed-search-filters>
|
||||
<ds-themed-search-settings [currentSortOption]="currentSortOption"
|
||||
<ds-themed-search-settings *ngIf="inPlaceSearch" [currentSortOption]="currentSortOption"
|
||||
[sortOptionsList]="sortOptionsList"></ds-themed-search-settings>
|
||||
</div>
|
||||
</div>
|
||||
|
@@ -10,8 +10,9 @@
|
||||
<ng-template *ngTemplateOutlet="searchContent"></ng-template>
|
||||
</div>
|
||||
|
||||
<ds-page-with-sidebar *ngIf="showSidebar && (initialized$ | async)" [id]="'search-page'" [sidebarContent]="sidebarContent">
|
||||
<ng-template *ngTemplateOutlet="searchContent"></ng-template>
|
||||
<ds-page-with-sidebar *ngIf="showSidebar && (initialized$ | async)" [id]="'search-page'" [sideBarWidth]="sideBarWidth"
|
||||
[sidebarContent]="sidebarContent">
|
||||
<ng-template *ngTemplateOutlet="searchContent"></ng-template>
|
||||
</ds-page-with-sidebar>
|
||||
|
||||
<ng-template #searchContent>
|
||||
@@ -22,15 +23,15 @@
|
||||
</div>
|
||||
<div id="search-content" class="col-12">
|
||||
<div class="d-block d-md-none search-controls clearfix">
|
||||
<ds-view-mode-switch [viewModeList]="viewModeList" [inPlaceSearch]="inPlaceSearch"></ds-view-mode-switch>
|
||||
<button [attr.aria-label]="'search.sidebar.open' | translate" (click)="openSidebar()"
|
||||
aria-controls="search-sidebar-content"
|
||||
<ds-view-mode-switch *ngIf="inPlaceSearch" [viewModeList]="viewModeList"
|
||||
[inPlaceSearch]="inPlaceSearch"></ds-view-mode-switch>
|
||||
<button (click)="openSidebar()" aria-controls="#search-body"
|
||||
class="btn btn-outline-primary float-right open-sidebar"><i
|
||||
class="fas fa-sliders"></i> {{"search.sidebar.open"
|
||||
| translate}}
|
||||
</button>
|
||||
</div>
|
||||
<ds-themed-search-results [searchResults]="resultsRD$ | async"
|
||||
<ds-themed-search-results *ngIf="inPlaceSearch" [searchResults]="resultsRD$ | async"
|
||||
[searchConfig]="searchOptions$ | async"
|
||||
[configuration]="(currentConfiguration$ | async)"
|
||||
[disableHeader]="!searchEnabled"
|
||||
@@ -85,6 +86,7 @@
|
||||
<ds-themed-search-form *ngIf="searchEnabled" id="search-form"
|
||||
[query]="(searchOptions$ | async)?.query"
|
||||
[scope]="(searchOptions$ | async)?.scope"
|
||||
[hideScopeInUrl]="hideScopeInUrl"
|
||||
[currentUrl]="searchLink"
|
||||
[showScopeSelector]="showScopeSelector"
|
||||
[inPlaceSearch]="inPlaceSearch"
|
||||
|
@@ -33,6 +33,8 @@ import { SearchFilterConfig } from './models/search-filter-config.model';
|
||||
import { FilterType } from './models/filter-type.model';
|
||||
import { getCommunityPageRoute } from '../../community-page/community-page-routing-paths';
|
||||
import { getCollectionPageRoute } from '../../collection-page/collection-page-routing-paths';
|
||||
import { environment } from '../../../environments/environment.test';
|
||||
import { APP_CONFIG } from '../../../config/app-config.interface';
|
||||
|
||||
let comp: SearchComponent;
|
||||
let fixture: ComponentFixture<SearchComponent>;
|
||||
@@ -209,7 +211,8 @@ export function configureSearchComponentTestingModule(compType, additionalDeclar
|
||||
{
|
||||
provide: SEARCH_CONFIG_SERVICE,
|
||||
useValue: searchConfigurationServiceStub
|
||||
}
|
||||
},
|
||||
{ provide: APP_CONFIG, useValue: environment },
|
||||
],
|
||||
schemas: [NO_ERRORS_SCHEMA]
|
||||
}).overrideComponent(compType, {
|
||||
|
@@ -31,13 +31,13 @@ import { ViewMode } from '../../core/shared/view-mode.model';
|
||||
import { SelectionConfig } from './search-results/search-results.component';
|
||||
import { ListableObject } from '../object-collection/shared/listable-object.model';
|
||||
import { CollectionElementLinkType } from '../object-collection/collection-element-link.type';
|
||||
import { environment } from 'src/environments/environment';
|
||||
import { SubmissionObject } from '../../core/submission/models/submission-object.model';
|
||||
import { SearchFilterConfig } from './models/search-filter-config.model';
|
||||
import { WorkspaceItem } from '../../core/submission/models/workspaceitem.model';
|
||||
import { ITEM_MODULE_PATH } from '../../item-page/item-page-routing-paths';
|
||||
import { COLLECTION_MODULE_PATH } from '../../collection-page/collection-page-routing-paths';
|
||||
import { COMMUNITY_MODULE_PATH } from '../../community-page/community-page-routing-paths';
|
||||
import { AppConfig, APP_CONFIG } from '../../../config/app-config.interface';
|
||||
|
||||
@Component({
|
||||
selector: 'ds-search',
|
||||
@@ -171,6 +171,11 @@ export class SearchComponent implements OnDestroy, OnInit {
|
||||
*/
|
||||
@Input() scope: string;
|
||||
|
||||
/**
|
||||
* Hides the scope in the url, this can be useful when you hardcode the scope in another way
|
||||
*/
|
||||
@Input() hideScopeInUrl: boolean;
|
||||
|
||||
/**
|
||||
* The current configuration used during the search
|
||||
*/
|
||||
@@ -278,7 +283,9 @@ export class SearchComponent implements OnDestroy, OnInit {
|
||||
protected windowService: HostWindowService,
|
||||
@Inject(SEARCH_CONFIG_SERVICE) public searchConfigService: SearchConfigurationService,
|
||||
protected routeService: RouteService,
|
||||
protected router: Router) {
|
||||
protected router: Router,
|
||||
@Inject(APP_CONFIG) protected appConfig: AppConfig,
|
||||
) {
|
||||
this.isXsOrSm$ = this.windowService.isXsOrSm();
|
||||
}
|
||||
|
||||
@@ -445,8 +452,10 @@ export class SearchComponent implements OnDestroy, OnInit {
|
||||
let followLinks = [
|
||||
followLink<Item>('thumbnail', { isOptional: true }),
|
||||
followLink<SubmissionObject>('item', { isOptional: true }, followLink<Item>('thumbnail', { isOptional: true })) as any,
|
||||
followLink<Item>('accessStatus', { isOptional: true, shouldEmbed: environment.item.showAccessStatuses }),
|
||||
];
|
||||
if (this.appConfig.item.showAccessStatuses) {
|
||||
followLinks.push(followLink<Item>('accessStatus', { isOptional: true }));
|
||||
}
|
||||
if (this.configuration === 'supervision') {
|
||||
followLinks.push(followLink<WorkspaceItem>('supervisionOrders', { isOptional: true }) as any);
|
||||
}
|
||||
@@ -476,18 +485,20 @@ export class SearchComponent implements OnDestroy, OnInit {
|
||||
* This method should only be called once and is essentially what SearchTrackingComponent used to do (now removed)
|
||||
* @private
|
||||
*/
|
||||
private subscribeToRoutingEvents() {
|
||||
this.subs.push(
|
||||
this.router.events.pipe(
|
||||
filter((event) => event instanceof NavigationStart),
|
||||
map((event: NavigationStart) => this.getDsoUUIDFromUrl(event.url)),
|
||||
hasValueOperator(),
|
||||
).subscribe((uuid) => {
|
||||
if (this.resultsRD$.value.hasSucceeded) {
|
||||
this.service.trackSearch(this.searchOptions$.value, this.resultsRD$.value.payload as SearchObjects<DSpaceObject>, uuid);
|
||||
}
|
||||
}),
|
||||
);
|
||||
private subscribeToRoutingEvents(): void {
|
||||
if (this.trackStatistics) {
|
||||
this.subs.push(
|
||||
this.router.events.pipe(
|
||||
filter((event) => event instanceof NavigationStart),
|
||||
map((event: NavigationStart) => this.getDsoUUIDFromUrl(event.url)),
|
||||
hasValueOperator(),
|
||||
).subscribe((uuid) => {
|
||||
if (this.resultsRD$.value.hasSucceeded) {
|
||||
this.service.trackSearch(this.searchOptions$.value, this.resultsRD$.value.payload as SearchObjects<DSpaceObject>, uuid);
|
||||
}
|
||||
}),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
|
@@ -34,7 +34,6 @@ import { ThemedSearchSettingsComponent } from './search-settings/themed-search-s
|
||||
import { NouisliderModule } from 'ng2-nouislider';
|
||||
import { ThemedSearchFiltersComponent } from './search-filters/themed-search-filters.component';
|
||||
import { ThemedSearchSidebarComponent } from './search-sidebar/themed-search-sidebar.component';
|
||||
|
||||
const COMPONENTS = [
|
||||
SearchComponent,
|
||||
ThemedSearchComponent,
|
||||
|
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user