mirror of
https://github.com/DSpace/dspace-angular.git
synced 2025-10-07 10:04:11 +00:00
Merge branch 'main' into w2p-99039_XSRFTokenGetRequest
This commit is contained in:
@@ -121,6 +121,9 @@ languages:
|
||||
- code: en
|
||||
label: English
|
||||
active: true
|
||||
- code: ca
|
||||
label: Català
|
||||
active: true
|
||||
- code: cs
|
||||
label: Čeština
|
||||
active: true
|
||||
|
@@ -9,7 +9,18 @@
|
||||
</ng-template>
|
||||
|
||||
<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>
|
||||
|
||||
<ds-alert *ngIf="groupBeingEdited?.permanent" [type]="AlertTypeEnum.Warning"
|
||||
|
@@ -1,9 +1,19 @@
|
||||
<ng-container>
|
||||
<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>
|
||||
|
||||
<form [formGroup]="searchForm" (ngSubmit)="search(searchForm.value)" class="d-flex justify-content-between">
|
||||
<div>
|
||||
<select name="scope" id="scope" formControlName="scope" class="form-control" aria-label="Search scope">
|
||||
|
@@ -1,7 +1,16 @@
|
||||
<ng-container>
|
||||
<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>
|
||||
<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')
|
||||
|
@@ -47,6 +47,7 @@ import { truncatableReducer, TruncatablesState } from './shared/truncatable/trun
|
||||
import { ThemeState, themeReducer } from './shared/theme-support/theme.reducer';
|
||||
import { MenusState } from './shared/menu/menus-state.model';
|
||||
import { correlationIdReducer } from './correlation-id/correlation-id.reducer';
|
||||
import { contextHelpReducer, ContextHelpState } from './shared/context-help.reducer';
|
||||
|
||||
export interface AppState {
|
||||
router: RouterReducerState;
|
||||
@@ -67,6 +68,7 @@ export interface AppState {
|
||||
epeopleRegistry: EPeopleRegistryState;
|
||||
groupRegistry: GroupRegistryState;
|
||||
correlationId: string;
|
||||
contextHelp: ContextHelpState;
|
||||
}
|
||||
|
||||
export const appReducers: ActionReducerMap<AppState> = {
|
||||
@@ -87,7 +89,8 @@ export const appReducers: ActionReducerMap<AppState> = {
|
||||
communityList: CommunityListReducer,
|
||||
epeopleRegistry: ePeopleRegistryReducer,
|
||||
groupRegistry: groupRegistryReducer,
|
||||
correlationId: correlationIdReducer
|
||||
correlationId: correlationIdReducer,
|
||||
contextHelp: contextHelpReducer,
|
||||
};
|
||||
|
||||
export const routerStateSelector = (state: AppState) => state.router;
|
||||
|
@@ -16,6 +16,7 @@ import { StatisticsModule } from '../statistics/statistics.module';
|
||||
import { CollectionFormModule } from './collection-form/collection-form.module';
|
||||
import { ThemedCollectionPageComponent } from './themed-collection-page.component';
|
||||
import { ComcolModule } from '../shared/comcol/comcol.module';
|
||||
import { DsoSharedModule } from '../dso-shared/dso-shared.module';
|
||||
|
||||
@NgModule({
|
||||
imports: [
|
||||
@@ -26,6 +27,7 @@ import { ComcolModule } from '../shared/comcol/comcol.module';
|
||||
EditItemPageModule,
|
||||
CollectionFormModule,
|
||||
ComcolModule,
|
||||
DsoSharedModule,
|
||||
],
|
||||
declarations: [
|
||||
CollectionPageComponent,
|
||||
|
@@ -3,7 +3,7 @@
|
||||
<div class="col-12" *ngVar="(itemRD$ | async) as itemRD">
|
||||
<ng-container *ngIf="itemRD?.hasSucceeded">
|
||||
<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>
|
||||
</ng-container>
|
||||
<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));
|
||||
|
||||
testMove([
|
||||
{ op: 'move', from: '/2', path: '/3' },
|
||||
{ op: 'move', from: '/0', path: '/3' },
|
||||
{ op: 'move', from: '/2', path: '/1' }
|
||||
], new MoveTest(0, 3), new MoveTest(1, 2));
|
||||
|
||||
testMove([
|
||||
{ op: 'move', from: '/3', path: '/4' },
|
||||
{ op: 'move', from: '/0', path: '/1' },
|
||||
{ op: 'move', from: '/3', path: '/4' }
|
||||
], new MoveTest(0, 1), new MoveTest(3, 4));
|
||||
|
||||
testMove([], new MoveTest(0, 4), new MoveTest(4, 0));
|
||||
|
||||
testMove([
|
||||
{ op: 'move', from: '/2', 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));
|
||||
|
||||
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)', () => {
|
||||
|
@@ -16,22 +16,31 @@ export class ArrayMoveChangeAnalyzer<T> {
|
||||
* @param array2 The custom array to compare with the original
|
||||
*/
|
||||
diff(array1: T[], array2: T[]): MoveOperation[] {
|
||||
const result = [];
|
||||
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',
|
||||
from: '/' + movedIndex,
|
||||
path: '/' + otherIndex
|
||||
}) as MoveOperation);
|
||||
}
|
||||
return this.getMoves(array1, array2).map((move) => Object.assign({
|
||||
op: 'move',
|
||||
from: '/' + move[0],
|
||||
path: '/' + move[1],
|
||||
}) 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 result;
|
||||
return moves;
|
||||
}, []);
|
||||
}
|
||||
}
|
||||
|
@@ -29,5 +29,7 @@ export enum FeatureID {
|
||||
CanViewUsageStatistics = 'canViewUsageStatistics',
|
||||
CanSendFeedback = 'canSendFeedback',
|
||||
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 { RequestService } from './request.service';
|
||||
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 { getMockRequestService } from '../../shared/mocks/request.service.mock';
|
||||
import { createPaginatedList } from '../../shared/testing/utils.test';
|
||||
import { RequestEntry } from './request-entry.model';
|
||||
import { FindListOptions } from './find-list-options.model';
|
||||
import { testSearchDataImplementation } from './base/search-data.spec';
|
||||
import { MetadataValue } from '../shared/metadata.models';
|
||||
import { MetadataRepresentationType } from '../shared/metadata-representation/metadata-representation.model';
|
||||
|
||||
describe('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 { Inject, Injectable } from '@angular/core';
|
||||
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 {
|
||||
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 { dataService } from './base/data-service.decorator';
|
||||
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;
|
||||
|
||||
@@ -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>>> {
|
||||
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));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@@ -226,7 +226,7 @@ export const metadataFieldsToString = () =>
|
||||
map((schema: MetadataSchema) => ({ field, schema }))
|
||||
);
|
||||
});
|
||||
return observableCombineLatest(fieldSchemaArray);
|
||||
return isNotEmpty(fieldSchemaArray) ? observableCombineLatest(fieldSchemaArray) : [[]];
|
||||
}),
|
||||
map((fieldSchemaArray: { field: MetadataField, schema: MetadataSchema }[]): string[] => {
|
||||
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 {
|
||||
|
||||
}
|
@@ -64,7 +64,7 @@
|
||||
</p>
|
||||
<ul class="footer-info list-unstyled small d-flex justify-content-center mb-0">
|
||||
<li>
|
||||
<a class="text-white" href="javascript:void(0);"
|
||||
<a class="text-white" href="javascript:void(0);"
|
||||
(click)="showCookieSettings()">{{ 'footer.link.cookies' | translate}}</a>
|
||||
</li>
|
||||
<li *ngIf="showPrivacyPolicy">
|
||||
|
@@ -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">
|
||||
<ds-themed-search-navbar></ds-themed-search-navbar>
|
||||
<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-impersonate-navbar></ds-impersonate-navbar>
|
||||
<div class="pl-2">
|
||||
|
@@ -15,7 +15,7 @@
|
||||
a {
|
||||
color: var(--ds-header-icon-color);
|
||||
|
||||
&:hover, &focus {
|
||||
&:hover, &:focus {
|
||||
color: var(--ds-header-icon-color-hover);
|
||||
}
|
||||
}
|
||||
|
@@ -1,6 +1,8 @@
|
||||
<ng-container *ngVar="(communitiesRD$ | async) as communitiesRD">
|
||||
<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>
|
||||
<ds-viewable-collection
|
||||
[config]="config"
|
||||
|
@@ -14,9 +14,6 @@ import { AbstractSimpleItemActionComponent } from './simple-item-action/abstract
|
||||
import { ItemPrivateComponent } from './item-private/item-private.component';
|
||||
import { ItemPublicComponent } from './item-public/item-public.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 { ItemEditBitstreamComponent } from './item-bitstreams/item-edit-bitstream/item-edit-bitstream.component';
|
||||
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 { ResourcePoliciesModule } from '../../shared/resource-policies/resource-policies.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,
|
||||
NgbModule,
|
||||
ItemVersionsModule,
|
||||
DsoSharedModule,
|
||||
],
|
||||
declarations: [
|
||||
EditItemPageComponent,
|
||||
@@ -65,16 +64,12 @@ import { ItemVersionsModule } from '../versions/item-versions.module';
|
||||
ItemPublicComponent,
|
||||
ItemDeleteComponent,
|
||||
ItemStatusComponent,
|
||||
ItemMetadataComponent,
|
||||
ThemedItemMetadataComponent,
|
||||
ItemRelationshipsComponent,
|
||||
ItemBitstreamsComponent,
|
||||
ItemVersionHistoryComponent,
|
||||
EditInPlaceFieldComponent,
|
||||
ItemEditBitstreamComponent,
|
||||
ItemEditBitstreamBundleComponent,
|
||||
PaginatedDragAndDropBitstreamListComponent,
|
||||
EditInPlaceFieldComponent,
|
||||
EditRelationshipComponent,
|
||||
EditRelationshipListComponent,
|
||||
ItemCollectionMapperComponent,
|
||||
@@ -87,10 +82,6 @@ import { ItemVersionsModule } from '../versions/item-versions.module';
|
||||
BundleDataService,
|
||||
ObjectValuesPipe
|
||||
],
|
||||
exports: [
|
||||
EditInPlaceFieldComponent,
|
||||
ThemedItemMetadataComponent,
|
||||
]
|
||||
})
|
||||
export class EditItemPageModule {
|
||||
|
||||
|
@@ -7,7 +7,6 @@ import { ItemPrivateComponent } from './item-private/item-private.component';
|
||||
import { ItemPublicComponent } from './item-public/item-public.component';
|
||||
import { ItemDeleteComponent } from './item-delete/item-delete.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 { ItemCollectionMapperComponent } from './item-collection-mapper/item-collection-mapper.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 { ItemPageVersionHistoryGuard } from './item-page-version-history.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
|
||||
@@ -75,7 +75,7 @@ import { ItemPageCollectionMapperGuard } from './item-page-collection-mapper.gua
|
||||
},
|
||||
{
|
||||
path: 'metadata',
|
||||
component: ItemMetadataComponent,
|
||||
component: ThemedDsoEditMetadataComponent,
|
||||
data: { title: 'item.edit.tabs.metadata.title', showBreadcrumbs: true },
|
||||
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 class="col-9 float-left action-button">
|
||||
<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}}
|
||||
</button>
|
||||
</span>
|
||||
<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}}
|
||||
</button>
|
||||
</span>
|
||||
|
@@ -4,11 +4,13 @@ import { By } from '@angular/platform-browser';
|
||||
import { MetadataRepresentationListComponent } from './metadata-representation-list.component';
|
||||
import { RelationshipDataService } from '../../../core/data/relationship-data.service';
|
||||
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 { VarDirective } from '../../../shared/utils/var.directive';
|
||||
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 metadataFields = ['dc.contributor.author', 'dc.creator'];
|
||||
@@ -73,39 +75,31 @@ const relatedCreator: Item = Object.assign(new Item(), {
|
||||
'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', () => {
|
||||
let comp: MetadataRepresentationListComponent;
|
||||
let fixture: ComponentFixture<MetadataRepresentationListComponent>;
|
||||
|
||||
relationshipService = {
|
||||
findById: (id: string) => {
|
||||
if (id === 'related-author') {
|
||||
return createSuccessfulRemoteDataObject$(authorRelation);
|
||||
}
|
||||
if (id === 'related-creator') {
|
||||
return createSuccessfulRemoteDataObject$(creatorRelation);
|
||||
}
|
||||
if (id === 'related-creator-unauthorized') {
|
||||
return createSuccessfulRemoteDataObject$(creatorRelationUnauthorized);
|
||||
}
|
||||
},
|
||||
};
|
||||
let relationshipService;
|
||||
|
||||
beforeEach(waitForAsync(() => {
|
||||
relationshipService = {
|
||||
resolveMetadataRepresentation: (metadatum: MetadataValue, parent: DSpaceObject, type: string) => {
|
||||
if (metadatum.value === 'Related Author with authority') {
|
||||
return observableOf(Object.assign(new ItemMetadataRepresentation(metadatum), relatedAuthor));
|
||||
}
|
||||
if (metadatum.value === 'Author without authority') {
|
||||
return observableOf(Object.assign(new MetadatumRepresentation(type), metadatum));
|
||||
}
|
||||
if (metadatum.value === 'Related Creator with authority') {
|
||||
return observableOf(Object.assign(new ItemMetadataRepresentation(metadatum), relatedCreator));
|
||||
}
|
||||
if (metadatum.value === 'Related Creator with authority - unauthorized') {
|
||||
return observableOf(Object.assign(new MetadatumRepresentation(type), metadatum));
|
||||
}
|
||||
},
|
||||
};
|
||||
|
||||
TestBed.configureTestingModule({
|
||||
imports: [TranslateModule.forRoot()],
|
||||
declarations: [MetadataRepresentationListComponent, VarDirective],
|
||||
|
@@ -1,21 +1,12 @@
|
||||
import { Component, Input } from '@angular/core';
|
||||
import { MetadataRepresentation } from '../../../core/shared/metadata-representation/metadata-representation.model';
|
||||
import {
|
||||
combineLatest as observableCombineLatest,
|
||||
Observable,
|
||||
of as observableOf,
|
||||
zip as observableZip
|
||||
} from 'rxjs';
|
||||
import { RelationshipDataService } from '../../../core/data/relationship-data.service';
|
||||
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 { 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';
|
||||
|
||||
@Component({
|
||||
@@ -85,29 +76,7 @@ export class MetadataRepresentationListComponent extends AbstractIncrementalList
|
||||
...metadata
|
||||
.slice((this.objects.length * this.incrementBy), (this.objects.length * this.incrementBy) + this.incrementBy)
|
||||
.map((metadatum: any) => Object.assign(new MetadataValue(), metadatum))
|
||||
.map((metadatum: MetadataValue) => {
|
||||
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));
|
||||
}
|
||||
})
|
||||
.map((metadatum: MetadataValue) => this.relationshipService.resolveMetadataRepresentation(metadatum, this.parentItem, this.itemType)),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
@@ -142,29 +142,7 @@ describe('MenuResolver', () => {
|
||||
});
|
||||
|
||||
describe('createAdminMenu$', () => {
|
||||
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(() => {
|
||||
return observableOf(false);
|
||||
});
|
||||
});
|
||||
|
||||
beforeEach((done) => {
|
||||
resolver.createAdminMenu$().subscribe((_) => {
|
||||
done();
|
||||
});
|
||||
});
|
||||
|
||||
const dontShowAdminSections = () => {
|
||||
it('should not show site admin section', () => {
|
||||
expect(menuService.addSection).toHaveBeenCalledWith(MenuID.ADMIN, jasmine.objectContaining({
|
||||
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', () => {
|
||||
expect(menuService.addSection).toHaveBeenCalledWith(MenuID.ADMIN, jasmine.objectContaining({
|
||||
id: 'access_control', visible: false,
|
||||
@@ -222,6 +187,122 @@ describe('MenuResolver', () => {
|
||||
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', () => {
|
||||
@@ -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', () => {
|
||||
expect(menuService.addSection).toHaveBeenCalledWith(MenuID.ADMIN, jasmine.objectContaining({
|
||||
id: 'admin_search', visible: true,
|
||||
|
@@ -167,21 +167,11 @@ export class MenuResolver implements Resolve<boolean> {
|
||||
combineLatest([
|
||||
this.authorizationService.isAuthorized(FeatureID.IsCollectionAdmin),
|
||||
this.authorizationService.isAuthorized(FeatureID.IsCommunityAdmin),
|
||||
this.authorizationService.isAuthorized(FeatureID.AdministratorOf)
|
||||
]).subscribe(([isCollectionAdmin, isCommunityAdmin, isSiteAdmin]) => {
|
||||
const menuList = [
|
||||
/* News */
|
||||
{
|
||||
id: 'new',
|
||||
active: false,
|
||||
visible: true,
|
||||
model: {
|
||||
type: MenuItemType.TEXT,
|
||||
text: 'menu.section.new'
|
||||
} as TextMenuItemModel,
|
||||
icon: 'plus',
|
||||
index: 0
|
||||
},
|
||||
this.authorizationService.isAuthorized(FeatureID.AdministratorOf),
|
||||
this.authorizationService.isAuthorized(FeatureID.CanSubmit),
|
||||
this.authorizationService.isAuthorized(FeatureID.CanEditItem),
|
||||
]).subscribe(([isCollectionAdmin, isCommunityAdmin, isSiteAdmin, canSubmit, canEditItem]) => {
|
||||
const newSubMenuList = [
|
||||
{
|
||||
id: 'new_community',
|
||||
parentID: 'new',
|
||||
@@ -212,7 +202,7 @@ export class MenuResolver implements Resolve<boolean> {
|
||||
id: 'new_item',
|
||||
parentID: 'new',
|
||||
active: false,
|
||||
visible: true,
|
||||
visible: canSubmit,
|
||||
model: {
|
||||
type: MenuItemType.ONCLICK,
|
||||
text: 'menu.section.new_item',
|
||||
@@ -225,38 +215,16 @@ export class MenuResolver implements Resolve<boolean> {
|
||||
id: 'new_process',
|
||||
parentID: 'new',
|
||||
active: false,
|
||||
visible: isCollectionAdmin,
|
||||
visible: isSiteAdmin,
|
||||
model: {
|
||||
type: MenuItemType.LINK,
|
||||
text: 'menu.section.new_process',
|
||||
link: '/processes/new'
|
||||
} as LinkMenuItemModel,
|
||||
},
|
||||
// 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,
|
||||
// },
|
||||
|
||||
];
|
||||
const editSubMenuList = [
|
||||
/* 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',
|
||||
parentID: 'edit',
|
||||
@@ -287,7 +255,7 @@ export class MenuResolver implements Resolve<boolean> {
|
||||
id: 'edit_item',
|
||||
parentID: 'edit',
|
||||
active: false,
|
||||
visible: true,
|
||||
visible: canEditItem,
|
||||
model: {
|
||||
type: MenuItemType.ONCLICK,
|
||||
text: 'menu.section.edit_item',
|
||||
@@ -296,6 +264,47 @@ export class MenuResolver implements Resolve<boolean> {
|
||||
}
|
||||
} 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 */
|
||||
// TODO: enable this menu item once the feature has been implemented
|
||||
|
@@ -1,6 +1,8 @@
|
||||
<div class="page-internal-server-error container">
|
||||
<h1>500</h1>
|
||||
<h2><small>{{"500.page-internal-server-error" | translate}}</small></h2>
|
||||
<h2><small>
|
||||
{{"500.page-internal-server-error" | translate}}
|
||||
</small></h2>
|
||||
<br/>
|
||||
<p>{{"500.help" | translate}}</p>
|
||||
<br/>
|
||||
|
@@ -42,6 +42,7 @@ import {
|
||||
} from './page-internal-server-error/page-internal-server-error.component';
|
||||
import { ThemedPageErrorComponent } from './page-error/themed-page-error.component';
|
||||
import { PageErrorComponent } from './page-error/page-error.component';
|
||||
import { ContextHelpToggleComponent } from './header/context-help-toggle/context-help-toggle.component';
|
||||
|
||||
const IMPORTS = [
|
||||
CommonModule,
|
||||
@@ -78,7 +79,8 @@ const DECLARATIONS = [
|
||||
ThemedPageInternalServerErrorComponent,
|
||||
PageInternalServerErrorComponent,
|
||||
ThemedPageErrorComponent,
|
||||
PageErrorComponent
|
||||
PageErrorComponent,
|
||||
ContextHelpToggleComponent,
|
||||
];
|
||||
|
||||
const EXPORTS = [
|
||||
|
@@ -61,7 +61,7 @@ export class RootComponent implements OnInit {
|
||||
}
|
||||
|
||||
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.totalSidebarWidth = this.cssService.getVariable('--ds-total-sidebar-width');
|
||||
|
@@ -1,9 +1,6 @@
|
||||
input[type="text"] {
|
||||
margin-top: calc(-0.5 * var(--bs-font-size-base));
|
||||
|
||||
&:focus {
|
||||
background-color: rgba(255, 255, 255, 0.5) !important;
|
||||
}
|
||||
background-color: #fff !important;
|
||||
|
||||
&.collapsed {
|
||||
opacity: 0;
|
||||
@@ -14,6 +11,11 @@ a.submit-icon {
|
||||
cursor: pointer;
|
||||
position: sticky;
|
||||
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)) {
|
||||
@@ -22,8 +24,5 @@ a.submit-icon {
|
||||
width: 40vw !important;
|
||||
}
|
||||
|
||||
a.submit-icon {
|
||||
color: var(--bs-link-color);
|
||||
}
|
||||
}
|
||||
|
||||
|
@@ -13,9 +13,9 @@
|
||||
}
|
||||
|
||||
.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);
|
||||
}
|
||||
}
|
||||
|
@@ -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;
|
||||
}
|
||||
|
||||
@media screen and (min-width: map-get($grid-breakpoints, md)) {
|
||||
.dropdown-toggle {
|
||||
color: var(--ds-header-icon-color) !important;
|
||||
.dropdown-toggle {
|
||||
color: var(--ds-header-icon-color);
|
||||
|
||||
&:hover, &focus {
|
||||
color: var(--ds-header-icon-color-hover);
|
||||
}
|
||||
&:hover, &:focus {
|
||||
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', () => {
|
||||
beforeEach(() => {
|
||||
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
|
||||
* @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
|
||||
* @param {MenuID} menuID The ID of the menu
|
||||
|
@@ -1,7 +1,7 @@
|
||||
.filters {
|
||||
a {
|
||||
color: var(--bs-body-color);
|
||||
&:hover, &focus {
|
||||
&:hover, &:focus {
|
||||
text-decoration: none;
|
||||
}
|
||||
span.badge {
|
||||
|
@@ -1,6 +1,6 @@
|
||||
a {
|
||||
color: var(--bs-body-color);
|
||||
&:hover, &focus {
|
||||
&:hover, &:focus {
|
||||
text-decoration: none;
|
||||
}
|
||||
span.badge {
|
||||
|
@@ -1,6 +1,6 @@
|
||||
a {
|
||||
color: var(--bs-body-color);
|
||||
&:hover, &focus {
|
||||
&:hover, &:focus {
|
||||
text-decoration: none;
|
||||
}
|
||||
span.badge {
|
||||
|
@@ -227,6 +227,8 @@ import { SearchNavbarComponent } from '../search-navbar/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 { 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 { BrowserOnlyPipe } from './utils/browser-only.pipe';
|
||||
import { ThemedLoadingComponent } from './loading/themed-loading.component';
|
||||
@@ -345,6 +347,7 @@ const COMPONENTS = [
|
||||
ListableNotificationObjectComponent,
|
||||
DsoPageEditButtonComponent,
|
||||
MetadataFieldWrapperComponent,
|
||||
ContextHelpWrapperComponent,
|
||||
];
|
||||
|
||||
const ENTRY_COMPONENTS = [
|
||||
@@ -423,7 +426,8 @@ const DIRECTIVES = [
|
||||
ClaimedTaskActionsDirective,
|
||||
NgForTrackByIdDirective,
|
||||
MetadataFieldValidator,
|
||||
HoverClassDirective
|
||||
HoverClassDirective,
|
||||
ContextHelpDirective,
|
||||
];
|
||||
|
||||
@NgModule({
|
||||
|
@@ -66,6 +66,10 @@ export class MenuServiceStub {
|
||||
return observableOf(true);
|
||||
}
|
||||
|
||||
isMenuVisibleWithVisibleSections(id: MenuID): Observable<boolean> {
|
||||
return observableOf(true);
|
||||
}
|
||||
|
||||
isMenuCollapsed(id: MenuID): Observable<boolean> {
|
||||
return observableOf(false);
|
||||
}
|
||||
|
@@ -3246,9 +3246,9 @@
|
||||
// TODO New key - Add a translation
|
||||
"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
|
||||
"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",
|
||||
// TODO New key - Add a translation
|
||||
@@ -3278,9 +3278,9 @@
|
||||
// TODO New key - Add a translation
|
||||
"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
|
||||
"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",
|
||||
// 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": "ম্যাপড সংগ্রহ পরিচালনা করুন",
|
||||
|
||||
// "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.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": "আপনি এই অ্যাকশন সঞ্চালন করার জন্য অনুমোদিত না",
|
||||
|
||||
// "item.edit.tabs.status.buttons.withdraw.button": "Withdraw...",
|
||||
"item.edit.tabs.status.buttons.withdraw.button": "প্রত্যাহার ...",
|
||||
// "item.edit.tabs.status.buttons.withdraw.button": "Withdraw this item",
|
||||
"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": "সংগ্রহস্থল থেকে আইটেম প্রত্যাহার",
|
||||
|
7155
src/assets/i18n/ca.json5
Normal file
7155
src/assets/i18n/ca.json5
Normal file
File diff suppressed because it is too large
Load Diff
@@ -3186,9 +3186,9 @@
|
||||
// TODO New key - Add a translation
|
||||
"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
|
||||
"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.label": "Move item to another collection",
|
||||
// TODO New key - Add a translation
|
||||
@@ -3218,9 +3218,9 @@
|
||||
// TODO New key - Add a translation
|
||||
"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
|
||||
"item.edit.tabs.status.buttons.withdraw.button": "Withdraw...",
|
||||
"item.edit.tabs.status.buttons.withdraw.button": "Withdraw this item",
|
||||
|
||||
// "item.edit.tabs.status.buttons.withdraw.label": "Withdraw item from the repository",
|
||||
// TODO New key - Add a translation
|
||||
|
@@ -2698,8 +2698,8 @@
|
||||
// "item.edit.tabs.status.buttons.mappedCollections.label": "Manage mapped collections",
|
||||
"item.edit.tabs.status.buttons.mappedCollections.label": "Gespiegelte Sammlungen bearbeiten",
|
||||
|
||||
// "item.edit.tabs.status.buttons.move.button": "Move...",
|
||||
"item.edit.tabs.status.buttons.move.button": "Verschieben...",
|
||||
// "item.edit.tabs.status.buttons.move.button": "Move this Item to a different Collection",
|
||||
"item.edit.tabs.status.buttons.move.button": "Verschieben Sie diesen Artikel in eine andere Sammlung",
|
||||
|
||||
// "item.edit.tabs.status.buttons.move.label": "Move item to another collection",
|
||||
"item.edit.tabs.status.buttons.move.label": "Item in eine andere Sammlung verschieben",
|
||||
@@ -2722,8 +2722,8 @@
|
||||
// "item.edit.tabs.status.buttons.reinstate.label": "Reinstate item into the repository",
|
||||
"item.edit.tabs.status.buttons.reinstate.label": "Item im Repositorium wiederherstellen",
|
||||
|
||||
// "item.edit.tabs.status.buttons.withdraw.button": "Withdraw...",
|
||||
"item.edit.tabs.status.buttons.withdraw.button": "Zurückziehen...",
|
||||
// "item.edit.tabs.status.buttons.withdraw.button": "Withdraw this item",
|
||||
"item.edit.tabs.status.buttons.withdraw.button": "Ziehen Sie diesen Artikel zurück",
|
||||
|
||||
// "item.edit.tabs.status.buttons.withdraw.label": "Withdraw item from the repository",
|
||||
"item.edit.tabs.status.buttons.withdraw.label": "Item aus dem Repositorium zurückziehen",
|
||||
|
@@ -501,7 +501,11 @@
|
||||
|
||||
"admin.access-control.groups.form.return": "Back",
|
||||
|
||||
"admin.access-control.groups.form.tooltip.editGroupPage": "On this page, you can modify the properties and members of a group. In the top section, you can edit the group name and description, unless this is an admin group for a collection or community, in which case the group name and description are auto-generated and cannot be edited. In the following sections, you can edit group membership. See [the wiki](https://wiki.lyrasis.org/display/DSDOC7x/Create+or+manage+a+user+group) for more details.",
|
||||
|
||||
"admin.access-control.groups.form.tooltip.editGroup.addEpeople": "To add or remove an EPerson to/from this group, either click the 'Browse All' button or use the search bar below to search for users (use the dropdown to the left of the search bar to choose whether to search by metadata or by email). Then click the plus icon for each user you wish to add in the list below, or the trash can icon for each user you wish to remove. The list below may have several pages: use the page controls below the list to navigate to the next pages. Once you are ready, save your changes by clicking the 'Save' button in the top section.",
|
||||
|
||||
"admin.access-control.groups.form.tooltip.editGroup.addSubgroups": "To add or remove a Subgroup to/from this group, either click the 'Browse All' button or use the search bar below to search for users. Then click the plus icon for each user you wish to add in the list below, or the trash can icon for each user you wish to remove. The list below may have several pages: use the page controls below the list to navigate to the next pages. Once you are ready, save your changes by clicking the 'Save' button in the top section.",
|
||||
|
||||
"admin.search.breadcrumbs": "Administrative Search",
|
||||
|
||||
@@ -1956,6 +1960,10 @@
|
||||
|
||||
"item.edit.metadata.discard-button": "Discard",
|
||||
|
||||
"item.edit.metadata.edit.buttons.confirm": "Confirm",
|
||||
|
||||
"item.edit.metadata.edit.buttons.drag": "Drag to reorder",
|
||||
|
||||
"item.edit.metadata.edit.buttons.edit": "Edit",
|
||||
|
||||
"item.edit.metadata.edit.buttons.remove": "Remove",
|
||||
@@ -1964,6 +1972,8 @@
|
||||
|
||||
"item.edit.metadata.edit.buttons.unedit": "Stop editing",
|
||||
|
||||
"item.edit.metadata.edit.buttons.virtual": "This is a virtual metadata value, i.e. a value inherited from a related entity. It can’t be modified directly. Add or remove the corresponding relationship in the \"Relationships\" tab",
|
||||
|
||||
"item.edit.metadata.empty": "The item currently doesn't contain any metadata. Click Add to start adding a metadata value.",
|
||||
|
||||
"item.edit.metadata.headers.edit": "Edit",
|
||||
@@ -1974,6 +1984,8 @@
|
||||
|
||||
"item.edit.metadata.headers.value": "Value",
|
||||
|
||||
"item.edit.metadata.metadatafield.error": "An error occurred validating the metadata field",
|
||||
|
||||
"item.edit.metadata.metadatafield.invalid": "Please choose a valid metadata field",
|
||||
|
||||
"item.edit.metadata.notifications.discarded.content": "Your changes were discarded. To reinstate your changes click the 'Undo' button",
|
||||
@@ -1996,6 +2008,8 @@
|
||||
|
||||
"item.edit.metadata.reinstate-button": "Undo",
|
||||
|
||||
"item.edit.metadata.reset-order-button": "Undo reorder",
|
||||
|
||||
"item.edit.metadata.save-button": "Save",
|
||||
|
||||
|
||||
@@ -2140,7 +2154,7 @@
|
||||
|
||||
"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",
|
||||
|
||||
"item.edit.tabs.status.buttons.move.label": "Move item to another collection",
|
||||
|
||||
@@ -2158,7 +2172,7 @@
|
||||
|
||||
"item.edit.tabs.status.buttons.unauthorized": "You're not authorized to perform this action",
|
||||
|
||||
"item.edit.tabs.status.buttons.withdraw.button": "Withdraw...",
|
||||
"item.edit.tabs.status.buttons.withdraw.button": "Withdraw this item",
|
||||
|
||||
"item.edit.tabs.status.buttons.withdraw.label": "Withdraw item from the repository",
|
||||
|
||||
@@ -2460,6 +2474,62 @@
|
||||
|
||||
|
||||
|
||||
"itemtemplate.edit.metadata.add-button": "Add",
|
||||
|
||||
"itemtemplate.edit.metadata.discard-button": "Discard",
|
||||
|
||||
"itemtemplate.edit.metadata.edit.buttons.confirm": "Confirm",
|
||||
|
||||
"itemtemplate.edit.metadata.edit.buttons.drag": "Drag to reorder",
|
||||
|
||||
"itemtemplate.edit.metadata.edit.buttons.edit": "Edit",
|
||||
|
||||
"itemtemplate.edit.metadata.edit.buttons.remove": "Remove",
|
||||
|
||||
"itemtemplate.edit.metadata.edit.buttons.undo": "Undo changes",
|
||||
|
||||
"itemtemplate.edit.metadata.edit.buttons.unedit": "Stop editing",
|
||||
|
||||
"itemtemplate.edit.metadata.empty": "The item template currently doesn't contain any metadata. Click Add to start adding a metadata value.",
|
||||
|
||||
"itemtemplate.edit.metadata.headers.edit": "Edit",
|
||||
|
||||
"itemtemplate.edit.metadata.headers.field": "Field",
|
||||
|
||||
"itemtemplate.edit.metadata.headers.language": "Lang",
|
||||
|
||||
"itemtemplate.edit.metadata.headers.value": "Value",
|
||||
|
||||
"itemtemplate.edit.metadata.metadatafield.error": "An error occurred validating the metadata field",
|
||||
|
||||
"itemtemplate.edit.metadata.metadatafield.invalid": "Please choose a valid metadata field",
|
||||
|
||||
"itemtemplate.edit.metadata.notifications.discarded.content": "Your changes were discarded. To reinstate your changes click the 'Undo' button",
|
||||
|
||||
"itemtemplate.edit.metadata.notifications.discarded.title": "Changed discarded",
|
||||
|
||||
"itemtemplate.edit.metadata.notifications.error.title": "An error occurred",
|
||||
|
||||
"itemtemplate.edit.metadata.notifications.invalid.content": "Your changes were not saved. Please make sure all fields are valid before you save.",
|
||||
|
||||
"itemtemplate.edit.metadata.notifications.invalid.title": "Metadata invalid",
|
||||
|
||||
"itemtemplate.edit.metadata.notifications.outdated.content": "The item template you're currently working on has been changed by another user. Your current changes are discarded to prevent conflicts",
|
||||
|
||||
"itemtemplate.edit.metadata.notifications.outdated.title": "Changed outdated",
|
||||
|
||||
"itemtemplate.edit.metadata.notifications.saved.content": "Your changes to this item template's metadata were saved.",
|
||||
|
||||
"itemtemplate.edit.metadata.notifications.saved.title": "Metadata saved",
|
||||
|
||||
"itemtemplate.edit.metadata.reinstate-button": "Undo",
|
||||
|
||||
"itemtemplate.edit.metadata.reset-order-button": "Undo reorder",
|
||||
|
||||
"itemtemplate.edit.metadata.save-button": "Save",
|
||||
|
||||
|
||||
|
||||
"journal.listelement.badge": "Journal",
|
||||
|
||||
"journal.page.description": "Description",
|
||||
@@ -2882,6 +2952,8 @@
|
||||
|
||||
"nav.community-browse.header": "By Community",
|
||||
|
||||
"nav.context-help-toggle": "Toggle context help",
|
||||
|
||||
"nav.language": "Language switch",
|
||||
|
||||
"nav.login": "Log In",
|
||||
|
File diff suppressed because it is too large
Load Diff
@@ -2470,8 +2470,8 @@
|
||||
// "item.edit.tabs.status.buttons.mappedCollections.label": "Manage mapped collections",
|
||||
"item.edit.tabs.status.buttons.mappedCollections.label": "Hallinnoi liitettyjä kokoelmia",
|
||||
|
||||
// "item.edit.tabs.status.buttons.move.button": "Move...",
|
||||
"item.edit.tabs.status.buttons.move.button": "Siirrä...",
|
||||
// "item.edit.tabs.status.buttons.move.button": "Move this Item to a different Collection",
|
||||
"item.edit.tabs.status.buttons.move.button": "Siirrä tämä kohde toiseen kokoelmaan",
|
||||
|
||||
// "item.edit.tabs.status.buttons.move.label": "Move item to another collection",
|
||||
"item.edit.tabs.status.buttons.move.label": "Siirrä tietue toiseen kokoelmaan",
|
||||
@@ -2494,8 +2494,8 @@
|
||||
// "item.edit.tabs.status.buttons.reinstate.label": "Reinstate item into the repository",
|
||||
"item.edit.tabs.status.buttons.reinstate.label": "Palauta tietue arkistoon",
|
||||
|
||||
// "item.edit.tabs.status.buttons.withdraw.button": "Withdraw...",
|
||||
"item.edit.tabs.status.buttons.withdraw.button": "Poista käytöstä...",
|
||||
// "item.edit.tabs.status.buttons.withdraw.button": "Withdraw this item",
|
||||
"item.edit.tabs.status.buttons.withdraw.button": "poista tämä kohde",
|
||||
|
||||
// "item.edit.tabs.status.buttons.withdraw.label": "Withdraw item from the repository",
|
||||
"item.edit.tabs.status.buttons.withdraw.label": "Poista tietue käytöstä",
|
||||
|
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user