Merge branch 'main-gh4s' into CST-7757

# Conflicts:
#	src/app/core/data/feature-authorization/feature-id.ts
This commit is contained in:
Davide Negretti
2023-02-06 11:02:50 +01:00
193 changed files with 13103 additions and 2356 deletions

View File

@@ -121,6 +121,9 @@ languages:
- code: en - code: en
label: English label: English
active: true active: true
- code: ca
label: Català
active: true
- code: cs - code: cs
label: Čeština label: Čeština
active: true active: true
@@ -310,3 +313,11 @@ info:
markdown: markdown:
enabled: false enabled: false
mathjax: false mathjax: false
# Which vocabularies should be used for which search filters
# and whether to show the filter in the search sidebar
# Take a look at the filter-vocabulary-config.ts file for documentation on how the options are obtained
vocabularies:
- filter: 'subject'
vocabulary: 'srsc'
enabled: true

View File

@@ -30,8 +30,9 @@
"clean:log": "rimraf *.log*", "clean:log": "rimraf *.log*",
"clean:json": "rimraf *.records.json", "clean:json": "rimraf *.records.json",
"clean:node": "rimraf node_modules", "clean:node": "rimraf node_modules",
"clean:cli": "rimraf .angular/cache",
"clean:prod": "yarn run clean:dist && yarn run clean:log && yarn run clean:doc && yarn run clean:coverage && yarn run clean:json", "clean:prod": "yarn run clean:dist && yarn run clean:log && yarn run clean:doc && yarn run clean:coverage && yarn run clean:json",
"clean": "yarn run clean:prod && yarn run clean:dev:config && yarn run clean:node", "clean": "yarn run clean:prod && yarn run clean:dev:config && yarn run clean:cli && yarn run clean:node",
"sync-i18n": "ts-node --project ./tsconfig.ts-node.json scripts/sync-i18n-files.ts", "sync-i18n": "ts-node --project ./tsconfig.ts-node.json scripts/sync-i18n-files.ts",
"build:mirador": "webpack --config webpack/webpack.mirador.config.ts", "build:mirador": "webpack --config webpack/webpack.mirador.config.ts",
"merge-i18n": "ts-node --project ./tsconfig.ts-node.json scripts/merge-i18n-files.ts", "merge-i18n": "ts-node --project ./tsconfig.ts-node.json scripts/merge-i18n-files.ts",
@@ -92,6 +93,7 @@
"date-fns": "^2.29.3", "date-fns": "^2.29.3",
"date-fns-tz": "^1.3.7", "date-fns-tz": "^1.3.7",
"deepmerge": "^4.2.2", "deepmerge": "^4.2.2",
"ejs": "^3.1.8",
"express": "^4.17.1", "express": "^4.17.1",
"express-rate-limit": "^5.1.3", "express-rate-limit": "^5.1.3",
"fast-json-patch": "^3.0.0-1", "fast-json-patch": "^3.0.0-1",
@@ -146,6 +148,7 @@
"@ngtools/webpack": "^13.2.6", "@ngtools/webpack": "^13.2.6",
"@nguniversal/builders": "^13.1.1", "@nguniversal/builders": "^13.1.1",
"@types/deep-freeze": "0.1.2", "@types/deep-freeze": "0.1.2",
"@types/ejs": "^3.1.1",
"@types/express": "^4.17.9", "@types/express": "^4.17.9",
"@types/jasmine": "~3.6.0", "@types/jasmine": "~3.6.0",
"@types/js-cookie": "2.2.6", "@types/js-cookie": "2.2.6",

View File

@@ -22,6 +22,7 @@ import 'rxjs';
/* eslint-disable import/no-namespace */ /* eslint-disable import/no-namespace */
import * as morgan from 'morgan'; import * as morgan from 'morgan';
import * as express from 'express'; import * as express from 'express';
import * as ejs from 'ejs';
import * as compression from 'compression'; import * as compression from 'compression';
import * as expressStaticGzip from 'express-static-gzip'; import * as expressStaticGzip from 'express-static-gzip';
/* eslint-enable import/no-namespace */ /* eslint-enable import/no-namespace */
@@ -136,10 +137,23 @@ export function app() {
})(_, (options as any), callback) })(_, (options as any), callback)
); );
server.engine('ejs', ejs.renderFile);
/* /*
* Register the view engines for html and ejs * Register the view engines for html and ejs
*/ */
server.set('view engine', 'html'); server.set('view engine', 'html');
server.set('view engine', 'ejs');
/**
* Serve the robots.txt ejs template, filling in the origin variable
*/
server.get('/robots.txt', (req, res) => {
res.setHeader('content-type', 'text/plain');
res.render('assets/robots.txt.ejs', {
'origin': req.protocol + '://' + req.headers.host
});
});
/* /*
* Set views folder path to directory where template files are stored * Set views folder path to directory where template files are stored

View File

@@ -9,7 +9,18 @@
</ng-template> </ng-template>
<ng-template #editheader> <ng-template #editheader>
<h2 class="border-bottom pb-2">{{messagePrefix + '.head.edit' | translate}}</h2> <h2 class="border-bottom pb-2">
<span
*dsContextHelp="{
content: 'admin.access-control.groups.form.tooltip.editGroupPage',
id: 'edit-group-page',
iconPlacement: 'right',
tooltipPlacement: ['right', 'bottom']
}"
>
{{messagePrefix + '.head.edit' | translate}}
</span>
</h2>
</ng-template> </ng-template>
<ds-alert *ngIf="groupBeingEdited?.permanent" [type]="AlertTypeEnum.Warning" <ds-alert *ngIf="groupBeingEdited?.permanent" [type]="AlertTypeEnum.Warning"

View File

@@ -266,6 +266,43 @@ describe('GroupFormComponent', () => {
fixture.detectChanges(); fixture.detectChanges();
}); });
it('should edit with name and description operations', () => {
const operations = [{
op: 'add',
path: '/metadata/dc.description',
value: 'testDescription'
}, {
op: 'replace',
path: '/name',
value: 'newGroupName'
}];
expect(groupsDataServiceStub.patch).toHaveBeenCalledWith(expected, operations);
});
it('should edit with description operations', () => {
component.groupName.value = null;
component.onSubmit();
fixture.detectChanges();
const operations = [{
op: 'add',
path: '/metadata/dc.description',
value: 'testDescription'
}];
expect(groupsDataServiceStub.patch).toHaveBeenCalledWith(expected, operations);
});
it('should edit with name operations', () => {
component.groupDescription.value = null;
component.onSubmit();
fixture.detectChanges();
const operations = [{
op: 'replace',
path: '/name',
value: 'newGroupName'
}];
expect(groupsDataServiceStub.patch).toHaveBeenCalledWith(expected, operations);
});
it('should emit the existing group using the correct new values', waitForAsync(() => { it('should emit the existing group using the correct new values', waitForAsync(() => {
fixture.whenStable().then(() => { fixture.whenStable().then(() => {
expect(component.submitForm.emit).toHaveBeenCalledWith(expected2); expect(component.submitForm.emit).toHaveBeenCalledWith(expected2);

View File

@@ -346,8 +346,8 @@ export class GroupFormComponent implements OnInit, OnDestroy {
if (hasValue(this.groupDescription.value)) { if (hasValue(this.groupDescription.value)) {
operations = [...operations, { operations = [...operations, {
op: 'replace', op: 'add',
path: '/metadata/dc.description/0/value', path: '/metadata/dc.description',
value: this.groupDescription.value value: this.groupDescription.value
}]; }];
} }

View File

@@ -1,9 +1,19 @@
<ng-container> <ng-container>
<h3 class="border-bottom pb-2">{{messagePrefix + '.head' | translate}}</h3> <h3 class="border-bottom pb-2">{{messagePrefix + '.head' | translate}}</h3>
<h4 id="search" class="border-bottom pb-2">{{messagePrefix + '.search.head' | translate}} <h4 id="search" class="border-bottom pb-2">
<span
*dsContextHelp="{
content: 'admin.access-control.groups.form.tooltip.editGroup.addEpeople',
id: 'edit-group-add-epeople',
iconPlacement: 'right',
tooltipPlacement: ['top', 'right', 'bottom']
}"
>
{{messagePrefix + '.search.head' | translate}}
</span>
</h4> </h4>
<form [formGroup]="searchForm" (ngSubmit)="search(searchForm.value)" class="d-flex justify-content-between"> <form [formGroup]="searchForm" (ngSubmit)="search(searchForm.value)" class="d-flex justify-content-between">
<div> <div>
<select name="scope" id="scope" formControlName="scope" class="form-control" aria-label="Search scope"> <select name="scope" id="scope" formControlName="scope" class="form-control" aria-label="Search scope">

View File

@@ -1,7 +1,16 @@
<ng-container> <ng-container>
<h3 class="border-bottom pb-2">{{messagePrefix + '.head' | translate}}</h3> <h3 class="border-bottom pb-2">{{messagePrefix + '.head' | translate}}</h3>
<h4 id="search" class="border-bottom pb-2">{{messagePrefix + '.search.head' | translate}} <h4 id="search" class="border-bottom pb-2">
<span *dsContextHelp="{
content: 'admin.access-control.groups.form.tooltip.editGroup.addSubgroups',
id: 'edit-group-add-subgroups',
iconPlacement: 'right',
tooltipPlacement: ['top', 'right', 'bottom']
}"
>
{{messagePrefix + '.search.head' | translate}}
</span>
</h4> </h4>
<form [formGroup]="searchForm" (ngSubmit)="search(searchForm.value)" class="d-flex justify-content-between"> <form [formGroup]="searchForm" (ngSubmit)="search(searchForm.value)" class="d-flex justify-content-between">

View File

@@ -100,6 +100,7 @@ export class AdminSidebarComponent extends MenuComponent implements OnInit {
} }
} }
}); });
this.menuVisible = this.menuService.isMenuVisibleWithVisibleSections(this.menuID);
} }
@HostListener('focusin') @HostListener('focusin')

View File

@@ -47,6 +47,7 @@ import { truncatableReducer, TruncatablesState } from './shared/truncatable/trun
import { ThemeState, themeReducer } from './shared/theme-support/theme.reducer'; import { ThemeState, themeReducer } from './shared/theme-support/theme.reducer';
import { MenusState } from './shared/menu/menus-state.model'; import { MenusState } from './shared/menu/menus-state.model';
import { correlationIdReducer } from './correlation-id/correlation-id.reducer'; import { correlationIdReducer } from './correlation-id/correlation-id.reducer';
import { contextHelpReducer, ContextHelpState } from './shared/context-help.reducer';
export interface AppState { export interface AppState {
router: RouterReducerState; router: RouterReducerState;
@@ -67,6 +68,7 @@ export interface AppState {
epeopleRegistry: EPeopleRegistryState; epeopleRegistry: EPeopleRegistryState;
groupRegistry: GroupRegistryState; groupRegistry: GroupRegistryState;
correlationId: string; correlationId: string;
contextHelp: ContextHelpState;
} }
export const appReducers: ActionReducerMap<AppState> = { export const appReducers: ActionReducerMap<AppState> = {
@@ -87,7 +89,8 @@ export const appReducers: ActionReducerMap<AppState> = {
communityList: CommunityListReducer, communityList: CommunityListReducer,
epeopleRegistry: ePeopleRegistryReducer, epeopleRegistry: ePeopleRegistryReducer,
groupRegistry: groupRegistryReducer, groupRegistry: groupRegistryReducer,
correlationId: correlationIdReducer correlationId: correlationIdReducer,
contextHelp: contextHelpReducer,
}; };
export const routerStateSelector = (state: AppState) => state.router; export const routerStateSelector = (state: AppState) => state.router;

View File

@@ -65,6 +65,7 @@ export class BrowseByDatePageComponent extends BrowseByMetadataPageComponent {
const searchOptions = browseParamsToOptions(params, currentPage, currentSort, this.browseId, this.fetchThumbnails); const searchOptions = browseParamsToOptions(params, currentPage, currentSort, this.browseId, this.fetchThumbnails);
this.updatePageWithItems(searchOptions, this.value, undefined); this.updatePageWithItems(searchOptions, this.value, undefined);
this.updateParent(params.scope); this.updateParent(params.scope);
this.updateLogo();
this.updateStartsWithOptions(this.browseId, metadataKeys, params.scope); this.updateStartsWithOptions(this.browseId, metadataKeys, params.scope);
})); }));
} }

View File

@@ -5,6 +5,11 @@
<!-- Parent Name --> <!-- Parent Name -->
<ds-comcol-page-header [name]="parentContext.name"> <ds-comcol-page-header [name]="parentContext.name">
</ds-comcol-page-header> </ds-comcol-page-header>
<!-- Collection logo -->
<ds-comcol-page-logo *ngIf="logo$"
[logo]="(logo$ | async)?.payload"
[alternateText]="'Community or Collection Logo'">
</ds-comcol-page-logo>
<!-- Handle --> <!-- Handle -->
<ds-themed-comcol-page-handle <ds-themed-comcol-page-handle
[content]="parentContext.handle" [content]="parentContext.handle"

View File

@@ -144,6 +144,9 @@ describe('BrowseByMetadataPageComponent', () => {
route.params = observableOf(paramsWithValue); route.params = observableOf(paramsWithValue);
comp.ngOnInit(); comp.ngOnInit();
comp.updateParent('fake-scope');
comp.updateLogo();
fixture.detectChanges();
}); });
it('should fetch items', () => { it('should fetch items', () => {
@@ -151,6 +154,10 @@ describe('BrowseByMetadataPageComponent', () => {
expect(result.payload.page).toEqual(mockItems); expect(result.payload.page).toEqual(mockItems);
}); });
}); });
it('should fetch the logo', () => {
expect(comp.logo$).toBeTruthy();
});
}); });
describe('when calling browseParamsToOptions', () => { describe('when calling browseParamsToOptions', () => {

View File

@@ -15,7 +15,11 @@ import { DSpaceObjectDataService } from '../../core/data/dspace-object-data.serv
import { DSpaceObject } from '../../core/shared/dspace-object.model'; import { DSpaceObject } from '../../core/shared/dspace-object.model';
import { StartsWithType } from '../../shared/starts-with/starts-with-decorator'; import { StartsWithType } from '../../shared/starts-with/starts-with-decorator';
import { PaginationService } from '../../core/pagination/pagination.service'; import { PaginationService } from '../../core/pagination/pagination.service';
import { map } from 'rxjs/operators'; import { filter, map, mergeMap } from 'rxjs/operators';
import { followLink, FollowLinkConfig } from '../../shared/utils/follow-link-config.model';
import { Bitstream } from '../../core/shared/bitstream.model';
import { Collection } from '../../core/shared/collection.model';
import { Community } from '../../core/shared/community.model';
import { APP_CONFIG, AppConfig } from '../../../config/app-config.interface'; import { APP_CONFIG, AppConfig } from '../../../config/app-config.interface';
export const BBM_PAGINATION_ID = 'bbm'; export const BBM_PAGINATION_ID = 'bbm';
@@ -48,6 +52,11 @@ export class BrowseByMetadataPageComponent implements OnInit, OnDestroy {
*/ */
parent$: Observable<RemoteData<DSpaceObject>>; parent$: Observable<RemoteData<DSpaceObject>>;
/**
* The logo of the current Community or Collection
*/
logo$: Observable<RemoteData<Bitstream>>;
/** /**
* The pagination config used to display the values * The pagination config used to display the values
*/ */
@@ -151,6 +160,7 @@ export class BrowseByMetadataPageComponent implements OnInit, OnDestroy {
this.updatePage(browseParamsToOptions(params, currentPage, currentSort, this.browseId, false)); this.updatePage(browseParamsToOptions(params, currentPage, currentSort, this.browseId, false));
} }
this.updateParent(params.scope); this.updateParent(params.scope);
this.updateLogo();
})); }));
this.updateStartsWithTextOptions(); this.updateStartsWithTextOptions();
@@ -196,12 +206,31 @@ export class BrowseByMetadataPageComponent implements OnInit, OnDestroy {
*/ */
updateParent(scope: string) { updateParent(scope: string) {
if (hasValue(scope)) { if (hasValue(scope)) {
this.parent$ = this.dsoService.findById(scope).pipe( const linksToFollow = () => {
return [followLink('logo')];
};
this.parent$ = this.dsoService.findById(scope,
true,
true,
...linksToFollow() as FollowLinkConfig<DSpaceObject>[]).pipe(
getFirstSucceededRemoteData() getFirstSucceededRemoteData()
); );
} }
} }
/**
* Update the parent Community or Collection logo
*/
updateLogo() {
if (hasValue(this.parent$)) {
this.logo$ = this.parent$.pipe(
map((rd: RemoteData<Collection | Community>) => rd.payload),
filter((collectionOrCommunity: Collection | Community) => hasValue(collectionOrCommunity.logo)),
mergeMap((collectionOrCommunity: Collection | Community) => collectionOrCommunity.logo)
);
}
}
/** /**
* Navigate to the previous page * Navigate to the previous page
*/ */

View File

@@ -4,16 +4,21 @@ import { BrowseByModule } from './browse-by.module';
import { ItemDataService } from '../core/data/item-data.service'; import { ItemDataService } from '../core/data/item-data.service';
import { BrowseService } from '../core/browse/browse.service'; import { BrowseService } from '../core/browse/browse.service';
import { BrowseByGuard } from './browse-by-guard'; import { BrowseByGuard } from './browse-by-guard';
import { SharedBrowseByModule } from '../shared/browse-by/shared-browse-by.module';
@NgModule({ @NgModule({
imports: [ imports: [
SharedBrowseByModule,
BrowseByRoutingModule, BrowseByRoutingModule,
BrowseByModule.withEntryComponents() BrowseByModule.withEntryComponents(),
], ],
providers: [ providers: [
ItemDataService, ItemDataService,
BrowseService, BrowseService,
BrowseByGuard BrowseByGuard,
],
declarations: [
] ]
}) })
export class BrowseByPageModule { export class BrowseByPageModule {

View File

@@ -49,6 +49,7 @@ export class BrowseByTitlePageComponent extends BrowseByMetadataPageComponent {
this.browseId = params.id || this.defaultBrowseId; this.browseId = params.id || this.defaultBrowseId;
this.updatePageWithItems(browseParamsToOptions(params, currentPage, currentSort, this.browseId, this.fetchThumbnails), undefined, undefined); this.updatePageWithItems(browseParamsToOptions(params, currentPage, currentSort, this.browseId, this.fetchThumbnails), undefined, undefined);
this.updateParent(params.scope); this.updateParent(params.scope);
this.updateLogo();
})); }));
this.updateStartsWithTextOptions(); this.updateStartsWithTextOptions();
} }

View File

@@ -1,7 +1,6 @@
import { NgModule } from '@angular/core'; import { NgModule } from '@angular/core';
import { CommonModule } from '@angular/common'; import { CommonModule } from '@angular/common';
import { BrowseByTitlePageComponent } from './browse-by-title-page/browse-by-title-page.component'; import { BrowseByTitlePageComponent } from './browse-by-title-page/browse-by-title-page.component';
import { SharedModule } from '../shared/shared.module';
import { BrowseByMetadataPageComponent } from './browse-by-metadata-page/browse-by-metadata-page.component'; import { BrowseByMetadataPageComponent } from './browse-by-metadata-page/browse-by-metadata-page.component';
import { BrowseByDatePageComponent } from './browse-by-date-page/browse-by-date-page.component'; import { BrowseByDatePageComponent } from './browse-by-date-page/browse-by-date-page.component';
import { BrowseBySwitcherComponent } from './browse-by-switcher/browse-by-switcher.component'; import { BrowseBySwitcherComponent } from './browse-by-switcher/browse-by-switcher.component';
@@ -10,6 +9,7 @@ import { ComcolModule } from '../shared/comcol/comcol.module';
import { ThemedBrowseByMetadataPageComponent } from './browse-by-metadata-page/themed-browse-by-metadata-page.component'; import { ThemedBrowseByMetadataPageComponent } from './browse-by-metadata-page/themed-browse-by-metadata-page.component';
import { ThemedBrowseByDatePageComponent } from './browse-by-date-page/themed-browse-by-date-page.component'; import { ThemedBrowseByDatePageComponent } from './browse-by-date-page/themed-browse-by-date-page.component';
import { ThemedBrowseByTitlePageComponent } from './browse-by-title-page/themed-browse-by-title-page.component'; import { ThemedBrowseByTitlePageComponent } from './browse-by-title-page/themed-browse-by-title-page.component';
import { SharedBrowseByModule } from '../shared/browse-by/shared-browse-by.module';
const ENTRY_COMPONENTS = [ const ENTRY_COMPONENTS = [
// put only entry components that use custom decorator // put only entry components that use custom decorator
@@ -25,9 +25,9 @@ const ENTRY_COMPONENTS = [
@NgModule({ @NgModule({
imports: [ imports: [
SharedBrowseByModule,
CommonModule, CommonModule,
ComcolModule, ComcolModule,
SharedModule
], ],
declarations: [ declarations: [
BrowseBySwitcherComponent, BrowseBySwitcherComponent,
@@ -45,7 +45,7 @@ export class BrowseByModule {
*/ */
static withEntryComponents() { static withEntryComponents() {
return { return {
ngModule: SharedModule, ngModule: SharedBrowseByModule,
providers: ENTRY_COMPONENTS.map((component) => ({provide: component})) providers: ENTRY_COMPONENTS.map((component) => ({provide: component}))
}; };
} }

View File

@@ -16,6 +16,7 @@ import { StatisticsModule } from '../statistics/statistics.module';
import { CollectionFormModule } from './collection-form/collection-form.module'; import { CollectionFormModule } from './collection-form/collection-form.module';
import { ThemedCollectionPageComponent } from './themed-collection-page.component'; import { ThemedCollectionPageComponent } from './themed-collection-page.component';
import { ComcolModule } from '../shared/comcol/comcol.module'; import { ComcolModule } from '../shared/comcol/comcol.module';
import { DsoSharedModule } from '../dso-shared/dso-shared.module';
@NgModule({ @NgModule({
imports: [ imports: [
@@ -26,6 +27,7 @@ import { ComcolModule } from '../shared/comcol/comcol.module';
EditItemPageModule, EditItemPageModule,
CollectionFormModule, CollectionFormModule,
ComcolModule, ComcolModule,
DsoSharedModule,
], ],
declarations: [ declarations: [
CollectionPageComponent, CollectionPageComponent,

View File

@@ -3,7 +3,7 @@
<div class="col-12" *ngVar="(itemRD$ | async) as itemRD"> <div class="col-12" *ngVar="(itemRD$ | async) as itemRD">
<ng-container *ngIf="itemRD?.hasSucceeded"> <ng-container *ngIf="itemRD?.hasSucceeded">
<h2 class="border-bottom">{{ 'collection.edit.template.head' | translate:{ collection: collection?.name } }}</h2> <h2 class="border-bottom">{{ 'collection.edit.template.head' | translate:{ collection: collection?.name } }}</h2>
<ds-themed-item-metadata [updateService]="itemTemplateService" [item]="itemRD?.payload"></ds-themed-item-metadata> <ds-themed-dso-edit-metadata [updateDataService]="itemTemplateService" [dso]="itemRD?.payload"></ds-themed-dso-edit-metadata>
<button [routerLink]="getCollectionEditUrl(collection)" class="btn btn-outline-secondary">{{ 'collection.edit.template.cancel' | translate }}</button> <button [routerLink]="getCollectionEditUrl(collection)" class="btn btn-outline-secondary">{{ 'collection.edit.template.cancel' | translate }}</button>
</ng-container> </ng-container>
<ds-themed-loading *ngIf="itemRD?.isLoading" [message]="'collection.edit.template.loading' | translate"></ds-themed-loading> <ds-themed-loading *ngIf="itemRD?.isLoading" [message]="'collection.edit.template.loading' | translate"></ds-themed-loading>

View File

@@ -41,21 +41,28 @@ describe('ArrayMoveChangeAnalyzer', () => {
], new MoveTest(0, 3)); ], new MoveTest(0, 3));
testMove([ testMove([
{ op: 'move', from: '/2', path: '/3' },
{ op: 'move', from: '/0', path: '/3' }, { op: 'move', from: '/0', path: '/3' },
{ op: 'move', from: '/2', path: '/1' }
], new MoveTest(0, 3), new MoveTest(1, 2)); ], new MoveTest(0, 3), new MoveTest(1, 2));
testMove([ testMove([
{ op: 'move', from: '/3', path: '/4' },
{ op: 'move', from: '/0', path: '/1' }, { op: 'move', from: '/0', path: '/1' },
{ op: 'move', from: '/3', path: '/4' }
], new MoveTest(0, 1), new MoveTest(3, 4)); ], new MoveTest(0, 1), new MoveTest(3, 4));
testMove([], new MoveTest(0, 4), new MoveTest(4, 0)); testMove([], new MoveTest(0, 4), new MoveTest(4, 0));
testMove([ testMove([
{ op: 'move', from: '/2', path: '/3' },
{ op: 'move', from: '/0', path: '/3' }, { op: 'move', from: '/0', path: '/3' },
{ op: 'move', from: '/2', path: '/1' }
], new MoveTest(0, 4), new MoveTest(1, 3), new MoveTest(2, 4)); ], new MoveTest(0, 4), new MoveTest(1, 3), new MoveTest(2, 4));
testMove([
{ op: 'move', from: '/3', path: '/4' },
{ op: 'move', from: '/2', path: '/4' },
{ op: 'move', from: '/1', path: '/3' },
{ op: 'move', from: '/0', path: '/3' },
], new MoveTest(4, 1), new MoveTest(4, 2), new MoveTest(0, 3));
}); });
describe('when some values are undefined (index 2 and 3)', () => { describe('when some values are undefined (index 2 and 3)', () => {

View File

@@ -16,22 +16,31 @@ export class ArrayMoveChangeAnalyzer<T> {
* @param array2 The custom array to compare with the original * @param array2 The custom array to compare with the original
*/ */
diff(array1: T[], array2: T[]): MoveOperation[] { diff(array1: T[], array2: T[]): MoveOperation[] {
const result = []; return this.getMoves(array1, array2).map((move) => Object.assign({
const moved = [...array1]; op: 'move',
array1.forEach((value: T, index: number) => { from: '/' + move[0],
if (hasValue(value)) { path: '/' + move[1],
const otherIndex = array2.indexOf(value); }) as MoveOperation);
const movedIndex = moved.indexOf(value); }
if (index !== otherIndex && movedIndex !== otherIndex) {
moveItemInArray(moved, movedIndex, otherIndex); /**
result.push(Object.assign({ * Determine a set of moves required to transform array1 into array2
op: 'move', * The moves are returned as an array of pairs of numbers where the first number is the original index and the second
from: '/' + movedIndex, * is the new index
path: '/' + otherIndex * It is assumed the operations are executed in the order they're returned (and not simultaneously)
}) as MoveOperation); * @param array1
} * @param array2
*/
private getMoves(array1: any[], array2: any[]): number[][] {
const moved = [...array2];
return array1.reduce((moves, item, index) => {
if (hasValue(item) && item !== moved[index]) {
const last = moved.lastIndexOf(item);
moveItemInArray(moved, last, index);
moves.unshift([index, last]);
} }
}); return moves;
return result; }, []);
} }
} }

View File

@@ -30,5 +30,7 @@ export enum FeatureID {
CanSendFeedback = 'canSendFeedback', CanSendFeedback = 'canSendFeedback',
CanClaimItem = 'canClaimItem', CanClaimItem = 'canClaimItem',
CanSynchronizeWithORCID = 'canSynchronizeWithORCID', CanSynchronizeWithORCID = 'canSynchronizeWithORCID',
CanSubmit = 'canSubmit',
CanEditItem = 'canEditItem',
CanSubscribe = 'canSubscribeDso', CanSubscribe = 'canSubscribeDso',
} }

View File

@@ -0,0 +1,33 @@
import { MetadataPatchOperation } from './metadata-patch-operation.model';
import { Operation } from 'fast-json-patch';
/**
* Wrapper object for a metadata patch move Operation
*/
export class MetadataPatchMoveOperation extends MetadataPatchOperation {
static operationType = 'move';
/**
* The original place of the metadata value to move
*/
from: number;
/**
* The new place to move the metadata value to
*/
to: number;
constructor(field: string, from: number, to: number) {
super(MetadataPatchMoveOperation.operationType, field);
this.from = from;
this.to = to;
}
/**
* Transform the MetadataPatchOperation into a fast-json-patch Operation by constructing its path and other properties
* using the information provided.
*/
toOperation(): Operation {
return { op: this.op as any, from: `/metadata/${this.field}/${this.from}`, path: `/metadata/${this.field}/${this.to}` };
}
}

View File

@@ -10,13 +10,19 @@ import { DeleteRequest } from './request.models';
import { RelationshipDataService } from './relationship-data.service'; import { RelationshipDataService } from './relationship-data.service';
import { RequestService } from './request.service'; import { RequestService } from './request.service';
import { HALEndpointServiceStub } from '../../shared/testing/hal-endpoint-service.stub'; import { HALEndpointServiceStub } from '../../shared/testing/hal-endpoint-service.stub';
import { createSuccessfulRemoteDataObject, createSuccessfulRemoteDataObject$ } from '../../shared/remote-data.utils'; import {
createFailedRemoteDataObject$,
createSuccessfulRemoteDataObject,
createSuccessfulRemoteDataObject$
} from '../../shared/remote-data.utils';
import { getMockRemoteDataBuildServiceHrefMap } from '../../shared/mocks/remote-data-build.service.mock'; import { getMockRemoteDataBuildServiceHrefMap } from '../../shared/mocks/remote-data-build.service.mock';
import { getMockRequestService } from '../../shared/mocks/request.service.mock'; import { getMockRequestService } from '../../shared/mocks/request.service.mock';
import { createPaginatedList } from '../../shared/testing/utils.test'; import { createPaginatedList } from '../../shared/testing/utils.test';
import { RequestEntry } from './request-entry.model'; import { RequestEntry } from './request-entry.model';
import { FindListOptions } from './find-list-options.model'; import { FindListOptions } from './find-list-options.model';
import { testSearchDataImplementation } from './base/search-data.spec'; import { testSearchDataImplementation } from './base/search-data.spec';
import { MetadataValue } from '../shared/metadata.models';
import { MetadataRepresentationType } from '../shared/metadata-representation/metadata-representation.model';
describe('RelationshipDataService', () => { describe('RelationshipDataService', () => {
let service: RelationshipDataService; let service: RelationshipDataService;
@@ -233,4 +239,152 @@ describe('RelationshipDataService', () => {
}); });
}); });
}); });
describe('resolveMetadataRepresentation', () => {
const parentItem: Item = Object.assign(new Item(), {
id: 'parent-item',
metadata: {
'dc.contributor.author': [
Object.assign(new MetadataValue(), {
language: null,
value: 'Related Author with authority',
authority: 'virtual::related-author',
place: 2
}),
Object.assign(new MetadataValue(), {
language: null,
value: 'Author without authority',
place: 1
}),
],
'dc.creator': [
Object.assign(new MetadataValue(), {
language: null,
value: 'Related Creator with authority',
authority: 'virtual::related-creator',
place: 3,
}),
Object.assign(new MetadataValue(), {
language: null,
value: 'Related Creator with authority - unauthorized',
authority: 'virtual::related-creator-unauthorized',
place: 4,
}),
],
'dc.title': [
Object.assign(new MetadataValue(), {
language: null,
value: 'Parent Item'
}),
]
}
});
const relatedAuthor: Item = Object.assign(new Item(), {
id: 'related-author',
metadata: {
'dc.title': [
Object.assign(new MetadataValue(), {
language: null,
value: 'Related Author'
}),
]
}
});
const relatedCreator: Item = Object.assign(new Item(), {
id: 'related-creator',
metadata: {
'dc.title': [
Object.assign(new MetadataValue(), {
language: null,
value: 'Related Creator'
}),
],
'dspace.entity.type': 'Person',
}
});
const authorRelation: Relationship = Object.assign(new Relationship(), {
leftItem: createSuccessfulRemoteDataObject$(parentItem),
rightItem: createSuccessfulRemoteDataObject$(relatedAuthor)
});
const creatorRelation: Relationship = Object.assign(new Relationship(), {
leftItem: createSuccessfulRemoteDataObject$(parentItem),
rightItem: createSuccessfulRemoteDataObject$(relatedCreator),
});
const creatorRelationUnauthorized: Relationship = Object.assign(new Relationship(), {
leftItem: createSuccessfulRemoteDataObject$(parentItem),
rightItem: createFailedRemoteDataObject$('Unauthorized', 401),
});
let metadatum: MetadataValue;
beforeEach(() => {
service.findById = (id: string) => {
if (id === 'related-author') {
return createSuccessfulRemoteDataObject$(authorRelation);
}
if (id === 'related-creator') {
return createSuccessfulRemoteDataObject$(creatorRelation);
}
if (id === 'related-creator-unauthorized') {
return createSuccessfulRemoteDataObject$(creatorRelationUnauthorized);
}
};
});
describe('when the metadata isn\'t virtual', () => {
beforeEach(() => {
metadatum = parentItem.metadata['dc.contributor.author'][1];
});
it('should return a plain text MetadatumRepresentation', (done) => {
service.resolveMetadataRepresentation(metadatum, parentItem, 'Person').subscribe((result) => {
expect(result.representationType).toEqual(MetadataRepresentationType.PlainText);
done();
});
});
});
describe('when the metadata is a virtual author', () => {
beforeEach(() => {
metadatum = parentItem.metadata['dc.contributor.author'][0];
});
it('should return a ItemMetadataRepresentation with the correct value', (done) => {
service.resolveMetadataRepresentation(metadatum, parentItem, 'Person').subscribe((result) => {
expect(result.representationType).toEqual(MetadataRepresentationType.Item);
expect(result.getValue()).toEqual(metadatum.value);
expect((result as any).id).toEqual(relatedAuthor.id);
done();
});
});
});
describe('when the metadata is a virtual creator', () => {
beforeEach(() => {
metadatum = parentItem.metadata['dc.creator'][0];
});
it('should return a ItemMetadataRepresentation with the correct value', (done) => {
service.resolveMetadataRepresentation(metadatum, parentItem, 'Person').subscribe((result) => {
expect(result.representationType).toEqual(MetadataRepresentationType.Item);
expect(result.getValue()).toEqual(metadatum.value);
expect((result as any).id).toEqual(relatedCreator.id);
done();
});
});
});
describe('when the metadata refers to a relationship leading to an error response', () => {
beforeEach(() => {
metadatum = parentItem.metadata['dc.creator'][1];
});
it('should return an authority controlled MetadatumRepresentation', (done) => {
service.resolveMetadataRepresentation(metadatum, parentItem, 'Person').subscribe((result) => {
expect(result.representationType).toEqual(MetadataRepresentationType.AuthorityControlled);
done();
});
});
});
});
}); });

View File

@@ -1,7 +1,7 @@
import { HttpHeaders } from '@angular/common/http'; import { HttpHeaders } from '@angular/common/http';
import { Inject, Injectable } from '@angular/core'; import { Inject, Injectable } from '@angular/core';
import { MemoizedSelector, select, Store } from '@ngrx/store'; import { MemoizedSelector, select, Store } from '@ngrx/store';
import { combineLatest as observableCombineLatest, Observable } from 'rxjs'; import { combineLatest as observableCombineLatest, Observable, of as observableOf } from 'rxjs';
import { distinctUntilChanged, filter, map, mergeMap, startWith, switchMap, take, tap } from 'rxjs/operators'; import { distinctUntilChanged, filter, map, mergeMap, startWith, switchMap, take, tap } from 'rxjs/operators';
import { import {
compareArraysUsingIds, PAGINATED_RELATIONS_TO_ITEMS_OPERATOR, compareArraysUsingIds, PAGINATED_RELATIONS_TO_ITEMS_OPERATOR,
@@ -46,6 +46,11 @@ import { PutData, PutDataImpl } from './base/put-data';
import { IdentifiableDataService } from './base/identifiable-data.service'; import { IdentifiableDataService } from './base/identifiable-data.service';
import { dataService } from './base/data-service.decorator'; import { dataService } from './base/data-service.decorator';
import { itemLinksToFollow } from '../../shared/utils/relation-query.utils'; import { itemLinksToFollow } from '../../shared/utils/relation-query.utils';
import { MetadataValue } from '../shared/metadata.models';
import { MetadataRepresentation } from '../shared/metadata-representation/metadata-representation.model';
import { MetadatumRepresentation } from '../shared/metadata-representation/metadatum/metadatum-representation.model';
import { ItemMetadataRepresentation } from '../shared/metadata-representation/item/item-metadata-representation.model';
import { DSpaceObject } from '../shared/dspace-object.model';
const relationshipListsStateSelector = (state: AppState) => state.relationshipLists; const relationshipListsStateSelector = (state: AppState) => state.relationshipLists;
@@ -550,4 +555,40 @@ export class RelationshipDataService extends IdentifiableDataService<Relationshi
searchBy(searchMethod: string, options?: FindListOptions, useCachedVersionIfAvailable?: boolean, reRequestOnStale?: boolean, ...linksToFollow: FollowLinkConfig<Relationship>[]): Observable<RemoteData<PaginatedList<Relationship>>> { searchBy(searchMethod: string, options?: FindListOptions, useCachedVersionIfAvailable?: boolean, reRequestOnStale?: boolean, ...linksToFollow: FollowLinkConfig<Relationship>[]): Observable<RemoteData<PaginatedList<Relationship>>> {
return this.searchData.searchBy(searchMethod, options, useCachedVersionIfAvailable, reRequestOnStale, ...linksToFollow); return this.searchData.searchBy(searchMethod, options, useCachedVersionIfAvailable, reRequestOnStale, ...linksToFollow);
} }
/**
* Resolve a {@link MetadataValue} into a {@link MetadataRepresentation} of the correct type
* @param metadatum {@link MetadataValue} to resolve
* @param parentItem Parent dspace object the metadata value belongs to
* @param itemType The type of item this metadata value represents (will only be used when no related item can be found, as a fallback)
*/
resolveMetadataRepresentation(metadatum: MetadataValue, parentItem: DSpaceObject, itemType: string): Observable<MetadataRepresentation> {
if (metadatum.isVirtual) {
return this.findById(metadatum.virtualValue, true, false, followLink('leftItem'), followLink('rightItem')).pipe(
getFirstSucceededRemoteData(),
switchMap((relRD: RemoteData<Relationship>) =>
observableCombineLatest(relRD.payload.leftItem, relRD.payload.rightItem).pipe(
filter(([leftItem, rightItem]) => leftItem.hasCompleted && rightItem.hasCompleted),
map(([leftItem, rightItem]) => {
if (!leftItem.hasSucceeded || !rightItem.hasSucceeded) {
return null;
} else if (rightItem.hasSucceeded && leftItem.payload.id === parentItem.id) {
return rightItem.payload;
} else if (rightItem.payload.id === parentItem.id) {
return leftItem.payload;
}
}),
map((item: Item) => {
if (hasValue(item)) {
return Object.assign(new ItemMetadataRepresentation(metadatum), item);
} else {
return Object.assign(new MetadatumRepresentation(itemType), metadatum);
}
})
)
));
} else {
return observableOf(Object.assign(new MetadatumRepresentation(itemType), metadatum));
}
}
} }

View File

@@ -8,7 +8,12 @@ import { Observable, of as observableOf, of } from 'rxjs';
import { RemoteData } from '../data/remote-data'; import { RemoteData } from '../data/remote-data';
import { Item } from '../shared/item.model'; import { Item } from '../shared/item.model';
import { ItemMock, MockBitstream1, MockBitstream3 } from '../../shared/mocks/item.mock'; import {
ItemMock,
MockBitstream1,
MockBitstream3,
MockBitstream2
} from '../../shared/mocks/item.mock';
import { createSuccessfulRemoteDataObject, createSuccessfulRemoteDataObject$ } from '../../shared/remote-data.utils'; import { createSuccessfulRemoteDataObject, createSuccessfulRemoteDataObject$ } from '../../shared/remote-data.utils';
import { PaginatedList } from '../data/paginated-list.model'; import { PaginatedList } from '../data/paginated-list.model';
import { Bitstream } from '../shared/bitstream.model'; import { Bitstream } from '../shared/bitstream.model';
@@ -24,6 +29,7 @@ import { HardRedirectService } from '../services/hard-redirect.service';
import { getMockStore } from '@ngrx/store/testing'; import { getMockStore } from '@ngrx/store/testing';
import { AddMetaTagAction, ClearMetaTagAction } from './meta-tag.actions'; import { AddMetaTagAction, ClearMetaTagAction } from './meta-tag.actions';
import { AuthorizationDataService } from '../data/feature-authorization/authorization-data.service'; import { AuthorizationDataService } from '../data/feature-authorization/authorization-data.service';
import { AppConfig } from '../../../config/app-config.interface';
describe('MetadataService', () => { describe('MetadataService', () => {
let metadataService: MetadataService; let metadataService: MetadataService;
@@ -44,6 +50,8 @@ describe('MetadataService', () => {
let router: Router; let router: Router;
let store; let store;
let appConfig: AppConfig;
const initialState = { 'core': { metaTag: { tagsInUse: ['title', 'description'] }}}; const initialState = { 'core': { metaTag: { tagsInUse: ['title', 'description'] }}};
@@ -86,6 +94,14 @@ describe('MetadataService', () => {
store = getMockStore({ initialState }); store = getMockStore({ initialState });
spyOn(store, 'dispatch'); spyOn(store, 'dispatch');
appConfig = {
item: {
bitstream: {
pageSize: 5
}
}
} as any;
metadataService = new MetadataService( metadataService = new MetadataService(
router, router,
translateService, translateService,
@@ -98,6 +114,7 @@ describe('MetadataService', () => {
rootService, rootService,
store, store,
hardRedirectService, hardRedirectService,
appConfig,
authorizationService authorizationService
); );
}); });
@@ -358,29 +375,66 @@ describe('MetadataService', () => {
}); });
})); }));
it('should link to first Bitstream with allowed format', fakeAsync(() => { describe(`when there's a bitstream with an allowed format on the first page`, () => {
const bitstreams = [MockBitstream3, MockBitstream3, MockBitstream1]; let bitstreams;
(bundleDataService.findByItemAndName as jasmine.Spy).and.returnValue(mockBundleRD$(bitstreams));
(bitstreamDataService.findListByHref as jasmine.Spy).and.returnValues(
...mockBitstreamPages$(bitstreams).map(bp => createSuccessfulRemoteDataObject$(bp)),
);
(metadataService as any).processRouteChange({ beforeEach(() => {
data: { bitstreams = [MockBitstream2, MockBitstream3, MockBitstream1];
value: { (bundleDataService.findByItemAndName as jasmine.Spy).and.returnValue(mockBundleRD$(bitstreams));
dso: createSuccessfulRemoteDataObject(ItemMock), (bitstreamDataService.findListByHref as jasmine.Spy).and.returnValues(
...mockBitstreamPages$(bitstreams).map(bp => createSuccessfulRemoteDataObject$(bp)),
);
});
it('should link to first Bitstream with allowed format', fakeAsync(() => {
(metadataService as any).processRouteChange({
data: {
value: {
dso: createSuccessfulRemoteDataObject(ItemMock),
}
} }
} });
}); tick();
tick(); expect(meta.addTag).toHaveBeenCalledWith({
expect(meta.addTag).toHaveBeenCalledWith({ name: 'citation_pdf_url',
name: 'citation_pdf_url', content: 'https://request.org/bitstreams/99b00f3c-1cc6-4689-8158-91965bee6b28/download'
content: 'https://request.org/bitstreams/cf9b0c8e-a1eb-4b65-afd0-567366448713/download' });
}); }));
}));
});
}); });
}); });
describe(`when there's no bitstream with an allowed format on the first page`, () => {
let bitstreams;
beforeEach(() => {
bitstreams = [MockBitstream1, MockBitstream3, MockBitstream2];
(bundleDataService.findByItemAndName as jasmine.Spy).and.returnValue(mockBundleRD$(bitstreams));
(bitstreamDataService.findListByHref as jasmine.Spy).and.returnValues(
...mockBitstreamPages$(bitstreams).map(bp => createSuccessfulRemoteDataObject$(bp)),
);
});
it(`shouldn't add a citation_pdf_url meta tag`, fakeAsync(() => {
(metadataService as any).processRouteChange({
data: {
value: {
dso: createSuccessfulRemoteDataObject(ItemMock),
}
}
});
tick();
expect(meta.addTag).not.toHaveBeenCalledWith({
name: 'citation_pdf_url',
content: 'https://request.org/bitstreams/99b00f3c-1cc6-4689-8158-91965bee6b28/download'
});
}));
});
describe('tagstore', () => { describe('tagstore', () => {
beforeEach(fakeAsync(() => { beforeEach(fakeAsync(() => {
(metadataService as any).processRouteChange({ (metadataService as any).processRouteChange({

View File

@@ -1,14 +1,21 @@
import { Injectable } from '@angular/core'; import { Injectable, Inject } from '@angular/core';
import { Meta, MetaDefinition, Title } from '@angular/platform-browser'; import { Meta, MetaDefinition, Title } from '@angular/platform-browser';
import { ActivatedRoute, NavigationEnd, Router } from '@angular/router'; import { ActivatedRoute, NavigationEnd, Router } from '@angular/router';
import { TranslateService } from '@ngx-translate/core'; import { TranslateService } from '@ngx-translate/core';
import { BehaviorSubject, combineLatest, EMPTY, Observable, of as observableOf } from 'rxjs'; import {
import { expand, filter, map, switchMap, take } from 'rxjs/operators'; BehaviorSubject,
combineLatest,
Observable,
of as observableOf,
concat as observableConcat,
EMPTY
} from 'rxjs';
import { filter, map, switchMap, take, mergeMap } from 'rxjs/operators';
import { hasNoValue, hasValue } from '../../shared/empty.util'; import { hasNoValue, hasValue, isNotEmpty } from '../../shared/empty.util';
import { DSONameService } from '../breadcrumbs/dso-name.service'; import { DSONameService } from '../breadcrumbs/dso-name.service';
import { BitstreamDataService } from '../data/bitstream-data.service'; import { BitstreamDataService } from '../data/bitstream-data.service';
import { BitstreamFormatDataService } from '../data/bitstream-format-data.service'; import { BitstreamFormatDataService } from '../data/bitstream-format-data.service';
@@ -37,6 +44,7 @@ import { coreSelector } from '../core.selectors';
import { CoreState } from '../core-state.model'; import { CoreState } from '../core-state.model';
import { AuthorizationDataService } from '../data/feature-authorization/authorization-data.service'; import { AuthorizationDataService } from '../data/feature-authorization/authorization-data.service';
import { getDownloadableBitstream } from '../shared/bitstream.operators'; import { getDownloadableBitstream } from '../shared/bitstream.operators';
import { APP_CONFIG, AppConfig } from '../../../config/app-config.interface';
/** /**
* The base selector function to select the metaTag section in the store * The base selector function to select the metaTag section in the store
@@ -87,6 +95,7 @@ export class MetadataService {
private rootService: RootDataService, private rootService: RootDataService,
private store: Store<CoreState>, private store: Store<CoreState>,
private hardRedirectService: HardRedirectService, private hardRedirectService: HardRedirectService,
@Inject(APP_CONFIG) private appConfig: AppConfig,
private authorizationService: AuthorizationDataService private authorizationService: AuthorizationDataService
) { ) {
} }
@@ -298,7 +307,13 @@ export class MetadataService {
true, true,
true, true,
followLink('primaryBitstream'), followLink('primaryBitstream'),
followLink('bitstreams', {}, followLink('format')), followLink('bitstreams', {
findListOptions: {
// limit the number of bitstreams used to find the citation pdf url to the number
// shown by default on an item page
elementsPerPage: this.appConfig.item.bitstream.pageSize
}
}, followLink('format')),
).pipe( ).pipe(
getFirstSucceededRemoteDataPayload(), getFirstSucceededRemoteDataPayload(),
switchMap((bundle: Bundle) => switchMap((bundle: Bundle) =>
@@ -363,64 +378,45 @@ export class MetadataService {
} }
/** /**
* For Items with more than one Bitstream (and no primary Bitstream), link to the first Bitstream with a MIME type * For Items with more than one Bitstream (and no primary Bitstream), link to the first Bitstream
* with a MIME type.
*
* Note this will only check the current page (page size determined item.bitstream.pageSize in the
* config) of bitstreams for performance reasons.
* See https://github.com/DSpace/DSpace/issues/8648 for more info
*
* included in {@linkcode CITATION_PDF_URL_MIMETYPES} * included in {@linkcode CITATION_PDF_URL_MIMETYPES}
* @param bitstreamRd * @param bitstreamRd
* @private * @private
*/ */
private getFirstAllowedFormatBitstreamLink(bitstreamRd: RemoteData<PaginatedList<Bitstream>>): Observable<string> { private getFirstAllowedFormatBitstreamLink(bitstreamRd: RemoteData<PaginatedList<Bitstream>>): Observable<string> {
return observableOf(bitstreamRd.payload).pipe( if (hasValue(bitstreamRd.payload) && isNotEmpty(bitstreamRd.payload.page)) {
// Because there can be more than one page of bitstreams, this expand operator // Retrieve the formats of all bitstreams in the page sequentially
// will retrieve them in turn. Due to the take(1) at the bottom, it will only return observableConcat(
// retrieve pages until a match is found ...bitstreamRd.payload.page.map((bitstream: Bitstream) => bitstream.format.pipe(
expand((paginatedList: PaginatedList<Bitstream>) => { getFirstSucceededRemoteDataPayload(),
if (hasNoValue(paginatedList.next)) { // Keep the original bitstream, because it, not the format, is what we'll need
// If there's no next page, stop. // for the link at the end
return EMPTY; map((format: BitstreamFormat) => [bitstream, format])
} else { ))
// Otherwise retrieve the next page ).pipe(
return this.bitstreamDataService.findListByHref( // Verify that the bitstream is downloadable
paginatedList.next, mergeMap(([bitstream, format]: [Bitstream, BitstreamFormat]) => observableOf(bitstream).pipe(
undefined, getDownloadableBitstream(this.authorizationService),
true, map((bit: Bitstream) => [bit, format])
true, )),
followLink('format') // Filter out only pairs with whitelisted formats and non-null bitstreams, null from download check
).pipe( filter(([bitstream, format]: [Bitstream, BitstreamFormat]) =>
getFirstCompletedRemoteData(), hasValue(format) && hasValue(bitstream) && this.CITATION_PDF_URL_MIMETYPES.includes(format.mimetype)),
map((next: RemoteData<PaginatedList<Bitstream>>) => { // We only need 1
if (hasValue(next.payload)) { take(1),
return next.payload; // Emit the link of the match
} else { // tap((v) => console.log('result', v)),
return EMPTY; map(([bitstream, ]: [Bitstream, BitstreamFormat]) => getBitstreamDownloadRoute(bitstream))
} );
}) } else {
); return EMPTY;
} }
}),
// Return the array of bitstreams inside each paginated list
map((paginatedList: PaginatedList<Bitstream>) => paginatedList.page),
// Emit the bitstreams in the list one at a time
switchMap((bitstreams: Bitstream[]) => bitstreams),
// Retrieve the format for each bitstream
switchMap((bitstream: Bitstream) => bitstream.format.pipe(
getFirstSucceededRemoteDataPayload(),
// Keep the original bitstream, because it, not the format, is what we'll need
// for the link at the end
map((format: BitstreamFormat) => [bitstream, format])
)),
// Check if bitstream downloadable
switchMap(([bitstream, format]: [Bitstream, BitstreamFormat]) => observableOf(bitstream).pipe(
getDownloadableBitstream(this.authorizationService),
map((bit: Bitstream) => [bit, format])
)),
// Filter out only pairs with whitelisted formats and non-null bitstreams, null from download check
filter(([bitstream, format]: [Bitstream, BitstreamFormat]) =>
hasValue(format) && hasValue(bitstream) && this.CITATION_PDF_URL_MIMETYPES.includes(format.mimetype)),
// We only need 1
take(1),
// Emit the link of the match
map(([bitstream, ]: [Bitstream, BitstreamFormat]) => getBitstreamDownloadRoute(bitstream))
);
} }
/** /**

View File

@@ -226,7 +226,7 @@ export const metadataFieldsToString = () =>
map((schema: MetadataSchema) => ({ field, schema })) map((schema: MetadataSchema) => ({ field, schema }))
); );
}); });
return observableCombineLatest(fieldSchemaArray); return isNotEmpty(fieldSchemaArray) ? observableCombineLatest(fieldSchemaArray) : [[]];
}), }),
map((fieldSchemaArray: { field: MetadataField, schema: MetadataSchema }[]): string[] => { map((fieldSchemaArray: { field: MetadataField, schema: MetadataSchema }[]): string[] => {
return fieldSchemaArray.map((fieldSchema: { field: MetadataField, schema: MetadataSchema }) => fieldSchema.schema.prefix + '.' + fieldSchema.field.toString()); return fieldSchemaArray.map((fieldSchema: { field: MetadataField, schema: MetadataSchema }) => fieldSchema.schema.prefix + '.' + fieldSchema.field.toString());

View File

@@ -0,0 +1,15 @@
<div class="flex-grow-1 ds-drop-list h-100" [class.disabled]="(draggingMdField$ | async) && (draggingMdField$ | async) !== mdField" cdkDropList (cdkDropListDropped)="drop($event)" role="table">
<ds-dso-edit-metadata-value-headers role="presentation" [dsoType]="dsoType"></ds-dso-edit-metadata-value-headers>
<ds-dso-edit-metadata-value *ngFor="let mdValue of form.fields[mdField]; let idx = index" role="presentation"
[dso]="dso"
[mdValue]="mdValue"
[dsoType]="dsoType"
[saving$]="saving$"
[isOnlyValue]="form.fields[mdField].length === 1"
(edit)="mdValue.editing = true"
(confirm)="mdValue.confirmChanges($event); form.resetReinstatable(); valueSaved.emit()"
(remove)="mdValue.change === DsoEditMetadataChangeTypeEnum.ADD ? form.remove(mdField, idx) : mdValue.change = DsoEditMetadataChangeTypeEnum.REMOVE; form.resetReinstatable(); valueSaved.emit()"
(undo)="mdValue.change === DsoEditMetadataChangeTypeEnum.ADD ? form.remove(mdField, idx) : mdValue.discard(); valueSaved.emit()"
(dragging)="$event ? draggingMdField$.next(mdField) : draggingMdField$.next(null)">
</ds-dso-edit-metadata-value>
</div>

View File

@@ -0,0 +1,7 @@
.ds-drop-list {
background-color: var(--bs-gray-500);
&.disabled {
opacity: 0.3;
}
}

View File

@@ -0,0 +1,135 @@
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 { DsoEditMetadataFieldValuesComponent } from './dso-edit-metadata-field-values.component';
import { DsoEditMetadataForm } from '../dso-edit-metadata-form';
import { DSpaceObject } from '../../../core/shared/dspace-object.model';
import { MetadataValue } from '../../../core/shared/metadata.models';
import { of } from 'rxjs/internal/observable/of';
import { BehaviorSubject } from 'rxjs/internal/BehaviorSubject';
import { By } from '@angular/platform-browser';
describe('DsoEditMetadataFieldValuesComponent', () => {
let component: DsoEditMetadataFieldValuesComponent;
let fixture: ComponentFixture<DsoEditMetadataFieldValuesComponent>;
let form: DsoEditMetadataForm;
let dso: DSpaceObject;
let mdField: string;
let draggingMdField$: BehaviorSubject<string>;
beforeEach(waitForAsync(() => {
dso = Object.assign(new DSpaceObject(), {
metadata: {
'dc.title': [
Object.assign(new MetadataValue(), {
value: 'Test Title',
language: 'en',
place: 0,
}),
],
'dc.subject': [
Object.assign(new MetadataValue(), {
value: 'Subject One',
language: 'en',
place: 0,
}),
Object.assign(new MetadataValue(), {
value: 'Subject Two',
language: 'en',
place: 1,
}),
Object.assign(new MetadataValue(), {
value: 'Subject Three',
language: 'en',
place: 2,
}),
],
},
});
form = new DsoEditMetadataForm(dso.metadata);
mdField = 'dc.subject';
draggingMdField$ = new BehaviorSubject<string>(null);
TestBed.configureTestingModule({
declarations: [DsoEditMetadataFieldValuesComponent, VarDirective],
imports: [TranslateModule.forRoot(), RouterTestingModule.withRoutes([])],
providers: [
],
schemas: [NO_ERRORS_SCHEMA]
}).compileComponents();
}));
beforeEach(() => {
fixture = TestBed.createComponent(DsoEditMetadataFieldValuesComponent);
component = fixture.componentInstance;
component.dso = dso;
component.form = form;
component.mdField = mdField;
component.saving$ = of(false);
component.draggingMdField$ = draggingMdField$;
fixture.detectChanges();
});
describe('when draggingMdField$ emits a value equal to mdField', () => {
beforeEach(() => {
draggingMdField$.next(mdField);
fixture.detectChanges();
});
it('should not disable the list', () => {
expect(fixture.debugElement.query(By.css('.ds-drop-list.disabled'))).toBeNull();
});
});
describe('when draggingMdField$ emits a value different to mdField', () => {
beforeEach(() => {
draggingMdField$.next(`${mdField}.fake`);
fixture.detectChanges();
});
it('should disable the list', () => {
expect(fixture.debugElement.query(By.css('.ds-drop-list.disabled'))).toBeTruthy();
});
});
describe('when draggingMdField$ emits null', () => {
beforeEach(() => {
draggingMdField$.next(null);
fixture.detectChanges();
});
it('should not disable the list', () => {
expect(fixture.debugElement.query(By.css('.ds-drop-list.disabled'))).toBeNull();
});
});
describe('dropping a value on a different index', () => {
beforeEach(() => {
component.drop(Object.assign({
previousIndex: 0,
currentIndex: 2,
}));
});
it('should physically move the relevant metadata value within the form', () => {
expect(form.fields[mdField][0].newValue.value).toEqual('Subject Two');
expect(form.fields[mdField][1].newValue.value).toEqual('Subject Three');
expect(form.fields[mdField][2].newValue.value).toEqual('Subject One');
});
it('should update the metadata values their new place to match the new physical order', () => {
expect(form.fields[mdField][0].newValue.place).toEqual(0);
expect(form.fields[mdField][1].newValue.place).toEqual(1);
expect(form.fields[mdField][2].newValue.place).toEqual(2);
});
it('should maintain the metadata values their original place in their original value so it can be used later to determine the patch operations', () => {
expect(form.fields[mdField][0].originalValue.place).toEqual(1);
expect(form.fields[mdField][1].originalValue.place).toEqual(2);
expect(form.fields[mdField][2].originalValue.place).toEqual(0);
});
});
});

View File

@@ -0,0 +1,81 @@
import { Component, EventEmitter, Input, Output } from '@angular/core';
import { DsoEditMetadataChangeType, DsoEditMetadataForm, DsoEditMetadataValue } from '../dso-edit-metadata-form';
import { Observable } from 'rxjs/internal/Observable';
import { DSpaceObject } from '../../../core/shared/dspace-object.model';
import { BehaviorSubject } from 'rxjs/internal/BehaviorSubject';
import { CdkDragDrop, moveItemInArray } from '@angular/cdk/drag-drop';
@Component({
selector: 'ds-dso-edit-metadata-field-values',
styleUrls: ['./dso-edit-metadata-field-values.component.scss'],
templateUrl: './dso-edit-metadata-field-values.component.html',
})
/**
* Component displaying table rows for each value for a certain metadata field within a form
*/
export class DsoEditMetadataFieldValuesComponent {
/**
* The parent {@link DSpaceObject} to display a metadata form for
* Also used to determine metadata-representations in case of virtual metadata
*/
@Input() dso: DSpaceObject;
/**
* A dynamic form object containing all information about the metadata and the changes made to them, see {@link DsoEditMetadataForm}
*/
@Input() form: DsoEditMetadataForm;
/**
* Metadata field to display values for
*/
@Input() mdField: string;
/**
* Type of DSO we're displaying values for
* Determines i18n messages
*/
@Input() dsoType: string;
/**
* Observable to check if the form is being saved or not
*/
@Input() saving$: Observable<boolean>;
/**
* Tracks for which metadata-field a drag operation is taking place
* Null when no drag is currently happening for any field
*/
@Input() draggingMdField$: BehaviorSubject<string>;
/**
* Emit when the value has been saved within the form
*/
@Output() valueSaved: EventEmitter<any> = new EventEmitter<any>();
/**
* The DsoEditMetadataChangeType enumeration for access in the component's template
* @type {DsoEditMetadataChangeType}
*/
public DsoEditMetadataChangeTypeEnum = DsoEditMetadataChangeType;
/**
* Drop a value into a new position
* Update the form's value array for the current field to match the dropped position
* Update the values their place property to match the new order
* Send an update to the parent
* @param event
*/
drop(event: CdkDragDrop<any>) {
const dragIndex = event.previousIndex;
const dropIndex = event.currentIndex;
// Move the value within its field
moveItemInArray(this.form.fields[this.mdField], dragIndex, dropIndex);
// Update all the values in this field their place property
this.form.fields[this.mdField].forEach((value: DsoEditMetadataValue, index: number) => {
value.newValue.place = index;
value.confirmChanges();
});
// Update the form statuses
this.form.resetReinstatable();
this.valueSaved.emit();
}
}

View File

@@ -0,0 +1,275 @@
import { DsoEditMetadataChangeType, DsoEditMetadataForm } from './dso-edit-metadata-form';
import { DSpaceObject } from '../../core/shared/dspace-object.model';
import { MetadataValue } from '../../core/shared/metadata.models';
describe('DsoEditMetadataForm', () => {
let form: DsoEditMetadataForm;
let dso: DSpaceObject;
beforeEach(() => {
dso = Object.assign(new DSpaceObject(), {
metadata: {
'dc.title': [
Object.assign(new MetadataValue(), {
value: 'Test Title',
language: 'en',
place: 0,
}),
],
'dc.subject': [
Object.assign(new MetadataValue(), {
value: 'Subject One',
language: 'en',
place: 0,
}),
Object.assign(new MetadataValue(), {
value: 'Subject Two',
language: 'en',
place: 1,
}),
Object.assign(new MetadataValue(), {
value: 'Subject Three',
language: 'en',
place: 2,
}),
],
},
});
form = new DsoEditMetadataForm(dso.metadata);
});
describe('adding a new value', () => {
beforeEach(() => {
form.add();
});
it('should add an empty value to \"newValue\" with no place yet and editing set to true', () => {
expect(form.newValue).toBeDefined();
expect(form.newValue.originalValue.place).toBeUndefined();
expect(form.newValue.newValue.place).toBeUndefined();
expect(form.newValue.editing).toBeTrue();
});
it('should not mark the form as changed yet', () => {
expect(form.hasChanges()).toEqual(false);
});
describe('and assigning a value and metadata field to it', () => {
let mdField: string;
let value: string;
let expectedPlace: number;
beforeEach(() => {
mdField = 'dc.subject';
value = 'Subject Four';
form.newValue.newValue.value = value;
form.setMetadataField(mdField);
expectedPlace = form.fields[mdField].length - 1;
});
it('should add the new value to the values of the relevant field', () => {
expect(form.fields[mdField][expectedPlace].newValue.value).toEqual(value);
});
it('should set its editing flag to false', () => {
expect(form.fields[mdField][expectedPlace].editing).toBeFalse();
});
it('should set both its original and new place to match its position in the value array', () => {
expect(form.fields[mdField][expectedPlace].newValue.place).toEqual(expectedPlace);
expect(form.fields[mdField][expectedPlace].originalValue.place).toEqual(expectedPlace);
});
it('should clear \"newValue\"', () => {
expect(form.newValue).toBeUndefined();
});
it('should mark the form as changed', () => {
expect(form.hasChanges()).toEqual(true);
});
describe('discard', () => {
beforeEach(() => {
form.discard();
});
it('should remove the new value', () => {
expect(form.fields[mdField][expectedPlace]).toBeUndefined();
});
it('should mark the form as unchanged again', () => {
expect(form.hasChanges()).toEqual(false);
});
describe('reinstate', () => {
beforeEach(() => {
form.reinstate();
});
it('should re-add the new value', () => {
expect(form.fields[mdField][expectedPlace].newValue.value).toEqual(value);
});
it('should mark the form as changed once again', () => {
expect(form.hasChanges()).toEqual(true);
});
});
});
});
});
describe('removing a value entirely (not just marking deleted)', () => {
it('should remove the value on the correct index', () => {
form.remove('dc.subject', 1);
expect(form.fields['dc.subject'].length).toEqual(2);
expect(form.fields['dc.subject'][0].newValue.value).toEqual('Subject One');
expect(form.fields['dc.subject'][1].newValue.value).toEqual('Subject Three');
});
});
describe('moving a value', () => {
beforeEach(() => {
form.fields['dc.subject'][0].newValue.place = form.fields['dc.subject'][1].originalValue.place;
form.fields['dc.subject'][1].newValue.place = form.fields['dc.subject'][0].originalValue.place;
form.fields['dc.subject'][0].confirmChanges();
form.fields['dc.subject'][1].confirmChanges();
});
it('should mark the value as changed', () => {
expect(form.fields['dc.subject'][0].hasChanges()).toEqual(true);
expect(form.fields['dc.subject'][1].hasChanges()).toEqual(true);
});
it('should mark the form as changed', () => {
expect(form.hasChanges()).toEqual(true);
});
describe('discard', () => {
beforeEach(() => {
form.discard();
});
it('should reset the moved values their places to their original values', () => {
expect(form.fields['dc.subject'][0].newValue.place).toEqual(form.fields['dc.subject'][0].originalValue.place);
expect(form.fields['dc.subject'][1].newValue.place).toEqual(form.fields['dc.subject'][1].originalValue.place);
});
it('should mark the form as unchanged again', () => {
expect(form.hasChanges()).toEqual(false);
});
describe('reinstate', () => {
beforeEach(() => {
form.reinstate();
});
it('should move the values to their new places again', () => {
expect(form.fields['dc.subject'][0].newValue.place).toEqual(form.fields['dc.subject'][1].originalValue.place);
expect(form.fields['dc.subject'][1].newValue.place).toEqual(form.fields['dc.subject'][0].originalValue.place);
});
it('should mark the form as changed once again', () => {
expect(form.hasChanges()).toEqual(true);
});
});
});
});
describe('marking a value deleted', () => {
beforeEach(() => {
form.fields['dc.title'][0].change = DsoEditMetadataChangeType.REMOVE;
});
it('should mark the value as changed', () => {
expect(form.fields['dc.title'][0].hasChanges()).toEqual(true);
});
it('should mark the form as changed', () => {
expect(form.hasChanges()).toEqual(true);
});
describe('discard', () => {
beforeEach(() => {
form.discard();
});
it('should remove the deleted mark from the value', () => {
expect(form.fields['dc.title'][0].change).toBeUndefined();
});
it('should mark the form as unchanged again', () => {
expect(form.hasChanges()).toEqual(false);
});
describe('reinstate', () => {
beforeEach(() => {
form.reinstate();
});
it('should re-mark the value as deleted', () => {
expect(form.fields['dc.title'][0].change).toEqual(DsoEditMetadataChangeType.REMOVE);
});
it('should mark the form as changed once again', () => {
expect(form.hasChanges()).toEqual(true);
});
});
});
});
describe('editing a value', () => {
const value = 'New title';
beforeEach(() => {
form.fields['dc.title'][0].editing = true;
form.fields['dc.title'][0].newValue.value = value;
});
it('should not mark the form as changed yet', () => {
expect(form.hasChanges()).toEqual(false);
});
describe('and confirming the changes', () => {
beforeEach(() => {
form.fields['dc.title'][0].confirmChanges(true);
});
it('should mark the value as changed', () => {
expect(form.fields['dc.title'][0].hasChanges()).toEqual(true);
});
it('should mark the form as changed', () => {
expect(form.hasChanges()).toEqual(true);
});
describe('discard', () => {
beforeEach(() => {
form.discard();
});
it('should reset the changed value to its original value', () => {
expect(form.fields['dc.title'][0].newValue.value).toEqual(form.fields['dc.title'][0].originalValue.value);
});
it('should mark the form as unchanged again', () => {
expect(form.hasChanges()).toEqual(false);
});
describe('reinstate', () => {
beforeEach(() => {
form.reinstate();
});
it('should put the changed value back in place', () => {
expect(form.fields['dc.title'][0].newValue.value).toEqual(value);
});
it('should mark the form as changed once again', () => {
expect(form.hasChanges()).toEqual(true);
});
});
});
});
});
});

View File

@@ -0,0 +1,453 @@
/* eslint-disable max-classes-per-file */
import { MetadataMap, MetadataValue } from '../../core/shared/metadata.models';
import { hasNoValue, hasValue, isEmpty, isNotEmpty } from '../../shared/empty.util';
import { MoveOperation, Operation } from 'fast-json-patch';
import { MetadataPatchReplaceOperation } from '../../core/data/object-updates/patch-operation-service/operations/metadata/metadata-patch-replace-operation.model';
import { MetadataPatchRemoveOperation } from '../../core/data/object-updates/patch-operation-service/operations/metadata/metadata-patch-remove-operation.model';
import { MetadataPatchAddOperation } from '../../core/data/object-updates/patch-operation-service/operations/metadata/metadata-patch-add-operation.model';
import { ArrayMoveChangeAnalyzer } from '../../core/data/array-move-change-analyzer.service';
import { MetadataPatchMoveOperation } from '../../core/data/object-updates/patch-operation-service/operations/metadata/metadata-patch-move-operation.model';
/**
* Enumeration for the type of change occurring on a metadata value
*/
export enum DsoEditMetadataChangeType {
UPDATE = 1,
ADD = 2,
REMOVE = 3
}
/**
* Class holding information about a metadata value and its changes within an edit form
*/
export class DsoEditMetadataValue {
/**
* The original metadata value (should stay the same!) used to compare changes with
*/
originalValue: MetadataValue;
/**
* The new value, dynamically changing
*/
newValue: MetadataValue;
/**
* A value that can be used to undo any discarding that took place
*/
reinstatableValue: MetadataValue;
/**
* Whether or not this value is currently being edited or not
*/
editing = false;
/**
* The type of change that's taking place on this metadata value
* Empty if no changes are made
*/
change: DsoEditMetadataChangeType;
/**
* A flag to keep track if the value has been reordered (place has changed)
*/
reordered = false;
/**
* A type or change that can be used to undo any discarding that took place
*/
reinstatableChange: DsoEditMetadataChangeType;
constructor(value: MetadataValue, added = false) {
this.originalValue = value;
this.newValue = Object.assign(new MetadataValue(), value);
if (added) {
this.change = DsoEditMetadataChangeType.ADD;
this.editing = true;
}
}
/**
* Save the current changes made to the metadata value
* This will set the type of change to UPDATE if the new metadata value's value and/or language are different from
* the original value
* It will also set the editing flag to false
*/
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)) {
this.change = DsoEditMetadataChangeType.UPDATE;
} else {
this.change = undefined;
}
}
if (finishEditing) {
this.editing = false;
}
}
/**
* Returns if the current value contains changes or not
* If the metadata value contains changes, but they haven't been confirmed yet through confirmChanges(), this might
* return false (which is desired)
*/
hasChanges(): boolean {
return hasValue(this.change) || this.reordered;
}
/**
* Discard the current changes and mark the value and change type re-instatable by storing them in their relevant
* properties
*/
discardAndMarkReinstatable(): void {
if (this.change === DsoEditMetadataChangeType.UPDATE || this.reordered) {
this.reinstatableValue = this.newValue;
}
this.reinstatableChange = this.change;
this.discard(false);
}
/**
* Discard the current changes
* Call discardAndMarkReinstatable() instead, if the discard should be re-instatable
*/
discard(keepPlace = true): void {
this.change = undefined;
const place = this.newValue.place;
this.newValue = Object.assign(new MetadataValue(), this.originalValue);
if (keepPlace) {
this.newValue.place = place;
}
this.confirmChanges(true);
}
/**
* Re-instate (undo) the last discard by replacing the value and change type with their reinstate properties (if present)
*/
reinstate(): void {
if (hasValue(this.reinstatableValue)) {
this.newValue = this.reinstatableValue;
this.reinstatableValue = undefined;
}
if (hasValue(this.reinstatableChange)) {
this.change = this.reinstatableChange;
this.reinstatableChange = undefined;
}
this.confirmChanges();
}
/**
* Returns if either the value or change type have a re-instatable property
* This will be the case if a discard has taken place that undid changes to the value or type
*/
isReinstatable(): boolean {
return hasValue(this.reinstatableValue) || hasValue(this.reinstatableChange);
}
/**
* Reset the state of the re-instatable properties
*/
resetReinstatable() {
this.reinstatableValue = undefined;
this.reinstatableChange = undefined;
}
}
/**
* Class holding information about the metadata of a DSpaceObject and its changes within an edit form
*/
export class DsoEditMetadataForm {
/**
* List of original metadata field keys (before any changes took place)
*/
originalFieldKeys: string[];
/**
* List of current metadata field keys (includes new fields for values added by the user)
*/
fieldKeys: string[];
/**
* Current state of the form
* Key: Metadata field
* Value: List of {@link DsoEditMetadataValue}s for the metadata field
*/
fields: {
[mdField: string]: DsoEditMetadataValue[],
};
/**
* A map of previously added metadata values before a discard of the form took place
* This can be used to re-instate the entire form to before the discard taking place
*/
reinstatableNewValues: {
[mdField: string]: DsoEditMetadataValue[],
};
/**
* A (temporary) new metadata value added by the user, not belonging to a metadata field yet
* This value will be finalised and added to a field using setMetadataField()
*/
newValue: DsoEditMetadataValue;
constructor(metadata: MetadataMap) {
this.originalFieldKeys = [];
this.fieldKeys = [];
this.fields = {};
this.reinstatableNewValues = {};
Object.entries(metadata).forEach(([mdField, values]: [string, MetadataValue[]]) => {
this.originalFieldKeys.push(mdField);
this.fieldKeys.push(mdField);
this.setValuesForFieldSorted(mdField, values.map((value: MetadataValue) => new DsoEditMetadataValue(value)));
});
this.sortFieldKeys();
}
/**
* Add a new temporary value for the user to edit
*/
add(): void {
if (hasNoValue(this.newValue)) {
this.newValue = new DsoEditMetadataValue(new MetadataValue(), true);
}
}
/**
* Add the temporary value to a metadata field
* Clear the temporary value afterwards
* @param mdField
*/
setMetadataField(mdField: string): void {
this.newValue.editing = false;
this.addValueToField(this.newValue, mdField);
// Set the place property to match the new value's position within its field
const place = this.fields[mdField].length - 1;
this.fields[mdField][place].originalValue.place = place;
this.fields[mdField][place].newValue.place = place;
this.newValue = undefined;
}
/**
* Add a value to a metadata field within the map
* @param value
* @param mdField
* @private
*/
private addValueToField(value: DsoEditMetadataValue, mdField: string): void {
if (isEmpty(this.fields[mdField])) {
this.fieldKeys.push(mdField);
this.sortFieldKeys();
this.fields[mdField] = [];
}
this.fields[mdField].push(value);
}
/**
* Remove a value from a metadata field on a given index (this actually removes the value, not just marking it deleted)
* @param mdField
* @param index
*/
remove(mdField: string, index: number): void {
if (isNotEmpty(this.fields[mdField])) {
this.fields[mdField].splice(index, 1);
if (this.fields[mdField].length === 0) {
this.fieldKeys.splice(this.fieldKeys.indexOf(mdField), 1);
delete this.fields[mdField];
}
}
}
/**
* Returns if at least one value within the form contains a change
*/
hasChanges(): boolean {
return Object.values(this.fields).some((values: DsoEditMetadataValue[]) => values.some((value: DsoEditMetadataValue) => value.hasChanges()));
}
/**
* Check if a metadata field contains changes within its order (place property of values)
* @param mdField
*/
hasOrderChanges(mdField: string): boolean {
return this.fields[mdField].some((value: DsoEditMetadataValue) => value.originalValue.place !== value.newValue.place);
}
/**
* Discard all changes within the form and store their current values within re-instatable properties so they can be
* undone afterwards
*/
discard(): void {
this.resetReinstatable();
// Discard changes from each value from each field
Object.entries(this.fields).forEach(([field, values]: [string, DsoEditMetadataValue[]]) => {
let removeFromIndex = -1;
values.forEach((value: DsoEditMetadataValue, index: number) => {
if (value.change === DsoEditMetadataChangeType.ADD) {
if (isEmpty(this.reinstatableNewValues[field])) {
this.reinstatableNewValues[field] = [];
}
this.reinstatableNewValues[field].push(value);
if (removeFromIndex === -1) {
removeFromIndex = index;
}
} else {
value.discardAndMarkReinstatable();
}
});
if (removeFromIndex > -1) {
this.fields[field].splice(removeFromIndex, this.fields[field].length - removeFromIndex);
}
});
// Delete new metadata fields
this.fieldKeys.forEach((field: string) => {
if (this.originalFieldKeys.indexOf(field) < 0) {
delete this.fields[field];
}
});
this.fieldKeys = [...this.originalFieldKeys];
this.sortFieldKeys();
// Reset the order of values within their fields to match their place property
this.fieldKeys.forEach((field: string) => {
this.setValuesForFieldSorted(field, this.fields[field]);
});
}
/**
* Reset the order of values within a metadata field to their original places
* Update the actual array to match the place properties
* @param mdField
*/
resetOrder(mdField: string) {
this.fields[mdField].forEach((value: DsoEditMetadataValue) => {
value.newValue.place = value.originalValue.place;
value.confirmChanges();
});
this.setValuesForFieldSorted(mdField, this.fields[mdField]);
}
/**
* Sort fieldKeys alphabetically
* Should be called whenever a field is added to ensure the alphabetical order is kept
*/
sortFieldKeys() {
this.fieldKeys.sort((a: string, b: string) => a.localeCompare(b));
}
/**
* Undo any previously discarded changes
*/
reinstate(): void {
// Reinstate each value
Object.values(this.fields).forEach((values: DsoEditMetadataValue[]) => {
values.forEach((value: DsoEditMetadataValue) => {
value.reinstate();
});
});
// Re-add new values
Object.entries(this.reinstatableNewValues).forEach(([field, values]: [string, DsoEditMetadataValue[]]) => {
values.forEach((value: DsoEditMetadataValue) => {
this.addValueToField(value, field);
});
});
// Reset the order of values within their fields to match their place property
this.fieldKeys.forEach((field: string) => {
this.setValuesForFieldSorted(field, this.fields[field]);
});
this.reinstatableNewValues = {};
}
/**
* Returns if at least one value contains a re-instatable property, meaning a discard can be reversed
*/
isReinstatable(): boolean {
return isNotEmpty(this.reinstatableNewValues) ||
Object.values(this.fields)
.some((values: DsoEditMetadataValue[]) => values
.some((value: DsoEditMetadataValue) => value.isReinstatable()));
}
/**
* Reset the state of the re-instatable properties and values
*/
resetReinstatable(): void {
this.reinstatableNewValues = {};
Object.values(this.fields).forEach((values: DsoEditMetadataValue[]) => {
values.forEach((value: DsoEditMetadataValue) => {
value.resetReinstatable();
});
});
}
/**
* Set the values of a metadata field and sort them by their newValue's place property
* @param mdField
* @param values
*/
private setValuesForFieldSorted(mdField: string, values: DsoEditMetadataValue[]) {
this.fields[mdField] = values.sort((a: DsoEditMetadataValue, b: DsoEditMetadataValue) => a.newValue.place - b.newValue.place);
}
/**
* Get the json PATCH operations for the current changes within this form
* For each metadata field, it'll return operations in the following order: replace, remove (from last to first place), add and move
* This order is important, as each operation is executed in succession of the previous one
*/
getOperations(moveAnalyser: ArrayMoveChangeAnalyzer<number>): Operation[] {
const operations: Operation[] = [];
Object.entries(this.fields).forEach(([field, values]: [string, DsoEditMetadataValue[]]) => {
const replaceOperations: MetadataPatchReplaceOperation[] = [];
const removeOperations: MetadataPatchRemoveOperation[] = [];
const addOperations: MetadataPatchAddOperation[] = [];
[...values]
.sort((a: DsoEditMetadataValue, b: DsoEditMetadataValue) => a.originalValue.place - b.originalValue.place)
.forEach((value: DsoEditMetadataValue) => {
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) {
replaceOperations.push(new MetadataPatchReplaceOperation(field, value.originalValue.place, {
value: value.newValue.value,
language: value.newValue.language,
}));
}
} else if (value.change === DsoEditMetadataChangeType.REMOVE) {
removeOperations.push(new MetadataPatchRemoveOperation(field, value.originalValue.place));
} else if (value.change === DsoEditMetadataChangeType.ADD) {
addOperations.push(new MetadataPatchAddOperation(field, {
value: value.newValue.value,
language: value.newValue.language,
}));
} else {
console.warn('Illegal metadata change state detected for', value);
}
}
});
operations.push(...replaceOperations
.map((operation: MetadataPatchReplaceOperation) => operation.toOperation()));
operations.push(...removeOperations
// Sort remove operations backwards first, because they get executed in order. This avoids one removal affecting the next.
.sort((a: MetadataPatchRemoveOperation, b: MetadataPatchRemoveOperation) => b.place - a.place)
.map((operation: MetadataPatchRemoveOperation) => operation.toOperation()));
operations.push(...addOperations
.map((operation: MetadataPatchAddOperation) => operation.toOperation()));
});
// Calculate and add the move operations that need to happen in order to move value from their old place to their new within the field
// This uses an ArrayMoveChangeAnalyzer
Object.entries(this.fields).forEach(([field, values]: [string, DsoEditMetadataValue[]]) => {
// Exclude values marked for removal, because operations are executed in order (remove first, then move)
const valuesWithoutRemoved = values.filter((value: DsoEditMetadataValue) => value.change !== DsoEditMetadataChangeType.REMOVE);
const moveOperations = moveAnalyser
.diff(
[...valuesWithoutRemoved]
.sort((a: DsoEditMetadataValue, b: DsoEditMetadataValue) => a.originalValue.place - b.originalValue.place)
.map((value: DsoEditMetadataValue) => value.originalValue.place),
[...valuesWithoutRemoved]
.sort((a: DsoEditMetadataValue, b: DsoEditMetadataValue) => a.newValue.place - b.newValue.place)
.map((value: DsoEditMetadataValue) => value.originalValue.place))
.map((operation: MoveOperation) => new MetadataPatchMoveOperation(field, +operation.from.substr(1), +operation.path.substr(1)).toOperation());
operations.push(...moveOperations);
});
return operations;
}
}

View File

@@ -0,0 +1,10 @@
<div class="d-flex flex-row ds-field-row ds-header-row">
<div class="lbl-cell">{{ dsoType + '.edit.metadata.headers.field' | translate }}</div>
<div class="flex-grow-1">
<div class="d-flex flex-row">
<div class="flex-grow-1 ds-flex-cell ds-value-cell"><b class="dont-break-out preserve-line-breaks">{{ dsoType + '.edit.metadata.headers.value' | translate }}</b></div>
<div class="ds-flex-cell ds-lang-cell"><b>{{ dsoType + '.edit.metadata.headers.language' | translate }}</b></div>
<div class="text-center ds-flex-cell ds-edit-cell"><b>{{ dsoType + '.edit.metadata.headers.edit' | translate }}</b></div>
</div>
</div>
</div>

View File

@@ -0,0 +1,12 @@
.lbl-cell {
min-width: var(--ds-dso-edit-field-width);
max-width: var(--ds-dso-edit-field-width);
background-color: var(--bs-gray-100);
font-weight: bold;
padding: 1rem;
border: 1px solid var(--bs-gray-200);
}
.ds-header-row {
background-color: var(--bs-gray-100);
}

View File

@@ -0,0 +1,32 @@
import { DsoEditMetadataHeadersComponent } from './dso-edit-metadata-headers.component';
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 { By } from '@angular/platform-browser';
describe('DsoEditMetadataHeadersComponent', () => {
let component: DsoEditMetadataHeadersComponent;
let fixture: ComponentFixture<DsoEditMetadataHeadersComponent>;
beforeEach(waitForAsync(() => {
TestBed.configureTestingModule({
declarations: [DsoEditMetadataHeadersComponent, VarDirective],
imports: [TranslateModule.forRoot(), RouterTestingModule.withRoutes([])],
providers: [
],
schemas: [NO_ERRORS_SCHEMA]
}).compileComponents();
}));
beforeEach(() => {
fixture = TestBed.createComponent(DsoEditMetadataHeadersComponent);
component = fixture.componentInstance;
fixture.detectChanges();
});
it('should display three headers', () => {
expect(fixture.debugElement.queryAll(By.css('.ds-flex-cell')).length).toEqual(3);
});
});

View File

@@ -0,0 +1,17 @@
import { Component, Input } from '@angular/core';
@Component({
selector: 'ds-dso-edit-metadata-headers',
styleUrls: ['./dso-edit-metadata-headers.component.scss', '../dso-edit-metadata-shared/dso-edit-metadata-cells.scss'],
templateUrl: './dso-edit-metadata-headers.component.html',
})
/**
* Component displaying the header table row for DSO edit metadata page
*/
export class DsoEditMetadataHeadersComponent {
/**
* Type of DSO we're displaying values for
* Determines i18n messages
*/
@Input() dsoType: string;
}

View File

@@ -0,0 +1,49 @@
.ds-field-row {
border: 1px solid var(--bs-gray-400);
}
.ds-flex-cell {
padding: 1rem;
border: 1px solid var(--bs-gray-200);
}
.ds-lang-cell {
min-width: var(--ds-dso-edit-lang-width);
max-width: var(--ds-dso-edit-lang-width);
}
.ds-edit-cell {
min-width: var(--ds-dso-edit-actions-width);
}
.ds-value-row {
background-color: white;
&:active {
cursor: grabbing;
}
&.ds-warning {
background-color: var(--bs-warning-bg);
.ds-flex-cell {
border: 1px solid var(--bs-warning);
}
}
&.ds-danger {
background-color: var(--bs-danger-bg);
.ds-flex-cell {
border: 1px solid var(--bs-danger);
}
}
&.ds-success {
background-color: var(--bs-success-bg);
.ds-flex-cell {
border: 1px solid var(--bs-success);
}
}
}

View File

@@ -0,0 +1,5 @@
<div role="row" class="visually-hidden">
<div role="columnheader">{{ dsoType + '.edit.metadata.headers.value' | translate }}</div>
<div role="columnheader">{{ dsoType + '.edit.metadata.headers.language' | translate }}</div>
<div role="columnheader">{{ dsoType + '.edit.metadata.headers.edit' | translate }}</div>
</div>

View File

@@ -0,0 +1,17 @@
import { Component, Input } from '@angular/core';
@Component({
selector: 'ds-dso-edit-metadata-value-headers',
styleUrls: ['./dso-edit-metadata-value-headers.component.scss', '../dso-edit-metadata-shared/dso-edit-metadata-cells.scss'],
templateUrl: './dso-edit-metadata-value-headers.component.html',
})
/**
* Component displaying invisible headers for a list of metadata values using table roles for accessibility
*/
export class DsoEditMetadataValueHeadersComponent {
/**
* Type of DSO we're displaying values for
* Determines i18n messages
*/
@Input() dsoType: string;
}

View File

@@ -0,0 +1,56 @@
<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="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"
[dsDebounce]="300" (onDebounce)="confirm.emit(false)"></textarea>
<div class="d-flex" *ngIf="mdRepresentation">
<a class="mr-2" target="_blank" [routerLink]="mdRepresentationItemRoute$ | async">{{ mdRepresentationName$ | async }}</a>
<ds-type-badge [object]="mdRepresentation"></ds-type-badge>
</div>
</div>
<div class="ds-flex-cell ds-lang-cell" role="cell">
<div class="dont-break-out preserve-line-breaks" *ngIf="!mdValue.editing">{{ mdValue.newValue.language }}</div>
<input class="form-control" type="text" *ngIf="mdValue.editing" [(ngModel)]="mdValue.newValue.language"
[dsDebounce]="300" (onDebounce)="confirm.emit(false)" />
</div>
<div class="text-center ds-flex-cell ds-edit-cell" role="cell">
<div class="btn-group">
<div class="edit-field">
<div class="btn-group edit-buttons" [ngbTooltip]="isVirtual ? (dsoType + '.edit.metadata.edit.buttons.virtual' | translate) : null">
<button class="btn btn-outline-primary btn-sm ng-star-inserted" id="metadata-edit-btn" *ngIf="!mdValue.editing"
[title]="dsoType + '.edit.metadata.edit.buttons.edit' | translate"
ngbTooltip="{{ dsoType + '.edit.metadata.edit.buttons.edit' | translate }}"
[disabled]="isVirtual || mdValue.change === DsoEditMetadataChangeTypeEnum.REMOVE || (saving$ | async)" (click)="edit.emit()">
<i class="fas fa-edit fa-fw"></i>
</button>
<button class="btn btn-outline-success btn-sm ng-star-inserted" id="metadata-confirm-btn" *ngIf="mdValue.editing"
[title]="dsoType + '.edit.metadata.edit.buttons.confirm' | translate"
ngbTooltip="{{ dsoType + '.edit.metadata.edit.buttons.confirm' | translate }}"
[disabled]="isVirtual || (saving$ | async)" (click)="confirm.emit(true)">
<i class="fas fa-check fa-fw"></i>
</button>
<button class="btn btn-outline-danger btn-sm" id="metadata-remove-btn"
[title]="dsoType + '.edit.metadata.edit.buttons.remove' | translate"
ngbTooltip="{{ dsoType + '.edit.metadata.edit.buttons.remove' | translate }}"
[disabled]="isVirtual || (mdValue.change && mdValue.change !== DsoEditMetadataChangeTypeEnum.ADD) || mdValue.editing || (saving$ | async)" (click)="remove.emit()">
<i class="fas fa-trash-alt fa-fw"></i>
</button>
<button class="btn btn-outline-warning btn-sm" id="metadata-undo-btn"
[title]="dsoType + '.edit.metadata.edit.buttons.undo' | translate"
ngbTooltip="{{ dsoType + '.edit.metadata.edit.buttons.undo' | translate }}"
[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>
</div>
</div>
<button class="btn btn-outline-secondary ds-drag-handle btn-sm" id="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>

View File

@@ -0,0 +1,16 @@
.ds-success {
background-color: var(--bs-success-bg);
border: 1px solid var(--bs-success);
}
.ds-drag-handle:not(.disabled) {
cursor: grab;
}
::ng-deep .edit-field>ngb-tooltip-window .tooltip-inner {
min-width: var(--ds-dso-edit-virtual-tooltip-min-width);
}
.cdk-drag-placeholder {
opacity: 0;
}

View File

@@ -0,0 +1,170 @@
import { DsoEditMetadataValueComponent } from './dso-edit-metadata-value.component';
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 { DebugElement, NO_ERRORS_SCHEMA } from '@angular/core';
import { RelationshipDataService } from '../../../core/data/relationship-data.service';
import { DSONameService } from '../../../core/breadcrumbs/dso-name.service';
import { of } from 'rxjs/internal/observable/of';
import { ItemMetadataRepresentation } from '../../../core/shared/metadata-representation/item/item-metadata-representation.model';
import { MetadataValue, VIRTUAL_METADATA_PREFIX } from '../../../core/shared/metadata.models';
import { DsoEditMetadataChangeType, DsoEditMetadataValue } from '../dso-edit-metadata-form';
import { By } from '@angular/platform-browser';
const EDIT_BTN = 'edit';
const CONFIRM_BTN = 'confirm';
const REMOVE_BTN = 'remove';
const UNDO_BTN = 'undo';
const DRAG_BTN = 'drag';
describe('DsoEditMetadataValueComponent', () => {
let component: DsoEditMetadataValueComponent;
let fixture: ComponentFixture<DsoEditMetadataValueComponent>;
let relationshipService: RelationshipDataService;
let dsoNameService: DSONameService;
let editMetadataValue: DsoEditMetadataValue;
let metadataValue: MetadataValue;
function initServices(): void {
relationshipService = jasmine.createSpyObj('relationshipService', {
resolveMetadataRepresentation: of(new ItemMetadataRepresentation(metadataValue)),
});
dsoNameService = jasmine.createSpyObj('dsoNameService', {
getName: 'Related Name',
});
}
beforeEach(waitForAsync(() => {
metadataValue = Object.assign(new MetadataValue(), {
value: 'Regular Name',
language: 'en',
place: 0,
authority: undefined,
});
editMetadataValue = new DsoEditMetadataValue(metadataValue);
initServices();
TestBed.configureTestingModule({
declarations: [DsoEditMetadataValueComponent, VarDirective],
imports: [TranslateModule.forRoot(), RouterTestingModule.withRoutes([])],
providers: [
{ provide: RelationshipDataService, useValue: relationshipService },
{ provide: DSONameService, useValue: dsoNameService },
],
schemas: [NO_ERRORS_SCHEMA]
}).compileComponents();
}));
beforeEach(() => {
fixture = TestBed.createComponent(DsoEditMetadataValueComponent);
component = fixture.componentInstance;
component.mdValue = editMetadataValue;
component.saving$ = of(false);
fixture.detectChanges();
});
it('should not show a badge', () => {
expect(fixture.debugElement.query(By.css('ds-type-badge'))).toBeNull();
});
describe('when no changes have been made', () => {
assertButton(EDIT_BTN, true, false);
assertButton(CONFIRM_BTN, false);
assertButton(REMOVE_BTN, true, false);
assertButton(UNDO_BTN, true, true);
assertButton(DRAG_BTN, true, false);
});
describe('when this is the only metadata value within its field', () => {
beforeEach(() => {
component.isOnlyValue = true;
fixture.detectChanges();
});
assertButton(DRAG_BTN, true, true);
});
describe('when the value is marked for removal', () => {
beforeEach(() => {
editMetadataValue.change = DsoEditMetadataChangeType.REMOVE;
fixture.detectChanges();
});
assertButton(REMOVE_BTN, true, true);
assertButton(UNDO_BTN, true, false);
});
describe('when the value is being edited', () => {
beforeEach(() => {
editMetadataValue.editing = true;
fixture.detectChanges();
});
assertButton(EDIT_BTN, false);
assertButton(CONFIRM_BTN, true, false);
assertButton(UNDO_BTN, true, false);
});
describe('when the value is new', () => {
beforeEach(() => {
editMetadataValue.change = DsoEditMetadataChangeType.ADD;
fixture.detectChanges();
});
assertButton(REMOVE_BTN, true, false);
assertButton(UNDO_BTN, true, false);
});
describe('when the metadata value is virtual', () => {
beforeEach(() => {
metadataValue = Object.assign(new MetadataValue(), {
value: 'Virtual Name',
language: 'en',
place: 0,
authority: `${VIRTUAL_METADATA_PREFIX}authority-key`,
});
editMetadataValue = new DsoEditMetadataValue(metadataValue);
component.mdValue = editMetadataValue;
component.ngOnInit();
fixture.detectChanges();
});
it('should show a badge', () => {
expect(fixture.debugElement.query(By.css('ds-type-badge'))).toBeTruthy();
});
assertButton(EDIT_BTN, true, true);
assertButton(CONFIRM_BTN, false);
assertButton(REMOVE_BTN, true, true);
assertButton(UNDO_BTN, true, true);
assertButton(DRAG_BTN, true, false);
});
function assertButton(name: string, exists: boolean, disabled: boolean = false): void {
describe(`${name} button`, () => {
let btn: DebugElement;
beforeEach(() => {
btn = fixture.debugElement.query(By.css(`#metadata-${name}-btn`));
});
if (exists) {
it('should exist', () => {
expect(btn).toBeTruthy();
});
it(`should${disabled ? ' ' : ' not '}be disabled`, () => {
expect(btn.nativeElement.disabled).toBe(disabled);
});
} else {
it('should not exist', () => {
expect(btn).toBeNull();
});
}
});
}
});

View File

@@ -0,0 +1,126 @@
import { Component, EventEmitter, Input, OnInit, Output } from '@angular/core';
import { DsoEditMetadataChangeType, DsoEditMetadataValue } from '../dso-edit-metadata-form';
import { Observable } from 'rxjs/internal/Observable';
import {
MetadataRepresentation,
MetadataRepresentationType
} from '../../../core/shared/metadata-representation/metadata-representation.model';
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 { getItemPageRoute } from '../../../item-page/item-page-routing-paths';
import { DSONameService } from '../../../core/breadcrumbs/dso-name.service';
import { EMPTY } from 'rxjs/internal/observable/empty';
@Component({
selector: 'ds-dso-edit-metadata-value',
styleUrls: ['./dso-edit-metadata-value.component.scss', '../dso-edit-metadata-shared/dso-edit-metadata-cells.scss'],
templateUrl: './dso-edit-metadata-value.component.html',
})
/**
* Component displaying a single editable row for a metadata value
*/
export class DsoEditMetadataValueComponent implements OnInit {
/**
* The parent {@link DSpaceObject} to display a metadata form for
* Also used to determine metadata-representations in case of virtual metadata
*/
@Input() dso: DSpaceObject;
/**
* Editable metadata value to show
*/
@Input() mdValue: DsoEditMetadataValue;
/**
* Type of DSO we're displaying values for
* Determines i18n messages
*/
@Input() dsoType: string;
/**
* Observable to check if the form is being saved or not
* Will disable certain functionality while saving
*/
@Input() saving$: Observable<boolean>;
/**
* Is this value the only one within its list?
* Will disable certain functionality like dragging (because dragging within a list of 1 is pointless)
*/
@Input() isOnlyValue = false;
/**
* Emits when the user clicked edit
*/
@Output() edit: EventEmitter<any> = new EventEmitter<any>();
/**
* Emits when the user clicked confirm
*/
@Output() confirm: EventEmitter<boolean> = new EventEmitter<boolean>();
/**
* Emits when the user clicked remove
*/
@Output() remove: EventEmitter<any> = new EventEmitter<any>();
/**
* Emits when the user clicked undo
*/
@Output() undo: EventEmitter<any> = new EventEmitter<any>();
/**
* Emits true when the user starts dragging a value, false when the user stops dragging
*/
@Output() dragging: EventEmitter<boolean> = new EventEmitter<boolean>();
/**
* The DsoEditMetadataChangeType enumeration for access in the component's template
* @type {DsoEditMetadataChangeType}
*/
public DsoEditMetadataChangeTypeEnum = DsoEditMetadataChangeType;
/**
* The item this metadata value represents in case it's virtual (if any, otherwise null)
*/
mdRepresentation$: Observable<ItemMetadataRepresentation | null>;
/**
* The route to the item represented by this virtual metadata value (otherwise null)
*/
mdRepresentationItemRoute$: Observable<string | null>;
/**
* The name of the item represented by this virtual metadata value (otherwise null)
*/
mdRepresentationName$: Observable<string | null>;
constructor(protected relationshipService: RelationshipDataService,
protected dsoNameService: DSONameService) {
}
ngOnInit(): void {
this.initVirtualProperties();
}
/**
* Initialise potential properties of a virtual metadata value
*/
initVirtualProperties(): void {
this.mdRepresentation$ = this.mdValue.newValue.isVirtual ?
this.relationshipService.resolveMetadataRepresentation(this.mdValue.newValue, this.dso, 'Item')
.pipe(
map((mdRepresentation: MetadataRepresentation) =>
mdRepresentation.representationType === MetadataRepresentationType.Item ? mdRepresentation as ItemMetadataRepresentation : null
)
) : EMPTY;
this.mdRepresentationItemRoute$ = this.mdRepresentation$.pipe(
map((mdRepresentation: ItemMetadataRepresentation) => mdRepresentation ? getItemPageRoute(mdRepresentation) : null),
);
this.mdRepresentationName$ = this.mdRepresentation$.pipe(
map((mdRepresentation: ItemMetadataRepresentation) => mdRepresentation ? this.dsoNameService.getName(mdRepresentation) : null),
);
}
}

View File

@@ -0,0 +1,91 @@
<div class="item-metadata" *ngIf="form">
<div class="button-row top d-flex my-2 space-children-mr ml-gap">
<button class="mr-auto btn btn-success" id="dso-add-btn" [disabled]="form.newValue || (saving$ | async)"
[title]="dsoType + '.edit.metadata.add-button' | translate"
(click)="add()"><i class="fas fa-plus"></i>
<span class="d-none d-sm-inline">&nbsp;{{ dsoType + '.edit.metadata.add-button' | translate }}</span>
</button>
<button class="btn btn-warning ml-1" id="dso-reinstate-btn" *ngIf="isReinstatable" [disabled]="(saving$ | async)"
[title]="dsoType + '.edit.metadata.reinstate-button' | translate"
(click)="reinstate()"><i class="fas fa-undo-alt"></i>
<span class="d-none d-sm-inline">&nbsp;{{ dsoType + '.edit.metadata.reinstate-button' | translate }}</span>
</button>
<button class="btn btn-primary ml-1" id="dso-save-btn" [disabled]="!hasChanges || (saving$ | async)"
[title]="dsoType + '.edit.metadata.save-button' | translate"
(click)="submit()"><i class="fas fa-save"></i>
<span class="d-none d-sm-inline">&nbsp;{{ dsoType + '.edit.metadata.save-button' | translate }}</span>
</button>
<button class="btn btn-danger ml-1" id="dso-discard-btn" *ngIf="!isReinstatable"
[title]="dsoType + '.edit.metadata.discard-button' | translate"
[disabled]="!hasChanges || (saving$ | async)"
(click)="discard()"><i class="fas fa-times"></i>
<span class="d-none d-sm-inline">&nbsp;{{ dsoType + '.edit.metadata.discard-button' | translate }}</span>
</button>
</div>
<div role="table" [attr.aria-label]="'item.edit.head' | translate">
<ds-dso-edit-metadata-headers [dsoType]="dsoType"></ds-dso-edit-metadata-headers>
<div class="d-flex flex-row ds-field-row" role="row" *ngIf="form.newValue">
<div class="lbl-cell ds-success" role="rowheader">
<ds-metadata-field-selector [dsoType]="dsoType"
[(mdField)]="newMdField"
[autofocus]="true">
</ds-metadata-field-selector>
</div>
<div class="flex-grow-1 ds-drop-list" role="cell">
<div role="table">
<ds-dso-edit-metadata-value-headers role="presentation" [dsoType]="dsoType"></ds-dso-edit-metadata-value-headers>
<ds-dso-edit-metadata-value [dso]="dso"
[mdValue]="form.newValue"
[dsoType]="dsoType"
[saving$]="savingOrLoadingFieldValidation$"
[isOnlyValue]="true"
(confirm)="confirmNewValue($event)"
(remove)="form.newValue = undefined"
(undo)="form.newValue = undefined">
</ds-dso-edit-metadata-value>
</div>
</div>
</div>
<div class="d-flex flex-row ds-field-row" role="row" *ngFor="let mdField of form.fieldKeys">
<div class="lbl-cell" role="rowheader">
<span class="dont-break-out preserve-line-breaks">{{ mdField }}</span>
<div class="btn btn-warning reset-order-button mt-2 w-100" *ngIf="form.hasOrderChanges(mdField)"
(click)="form.resetOrder(mdField); onValueSaved()">
{{ dsoType + '.edit.metadata.reset-order-button' | translate }}
</div>
</div>
<ds-dso-edit-metadata-field-values class="flex-grow-1" role="cell"
[dso]="dso"
[form]="form"
[dsoType]="dsoType"
[saving$]="saving$"
[draggingMdField$]="draggingMdField$"
[mdField]="mdField"
(valueSaved)="onValueSaved()">
</ds-dso-edit-metadata-field-values>
</div>
</div>
<div *ngIf="isEmpty && !form.newValue">
<ds-alert [content]="dsoType + '.edit.metadata.empty'" [type]="AlertTypeEnum.Info"></ds-alert>
</div>
<div class="button-row bottom d-inline-block w-100">
<div class="mt-2 float-right space-children-mr ml-gap">
<button class="btn btn-warning" *ngIf="isReinstatable" [disabled]="(saving$ | async)"
[title]="dsoType + '.edit.metadata.reinstate-button' | translate"
(click)="reinstate()"><i class="fas fa-undo-alt"></i> {{ dsoType + '.edit.metadata.reinstate-button' | translate }}
</button>
<button class="btn btn-primary" [disabled]="!hasChanges || (saving$ | async)"
[title]="dsoType + '.edit.metadata.save-button' | translate"
(click)="submit()"><i class="fas fa-save"></i> {{ dsoType + '.edit.metadata.save-button' | translate }}
</button>
<button class="btn btn-danger" *ngIf="!isReinstatable"
[title]="dsoType + '.edit.metadata.discard-button' | translate"
[disabled]="!hasChanges || (saving$ | async)"
(click)="discard()"><i class="fas fa-times"></i> {{ dsoType + '.edit.metadata.discard-button' | translate }}
</button>
</div>
</div>
</div>
<ds-loading *ngIf="!form"></ds-loading>

View File

@@ -0,0 +1,21 @@
.lbl-cell {
min-width: var(--ds-dso-edit-field-width);
max-width: var(--ds-dso-edit-field-width);
background-color: var(--bs-gray-100);
font-weight: bold;
padding: 1rem;
border: 1px solid var(--bs-gray-200);
&.ds-success {
background-color: var(--bs-success-bg);
border: 1px solid var(--bs-success);
}
}
.ds-field-row {
border: 1px solid var(--bs-gray-400);
}
.reset-order-button:hover {
cursor: pointer;
}

View File

@@ -0,0 +1,193 @@
import { DsoEditMetadataComponent } from './dso-edit-metadata.component';
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 { DebugElement, Injectable, NO_ERRORS_SCHEMA } from '@angular/core';
import { DSpaceObject } from '../../core/shared/dspace-object.model';
import { Item } from '../../core/shared/item.model';
import { MetadataValue } from '../../core/shared/metadata.models';
import { createSuccessfulRemoteDataObject$ } from '../../shared/remote-data.utils';
import { By } from '@angular/platform-browser';
import { NotificationsService } from '../../shared/notifications/notifications.service';
import { ArrayMoveChangeAnalyzer } from '../../core/data/array-move-change-analyzer.service';
import { ITEM } from '../../core/shared/item.resource-type';
import { DATA_SERVICE_FACTORY } from '../../core/data/base/data-service.decorator';
import { Operation } from 'fast-json-patch';
import { RemoteData } from '../../core/data/remote-data';
import { Observable } from 'rxjs/internal/Observable';
const ADD_BTN = 'add';
const REINSTATE_BTN = 'reinstate';
const SAVE_BTN = 'save';
const DISCARD_BTN = 'discard';
@Injectable()
class TestDataService {
patch(object: Item, operations: Operation[]): Observable<RemoteData<Item>> {
return createSuccessfulRemoteDataObject$(object);
}
}
describe('DsoEditMetadataComponent', () => {
let component: DsoEditMetadataComponent;
let fixture: ComponentFixture<DsoEditMetadataComponent>;
let notificationsService: NotificationsService;
let dso: DSpaceObject;
beforeEach(waitForAsync(() => {
dso = Object.assign(new Item(), {
type: ITEM,
metadata: {
'dc.title': [
Object.assign(new MetadataValue(), {
value: 'Test Title',
language: 'en',
place: 0,
}),
],
'dc.subject': [
Object.assign(new MetadataValue(), {
value: 'Subject One',
language: 'en',
place: 0,
}),
Object.assign(new MetadataValue(), {
value: 'Subject Two',
language: 'en',
place: 1,
}),
Object.assign(new MetadataValue(), {
value: 'Subject Three',
language: 'en',
place: 2,
}),
],
},
});
notificationsService = jasmine.createSpyObj('notificationsService', ['error', 'success']);
TestBed.configureTestingModule({
declarations: [DsoEditMetadataComponent, VarDirective],
imports: [TranslateModule.forRoot(), RouterTestingModule.withRoutes([])],
providers: [
TestDataService,
{ provide: DATA_SERVICE_FACTORY, useValue: jasmine.createSpy('getDataServiceFor').and.returnValue(TestDataService) },
{ provide: NotificationsService, useValue: notificationsService },
ArrayMoveChangeAnalyzer,
],
schemas: [NO_ERRORS_SCHEMA]
}).compileComponents();
}));
beforeEach(() => {
fixture = TestBed.createComponent(DsoEditMetadataComponent);
component = fixture.componentInstance;
component.dso = dso;
fixture.detectChanges();
});
describe('when no changes have been made', () => {
assertButton(ADD_BTN, true, false);
assertButton(REINSTATE_BTN, false);
assertButton(SAVE_BTN, true, true);
assertButton(DISCARD_BTN, true, true);
});
describe('when the form contains changes', () => {
beforeEach(() => {
component.form.fields['dc.title'][0].newValue.value = 'Updated Title Once';
component.form.fields['dc.title'][0].confirmChanges();
component.form.resetReinstatable();
component.onValueSaved();
fixture.detectChanges();
});
assertButton(SAVE_BTN, true, false);
assertButton(DISCARD_BTN, true, false);
describe('and they were discarded', () => {
beforeEach(() => {
component.discard();
fixture.detectChanges();
});
assertButton(REINSTATE_BTN, true, false);
assertButton(SAVE_BTN, true, true);
assertButton(DISCARD_BTN, false);
describe('and a new change is made', () => {
beforeEach(() => {
component.form.fields['dc.title'][0].newValue.value = 'Updated Title Twice';
component.form.fields['dc.title'][0].confirmChanges();
component.form.resetReinstatable();
component.onValueSaved();
fixture.detectChanges();
});
assertButton(REINSTATE_BTN, false);
assertButton(SAVE_BTN, true, false);
assertButton(DISCARD_BTN, true, false);
});
});
});
describe('when a new value is present', () => {
beforeEach(() => {
component.add();
fixture.detectChanges();
});
assertButton(ADD_BTN, true, true);
it('should display a row with a field selector and metadata value', () => {
expect(fixture.debugElement.query(By.css('ds-metadata-field-selector'))).toBeTruthy();
expect(fixture.debugElement.query(By.css('ds-dso-edit-metadata-value'))).toBeTruthy();
});
describe('and gets assigned to a metadata field', () => {
beforeEach(() => {
component.form.newValue.newValue.value = 'New Subject';
component.form.setMetadataField('dc.subject');
component.form.resetReinstatable();
component.onValueSaved();
fixture.detectChanges();
});
assertButton(ADD_BTN, true, false);
it('should not display the separate row with field selector and metadata value anymore', () => {
expect(fixture.debugElement.query(By.css('ds-metadata-field-selector'))).toBeNull();
expect(fixture.debugElement.query(By.css('ds-dso-edit-metadata-value'))).toBeNull();
});
});
});
function assertButton(name: string, exists: boolean, disabled: boolean = false): void {
describe(`${name} button`, () => {
let btn: DebugElement;
beforeEach(() => {
btn = fixture.debugElement.query(By.css(`#dso-${name}-btn`));
});
if (exists) {
it('should exist', () => {
expect(btn).toBeTruthy();
});
it(`should${disabled ? ' ' : ' not '}be disabled`, () => {
expect(btn.nativeElement.disabled).toBe(disabled);
});
} else {
it('should not exist', () => {
expect(btn).toBeNull();
});
}
});
}
});

View File

@@ -0,0 +1,261 @@
import { Component, Inject, Injector, Input, OnDestroy, OnInit, ViewChild } from '@angular/core';
import { AlertType } from '../../shared/alert/aletr-type';
import { DSpaceObject } from '../../core/shared/dspace-object.model';
import { DsoEditMetadataForm } from './dso-edit-metadata-form';
import { map } from 'rxjs/operators';
import { ActivatedRoute, Data } from '@angular/router';
import { combineLatest as observableCombineLatest } from 'rxjs/internal/observable/combineLatest';
import { Subscription } from 'rxjs/internal/Subscription';
import { RemoteData } from '../../core/data/remote-data';
import { hasNoValue, hasValue } from '../../shared/empty.util';
import { BehaviorSubject } from 'rxjs/internal/BehaviorSubject';
import {
getFirstCompletedRemoteData,
} from '../../core/shared/operators';
import { UpdateDataService } from '../../core/data/update-data.service';
import { ResourceType } from '../../core/shared/resource-type';
import { NotificationsService } from '../../shared/notifications/notifications.service';
import { TranslateService } from '@ngx-translate/core';
import { MetadataFieldSelectorComponent } from './metadata-field-selector/metadata-field-selector.component';
import { Observable } from 'rxjs/internal/Observable';
import { ArrayMoveChangeAnalyzer } from '../../core/data/array-move-change-analyzer.service';
import { DATA_SERVICE_FACTORY } from '../../core/data/base/data-service.decorator';
import { GenericConstructor } from '../../core/shared/generic-constructor';
import { HALDataService } from '../../core/data/base/hal-data-service.interface';
@Component({
selector: 'ds-dso-edit-metadata',
styleUrls: ['./dso-edit-metadata.component.scss'],
templateUrl: './dso-edit-metadata.component.html',
})
/**
* Component showing a table of all metadata on a DSpaceObject and options to modify them
*/
export class DsoEditMetadataComponent implements OnInit, OnDestroy {
/**
* DSpaceObject to edit metadata for
*/
@Input() dso: DSpaceObject;
/**
* Reference to the component responsible for showing a metadata-field selector
* Used to validate its contents (existing metadata field) before adding a new metadata value
*/
@ViewChild(MetadataFieldSelectorComponent) metadataFieldSelectorComponent: MetadataFieldSelectorComponent;
/**
* Resolved update data-service for the given DSpaceObject (depending on its type, e.g. ItemDataService for an Item)
* Used to send the PATCH request
*/
@Input() updateDataService: UpdateDataService<DSpaceObject>;
/**
* Type of the DSpaceObject in String
* Used to resolve i18n messages
*/
dsoType: string;
/**
* A dynamic form object containing all information about the metadata and the changes made to them, see {@link DsoEditMetadataForm}
*/
form: DsoEditMetadataForm;
/**
* The metadata field entered by the user for a new metadata value
*/
newMdField: string;
// Properties determined by the state of the dynamic form, updated by onValueSaved()
isReinstatable: boolean;
hasChanges: boolean;
isEmpty: boolean;
/**
* Whether or not the form is currently being submitted
*/
saving$: BehaviorSubject<boolean> = new BehaviorSubject<boolean>(false);
/**
* Tracks for which metadata-field a drag operation is taking place
* Null when no drag is currently happening for any field
* This is a BehaviorSubject that is passed down to child components, to give them the power to alter the state
*/
draggingMdField$: BehaviorSubject<string> = new BehaviorSubject<string>(null);
/**
* Whether or not the metadata field is currently being validated
*/
loadingFieldValidation$: BehaviorSubject<boolean> = new BehaviorSubject<boolean>(false);
/**
* Combination of saving$ and loadingFieldValidation$
* Emits true when any of the two emit true
*/
savingOrLoadingFieldValidation$: Observable<boolean>;
/**
* The AlertType enumeration for access in the component's template
* @type {AlertType}
*/
public AlertTypeEnum = AlertType;
/**
* Subscription for updating the current DSpaceObject
* Unsubscribed from in ngOnDestroy()
*/
dsoUpdateSubscription: Subscription;
constructor(protected route: ActivatedRoute,
protected notificationsService: NotificationsService,
protected translateService: TranslateService,
protected parentInjector: Injector,
protected arrayMoveChangeAnalyser: ArrayMoveChangeAnalyzer<number>,
@Inject(DATA_SERVICE_FACTORY) protected getDataServiceFor: (resourceType: ResourceType) => GenericConstructor<HALDataService<any>>) {
}
/**
* Read the route (or parent route)'s data to retrieve the current DSpaceObject
* After it's retrieved, initialise the data-service and form
*/
ngOnInit(): void {
if (hasNoValue(this.dso)) {
this.dsoUpdateSubscription = observableCombineLatest([this.route.data, this.route.parent.data]).pipe(
map(([data, parentData]: [Data, Data]) => Object.assign({}, data, parentData)),
map((data: any) => data.dso)
).subscribe((rd: RemoteData<DSpaceObject>) => {
this.dso = rd.payload;
this.initDataService();
this.initForm();
});
} else {
this.initDataService();
this.initForm();
}
this.savingOrLoadingFieldValidation$ = observableCombineLatest([this.saving$, this.loadingFieldValidation$]).pipe(
map(([saving, loading]: [boolean, boolean]) => saving || loading),
);
}
/**
* Initialise (resolve) the data-service for the current DSpaceObject
*/
initDataService(): void {
let type: ResourceType;
if (typeof this.dso.type === 'string') {
type = new ResourceType(this.dso.type);
} else {
type = this.dso.type;
}
if (hasNoValue(this.updateDataService)) {
const provider = this.getDataServiceFor(type);
this.updateDataService = Injector.create({
providers: [],
parent: this.parentInjector
}).get(provider);
}
this.dsoType = type.value;
}
/**
* Initialise the dynamic form object by passing the DSpaceObject's metadata
* Call onValueSaved() to update the form's state properties
*/
initForm(): void {
this.form = new DsoEditMetadataForm(this.dso.metadata);
this.onValueSaved();
}
/**
* Update the form's state properties
*/
onValueSaved(): void {
this.hasChanges = this.form.hasChanges();
this.isReinstatable = this.form.isReinstatable();
this.isEmpty = Object.keys(this.form.fields).length === 0;
}
/**
* Submit the current changes to the form by retrieving json PATCH operations from the form and sending it to the
* DSpaceObject's data-service
* Display notificiations and reset the form afterwards if successful
*/
submit(): void {
this.saving$.next(true);
this.updateDataService.patch(this.dso, this.form.getOperations(this.arrayMoveChangeAnalyser)).pipe(
getFirstCompletedRemoteData()
).subscribe((rd: RemoteData<DSpaceObject>) => {
this.saving$.next(false);
if (rd.hasFailed) {
this.notificationsService.error(this.translateService.instant(`${this.dsoType}.edit.metadata.notifications.error.title`), rd.errorMessage);
} else {
this.notificationsService.success(
this.translateService.instant(`${this.dsoType}.edit.metadata.notifications.saved.title`),
this.translateService.instant(`${this.dsoType}.edit.metadata.notifications.saved.content`)
);
this.dso = rd.payload;
this.initForm();
}
});
}
/**
* Confirm the newly added value
* @param saved Whether or not the value was manually saved (only then, add the value to its metadata field)
*/
confirmNewValue(saved: boolean): void {
if (saved) {
this.setMetadataField();
}
}
/**
* Set the metadata field of the temporary added new metadata value
* This will move the new value to its respective parent metadata field
* Validate the metadata field first
*/
setMetadataField(): void {
this.form.resetReinstatable();
this.loadingFieldValidation$.next(true);
this.metadataFieldSelectorComponent.validate().subscribe((valid: boolean) => {
this.loadingFieldValidation$.next(false);
if (valid) {
this.form.setMetadataField(this.newMdField);
this.onValueSaved();
}
});
}
/**
* Add a new temporary metadata value
*/
add(): void {
this.newMdField = undefined;
this.form.add();
}
/**
* Discard all changes within the current form
*/
discard(): void {
this.form.discard();
this.onValueSaved();
}
/**
* Restore any changes previously discarded from the form
*/
reinstate(): void {
this.form.reinstate();
this.onValueSaved();
}
/**
* Unsubscribe from any open subscriptions
*/
ngOnDestroy(): void {
if (hasValue(this.dsoUpdateSubscription)) {
this.dsoUpdateSubscription.unsubscribe();
}
}
}

View File

@@ -0,0 +1,19 @@
<div class="w-100 position-relative">
<input type="text" #mdFieldInput
class="form-control" [ngClass]="{ 'is-invalid': showInvalid }"
[value]="mdField"
[formControl]="input"
(focusin)="query$.next(mdField)"
(dsClickOutside)="query$.next(null)"
(click)="$event.stopPropagation();" />
<div class="invalid-feedback show-feedback" *ngIf="showInvalid">{{ dsoType + '.edit.metadata.metadatafield.invalid' | translate }}</div>
<div class="autocomplete dropdown-menu" [ngClass]="{'show': (mdFieldOptions$ | async)?.length > 0}">
<div class="dropdown-list">
<div *ngFor="let mdFieldOption of (mdFieldOptions$ | async)">
<a href="javascript:void(0);" class="d-block dropdown-item" (click)="select(mdFieldOption)">
<span [innerHTML]="mdFieldOption"></span>
</a>
</div>
</div>
</div>
</div>

View File

@@ -0,0 +1,122 @@
import { MetadataFieldSelectorComponent } from './metadata-field-selector.component';
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 { RegistryService } from '../../../core/registry/registry.service';
import { MetadataField } from '../../../core/metadata/metadata-field.model';
import { MetadataSchema } from '../../../core/metadata/metadata-schema.model';
import { createFailedRemoteDataObject$, createSuccessfulRemoteDataObject$ } from '../../../shared/remote-data.utils';
import { createPaginatedList } from '../../../shared/testing/utils.test';
import { followLink } from '../../../shared/utils/follow-link-config.model';
import { By } from '@angular/platform-browser';
import { NotificationsService } from '../../../shared/notifications/notifications.service';
describe('MetadataFieldSelectorComponent', () => {
let component: MetadataFieldSelectorComponent;
let fixture: ComponentFixture<MetadataFieldSelectorComponent>;
let registryService: RegistryService;
let notificationsService: NotificationsService;
let metadataSchema: MetadataSchema;
let metadataFields: MetadataField[];
beforeEach(waitForAsync(() => {
metadataSchema = Object.assign(new MetadataSchema(), {
id: 0,
prefix: 'dc',
namespace: 'http://dublincore.org/documents/dcmi-terms/',
});
metadataFields = [
Object.assign(new MetadataField(), {
id: 0,
element: 'description',
qualifier: undefined,
schema: createSuccessfulRemoteDataObject$(metadataSchema),
}),
Object.assign(new MetadataField(), {
id: 1,
element: 'description',
qualifier: 'abstract',
schema: createSuccessfulRemoteDataObject$(metadataSchema),
}),
];
registryService = jasmine.createSpyObj('registryService', {
queryMetadataFields: createSuccessfulRemoteDataObject$(createPaginatedList(metadataFields)),
});
notificationsService = jasmine.createSpyObj('notificationsService', ['error', 'success']);
TestBed.configureTestingModule({
declarations: [MetadataFieldSelectorComponent, VarDirective],
imports: [TranslateModule.forRoot(), RouterTestingModule.withRoutes([])],
providers: [
{ provide: RegistryService, useValue: registryService },
{ provide: NotificationsService, useValue: notificationsService },
],
schemas: [NO_ERRORS_SCHEMA]
}).compileComponents();
}));
beforeEach(() => {
fixture = TestBed.createComponent(MetadataFieldSelectorComponent);
component = fixture.componentInstance;
fixture.detectChanges();
});
describe('when a query is entered', () => {
const query = 'test query';
beforeEach(() => {
component.showInvalid = true;
component.query$.next(query);
});
it('should reset showInvalid', () => {
expect(component.showInvalid).toBeFalse();
});
it('should query the registry service for metadata fields and include the schema', () => {
expect(registryService.queryMetadataFields).toHaveBeenCalledWith(query, null, true, false, followLink('schema'));
});
});
describe('validate', () => {
it('should return an observable true and show no feedback if the current mdField exists in registry', (done) => {
component.mdField = 'dc.description.abstract';
component.validate().subscribe((result) => {
expect(result).toBeTrue();
fixture.detectChanges();
expect(fixture.debugElement.query(By.css('.invalid-feedback'))).toBeNull();
done();
});
});
it('should return an observable false and show invalid feedback if the current mdField is missing in registry', (done) => {
component.mdField = 'dc.fake.field';
component.validate().subscribe((result) => {
expect(result).toBeFalse();
fixture.detectChanges();
expect(fixture.debugElement.query(By.css('.invalid-feedback'))).toBeTruthy();
done();
});
});
describe('when querying the metadata fields returns an error response', () => {
beforeEach(() => {
(registryService.queryMetadataFields as jasmine.Spy).and.returnValue(createFailedRemoteDataObject$('Failed'));
});
it('should return an observable false and show a notification', (done) => {
component.mdField = 'dc.description.abstract';
component.validate().subscribe((result) => {
expect(result).toBeFalse();
expect(notificationsService.error).toHaveBeenCalled();
done();
});
});
});
});
});

View File

@@ -0,0 +1,188 @@
import {
AfterViewInit,
Component,
ElementRef,
EventEmitter,
Input,
OnDestroy,
OnInit,
Output,
ViewChild
} from '@angular/core';
import { switchMap, debounceTime, distinctUntilChanged, map, tap, take } from 'rxjs/operators';
import { followLink } from '../../../shared/utils/follow-link-config.model';
import {
getAllSucceededRemoteData, getFirstCompletedRemoteData,
metadataFieldsToString
} from '../../../core/shared/operators';
import { Observable } from 'rxjs/internal/Observable';
import { RegistryService } from '../../../core/registry/registry.service';
import { FormControl } from '@angular/forms';
import { BehaviorSubject } from 'rxjs/internal/BehaviorSubject';
import { hasValue } from '../../../shared/empty.util';
import { Subscription } from 'rxjs/internal/Subscription';
import { of } from 'rxjs/internal/observable/of';
import { NotificationsService } from '../../../shared/notifications/notifications.service';
import { TranslateService } from '@ngx-translate/core';
@Component({
selector: 'ds-metadata-field-selector',
styleUrls: ['./metadata-field-selector.component.scss'],
templateUrl: './metadata-field-selector.component.html'
})
/**
* Component displaying a searchable input for metadata-fields
*/
export class MetadataFieldSelectorComponent implements OnInit, OnDestroy, AfterViewInit {
/**
* Type of the DSpaceObject
* Used to resolve i18n messages
*/
@Input() dsoType: string;
/**
* The currently entered metadata field
*/
@Input() mdField: string;
/**
* If true, the input will be automatically focussed upon when the component is first loaded
*/
@Input() autofocus = false;
/**
* Emit any changes made to the metadata field
* This will only emit after a debounce takes place to avoid constant emits when the user is typing
*/
@Output() mdFieldChange = new EventEmitter<string>();
/**
* Reference to the metadata-field's input
*/
@ViewChild('mdFieldInput', { static: true }) mdFieldInput: ElementRef;
/**
* List of available metadata field options to choose from, dependent on the current query the user entered
* Shows up in a dropdown below the input
*/
mdFieldOptions$: Observable<string[]>;
/**
* FormControl for the input
*/
public input: FormControl = new FormControl();
/**
* The current query to update mdFieldOptions$ for
* This is controlled by a debounce, to avoid too many requests
*/
query$: BehaviorSubject<string> = new BehaviorSubject<string>(null);
/**
* The amount of time to debounce the query for (in ms)
*/
debounceTime = 300;
/**
* Whether or not the the user just selected a value
* This flag avoids the metadata field from updating twice, which would result in the dropdown opening again right after selecting a value
*/
selectedValueLoading = false;
/**
* Whether or not to show the invalid feedback
* True when validate() is called and the mdField isn't present in the available metadata fields retrieved from the server
*/
showInvalid = false;
/**
* Subscriptions to unsubscribe from on destroy
*/
subs: Subscription[] = [];
constructor(protected registryService: RegistryService,
protected notificationsService: NotificationsService,
protected translate: TranslateService) {
}
/**
* Subscribe to any changes made to the input, with a debounce and fire a query, as well as emit the change from this component
* Update the mdFieldOptions$ depending on the query$ fired by querying the server
*/
ngOnInit(): void {
this.subs.push(
this.input.valueChanges.pipe(
debounceTime(this.debounceTime),
).subscribe((valueChange) => {
if (!this.selectedValueLoading) {
this.query$.next(valueChange);
}
this.selectedValueLoading = false;
this.mdField = valueChange;
this.mdFieldChange.emit(this.mdField);
}),
);
this.mdFieldOptions$ = this.query$.pipe(
distinctUntilChanged(),
switchMap((query: string) => {
this.showInvalid = false;
if (query !== null) {
return this.registryService.queryMetadataFields(query, null, true, false, followLink('schema')).pipe(
getAllSucceededRemoteData(),
metadataFieldsToString(),
);
} else {
return [[]];
}
}),
);
}
/**
* Focus the input if autofocus is enabled
*/
ngAfterViewInit(): void {
if (this.autofocus) {
this.mdFieldInput.nativeElement.focus();
}
}
/**
* Validate the metadata field to check if it exists on the server and return an observable boolean for success/error
* Upon subscribing to the returned observable, the showInvalid flag is updated accordingly to show the feedback under the input
*/
validate(): Observable<boolean> {
return this.registryService.queryMetadataFields(this.mdField, null, true, false, followLink('schema')).pipe(
getFirstCompletedRemoteData(),
switchMap((rd) => {
if (rd.hasSucceeded) {
return of(rd).pipe(
metadataFieldsToString(),
take(1),
map((fields: string[]) => fields.indexOf(this.mdField) > -1),
tap((exists: boolean) => this.showInvalid = !exists),
);
} else {
this.notificationsService.error(this.translate.instant(`${this.dsoType}.edit.metadata.metadatafield.error`), rd.errorMessage);
return [false];
}
}),
);
}
/**
* Select a metadata field from the dropdown options
* @param mdFieldOption
*/
select(mdFieldOption: string): void {
this.selectedValueLoading = true;
this.input.setValue(mdFieldOption);
}
/**
* Unsubscribe from any open subscriptions
*/
ngOnDestroy(): void {
this.subs.filter((sub: Subscription) => hasValue(sub)).forEach((sub: Subscription) => sub.unsubscribe());
}
}

View File

@@ -0,0 +1,33 @@
import { ThemedComponent } from '../../shared/theme-support/themed.component';
import { DsoEditMetadataComponent } from './dso-edit-metadata.component';
import { Component, Input } from '@angular/core';
import { DSpaceObject } from '../../core/shared/dspace-object.model';
import { UpdateDataService } from '../../core/data/update-data.service';
@Component({
selector: 'ds-themed-dso-edit-metadata',
styleUrls: [],
templateUrl: './../../shared/theme-support/themed.component.html',
})
export class ThemedDsoEditMetadataComponent extends ThemedComponent<DsoEditMetadataComponent> {
@Input() dso: DSpaceObject;
@Input() updateDataService: UpdateDataService<DSpaceObject>;
protected inAndOutputNames: (keyof DsoEditMetadataComponent & keyof this)[] = ['dso', 'updateDataService'];
protected getComponentName(): string {
return 'DsoEditMetadataComponent';
}
protected importThemedComponent(themeName: string): Promise<any> {
return import(`../../../themes/${themeName}/app/dso-shared/dso-edit-metadata/dso-edit-metadata.component`);
}
protected importUnthemedComponent(): Promise<any> {
return import(`./dso-edit-metadata.component`);
}
}

View File

@@ -0,0 +1,36 @@
import { NgModule } from '@angular/core';
import { SharedModule } from '../shared/shared.module';
import { DsoEditMetadataComponent } from './dso-edit-metadata/dso-edit-metadata.component';
import { MetadataFieldSelectorComponent } from './dso-edit-metadata/metadata-field-selector/metadata-field-selector.component';
import { DsoEditMetadataFieldValuesComponent } from './dso-edit-metadata/dso-edit-metadata-field-values/dso-edit-metadata-field-values.component';
import { DsoEditMetadataValueComponent } from './dso-edit-metadata/dso-edit-metadata-value/dso-edit-metadata-value.component';
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';
@NgModule({
imports: [
SharedModule,
],
declarations: [
DsoEditMetadataComponent,
ThemedDsoEditMetadataComponent,
MetadataFieldSelectorComponent,
DsoEditMetadataFieldValuesComponent,
DsoEditMetadataValueComponent,
DsoEditMetadataHeadersComponent,
DsoEditMetadataValueHeadersComponent,
],
exports: [
DsoEditMetadataComponent,
ThemedDsoEditMetadataComponent,
MetadataFieldSelectorComponent,
DsoEditMetadataFieldValuesComponent,
DsoEditMetadataValueComponent,
DsoEditMetadataHeadersComponent,
DsoEditMetadataValueHeadersComponent,
],
})
export class DsoSharedModule {
}

View File

@@ -1,3 +1,4 @@
<ds-themed-results-back-button *ngIf="showBackButton | async" [back]="back"></ds-themed-results-back-button>
<div class="d-flex flex-row"> <div class="d-flex flex-row">
<ds-item-page-title-field [item]="object" class="mr-auto"> <ds-item-page-title-field [item]="object" class="mr-auto">
</ds-item-page-title-field> </ds-item-page-title-field>

View File

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

View File

@@ -1,3 +1,4 @@
<ds-themed-results-back-button *ngIf="showBackButton | async" [back]="back"></ds-themed-results-back-button>
<div class="d-flex flex-row"> <div class="d-flex flex-row">
<ds-item-page-title-field [item]="object" class="mr-auto"> <ds-item-page-title-field [item]="object" class="mr-auto">
</ds-item-page-title-field> </ds-item-page-title-field>

View File

@@ -1,3 +1,4 @@
<ds-themed-results-back-button *ngIf="showBackButton | async" [back]="back"></ds-themed-results-back-button>
<div class="d-flex flex-row"> <div class="d-flex flex-row">
<ds-item-page-title-field [item]="object" class="mr-auto"> <ds-item-page-title-field [item]="object" class="mr-auto">
</ds-item-page-title-field> </ds-item-page-title-field>

View File

@@ -34,6 +34,7 @@ import { VersionHistoryDataService } from '../../../../core/data/version-history
import { VersionDataService } from '../../../../core/data/version-data.service'; import { VersionDataService } from '../../../../core/data/version-data.service';
import { WorkspaceitemDataService } from '../../../../core/submission/workspaceitem-data.service'; import { WorkspaceitemDataService } from '../../../../core/submission/workspaceitem-data.service';
import { SearchService } from '../../../../core/shared/search/search.service'; import { SearchService } from '../../../../core/shared/search/search.service';
import { mockRouteService } from '../../../../item-page/simple/item-types/shared/item.component.spec';
let comp: JournalComponent; let comp: JournalComponent;
let fixture: ComponentFixture<JournalComponent>; let fixture: ComponentFixture<JournalComponent>;
@@ -99,7 +100,7 @@ describe('JournalComponent', () => {
{ provide: BitstreamDataService, useValue: mockBitstreamDataService }, { provide: BitstreamDataService, useValue: mockBitstreamDataService },
{ provide: WorkspaceitemDataService, useValue: {} }, { provide: WorkspaceitemDataService, useValue: {} },
{ provide: SearchService, useValue: {} }, { provide: SearchService, useValue: {} },
{ provide: RouteService, useValue: {} } { provide: RouteService, useValue: mockRouteService }
], ],
schemas: [NO_ERRORS_SCHEMA] schemas: [NO_ERRORS_SCHEMA]

View File

@@ -20,6 +20,7 @@ import { JournalVolumeSidebarSearchListElementComponent } from './item-list-elem
import { JournalIssueSidebarSearchListElementComponent } from './item-list-elements/sidebar-search-list-elements/journal-issue/journal-issue-sidebar-search-list-element.component'; import { JournalIssueSidebarSearchListElementComponent } from './item-list-elements/sidebar-search-list-elements/journal-issue/journal-issue-sidebar-search-list-element.component';
import { JournalSidebarSearchListElementComponent } from './item-list-elements/sidebar-search-list-elements/journal/journal-sidebar-search-list-element.component'; import { JournalSidebarSearchListElementComponent } from './item-list-elements/sidebar-search-list-elements/journal/journal-sidebar-search-list-element.component';
import { ItemSharedModule } from '../../item-page/item-shared.module'; import { ItemSharedModule } from '../../item-page/item-shared.module';
import { ResultsBackButtonModule } from '../../shared/results-back-button/results-back-button.module';
const ENTRY_COMPONENTS = [ const ENTRY_COMPONENTS = [
// put only entry components that use custom decorator // put only entry components that use custom decorator
@@ -47,7 +48,8 @@ const ENTRY_COMPONENTS = [
imports: [ imports: [
CommonModule, CommonModule,
ItemSharedModule, ItemSharedModule,
SharedModule SharedModule,
ResultsBackButtonModule
], ],
declarations: [ declarations: [
...ENTRY_COMPONENTS ...ENTRY_COMPONENTS

View File

@@ -1,3 +1,4 @@
<ds-themed-results-back-button *ngIf="showBackButton | async" [back]="back"></ds-themed-results-back-button>
<div class="d-flex flex-row"> <div class="d-flex flex-row">
<ds-item-page-title-field [item]="object" class="mr-auto"> <ds-item-page-title-field [item]="object" class="mr-auto">
</ds-item-page-title-field> </ds-item-page-title-field>

View File

@@ -1,3 +1,4 @@
<ds-themed-results-back-button *ngIf="showBackButton | async" [back]="back"></ds-themed-results-back-button>
<div class="d-flex flex-row"> <div class="d-flex flex-row">
<ds-item-page-title-field class="mr-auto" [item]="object"> <ds-item-page-title-field class="mr-auto" [item]="object">
</ds-item-page-title-field> </ds-item-page-title-field>

View File

@@ -1,3 +1,4 @@
<ds-themed-results-back-button *ngIf="showBackButton | async" [back]="back"></ds-themed-results-back-button>
<div class="d-flex flex-row"> <div class="d-flex flex-row">
<ds-item-page-title-field [item]="object" class="mr-auto"> <ds-item-page-title-field [item]="object" class="mr-auto">
</ds-item-page-title-field> </ds-item-page-title-field>

View File

@@ -29,6 +29,7 @@ import { OrgUnitSidebarSearchListElementComponent } from './item-list-elements/s
import { PersonSidebarSearchListElementComponent } from './item-list-elements/sidebar-search-list-elements/person/person-sidebar-search-list-element.component'; import { PersonSidebarSearchListElementComponent } from './item-list-elements/sidebar-search-list-elements/person/person-sidebar-search-list-element.component';
import { ProjectSidebarSearchListElementComponent } from './item-list-elements/sidebar-search-list-elements/project/project-sidebar-search-list-element.component'; import { ProjectSidebarSearchListElementComponent } from './item-list-elements/sidebar-search-list-elements/project/project-sidebar-search-list-element.component';
import { ItemSharedModule } from '../../item-page/item-shared.module'; import { ItemSharedModule } from '../../item-page/item-shared.module';
import { ResultsBackButtonModule } from '../../shared/results-back-button/results-back-button.module';
const ENTRY_COMPONENTS = [ const ENTRY_COMPONENTS = [
// put only entry components that use custom decorator // put only entry components that use custom decorator
@@ -69,7 +70,8 @@ const COMPONENTS = [
CommonModule, CommonModule,
ItemSharedModule, ItemSharedModule,
SharedModule, SharedModule,
NgbTooltipModule NgbTooltipModule,
ResultsBackButtonModule
], ],
declarations: [ declarations: [
...COMPONENTS, ...COMPONENTS,

View File

@@ -64,7 +64,7 @@
</p> </p>
<ul class="footer-info list-unstyled small d-flex justify-content-center mb-0"> <ul class="footer-info list-unstyled small d-flex justify-content-center mb-0">
<li> <li>
<a class="text-white" href="javascript:void(0);" <a class="text-white" href="javascript:void(0);"
(click)="showCookieSettings()">{{ 'footer.link.cookies' | translate}}</a> (click)="showCookieSettings()">{{ 'footer.link.cookies' | translate}}</a>
</li> </li>
<li *ngIf="showPrivacyPolicy"> <li *ngIf="showPrivacyPolicy">

View File

@@ -0,0 +1,10 @@
<div *ngIf="buttonVisible$ | async">
<a href="javascript:void(0);"
role="button"
(click)="onClick()"
[attr.aria-label]="'nav.context-help-toggle' | translate"
[title]="'nav.context-help-toggle' | translate"
>
<i class="fas fa-lg fa-fw fa-question-circle ds-context-help-toggle"></i>
</a>
</div>

View File

@@ -0,0 +1,8 @@
.ds-context-help-toggle {
color: var(--ds-header-icon-color);
background-color: var(--ds-header-bg);
&:hover, &:focus {
color: var(--ds-header-icon-color-hover);
}
}

View File

@@ -0,0 +1,63 @@
import { ComponentFixture, TestBed, fakeAsync, tick } from '@angular/core/testing';
import { ContextHelpToggleComponent } from './context-help-toggle.component';
import { TranslateModule } from '@ngx-translate/core';
import { ContextHelpService } from '../../shared/context-help.service';
import { of as observableOf } from 'rxjs';
import { By } from '@angular/platform-browser';
describe('ContextHelpToggleComponent', () => {
let component: ContextHelpToggleComponent;
let fixture: ComponentFixture<ContextHelpToggleComponent>;
let contextHelpService;
beforeEach(async () => {
contextHelpService = jasmine.createSpyObj('contextHelpService', [
'tooltipCount$', 'toggleIcons'
]);
contextHelpService.tooltipCount$.and.returnValue(observableOf(0));
await TestBed.configureTestingModule({
declarations: [ ContextHelpToggleComponent ],
providers: [
{ provide: ContextHelpService, useValue: contextHelpService },
],
imports: [ TranslateModule.forRoot() ]
})
.compileComponents();
});
beforeEach(() => {
fixture = TestBed.createComponent(ContextHelpToggleComponent);
component = fixture.componentInstance;
});
it('should create', () => {
fixture.detectChanges();
expect(component).toBeTruthy();
});
describe('if there are no elements on the page with a tooltip', () => {
it('the toggle should not be visible', fakeAsync(() => {
fixture.detectChanges();
fixture.whenStable().then(() => {
expect(fixture.debugElement.query(By.css('div'))).toBeNull();
});
}));
});
describe('if there are elements on the page with a tooltip', () => {
beforeEach(() => {
contextHelpService.tooltipCount$.and.returnValue(observableOf(1));
fixture.detectChanges();
});
it('clicking the button should toggle context help icon visibility', fakeAsync(() => {
fixture.whenStable().then(() => {
fixture.debugElement.query(By.css('a')).nativeElement.click();
tick();
expect(contextHelpService.toggleIcons).toHaveBeenCalled();
});
}));
});
});

View File

@@ -0,0 +1,36 @@
import { Component, OnInit, OnDestroy } from '@angular/core';
import { ContextHelpService } from '../../shared/context-help.service';
import { Observable, Subscription } from 'rxjs';
import { map } from 'rxjs/operators';
/**
* Renders a "context help toggle" button that toggles the visibility of tooltip buttons on the page.
* If there are no tooltip buttons available on the current page, the toggle is unclickable.
*/
@Component({
selector: 'ds-context-help-toggle',
templateUrl: './context-help-toggle.component.html',
styleUrls: ['./context-help-toggle.component.scss']
})
export class ContextHelpToggleComponent implements OnInit, OnDestroy {
buttonVisible$: Observable<boolean>;
constructor(
private contextHelpService: ContextHelpService,
) { }
private subs: Subscription[];
ngOnInit(): void {
this.buttonVisible$ = this.contextHelpService.tooltipCount$().pipe(map(x => x > 0));
this.subs = [this.buttonVisible$.subscribe()];
}
ngOnDestroy() {
this.subs.forEach(sub => sub.unsubscribe());
}
onClick() {
this.contextHelpService.toggleIcons();
}
}

View File

@@ -8,6 +8,7 @@
<nav role="navigation" [attr.aria-label]="'nav.user.description' | translate" class="navbar navbar-light navbar-expand-md flex-shrink-0 px-0"> <nav role="navigation" [attr.aria-label]="'nav.user.description' | translate" class="navbar navbar-light navbar-expand-md flex-shrink-0 px-0">
<ds-themed-search-navbar></ds-themed-search-navbar> <ds-themed-search-navbar></ds-themed-search-navbar>
<ds-lang-switch></ds-lang-switch> <ds-lang-switch></ds-lang-switch>
<ds-context-help-toggle></ds-context-help-toggle>
<ds-themed-auth-nav-menu></ds-themed-auth-nav-menu> <ds-themed-auth-nav-menu></ds-themed-auth-nav-menu>
<ds-impersonate-navbar></ds-impersonate-navbar> <ds-impersonate-navbar></ds-impersonate-navbar>
<div class="pl-2"> <div class="pl-2">

View File

@@ -15,7 +15,7 @@
a { a {
color: var(--ds-header-icon-color); color: var(--ds-header-icon-color);
&:hover, &focus { &:hover, &:focus {
color: var(--ds-header-icon-color-hover); color: var(--ds-header-icon-color-hover);
} }
} }

View File

@@ -1,6 +1,8 @@
<ng-container *ngVar="(communitiesRD$ | async) as communitiesRD"> <ng-container *ngVar="(communitiesRD$ | async) as communitiesRD">
<div *ngIf="communitiesRD?.hasSucceeded "> <div *ngIf="communitiesRD?.hasSucceeded ">
<h2>{{'home.top-level-communities.head' | translate}}</h2> <h2>
{{'home.top-level-communities.head' | translate}}
</h2>
<p class="lead">{{'home.top-level-communities.help' | translate}}</p> <p class="lead">{{'home.top-level-communities.help' | translate}}</p>
<ds-viewable-collection <ds-viewable-collection
[config]="config" [config]="config"

View File

@@ -14,9 +14,6 @@ import { AbstractSimpleItemActionComponent } from './simple-item-action/abstract
import { ItemPrivateComponent } from './item-private/item-private.component'; import { ItemPrivateComponent } from './item-private/item-private.component';
import { ItemPublicComponent } from './item-public/item-public.component'; import { ItemPublicComponent } from './item-public/item-public.component';
import { ItemDeleteComponent } from './item-delete/item-delete.component'; import { ItemDeleteComponent } from './item-delete/item-delete.component';
import { ItemMetadataComponent } from './item-metadata/item-metadata.component';
import { ThemedItemMetadataComponent } from './item-metadata/themed-item-metadata.component';
import { EditInPlaceFieldComponent } from './item-metadata/edit-in-place-field/edit-in-place-field.component';
import { ItemBitstreamsComponent } from './item-bitstreams/item-bitstreams.component'; import { ItemBitstreamsComponent } from './item-bitstreams/item-bitstreams.component';
import { ItemEditBitstreamComponent } from './item-bitstreams/item-edit-bitstream/item-edit-bitstream.component'; import { ItemEditBitstreamComponent } from './item-bitstreams/item-edit-bitstream/item-edit-bitstream.component';
import { SearchPageModule } from '../../search-page/search-page.module'; import { SearchPageModule } from '../../search-page/search-page.module';
@@ -37,6 +34,7 @@ import { ItemAuthorizationsComponent } from './item-authorizations/item-authoriz
import { ObjectValuesPipe } from '../../shared/utils/object-values-pipe'; import { ObjectValuesPipe } from '../../shared/utils/object-values-pipe';
import { ResourcePoliciesModule } from '../../shared/resource-policies/resource-policies.module'; import { ResourcePoliciesModule } from '../../shared/resource-policies/resource-policies.module';
import { ItemVersionsModule } from '../versions/item-versions.module'; import { ItemVersionsModule } from '../versions/item-versions.module';
import { DsoSharedModule } from '../../dso-shared/dso-shared.module';
/** /**
@@ -53,6 +51,7 @@ import { ItemVersionsModule } from '../versions/item-versions.module';
ResourcePoliciesModule, ResourcePoliciesModule,
NgbModule, NgbModule,
ItemVersionsModule, ItemVersionsModule,
DsoSharedModule,
], ],
declarations: [ declarations: [
EditItemPageComponent, EditItemPageComponent,
@@ -65,16 +64,12 @@ import { ItemVersionsModule } from '../versions/item-versions.module';
ItemPublicComponent, ItemPublicComponent,
ItemDeleteComponent, ItemDeleteComponent,
ItemStatusComponent, ItemStatusComponent,
ItemMetadataComponent,
ThemedItemMetadataComponent,
ItemRelationshipsComponent, ItemRelationshipsComponent,
ItemBitstreamsComponent, ItemBitstreamsComponent,
ItemVersionHistoryComponent, ItemVersionHistoryComponent,
EditInPlaceFieldComponent,
ItemEditBitstreamComponent, ItemEditBitstreamComponent,
ItemEditBitstreamBundleComponent, ItemEditBitstreamBundleComponent,
PaginatedDragAndDropBitstreamListComponent, PaginatedDragAndDropBitstreamListComponent,
EditInPlaceFieldComponent,
EditRelationshipComponent, EditRelationshipComponent,
EditRelationshipListComponent, EditRelationshipListComponent,
ItemCollectionMapperComponent, ItemCollectionMapperComponent,
@@ -87,10 +82,6 @@ import { ItemVersionsModule } from '../versions/item-versions.module';
BundleDataService, BundleDataService,
ObjectValuesPipe ObjectValuesPipe
], ],
exports: [
EditInPlaceFieldComponent,
ThemedItemMetadataComponent,
]
}) })
export class EditItemPageModule { export class EditItemPageModule {

View File

@@ -7,7 +7,6 @@ import { ItemPrivateComponent } from './item-private/item-private.component';
import { ItemPublicComponent } from './item-public/item-public.component'; import { ItemPublicComponent } from './item-public/item-public.component';
import { ItemDeleteComponent } from './item-delete/item-delete.component'; import { ItemDeleteComponent } from './item-delete/item-delete.component';
import { ItemStatusComponent } from './item-status/item-status.component'; import { ItemStatusComponent } from './item-status/item-status.component';
import { ItemMetadataComponent } from './item-metadata/item-metadata.component';
import { ItemBitstreamsComponent } from './item-bitstreams/item-bitstreams.component'; import { ItemBitstreamsComponent } from './item-bitstreams/item-bitstreams.component';
import { ItemCollectionMapperComponent } from './item-collection-mapper/item-collection-mapper.component'; import { ItemCollectionMapperComponent } from './item-collection-mapper/item-collection-mapper.component';
import { ItemMoveComponent } from './item-move/item-move.component'; import { ItemMoveComponent } from './item-move/item-move.component';
@@ -38,6 +37,7 @@ import { ItemPageBitstreamsGuard } from './item-page-bitstreams.guard';
import { ItemPageRelationshipsGuard } from './item-page-relationships.guard'; import { ItemPageRelationshipsGuard } from './item-page-relationships.guard';
import { ItemPageVersionHistoryGuard } from './item-page-version-history.guard'; import { ItemPageVersionHistoryGuard } from './item-page-version-history.guard';
import { ItemPageCollectionMapperGuard } from './item-page-collection-mapper.guard'; import { ItemPageCollectionMapperGuard } from './item-page-collection-mapper.guard';
import { ThemedDsoEditMetadataComponent } from '../../dso-shared/dso-edit-metadata/themed-dso-edit-metadata.component';
/** /**
* Routing module that handles the routing for the Edit Item page administrator functionality * Routing module that handles the routing for the Edit Item page administrator functionality
@@ -75,7 +75,7 @@ import { ItemPageCollectionMapperGuard } from './item-page-collection-mapper.gua
}, },
{ {
path: 'metadata', path: 'metadata',
component: ItemMetadataComponent, component: ThemedDsoEditMetadataComponent,
data: { title: 'item.edit.tabs.metadata.title', showBreadcrumbs: true }, data: { title: 'item.edit.tabs.metadata.title', showBreadcrumbs: true },
canActivate: [ItemPageMetadataGuard] canActivate: [ItemPageMetadataGuard]
}, },

View File

@@ -1,71 +0,0 @@
<td>
<div class="metadata-field">
<div *ngIf="!(editable | async)">
<span >{{metadata?.key?.split('.').join('.&#8203;')}}</span>
</div>
<div *ngIf="(editable | async)" class="field-container">
<ds-validation-suggestions [disable]="fieldUpdate.changeType != 1" [suggestions]="(metadataFieldSuggestions | async)"
[(ngModel)]="metadata.key"
[url]="this.url"
[metadata]="this.metadata"
(submitSuggestion)="update(suggestionControl)"
(clickSuggestion)="update(suggestionControl)"
(typeSuggestion)="update(suggestionControl)"
(dsClickOutside)="checkValidity(suggestionControl)"
(findSuggestions)="findMetadataFieldSuggestions($event)"
#suggestionControl="ngModel"
[valid]="(valid | async) !== false"
dsAutoFocus autoFocusSelector=".suggestion_input"
[ngModelOptions]="{standalone: true}"
></ds-validation-suggestions>
</div>
<small class="text-danger"
*ngIf="(valid | async) === false">{{"item.edit.metadata.metadatafield.invalid" | translate}}</small>
</div>
</td>
<td class="w-100">
<div class="value-field">
<div *ngIf="!(editable | async)">
<span class="dont-break-out preserve-line-breaks">{{metadata?.value}}</span>
</div>
<div *ngIf="(editable | async)" class="field-container">
<textarea class="form-control" type="textarea" attr.aria-labelledby="fieldValue" [(ngModel)]="metadata.value" [dsDebounce]
(onDebounce)="update()"></textarea>
</div>
</div>
</td>
<td class="text-center">
<div class="language-field">
<div *ngIf="!(editable | async)">
<span>{{metadata?.language}}</span>
</div>
<div *ngIf="(editable | async)" class="field-container">
<input class="form-control" type="text" attr.aria-labelledby="fieldLang" [(ngModel)]="metadata.language" [dsDebounce]
(onDebounce)="update()"/>
</div>
</div>
</td>
<td class="text-center">
<div class="btn-group edit-field">
<button [disabled]="!(canSetEditable() | async)" *ngIf="!(editable | async)"
(click)="setEditable(true)" class="btn btn-outline-primary btn-sm"
title="{{'item.edit.metadata.edit.buttons.edit' | translate}}">
<i class="fas fa-edit fa-fw"></i>
</button>
<button [disabled]="!(canSetUneditable() | async) || (valid | async) === false" *ngIf="(editable | async)"
(click)="setEditable(false)" class="btn btn-outline-success btn-sm"
title="{{'item.edit.metadata.edit.buttons.unedit' | translate}}">
<i class="fas fa-check fa-fw"></i>
</button>
<button [disabled]="!(canRemove() | async)" (click)="remove()"
class="btn btn-outline-danger btn-sm"
title="{{'item.edit.metadata.edit.buttons.remove' | translate}}">
<i class="fas fa-trash-alt fa-fw"></i>
</button>
<button [disabled]="!(canUndo() | async)" (click)="removeChangesFromField()"
class="btn btn-outline-warning btn-sm"
title="{{'item.edit.metadata.edit.buttons.undo' | translate}}">
<i class="fas fa-undo-alt fa-fw"></i>
</button>
</div>
</td>

View File

@@ -1,13 +0,0 @@
.btn[disabled] {
color: var(--bs-gray-600);
border-color: var(--bs-gray-600);
z-index: 0; // prevent border colors jumping on hover
}
.metadata-field {
width: var(--ds-edit-item-metadata-field-width);
}
.language-field {
width: var(--ds-edit-item-language-field-width);
}

View File

@@ -1,505 +0,0 @@
import { CUSTOM_ELEMENTS_SCHEMA, DebugElement } from '@angular/core';
import { ComponentFixture, fakeAsync, TestBed, tick, waitForAsync } from '@angular/core/testing';
import { FormsModule } from '@angular/forms';
import { By } from '@angular/platform-browser';
import { TranslateModule } from '@ngx-translate/core';
import { getTestScheduler } from 'jasmine-marbles';
import { of as observableOf } from 'rxjs';
import { TestScheduler } from 'rxjs/testing';
import { MetadataFieldDataService } from '../../../../core/data/metadata-field-data.service';
import { ObjectUpdatesService } from '../../../../core/data/object-updates/object-updates.service';
import { buildPaginatedList } from '../../../../core/data/paginated-list.model';
import { MetadataField } from '../../../../core/metadata/metadata-field.model';
import { MetadataSchema } from '../../../../core/metadata/metadata-schema.model';
import { RegistryService } from '../../../../core/registry/registry.service';
import { MetadatumViewModel } from '../../../../core/shared/metadata.models';
import { InputSuggestion } from '../../../../shared/input-suggestions/input-suggestions.model';
import { createSuccessfulRemoteDataObject$ } from '../../../../shared/remote-data.utils';
import { followLink } from '../../../../shared/utils/follow-link-config.model';
import { EditInPlaceFieldComponent } from './edit-in-place-field.component';
import { MockComponent, MockDirective } from 'ng-mocks';
import { DebounceDirective } from '../../../../shared/utils/debounce.directive';
import { ValidationSuggestionsComponent } from '../../../../shared/input-suggestions/validation-suggestions/validation-suggestions.component';
import { FieldChangeType } from '../../../../core/data/object-updates/field-change-type.model';
let comp: EditInPlaceFieldComponent;
let fixture: ComponentFixture<EditInPlaceFieldComponent>;
let de: DebugElement;
let el: HTMLElement;
let metadataFieldService;
let objectUpdatesService;
let paginatedMetadataFields;
const mdSchema = Object.assign(new MetadataSchema(), { prefix: 'dc' });
const mdSchemaRD$ = createSuccessfulRemoteDataObject$(mdSchema);
const mdField1 = Object.assign(new MetadataField(), {
schema: mdSchemaRD$,
element: 'contributor',
qualifier: 'author'
});
const mdField2 = Object.assign(new MetadataField(), {
schema: mdSchemaRD$,
element: 'title'
});
const mdField3 = Object.assign(new MetadataField(), {
schema: mdSchemaRD$,
element: 'description',
qualifier: 'abstract',
});
const metadatum = Object.assign(new MetadatumViewModel(), {
key: 'dc.description.abstract',
value: 'Example abstract',
language: 'en'
});
const url = 'http://test-url.com/test-url';
const fieldUpdate = {
field: metadatum,
changeType: undefined
};
let scheduler: TestScheduler;
describe('EditInPlaceFieldComponent', () => {
beforeEach(waitForAsync(() => {
scheduler = getTestScheduler();
paginatedMetadataFields = buildPaginatedList(undefined, [mdField1, mdField2, mdField3]);
metadataFieldService = jasmine.createSpyObj({
queryMetadataFields: createSuccessfulRemoteDataObject$(paginatedMetadataFields),
});
objectUpdatesService = jasmine.createSpyObj('objectUpdatesService',
{
saveChangeFieldUpdate: {},
saveRemoveFieldUpdate: {},
setEditableFieldUpdate: {},
setValidFieldUpdate: {},
removeSingleFieldUpdate: {},
isEditable: observableOf(false), // should always return something --> its in ngOnInit
isValid: observableOf(true) // should always return something --> its in ngOnInit
}
);
TestBed.configureTestingModule({
imports: [FormsModule, TranslateModule.forRoot()],
declarations: [
EditInPlaceFieldComponent,
MockDirective(DebounceDirective),
MockComponent(ValidationSuggestionsComponent)
],
providers: [
{ provide: RegistryService, useValue: metadataFieldService },
{ provide: ObjectUpdatesService, useValue: objectUpdatesService },
{ provide: MetadataFieldDataService, useValue: {} }
], schemas: [
CUSTOM_ELEMENTS_SCHEMA
]
}).compileComponents();
}));
beforeEach(() => {
fixture = TestBed.createComponent(EditInPlaceFieldComponent);
comp = fixture.componentInstance; // EditInPlaceFieldComponent test instance
de = fixture.debugElement;
el = de.nativeElement;
comp.url = url;
comp.fieldUpdate = fieldUpdate;
comp.metadata = metadatum;
});
describe('update', () => {
beforeEach(() => {
comp.update();
fixture.detectChanges();
});
it('it should call saveChangeFieldUpdate on the objectUpdatesService with the correct url and metadata', () => {
expect(objectUpdatesService.saveChangeFieldUpdate).toHaveBeenCalledWith(url, metadatum);
});
});
describe('setEditable', () => {
const editable = false;
beforeEach(() => {
comp.setEditable(editable);
fixture.detectChanges();
});
it('it should call setEditableFieldUpdate on the objectUpdatesService with the correct url and uuid and false', () => {
expect(objectUpdatesService.setEditableFieldUpdate).toHaveBeenCalledWith(url, metadatum.uuid, editable);
});
});
describe('editable is true', () => {
beforeEach(() => {
objectUpdatesService.isEditable.and.returnValue(observableOf(true));
fixture.detectChanges();
});
it('the div should contain input fields or textareas', () => {
const inputField = de.queryAll(By.css('input'));
const textAreas = de.queryAll(By.css('textarea'));
expect(inputField.length + textAreas.length).toBeGreaterThan(0);
});
});
describe('editable is false', () => {
beforeEach(() => {
objectUpdatesService.isEditable.and.returnValue(observableOf(false));
fixture.detectChanges();
});
it('the div should contain no input fields or textareas', () => {
const inputField = de.queryAll(By.css('input'));
const textAreas = de.queryAll(By.css('textarea'));
expect(inputField.length + textAreas.length).toBe(0);
});
});
describe('isValid is true', () => {
beforeEach(() => {
objectUpdatesService.isValid.and.returnValue(observableOf(true));
fixture.detectChanges();
});
it('the div should not contain an error message', () => {
const errorMessages = de.queryAll(By.css('small.text-danger'));
expect(errorMessages.length).toBe(0);
});
});
describe('isValid is false', () => {
beforeEach(() => {
objectUpdatesService.isValid.and.returnValue(observableOf(false));
fixture.detectChanges();
});
it('there should be an error message', () => {
const errorMessages = de.queryAll(By.css('small.text-danger'));
expect(errorMessages.length).toBeGreaterThan(0);
});
});
describe('remove', () => {
beforeEach(() => {
comp.remove();
fixture.detectChanges();
});
it('it should call saveRemoveFieldUpdate on the objectUpdatesService with the correct url and metadata', () => {
expect(objectUpdatesService.saveRemoveFieldUpdate).toHaveBeenCalledWith(url, metadatum);
});
});
describe('removeChangesFromField', () => {
beforeEach(() => {
comp.removeChangesFromField();
fixture.detectChanges();
});
it('it should call removeChangesFromField on the objectUpdatesService with the correct url and uuid', () => {
expect(objectUpdatesService.removeSingleFieldUpdate).toHaveBeenCalledWith(url, metadatum.uuid);
});
});
describe('findMetadataFieldSuggestions', () => {
const query = 'query string';
const metadataFieldSuggestions: InputSuggestion[] =
[
{
displayValue: ('dc.' + mdField1.toString()).split('.').join('.&#8203;'),
value: ('dc.' + mdField1.toString())
},
{
displayValue: ('dc.' + mdField2.toString()).split('.').join('.&#8203;'),
value: ('dc.' + mdField2.toString())
},
{
displayValue: ('dc.' + mdField3.toString()).split('.').join('.&#8203;'),
value: ('dc.' + mdField3.toString())
}
];
beforeEach(fakeAsync(() => {
comp.findMetadataFieldSuggestions(query);
tick();
fixture.detectChanges();
}));
it('it should call queryMetadataFields on the metadataFieldService with the correct query', () => {
expect(metadataFieldService.queryMetadataFields).toHaveBeenCalledWith(query, null, true, false, followLink('schema'));
});
it('it should set metadataFieldSuggestions to the right value', () => {
const expected = 'a';
scheduler.expectObservable(comp.metadataFieldSuggestions).toBe(expected, { a: metadataFieldSuggestions });
});
});
describe('canSetEditable', () => {
describe('when editable is currently true', () => {
beforeEach(() => {
objectUpdatesService.isEditable.and.returnValue(observableOf(true));
fixture.detectChanges();
});
it('canSetEditable should return an observable emitting false', () => {
const expected = '(a|)';
scheduler.expectObservable(comp.canSetEditable()).toBe(expected, { a: false });
});
});
describe('when editable is currently false', () => {
beforeEach(() => {
objectUpdatesService.isEditable.and.returnValue(observableOf(false));
fixture.detectChanges();
});
describe('when the fieldUpdate\'s changeType is currently not REMOVE', () => {
beforeEach(() => {
comp.fieldUpdate.changeType = FieldChangeType.ADD;
fixture.detectChanges();
});
it('canSetEditable should return an observable emitting true', () => {
const expected = '(a|)';
scheduler.expectObservable(comp.canSetEditable()).toBe(expected, { a: true });
});
});
describe('when the fieldUpdate\'s changeType is currently REMOVE', () => {
beforeEach(() => {
comp.fieldUpdate.changeType = FieldChangeType.REMOVE;
fixture.detectChanges();
});
it('canSetEditable should return an observable emitting false', () => {
const expected = '(a|)';
scheduler.expectObservable(comp.canSetEditable()).toBe(expected, { a: false });
});
});
});
});
describe('canSetUneditable', () => {
describe('when editable is currently true', () => {
beforeEach(() => {
objectUpdatesService.isEditable.and.returnValue(observableOf(true));
fixture.detectChanges();
});
it('canSetUneditable should return an observable emitting true', () => {
const expected = '(a|)';
scheduler.expectObservable(comp.canSetUneditable()).toBe(expected, { a: true });
});
});
describe('when editable is currently false', () => {
beforeEach(() => {
objectUpdatesService.isEditable.and.returnValue(observableOf(false));
fixture.detectChanges();
});
it('canSetUneditable should return an observable emitting false', () => {
const expected = '(a|)';
scheduler.expectObservable(comp.canSetUneditable()).toBe(expected, { a: false });
});
});
});
describe('when canSetEditable emits true', () => {
beforeEach(() => {
objectUpdatesService.isEditable.and.returnValue(observableOf(false));
spyOn(comp, 'canSetEditable').and.returnValue(observableOf(true));
fixture.detectChanges();
});
it('the div should have an enabled button with an edit icon', () => {
const editIcon = de.query(By.css('i.fa-edit')).parent.nativeElement.disabled;
expect(editIcon).toBe(false);
});
});
describe('when canSetEditable emits false', () => {
beforeEach(() => {
objectUpdatesService.isEditable.and.returnValue(observableOf(false));
spyOn(comp, 'canSetEditable').and.returnValue(observableOf(false));
fixture.detectChanges();
});
it('the div should have a disabled button with an edit icon', () => {
const editIcon = de.query(By.css('i.fa-edit')).parent.nativeElement.disabled;
expect(editIcon).toBe(true);
});
});
describe('when canSetUneditable emits true', () => {
beforeEach(() => {
objectUpdatesService.isEditable.and.returnValue(observableOf(true));
spyOn(comp, 'canSetUneditable').and.returnValue(observableOf(true));
fixture.detectChanges();
});
it('the div should have an enabled button with a check icon', () => {
const checkButtonAttrs = de.query(By.css('i.fa-check')).parent.nativeElement.disabled;
expect(checkButtonAttrs).toBe(false);
});
});
describe('when canSetUneditable emits false', () => {
beforeEach(() => {
objectUpdatesService.isEditable.and.returnValue(observableOf(true));
spyOn(comp, 'canSetUneditable').and.returnValue(observableOf(false));
fixture.detectChanges();
});
it('the div should have a disabled button with a check icon', () => {
const checkButtonAttrs = de.query(By.css('i.fa-check')).parent.nativeElement.disabled;
expect(checkButtonAttrs).toBe(true);
});
});
describe('when canRemove emits true', () => {
beforeEach(() => {
spyOn(comp, 'canRemove').and.returnValue(observableOf(true));
fixture.detectChanges();
});
it('the div should have an enabled button with a trash icon', () => {
const trashButtonAttrs = de.query(By.css('i.fa-trash-alt')).parent.nativeElement.disabled;
expect(trashButtonAttrs).toBe(false);
});
});
describe('when canRemove emits false', () => {
beforeEach(() => {
spyOn(comp, 'canRemove').and.returnValue(observableOf(false));
fixture.detectChanges();
});
it('the div should have a disabled button with a trash icon', () => {
const trashButtonAttrs = de.query(By.css('i.fa-trash-alt')).parent.nativeElement.disabled;
expect(trashButtonAttrs).toBe(true);
});
});
describe('when canUndo emits true', () => {
beforeEach(() => {
spyOn(comp, 'canUndo').and.returnValue(observableOf(true));
fixture.detectChanges();
});
it('the div should have an enabled button with an undo icon', () => {
const undoIcon = de.query(By.css('i.fa-undo-alt')).parent.nativeElement.disabled;
expect(undoIcon).toBe(false);
});
});
describe('when canUndo emits false', () => {
beforeEach(() => {
spyOn(comp, 'canUndo').and.returnValue(observableOf(false));
fixture.detectChanges();
});
it('the div should have a disabled button with an undo icon', () => {
const undoIcon = de.query(By.css('i.fa-undo-alt')).parent.nativeElement.disabled;
expect(undoIcon).toBe(true);
});
});
describe('canRemove', () => {
describe('when the fieldUpdate\'s changeType is currently not REMOVE or ADD', () => {
beforeEach(() => {
comp.fieldUpdate.changeType = FieldChangeType.UPDATE;
fixture.detectChanges();
});
it('canRemove should return an observable emitting true', () => {
const expected = '(a|)';
scheduler.expectObservable(comp.canRemove()).toBe(expected, { a: true });
});
});
describe('when the fieldUpdate\'s changeType is currently ADD', () => {
beforeEach(() => {
comp.fieldUpdate.changeType = FieldChangeType.ADD;
fixture.detectChanges();
});
it('canRemove should return an observable emitting false', () => {
const expected = '(a|)';
scheduler.expectObservable(comp.canRemove()).toBe(expected, { a: false });
});
});
});
describe('canUndo', () => {
describe('when editable is currently true', () => {
beforeEach(() => {
objectUpdatesService.isEditable.and.returnValue(observableOf(true));
comp.fieldUpdate.changeType = undefined;
fixture.detectChanges();
});
it('canUndo should return an observable emitting true', () => {
const expected = '(a|)';
scheduler.expectObservable(comp.canUndo()).toBe(expected, { a: true });
});
});
describe('when editable is currently false', () => {
describe('when the fieldUpdate\'s changeType is currently ADD, UPDATE or REMOVE', () => {
beforeEach(() => {
comp.fieldUpdate.changeType = FieldChangeType.ADD;
fixture.detectChanges();
});
it('canUndo should return an observable emitting true', () => {
const expected = '(a|)';
scheduler.expectObservable(comp.canUndo()).toBe(expected, { a: true });
});
});
describe('when the fieldUpdate\'s changeType is currently undefined', () => {
beforeEach(() => {
comp.fieldUpdate.changeType = undefined;
fixture.detectChanges();
});
it('canUndo should return an observable emitting false', () => {
const expected = '(a|)';
scheduler.expectObservable(comp.canUndo()).toBe(expected, { a: false });
});
});
});
});
describe('canEditMetadataField', () => {
describe('when the fieldUpdate\'s changeType is currently ADD', () => {
beforeEach(() => {
objectUpdatesService.isEditable.and.returnValue(observableOf(true));
comp.fieldUpdate.changeType = FieldChangeType.ADD;
fixture.detectChanges();
});
it('can edit metadata field', () => {
const disabledMetadataField = fixture.debugElement.query(By.css('ds-validation-suggestions'))
.componentInstance.disable;
expect(disabledMetadataField).toBe(false);
});
});
describe('when the fieldUpdate\'s changeType is currently REMOVE', () => {
beforeEach(() => {
objectUpdatesService.isEditable.and.returnValue(observableOf(true));
comp.fieldUpdate.changeType = FieldChangeType.REMOVE;
fixture.detectChanges();
});
it('can edit metadata field', () => {
const disabledMetadataField = fixture.debugElement.query(By.css('ds-validation-suggestions'))
.componentInstance.disable;
expect(disabledMetadataField).toBe(true);
});
});
describe('when the fieldUpdate\'s changeType is currently UPDATE', () => {
beforeEach(() => {
objectUpdatesService.isEditable.and.returnValue(observableOf(true));
comp.fieldUpdate.changeType = FieldChangeType.UPDATE;
fixture.detectChanges();
});
it('can edit metadata field', () => {
const disabledMetadataField = fixture.debugElement.query(By.css('ds-validation-suggestions'))
.componentInstance.disable;
expect(disabledMetadataField).toBe(true);
});
});
});
});

View File

@@ -1,201 +0,0 @@
import { Component, Input, OnChanges, OnInit } from '@angular/core';
import {
metadataFieldsToString,
getFirstSucceededRemoteData
} from '../../../../core/shared/operators';
import { hasValue, isNotEmpty } from '../../../../shared/empty.util';
import { RegistryService } from '../../../../core/registry/registry.service';
import cloneDeep from 'lodash/cloneDeep';
import { BehaviorSubject, Observable, of as observableOf } from 'rxjs';
import { map } from 'rxjs/operators';
import { ObjectUpdatesService } from '../../../../core/data/object-updates/object-updates.service';
import { NgModel } from '@angular/forms';
import { MetadatumViewModel } from '../../../../core/shared/metadata.models';
import { InputSuggestion } from '../../../../shared/input-suggestions/input-suggestions.model';
import { followLink } from '../../../../shared/utils/follow-link-config.model';
import { FieldUpdate } from '../../../../core/data/object-updates/field-update.model';
import { FieldChangeType } from '../../../../core/data/object-updates/field-change-type.model';
@Component({
// eslint-disable-next-line @angular-eslint/component-selector
selector: '[ds-edit-in-place-field]',
styleUrls: ['./edit-in-place-field.component.scss'],
templateUrl: './edit-in-place-field.component.html',
})
/**
* Component that displays a single metadatum of an item on the edit page
*/
export class EditInPlaceFieldComponent implements OnInit, OnChanges {
/**
* The current field, value and state of the metadatum
*/
@Input() fieldUpdate: FieldUpdate;
/**
* The current url of this page
*/
@Input() url: string;
/**
* The metadatum of this field
*/
@Input() metadata: MetadatumViewModel;
/**
* Emits whether or not this field is currently editable
*/
editable: Observable<boolean>;
/**
* Emits whether or not this field is currently valid
*/
valid: Observable<boolean>;
/**
* The current suggestions for the metadatafield when editing
*/
metadataFieldSuggestions: BehaviorSubject<InputSuggestion[]> = new BehaviorSubject([]);
constructor(
private registryService: RegistryService,
private objectUpdatesService: ObjectUpdatesService,
) {
}
/**
* Sets up an observable that keeps track of the current editable and valid state of this field
*/
ngOnInit(): void {
this.editable = this.objectUpdatesService.isEditable(this.url, this.metadata.uuid);
this.valid = this.objectUpdatesService.isValid(this.url, this.metadata.uuid);
}
/**
* Sends a new change update for this field to the object updates service
*/
update(ngModel?: NgModel) {
this.objectUpdatesService.saveChangeFieldUpdate(this.url, cloneDeep(this.metadata));
if (hasValue(ngModel)) {
this.checkValidity(ngModel);
}
}
/**
* Method to check the validity of a form control
* @param ngModel
*/
public checkValidity(ngModel: NgModel) {
ngModel.control.setValue(ngModel.viewModel);
ngModel.control.updateValueAndValidity();
this.objectUpdatesService.setValidFieldUpdate(this.url, this.metadata.uuid, ngModel.control.valid);
}
/**
* Sends a new editable state for this field to the service to change it
* @param editable The new editable state for this field
*/
setEditable(editable: boolean) {
this.objectUpdatesService.setEditableFieldUpdate(this.url, this.metadata.uuid, editable);
}
/**
* Sends a new remove update for this field to the object updates service
*/
remove() {
this.objectUpdatesService.saveRemoveFieldUpdate(this.url, cloneDeep(this.metadata));
}
/**
* Notifies the object updates service that the updates for the current field can be removed
*/
removeChangesFromField() {
this.objectUpdatesService.removeSingleFieldUpdate(this.url, this.metadata.uuid);
}
/**
* Sets the current metadatafield based on the fieldUpdate input field
*/
ngOnChanges(): void {
this.metadata = cloneDeep(this.fieldUpdate.field) as MetadatumViewModel;
}
/**
* Requests all metadata fields that contain the query string in their key
* Then sets all found metadata fields as metadataFieldSuggestions
* Ignores fields from metadata schemas "relation" and "relationship"
* @param query The query to look for
*/
findMetadataFieldSuggestions(query: string) {
if (isNotEmpty(query)) {
return this.registryService.queryMetadataFields(query, null, true, false, followLink('schema')).pipe(
getFirstSucceededRemoteData(),
metadataFieldsToString(),
).subscribe((fieldNames: string[]) => {
this.setInputSuggestions(fieldNames);
});
} else {
this.metadataFieldSuggestions.next([]);
}
}
/**
* Set the list of input suggestion with the given Metadata fields, which all require a resolved MetadataSchema
* @param fields list of Metadata fields, which all require a resolved MetadataSchema
*/
setInputSuggestions(fields: string[]) {
this.metadataFieldSuggestions.next(
fields.map((fieldName: string) => {
return {
displayValue: fieldName.split('.').join('.&#8203;'),
value: fieldName
};
})
);
}
/**
* Check if a user should be allowed to edit this field
* @return an observable that emits true when the user should be able to edit this field and false when they should not
*/
canSetEditable(): Observable<boolean> {
return this.editable.pipe(
map((editable: boolean) => {
if (editable) {
return false;
} else {
return this.fieldUpdate.changeType !== FieldChangeType.REMOVE;
}
})
);
}
/**
* Check if a user should be allowed to disabled editing this field
* @return an observable that emits true when the user should be able to disable editing this field and false when they should not
*/
canSetUneditable(): Observable<boolean> {
return this.editable;
}
/**
* Check if a user should be allowed to remove this field
* @return an observable that emits true when the user should be able to remove this field and false when they should not
*/
canRemove(): Observable<boolean> {
return observableOf(this.fieldUpdate.changeType !== FieldChangeType.REMOVE && this.fieldUpdate.changeType !== FieldChangeType.ADD);
}
/**
* Check if a user should be allowed to undo changes to this field
* @return an observable that emits true when the user should be able to undo changes to this field and false when they should not
*/
canUndo(): Observable<boolean> {
return this.editable.pipe(
map((editable: boolean) => this.fieldUpdate.changeType >= 0 || editable)
);
}
protected isNotEmpty(value): boolean {
return isNotEmpty(value);
}
}

View File

@@ -1,69 +0,0 @@
<div class="item-metadata">
<div class="button-row top d-flex mb-2 space-children-mr">
<button class="mr-auto btn btn-success"
(click)="add()"><i
class="fas fa-plus"></i>
<span class="d-none d-sm-inline">&nbsp;{{"item.edit.metadata.add-button" | translate}}</span>
</button>
<button class="btn btn-warning" *ngIf="isReinstatable() | async"
(click)="reinstate()"><i
class="fas fa-undo-alt"></i>
<span class="d-none d-sm-inline">&nbsp;{{"item.edit.metadata.reinstate-button" | translate}}</span>
</button>
<button class="btn btn-primary" [disabled]="!(hasChanges() | async) || !(isValid() | async)"
(click)="submit()"><i
class="fas fa-save"></i>
<span class="d-none d-sm-inline">&nbsp;{{"item.edit.metadata.save-button" | translate}}</span>
</button>
<button class="btn btn-danger" *ngIf="!(isReinstatable() | async)"
[disabled]="!(hasChanges() | async)"
(click)="discard()"><i
class="fas fa-times"></i>
<span class="d-none d-sm-inline">&nbsp;{{"item.edit.metadata.discard-button" | translate}}</span>
</button>
</div>
<table class="table table-responsive table-striped table-bordered"
*ngIf="((updates$ | async)| dsObjectValues).length > 0">
<thead>
<tr>
<th><span id="fieldName">{{'item.edit.metadata.headers.field' | translate}}</span></th>
<th><span id="fieldValue">{{'item.edit.metadata.headers.value' | translate}}</span></th>
<th class="text-center"><span id="fieldLang">{{'item.edit.metadata.headers.language' | translate}}</span></th>
<th class="text-center">{{'item.edit.metadata.headers.edit' | translate}}</th>
</tr>
</thead>
<tbody>
<tr *ngFor="let updateValue of ((updates$ | async)| dsObjectValues); trackBy: trackUpdate"
ds-edit-in-place-field
[fieldUpdate]="updateValue || {}"
[url]="url"
[ngClass]="{
'table-warning': updateValue.changeType === 0,
'table-danger': updateValue.changeType === 2,
'table-success': updateValue.changeType === 1
}">
</tr>
</tbody>
</table>
<div *ngIf="((updates$ | async)| dsObjectValues).length == 0">
<ds-alert [content]="'item.edit.metadata.empty'" [type]="AlertTypeEnum.Info"></ds-alert>
</div>
<div class="button-row bottom">
<div class="mt-2 float-right space-children-mr ml-gap">
<button class="btn btn-warning" *ngIf="isReinstatable() | async"
(click)="reinstate()"><i
class="fas fa-undo-alt"></i> {{"item.edit.metadata.reinstate-button" | translate}}
</button>
<button class="btn btn-primary" [disabled]="!(hasChanges() | async)"
(click)="submit()"><i
class="fas fa-save"></i> {{"item.edit.metadata.save-button" | translate}}
</button>
<button class="btn btn-danger" *ngIf="!(isReinstatable() | async)"
[disabled]="!(hasChanges() | async)"
(click)="discard()"><i
class="fas fa-times"></i> {{"item.edit.metadata.discard-button" | translate}}
</button>
</div>
</div>
</div>

View File

@@ -1,20 +0,0 @@
.button-row {
.btn {
margin-right: var(--ds-gap);
&:last-child {
margin-right: 0;
}
@media screen and (min-width: map-get($grid-breakpoints, sm)) {
min-width: var(--ds-edit-item-button-min-width);
}
}
&.top .btn {
margin-top: calc(var(--bs-spacer) / 2);
margin-bottom: calc(var(--bs-spacer) / 2);
}
}

View File

@@ -1,290 +0,0 @@
import { ComponentFixture, TestBed, waitForAsync } from '@angular/core/testing';
import { DebugElement, NO_ERRORS_SCHEMA } from '@angular/core';
import { of as observableOf } from 'rxjs';
import { getTestScheduler } from 'jasmine-marbles';
import { ItemMetadataComponent } from './item-metadata.component';
import { TestScheduler } from 'rxjs/testing';
import { SharedModule } from '../../../shared/shared.module';
import { ObjectUpdatesService } from '../../../core/data/object-updates/object-updates.service';
import { ActivatedRoute, Router } from '@angular/router';
import { NotificationsService } from '../../../shared/notifications/notifications.service';
import { TranslateModule } from '@ngx-translate/core';
import { ItemDataService } from '../../../core/data/item-data.service';
import { By } from '@angular/platform-browser';
import { INotification, Notification } from '../../../shared/notifications/models/notification.model';
import { NotificationType } from '../../../shared/notifications/models/notification-type';
import { RouterStub } from '../../../shared/testing/router.stub';
import { Item } from '../../../core/shared/item.model';
import { MetadatumViewModel } from '../../../core/shared/metadata.models';
import { RegistryService } from '../../../core/registry/registry.service';
import { MetadataSchema } from '../../../core/metadata/metadata-schema.model';
import { MetadataField } from '../../../core/metadata/metadata-field.model';
import { createSuccessfulRemoteDataObject, createSuccessfulRemoteDataObject$ } from '../../../shared/remote-data.utils';
import { ObjectCacheService } from '../../../core/cache/object-cache.service';
import { DSOSuccessResponse } from '../../../core/cache/response.models';
import { createPaginatedList } from '../../../shared/testing/utils.test';
import { FieldChangeType } from '../../../core/data/object-updates/field-change-type.model';
let comp: any;
let fixture: ComponentFixture<ItemMetadataComponent>;
let de: DebugElement;
let el: HTMLElement;
let objectUpdatesService;
const infoNotification: INotification = new Notification('id', NotificationType.Info, 'info');
const warningNotification: INotification = new Notification('id', NotificationType.Warning, 'warning');
const successNotification: INotification = new Notification('id', NotificationType.Success, 'success');
const date = new Date();
const router = new RouterStub();
let metadataFieldService;
let paginatedMetadataFields;
let routeStub;
let objectCacheService;
const mdSchema = Object.assign(new MetadataSchema(), { prefix: 'dc' });
const mdField1 = Object.assign(new MetadataField(), {
schema: mdSchema,
element: 'contributor',
qualifier: 'author'
});
const mdField2 = Object.assign(new MetadataField(), { schema: mdSchema, element: 'title' });
const mdField3 = Object.assign(new MetadataField(), {
schema: mdSchema,
element: 'description',
qualifier: 'abstract'
});
let itemService;
const notificationsService = jasmine.createSpyObj('notificationsService',
{
info: infoNotification,
warning: warningNotification,
success: successNotification
}
);
const metadatum1 = Object.assign(new MetadatumViewModel(), {
key: 'dc.description.abstract',
value: 'Example abstract',
language: 'en'
});
const metadatum2 = Object.assign(new MetadatumViewModel(), {
key: 'dc.title',
value: 'Title test',
language: 'de'
});
const metadatum3 = Object.assign(new MetadatumViewModel(), {
key: 'dc.contributor.author',
value: 'Shakespeare, William',
});
const url = 'http://test-url.com/test-url';
router.url = url;
const fieldUpdate1 = {
field: metadatum1,
changeType: undefined
};
const fieldUpdate2 = {
field: metadatum2,
changeType: FieldChangeType.REMOVE
};
const fieldUpdate3 = {
field: metadatum3,
changeType: undefined
};
const operation1 = { op: 'remove', path: '/metadata/dc.title/1' };
let scheduler: TestScheduler;
let item;
describe('ItemMetadataComponent', () => {
beforeEach(waitForAsync(() => {
item = Object.assign(new Item(), {
metadata: {
[metadatum1.key]: [metadatum1],
[metadatum2.key]: [metadatum2],
[metadatum3.key]: [metadatum3]
},
_links: {
self: {
href: 'https://rest.api/core/items/a36d8bd2-8e8c-4969-9b1f-a574c2064983'
}
}
},
{
lastModified: date
}
)
;
itemService = jasmine.createSpyObj('itemService', {
update: createSuccessfulRemoteDataObject$(item),
commitUpdates: {},
patch: observableOf(new DSOSuccessResponse(['item-selflink'], 200, 'OK')),
findByHref: createSuccessfulRemoteDataObject$(item)
});
routeStub = {
data: observableOf({}),
parent: {
data: observableOf({ dso: createSuccessfulRemoteDataObject(item) })
}
};
paginatedMetadataFields = createPaginatedList([mdField1, mdField2, mdField3]);
metadataFieldService = jasmine.createSpyObj({
getAllMetadataFields: createSuccessfulRemoteDataObject$(paginatedMetadataFields)
});
scheduler = getTestScheduler();
objectUpdatesService = jasmine.createSpyObj('objectUpdatesService',
{
getFieldUpdates: observableOf({
[metadatum1.uuid]: fieldUpdate1,
[metadatum2.uuid]: fieldUpdate2,
[metadatum3.uuid]: fieldUpdate3
}),
saveAddFieldUpdate: {},
discardFieldUpdates: {},
reinstateFieldUpdates: observableOf(true),
initialize: {},
getUpdatedFields: observableOf([metadatum1, metadatum2, metadatum3]),
getLastModified: observableOf(date),
hasUpdates: observableOf(true),
isReinstatable: observableOf(false), // should always return something --> its in ngOnInit
isValidPage: observableOf(true),
createPatch: observableOf([
operation1
])
}
);
objectCacheService = jasmine.createSpyObj('objectCacheService', ['addPatch']);
TestBed.configureTestingModule({
imports: [SharedModule, TranslateModule.forRoot()],
declarations: [ItemMetadataComponent],
providers: [
{ provide: ItemDataService, useValue: itemService },
{ provide: ObjectUpdatesService, useValue: objectUpdatesService },
{ provide: Router, useValue: router },
{ provide: ActivatedRoute, useValue: routeStub },
{ provide: NotificationsService, useValue: notificationsService },
{ provide: RegistryService, useValue: metadataFieldService },
{ provide: ObjectCacheService, useValue: objectCacheService },
], schemas: [
NO_ERRORS_SCHEMA
]
}).compileComponents();
})
);
beforeEach(() => {
fixture = TestBed.createComponent(ItemMetadataComponent);
comp = fixture.componentInstance; // EditInPlaceFieldComponent test instance
de = fixture.debugElement;
el = de.nativeElement;
comp.url = url;
fixture.detectChanges();
});
describe('add', () => {
const md = new MetadatumViewModel();
beforeEach(() => {
comp.add(md);
});
it('it should call saveAddFieldUpdate on the objectUpdatesService with the correct url and metadata', () => {
expect(objectUpdatesService.saveAddFieldUpdate).toHaveBeenCalledWith(url, md);
});
});
describe('discard', () => {
beforeEach(() => {
comp.discard();
});
it('it should call discardFieldUpdates on the objectUpdatesService with the correct url and notification', () => {
expect(objectUpdatesService.discardFieldUpdates).toHaveBeenCalledWith(url, infoNotification);
});
});
describe('reinstate', () => {
beforeEach(() => {
comp.reinstate();
});
it('it should call reinstateFieldUpdates on the objectUpdatesService with the correct url', () => {
expect(objectUpdatesService.reinstateFieldUpdates).toHaveBeenCalledWith(url);
});
});
describe('submit', () => {
beforeEach(() => {
comp.submit();
});
it('it should call reinstateFieldUpdates on the objectUpdatesService with the correct url and metadata', () => {
expect(objectUpdatesService.createPatch).toHaveBeenCalledWith(url);
expect(itemService.patch).toHaveBeenCalledWith(comp.item, [operation1]);
expect(objectUpdatesService.getFieldUpdates).toHaveBeenCalledWith(url, comp.item.metadataAsList);
});
});
describe('hasChanges', () => {
describe('when the objectUpdatesService\'s hasUpdated method returns true', () => {
beforeEach(() => {
objectUpdatesService.hasUpdates.and.returnValue(observableOf(true));
});
it('should return an observable that emits true', () => {
const expected = '(a|)';
scheduler.expectObservable(comp.hasChanges()).toBe(expected, { a: true });
});
});
describe('when the objectUpdatesService\'s hasUpdated method returns false', () => {
beforeEach(() => {
objectUpdatesService.hasUpdates.and.returnValue(observableOf(false));
});
it('should return an observable that emits false', () => {
const expected = '(a|)';
scheduler.expectObservable(comp.hasChanges()).toBe(expected, { a: false });
});
});
});
describe('changeType is UPDATE', () => {
beforeEach(() => {
fieldUpdate1.changeType = FieldChangeType.UPDATE;
fixture.detectChanges();
});
it('the div should have class table-warning', () => {
const element = de.queryAll(By.css('tr'))[1].nativeElement;
expect(element.classList).toContain('table-warning');
});
});
describe('changeType is ADD', () => {
beforeEach(() => {
fieldUpdate1.changeType = FieldChangeType.ADD;
fixture.detectChanges();
});
it('the div should have class table-success', () => {
const element = de.queryAll(By.css('tr'))[1].nativeElement;
expect(element.classList).toContain('table-success');
});
});
describe('changeType is REMOVE', () => {
beforeEach(() => {
fieldUpdate1.changeType = FieldChangeType.REMOVE;
fixture.detectChanges();
});
it('the div should have class table-danger', () => {
const element = de.queryAll(By.css('tr'))[1].nativeElement;
expect(element.classList).toContain('table-danger');
});
});
});

View File

@@ -1,135 +0,0 @@
import { Component, Input } from '@angular/core';
import { Item } from '../../../core/shared/item.model';
import { ItemDataService } from '../../../core/data/item-data.service';
import { ObjectUpdatesService } from '../../../core/data/object-updates/object-updates.service';
import { ActivatedRoute, Router } from '@angular/router';
import cloneDeep from 'lodash/cloneDeep';
import { first, switchMap } from 'rxjs/operators';
import { getFirstCompletedRemoteData } from '../../../core/shared/operators';
import { RemoteData } from '../../../core/data/remote-data';
import { NotificationsService } from '../../../shared/notifications/notifications.service';
import { TranslateService } from '@ngx-translate/core';
import { MetadataValue, MetadatumViewModel } from '../../../core/shared/metadata.models';
import { AbstractItemUpdateComponent } from '../abstract-item-update/abstract-item-update.component';
import { UpdateDataService } from '../../../core/data/update-data.service';
import { hasNoValue, hasValue } from '../../../shared/empty.util';
import { AlertType } from '../../../shared/alert/aletr-type';
import { Operation } from 'fast-json-patch';
import { MetadataPatchOperationService } from '../../../core/data/object-updates/patch-operation-service/metadata-patch-operation.service';
@Component({
selector: 'ds-item-metadata',
styleUrls: ['./item-metadata.component.scss'],
templateUrl: './item-metadata.component.html',
})
/**
* Component for displaying an item's metadata edit page
*/
export class ItemMetadataComponent extends AbstractItemUpdateComponent {
/**
* The AlertType enumeration
* @type {AlertType}
*/
public AlertTypeEnum = AlertType;
/**
* A custom update service to use for adding and committing patches
* This will default to the ItemDataService
*/
@Input() updateService: UpdateDataService<Item>;
constructor(
public itemService: ItemDataService,
public objectUpdatesService: ObjectUpdatesService,
public router: Router,
public notificationsService: NotificationsService,
public translateService: TranslateService,
public route: ActivatedRoute,
) {
super(itemService, objectUpdatesService, router, notificationsService, translateService, route);
}
/**
* Set up and initialize all fields
*/
ngOnInit(): void {
super.ngOnInit();
if (hasNoValue(this.updateService)) {
this.updateService = this.itemService;
}
}
/**
* Initialize the values and updates of the current item's metadata fields
*/
public initializeUpdates(): void {
this.updates$ = this.objectUpdatesService.getFieldUpdates(this.url, this.item.metadataAsList);
}
/**
* Initialize the prefix for notification messages
*/
public initializeNotificationsPrefix(): void {
this.notificationsPrefix = 'item.edit.metadata.notifications.';
}
/**
* Sends a new add update for a field to the object updates service
* @param metadata The metadata to add, if no parameter is supplied, create a new Metadatum
*/
add(metadata: MetadatumViewModel = new MetadatumViewModel()) {
this.objectUpdatesService.saveAddFieldUpdate(this.url, metadata);
}
/**
* Sends all initial values of this item to the object updates service
*/
public initializeOriginalFields() {
this.objectUpdatesService.initialize(this.url, this.item.metadataAsList, this.item.lastModified, MetadataPatchOperationService);
}
/**
* Requests all current metadata for this item and requests the item service to update the item
* Makes sure the new version of the item is rendered on the page
*/
public submit() {
this.isValid().pipe(first()).subscribe((isValid) => {
if (isValid) {
this.objectUpdatesService.createPatch(this.url).pipe(
first(),
switchMap((patch: Operation[]) => {
return this.updateService.patch(this.item, patch).pipe(
getFirstCompletedRemoteData()
);
})
).subscribe(
(rd: RemoteData<Item>) => {
if (rd.hasFailed) {
this.notificationsService.error(this.getNotificationTitle('error'), rd.errorMessage);
} else {
this.item = rd.payload;
this.checkAndFixMetadataUUIDs();
this.initializeOriginalFields();
this.updates$ = this.objectUpdatesService.getFieldUpdates(this.url, this.item.metadataAsList);
this.notificationsService.success(this.getNotificationTitle('saved'), this.getNotificationContent('saved'));
}
}
);
} else {
this.notificationsService.error(this.getNotificationTitle('invalid'), this.getNotificationContent('invalid'));
}
});
}
/**
* Check for empty metadata UUIDs and fix them (empty UUIDs would break the object-update service)
*/
checkAndFixMetadataUUIDs() {
const metadata = cloneDeep(this.item.metadata);
Object.keys(this.item.metadata).forEach((key: string) => {
metadata[key] = this.item.metadata[key].map((value) => hasValue(value.uuid) ? value : Object.assign(new MetadataValue(), value));
});
this.item.metadata = metadata;
}
}

View File

@@ -1,34 +0,0 @@
import { Component, Input } from '@angular/core';
import { Item } from '../../../core/shared/item.model';
import { UpdateDataService } from '../../../core/data/update-data.service';
import { ItemMetadataComponent } from './item-metadata.component';
import { ThemedComponent } from '../../../shared/theme-support/themed.component';
@Component({
selector: 'ds-themed-item-metadata',
styleUrls: [],
templateUrl: './../../../shared/theme-support/themed.component.html',
})
/**
* Component for displaying an item's metadata edit page
*/
export class ThemedItemMetadataComponent extends ThemedComponent<ItemMetadataComponent> {
@Input() item: Item;
@Input() updateService: UpdateDataService<Item>;
protected inAndOutputNames: (keyof ItemMetadataComponent & keyof this)[] = ['item', 'updateService'];
protected getComponentName(): string {
return 'ItemMetadataComponent';
}
protected importThemedComponent(themeName: string): Promise<any> {
return import(`../../../../themes/${themeName}/app/item-page/edit-item-page/item-metadata/item-metadata.component`);
}
protected importUnthemedComponent(): Promise<any> {
return import(`./item-metadata.component`);
}
}

View File

@@ -5,12 +5,12 @@
</div> </div>
<div class="col-9 float-left action-button"> <div class="col-9 float-left action-button">
<span *ngIf="operation.authorized"> <span *ngIf="operation.authorized">
<button class="btn btn-outline-primary" [disabled]="operation.disabled" [routerLink]="operation.operationUrl"> <button class="btn btn-outline-primary" [disabled]="operation.disabled" [routerLink]="operation.operationUrl" [attr.aria-label]="'item.edit.tabs.status.buttons.' + operation.operationKey + '.button' | translate">
{{'item.edit.tabs.status.buttons.' + operation.operationKey + '.button' | translate}} {{'item.edit.tabs.status.buttons.' + operation.operationKey + '.button' | translate}}
</button> </button>
</span> </span>
<span *ngIf="!operation.authorized" [ngbTooltip]="'item.edit.tabs.status.buttons.unauthorized' | translate"> <span *ngIf="!operation.authorized" [ngbTooltip]="'item.edit.tabs.status.buttons.unauthorized' | translate">
<button class="btn btn-outline-primary" [disabled]="true"> <button class="btn btn-outline-primary" [disabled]="true" [attr.aria-label]="'item.edit.tabs.status.buttons.' + operation.operationKey + '.button' | translate">
{{'item.edit.tabs.status.buttons.' + operation.operationKey + '.button' | translate}} {{'item.edit.tabs.status.buttons.' + operation.operationKey + '.button' | translate}}
</button> </button>
</span> </span>

View File

@@ -47,6 +47,7 @@ import { NgbAccordionModule } from '@ng-bootstrap/ng-bootstrap';
import { OrcidSyncSettingsComponent } from './orcid-page/orcid-sync-settings/orcid-sync-settings.component'; import { OrcidSyncSettingsComponent } from './orcid-page/orcid-sync-settings/orcid-sync-settings.component';
import { OrcidQueueComponent } from './orcid-page/orcid-queue/orcid-queue.component'; import { OrcidQueueComponent } from './orcid-page/orcid-queue/orcid-queue.component';
import { UploadModule } from '../shared/upload/upload.module'; import { UploadModule } from '../shared/upload/upload.module';
import { ResultsBackButtonModule } from '../shared/results-back-button/results-back-button.module';
import { ItemAlertsComponent } from './alerts/item-alerts.component'; import { ItemAlertsComponent } from './alerts/item-alerts.component';
import { ItemVersionsModule } from './versions/item-versions.module'; import { ItemVersionsModule } from './versions/item-versions.module';
import { BitstreamRequestACopyPageComponent } from './bitstreams/request-a-copy/bitstream-request-a-copy-page.component'; import { BitstreamRequestACopyPageComponent } from './bitstreams/request-a-copy/bitstream-request-a-copy-page.component';
@@ -107,7 +108,8 @@ const DECLARATIONS = [
ResearchEntitiesModule.withEntryComponents(), ResearchEntitiesModule.withEntryComponents(),
NgxGalleryModule, NgxGalleryModule,
NgbAccordionModule, NgbAccordionModule,
UploadModule, ResultsBackButtonModule,
UploadModule
], ],
declarations: [ declarations: [
...DECLARATIONS, ...DECLARATIONS,

View File

@@ -1,3 +1,4 @@
<ds-themed-results-back-button *ngIf="showBackButton | async" [back]="back"></ds-themed-results-back-button>
<div class="row" *ngIf="iiifEnabled"> <div class="row" *ngIf="iiifEnabled">
<div class="col-12"> <div class="col-12">
<ds-mirador-viewer id="iiif-viewer" <ds-mirador-viewer id="iiif-viewer"

View File

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

View File

@@ -163,12 +163,12 @@ describe('PublicationComponent', () => {
describe('with IIIF viewer and search', () => { describe('with IIIF viewer and search', () => {
const localMockRouteService = {
getPreviousUrl(): Observable<string> {
return of('/search?query=test%20query&fakeParam=true');
}
};
beforeEach(waitForAsync(() => { beforeEach(waitForAsync(() => {
const localMockRouteService = {
getPreviousUrl(): Observable<string> {
return of('/search?query=test%20query&fakeParam=true');
}
};
const iiifEnabledMap: MetadataMap = { const iiifEnabledMap: MetadataMap = {
'dspace.iiif.enabled': [getIIIFEnabled(true)], 'dspace.iiif.enabled': [getIIIFEnabled(true)],
'iiif.search.enabled': [getIIIFSearchEnabled(true)], 'iiif.search.enabled': [getIIIFSearchEnabled(true)],
@@ -193,13 +193,12 @@ describe('PublicationComponent', () => {
}); });
describe('with IIIF viewer and search but no previous search query', () => { describe('with IIIF viewer and search but no previous search query', () => {
const localMockRouteService = {
getPreviousUrl(): Observable<string> {
return of('/item');
}
};
beforeEach(waitForAsync(() => { beforeEach(waitForAsync(() => {
const localMockRouteService = {
getPreviousUrl(): Observable<string> {
return of('/item');
}
};
const iiifEnabledMap: MetadataMap = { const iiifEnabledMap: MetadataMap = {
'dspace.iiif.enabled': [getIIIFEnabled(true)], 'dspace.iiif.enabled': [getIIIFEnabled(true)],
'iiif.search.enabled': [getIIIFSearchEnabled(true)], 'iiif.search.enabled': [getIIIFSearchEnabled(true)],

View File

@@ -39,6 +39,11 @@ import { RouterTestingModule } from '@angular/router/testing';
import { AuthorizationDataService } from '../../../../core/data/feature-authorization/authorization-data.service'; import { AuthorizationDataService } from '../../../../core/data/feature-authorization/authorization-data.service';
import { ResearcherProfileDataService } from '../../../../core/profile/researcher-profile-data.service'; import { ResearcherProfileDataService } from '../../../../core/profile/researcher-profile-data.service';
import { buildPaginatedList } from '../../../../core/data/paginated-list.model';
import { PageInfo } from '../../../../core/shared/page-info.model';
import { Router } from '@angular/router';
import { ItemComponent } from './item.component';
export function getIIIFSearchEnabled(enabled: boolean): MetadataValue { export function getIIIFSearchEnabled(enabled: boolean): MetadataValue {
return Object.assign(new MetadataValue(), { return Object.assign(new MetadataValue(), {
'value': enabled, 'value': enabled,
@@ -59,7 +64,11 @@ export function getIIIFEnabled(enabled: boolean): MetadataValue {
}); });
} }
export const mockRouteService = jasmine.createSpyObj('RouteService', ['getPreviousUrl']); export const mockRouteService = {
getPreviousUrl(): Observable<string> {
return observableOf('');
}
};
/** /**
* Create a generic test for an item-page-fields component using a mockItem and the type of component * Create a generic test for an item-page-fields component using a mockItem and the type of component
@@ -114,7 +123,7 @@ export function getItemPageFieldsTest(mockItem: Item, component) {
{ provide: BitstreamDataService, useValue: mockBitstreamDataService }, { provide: BitstreamDataService, useValue: mockBitstreamDataService },
{ provide: WorkspaceitemDataService, useValue: {} }, { provide: WorkspaceitemDataService, useValue: {} },
{ provide: SearchService, useValue: {} }, { provide: SearchService, useValue: {} },
{ provide: RouteService, useValue: {} }, { provide: RouteService, useValue: mockRouteService },
{ provide: AuthorizationDataService, useValue: authorizationService }, { provide: AuthorizationDataService, useValue: authorizationService },
{ provide: ResearcherProfileDataService, useValue: {} } { provide: ResearcherProfileDataService, useValue: {} }
], ],
@@ -376,4 +385,110 @@ describe('ItemComponent', () => {
}); });
}); });
const mockItem: Item = Object.assign(new Item(), {
bundles: createSuccessfulRemoteDataObject$(buildPaginatedList(new PageInfo(), [])),
metadata: {
'publicationissue.issueNumber': [
{
language: 'en_US',
value: '1234'
}
],
'dc.description': [
{
language: 'en_US',
value: 'desc'
}
]
},
});
describe('back to results', () => {
let comp: ItemComponent;
let fixture: ComponentFixture<any>;
let router: Router;
const searchUrl = '/search?query=test&spc.page=2';
const browseUrl = '/browse/title?scope=0cc&bbm.page=3';
const recentSubmissionsUrl = '/collections/be7b8430-77a5-4016-91c9-90863e50583a?cp.page=3';
beforeEach(waitForAsync(() => {
TestBed.configureTestingModule({
imports: [
TranslateModule.forRoot({
loader: {
provide: TranslateLoader,
useClass: TranslateLoaderMock
}
}),
RouterTestingModule,
],
declarations: [ItemComponent, GenericItemPageFieldComponent, TruncatePipe ],
providers: [
{ provide: ItemDataService, useValue: {} },
{ provide: TruncatableService, useValue: {} },
{ provide: RelationshipDataService, useValue: {} },
{ provide: ObjectCacheService, useValue: {} },
{ provide: UUIDService, useValue: {} },
{ provide: Store, useValue: {} },
{ provide: RemoteDataBuildService, useValue: {} },
{ provide: CommunityDataService, useValue: {} },
{ provide: HALEndpointService, useValue: {} },
{ provide: HttpClient, useValue: {} },
{ provide: DSOChangeAnalyzer, useValue: {} },
{ provide: VersionHistoryDataService, useValue: {} },
{ provide: VersionDataService, useValue: {} },
{ provide: NotificationsService, useValue: {} },
{ provide: DefaultChangeAnalyzer, useValue: {} },
{ provide: BitstreamDataService, useValue: {} },
{ provide: WorkspaceitemDataService, useValue: {} },
{ provide: SearchService, useValue: {} },
{ provide: RouteService, useValue: mockRouteService },
{ provide: AuthorizationDataService, useValue: {} },
{ provide: ResearcherProfileDataService, useValue: {} }
],
schemas: [NO_ERRORS_SCHEMA]
}).overrideComponent(ItemComponent, {
set: {changeDetection: ChangeDetectionStrategy.Default}
});
}));
beforeEach(waitForAsync(() => {
router = TestBed.inject(Router);
spyOn(router, 'navigateByUrl');
TestBed.compileComponents();
fixture = TestBed.createComponent(ItemComponent);
comp = fixture.componentInstance;
comp.object = mockItem;
fixture.detectChanges();
}));
it('should hide back button',() => {
spyOn(mockRouteService, 'getPreviousUrl').and.returnValue(observableOf('/item'));
comp.showBackButton.subscribe((val) => {
expect(val).toBeFalse();
});
});
it('should show back button for search', () => {
spyOn(mockRouteService, 'getPreviousUrl').and.returnValue(observableOf(searchUrl));
comp.ngOnInit();
comp.showBackButton.subscribe((val) => {
expect(val).toBeTrue();
});
});
it('should show back button for browse', () => {
spyOn(mockRouteService, 'getPreviousUrl').and.returnValue(observableOf(browseUrl));
comp.ngOnInit();
comp.showBackButton.subscribe((val) => {
expect(val).toBeTrue();
});
});
it('should show back button for recent submissions', () => {
spyOn(mockRouteService, 'getPreviousUrl').and.returnValue(observableOf(recentSubmissionsUrl));
comp.ngOnInit();
comp.showBackButton.subscribe((val) => {
expect(val).toBeTrue();
});
});
});
}); });

View File

@@ -5,6 +5,8 @@ import { getItemPageRoute } from '../../../item-page-routing-paths';
import { RouteService } from '../../../../core/services/route.service'; import { RouteService } from '../../../../core/services/route.service';
import { Observable } from 'rxjs'; import { Observable } from 'rxjs';
import { getDSpaceQuery, isIiifEnabled, isIiifSearchEnabled } from './item-iiif-utils'; import { getDSpaceQuery, isIiifEnabled, isIiifSearchEnabled } from './item-iiif-utils';
import { filter, map, take } from 'rxjs/operators';
import { Router } from '@angular/router';
@Component({ @Component({
selector: 'ds-item', selector: 'ds-item',
@@ -16,6 +18,17 @@ import { getDSpaceQuery, isIiifEnabled, isIiifSearchEnabled } from './item-iiif-
export class ItemComponent implements OnInit { export class ItemComponent implements OnInit {
@Input() object: Item; @Input() object: Item;
/**
* This regex matches previous routes. The button is shown
* for matching paths and hidden in other cases.
*/
previousRoute = /^(\/search|\/browse|\/collections|\/admin\/search|\/mydspace)/;
/**
* Used to show or hide the back to results button in the view.
*/
showBackButton: Observable<boolean>;
/** /**
* Route to the item page * Route to the item page
*/ */
@@ -38,12 +51,33 @@ export class ItemComponent implements OnInit {
mediaViewer; mediaViewer;
constructor(protected routeService: RouteService) { constructor(protected routeService: RouteService,
protected router: Router) {
this.mediaViewer = environment.mediaViewer; this.mediaViewer = environment.mediaViewer;
} }
/**
* The function used to return to list from the item.
*/
back = () => {
this.routeService.getPreviousUrl().pipe(
take(1)
).subscribe(
(url => {
this.router.navigateByUrl(url);
})
);
};
ngOnInit(): void { ngOnInit(): void {
this.itemPageRoute = getItemPageRoute(this.object); this.itemPageRoute = getItemPageRoute(this.object);
// hide/show the back button
this.showBackButton = this.routeService.getPreviousUrl().pipe(
filter(url => this.previousRoute.test(url)),
take(1),
map(() => true)
);
// check to see if iiif viewer is required. // check to see if iiif viewer is required.
this.iiifEnabled = isIiifEnabled(this.object); this.iiifEnabled = isIiifEnabled(this.object);
this.iiifSearchEnabled = isIiifSearchEnabled(this.object); this.iiifSearchEnabled = isIiifSearchEnabled(this.object);

View File

@@ -1,3 +1,4 @@
<ds-themed-results-back-button *ngIf="showBackButton | async" [back]="back"></ds-themed-results-back-button>
<div class="row" *ngIf="iiifEnabled"> <div class="row" *ngIf="iiifEnabled">
<div class="col-12"> <div class="col-12">
<ds-mirador-viewer id="iiif-viewer" <ds-mirador-viewer id="iiif-viewer"
@@ -7,6 +8,7 @@
</ds-mirador-viewer> </ds-mirador-viewer>
</div> </div>
</div> </div>
<div class="d-flex flex-row"> <div class="d-flex flex-row">
<ds-item-page-title-field [item]="object" class="mr-auto"> <ds-item-page-title-field [item]="object" class="mr-auto">
</ds-item-page-title-field> </ds-item-page-title-field>

View File

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

View File

@@ -169,13 +169,12 @@ describe('UntypedItemComponent', () => {
}); });
describe('with IIIF viewer and search', () => { describe('with IIIF viewer and search', () => {
const localMockRouteService = {
getPreviousUrl(): Observable<string> {
return of('/search?query=test%20query&fakeParam=true');
}
};
beforeEach(waitForAsync(() => { beforeEach(waitForAsync(() => {
const localMockRouteService = {
getPreviousUrl(): Observable<string> {
return of('/search?query=test%20query&fakeParam=true');
}
};
const iiifEnabledMap: MetadataMap = { const iiifEnabledMap: MetadataMap = {
'dspace.iiif.enabled': [getIIIFEnabled(true)], 'dspace.iiif.enabled': [getIIIFEnabled(true)],
'iiif.search.enabled': [getIIIFSearchEnabled(true)], 'iiif.search.enabled': [getIIIFSearchEnabled(true)],
@@ -183,6 +182,7 @@ describe('UntypedItemComponent', () => {
TestBed.overrideProvider(RouteService, {useValue: localMockRouteService}); TestBed.overrideProvider(RouteService, {useValue: localMockRouteService});
TestBed.compileComponents(); TestBed.compileComponents();
fixture = TestBed.createComponent(UntypedItemComponent); fixture = TestBed.createComponent(UntypedItemComponent);
spyOn(localMockRouteService, 'getPreviousUrl').and.callThrough();
comp = fixture.componentInstance; comp = fixture.componentInstance;
comp.object = getItem(iiifEnabledMap); comp.object = getItem(iiifEnabledMap);
fixture.detectChanges(); fixture.detectChanges();
@@ -196,17 +196,16 @@ describe('UntypedItemComponent', () => {
it('should retrieve the query term for previous route', (): void => { it('should retrieve the query term for previous route', (): void => {
expect(comp.iiifQuery$.subscribe(result => expect(result).toEqual('test query'))); expect(comp.iiifQuery$.subscribe(result => expect(result).toEqual('test query')));
}); });
}); });
describe('with IIIF viewer and search but no previous search query', () => { describe('with IIIF viewer and search but no previous search query', () => {
const localMockRouteService = {
getPreviousUrl(): Observable<string> {
return of('/item');
}
};
beforeEach(waitForAsync(() => { beforeEach(waitForAsync(() => {
const localMockRouteService = {
getPreviousUrl(): Observable<string> {
return of('/item');
}
};
const iiifEnabledMap: MetadataMap = { const iiifEnabledMap: MetadataMap = {
'dspace.iiif.enabled': [getIIIFEnabled(true)], 'dspace.iiif.enabled': [getIIIFEnabled(true)],
'iiif.search.enabled': [getIIIFSearchEnabled(true)], 'iiif.search.enabled': [getIIIFSearchEnabled(true)],
@@ -214,6 +213,7 @@ describe('UntypedItemComponent', () => {
TestBed.overrideProvider(RouteService, {useValue: localMockRouteService}); TestBed.overrideProvider(RouteService, {useValue: localMockRouteService});
TestBed.compileComponents(); TestBed.compileComponents();
fixture = TestBed.createComponent(UntypedItemComponent); fixture = TestBed.createComponent(UntypedItemComponent);
comp = fixture.componentInstance; comp = fixture.componentInstance;
comp.object = getItem(iiifEnabledMap); comp.object = getItem(iiifEnabledMap);
fixture.detectChanges(); fixture.detectChanges();

View File

@@ -31,13 +31,13 @@ export class VersionedItemComponent extends ItemComponent {
private translateService: TranslateService, private translateService: TranslateService,
private versionService: VersionDataService, private versionService: VersionDataService,
private itemVersionShared: ItemVersionsSharedService, private itemVersionShared: ItemVersionsSharedService,
private router: Router, protected router: Router,
private workspaceItemDataService: WorkspaceitemDataService, private workspaceItemDataService: WorkspaceitemDataService,
private searchService: SearchService, private searchService: SearchService,
private itemService: ItemDataService, private itemService: ItemDataService,
protected routeService: RouteService protected routeService: RouteService,
) { ) {
super(routeService); super(routeService, router);
} }
/** /**

View File

@@ -4,11 +4,13 @@ import { By } from '@angular/platform-browser';
import { MetadataRepresentationListComponent } from './metadata-representation-list.component'; import { MetadataRepresentationListComponent } from './metadata-representation-list.component';
import { RelationshipDataService } from '../../../core/data/relationship-data.service'; import { RelationshipDataService } from '../../../core/data/relationship-data.service';
import { Item } from '../../../core/shared/item.model'; import { Item } from '../../../core/shared/item.model';
import { Relationship } from '../../../core/shared/item-relationships/relationship.model';
import { createSuccessfulRemoteDataObject$, createFailedRemoteDataObject$ } from '../../../shared/remote-data.utils';
import { TranslateModule } from '@ngx-translate/core'; import { TranslateModule } from '@ngx-translate/core';
import { VarDirective } from '../../../shared/utils/var.directive'; import { VarDirective } from '../../../shared/utils/var.directive';
import { of as observableOf } from 'rxjs'; import { of as observableOf } from 'rxjs';
import { MetadataValue } from '../../../core/shared/metadata.models';
import { DSpaceObject } from '../../../core/shared/dspace-object.model';
import { ItemMetadataRepresentation } from '../../../core/shared/metadata-representation/item/item-metadata-representation.model';
import { MetadatumRepresentation } from '../../../core/shared/metadata-representation/metadatum/metadatum-representation.model';
const itemType = 'Person'; const itemType = 'Person';
const metadataFields = ['dc.contributor.author', 'dc.creator']; const metadataFields = ['dc.contributor.author', 'dc.creator'];
@@ -73,39 +75,31 @@ const relatedCreator: Item = Object.assign(new Item(), {
'dspace.entity.type': 'Person', 'dspace.entity.type': 'Person',
} }
}); });
const authorRelation: Relationship = Object.assign(new Relationship(), {
leftItem: createSuccessfulRemoteDataObject$(parentItem),
rightItem: createSuccessfulRemoteDataObject$(relatedAuthor)
});
const creatorRelation: Relationship = Object.assign(new Relationship(), {
leftItem: createSuccessfulRemoteDataObject$(parentItem),
rightItem: createSuccessfulRemoteDataObject$(relatedCreator),
});
const creatorRelationUnauthorized: Relationship = Object.assign(new Relationship(), {
leftItem: createSuccessfulRemoteDataObject$(parentItem),
rightItem: createFailedRemoteDataObject$('Unauthorized', 401),
});
let relationshipService;
describe('MetadataRepresentationListComponent', () => { describe('MetadataRepresentationListComponent', () => {
let comp: MetadataRepresentationListComponent; let comp: MetadataRepresentationListComponent;
let fixture: ComponentFixture<MetadataRepresentationListComponent>; let fixture: ComponentFixture<MetadataRepresentationListComponent>;
relationshipService = { let relationshipService;
findById: (id: string) => {
if (id === 'related-author') {
return createSuccessfulRemoteDataObject$(authorRelation);
}
if (id === 'related-creator') {
return createSuccessfulRemoteDataObject$(creatorRelation);
}
if (id === 'related-creator-unauthorized') {
return createSuccessfulRemoteDataObject$(creatorRelationUnauthorized);
}
},
};
beforeEach(waitForAsync(() => { beforeEach(waitForAsync(() => {
relationshipService = {
resolveMetadataRepresentation: (metadatum: MetadataValue, parent: DSpaceObject, type: string) => {
if (metadatum.value === 'Related Author with authority') {
return observableOf(Object.assign(new ItemMetadataRepresentation(metadatum), relatedAuthor));
}
if (metadatum.value === 'Author without authority') {
return observableOf(Object.assign(new MetadatumRepresentation(type), metadatum));
}
if (metadatum.value === 'Related Creator with authority') {
return observableOf(Object.assign(new ItemMetadataRepresentation(metadatum), relatedCreator));
}
if (metadatum.value === 'Related Creator with authority - unauthorized') {
return observableOf(Object.assign(new MetadatumRepresentation(type), metadatum));
}
},
};
TestBed.configureTestingModule({ TestBed.configureTestingModule({
imports: [TranslateModule.forRoot()], imports: [TranslateModule.forRoot()],
declarations: [MetadataRepresentationListComponent, VarDirective], declarations: [MetadataRepresentationListComponent, VarDirective],

View File

@@ -1,21 +1,12 @@
import { Component, Input } from '@angular/core'; import { Component, Input } from '@angular/core';
import { MetadataRepresentation } from '../../../core/shared/metadata-representation/metadata-representation.model'; import { MetadataRepresentation } from '../../../core/shared/metadata-representation/metadata-representation.model';
import { import {
combineLatest as observableCombineLatest,
Observable, Observable,
of as observableOf,
zip as observableZip zip as observableZip
} from 'rxjs'; } from 'rxjs';
import { RelationshipDataService } from '../../../core/data/relationship-data.service'; import { RelationshipDataService } from '../../../core/data/relationship-data.service';
import { MetadataValue } from '../../../core/shared/metadata.models'; import { MetadataValue } from '../../../core/shared/metadata.models';
import { getFirstSucceededRemoteData } from '../../../core/shared/operators';
import { filter, map, switchMap } from 'rxjs/operators';
import { RemoteData } from '../../../core/data/remote-data';
import { Relationship } from '../../../core/shared/item-relationships/relationship.model';
import { Item } from '../../../core/shared/item.model'; import { Item } from '../../../core/shared/item.model';
import { MetadatumRepresentation } from '../../../core/shared/metadata-representation/metadatum/metadatum-representation.model';
import { ItemMetadataRepresentation } from '../../../core/shared/metadata-representation/item/item-metadata-representation.model';
import { followLink } from '../../../shared/utils/follow-link-config.model';
import { AbstractIncrementalListComponent } from '../abstract-incremental-list/abstract-incremental-list.component'; import { AbstractIncrementalListComponent } from '../abstract-incremental-list/abstract-incremental-list.component';
@Component({ @Component({
@@ -85,29 +76,7 @@ export class MetadataRepresentationListComponent extends AbstractIncrementalList
...metadata ...metadata
.slice((this.objects.length * this.incrementBy), (this.objects.length * this.incrementBy) + this.incrementBy) .slice((this.objects.length * this.incrementBy), (this.objects.length * this.incrementBy) + this.incrementBy)
.map((metadatum: any) => Object.assign(new MetadataValue(), metadatum)) .map((metadatum: any) => Object.assign(new MetadataValue(), metadatum))
.map((metadatum: MetadataValue) => { .map((metadatum: MetadataValue) => this.relationshipService.resolveMetadataRepresentation(metadatum, this.parentItem, this.itemType)),
if (metadatum.isVirtual) {
return this.relationshipService.findById(metadatum.virtualValue, true, false, followLink('leftItem'), followLink('rightItem')).pipe(
getFirstSucceededRemoteData(),
switchMap((relRD: RemoteData<Relationship>) =>
observableCombineLatest(relRD.payload.leftItem, relRD.payload.rightItem).pipe(
filter(([leftItem, rightItem]) => leftItem.hasCompleted && rightItem.hasCompleted),
map(([leftItem, rightItem]) => {
if (!leftItem.hasSucceeded || !rightItem.hasSucceeded) {
return observableOf(Object.assign(new MetadatumRepresentation(this.itemType), metadatum));
} else if (rightItem.hasSucceeded && leftItem.payload.id === this.parentItem.id) {
return rightItem.payload;
} else if (rightItem.payload.id === this.parentItem.id) {
return leftItem.payload;
}
}),
map((item: Item) => Object.assign(new ItemMetadataRepresentation(metadatum), item))
)
));
} else {
return observableOf(Object.assign(new MetadatumRepresentation(this.itemType), metadatum));
}
})
); );
} }
} }

View File

@@ -142,29 +142,7 @@ describe('MenuResolver', () => {
}); });
describe('createAdminMenu$', () => { describe('createAdminMenu$', () => {
it('should retrieve the menu by ID return an Observable that emits true as soon as it is created', () => { const dontShowAdminSections = () => {
(menuService as any).getMenu.and.returnValue(cold('--u--m', {
u: undefined,
m: MENU_STATE,
}));
expect(resolver.createAdminMenu$()).toBeObservable(cold('-----(t|)', BOOLEAN));
expect(menuService.getMenu).toHaveBeenCalledOnceWith(MenuID.ADMIN);
});
describe('for regular user', () => {
beforeEach(() => {
authorizationService.isAuthorized = createSpy('isAuthorized').and.callFake(() => {
return observableOf(false);
});
});
beforeEach((done) => {
resolver.createAdminMenu$().subscribe((_) => {
done();
});
});
it('should not show site admin section', () => { it('should not show site admin section', () => {
expect(menuService.addSection).toHaveBeenCalledWith(MenuID.ADMIN, jasmine.objectContaining({ expect(menuService.addSection).toHaveBeenCalledWith(MenuID.ADMIN, jasmine.objectContaining({
id: 'admin_search', visible: false, id: 'admin_search', visible: false,
@@ -183,19 +161,6 @@ describe('MenuResolver', () => {
})); }));
}); });
it('should not show edit_community', () => {
expect(menuService.addSection).toHaveBeenCalledWith(MenuID.ADMIN, jasmine.objectContaining({
id: 'edit_community', visible: false,
}));
});
it('should not show edit_collection', () => {
expect(menuService.addSection).toHaveBeenCalledWith(MenuID.ADMIN, jasmine.objectContaining({
id: 'edit_collection', visible: false,
}));
});
it('should not show access control section', () => { it('should not show access control section', () => {
expect(menuService.addSection).toHaveBeenCalledWith(MenuID.ADMIN, jasmine.objectContaining({ expect(menuService.addSection).toHaveBeenCalledWith(MenuID.ADMIN, jasmine.objectContaining({
id: 'access_control', visible: false, id: 'access_control', visible: false,
@@ -222,6 +187,122 @@ describe('MenuResolver', () => {
id: 'export', visible: true, id: 'export', visible: true,
})); }));
}); });
};
const dontShowNewSection = () => {
it('should not show the "New" section', () => {
expect(menuService.addSection).toHaveBeenCalledWith(MenuID.ADMIN, jasmine.objectContaining({
id: 'new_community', visible: false,
}));
expect(menuService.addSection).toHaveBeenCalledWith(MenuID.ADMIN, jasmine.objectContaining({
id: 'new_collection', visible: false,
}));
expect(menuService.addSection).toHaveBeenCalledWith(MenuID.ADMIN, jasmine.objectContaining({
id: 'new_item', visible: false,
}));
expect(menuService.addSection).toHaveBeenCalledWith(MenuID.ADMIN, jasmine.objectContaining({
id: 'new', visible: false,
}));
});
};
const dontShowEditSection = () => {
it('should not show the "Edit" section', () => {
expect(menuService.addSection).toHaveBeenCalledWith(MenuID.ADMIN, jasmine.objectContaining({
id: 'edit_community', visible: false,
}));
expect(menuService.addSection).toHaveBeenCalledWith(MenuID.ADMIN, jasmine.objectContaining({
id: 'edit_collection', visible: false,
}));
expect(menuService.addSection).toHaveBeenCalledWith(MenuID.ADMIN, jasmine.objectContaining({
id: 'edit_item', visible: false,
}));
expect(menuService.addSection).toHaveBeenCalledWith(MenuID.ADMIN, jasmine.objectContaining({
id: 'edit', visible: false,
}));
});
};
it('should retrieve the menu by ID return an Observable that emits true as soon as it is created', () => {
(menuService as any).getMenu.and.returnValue(cold('--u--m', {
u: undefined,
m: MENU_STATE,
}));
expect(resolver.createAdminMenu$()).toBeObservable(cold('-----(t|)', BOOLEAN));
expect(menuService.getMenu).toHaveBeenCalledOnceWith(MenuID.ADMIN);
});
describe('for regular user', () => {
beforeEach(() => {
authorizationService.isAuthorized = createSpy('isAuthorized').and.callFake((featureID) => {
return observableOf(false);
});
});
beforeEach((done) => {
resolver.createAdminMenu$().subscribe((_) => {
done();
});
});
dontShowAdminSections();
dontShowNewSection();
dontShowEditSection();
});
describe('regular user who can submit', () => {
beforeEach(() => {
authorizationService.isAuthorized = createSpy('isAuthorized')
.and.callFake((featureID: FeatureID) => {
return observableOf(featureID === FeatureID.CanSubmit);
});
});
beforeEach((done) => {
resolver.createAdminMenu$().subscribe((_) => {
done();
});
});
it('should show "New Item" section', () => {
expect(menuService.addSection).toHaveBeenCalledWith(MenuID.ADMIN, jasmine.objectContaining({
id: 'new_item', visible: true,
}));
expect(menuService.addSection).toHaveBeenCalledWith(MenuID.ADMIN, jasmine.objectContaining({
id: 'new', visible: true,
}));
});
dontShowAdminSections();
dontShowEditSection();
});
describe('regular user who can edit items', () => {
beforeEach(() => {
authorizationService.isAuthorized = createSpy('isAuthorized')
.and.callFake((featureID: FeatureID) => {
return observableOf(featureID === FeatureID.CanEditItem);
});
});
beforeEach((done) => {
resolver.createAdminMenu$().subscribe((_) => {
done();
});
});
it('should show "Edit Item" section', () => {
expect(menuService.addSection).toHaveBeenCalledWith(MenuID.ADMIN, jasmine.objectContaining({
id: 'edit_item', visible: true,
}));
expect(menuService.addSection).toHaveBeenCalledWith(MenuID.ADMIN, jasmine.objectContaining({
id: 'edit', visible: true,
}));
});
dontShowAdminSections();
dontShowNewSection();
}); });
describe('for site admin', () => { describe('for site admin', () => {
@@ -237,6 +318,12 @@ describe('MenuResolver', () => {
}); });
}); });
it('should show new_process', () => {
expect(menuService.addSection).toHaveBeenCalledWith(MenuID.ADMIN, jasmine.objectContaining({
id: 'new_process', visible: true,
}));
});
it('should contain site admin section', () => { it('should contain site admin section', () => {
expect(menuService.addSection).toHaveBeenCalledWith(MenuID.ADMIN, jasmine.objectContaining({ expect(menuService.addSection).toHaveBeenCalledWith(MenuID.ADMIN, jasmine.objectContaining({
id: 'admin_search', visible: true, id: 'admin_search', visible: true,

View File

@@ -167,21 +167,11 @@ export class MenuResolver implements Resolve<boolean> {
combineLatest([ combineLatest([
this.authorizationService.isAuthorized(FeatureID.IsCollectionAdmin), this.authorizationService.isAuthorized(FeatureID.IsCollectionAdmin),
this.authorizationService.isAuthorized(FeatureID.IsCommunityAdmin), this.authorizationService.isAuthorized(FeatureID.IsCommunityAdmin),
this.authorizationService.isAuthorized(FeatureID.AdministratorOf) this.authorizationService.isAuthorized(FeatureID.AdministratorOf),
]).subscribe(([isCollectionAdmin, isCommunityAdmin, isSiteAdmin]) => { this.authorizationService.isAuthorized(FeatureID.CanSubmit),
const menuList = [ this.authorizationService.isAuthorized(FeatureID.CanEditItem),
/* News */ ]).subscribe(([isCollectionAdmin, isCommunityAdmin, isSiteAdmin, canSubmit, canEditItem]) => {
{ const newSubMenuList = [
id: 'new',
active: false,
visible: true,
model: {
type: MenuItemType.TEXT,
text: 'menu.section.new'
} as TextMenuItemModel,
icon: 'plus',
index: 0
},
{ {
id: 'new_community', id: 'new_community',
parentID: 'new', parentID: 'new',
@@ -212,7 +202,7 @@ export class MenuResolver implements Resolve<boolean> {
id: 'new_item', id: 'new_item',
parentID: 'new', parentID: 'new',
active: false, active: false,
visible: true, visible: canSubmit,
model: { model: {
type: MenuItemType.ONCLICK, type: MenuItemType.ONCLICK,
text: 'menu.section.new_item', text: 'menu.section.new_item',
@@ -225,38 +215,16 @@ export class MenuResolver implements Resolve<boolean> {
id: 'new_process', id: 'new_process',
parentID: 'new', parentID: 'new',
active: false, active: false,
visible: isCollectionAdmin, visible: isSiteAdmin,
model: { model: {
type: MenuItemType.LINK, type: MenuItemType.LINK,
text: 'menu.section.new_process', text: 'menu.section.new_process',
link: '/processes/new' link: '/processes/new'
} as LinkMenuItemModel, } as LinkMenuItemModel,
}, },
// TODO: enable this menu item once the feature has been implemented ];
// { const editSubMenuList = [
// id: 'new_item_version',
// parentID: 'new',
// active: false,
// visible: true,
// model: {
// type: MenuItemType.LINK,
// text: 'menu.section.new_item_version',
// link: ''
// } as LinkMenuItemModel,
// },
/* Edit */ /* Edit */
{
id: 'edit',
active: false,
visible: true,
model: {
type: MenuItemType.TEXT,
text: 'menu.section.edit'
} as TextMenuItemModel,
icon: 'pencil-alt',
index: 1
},
{ {
id: 'edit_community', id: 'edit_community',
parentID: 'edit', parentID: 'edit',
@@ -287,7 +255,7 @@ export class MenuResolver implements Resolve<boolean> {
id: 'edit_item', id: 'edit_item',
parentID: 'edit', parentID: 'edit',
active: false, active: false,
visible: true, visible: canEditItem,
model: { model: {
type: MenuItemType.ONCLICK, type: MenuItemType.ONCLICK,
text: 'menu.section.edit_item', text: 'menu.section.edit_item',
@@ -296,6 +264,47 @@ export class MenuResolver implements Resolve<boolean> {
} }
} as OnClickMenuItemModel, } as OnClickMenuItemModel,
}, },
];
const newSubMenu = {
id: 'new',
active: false,
visible: newSubMenuList.some(subMenu => subMenu.visible),
model: {
type: MenuItemType.TEXT,
text: 'menu.section.new'
} as TextMenuItemModel,
icon: 'plus',
index: 0
};
const editSubMenu = {
id: 'edit',
active: false,
visible: editSubMenuList.some(subMenu => subMenu.visible),
model: {
type: MenuItemType.TEXT,
text: 'menu.section.edit'
} as TextMenuItemModel,
icon: 'pencil-alt',
index: 1
};
const menuList = [
...newSubMenuList,
newSubMenu,
...editSubMenuList,
editSubMenu,
// TODO: enable this menu item once the feature has been implemented
// {
// id: 'new_item_version',
// parentID: 'new',
// active: false,
// visible: true,
// model: {
// type: MenuItemType.LINK,
// text: 'menu.section.new_item_version',
// link: ''
// } as LinkMenuItemModel,
// },
/* Statistics */ /* Statistics */
// TODO: enable this menu item once the feature has been implemented // TODO: enable this menu item once the feature has been implemented

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