Skip to content

Commit 4b4b977

Browse files
milanmajchrakclaude
andcommitted
Port X-02f to dtq-dev-9-base: item-page URI DOI resolver and the sidebar expand-all truncatable hooks
Source: no single commit - the fork delta is measured against dspace-7.6.5, and the v9 upgrade took all of these files wholesale from vanilla 9.3, so the fork hunks were never applied (guard X1 sweep 2026-09-10, card X-02f). (A) Sidebar expand-all - truncatable-part: @input() externalToggle, @output() truncated, toggle(event?, expand?) with stopPropagation and a toggleWithoutId() fallback for parts with no id, isExpanded reduced to `expand` (the `expandable` flip in toggle() goes with it). - The v9 body of truncateElement() is kept (scrollHeight > clientHeight); only its guard is widened, and `truncated` is emitted through a change-only gate because truncateElement() runs on every ngAfterViewChecked. - sidebar-search-list-element: the expandable/expanded/truncatedStates controller and the .toggleIcons chevrons, on top of FE-24's descriptionLang hunks. - listable-object-component-loader.component.scss positions the chevrons. (B) item-page-uri-field resolves a bare DOI through ItemIdentifierService (loadDoiResolverConfiguration + DEFAULT_DOI_RESOLVER), not a second ConfigurationDataService. The prefix is applied ONLY to dc.identifier.doi: on this base the component also renders dc.identifier.uri, coar.notify.endorsedBy and three datacite.relation.* fields, and the fork's blanket "no http means DOI" rule would corrupt those. (C) auth-nav-menu.component.scss gains `.dropdown-toggle` WITH the leading dot - the fork's selector has no dot, so it targets a <dropdown-toggle> element that does not exist. Not ported, deliberately: getParentHierarchyTitle/getAllParentsRecursive (a separate unowned feature), and the fork's three vacuous truncatable tests (.expandIcon / .collapseIcon, whose selectors exist nowhere in the tree, and a tautological getter assertion). Closes card X-02f. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
1 parent 8ac588e commit 4b4b977

11 files changed

Lines changed: 474 additions & 27 deletions
Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,3 @@
11
<div class="item-page-field">
2-
<ds-metadata-uri-values [mdValues]="item?.allMetadata(fields)" [separator]="separator" [label]="label"></ds-metadata-uri-values>
2+
<ds-metadata-uri-values [mdValues]="getUriMetadataValues()" [separator]="separator" [label]="label"></ds-metadata-uri-values>
33
</div>

src/app/item-page/simple/field-components/specific-field/uri/item-page-uri-field.component.spec.ts

Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,15 +7,18 @@ import {
77
TestBed,
88
waitForAsync,
99
} from '@angular/core/testing';
10+
import { By } from '@angular/platform-browser';
1011
import {
1112
TranslateLoader,
1213
TranslateModule,
1314
} from '@ngx-translate/core';
15+
import { of } from 'rxjs';
1416

1517
import { APP_CONFIG } from '../../../../../../config/app-config.interface';
1618
import { environment } from '../../../../../../environments/environment';
1719
import { BrowseService } from '../../../../../core/browse/browse.service';
1820
import { BrowseDefinitionDataService } from '../../../../../core/browse/browse-definition-data.service';
21+
import { ItemIdentifierService } from '../../../../../shared/item-identifier.service';
1922
import { BrowseDefinitionDataServiceStub } from '../../../../../shared/testing/browse-definition-data-service.stub';
2023
import { BrowseServiceStub } from '../../../../../shared/testing/browse-service.stub';
2124
import { TranslateLoaderMock } from '../../../../../shared/testing/translate-loader.mock';
@@ -30,8 +33,19 @@ const mockField = 'dc.identifier.uri';
3033
const mockValue = 'test value';
3134
const mockLabel = 'test label';
3235

36+
const DOI_FIELD = 'dc.identifier.doi';
37+
const NON_DOI_FIELD = 'coar.notify.endorsedBy';
38+
const RESOLVER = 'https://doi.test';
39+
40+
let itemIdentifierService: jasmine.SpyObj<ItemIdentifierService>;
41+
42+
const hrefOfFirstAnchor = (f: ComponentFixture<ItemPageUriFieldComponent>): string =>
43+
f.debugElement.query(By.css('a')).nativeElement.getAttribute('href');
44+
3345
describe('ItemPageUriFieldComponent', () => {
3446
beforeEach(waitForAsync(() => {
47+
itemIdentifierService = jasmine.createSpyObj('itemIdentifierService', ['loadDoiResolverConfiguration']);
48+
itemIdentifierService.loadDoiResolverConfiguration.and.returnValue(of(RESOLVER));
3549
TestBed.configureTestingModule({
3650
imports: [TranslateModule.forRoot({
3751
loader: {
@@ -43,6 +57,7 @@ describe('ItemPageUriFieldComponent', () => {
4357
{ provide: APP_CONFIG, useValue: environment },
4458
{ provide: BrowseDefinitionDataService, useValue: BrowseDefinitionDataServiceStub },
4559
{ provide: BrowseService, useValue: BrowseServiceStub },
60+
{ provide: ItemIdentifierService, useValue: itemIdentifierService },
4661
],
4762
schemas: [NO_ERRORS_SCHEMA],
4863
}).overrideComponent(ItemPageUriFieldComponent, {
@@ -62,4 +77,41 @@ describe('ItemPageUriFieldComponent', () => {
6277
it('should display display the correct metadata value', () => {
6378
expect(fixture.nativeElement.innerHTML).toContain(mockValue);
6479
});
80+
81+
describe('DOI resolver', () => {
82+
const render = (field: string, value: string) => {
83+
fixture = TestBed.createComponent(ItemPageUriFieldComponent);
84+
comp = fixture.componentInstance;
85+
comp.item = mockItemWithMetadataFieldsAndValue([field], value);
86+
comp.fields = [field];
87+
comp.label = mockLabel;
88+
fixture.detectChanges();
89+
return fixture;
90+
};
91+
92+
it('should prefix a bare dc.identifier.doi with the configured resolver', () => {
93+
const f = render(DOI_FIELD, '10.1234/abc');
94+
95+
expect(hrefOfFirstAnchor(f)).toEqual(RESOLVER + '/' + '10.1234/abc');
96+
});
97+
98+
it('should leave a dc.identifier.doi that already has a scheme alone', () => {
99+
const f = render(DOI_FIELD, 'https://doi.test/10.1234/abc');
100+
101+
expect(hrefOfFirstAnchor(f)).toEqual('https://doi.test/10.1234/abc');
102+
});
103+
104+
it('should leave a bare coar.notify.endorsedBy value untouched', () => {
105+
const f = render(NON_DOI_FIELD, '10.1234/abc');
106+
107+
expect(hrefOfFirstAnchor(f)).toEqual('10.1234/abc');
108+
});
109+
110+
it('should not produce a double slash when the configured resolver ends with one', () => {
111+
itemIdentifierService.loadDoiResolverConfiguration.and.returnValue(of('https://doi.test/'));
112+
const f = render(DOI_FIELD, '10.1234/abc');
113+
114+
expect(hrefOfFirstAnchor(f)).toEqual('https://doi.test/10.1234/abc');
115+
});
116+
});
65117
});

src/app/item-page/simple/field-components/specific-field/uri/item-page-uri-field.component.ts

Lines changed: 58 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,20 @@
11
import {
22
Component,
33
Input,
4+
OnInit,
45
} from '@angular/core';
56

7+
import { BrowseService } from '../../../../../core/browse/browse.service';
8+
import { BrowseDefinitionDataService } from '../../../../../core/browse/browse-definition-data.service';
69
import { Item } from '../../../../../core/shared/item.model';
10+
import { MetadataValue } from '../../../../../core/shared/metadata.models';
11+
import { isEmpty } from '../../../../../shared/empty.util';
12+
import {
13+
DEFAULT_DOI_RESOLVER,
14+
ItemIdentifierService,
15+
} from '../../../../../shared/item-identifier.service';
716
import { MetadataUriValuesComponent } from '../../../../field-components/metadata-uri-values/metadata-uri-values.component';
17+
import { DOI_METADATA_FIELD } from '../../clarin-generic-item-field/clarin-generic-item-field.constants';
818
import { ItemPageFieldComponent } from '../item-page-field.component';
919

1020
@Component({
@@ -18,7 +28,18 @@ import { ItemPageFieldComponent } from '../item-page-field.component';
1828
* This component can be used to represent any uri on a simple item page.
1929
* It expects 4 parameters: The item, a separator, the metadata keys and an i18n key
2030
*/
21-
export class ItemPageUriFieldComponent extends ItemPageFieldComponent {
31+
export class ItemPageUriFieldComponent extends ItemPageFieldComponent implements OnInit {
32+
33+
/**
34+
* The configured DOI resolver, used to turn a bare DOI into a resolvable link.
35+
*/
36+
doiResolver: string;
37+
38+
constructor(protected browseDefinitionDataService: BrowseDefinitionDataService,
39+
protected browseService: BrowseService,
40+
protected itemIdentifierService: ItemIdentifierService) {
41+
super(browseDefinitionDataService, browseService);
42+
}
2243

2344
/**
2445
* The item to display metadata for
@@ -41,4 +62,40 @@ export class ItemPageUriFieldComponent extends ItemPageFieldComponent {
4162
*/
4263
@Input() label: string;
4364

65+
ngOnInit(): void {
66+
this.itemIdentifierService.loadDoiResolverConfiguration().subscribe((resolver: string) => {
67+
this.doiResolver = isEmpty(resolver) ? DEFAULT_DOI_RESOLVER : resolver;
68+
});
69+
}
70+
71+
/**
72+
* The metadata values to render.
73+
*
74+
* A bare DOI (no scheme) is not resolvable on its own, so it is prefixed with the configured
75+
* `identifier.doi.resolver`. Only `dc.identifier.doi` is treated that way: this component also
76+
* renders `dc.identifier.uri`, `coar.notify.endorsedBy` and three `datacite.relation.*` fields,
77+
* and prefixing a bare value of any of those with a DOI resolver would corrupt the link.
78+
*/
79+
getUriMetadataValues(): MetadataValue[] {
80+
const mdValues: MetadataValue[] = this.item?.allMetadata(this.fields);
81+
82+
if (isEmpty(mdValues) || !this.fields?.includes(DOI_METADATA_FIELD)) {
83+
return mdValues;
84+
}
85+
86+
// allMetadata() returns readonly values, so build new ones rather than mutating them.
87+
return mdValues.map((mdValue: MetadataValue) =>
88+
Object.assign(new MetadataValue(), mdValue, { value: this.resolveDoi(mdValue.value) }));
89+
}
90+
91+
/**
92+
* Prefix a bare DOI with the resolver. A value that already carries a scheme is left alone.
93+
*/
94+
private resolveDoi(value: string): string {
95+
if (isEmpty(value) || /^https?:\/\//.test(value) || isEmpty(this.doiResolver)) {
96+
return value;
97+
}
98+
return `${this.doiResolver.replace(/\/+$/, '')}/${value}`;
99+
}
100+
44101
}

src/app/shared/auth-nav-menu/auth-nav-menu.component.scss

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,7 @@
1212
background-color: transparent !important;
1313
}
1414

15-
.loginLink, .dropdownLogin, .logoutLink, .dropdownLogout {
15+
.loginLink, .dropdownLogin, .logoutLink, .dropdownLogout, .dropdown-toggle {
1616
color: var(--ds-header-icon-color);
1717

1818
&:hover, &:focus {
Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,10 @@
11
:host {
22
width: 100%;
33
}
4+
5+
::ng-deep .toggleIcons {
6+
position: absolute;
7+
top: 5px;
8+
right: 5px;
9+
cursor: pointer;
10+
}
Lines changed: 19 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,17 +1,33 @@
1-
<ds-truncatable-part [maxLines]="1" [background]="isCurrent() ? 'primary' : 'default'" [showToggle]="false">
1+
<ds-truncatable-part [externalToggle]="true" (truncated)="onTruncatedStateChange(1, $event)" [maxLines]="1" [background]="isCurrent() ? 'primary' : 'default'" [showToggle]="false">
22
<div [ngClass]="isCurrent() ? 'text-light' : 'text-body'"
33
[innerHTML]="(parentTitle$ && parentTitle$ | async) ? (parentTitle$ | async) : ('home.breadcrumbs' | translate)"></div>
44
</ds-truncatable-part>
5-
<ds-truncatable-part [maxLines]="1" [background]="isCurrent() ? 'primary' : 'default'" [showToggle]="false">
5+
<ds-truncatable-part [externalToggle]="true" (truncated)="onTruncatedStateChange(2, $event)" [maxLines]="1" [background]="isCurrent() ? 'primary' : 'default'" [showToggle]="false">
66
<div class="fw-bold"
77
[ngClass]="isCurrent() ? 'text-light' : 'text-primary'"
88
[innerHTML]="dsoTitle"></div>
99
</ds-truncatable-part>
1010
@if (description) {
11-
<ds-truncatable-part [maxLines]="1" [background]="isCurrent() ? 'primary' : 'default'" [showToggle]="false">
11+
<ds-truncatable-part [externalToggle]="true" (truncated)="onTruncatedStateChange(3, $event)" [maxLines]="1" [background]="isCurrent() ? 'primary' : 'default'" [showToggle]="false">
1212
<div class="text-secondary"
1313
[ngClass]="isCurrent() ? 'text-light' : 'text-secondary'"
1414
[attr.lang]="descriptionLang || null"
1515
[innerHTML]="description"></div>
1616
</ds-truncatable-part>
1717
}
18+
@if (expandable) {
19+
<div class="toggleIcons">
20+
@if (!expanded) {
21+
<i (click)="toggleView($event, true)"
22+
class="fa fa-angle-down p-1"
23+
aria-hidden="true"
24+
[title]="'sidebar.expand.all' | translate"></i>
25+
}
26+
@if (expanded) {
27+
<i (click)="toggleView($event, false)"
28+
class="fa fa-angle-up p-1"
29+
aria-hidden="true"
30+
[title]="'sidebar.collapse.all' | translate"></i>
31+
}
32+
</div>
33+
}

src/app/shared/object-list/sidebar-search-list-element/sidebar-search-list-element.component.spec.ts

Lines changed: 44 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,12 @@
1-
import { NO_ERRORS_SCHEMA } from '@angular/core';
1+
import {
2+
NO_ERRORS_SCHEMA,
3+
QueryList,
4+
} from '@angular/core';
25
import {
36
ComponentFixture,
7+
fakeAsync,
48
TestBed,
9+
tick,
510
waitForAsync,
611
} from '@angular/core/testing';
712
import { RouterTestingModule } from '@angular/router/testing';
@@ -16,6 +21,7 @@ import { mockTruncatableService } from '../../mocks/mock-trucatable.service';
1621
import { createSuccessfulRemoteDataObject$ } from '../../remote-data.utils';
1722
import { SearchResult } from '../../search/models/search-result.model';
1823
import { TruncatableService } from '../../truncatable/truncatable.service';
24+
import { TruncatablePartComponent } from '../../truncatable/truncatable-part/truncatable-part.component';
1925
import { VarDirective } from '../../utils/var.directive';
2026

2127
export function createSidebarSearchListElementTests(
@@ -73,5 +79,42 @@ export function createSidebarSearchListElementTests(
7379
it('should contain the correct description', () => {
7480
expect(component.description).toEqual(expectedDescription);
7581
});
82+
83+
it('should expand every truncatable part at once and not open the item itself', () => {
84+
const parts = [1, 2, 3].map(() => new TruncatablePartComponent(mockTruncatableService as any));
85+
const queryList = new QueryList<TruncatablePartComponent>();
86+
queryList.reset(parts);
87+
component.truncatableComponents = queryList;
88+
const event = jasmine.createSpyObj('event', ['stopPropagation']);
89+
90+
component.toggleView(event, true);
91+
92+
expect(event.stopPropagation).toHaveBeenCalled();
93+
expect(component.expanded).toBeTrue();
94+
parts.forEach((part) => expect(part.lines).toEqual('none'));
95+
});
96+
97+
it('should collapse every truncatable part again', () => {
98+
const parts = [1, 2, 3].map(() => new TruncatablePartComponent(mockTruncatableService as any));
99+
parts.forEach((part) => part.toggleWithoutId(true));
100+
const queryList = new QueryList<TruncatablePartComponent>();
101+
queryList.reset(parts);
102+
component.truncatableComponents = queryList;
103+
104+
component.toggleView(jasmine.createSpyObj('event', ['stopPropagation']), false);
105+
106+
expect(component.expanded).toBeFalse();
107+
parts.forEach((part) => expect(part.lines).toEqual('1'));
108+
});
109+
110+
it('should only offer the expand-all control once a part reports itself truncated', fakeAsync(() => {
111+
component.onTruncatedStateChange(1, false);
112+
tick();
113+
expect(component.expandable).toBeFalse();
114+
115+
component.onTruncatedStateChange(2, true);
116+
tick();
117+
expect(component.expandable).toBeTrue();
118+
}));
76119
};
77120
}

0 commit comments

Comments
 (0)