[TLC-380] Support browse links and regex links in metadata display

(resolved conflicts jan 2023)
This commit is contained in:
Kim Shepherd
2022-11-08 16:43:24 +13:00
parent 430b43581a
commit 928157f994
27 changed files with 491 additions and 26 deletions

View File

@@ -0,0 +1,28 @@
import { followLink } from '../../shared/utils/follow-link-config.model';
import { EMPTY } from 'rxjs';
import { FindListOptions } from '../data/find-list-options.model';
import { BrowseLinkDataService } from './browse-link-data.service';
describe(`BrowseLinkDataService`, () => {
let service: BrowseLinkDataService;
const findAllDataSpy = jasmine.createSpyObj('findAllData', {
findAll: EMPTY,
});
const options = new FindListOptions();
const linksToFollow = [
followLink('entries'),
followLink('items')
];
beforeEach(() => {
service = new BrowseLinkDataService(null, null, null, null, null);
(service as any).findAllData = findAllDataSpy;
});
describe(`getBrowseLinkFor`, () => {
it(`should call findAll on findAllData`, () => {
service.getBrowseLinkFor(['dc.test']);
expect(findAllDataSpy.findAll).toHaveBeenCalledWith({ elementsPerPage: 9999 });
});
});
});

View File

@@ -0,0 +1,81 @@
import { Injectable } from '@angular/core';
import { BrowseDefinition } from '../shared/browse-definition.model';
import { RequestService } from '../data/request.service';
import { RemoteDataBuildService } from '../cache/builders/remote-data-build.service';
import { ObjectCacheService } from '../cache/object-cache.service';
import { HALEndpointService } from '../shared/hal-endpoint.service';
import { Observable } from 'rxjs';
import { RemoteData } from '../data/remote-data';
import { PaginatedList } from '../data/paginated-list.model';
import { IdentifiableDataService } from '../data/base/identifiable-data.service';
import { FindAllDataImpl } from '../data/base/find-all-data';
import { BrowseDefinitionDataService } from './browse-definition-data.service';
import {
getFirstSucceededRemoteData, getPaginatedListPayload, getRemoteDataPayload
} from '../shared/operators';
import { distinctUntilChanged, map, startWith } from 'rxjs/operators';
import { isEmpty, isNotEmpty } from '../../shared/empty.util';
import { BrowseService } from './browse.service';
/**
* Data service responsible for retrieving browse definitions from the REST server, IF AND ONLY IF
* they are configured as browse links (webui.browse.link.<n>)
*/
@Injectable()
export class BrowseLinkDataService extends IdentifiableDataService<BrowseDefinition> {
private findAllData: FindAllDataImpl<BrowseDefinition>;
constructor(
protected requestService: RequestService,
protected rdbService: RemoteDataBuildService,
protected objectCache: ObjectCacheService,
protected halService: HALEndpointService,
protected browseDefinitionDataService: BrowseDefinitionDataService
) {
super('browselinks', requestService, rdbService, objectCache, halService);
this.findAllData = new FindAllDataImpl(this.linkPath, requestService, rdbService, objectCache, halService, this.responseMsToLive);
}
/**
* Get all BrowseDefinitions
*/
getBrowseLinks(): Observable<RemoteData<PaginatedList<BrowseDefinition>>> {
return this.findAllData.findAll({ elementsPerPage: 9999 }).pipe(
getFirstSucceededRemoteData(),
);
}
/**
* Get the browse URL by providing a list of metadata keys
* @param metadatumKey
* @param linkPath
*/
getBrowseLinkFor(metadataKeys: string[]): Observable<BrowseDefinition> {
let searchKeyArray: string[] = [];
metadataKeys.forEach((metadataKey) => {
searchKeyArray = searchKeyArray.concat(BrowseService.toSearchKeyArray(metadataKey));
})
return this.getBrowseLinks().pipe(
getRemoteDataPayload(),
getPaginatedListPayload(),
map((browseDefinitions: BrowseDefinition[]) => browseDefinitions
.find((def: BrowseDefinition) => {
const matchingKeys = def.metadataKeys.find((key: string) => searchKeyArray.indexOf(key) >= 0);
return isNotEmpty(matchingKeys);
})
),
map((def: BrowseDefinition) => {
if (isEmpty(def) || isEmpty(def.id)) {
//throw new Error(`A browse definition for field ${metadataKey} isn't configured`);
} else {
return def;
}
}),
startWith(undefined),
distinctUntilChanged()
);
}
}

View File

@@ -35,7 +35,7 @@ export const BROWSE_LINKS_TO_FOLLOW: FollowLinkConfig<BrowseEntry | Item>[] = [
export class BrowseService { export class BrowseService {
protected linkPath = 'browses'; protected linkPath = 'browses';
private static toSearchKeyArray(metadataKey: string): string[] { public static toSearchKeyArray(metadataKey: string): string[] {
const keyParts = metadataKey.split('.'); const keyParts = metadataKey.split('.');
const searchFor = []; const searchFor = [];
searchFor.push('*'); searchFor.push('*');
@@ -229,6 +229,7 @@ export class BrowseService {
* @param linkPath * @param linkPath
*/ */
getBrowseURLFor(metadataKey: string, linkPath: string): Observable<string> { getBrowseURLFor(metadataKey: string, linkPath: string): Observable<string> {
console.log("Looking for " + metadataKey + " in link path " + linkPath);
const searchKeyArray = BrowseService.toSearchKeyArray(metadataKey); const searchKeyArray = BrowseService.toSearchKeyArray(metadataKey);
return this.getBrowseDefinitions().pipe( return this.getBrowseDefinitions().pipe(
getRemoteDataPayload(), getRemoteDataPayload(),
@@ -251,4 +252,37 @@ export class BrowseService {
); );
} }
/**
* Get the browse URL by providing a metadatum key and linkPath
* @param metadatumKey
* @param linkPath
*/
getBrowseDefinitionFor(metadataKeys: string[]): Observable<BrowseDefinition> {
let searchKeyArray: string[] = [];
metadataKeys.forEach((metadataKey) => {
searchKeyArray = searchKeyArray.concat(BrowseService.toSearchKeyArray(metadataKey));
})
return this.getBrowseDefinitions().pipe(
getRemoteDataPayload(),
getPaginatedListPayload(),
map((browseDefinitions: BrowseDefinition[]) => browseDefinitions
.find((def: BrowseDefinition) => {
//console.dir(def.metadataKeys);
const matchingKeys = def.metadataKeys.find((key: string) => searchKeyArray.indexOf(key) >= 0);
//console.dir(matchingKeys);
return isNotEmpty(matchingKeys);
})
),
map((def: BrowseDefinition) => {
if (isEmpty(def) || isEmpty(def.id)) {
//throw new Error(`A browse definition for field ${metadataKey} isn't configured`);
} else {
return def;
}
}),
startWith(undefined),
distinctUntilChanged()
);
}
} }

View File

@@ -24,6 +24,7 @@ import { SidebarService } from '../shared/sidebar/sidebar.service';
import { AuthenticatedGuard } from './auth/authenticated.guard'; import { AuthenticatedGuard } from './auth/authenticated.guard';
import { AuthStatus } from './auth/models/auth-status.model'; import { AuthStatus } from './auth/models/auth-status.model';
import { BrowseService } from './browse/browse.service'; import { BrowseService } from './browse/browse.service';
import { BrowseLinkDataService } from './browse/browse-link-data.service';
import { RemoteDataBuildService } from './cache/builders/remote-data-build.service'; import { RemoteDataBuildService } from './cache/builders/remote-data-build.service';
import { ObjectCacheService } from './cache/object-cache.service'; import { ObjectCacheService } from './cache/object-cache.service';
import { SubmissionDefinitionsModel } from './config/models/config-submission-definitions.model'; import { SubmissionDefinitionsModel } from './config/models/config-submission-definitions.model';
@@ -221,6 +222,7 @@ const PROVIDERS = [
MyDSpaceResponseParsingService, MyDSpaceResponseParsingService,
ServerResponseService, ServerResponseService,
BrowseService, BrowseService,
BrowseLinkDataService,
AccessStatusDataService, AccessStatusDataService,
SubmissionCcLicenseDataService, SubmissionCcLicenseDataService,
SubmissionCcLicenseUrlDataService, SubmissionCcLicenseUrlDataService,

View File

@@ -1,11 +1,14 @@
/** /**
* An Enum defining the representation type of metadata * An Enum defining the representation type of metadata
*/ */
import { BrowseDefinition } from '../browse-definition.model';
export enum MetadataRepresentationType { export enum MetadataRepresentationType {
None = 'none', None = 'none',
Item = 'item', Item = 'item',
AuthorityControlled = 'authority_controlled', AuthorityControlled = 'authority_controlled',
PlainText = 'plain_text' PlainText = 'plain_text',
BrowseLink = 'browse_link'
} }
/** /**
@@ -24,8 +27,14 @@ export interface MetadataRepresentation {
*/ */
representationType: MetadataRepresentationType; representationType: MetadataRepresentationType;
/**
* The browse definition (optional)
*/
browseDefinition?: BrowseDefinition;
/** /**
* Fetches the value to be displayed * Fetches the value to be displayed
*/ */
getValue(): string; getValue(): string;
} }

View File

@@ -1,6 +1,7 @@
import { MetadataRepresentation, MetadataRepresentationType } from '../metadata-representation.model'; import { MetadataRepresentation, MetadataRepresentationType } from '../metadata-representation.model';
import { hasValue } from '../../../../shared/empty.util'; import { hasValue } from '../../../../shared/empty.util';
import { MetadataValue } from '../../metadata.models'; import { MetadataValue } from '../../metadata.models';
import { BrowseDefinition } from '../../browse-definition.model';
/** /**
* This class defines the way the metadatum it extends should be represented * This class defines the way the metadatum it extends should be represented
@@ -12,9 +13,15 @@ export class MetadatumRepresentation extends MetadataValue implements MetadataRe
*/ */
itemType: string; itemType: string;
constructor(itemType: string) { /**
* The browse definition ID passed in with the metadatum, if any
*/
browseDefinition?: BrowseDefinition;
constructor(itemType: string, browseDefinition?: BrowseDefinition) {
super(); super();
this.itemType = itemType; this.itemType = itemType;
this.browseDefinition = browseDefinition;
} }
/** /**
@@ -23,6 +30,8 @@ export class MetadatumRepresentation extends MetadataValue implements MetadataRe
get representationType(): MetadataRepresentationType { get representationType(): MetadataRepresentationType {
if (hasValue(this.authority)) { if (hasValue(this.authority)) {
return MetadataRepresentationType.AuthorityControlled; return MetadataRepresentationType.AuthorityControlled;
} else if (hasValue(this.browseDefinition)) {
return MetadataRepresentationType.BrowseLink;
} else { } else {
return MetadataRepresentationType.PlainText; return MetadataRepresentationType.PlainText;
} }

View File

@@ -1,16 +1,37 @@
<ds-metadata-field-wrapper [label]="label | translate"> <ds-metadata-field-wrapper [label]="label | translate">
<ng-container *ngFor="let mdValue of mdValues; let last=last;"> <ng-container *ngFor="let mdValue of mdValues; let last=last;">
<ng-container *ngTemplateOutlet="(renderMarkdown ? markdown : simple); context: {value: mdValue.value}"> <!--
Choose a template. Priority: markdown, link, browse link.
-->
<ng-container *ngTemplateOutlet="(renderMarkdown ? markdown : (hasLink(mdValue) ? link : (hasBrowseDefinition() ? browselink : simple)));
context: {value: mdValue.value}">
</ng-container> </ng-container>
<span class="separator" *ngIf="!last" [innerHTML]="separator"></span> <span class="separator" *ngIf="!last" [innerHTML]="separator"></span>
</ng-container> </ng-container>
</ds-metadata-field-wrapper> </ds-metadata-field-wrapper>
<!-- Render value as markdown -->
<ng-template #markdown let-value="value"> <ng-template #markdown let-value="value">
<span class="dont-break-out" [innerHTML]="value | dsMarkdown | async"> <span class="dont-break-out" [innerHTML]="value | dsMarkdown | async">
</span> </span>
</ng-template> </ng-template>
<!-- Render value as a link (href and label) -->
<ng-template #link let-value="value">
<a class="dont-break-out ds-simple-metadata-link" target="_blank" [href]="value">
{{value}}
</a>
</ng-template>
<!-- Render simple value in a span -->
<ng-template #simple let-value="value"> <ng-template #simple let-value="value">
<span class="dont-break-out preserve-line-breaks">{{value}}</span> <span class="dont-break-out preserve-line-breaks">{{value}}</span>
</ng-template> </ng-template>
<!-- Render value as a link to browse index -->
<ng-template #browselink let-value="value">
<a class="dont-break-out preserve-line-breaks ds-browse-link"
href="/browse/{{browseDefinition.id}}?{{(browseDefinition.metadataBrowse?'value':'startsWith')}}={{value}}&bbm.page=1">
{{value}}
</a>
</ng-template>

View File

@@ -1,6 +1,8 @@
import { Component, Inject, Input, OnChanges, SimpleChanges } from '@angular/core'; import { Component, Inject, Input, OnChanges, SimpleChanges } from '@angular/core';
import { MetadataValue } from '../../../core/shared/metadata.models'; import { MetadataValue } from '../../../core/shared/metadata.models';
import { APP_CONFIG, AppConfig } from '../../../../config/app-config.interface'; import { APP_CONFIG, AppConfig } from '../../../../config/app-config.interface';
import { BrowseDefinition } from '../../../core/shared/browse-definition.model';
import { hasValue } from '../../../shared/empty.util';
/** /**
* This component renders the configured 'values' into the ds-metadata-field-wrapper component. * This component renders the configured 'values' into the ds-metadata-field-wrapper component.
@@ -40,12 +42,38 @@ export class MetadataValuesComponent implements OnChanges {
*/ */
@Input() enableMarkdown = false; @Input() enableMarkdown = false;
/**
* Whether any valid HTTP(S) URL should be rendered as a link
*/
@Input() urlRegex?;
/** /**
* This variable will be true if both {@link environment.markdown.enabled} and {@link enableMarkdown} are true. * This variable will be true if both {@link environment.markdown.enabled} and {@link enableMarkdown} are true.
*/ */
renderMarkdown; renderMarkdown;
@Input() browseDefinition?: BrowseDefinition;
ngOnChanges(changes: SimpleChanges): void { ngOnChanges(changes: SimpleChanges): void {
this.renderMarkdown = !!this.appConfig.markdown.enabled && this.enableMarkdown; this.renderMarkdown = !!this.appConfig.markdown.enabled && this.enableMarkdown;
} }
/**
* Does this metadata value have a configured link to a browse definition?
*/
hasBrowseDefinition(): boolean {
return hasValue(this.browseDefinition);
}
/**
* Does this metadata value have a valid URL that should be rendered as a link?
* @param value
*/
hasLink(value): boolean {
if (hasValue(this.urlRegex)) {
const pattern: RegExp = new RegExp(this.urlRegex);
return pattern.test(value.value);
}
return false;
}
} }

View File

@@ -3,7 +3,7 @@ import { ChangeDetectionStrategy, NO_ERRORS_SCHEMA } from '@angular/core';
import { ComponentFixture, TestBed, waitForAsync } from '@angular/core/testing'; import { ComponentFixture, TestBed, waitForAsync } from '@angular/core/testing';
import { TranslateLoaderMock } from '../../../../../shared/testing/translate-loader.mock'; import { TranslateLoaderMock } from '../../../../../shared/testing/translate-loader.mock';
import { MetadataValuesComponent } from '../../../../field-components/metadata-values/metadata-values.component'; import { MetadataValuesComponent } from '../../../../field-components/metadata-values/metadata-values.component';
import { mockItemWithMetadataFieldAndValue } from '../item-page-field.component.spec'; import { mockItemWithMetadataFieldsAndValue } from '../item-page-field.component.spec';
import { ItemPageAuthorFieldComponent } from './item-page-author-field.component'; import { ItemPageAuthorFieldComponent } from './item-page-author-field.component';
import { APP_CONFIG } from '../../../../../../config/app-config.interface'; import { APP_CONFIG } from '../../../../../../config/app-config.interface';
import { environment } from '../../../../../../environments/environment'; import { environment } from '../../../../../../environments/environment';
@@ -37,7 +37,7 @@ describe('ItemPageAuthorFieldComponent', () => {
beforeEach(waitForAsync(() => { beforeEach(waitForAsync(() => {
fixture = TestBed.createComponent(ItemPageAuthorFieldComponent); fixture = TestBed.createComponent(ItemPageAuthorFieldComponent);
comp = fixture.componentInstance; comp = fixture.componentInstance;
comp.item = mockItemWithMetadataFieldAndValue(field, mockValue); comp.item = mockItemWithMetadataFieldsAndValue([field], mockValue);
fixture.detectChanges(); fixture.detectChanges();
})); }));

View File

@@ -3,7 +3,7 @@ import { ChangeDetectionStrategy, NO_ERRORS_SCHEMA } from '@angular/core';
import { ComponentFixture, TestBed, waitForAsync } from '@angular/core/testing'; import { ComponentFixture, TestBed, waitForAsync } from '@angular/core/testing';
import { TranslateLoaderMock } from '../../../../../shared/testing/translate-loader.mock'; import { TranslateLoaderMock } from '../../../../../shared/testing/translate-loader.mock';
import { MetadataValuesComponent } from '../../../../field-components/metadata-values/metadata-values.component'; import { MetadataValuesComponent } from '../../../../field-components/metadata-values/metadata-values.component';
import { mockItemWithMetadataFieldAndValue } from '../item-page-field.component.spec'; import { mockItemWithMetadataFieldsAndValue } from '../item-page-field.component.spec';
import { ItemPageDateFieldComponent } from './item-page-date-field.component'; import { ItemPageDateFieldComponent } from './item-page-date-field.component';
import { APP_CONFIG } from '../../../../../../config/app-config.interface'; import { APP_CONFIG } from '../../../../../../config/app-config.interface';
import { environment } from '../../../../../../environments/environment'; import { environment } from '../../../../../../environments/environment';
@@ -36,7 +36,7 @@ describe('ItemPageDateFieldComponent', () => {
beforeEach(waitForAsync(() => { beforeEach(waitForAsync(() => {
fixture = TestBed.createComponent(ItemPageDateFieldComponent); fixture = TestBed.createComponent(ItemPageDateFieldComponent);
comp = fixture.componentInstance; comp = fixture.componentInstance;
comp.item = mockItemWithMetadataFieldAndValue(mockField, mockValue); comp.item = mockItemWithMetadataFieldsAndValue([mockField], mockValue);
fixture.detectChanges(); fixture.detectChanges();
})); }));

View File

@@ -3,7 +3,7 @@ import { ChangeDetectionStrategy, NO_ERRORS_SCHEMA } from '@angular/core';
import { ComponentFixture, TestBed, waitForAsync } from '@angular/core/testing'; import { ComponentFixture, TestBed, waitForAsync } from '@angular/core/testing';
import { TranslateLoaderMock } from '../../../../../shared/testing/translate-loader.mock'; import { TranslateLoaderMock } from '../../../../../shared/testing/translate-loader.mock';
import { MetadataValuesComponent } from '../../../../field-components/metadata-values/metadata-values.component'; import { MetadataValuesComponent } from '../../../../field-components/metadata-values/metadata-values.component';
import { mockItemWithMetadataFieldAndValue } from '../item-page-field.component.spec'; import { mockItemWithMetadataFieldsAndValue } from '../item-page-field.component.spec';
import { GenericItemPageFieldComponent } from './generic-item-page-field.component'; import { GenericItemPageFieldComponent } from './generic-item-page-field.component';
import { APP_CONFIG } from '../../../../../../config/app-config.interface'; import { APP_CONFIG } from '../../../../../../config/app-config.interface';
import { environment } from '../../../../../../environments/environment'; import { environment } from '../../../../../../environments/environment';
@@ -38,7 +38,7 @@ describe('GenericItemPageFieldComponent', () => {
beforeEach(waitForAsync(() => { beforeEach(waitForAsync(() => {
fixture = TestBed.createComponent(GenericItemPageFieldComponent); fixture = TestBed.createComponent(GenericItemPageFieldComponent);
comp = fixture.componentInstance; comp = fixture.componentInstance;
comp.item = mockItemWithMetadataFieldAndValue(mockField, mockValue); comp.item = mockItemWithMetadataFieldsAndValue([mockField], mockValue);
comp.fields = mockFields; comp.fields = mockFields;
comp.label = mockLabel; comp.label = mockLabel;
fixture.detectChanges(); fixture.detectChanges();

View File

@@ -40,5 +40,10 @@ export class GenericItemPageFieldComponent extends ItemPageFieldComponent {
*/ */
@Input() enableMarkdown = false; @Input() enableMarkdown = false;
/**
* Whether any valid HTTP(S) URL should be rendered as a link
*/
@Input() urlRegex?;
} }

View File

@@ -4,5 +4,7 @@
[separator]="separator" [separator]="separator"
[label]="label" [label]="label"
[enableMarkdown]="enableMarkdown" [enableMarkdown]="enableMarkdown"
[urlRegex]="urlRegex"
[browseDefinition]="browseDefinition|async"
></ds-metadata-values> ></ds-metadata-values>
</div> </div>

View File

@@ -1,6 +1,6 @@
import { TranslateLoader, TranslateModule } from '@ngx-translate/core'; import { TranslateLoader, TranslateModule } from '@ngx-translate/core';
import { ChangeDetectionStrategy, NO_ERRORS_SCHEMA } from '@angular/core'; import { ChangeDetectionStrategy, NO_ERRORS_SCHEMA } from '@angular/core';
import { ComponentFixture, TestBed, waitForAsync } from '@angular/core/testing'; import { ComponentFixture, inject, TestBed, waitForAsync } from '@angular/core/testing';
import { Item } from '../../../../core/shared/item.model'; import { Item } from '../../../../core/shared/item.model';
import { TranslateLoaderMock } from '../../../../shared/mocks/translate-loader.mock'; import { TranslateLoaderMock } from '../../../../shared/mocks/translate-loader.mock';
import { ItemPageFieldComponent } from './item-page-field.component'; import { ItemPageFieldComponent } from './item-page-field.component';
@@ -12,6 +12,9 @@ import { environment } from '../../../../../environments/environment';
import { MarkdownPipe } from '../../../../shared/utils/markdown.pipe'; import { MarkdownPipe } from '../../../../shared/utils/markdown.pipe';
import { SharedModule } from '../../../../shared/shared.module'; import { SharedModule } from '../../../../shared/shared.module';
import { APP_CONFIG } from '../../../../../config/app-config.interface'; import { APP_CONFIG } from '../../../../../config/app-config.interface';
import { BrowseLinkDataService } from '../../../../core/browse/browse-link-data.service';
import { browseLinkDataServiceStub } from '../../../../shared/testing/browse-link-data-service.stub';
import { By } from '@angular/platform-browser';
let comp: ItemPageFieldComponent; let comp: ItemPageFieldComponent;
let fixture: ComponentFixture<ItemPageFieldComponent>; let fixture: ComponentFixture<ItemPageFieldComponent>;
@@ -20,7 +23,9 @@ let markdownSpy;
const mockValue = 'test value'; const mockValue = 'test value';
const mockField = 'dc.test'; const mockField = 'dc.test';
const mockLabel = 'test label'; const mockLabel = 'test label';
const mockFields = [mockField]; const mockAuthorField = 'dc.contributor.author';
const mockDateIssuedField = 'dc.date.issued';
const mockFields = [mockField, mockAuthorField, mockDateIssuedField];
describe('ItemPageFieldComponent', () => { describe('ItemPageFieldComponent', () => {
@@ -44,6 +49,7 @@ describe('ItemPageFieldComponent', () => {
], ],
providers: [ providers: [
{ provide: APP_CONFIG, useValue: appConfig }, { provide: APP_CONFIG, useValue: appConfig },
{ provide: BrowseLinkDataService, useValue: browseLinkDataServiceStub }
], ],
declarations: [ItemPageFieldComponent, MetadataValuesComponent], declarations: [ItemPageFieldComponent, MetadataValuesComponent],
schemas: [NO_ERRORS_SCHEMA] schemas: [NO_ERRORS_SCHEMA]
@@ -53,7 +59,7 @@ describe('ItemPageFieldComponent', () => {
markdownSpy = spyOn(MarkdownPipe.prototype, 'transform'); markdownSpy = spyOn(MarkdownPipe.prototype, 'transform');
fixture = TestBed.createComponent(ItemPageFieldComponent); fixture = TestBed.createComponent(ItemPageFieldComponent);
comp = fixture.componentInstance; comp = fixture.componentInstance;
comp.item = mockItemWithMetadataFieldAndValue(mockField, mockValue); comp.item = mockItemWithMetadataFieldsAndValue(mockFields, mockValue);
comp.fields = mockFields; comp.fields = mockFields;
comp.label = mockLabel; comp.label = mockLabel;
fixture.detectChanges(); fixture.detectChanges();
@@ -126,17 +132,55 @@ describe('ItemPageFieldComponent', () => {
expect(markdownSpy).toHaveBeenCalled(); expect(markdownSpy).toHaveBeenCalled();
}); });
}); });
}); });
describe("test rendering of configured browse links", () => {
beforeEach(() => {
fixture.detectChanges();
});
it('should have a browse link', () => {
expect(fixture.debugElement.query(By.css('a.ds-browse-link')).nativeElement.innerHTML).toContain(mockValue);
})
});
describe("test rendering of configured regex-based links", () => {
beforeEach(() => {
comp.urlRegex = '^test'
fixture.detectChanges();
});
beforeEach(waitForAsync(() => {
it('should have a rendered (non-browse) link since the value matches ^test', () => {
expect(fixture.debugElement.query(By.css('a.ds-simple-metadata-link')).nativeElement.innerHTML).toContain(mockValue);
})
}))
});
describe("test skipping of configured links that do NOT match regex", () => {
beforeEach(() => {
comp.urlRegex = '^nope'
fixture.detectChanges();
});
beforeEach(waitForAsync(() => {
it('should NOT have a rendered (non-browse) link since the value matches ^test', () => {
expect(fixture.debugElement.query(By.css('a.ds-simple-metadata-link'))).toBeNull()
})
}))
});
}); });
export function mockItemWithMetadataFieldAndValue(field: string, value: string): Item { export function mockItemWithMetadataFieldsAndValue(fields: string[], value: string): Item {
const item = Object.assign(new Item(), { const item = Object.assign(new Item(), {
bundles: createSuccessfulRemoteDataObject$(createPaginatedList([])), bundles: createSuccessfulRemoteDataObject$(createPaginatedList([])),
metadata: new MetadataMap() metadata: new MetadataMap()
}); });
item.metadata[field] = [{ fields.forEach((field: string) => {
language: 'en_US', item.metadata[field] = [{
value: value language: 'en_US',
}] as MetadataValue[]; value: value
}] as MetadataValue[];
})
return item; return item;
} }

View File

@@ -1,5 +1,9 @@
import { Component, Input } from '@angular/core'; import { Component, Input } from '@angular/core';
import { Item } from '../../../../core/shared/item.model'; import { Item } from '../../../../core/shared/item.model';
import { map } from 'rxjs/operators';
import { Observable } from 'rxjs';
import { BrowseDefinition } from '../../../../core/shared/browse-definition.model';
import { BrowseLinkDataService } from '../../../../core/browse/browse-link-data.service';
/** /**
* This component can be used to represent metadata on a simple item page. * This component can be used to represent metadata on a simple item page.
@@ -12,6 +16,9 @@ import { Item } from '../../../../core/shared/item.model';
}) })
export class ItemPageFieldComponent { export class ItemPageFieldComponent {
constructor(protected browseLinkDataService: BrowseLinkDataService) {
}
/** /**
* The item to display metadata for * The item to display metadata for
*/ */
@@ -38,4 +45,17 @@ export class ItemPageFieldComponent {
*/ */
separator = '<br/>'; separator = '<br/>';
/**
* Whether any valid HTTP(S) URL should be rendered as a link
*/
urlRegex?: string;
/**
* Return browse definition that matches any field used in this component if it is configured as a browse
* link in dspace.cfg (webui.browse.link.<n>)
*/
get browseDefinition(): Observable<BrowseDefinition> {
return this.browseLinkDataService.getBrowseLinkFor(this.fields).pipe(
map((def) => def));
}
} }

View File

@@ -3,7 +3,7 @@ import { ChangeDetectionStrategy, NO_ERRORS_SCHEMA } from '@angular/core';
import { ComponentFixture, TestBed, waitForAsync } from '@angular/core/testing'; import { ComponentFixture, TestBed, waitForAsync } from '@angular/core/testing';
import { TranslateLoaderMock } from '../../../../../shared/testing/translate-loader.mock'; import { TranslateLoaderMock } from '../../../../../shared/testing/translate-loader.mock';
import { MetadataValuesComponent } from '../../../../field-components/metadata-values/metadata-values.component'; import { MetadataValuesComponent } from '../../../../field-components/metadata-values/metadata-values.component';
import { mockItemWithMetadataFieldAndValue } from '../item-page-field.component.spec'; import { mockItemWithMetadataFieldsAndValue } from '../item-page-field.component.spec';
import { ItemPageTitleFieldComponent } from './item-page-title-field.component'; import { ItemPageTitleFieldComponent } from './item-page-title-field.component';
let comp: ItemPageTitleFieldComponent; let comp: ItemPageTitleFieldComponent;
@@ -31,7 +31,7 @@ describe('ItemPageTitleFieldComponent', () => {
beforeEach(waitForAsync(() => { beforeEach(waitForAsync(() => {
fixture = TestBed.createComponent(ItemPageTitleFieldComponent); fixture = TestBed.createComponent(ItemPageTitleFieldComponent);
comp = fixture.componentInstance; comp = fixture.componentInstance;
comp.item = mockItemWithMetadataFieldAndValue(mockField, mockValue); comp.item = mockItemWithMetadataFieldsAndValue([mockField], mockValue);
fixture.detectChanges(); fixture.detectChanges();
})); }));

View File

@@ -2,7 +2,7 @@ import { TranslateLoader, TranslateModule } from '@ngx-translate/core';
import { ChangeDetectionStrategy, NO_ERRORS_SCHEMA } from '@angular/core'; import { ChangeDetectionStrategy, NO_ERRORS_SCHEMA } from '@angular/core';
import { ComponentFixture, TestBed, waitForAsync } from '@angular/core/testing'; import { ComponentFixture, TestBed, waitForAsync } from '@angular/core/testing';
import { TranslateLoaderMock } from '../../../../../shared/testing/translate-loader.mock'; import { TranslateLoaderMock } from '../../../../../shared/testing/translate-loader.mock';
import { mockItemWithMetadataFieldAndValue } from '../item-page-field.component.spec'; import { mockItemWithMetadataFieldsAndValue } from '../item-page-field.component.spec';
import { ItemPageUriFieldComponent } from './item-page-uri-field.component'; import { ItemPageUriFieldComponent } from './item-page-uri-field.component';
import { MetadataUriValuesComponent } from '../../../../field-components/metadata-uri-values/metadata-uri-values.component'; import { MetadataUriValuesComponent } from '../../../../field-components/metadata-uri-values/metadata-uri-values.component';
import { environment } from '../../../../../../environments/environment'; import { environment } from '../../../../../../environments/environment';
@@ -37,7 +37,7 @@ describe('ItemPageUriFieldComponent', () => {
beforeEach(waitForAsync(() => { beforeEach(waitForAsync(() => {
fixture = TestBed.createComponent(ItemPageUriFieldComponent); fixture = TestBed.createComponent(ItemPageUriFieldComponent);
comp = fixture.componentInstance; comp = fixture.componentInstance;
comp.item = mockItemWithMetadataFieldAndValue(mockField, mockValue); comp.item = mockItemWithMetadataFieldsAndValue([mockField], mockValue);
comp.fields = [mockField]; comp.fields = [mockField];
comp.label = mockLabel; comp.label = mockLabel;
fixture.detectChanges(); fixture.detectChanges();

View File

@@ -4,9 +4,11 @@ import { Item } from '../../../../core/shared/item.model';
import { getItemPageRoute } from '../../../item-page-routing-paths'; import { getItemPageRoute } from '../../../item-page-routing-paths';
import { RouteService } from '../../../../core/services/route.service'; import { RouteService } from '../../../../core/services/route.service';
import { Observable } from 'rxjs'; import { Observable } from 'rxjs';
import { BrowseService } from '../../../../../app/core/browse/browse.service';
import { getDSpaceQuery, isIiifEnabled, isIiifSearchEnabled } from './item-iiif-utils'; import { getDSpaceQuery, isIiifEnabled, isIiifSearchEnabled } from './item-iiif-utils';
import { filter, map, take } from 'rxjs/operators'; import { filter, map, take } from 'rxjs/operators';
import { Router } from '@angular/router'; import { Router } from '@angular/router';
import { BrowseDefinition } from '../../../../core/shared/browse-definition.model';
@Component({ @Component({
selector: 'ds-item', selector: 'ds-item',
@@ -49,10 +51,14 @@ export class ItemComponent implements OnInit {
*/ */
iiifQuery$: Observable<string>; iiifQuery$: Observable<string>;
browseDefinitions: BrowseDefinition[];
browseDefinitions$: Observable<BrowseDefinition[]>;
mediaViewer; mediaViewer;
constructor(protected routeService: RouteService, constructor(protected routeService: RouteService,
protected router: Router) { protected router: Router,
protected browseService: BrowseService) {
this.mediaViewer = environment.mediaViewer; this.mediaViewer = environment.mediaViewer;
} }
@@ -84,5 +90,9 @@ export class ItemComponent implements OnInit {
if (this.iiifSearchEnabled) { if (this.iiifSearchEnabled) {
this.iiifQuery$ = getDSpaceQuery(this.object, this.routeService); this.iiifQuery$ = getDSpaceQuery(this.object, this.routeService);
} }
// get browse definitions
this.browseDefinitions$ = this.browseService.getBrowseDefinitions().pipe(
map((data) => data.payload.page as BrowseDefinition[])
);
} }
} }

View File

@@ -17,6 +17,7 @@ import { Item } from '../../../../core/shared/item.model';
import { ItemDataService } from '../../../../core/data/item-data.service'; import { ItemDataService } from '../../../../core/data/item-data.service';
import { WorkspaceItem } from '../../../../core/submission/models/workspaceitem.model'; import { WorkspaceItem } from '../../../../core/submission/models/workspaceitem.model';
import { RouteService } from '../../../../core/services/route.service'; import { RouteService } from '../../../../core/services/route.service';
import { BrowseService } from '../../../../core/browse/browse.service';
@Component({ @Component({
selector: 'ds-versioned-item', selector: 'ds-versioned-item',
@@ -36,8 +37,9 @@ export class VersionedItemComponent extends ItemComponent {
private searchService: SearchService, private searchService: SearchService,
private itemService: ItemDataService, private itemService: ItemDataService,
protected routeService: RouteService, protected routeService: RouteService,
protected browseService: BrowseService,
) { ) {
super(routeService, router); super(routeService, router, browseService);
} }
/** /**

View File

@@ -0,0 +1,8 @@
<div>
<a *ngIf="(metadataRepresentation.representationType=='browse_link')"
target="_blank" class="dont-break-out"
href="/browse/{{metadataRepresentation.browseDefinition.id}}?{{metadataRepresentation.browseDefinition.metadataBrowse?'value':'startsWith'}}={{metadataRepresentation.getValue()}}">
{{metadataRepresentation.getValue()}}
</a>
<b>(new browse link page)</b>
</div>

View File

@@ -0,0 +1,36 @@
import { ComponentFixture, TestBed, waitForAsync } from '@angular/core/testing';
import { ChangeDetectionStrategy, NO_ERRORS_SCHEMA } from '@angular/core';
import { BrowseLinkMetadataListElementComponent } from './browse-link-metadata-list-element.component';
import { MetadatumRepresentation } from '../../../../core/shared/metadata-representation/metadatum/metadatum-representation.model';
const mockMetadataRepresentation = Object.assign(new MetadatumRepresentation('type'), {
key: 'dc.contributor.author',
value: 'Test Author'
});
describe('BrowseLinkMetadataListElementComponent', () => {
let comp: BrowseLinkMetadataListElementComponent;
let fixture: ComponentFixture<BrowseLinkMetadataListElementComponent>;
beforeEach(waitForAsync(() => {
TestBed.configureTestingModule({
imports: [],
declarations: [BrowseLinkMetadataListElementComponent],
schemas: [NO_ERRORS_SCHEMA]
}).overrideComponent(BrowseLinkMetadataListElementComponent, {
set: { changeDetection: ChangeDetectionStrategy.Default }
}).compileComponents();
}));
beforeEach(waitForAsync(() => {
fixture = TestBed.createComponent(BrowseLinkMetadataListElementComponent);
comp = fixture.componentInstance;
comp.metadataRepresentation = mockMetadataRepresentation;
fixture.detectChanges();
}));
it('should contain the value as a browse link', () => {
expect(fixture.debugElement.nativeElement.textContent).toContain(mockMetadataRepresentation.value);
});
});

View File

@@ -0,0 +1,18 @@
import { MetadataRepresentationType } from '../../../../core/shared/metadata-representation/metadata-representation.model';
import { Component } from '@angular/core';
import { MetadataRepresentationListElementComponent } from '../metadata-representation-list-element.component';
import { metadataRepresentationComponent } from '../../../metadata-representation/metadata-representation.decorator';
//@metadataRepresentationComponent('Publication', MetadataRepresentationType.PlainText)
// For now, authority controlled fields are rendered the same way as plain text fields
//@metadataRepresentationComponent('Publication', MetadataRepresentationType.AuthorityControlled)
@metadataRepresentationComponent('Publication', MetadataRepresentationType.BrowseLink)
@Component({
selector: 'ds-browse-link-metadata-list-element',
templateUrl: './browse-link-metadata-list-element.component.html'
})
/**
* A component for displaying MetadataRepresentation objects in the form of plain text
* It will simply use the value retrieved from MetadataRepresentation.getValue() to display as plain text
*/
export class BrowseLinkMetadataListElementComponent extends MetadataRepresentationListElementComponent {
}

View File

@@ -13,4 +13,11 @@ export class MetadataRepresentationListElementComponent {
* The metadata representation of this component * The metadata representation of this component
*/ */
metadataRepresentation: MetadataRepresentation; metadataRepresentation: MetadataRepresentation;
isLink(): boolean {
// Match any http:// or https://
const linkPattern: RegExp = /^https?\/\//;
return linkPattern.test(this.metadataRepresentation.getValue());
}
} }

View File

@@ -1,3 +1,16 @@
<div> <div>
<span class="dont-break-out">{{metadataRepresentation.getValue()}}</span> <!-- Because this template is used by default, we will additionally test for representation type and display accordingly -->
<span *ngIf="(metadataRepresentation.representationType=='plain_text') && !isLink()" class="dont-break-out">
{{metadataRepresentation.getValue()}}
</span>
<a *ngIf="(metadataRepresentation.representationType=='plain_text') && isLink()" class="dont-break-out"
target="_blank" href="{{metadataRepresentation.getValue()}}">
{{metadataRepresentation.getValue()}}
</a>
<span *ngIf="(metadataRepresentation.representationType=='authority_controlled')" class="dont-break-out">{{metadataRepresentation.getValue()}}</span>
<a *ngIf="(metadataRepresentation.representationType=='browse_link')"
class="dont-break-out ds-browse-link"
href="/browse/{{metadataRepresentation.browseDefinition.id}}?{{metadataRepresentation.browseDefinition.metadataBrowse?'value':'startsWith'}}={{metadataRepresentation.getValue()}}&bbm.page=1">
{{metadataRepresentation.getValue()}}
</a>
</div> </div>

View File

@@ -2,8 +2,12 @@ import { ComponentFixture, TestBed, waitForAsync } from '@angular/core/testing';
import { ChangeDetectionStrategy, NO_ERRORS_SCHEMA } from '@angular/core'; import { ChangeDetectionStrategy, NO_ERRORS_SCHEMA } from '@angular/core';
import { PlainTextMetadataListElementComponent } from './plain-text-metadata-list-element.component'; import { PlainTextMetadataListElementComponent } from './plain-text-metadata-list-element.component';
import { MetadatumRepresentation } from '../../../../core/shared/metadata-representation/metadatum/metadatum-representation.model'; import { MetadatumRepresentation } from '../../../../core/shared/metadata-representation/metadatum/metadatum-representation.model';
import { By } from '@angular/platform-browser';
import { mockData } from '../../../testing/browse-link-data-service.stub';
const mockMetadataRepresentation = Object.assign(new MetadatumRepresentation('type'), { // Render the mock representation with the default mock author browse definition so it is also rendered as a link
// without affecting other tests
const mockMetadataRepresentation = Object.assign(new MetadatumRepresentation('type', mockData[1]), {
key: 'dc.contributor.author', key: 'dc.contributor.author',
value: 'Test Author' value: 'Test Author'
}); });
@@ -33,4 +37,8 @@ describe('PlainTextMetadataListElementComponent', () => {
expect(fixture.debugElement.nativeElement.textContent).toContain(mockMetadataRepresentation.value); expect(fixture.debugElement.nativeElement.textContent).toContain(mockMetadataRepresentation.value);
}); });
it('should contain the browse link as plain text', () => {
expect(fixture.debugElement.query(By.css('a.ds-browse-link')).nativeElement.innerHTML).toContain(mockMetadataRepresentation.value);
});
}); });

View File

@@ -84,6 +84,8 @@ import { LangSwitchComponent } from './lang-switch/lang-switch.component';
import { import {
PlainTextMetadataListElementComponent PlainTextMetadataListElementComponent
} from './object-list/metadata-representation-list-element/plain-text/plain-text-metadata-list-element.component'; } from './object-list/metadata-representation-list-element/plain-text/plain-text-metadata-list-element.component';
import { BrowseLinkMetadataListElementComponent }
from './object-list/metadata-representation-list-element/browse-link/browse-link-metadata-list-element.component';
import { import {
ItemMetadataListElementComponent ItemMetadataListElementComponent
} from './object-list/metadata-representation-list-element/item/item-metadata-list-element.component'; } from './object-list/metadata-representation-list-element/item/item-metadata-list-element.component';
@@ -383,6 +385,7 @@ const ENTRY_COMPONENTS = [
EditItemSelectorComponent, EditItemSelectorComponent,
ThemedEditItemSelectorComponent, ThemedEditItemSelectorComponent,
PlainTextMetadataListElementComponent, PlainTextMetadataListElementComponent,
BrowseLinkMetadataListElementComponent,
ItemMetadataListElementComponent, ItemMetadataListElementComponent,
MetadataRepresentationListElementComponent, MetadataRepresentationListElementComponent,
ItemMetadataRepresentationListElementComponent, ItemMetadataRepresentationListElementComponent,

View File

@@ -0,0 +1,77 @@
import { EMPTY, Observable, of as observableOf } from 'rxjs';
import { RemoteData } from '../../core/data/remote-data';
import { buildPaginatedList, PaginatedList } from '../../core/data/paginated-list.model';
import { BrowseDefinition } from '../../core/shared/browse-definition.model';
import {
getPaginatedListPayload,
getRemoteDataPayload
} from '../../core/shared/operators';
import { BrowseService } from '../../core/browse/browse.service';
import { distinctUntilChanged, map, startWith } from 'rxjs/operators';
import { isEmpty, isNotEmpty } from '../empty.util';
import { createSuccessfulRemoteDataObject } from '../remote-data.utils';
import { PageInfo } from '../../core/shared/page-info.model';
// This data is in post-serialized form (metadata -> metadataKeys)
export const mockData: BrowseDefinition[] = [
Object.assign(new BrowseDefinition, {
"id" : "dateissued",
"metadataBrowse" : false,
"dataType" : "date",
"sortOptions" : EMPTY,
"order" : "ASC",
"type" : "browse",
"metadataKeys" : [ "dc.date.issued" ],
"_links" : EMPTY
}),
Object.assign(new BrowseDefinition, {
"id" : "author",
"metadataBrowse" : true,
"dataType" : "text",
"sortOptions" : EMPTY,
"order" : "ASC",
"type" : "browse",
"metadataKeys" : [ "dc.contributor.*", "dc.creator" ],
"_links" : EMPTY
})
];
export const browseLinkDataServiceStub: any = {
/**
* Get all BrowseDefinitions
*/
getBrowseLinks(): Observable<RemoteData<PaginatedList<BrowseDefinition>>> {
return observableOf(createSuccessfulRemoteDataObject(buildPaginatedList(new PageInfo(), mockData)));
},
/**
* Get the browse URL by providing a list of metadata keys
* @param metadatumKey
* @param linkPath
*/
getBrowseLinkFor(metadataKeys: string[]): Observable<BrowseDefinition> {
let searchKeyArray: string[] = [];
metadataKeys.forEach((metadataKey) => {
searchKeyArray = searchKeyArray.concat(BrowseService.toSearchKeyArray(metadataKey));
})
return this.getBrowseLinks().pipe(
getRemoteDataPayload(),
getPaginatedListPayload(),
map((browseDefinitions: BrowseDefinition[]) => browseDefinitions
.find((def: BrowseDefinition) => {
const matchingKeys = def.metadataKeys.find((key: string) => searchKeyArray.indexOf(key) >= 0);
return isNotEmpty(matchingKeys);
})
),
map((def: BrowseDefinition) => {
if (isEmpty(def) || isEmpty(def.id)) {
//throw new Error(`A browse definition for field ${metadataKey} isn't configured`);
} else {
return def;
}
}),
startWith(undefined),
distinctUntilChanged()
);
}
}