mirror of
https://github.com/DSpace/dspace-angular.git
synced 2025-10-07 18:14:17 +00:00
Merge branch 'DSpace:main' into collection-in-workflow-tasks
This commit is contained in:
@@ -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
|
||||||
|
@@ -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"
|
||||||
|
@@ -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">
|
||||||
|
@@ -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">
|
||||||
|
@@ -100,6 +100,7 @@ export class AdminSidebarComponent extends MenuComponent implements OnInit {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
this.menuVisible = this.menuService.isMenuVisibleWithVisibleSections(this.menuID);
|
||||||
}
|
}
|
||||||
|
|
||||||
@HostListener('focusin')
|
@HostListener('focusin')
|
||||||
|
@@ -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;
|
||||||
|
@@ -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);
|
||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
|
@@ -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"
|
||||||
|
@@ -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', () => {
|
||||||
|
@@ -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
|
||||||
*/
|
*/
|
||||||
|
@@ -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();
|
||||||
}
|
}
|
||||||
|
@@ -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,
|
||||||
|
@@ -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>
|
||||||
|
@@ -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)', () => {
|
||||||
|
@@ -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];
|
|
||||||
array1.forEach((value: T, index: number) => {
|
|
||||||
if (hasValue(value)) {
|
|
||||||
const otherIndex = array2.indexOf(value);
|
|
||||||
const movedIndex = moved.indexOf(value);
|
|
||||||
if (index !== otherIndex && movedIndex !== otherIndex) {
|
|
||||||
moveItemInArray(moved, movedIndex, otherIndex);
|
|
||||||
result.push(Object.assign({
|
|
||||||
op: 'move',
|
op: 'move',
|
||||||
from: '/' + movedIndex,
|
from: '/' + move[0],
|
||||||
path: '/' + otherIndex
|
path: '/' + move[1],
|
||||||
}) as MoveOperation);
|
}) as MoveOperation);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Determine a set of moves required to transform array1 into array2
|
||||||
|
* The moves are returned as an array of pairs of numbers where the first number is the original index and the second
|
||||||
|
* is the new index
|
||||||
|
* It is assumed the operations are executed in the order they're returned (and not simultaneously)
|
||||||
|
* @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;
|
}, []);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@@ -29,5 +29,7 @@ export enum FeatureID {
|
|||||||
CanViewUsageStatistics = 'canViewUsageStatistics',
|
CanViewUsageStatistics = 'canViewUsageStatistics',
|
||||||
CanSendFeedback = 'canSendFeedback',
|
CanSendFeedback = 'canSendFeedback',
|
||||||
CanClaimItem = 'canClaimItem',
|
CanClaimItem = 'canClaimItem',
|
||||||
CanSynchronizeWithORCID = 'canSynchronizeWithORCID'
|
CanSynchronizeWithORCID = 'canSynchronizeWithORCID',
|
||||||
|
CanSubmit = 'canSubmit',
|
||||||
|
CanEditItem = 'canEditItem',
|
||||||
}
|
}
|
||||||
|
@@ -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}` };
|
||||||
|
}
|
||||||
|
}
|
@@ -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();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
@@ -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));
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
@@ -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,13 +375,18 @@ 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;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
bitstreams = [MockBitstream2, MockBitstream3, MockBitstream1];
|
||||||
(bundleDataService.findByItemAndName as jasmine.Spy).and.returnValue(mockBundleRD$(bitstreams));
|
(bundleDataService.findByItemAndName as jasmine.Spy).and.returnValue(mockBundleRD$(bitstreams));
|
||||||
(bitstreamDataService.findListByHref as jasmine.Spy).and.returnValues(
|
(bitstreamDataService.findListByHref as jasmine.Spy).and.returnValues(
|
||||||
...mockBitstreamPages$(bitstreams).map(bp => createSuccessfulRemoteDataObject$(bp)),
|
...mockBitstreamPages$(bitstreams).map(bp => createSuccessfulRemoteDataObject$(bp)),
|
||||||
);
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should link to first Bitstream with allowed format', fakeAsync(() => {
|
||||||
(metadataService as any).processRouteChange({
|
(metadataService as any).processRouteChange({
|
||||||
data: {
|
data: {
|
||||||
value: {
|
value: {
|
||||||
@@ -375,12 +397,44 @@ describe('MetadataService', () => {
|
|||||||
tick();
|
tick();
|
||||||
expect(meta.addTag).toHaveBeenCalledWith({
|
expect(meta.addTag).toHaveBeenCalledWith({
|
||||||
name: 'citation_pdf_url',
|
name: 'citation_pdf_url',
|
||||||
content: 'https://request.org/bitstreams/cf9b0c8e-a1eb-4b65-afd0-567366448713/download'
|
content: 'https://request.org/bitstreams/99b00f3c-1cc6-4689-8158-91965bee6b28/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({
|
||||||
|
@@ -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,53 +378,30 @@ 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>) => {
|
|
||||||
if (hasNoValue(paginatedList.next)) {
|
|
||||||
// If there's no next page, stop.
|
|
||||||
return EMPTY;
|
|
||||||
} else {
|
|
||||||
// Otherwise retrieve the next page
|
|
||||||
return this.bitstreamDataService.findListByHref(
|
|
||||||
paginatedList.next,
|
|
||||||
undefined,
|
|
||||||
true,
|
|
||||||
true,
|
|
||||||
followLink('format')
|
|
||||||
).pipe(
|
|
||||||
getFirstCompletedRemoteData(),
|
|
||||||
map((next: RemoteData<PaginatedList<Bitstream>>) => {
|
|
||||||
if (hasValue(next.payload)) {
|
|
||||||
return next.payload;
|
|
||||||
} 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(),
|
getFirstSucceededRemoteDataPayload(),
|
||||||
// Keep the original bitstream, because it, not the format, is what we'll need
|
// Keep the original bitstream, because it, not the format, is what we'll need
|
||||||
// for the link at the end
|
// for the link at the end
|
||||||
map((format: BitstreamFormat) => [bitstream, format])
|
map((format: BitstreamFormat) => [bitstream, format])
|
||||||
)),
|
))
|
||||||
// Check if bitstream downloadable
|
).pipe(
|
||||||
switchMap(([bitstream, format]: [Bitstream, BitstreamFormat]) => observableOf(bitstream).pipe(
|
// Verify that the bitstream is downloadable
|
||||||
|
mergeMap(([bitstream, format]: [Bitstream, BitstreamFormat]) => observableOf(bitstream).pipe(
|
||||||
getDownloadableBitstream(this.authorizationService),
|
getDownloadableBitstream(this.authorizationService),
|
||||||
map((bit: Bitstream) => [bit, format])
|
map((bit: Bitstream) => [bit, format])
|
||||||
)),
|
)),
|
||||||
@@ -419,8 +411,12 @@ export class MetadataService {
|
|||||||
// We only need 1
|
// We only need 1
|
||||||
take(1),
|
take(1),
|
||||||
// Emit the link of the match
|
// Emit the link of the match
|
||||||
|
// tap((v) => console.log('result', v)),
|
||||||
map(([bitstream, ]: [Bitstream, BitstreamFormat]) => getBitstreamDownloadRoute(bitstream))
|
map(([bitstream, ]: [Bitstream, BitstreamFormat]) => getBitstreamDownloadRoute(bitstream))
|
||||||
);
|
);
|
||||||
|
} else {
|
||||||
|
return EMPTY;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
@@ -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());
|
||||||
|
@@ -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>
|
@@ -0,0 +1,7 @@
|
|||||||
|
.ds-drop-list {
|
||||||
|
background-color: var(--bs-gray-500);
|
||||||
|
|
||||||
|
&.disabled {
|
||||||
|
opacity: 0.3;
|
||||||
|
}
|
||||||
|
}
|
@@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
@@ -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();
|
||||||
|
}
|
||||||
|
}
|
@@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
453
src/app/dso-shared/dso-edit-metadata/dso-edit-metadata-form.ts
Normal file
453
src/app/dso-shared/dso-edit-metadata/dso-edit-metadata-form.ts
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
@@ -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>
|
@@ -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);
|
||||||
|
}
|
@@ -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);
|
||||||
|
});
|
||||||
|
});
|
@@ -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;
|
||||||
|
}
|
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
@@ -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>
|
@@ -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;
|
||||||
|
}
|
@@ -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>
|
@@ -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;
|
||||||
|
}
|
@@ -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();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
@@ -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),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
@@ -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"> {{ 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"> {{ 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"> {{ 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"> {{ 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>
|
@@ -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;
|
||||||
|
}
|
@@ -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();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
});
|
@@ -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();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
@@ -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>
|
@@ -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();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
@@ -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());
|
||||||
|
}
|
||||||
|
}
|
@@ -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`);
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
}
|
36
src/app/dso-shared/dso-shared.module.ts
Normal file
36
src/app/dso-shared/dso-shared.module.ts
Normal 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 {
|
||||||
|
|
||||||
|
}
|
@@ -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>
|
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
@@ -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();
|
||||||
|
});
|
||||||
|
}));
|
||||||
|
});
|
||||||
|
|
||||||
|
});
|
@@ -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();
|
||||||
|
}
|
||||||
|
}
|
@@ -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">
|
||||||
|
@@ -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);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@@ -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"
|
||||||
|
@@ -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 {
|
||||||
|
|
||||||
|
@@ -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]
|
||||||
},
|
},
|
||||||
|
@@ -1,71 +0,0 @@
|
|||||||
<td>
|
|
||||||
<div class="metadata-field">
|
|
||||||
<div *ngIf="!(editable | async)">
|
|
||||||
<span >{{metadata?.key?.split('.').join('.​')}}</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>
|
|
@@ -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);
|
|
||||||
}
|
|
@@ -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('.​'),
|
|
||||||
value: ('dc.' + mdField1.toString())
|
|
||||||
},
|
|
||||||
{
|
|
||||||
displayValue: ('dc.' + mdField2.toString()).split('.').join('.​'),
|
|
||||||
value: ('dc.' + mdField2.toString())
|
|
||||||
},
|
|
||||||
{
|
|
||||||
displayValue: ('dc.' + mdField3.toString()).split('.').join('.​'),
|
|
||||||
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);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
@@ -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('.​'),
|
|
||||||
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);
|
|
||||||
}
|
|
||||||
}
|
|
@@ -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"> {{"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"> {{"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"> {{"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"> {{"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>
|
|
@@ -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);
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
}
|
|
@@ -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');
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
@@ -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;
|
|
||||||
}
|
|
||||||
}
|
|
@@ -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`);
|
|
||||||
}
|
|
||||||
}
|
|
@@ -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>
|
||||||
|
@@ -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>;
|
||||||
|
|
||||||
|
let relationshipService;
|
||||||
|
|
||||||
|
beforeEach(waitForAsync(() => {
|
||||||
relationshipService = {
|
relationshipService = {
|
||||||
findById: (id: string) => {
|
resolveMetadataRepresentation: (metadatum: MetadataValue, parent: DSpaceObject, type: string) => {
|
||||||
if (id === 'related-author') {
|
if (metadatum.value === 'Related Author with authority') {
|
||||||
return createSuccessfulRemoteDataObject$(authorRelation);
|
return observableOf(Object.assign(new ItemMetadataRepresentation(metadatum), relatedAuthor));
|
||||||
}
|
}
|
||||||
if (id === 'related-creator') {
|
if (metadatum.value === 'Author without authority') {
|
||||||
return createSuccessfulRemoteDataObject$(creatorRelation);
|
return observableOf(Object.assign(new MetadatumRepresentation(type), metadatum));
|
||||||
}
|
}
|
||||||
if (id === 'related-creator-unauthorized') {
|
if (metadatum.value === 'Related Creator with authority') {
|
||||||
return createSuccessfulRemoteDataObject$(creatorRelationUnauthorized);
|
return observableOf(Object.assign(new ItemMetadataRepresentation(metadatum), relatedCreator));
|
||||||
|
}
|
||||||
|
if (metadatum.value === 'Related Creator with authority - unauthorized') {
|
||||||
|
return observableOf(Object.assign(new MetadatumRepresentation(type), metadatum));
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
beforeEach(waitForAsync(() => {
|
|
||||||
TestBed.configureTestingModule({
|
TestBed.configureTestingModule({
|
||||||
imports: [TranslateModule.forRoot()],
|
imports: [TranslateModule.forRoot()],
|
||||||
declarations: [MetadataRepresentationListComponent, VarDirective],
|
declarations: [MetadataRepresentationListComponent, VarDirective],
|
||||||
|
@@ -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));
|
|
||||||
}
|
|
||||||
})
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@@ -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,
|
||||||
|
@@ -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
|
||||||
|
@@ -1,6 +1,8 @@
|
|||||||
<div class="page-internal-server-error container">
|
<div class="page-internal-server-error container">
|
||||||
<h1>500</h1>
|
<h1>500</h1>
|
||||||
<h2><small>{{"500.page-internal-server-error" | translate}}</small></h2>
|
<h2><small>
|
||||||
|
{{"500.page-internal-server-error" | translate}}
|
||||||
|
</small></h2>
|
||||||
<br/>
|
<br/>
|
||||||
<p>{{"500.help" | translate}}</p>
|
<p>{{"500.help" | translate}}</p>
|
||||||
<br/>
|
<br/>
|
||||||
|
@@ -42,6 +42,7 @@ import {
|
|||||||
} from './page-internal-server-error/page-internal-server-error.component';
|
} from './page-internal-server-error/page-internal-server-error.component';
|
||||||
import { ThemedPageErrorComponent } from './page-error/themed-page-error.component';
|
import { ThemedPageErrorComponent } from './page-error/themed-page-error.component';
|
||||||
import { PageErrorComponent } from './page-error/page-error.component';
|
import { PageErrorComponent } from './page-error/page-error.component';
|
||||||
|
import { ContextHelpToggleComponent } from './header/context-help-toggle/context-help-toggle.component';
|
||||||
|
|
||||||
const IMPORTS = [
|
const IMPORTS = [
|
||||||
CommonModule,
|
CommonModule,
|
||||||
@@ -78,7 +79,8 @@ const DECLARATIONS = [
|
|||||||
ThemedPageInternalServerErrorComponent,
|
ThemedPageInternalServerErrorComponent,
|
||||||
PageInternalServerErrorComponent,
|
PageInternalServerErrorComponent,
|
||||||
ThemedPageErrorComponent,
|
ThemedPageErrorComponent,
|
||||||
PageErrorComponent
|
PageErrorComponent,
|
||||||
|
ContextHelpToggleComponent,
|
||||||
];
|
];
|
||||||
|
|
||||||
const EXPORTS = [
|
const EXPORTS = [
|
||||||
|
@@ -61,7 +61,7 @@ export class RootComponent implements OnInit {
|
|||||||
}
|
}
|
||||||
|
|
||||||
ngOnInit() {
|
ngOnInit() {
|
||||||
this.sidebarVisible = this.menuService.isMenuVisible(MenuID.ADMIN);
|
this.sidebarVisible = this.menuService.isMenuVisibleWithVisibleSections(MenuID.ADMIN);
|
||||||
|
|
||||||
this.collapsedSidebarWidth = this.cssService.getVariable('--ds-collapsed-sidebar-width');
|
this.collapsedSidebarWidth = this.cssService.getVariable('--ds-collapsed-sidebar-width');
|
||||||
this.totalSidebarWidth = this.cssService.getVariable('--ds-total-sidebar-width');
|
this.totalSidebarWidth = this.cssService.getVariable('--ds-total-sidebar-width');
|
||||||
|
@@ -14,6 +14,11 @@ a.submit-icon {
|
|||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
position: sticky;
|
position: sticky;
|
||||||
top: 0;
|
top: 0;
|
||||||
|
|
||||||
|
color: var(--ds-header-icon-color);
|
||||||
|
&:hover, &:focus {
|
||||||
|
color: var(--ds-header-icon-color-hover);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@media screen and (max-width: map-get($grid-breakpoints, md)) {
|
@media screen and (max-width: map-get($grid-breakpoints, md)) {
|
||||||
@@ -22,8 +27,5 @@ a.submit-icon {
|
|||||||
width: 40vw !important;
|
width: 40vw !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
a.submit-icon {
|
|
||||||
color: var(--bs-link-color);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@@ -13,9 +13,9 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.dropdown-toggle {
|
.dropdown-toggle {
|
||||||
color: var(--ds-header-icon-color) !important;
|
color: var(--ds-header-icon-color);
|
||||||
|
|
||||||
&:hover, &focus {
|
&:hover, &:focus {
|
||||||
color: var(--ds-header-icon-color-hover);
|
color: var(--ds-header-icon-color-hover);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@@ -0,0 +1,25 @@
|
|||||||
|
<ng-template #help>
|
||||||
|
<div class="preserve-line-breaks ds-context-help-content">
|
||||||
|
<ng-container *ngFor="let elem of (parsedContent$ | async)">
|
||||||
|
<ng-container *ngIf="elem.href">
|
||||||
|
<a href="{{elem.href}}" target="_blank">{{elem.text}}</a>
|
||||||
|
</ng-container>
|
||||||
|
<ng-container *ngIf="elem.href === undefined">
|
||||||
|
{{ elem }}
|
||||||
|
</ng-container>
|
||||||
|
</ng-container>
|
||||||
|
</div>
|
||||||
|
</ng-template>
|
||||||
|
<i *ngIf="shouldShowIcon$ | async"
|
||||||
|
[ngClass]="{'ds-context-help-icon fas fa-question-circle shadow-sm': true,
|
||||||
|
'ds-context-help-icon-right': iconPlacement !== 'left',
|
||||||
|
'ds-context-help-icon-left': iconPlacement === 'left'}"
|
||||||
|
[ngbTooltip]="help"
|
||||||
|
[placement]="tooltipPlacement"
|
||||||
|
autoClose="outside"
|
||||||
|
triggers="manual"
|
||||||
|
container="body"
|
||||||
|
#tooltip="ngbTooltip"
|
||||||
|
(click)="onClick()">
|
||||||
|
</i>
|
||||||
|
<ng-container *ngTemplateOutlet="templateRef"></ng-container>
|
@@ -0,0 +1,31 @@
|
|||||||
|
:host {
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ds-context-help-icon {
|
||||||
|
position: absolute;
|
||||||
|
top: 0;
|
||||||
|
cursor: pointer;
|
||||||
|
color: var(--bs-info);
|
||||||
|
background-color: var(--bs-white);
|
||||||
|
font-size: 16px; // not relative, because we don't want the icon to resize based on the container
|
||||||
|
line-height: 1;
|
||||||
|
border-radius: 50%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ds-context-help-icon-left {
|
||||||
|
left: var(--ds-context-x-offset);
|
||||||
|
}
|
||||||
|
|
||||||
|
.ds-context-help-icon-right {
|
||||||
|
right: calc(-1 * var(--ds-context-help-icon-size));
|
||||||
|
}
|
||||||
|
|
||||||
|
::ng-deep .tooltip-inner {
|
||||||
|
width: var(--ds-context-help-tooltip-width);
|
||||||
|
max-width: var(--ds-context-help-tooltip-width);
|
||||||
|
a {
|
||||||
|
color: var(--ds-context-help-tooltip-link-color);
|
||||||
|
text-decoration: underline;
|
||||||
|
}
|
||||||
|
}
|
@@ -0,0 +1,219 @@
|
|||||||
|
import { ComponentFixture, TestBed, waitForAsync } from '@angular/core/testing';
|
||||||
|
import { of as observableOf, BehaviorSubject } from 'rxjs';
|
||||||
|
import { ContextHelpWrapperComponent } from './context-help-wrapper.component';
|
||||||
|
import { NgbTooltipModule } from '@ng-bootstrap/ng-bootstrap';
|
||||||
|
import { TranslateService } from '@ngx-translate/core';
|
||||||
|
import { ContextHelpService } from '../context-help.service';
|
||||||
|
import { ContextHelp } from '../context-help.model';
|
||||||
|
import { Component, Input, DebugElement } from '@angular/core';
|
||||||
|
import { PlacementArray } from '@ng-bootstrap/ng-bootstrap/util/positioning';
|
||||||
|
import { PlacementDir } from './placement-dir.model';
|
||||||
|
import { By } from '@angular/platform-browser';
|
||||||
|
|
||||||
|
@Component({
|
||||||
|
template: `
|
||||||
|
<ng-template #div>template</ng-template>
|
||||||
|
<ds-context-help-wrapper
|
||||||
|
#chwrapper
|
||||||
|
[templateRef]="div"
|
||||||
|
[content]="content"
|
||||||
|
[id]="id"
|
||||||
|
[tooltipPlacement]="tooltipPlacement"
|
||||||
|
[iconPlacement]="iconPlacement"
|
||||||
|
[dontParseLinks]="dontParseLinks"
|
||||||
|
>
|
||||||
|
</ds-context-help-wrapper>
|
||||||
|
`
|
||||||
|
})
|
||||||
|
class TemplateComponent {
|
||||||
|
@Input() content: string;
|
||||||
|
@Input() id: string;
|
||||||
|
@Input() tooltipPlacement?: PlacementArray;
|
||||||
|
@Input() iconPlacement?: PlacementDir;
|
||||||
|
@Input() dontParseLinks?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
const messages = {
|
||||||
|
lorem: 'lorem ipsum dolor sit amet',
|
||||||
|
linkTest: 'This is text, [this](https://dspace.lyrasis.org/) is a link, and [so is this](https://google.com/)'
|
||||||
|
};
|
||||||
|
const exampleContextHelp: ContextHelp = {
|
||||||
|
id: 'test-tooltip',
|
||||||
|
isTooltipVisible: false
|
||||||
|
};
|
||||||
|
|
||||||
|
describe('ContextHelpWrapperComponent', () => {
|
||||||
|
let templateComponent: TemplateComponent;
|
||||||
|
let wrapperComponent: ContextHelpWrapperComponent;
|
||||||
|
let fixture: ComponentFixture<TemplateComponent>;
|
||||||
|
let el: DebugElement;
|
||||||
|
let translateService: any;
|
||||||
|
let contextHelpService: any;
|
||||||
|
let getContextHelp$: BehaviorSubject<ContextHelp>;
|
||||||
|
let shouldShowIcons$: BehaviorSubject<boolean>;
|
||||||
|
|
||||||
|
function makeWrappedElement(): HTMLElement {
|
||||||
|
const wrapped: HTMLElement = document.createElement('div');
|
||||||
|
wrapped.innerHTML = 'example element';
|
||||||
|
return wrapped;
|
||||||
|
}
|
||||||
|
|
||||||
|
beforeEach(waitForAsync( () => {
|
||||||
|
translateService = jasmine.createSpyObj('translateService', ['get']);
|
||||||
|
contextHelpService = jasmine.createSpyObj('contextHelpService', [
|
||||||
|
'shouldShowIcons$',
|
||||||
|
'getContextHelp$',
|
||||||
|
'add',
|
||||||
|
'remove',
|
||||||
|
'toggleIcons',
|
||||||
|
'toggleTooltip',
|
||||||
|
'showTooltip',
|
||||||
|
'hideTooltip'
|
||||||
|
]);
|
||||||
|
|
||||||
|
TestBed.configureTestingModule({
|
||||||
|
imports: [ NgbTooltipModule ],
|
||||||
|
providers: [
|
||||||
|
{ provide: TranslateService, useValue: translateService },
|
||||||
|
{ provide: ContextHelpService, useValue: contextHelpService },
|
||||||
|
],
|
||||||
|
declarations: [ TemplateComponent, ContextHelpWrapperComponent ]
|
||||||
|
}).compileComponents();
|
||||||
|
}));
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
// Initializing services.
|
||||||
|
getContextHelp$ = new BehaviorSubject<ContextHelp>(exampleContextHelp);
|
||||||
|
shouldShowIcons$ = new BehaviorSubject<boolean>(false);
|
||||||
|
contextHelpService.getContextHelp$.and.returnValue(getContextHelp$);
|
||||||
|
contextHelpService.shouldShowIcons$.and.returnValue(shouldShowIcons$);
|
||||||
|
translateService.get.and.callFake((content) => observableOf(messages[content]));
|
||||||
|
|
||||||
|
getContextHelp$.next(exampleContextHelp);
|
||||||
|
shouldShowIcons$.next(false);
|
||||||
|
|
||||||
|
// Initializing components.
|
||||||
|
fixture = TestBed.createComponent(TemplateComponent);
|
||||||
|
el = fixture.debugElement;
|
||||||
|
templateComponent = fixture.componentInstance;
|
||||||
|
templateComponent.content = 'lorem';
|
||||||
|
templateComponent.id = 'test-tooltip';
|
||||||
|
templateComponent.tooltipPlacement = ['bottom'];
|
||||||
|
templateComponent.iconPlacement = 'left';
|
||||||
|
wrapperComponent = el.query(By.css('ds-context-help-wrapper')).componentInstance;
|
||||||
|
fixture.detectChanges();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should create', () => {
|
||||||
|
expect(templateComponent).toBeDefined();
|
||||||
|
expect(wrapperComponent).toBeDefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should not show the context help icon while icon visibility is not turned on', (done) => {
|
||||||
|
fixture.whenStable().then(() => {
|
||||||
|
const wrapper = el.query(By.css('ds-context-help-wrapper')).nativeElement;
|
||||||
|
expect(wrapper.children.length).toBe(0);
|
||||||
|
done();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('when icon visibility is turned on', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
shouldShowIcons$.next(true);
|
||||||
|
fixture.detectChanges();
|
||||||
|
spyOn(wrapperComponent.tooltip, 'open').and.callThrough();
|
||||||
|
spyOn(wrapperComponent.tooltip, 'close').and.callThrough();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should show the context help button', (done) => {
|
||||||
|
fixture.whenStable().then(() => {
|
||||||
|
const wrapper = el.query(By.css('ds-context-help-wrapper')).nativeElement;
|
||||||
|
expect(wrapper.children.length).toBe(1);
|
||||||
|
const [i] = wrapper.children;
|
||||||
|
expect(i.tagName).toBe('I');
|
||||||
|
done();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('after the icon is clicked', () => {
|
||||||
|
let i;
|
||||||
|
beforeEach(() => {
|
||||||
|
i = el.query(By.css('.ds-context-help-icon')).nativeElement;
|
||||||
|
i.click();
|
||||||
|
fixture.detectChanges();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should display the tooltip', () => {
|
||||||
|
expect(contextHelpService.toggleTooltip).toHaveBeenCalledWith('test-tooltip');
|
||||||
|
getContextHelp$.next({...exampleContextHelp, isTooltipVisible: true});
|
||||||
|
fixture.detectChanges();
|
||||||
|
expect(wrapperComponent.tooltip.open).toHaveBeenCalled();
|
||||||
|
expect(wrapperComponent.tooltip.close).toHaveBeenCalledTimes(0);
|
||||||
|
expect(fixture.debugElement.query(By.css('.ds-context-help-content')).nativeElement.textContent)
|
||||||
|
.toMatch(/\s*lorem ipsum dolor sit amet\s*/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should correctly display links', () => {
|
||||||
|
templateComponent.content = 'linkTest';
|
||||||
|
getContextHelp$.next({...exampleContextHelp, isTooltipVisible: true});
|
||||||
|
fixture.detectChanges();
|
||||||
|
const nodeList: NodeList = fixture.debugElement.query(By.css('.ds-context-help-content'))
|
||||||
|
.nativeElement
|
||||||
|
.childNodes;
|
||||||
|
const relevantNodes = Array.from(nodeList).filter(node => node.nodeType !== Node.COMMENT_NODE);
|
||||||
|
expect(relevantNodes.length).toBe(4);
|
||||||
|
|
||||||
|
const [text1, link1, text2, link2] = relevantNodes;
|
||||||
|
|
||||||
|
expect(text1.nodeType).toBe(Node.TEXT_NODE);
|
||||||
|
expect(text1.nodeValue).toMatch(/\s* This is text, \s*/);
|
||||||
|
|
||||||
|
expect(link1.nodeName).toBe('A');
|
||||||
|
expect((link1 as any).href).toBe('https://dspace.lyrasis.org/');
|
||||||
|
expect(link1.textContent).toBe('this');
|
||||||
|
|
||||||
|
expect(text2.nodeType).toBe(Node.TEXT_NODE);
|
||||||
|
expect(text2.nodeValue).toMatch(/\s* is a link, and \s*/);
|
||||||
|
|
||||||
|
expect(link2.nodeName).toBe('A');
|
||||||
|
expect((link2 as any).href).toBe('https://google.com/');
|
||||||
|
expect(link2.textContent).toBe('so is this');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should not display links if specified not to', () => {
|
||||||
|
templateComponent.dontParseLinks = true;
|
||||||
|
templateComponent.content = 'linkTest';
|
||||||
|
getContextHelp$.next({...exampleContextHelp, isTooltipVisible: true});
|
||||||
|
fixture.detectChanges();
|
||||||
|
|
||||||
|
|
||||||
|
const nodeList: NodeList = fixture.debugElement.query(By.css('.ds-context-help-content'))
|
||||||
|
.nativeElement
|
||||||
|
.childNodes;
|
||||||
|
const relevantNodes = Array.from(nodeList).filter(node => node.nodeType !== Node.COMMENT_NODE);
|
||||||
|
expect(relevantNodes.length).toBe(1);
|
||||||
|
|
||||||
|
const [text] = relevantNodes;
|
||||||
|
|
||||||
|
expect(text.nodeType).toBe(Node.TEXT_NODE);
|
||||||
|
expect(text.nodeValue).toMatch(
|
||||||
|
/\s* This is text, \[this\]\(https:\/\/dspace.lyrasis.org\/\) is a link, and \[so is this\]\(https:\/\/google.com\/\) \s*/);
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('after the icon is clicked again', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
i.click();
|
||||||
|
fixture.detectChanges();
|
||||||
|
spyOn(wrapperComponent.tooltip, 'isOpen').and.returnValue(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should close the tooltip', () => {
|
||||||
|
expect(contextHelpService.toggleTooltip).toHaveBeenCalledWith('test-tooltip');
|
||||||
|
getContextHelp$.next({...exampleContextHelp, isTooltipVisible: false});
|
||||||
|
fixture.detectChanges();
|
||||||
|
expect(wrapperComponent.tooltip.close).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
@@ -0,0 +1,171 @@
|
|||||||
|
import { Component, Input, OnInit, TemplateRef, OnDestroy, ViewChild } from '@angular/core';
|
||||||
|
import { PlacementArray } from '@ng-bootstrap/ng-bootstrap/util/positioning';
|
||||||
|
import { TranslateService } from '@ngx-translate/core';
|
||||||
|
import { Observable, Subscription, BehaviorSubject, combineLatest } from 'rxjs';
|
||||||
|
import { map, distinctUntilChanged, mergeMap } from 'rxjs/operators';
|
||||||
|
import { PlacementDir } from './placement-dir.model';
|
||||||
|
import { ContextHelpService } from '../context-help.service';
|
||||||
|
import { NgbTooltip } from '@ng-bootstrap/ng-bootstrap';
|
||||||
|
import { hasValueOperator } from '../empty.util';
|
||||||
|
import { ContextHelp } from '../context-help.model';
|
||||||
|
|
||||||
|
type ParsedContent = (string | {href: string, text: string})[];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* This component renders an info icon next to the wrapped element which
|
||||||
|
* produces a tooltip when clicked.
|
||||||
|
*/
|
||||||
|
@Component({
|
||||||
|
selector: 'ds-context-help-wrapper',
|
||||||
|
templateUrl: './context-help-wrapper.component.html',
|
||||||
|
styleUrls: ['./context-help-wrapper.component.scss'],
|
||||||
|
})
|
||||||
|
export class ContextHelpWrapperComponent implements OnInit, OnDestroy {
|
||||||
|
/**
|
||||||
|
* Template reference for the wrapped element.
|
||||||
|
*/
|
||||||
|
@Input() templateRef: TemplateRef<any>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Identifier for the context help tooltip.
|
||||||
|
*/
|
||||||
|
@Input() id: string;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Indicate where the tooltip should show up, relative to the info icon.
|
||||||
|
*/
|
||||||
|
@Input() tooltipPlacement?: PlacementArray = [];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Indicate whether the info icon should appear to the left or to
|
||||||
|
* the right of the wrapped element.
|
||||||
|
*/
|
||||||
|
@Input() iconPlacement?: PlacementDir = 'left';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* If true, don't process text to render links.
|
||||||
|
*/
|
||||||
|
@Input() set dontParseLinks(dont: boolean) {
|
||||||
|
this.dontParseLinks$.next(dont);
|
||||||
|
}
|
||||||
|
private dontParseLinks$: BehaviorSubject<boolean> = new BehaviorSubject(false);
|
||||||
|
|
||||||
|
shouldShowIcon$: Observable<boolean>;
|
||||||
|
|
||||||
|
tooltip: NgbTooltip;
|
||||||
|
|
||||||
|
@Input() set content(translateKey: string) {
|
||||||
|
this.content$.next(translateKey);
|
||||||
|
}
|
||||||
|
private content$: BehaviorSubject<string | undefined> = new BehaviorSubject(undefined);
|
||||||
|
|
||||||
|
parsedContent$: Observable<ParsedContent>;
|
||||||
|
|
||||||
|
private subs: {always: Subscription[], tooltipBound: Subscription[]}
|
||||||
|
= {always: [], tooltipBound: []};
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
private translateService: TranslateService,
|
||||||
|
private contextHelpService: ContextHelpService
|
||||||
|
) { }
|
||||||
|
|
||||||
|
ngOnInit() {
|
||||||
|
this.parsedContent$ = combineLatest([
|
||||||
|
this.content$.pipe(distinctUntilChanged(), mergeMap(translateKey => this.translateService.get(translateKey))),
|
||||||
|
this.dontParseLinks$.pipe(distinctUntilChanged())
|
||||||
|
]).pipe(
|
||||||
|
map(([text, dontParseLinks]) =>
|
||||||
|
dontParseLinks ? [text] : this.parseLinks(text))
|
||||||
|
);
|
||||||
|
this.shouldShowIcon$ = this.contextHelpService.shouldShowIcons$();
|
||||||
|
this.subs.always = [this.parsedContent$.subscribe(), this.shouldShowIcon$.subscribe()];
|
||||||
|
}
|
||||||
|
|
||||||
|
@ViewChild('tooltip', { static: false }) set setTooltip(tooltip: NgbTooltip) {
|
||||||
|
this.tooltip = tooltip;
|
||||||
|
this.clearSubs('tooltipBound');
|
||||||
|
if (this.tooltip !== undefined) {
|
||||||
|
this.subs.tooltipBound = [
|
||||||
|
this.contextHelpService.getContextHelp$(this.id)
|
||||||
|
.pipe(hasValueOperator())
|
||||||
|
.subscribe((ch: ContextHelp) => {
|
||||||
|
|
||||||
|
if (ch.isTooltipVisible && !this.tooltip.isOpen()) {
|
||||||
|
this.tooltip.open();
|
||||||
|
} else if (!ch.isTooltipVisible && this.tooltip.isOpen()) {
|
||||||
|
this.tooltip.close();
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
|
||||||
|
this.tooltip.shown.subscribe(() => {
|
||||||
|
this.contextHelpService.showTooltip(this.id);
|
||||||
|
}),
|
||||||
|
|
||||||
|
this.tooltip.hidden.subscribe(() => {
|
||||||
|
this.contextHelpService.hideTooltip(this.id);
|
||||||
|
})
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
ngOnDestroy() {
|
||||||
|
this.clearSubs();
|
||||||
|
}
|
||||||
|
|
||||||
|
onClick() {
|
||||||
|
this.contextHelpService.toggleTooltip(this.id);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Parses Markdown-style links, splitting up a given text
|
||||||
|
* into link-free pieces of text and objects of the form
|
||||||
|
* {href: string, text: string} (which represent links).
|
||||||
|
* This function makes no effort to check whether the href is a
|
||||||
|
* correct URL. Currently, this function does not support escape
|
||||||
|
* characters: its behavior when given a string containing square
|
||||||
|
* brackets that do not deliminate a link is undefined.
|
||||||
|
* Regular parentheses outside of links do work, however.
|
||||||
|
*
|
||||||
|
* For example:
|
||||||
|
* parseLinks("This is text, [this](https://google.com) is a link, and [so is this](https://youtube.com)")
|
||||||
|
* =
|
||||||
|
* [ "This is text, ",
|
||||||
|
* {href: "https://google.com", text: "this"},
|
||||||
|
* " is a link, and ",
|
||||||
|
* {href: "https://youtube.com", text: "so is this"}
|
||||||
|
* ]
|
||||||
|
*/
|
||||||
|
private parseLinks(text: string): ParsedContent {
|
||||||
|
// Implementation note: due to `matchAll` method on strings not being available for all versions,
|
||||||
|
// separate "split" and "parse" steps are needed.
|
||||||
|
|
||||||
|
// We use splitRegexp (the outer `match` call) to split the text
|
||||||
|
// into link-free pieces of text (matched by /[^\[]+/) and pieces
|
||||||
|
// of text of the form "[some link text](some.link.here)" (matched
|
||||||
|
// by /\[([^\]]*)\]\(([^\)]*)\)/)
|
||||||
|
const splitRegexp = /[^\[]+|\[([^\]]*)\]\(([^\)]*)\)/g;
|
||||||
|
|
||||||
|
// Once the array is split up in link-representing strings and
|
||||||
|
// non-link-representing strings, we use parseRegexp (the inner
|
||||||
|
// `match` call) to transform the link-representing strings into
|
||||||
|
// {href: string, text: string} objects.
|
||||||
|
const parseRegexp = /^\[([^\]]*)\]\(([^\)]*)\)$/;
|
||||||
|
|
||||||
|
return text.match(splitRegexp).map((substring: string) => {
|
||||||
|
const match = substring.match(parseRegexp);
|
||||||
|
return match === null
|
||||||
|
? substring
|
||||||
|
: ({href: match[2], text: match[1]});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private clearSubs(filter: null | 'tooltipBound' = null) {
|
||||||
|
if (filter === null) {
|
||||||
|
[].concat(...Object.values(this.subs)).forEach(sub => sub.unsubscribe());
|
||||||
|
this.subs = {always: [], tooltipBound: []};
|
||||||
|
} else {
|
||||||
|
this.subs[filter].forEach(sub => sub.unsubscribe());
|
||||||
|
this.subs[filter] = [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
@@ -0,0 +1 @@
|
|||||||
|
export type PlacementDir = 'left' | 'right';
|
83
src/app/shared/context-help.actions.ts
Normal file
83
src/app/shared/context-help.actions.ts
Normal file
@@ -0,0 +1,83 @@
|
|||||||
|
/* eslint-disable max-classes-per-file */
|
||||||
|
|
||||||
|
import { Action } from '@ngrx/store';
|
||||||
|
import { type } from './ngrx/type';
|
||||||
|
import { ContextHelp } from './context-help.model';
|
||||||
|
|
||||||
|
export const ContextHelpActionTypes = {
|
||||||
|
'CONTEXT_HELP_TOGGLE_ICONS': type('dspace/context-help/CONTEXT_HELP_TOGGLE_ICONS'),
|
||||||
|
'CONTEXT_HELP_ADD': type('dspace/context-help/CONTEXT_HELP_ADD'),
|
||||||
|
'CONTEXT_HELP_REMOVE': type('dspace/context-help/CONTEXT_HELP_REMOVE'),
|
||||||
|
'CONTEXT_HELP_TOGGLE_TOOLTIP': type('dspace/context-help/CONTEXT_HELP_TOGGLE_TOOLTIP'),
|
||||||
|
'CONTEXT_HELP_SHOW_TOOLTIP': type('dspace/context-help/CONTEXT_HELP_SHOW_TOOLTIP'),
|
||||||
|
'CONTEXT_HELP_HIDE_TOOLTIP' : type('dspace/context-help/CONTEXT_HELP_HIDE_TOOLTIP'),
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Toggles the visibility of all context help icons.
|
||||||
|
*/
|
||||||
|
export class ContextHelpToggleIconsAction implements Action {
|
||||||
|
type = ContextHelpActionTypes.CONTEXT_HELP_TOGGLE_ICONS;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Registers a new context help icon to the store.
|
||||||
|
*/
|
||||||
|
export class ContextHelpAddAction implements Action {
|
||||||
|
type = ContextHelpActionTypes.CONTEXT_HELP_ADD;
|
||||||
|
model: ContextHelp;
|
||||||
|
|
||||||
|
constructor (model: ContextHelp) {
|
||||||
|
this.model = model;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Removes a context help icon from the store.
|
||||||
|
*/
|
||||||
|
export class ContextHelpRemoveAction implements Action {
|
||||||
|
type = ContextHelpActionTypes.CONTEXT_HELP_REMOVE;
|
||||||
|
id: string;
|
||||||
|
|
||||||
|
constructor(id: string) {
|
||||||
|
this.id = id;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export abstract class ContextHelpTooltipAction implements Action {
|
||||||
|
type;
|
||||||
|
id: string;
|
||||||
|
|
||||||
|
constructor(id: string) {
|
||||||
|
this.id = id;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Toggles the tooltip of a single context help icon.
|
||||||
|
*/
|
||||||
|
export class ContextHelpToggleTooltipAction extends ContextHelpTooltipAction {
|
||||||
|
type = ContextHelpActionTypes.CONTEXT_HELP_TOGGLE_TOOLTIP;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Shows the tooltip of a single context help icon.
|
||||||
|
*/
|
||||||
|
export class ContextHelpShowTooltipAction extends ContextHelpTooltipAction {
|
||||||
|
type = ContextHelpActionTypes.CONTEXT_HELP_SHOW_TOOLTIP;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Hides the tooltip of a single context help icon.
|
||||||
|
*/
|
||||||
|
export class ContextHelpHideTooltipAction extends ContextHelpTooltipAction {
|
||||||
|
type = ContextHelpActionTypes.CONTEXT_HELP_HIDE_TOOLTIP;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type ContextHelpAction
|
||||||
|
= ContextHelpToggleIconsAction
|
||||||
|
| ContextHelpAddAction
|
||||||
|
| ContextHelpRemoveAction
|
||||||
|
| ContextHelpToggleTooltipAction
|
||||||
|
| ContextHelpShowTooltipAction
|
||||||
|
| ContextHelpHideTooltipAction;
|
93
src/app/shared/context-help.directive.spec.ts
Normal file
93
src/app/shared/context-help.directive.spec.ts
Normal file
@@ -0,0 +1,93 @@
|
|||||||
|
import { Component, Input } from '@angular/core';
|
||||||
|
import { ComponentFixture, TestBed, waitForAsync } from '@angular/core/testing';
|
||||||
|
import { of as observableOf, BehaviorSubject } from 'rxjs';
|
||||||
|
import { ContextHelpDirective, ContextHelpDirectiveInput } from './context-help.directive';
|
||||||
|
import { TranslateService } from '@ngx-translate/core';
|
||||||
|
import { ContextHelpWrapperComponent } from './context-help-wrapper/context-help-wrapper.component';
|
||||||
|
import { NgbTooltipModule } from '@ng-bootstrap/ng-bootstrap';
|
||||||
|
import { ContextHelpService } from './context-help.service';
|
||||||
|
import { ContextHelp } from './context-help.model';
|
||||||
|
|
||||||
|
@Component({
|
||||||
|
template: `<div *dsContextHelp="contextHelpParams()">some text</div>`
|
||||||
|
})
|
||||||
|
class TestComponent {
|
||||||
|
@Input() content = '';
|
||||||
|
@Input() id = '';
|
||||||
|
contextHelpParams(): ContextHelpDirectiveInput {
|
||||||
|
return {
|
||||||
|
content: this.content,
|
||||||
|
id: this.id,
|
||||||
|
iconPlacement: 'left',
|
||||||
|
tooltipPlacement: ['bottom']
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const messages = {
|
||||||
|
lorem: 'lorem ipsum dolor sit amet',
|
||||||
|
linkTest: 'This is text, [this](https://dspace.lyrasis.org) is a link, and [so is this](https://google.com)'
|
||||||
|
};
|
||||||
|
const exampleContextHelp: ContextHelp = {
|
||||||
|
id: 'test-tooltip',
|
||||||
|
isTooltipVisible: false
|
||||||
|
};
|
||||||
|
describe('ContextHelpDirective', () => {
|
||||||
|
let component: TestComponent;
|
||||||
|
let fixture: ComponentFixture<TestComponent>;
|
||||||
|
let translateService: any;
|
||||||
|
let contextHelpService: any;
|
||||||
|
let getContextHelp$: BehaviorSubject<ContextHelp>;
|
||||||
|
let shouldShowIcons$: BehaviorSubject<boolean>;
|
||||||
|
|
||||||
|
beforeEach(waitForAsync(() => {
|
||||||
|
translateService = jasmine.createSpyObj('translateService', ['get']);
|
||||||
|
contextHelpService = jasmine.createSpyObj('contextHelpService', [
|
||||||
|
'shouldShowIcons$',
|
||||||
|
'getContextHelp$',
|
||||||
|
'add',
|
||||||
|
'remove',
|
||||||
|
'toggleIcons',
|
||||||
|
'toggleTooltip',
|
||||||
|
'showTooltip',
|
||||||
|
'hideTooltip'
|
||||||
|
]);
|
||||||
|
|
||||||
|
TestBed.configureTestingModule({
|
||||||
|
imports: [NgbTooltipModule],
|
||||||
|
providers: [
|
||||||
|
{ provide: TranslateService, useValue: translateService },
|
||||||
|
{ provide: ContextHelpService, useValue: contextHelpService }
|
||||||
|
],
|
||||||
|
declarations: [TestComponent, ContextHelpWrapperComponent, ContextHelpDirective]
|
||||||
|
}).compileComponents();
|
||||||
|
}));
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
// Set up service behavior.
|
||||||
|
getContextHelp$ = new BehaviorSubject<ContextHelp>(exampleContextHelp);
|
||||||
|
shouldShowIcons$ = new BehaviorSubject<boolean>(false);
|
||||||
|
contextHelpService.getContextHelp$.and.returnValue(getContextHelp$);
|
||||||
|
contextHelpService.shouldShowIcons$.and.returnValue(shouldShowIcons$);
|
||||||
|
translateService.get.and.callFake((content) => observableOf(messages[content]));
|
||||||
|
|
||||||
|
// Set up fixture and component.
|
||||||
|
fixture = TestBed.createComponent(TestComponent);
|
||||||
|
component = fixture.componentInstance;
|
||||||
|
component.id = 'test-tooltip';
|
||||||
|
component.content = 'lorem';
|
||||||
|
|
||||||
|
fixture.detectChanges();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should generate the context help wrapper component', (done) => {
|
||||||
|
fixture.whenStable().then(() => {
|
||||||
|
expect(fixture.nativeElement.children.length).toBe(1);
|
||||||
|
const [wrapper] = fixture.nativeElement.children;
|
||||||
|
expect(component).toBeDefined();
|
||||||
|
expect(wrapper.tagName).toBe('DS-CONTEXT-HELP-WRAPPER');
|
||||||
|
expect(contextHelpService.add).toHaveBeenCalledWith(exampleContextHelp);
|
||||||
|
done();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
76
src/app/shared/context-help.directive.ts
Normal file
76
src/app/shared/context-help.directive.ts
Normal file
@@ -0,0 +1,76 @@
|
|||||||
|
import {
|
||||||
|
ComponentFactoryResolver,
|
||||||
|
ComponentRef,
|
||||||
|
Directive,
|
||||||
|
Input,
|
||||||
|
OnChanges,
|
||||||
|
TemplateRef,
|
||||||
|
ViewContainerRef,
|
||||||
|
OnDestroy
|
||||||
|
} from '@angular/core';
|
||||||
|
import { PlacementArray } from '@ng-bootstrap/ng-bootstrap/util/positioning';
|
||||||
|
import { ContextHelpWrapperComponent } from './context-help-wrapper/context-help-wrapper.component';
|
||||||
|
import { PlacementDir } from './context-help-wrapper/placement-dir.model';
|
||||||
|
import { ContextHelpService } from './context-help.service';
|
||||||
|
|
||||||
|
export interface ContextHelpDirectiveInput {
|
||||||
|
content: string;
|
||||||
|
id: string;
|
||||||
|
tooltipPlacement?: PlacementArray;
|
||||||
|
iconPlacement?: PlacementDir;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Directive to add a clickable tooltip icon to an element.
|
||||||
|
* The tooltip icon's position is configurable ('left' or 'right')
|
||||||
|
* and so is the position of the tooltip itself (PlacementArray).
|
||||||
|
*/
|
||||||
|
@Directive({
|
||||||
|
selector: '[dsContextHelp]',
|
||||||
|
})
|
||||||
|
export class ContextHelpDirective implements OnChanges, OnDestroy {
|
||||||
|
/**
|
||||||
|
* Expects an object with the following fields:
|
||||||
|
* - content: a string referring to an entry in the i18n files
|
||||||
|
* - tooltipPlacement: a PlacementArray describing where the tooltip should expand, relative to the tooltip icon
|
||||||
|
* - iconPlacement: a string 'left' or 'right', describing where the tooltip icon should be placed, relative to the element
|
||||||
|
*/
|
||||||
|
@Input() dsContextHelp: ContextHelpDirectiveInput;
|
||||||
|
mostRecentId: string | undefined = undefined;
|
||||||
|
|
||||||
|
protected wrapper: ComponentRef<ContextHelpWrapperComponent>;
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
private templateRef: TemplateRef<any>,
|
||||||
|
private viewContainerRef: ViewContainerRef,
|
||||||
|
private componentFactoryResolver: ComponentFactoryResolver,
|
||||||
|
private contextHelpService: ContextHelpService
|
||||||
|
) {}
|
||||||
|
|
||||||
|
ngOnChanges() {
|
||||||
|
this.clearMostRecentId();
|
||||||
|
this.mostRecentId = this.dsContextHelp.id;
|
||||||
|
this.contextHelpService.add({id: this.dsContextHelp.id, isTooltipVisible: false});
|
||||||
|
|
||||||
|
if (this.wrapper === undefined) {
|
||||||
|
const factory
|
||||||
|
= this.componentFactoryResolver.resolveComponentFactory(ContextHelpWrapperComponent);
|
||||||
|
this.wrapper = this.viewContainerRef.createComponent(factory);
|
||||||
|
}
|
||||||
|
this.wrapper.instance.templateRef = this.templateRef;
|
||||||
|
this.wrapper.instance.content = this.dsContextHelp.content;
|
||||||
|
this.wrapper.instance.id = this.dsContextHelp.id;
|
||||||
|
this.wrapper.instance.tooltipPlacement = this.dsContextHelp.tooltipPlacement;
|
||||||
|
this.wrapper.instance.iconPlacement = this.dsContextHelp.iconPlacement;
|
||||||
|
}
|
||||||
|
|
||||||
|
ngOnDestroy() {
|
||||||
|
this.clearMostRecentId();
|
||||||
|
}
|
||||||
|
|
||||||
|
private clearMostRecentId(): void {
|
||||||
|
if (this.mostRecentId !== undefined) {
|
||||||
|
this.contextHelpService.remove(this.mostRecentId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
4
src/app/shared/context-help.model.ts
Normal file
4
src/app/shared/context-help.model.ts
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
export class ContextHelp {
|
||||||
|
id: string;
|
||||||
|
isTooltipVisible = false;
|
||||||
|
}
|
48
src/app/shared/context-help.reducer.ts
Normal file
48
src/app/shared/context-help.reducer.ts
Normal file
@@ -0,0 +1,48 @@
|
|||||||
|
import { ContextHelp } from './context-help.model';
|
||||||
|
import { ContextHelpAction, ContextHelpActionTypes } from './context-help.actions';
|
||||||
|
|
||||||
|
export interface ContextHelpModels {
|
||||||
|
[id: string]: ContextHelp;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ContextHelpState {
|
||||||
|
allIconsVisible: boolean;
|
||||||
|
models: ContextHelpModels;
|
||||||
|
}
|
||||||
|
|
||||||
|
const initialState: ContextHelpState = {allIconsVisible: false, models: {}};
|
||||||
|
|
||||||
|
export function contextHelpReducer(state: ContextHelpState = initialState, action: ContextHelpAction): ContextHelpState {
|
||||||
|
switch (action.type) {
|
||||||
|
case ContextHelpActionTypes.CONTEXT_HELP_TOGGLE_ICONS: {
|
||||||
|
return {...state, allIconsVisible: !state.allIconsVisible};
|
||||||
|
}
|
||||||
|
case ContextHelpActionTypes.CONTEXT_HELP_ADD: {
|
||||||
|
const newModels = {...state.models, [action.model.id]: action.model};
|
||||||
|
return {...state, models: newModels};
|
||||||
|
}
|
||||||
|
case ContextHelpActionTypes.CONTEXT_HELP_REMOVE: {
|
||||||
|
const {[action.id]: _, ...remainingModels} = state.models;
|
||||||
|
return {...state, models: remainingModels};
|
||||||
|
}
|
||||||
|
case ContextHelpActionTypes.CONTEXT_HELP_TOGGLE_TOOLTIP: {
|
||||||
|
return modifyTooltipVisibility(state, action.id, v => !v);
|
||||||
|
}
|
||||||
|
case ContextHelpActionTypes.CONTEXT_HELP_SHOW_TOOLTIP: {
|
||||||
|
return modifyTooltipVisibility(state, action.id, _ => true);
|
||||||
|
}
|
||||||
|
case ContextHelpActionTypes.CONTEXT_HELP_HIDE_TOOLTIP: {
|
||||||
|
return modifyTooltipVisibility(state, action.id, _ => false);
|
||||||
|
}
|
||||||
|
default: {
|
||||||
|
return state;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function modifyTooltipVisibility(state: ContextHelpState, id: string, modify: (vis: boolean) => boolean): ContextHelpState {
|
||||||
|
const {[id]: matchingModel, ...otherModels} = state.models;
|
||||||
|
const modifiedModel = {...matchingModel, isTooltipVisible: modify(matchingModel.isTooltipVisible)};
|
||||||
|
const newModels = {...otherModels, [id]: modifiedModel};
|
||||||
|
return {...state, models: newModels};
|
||||||
|
}
|
78
src/app/shared/context-help.service.spec.ts
Normal file
78
src/app/shared/context-help.service.spec.ts
Normal file
@@ -0,0 +1,78 @@
|
|||||||
|
import { TestBed } from '@angular/core/testing';
|
||||||
|
|
||||||
|
import { ContextHelpService } from './context-help.service';
|
||||||
|
import { StoreModule, Store } from '@ngrx/store';
|
||||||
|
import { appReducers, storeModuleConfig } from '../app.reducer';
|
||||||
|
import { TestScheduler } from 'rxjs/testing';
|
||||||
|
|
||||||
|
describe('ContextHelpService', () => {
|
||||||
|
let service: ContextHelpService;
|
||||||
|
let store;
|
||||||
|
let testScheduler;
|
||||||
|
const booleans = { f: false, t: true };
|
||||||
|
const mkContextHelp = (id: string) => ({ 0: {id, isTooltipVisible: false}, 1: {id, isTooltipVisible: true} });
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
TestBed.configureTestingModule({
|
||||||
|
imports: [
|
||||||
|
StoreModule.forRoot(appReducers, storeModuleConfig)
|
||||||
|
]
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
store = TestBed.inject(Store);
|
||||||
|
service = new ContextHelpService(store);
|
||||||
|
testScheduler = new TestScheduler((actual, expected) => expect(actual).toEqual(expected));
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should be created', () => {
|
||||||
|
expect(service).toBeTruthy();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('toggleIcons calls should be observable in shouldShowIcons$', () => {
|
||||||
|
testScheduler.run(({cold, expectObservable}) => {
|
||||||
|
const toggles = cold('-xxxxx');
|
||||||
|
toggles.subscribe((_) => service.toggleIcons());
|
||||||
|
expectObservable(service.shouldShowIcons$()).toBe('ftftft', booleans);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('add and remove calls should be observable in getContextHelp$', () => {
|
||||||
|
testScheduler.run(({cold, expectObservable}) => {
|
||||||
|
const modifications = cold('-abAcCB', {
|
||||||
|
a: () => service.add({id: 'a', isTooltipVisible: false}),
|
||||||
|
b: () => service.add({id: 'b', isTooltipVisible: false}),
|
||||||
|
c: () => service.add({id: 'c', isTooltipVisible: false}),
|
||||||
|
A: () => service.remove('a'), B: () => service.remove('b'), C: () => service.remove('c'),
|
||||||
|
});
|
||||||
|
modifications.subscribe(mod => mod());
|
||||||
|
const match = (id) => ({ 0: undefined, 1: {id, isTooltipVisible: false} });
|
||||||
|
expectObservable(service.getContextHelp$('a')).toBe('01-0---', match('a'));
|
||||||
|
expectObservable(service.getContextHelp$('b')).toBe('0-1---0', match('b'));
|
||||||
|
expectObservable(service.getContextHelp$('c')).toBe('0---10-', match('c'));
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('toggleTooltip calls should be observable in getContextHelp$', () => {
|
||||||
|
service.add({id: 'a', isTooltipVisible: false});
|
||||||
|
service.add({id: 'b', isTooltipVisible: false});
|
||||||
|
testScheduler.run(({cold, expectObservable}) => {
|
||||||
|
const toggles = cold('-aaababbabba');
|
||||||
|
toggles.subscribe(id => service.toggleTooltip(id));
|
||||||
|
expectObservable(service.getContextHelp$('a')).toBe('0101-0--1--0', mkContextHelp('a'));
|
||||||
|
expectObservable(service.getContextHelp$('b')).toBe('0---1-01-01-', mkContextHelp('b'));
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('hideTooltip and showTooltip calls should be observable in getContextHelp$', () => {
|
||||||
|
service.add({id: 'a', isTooltipVisible: false});
|
||||||
|
testScheduler.run(({cold, expectObservable}) => {
|
||||||
|
const hideShowCalls = cold('-shssshhs', {
|
||||||
|
s: () => service.showTooltip('a'), h: () => service.hideTooltip('a')
|
||||||
|
});
|
||||||
|
hideShowCalls.subscribe(fn => fn());
|
||||||
|
expectObservable(service.getContextHelp$('a')).toBe('010111001', mkContextHelp('a'));
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
113
src/app/shared/context-help.service.ts
Normal file
113
src/app/shared/context-help.service.ts
Normal file
@@ -0,0 +1,113 @@
|
|||||||
|
import { Injectable } from '@angular/core';
|
||||||
|
import { ContextHelp } from './context-help.model';
|
||||||
|
import { Store, createFeatureSelector, createSelector, select, MemoizedSelector } from '@ngrx/store';
|
||||||
|
import { ContextHelpState, ContextHelpModels } from './context-help.reducer';
|
||||||
|
import {
|
||||||
|
ContextHelpToggleIconsAction,
|
||||||
|
ContextHelpAddAction,
|
||||||
|
ContextHelpRemoveAction,
|
||||||
|
ContextHelpShowTooltipAction,
|
||||||
|
ContextHelpHideTooltipAction,
|
||||||
|
ContextHelpToggleTooltipAction
|
||||||
|
} from './context-help.actions';
|
||||||
|
import { Observable } from 'rxjs';
|
||||||
|
import { map } from 'rxjs/operators';
|
||||||
|
|
||||||
|
const contextHelpStateSelector =
|
||||||
|
createFeatureSelector<ContextHelpState>('contextHelp');
|
||||||
|
const allIconsVisibleSelector = createSelector(
|
||||||
|
contextHelpStateSelector,
|
||||||
|
(state: ContextHelpState): boolean => state.allIconsVisible
|
||||||
|
);
|
||||||
|
const contextHelpSelector =
|
||||||
|
(id: string): MemoizedSelector<ContextHelpState, ContextHelp> => createSelector(
|
||||||
|
contextHelpStateSelector,
|
||||||
|
(state: ContextHelpState) => state.models[id]
|
||||||
|
);
|
||||||
|
const allContextHelpSelector = createSelector(
|
||||||
|
contextHelpStateSelector,
|
||||||
|
((state: ContextHelpState) => state.models)
|
||||||
|
);
|
||||||
|
|
||||||
|
@Injectable({
|
||||||
|
providedIn: 'root'
|
||||||
|
})
|
||||||
|
export class ContextHelpService {
|
||||||
|
constructor(private store: Store<ContextHelpState>) { }
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Observable keeping track of whether context help icons should be visible globally.
|
||||||
|
*/
|
||||||
|
shouldShowIcons$(): Observable<boolean> {
|
||||||
|
return this.store.pipe(select(allIconsVisibleSelector));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Observable that tracks the state for a specific context help icon.
|
||||||
|
*
|
||||||
|
* @param id: id of the context help icon.
|
||||||
|
*/
|
||||||
|
getContextHelp$(id: string): Observable<ContextHelp> {
|
||||||
|
return this.store.pipe(select(contextHelpSelector(id)));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Observable that yields true iff there are currently no context help entries in the store.
|
||||||
|
*/
|
||||||
|
tooltipCount$(): Observable<number> {
|
||||||
|
return this.store.pipe(select(allContextHelpSelector))
|
||||||
|
.pipe(map((models: ContextHelpModels) => Object.keys(models).length));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Toggles the visibility of all context help icons.
|
||||||
|
*/
|
||||||
|
toggleIcons() {
|
||||||
|
this.store.dispatch(new ContextHelpToggleIconsAction());
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Registers a new context help icon to the store.
|
||||||
|
*
|
||||||
|
* @param contextHelp: the initial state of the new help icon.
|
||||||
|
*/
|
||||||
|
add(contextHelp: ContextHelp) {
|
||||||
|
this.store.dispatch(new ContextHelpAddAction(contextHelp));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Removes a context help icon from the store.
|
||||||
|
*
|
||||||
|
* @id: the id of the help icon to be removed.
|
||||||
|
*/
|
||||||
|
remove(id: string) {
|
||||||
|
this.store.dispatch(new ContextHelpRemoveAction(id));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Toggles the tooltip of a single context help icon.
|
||||||
|
*
|
||||||
|
* @id: the id of the help icon for which the visibility will be toggled.
|
||||||
|
*/
|
||||||
|
toggleTooltip(id: string) {
|
||||||
|
this.store.dispatch(new ContextHelpToggleTooltipAction(id));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Shows the tooltip of a single context help icon.
|
||||||
|
*
|
||||||
|
* @id: the id of the help icon that will be made visible.
|
||||||
|
*/
|
||||||
|
showTooltip(id: string) {
|
||||||
|
this.store.dispatch(new ContextHelpShowTooltipAction(id));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Hides the tooltip of a single context help icon.
|
||||||
|
*
|
||||||
|
* @id: the id of the help icon that will be made invisible.
|
||||||
|
*/
|
||||||
|
hideTooltip(id: string) {
|
||||||
|
this.store.dispatch(new ContextHelpHideTooltipAction(id));
|
||||||
|
}
|
||||||
|
}
|
@@ -2,12 +2,10 @@
|
|||||||
display:none;
|
display:none;
|
||||||
}
|
}
|
||||||
|
|
||||||
@media screen and (min-width: map-get($grid-breakpoints, md)) {
|
|
||||||
.dropdown-toggle {
|
.dropdown-toggle {
|
||||||
color: var(--ds-header-icon-color) !important;
|
color: var(--ds-header-icon-color);
|
||||||
|
|
||||||
&:hover, &focus {
|
&:hover, &:focus {
|
||||||
color: var(--ds-header-icon-color-hover);
|
color: var(--ds-header-icon-color-hover);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
@@ -243,6 +243,84 @@ describe('MenuService', () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('isMenuVisibleWithVisibleSections', () => {
|
||||||
|
it('should return false when the menu is empty', () => {
|
||||||
|
const testMenu = {
|
||||||
|
id: MenuID.ADMIN,
|
||||||
|
collapsed: false,
|
||||||
|
visible: true,
|
||||||
|
sections: {},
|
||||||
|
previewCollapsed: false,
|
||||||
|
sectionToSubsectionIndex: {}
|
||||||
|
} as any;
|
||||||
|
spyOn(service, 'getMenu').and.returnValue(observableOf(testMenu));
|
||||||
|
|
||||||
|
const result = service.isMenuVisibleWithVisibleSections(MenuID.ADMIN);
|
||||||
|
const expected = cold('(b|)', {
|
||||||
|
b: false
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result).toBeObservable(expected);
|
||||||
|
});
|
||||||
|
it('should return false when no top-level sections are visible', () => {
|
||||||
|
const noTopLevelVisibleSections = {
|
||||||
|
section: {id: 's1', visible: false},
|
||||||
|
section_2: {id: 's2', visible: false},
|
||||||
|
section_3: {id: 's3', visible: false},
|
||||||
|
section_4: {id: 's1_1', visible: true, parentID: 's1'},
|
||||||
|
section_5: {id: 's2_1', visible: true, parentID: 's2'},
|
||||||
|
};
|
||||||
|
const testMenu = {
|
||||||
|
id: MenuID.ADMIN,
|
||||||
|
collapsed: false,
|
||||||
|
visible: true,
|
||||||
|
sections: noTopLevelVisibleSections,
|
||||||
|
previewCollapsed: false,
|
||||||
|
sectionToSubsectionIndex: {
|
||||||
|
'section': ['section_4'],
|
||||||
|
'section_2': ['section_5'],
|
||||||
|
}
|
||||||
|
} as any;
|
||||||
|
spyOn(service, 'getMenu').and.returnValue(observableOf(testMenu));
|
||||||
|
|
||||||
|
const result = service.isMenuVisibleWithVisibleSections(MenuID.ADMIN);
|
||||||
|
const expected = cold('(b|)', {
|
||||||
|
b: false
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result).toBeObservable(expected);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return true when any top-level section is visible', () => {
|
||||||
|
const noTopLevelVisibleSections = {
|
||||||
|
section: {id: 's1', visible: false},
|
||||||
|
section_2: {id: 's2', visible: true},
|
||||||
|
section_3: {id: 's3', visible: false},
|
||||||
|
section_4: {id: 's1_1', visible: true, parentID: 's1'},
|
||||||
|
section_5: {id: 's2_1', visible: true, parentID: 's2'},
|
||||||
|
};
|
||||||
|
const testMenu = {
|
||||||
|
id: MenuID.ADMIN,
|
||||||
|
collapsed: false,
|
||||||
|
visible: true,
|
||||||
|
sections: noTopLevelVisibleSections,
|
||||||
|
previewCollapsed: false,
|
||||||
|
sectionToSubsectionIndex: {
|
||||||
|
'section': ['section_4'],
|
||||||
|
'section_2': ['section_5'],
|
||||||
|
}
|
||||||
|
} as any;
|
||||||
|
spyOn(service, 'getMenu').and.returnValue(observableOf(testMenu));
|
||||||
|
|
||||||
|
const result = service.isMenuVisibleWithVisibleSections(MenuID.ADMIN);
|
||||||
|
const expected = cold('(b|)', {
|
||||||
|
b: true
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result).toBeObservable(expected);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
describe('isMenuVisible', () => {
|
describe('isMenuVisible', () => {
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
spyOn(service, 'getMenu').and.returnValue(observableOf(fakeMenu));
|
spyOn(service, 'getMenu').and.returnValue(observableOf(fakeMenu));
|
||||||
|
@@ -181,6 +181,18 @@ export class MenuService {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if a given menu is visible and has visible top-level (!) sections
|
||||||
|
* @param {MenuID} menuID The ID of the menu that is to be checked
|
||||||
|
* @returns {Observable<boolean>} Emits true if the given menu is
|
||||||
|
* visible and has visible sections, emits false when it's hidden
|
||||||
|
*/
|
||||||
|
isMenuVisibleWithVisibleSections(menuID: MenuID): Observable<boolean> {
|
||||||
|
return observableCombineLatest([this.isMenuVisible(menuID), this.menuHasVisibleSections(menuID)]).pipe(
|
||||||
|
map(([menuVisible, visibleSections]) => menuVisible && visibleSections)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Check if a given menu is visible
|
* Check if a given menu is visible
|
||||||
* @param {MenuID} menuID The ID of the menu that is to be checked
|
* @param {MenuID} menuID The ID of the menu that is to be checked
|
||||||
@@ -192,6 +204,20 @@ export class MenuService {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if a menu has at least one top-level (!) section that is visible.
|
||||||
|
* @param {MenuID} menuID The ID of the menu that is to be checked
|
||||||
|
* @returns {Observable<boolean>} Emits true if the given menu has visible sections, emits false otherwise
|
||||||
|
*/
|
||||||
|
menuHasVisibleSections(menuID: MenuID): Observable<boolean> {
|
||||||
|
return this.getMenu(menuID).pipe(
|
||||||
|
map((state: MenuState) => hasValue(state)
|
||||||
|
? Object.values(state.sections)
|
||||||
|
.some(section => section.visible && section.parentID === undefined)
|
||||||
|
: undefined)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Expands a given menu
|
* Expands a given menu
|
||||||
* @param {MenuID} menuID The ID of the menu
|
* @param {MenuID} menuID The ID of the menu
|
||||||
|
@@ -1,7 +1,7 @@
|
|||||||
.filters {
|
.filters {
|
||||||
a {
|
a {
|
||||||
color: var(--bs-body-color);
|
color: var(--bs-body-color);
|
||||||
&:hover, &focus {
|
&:hover, &:focus {
|
||||||
text-decoration: none;
|
text-decoration: none;
|
||||||
}
|
}
|
||||||
span.badge {
|
span.badge {
|
||||||
|
@@ -1,6 +1,6 @@
|
|||||||
a {
|
a {
|
||||||
color: var(--bs-body-color);
|
color: var(--bs-body-color);
|
||||||
&:hover, &focus {
|
&:hover, &:focus {
|
||||||
text-decoration: none;
|
text-decoration: none;
|
||||||
}
|
}
|
||||||
span.badge {
|
span.badge {
|
||||||
|
@@ -1,6 +1,6 @@
|
|||||||
a {
|
a {
|
||||||
color: var(--bs-body-color);
|
color: var(--bs-body-color);
|
||||||
&:hover, &focus {
|
&:hover, &:focus {
|
||||||
text-decoration: none;
|
text-decoration: none;
|
||||||
}
|
}
|
||||||
span.badge {
|
span.badge {
|
||||||
|
@@ -227,6 +227,8 @@ import { SearchNavbarComponent } from '../search-navbar/search-navbar.component'
|
|||||||
import { ThemedSearchNavbarComponent } from '../search-navbar/themed-search-navbar.component';
|
import { ThemedSearchNavbarComponent } from '../search-navbar/themed-search-navbar.component';
|
||||||
import { ScopeSelectorModalComponent } from './search-form/scope-selector-modal/scope-selector-modal.component';
|
import { ScopeSelectorModalComponent } from './search-form/scope-selector-modal/scope-selector-modal.component';
|
||||||
import { DsSelectComponent } from './ds-select/ds-select.component';
|
import { DsSelectComponent } from './ds-select/ds-select.component';
|
||||||
|
import { ContextHelpDirective } from './context-help.directive';
|
||||||
|
import { ContextHelpWrapperComponent } from './context-help-wrapper/context-help-wrapper.component';
|
||||||
import { RSSComponent } from './rss-feed/rss.component';
|
import { RSSComponent } from './rss-feed/rss.component';
|
||||||
import { BrowserOnlyPipe } from './utils/browser-only.pipe';
|
import { BrowserOnlyPipe } from './utils/browser-only.pipe';
|
||||||
import { ThemedLoadingComponent } from './loading/themed-loading.component';
|
import { ThemedLoadingComponent } from './loading/themed-loading.component';
|
||||||
@@ -345,6 +347,7 @@ const COMPONENTS = [
|
|||||||
ListableNotificationObjectComponent,
|
ListableNotificationObjectComponent,
|
||||||
DsoPageEditButtonComponent,
|
DsoPageEditButtonComponent,
|
||||||
MetadataFieldWrapperComponent,
|
MetadataFieldWrapperComponent,
|
||||||
|
ContextHelpWrapperComponent,
|
||||||
];
|
];
|
||||||
|
|
||||||
const ENTRY_COMPONENTS = [
|
const ENTRY_COMPONENTS = [
|
||||||
@@ -423,7 +426,8 @@ const DIRECTIVES = [
|
|||||||
ClaimedTaskActionsDirective,
|
ClaimedTaskActionsDirective,
|
||||||
NgForTrackByIdDirective,
|
NgForTrackByIdDirective,
|
||||||
MetadataFieldValidator,
|
MetadataFieldValidator,
|
||||||
HoverClassDirective
|
HoverClassDirective,
|
||||||
|
ContextHelpDirective,
|
||||||
];
|
];
|
||||||
|
|
||||||
@NgModule({
|
@NgModule({
|
||||||
|
@@ -66,6 +66,10 @@ export class MenuServiceStub {
|
|||||||
return observableOf(true);
|
return observableOf(true);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
isMenuVisibleWithVisibleSections(id: MenuID): Observable<boolean> {
|
||||||
|
return observableOf(true);
|
||||||
|
}
|
||||||
|
|
||||||
isMenuCollapsed(id: MenuID): Observable<boolean> {
|
isMenuCollapsed(id: MenuID): Observable<boolean> {
|
||||||
return observableOf(false);
|
return observableOf(false);
|
||||||
}
|
}
|
||||||
|
@@ -3246,9 +3246,9 @@
|
|||||||
// TODO New key - Add a translation
|
// TODO New key - Add a translation
|
||||||
"item.edit.tabs.status.buttons.mappedCollections.label": "Manage mapped collections",
|
"item.edit.tabs.status.buttons.mappedCollections.label": "Manage mapped collections",
|
||||||
|
|
||||||
// "item.edit.tabs.status.buttons.move.button": "Move...",
|
// "item.edit.tabs.status.buttons.move.button": "Move this Item to a different Collection",
|
||||||
// TODO New key - Add a translation
|
// TODO New key - Add a translation
|
||||||
"item.edit.tabs.status.buttons.move.button": "Move...",
|
"item.edit.tabs.status.buttons.move.button": "Mover éste ítem a una colección distinta",
|
||||||
|
|
||||||
// "item.edit.tabs.status.buttons.move.label": "Move item to another collection",
|
// "item.edit.tabs.status.buttons.move.label": "Move item to another collection",
|
||||||
// TODO New key - Add a translation
|
// TODO New key - Add a translation
|
||||||
@@ -3278,9 +3278,9 @@
|
|||||||
// TODO New key - Add a translation
|
// TODO New key - Add a translation
|
||||||
"item.edit.tabs.status.buttons.reinstate.label": "Reinstate item into the repository",
|
"item.edit.tabs.status.buttons.reinstate.label": "Reinstate item into the repository",
|
||||||
|
|
||||||
// "item.edit.tabs.status.buttons.withdraw.button": "Withdraw...",
|
// "item.edit.tabs.status.buttons.withdraw.button": "Withdraw this item",
|
||||||
// TODO New key - Add a translation
|
// TODO New key - Add a translation
|
||||||
"item.edit.tabs.status.buttons.withdraw.button": "Withdraw...",
|
"item.edit.tabs.status.buttons.withdraw.button": "Retirar éste ítem",
|
||||||
|
|
||||||
// "item.edit.tabs.status.buttons.withdraw.label": "Withdraw item from the repository",
|
// "item.edit.tabs.status.buttons.withdraw.label": "Withdraw item from the repository",
|
||||||
// TODO New key - Add a translation
|
// TODO New key - Add a translation
|
||||||
|
@@ -2908,7 +2908,7 @@
|
|||||||
// "item.edit.tabs.status.buttons.mappedCollections.label": "Manage mapped collections",
|
// "item.edit.tabs.status.buttons.mappedCollections.label": "Manage mapped collections",
|
||||||
"item.edit.tabs.status.buttons.mappedCollections.label": "ম্যাপড সংগ্রহ পরিচালনা করুন",
|
"item.edit.tabs.status.buttons.mappedCollections.label": "ম্যাপড সংগ্রহ পরিচালনা করুন",
|
||||||
|
|
||||||
// "item.edit.tabs.status.buttons.move.button": "Move...",
|
// "item.edit.tabs.status.buttons.move.button": "Move this Item to a different Collection",
|
||||||
"item.edit.tabs.status.buttons.move.button": "সরানো ...",
|
"item.edit.tabs.status.buttons.move.button": "সরানো ...",
|
||||||
|
|
||||||
// "item.edit.tabs.status.buttons.move.label": "Move item to another collection",
|
// "item.edit.tabs.status.buttons.move.label": "Move item to another collection",
|
||||||
@@ -2935,8 +2935,8 @@
|
|||||||
// "item.edit.tabs.status.buttons.unauthorized": "You're not authorized to perform this action",
|
// "item.edit.tabs.status.buttons.unauthorized": "You're not authorized to perform this action",
|
||||||
"item.edit.tabs.status.buttons.unauthorized": "আপনি এই অ্যাকশন সঞ্চালন করার জন্য অনুমোদিত না",
|
"item.edit.tabs.status.buttons.unauthorized": "আপনি এই অ্যাকশন সঞ্চালন করার জন্য অনুমোদিত না",
|
||||||
|
|
||||||
// "item.edit.tabs.status.buttons.withdraw.button": "Withdraw...",
|
// "item.edit.tabs.status.buttons.withdraw.button": "Withdraw this item",
|
||||||
"item.edit.tabs.status.buttons.withdraw.button": "প্রত্যাহার ...",
|
"item.edit.tabs.status.buttons.withdraw.button": "এই আইটেম প্রত্যাহার করুন",
|
||||||
|
|
||||||
// "item.edit.tabs.status.buttons.withdraw.label": "Withdraw item from the repository",
|
// "item.edit.tabs.status.buttons.withdraw.label": "Withdraw item from the repository",
|
||||||
"item.edit.tabs.status.buttons.withdraw.label": "সংগ্রহস্থল থেকে আইটেম প্রত্যাহার",
|
"item.edit.tabs.status.buttons.withdraw.label": "সংগ্রহস্থল থেকে আইটেম প্রত্যাহার",
|
||||||
|
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user