Skip to content

Commit 508977c

Browse files
milanmajchrakjr-rkclaudemilanmajchrak
authored
UoE/WP2-2026-07-09 bugfixes (#6)
* feat: show file checksum instead of format in full item view Replace the per-file "Format" line in the full item view file listing with a "File checksum" line (algorithm + value). Add the checkSum field to the Bitstream model so the REST value deserializes, and add the item.page.filesection.checksum i18n key. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * refactor: guard checksum display and show value only Wrap the full item view checksum dt/dd in *ngIf="file?.checkSum" and render only the checksum value (drop the algorithm prefix), so no stray colon shows when a bitstream has no checksum. Propagate item.page.filesection.checksum to all locale files with an English placeholder. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * refactor: align Bitstream checkSum with upstream ChecksumInfo Match DSpace/dspace-angular@main (commit 4f72074): use the exported ChecksumInfo interface and `checkSum: ChecksumInfo` positioned after `description`, replacing the inline anonymous type, to minimize conflicts on a future DSpace 9 migration. Display and i18n key are unchanged (upstream renders no checksum row in the full item view). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * revert: drop non-en filesection.checksum i18n key UoE instance only ships English; other locale .json5 files aren't maintained here. Revert the checksum key addition to all 27 non-en locales, keep it in en.json5 only. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * refactor: drop redundant optional chaining on checkSum file is guaranteed by *ngFor and checkSum is truthy inside the *ngIf guard, so use plain file.checkSum / file.checkSum.value to match the surrounding template style. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * fix: changed - File checksum: - to - Checksum: - * fix: added checksum algorithm (same style as upstream) * Fix submission stuck on "Saving..." by serializing save effects (switchMap -> concatMap) Backport of the save-effect change from upstream PR DSpace#5145 (commit a3c14c5, first released in DSpace 10.0; not present in 8.x/9.x). The four submission save effects used switchMap. When two save actions of the same type overlap (common during editing: unguarded section/upload/save-for-later saves, save-on-change on validation errors, or an enabled autosave timer), switchMap cancels the in-flight save's inner observable AFTER it dispatched StartTransactionPatchOperationsAction (commitPending=true) but BEFORE its response handler runs. Commit/Rollback are dispatched only inside that cancelled observable, so commitPending stays true forever; the next save is then dropped by the take(1)+filter(!commitPending) guard in submitJsonPatchOperations, no SAVE_*_SUCCESS/ERROR is emitted, savePending stays true, and the form is stuck on "Saving..." until the user reloads (losing unsaved metadata). concatMap serializes the save effects so each inner observable completes (dispatching Commit/Rollback and clearing commitPending) before the next save runs, eliminating the race. Only the four save effects are changed (saveSubmission$, saveForLaterSubmission$, saveSection$, saveAndDeposit$), matching upstream. deposit/discard/updateSection intentionally keep switchMap. The improved catchError from DSpace#5145 is not backported (it depends on parseErrorResponse and action signatures not present in 8.3). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * Harden submission form/vocabulary loading against permanent stuck spinners Fixes a class of "spinner spins forever, user must reload and loses typed metadata" bugs in the submission form. A component sets a loading/searching flag, loads data via a RemoteData pipe, and resets the flag ONLY on the success path using getFirstSucceededRemoteData()/...Payload()/...ListPayload(). DSpace turns HTTP errors into a NON-throwing failed RemoteData (state=Error), and the store-backed RemoteData observable never completes, so on a failed OR slow/ never-responding request the success-only operator never emits, the component's catchError (which only catches thrown errors) never fires, and the spinner stays forever. There is also no HTTP request timeout anywhere in the app. Inherited from vanilla DSpace 8.3 and NOT fixed upstream (the same pattern is still present on main/10.x), so there is no backport available -- this is a new hardening fix, following the same getFirstCompletedRemoteData approach upstream used for the whole-form loading fix (DSpace#4060). For each affected load: switch getFirstSucceededRemoteData* to getFirstCompletedRemoteData() (emits on success AND failure, then completes), extract the payload failure-safely, add timeout({ each: 30000 }) for never-responding requests, and reset the spinner flag in finalize() so it always clears. On failure/timeout the control shows an empty result instead of hanging. Sites fixed (all inherited from 8.3): - sections/form/section-form.component.ts (Describe section) -- bounded 30s timeout so it never spins forever; kept getFirstSucceededRemoteData for the item follow-link to avoid dereferencing an undefined submission object. - ds-dynamic-form-ui/models/list/dynamic-list.component.ts (radio/checkbox vocab, e.g. dc.type) - ds-dynamic-form-ui/models/scrollable-dropdown/dynamic-scrollable-dropdown.component.ts (vocabulary dropdowns) - ds-dynamic-form-ui/models/onebox/dynamic-onebox.component.ts (authority typeahead) - ds-dynamic-form-ui/models/tag/dynamic-tag.component.ts (keyword/tag authority) - ds-dynamic-form-ui/models/lookup/dynamic-lookup.component.ts (lookup authority) - vocabulary-treeview/vocabulary-treeview.service.ts (hierarchical vocabulary tree) - collection-dropdown/collection-dropdown.component.ts (collection picker; parentCommunity was hanging reduce()) - submission/form/collection/submission-form-collection.component.ts (change-collection selector) - sections/cc-license/submission-section-cc-licenses.component.ts (CC-license section) - ds-dynamic-form-ui/models/relation-group/dynamic-relation-group.components.ts (relation chips on edit) Verified locally: `tsc --noEmit -p tsconfig.app.json` and `eslint` both pass with 0 errors on the changed files. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * docs(repro): add before/after reproduction of the dropdown loading hang Deterministic reproduction on a local DSpace 8.3 stack (published dspace-8_x-test backend images + this frontend). A Playwright script simulates a failing/slow controlled-vocabulary endpoint by returning HTTP 500 for /api/submission/vocabularies/{name}/entries, then opens the "Type" dropdown on a new submission's Describe form. - before: getFirstSucceededRemoteData ignores the failed RemoteData, so the "Loading..." indicator stays visible forever (measured stuck for the full 14s observation window). - after: getFirstCompletedRemoteData + finalize handles the failed RemoteData, the spinner clears immediately and the dropdown shows "No results found". Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * docs(repro): add before/after reproduction of the stuck "Saving..." hang Deterministic reproduction on a local DSpace 8.3 stack. A Playwright script delays every submission PATCH by ~3s (models a slow save) and fires two overlapping autosaves (two edits of the autosave-on-change dc.title field). - before (base, switchMap): the second save cancels the in-flight first save mid-transaction; commitPending is orphaned true; the second save is dropped by the take(1)+filter(!commitPending) guard (only 1 PATCH ever leaves the client); savePending never resets -> footer stuck on "Saving..." for the full 18s window. - after (this PR, concatMap): the saves are serialized; the first completes and clears commitPending before the second runs; "Saving..." clears within ~4s. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * docs(repro): add never-responding-backend (timeout backstop) before/after Second reproduction covering the slow/never-responding path (not just HTTP 500). The vocabulary /entries request is held open forever (models a hung socket). - before (no timeout): "Loading..." stays stuck past 45s (never recovers). - after (timeout({each:30000})): the spinner clears ~30s after the request stalls and the dropdown shows "No results found". Clips are sped up ~3x. Confirms the timeout() backstop half of the fix, which the HTTP-500 reproduction did not exercise. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(item-page): show DOI field on simple item view while DOI registration is pending The DataShare simple item view renders the DOI ("Persistent Identifier") field via ds-item-page-uri-field, which filtered dc.identifier.uri for values starting with https://doi.org. The whole field wrapper was gated behind *ngIf="hasDoiLink", so when a record's DOI has not been registered yet (the DOI value is only written to dc.identifier.uri by the backend AFTER the doi-organiser scheduled task registers it), the field was hidden entirely and users saw no indication that a DOI exists or is coming. Restore the previous behaviour where the DOI field is always shown (with an empty value while registration is pending): - MetadataUriValuesComponent / ItemPageUriFieldComponent: add an opt-in `doiField` input. In DOI mode the field wrapper is always rendered (hideIfNoTextContent = false) and only https://doi.org values are shown as links. When false (default) the upstream generic behaviour is kept, which also repairs non-DOI URI fields in the base theme that were unintentionally hidden by the previous global filter. - datashare untyped-item: opt the DOI field into `[doiField]="true"`. Test-driven: added specs for the pending (no DOI yet), registered, and empty cases, plus the default (non-DOI) URI behaviour. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * Reduce vocab-load timeout to 15s + show a user-friendly error notification Follow-up to the loading-spinner hardening: - Lower the request timeout backstop from 30s to 15s on all the hardened loads (a 30s spinner is too long for an interactive dropdown). - On a failed OR timed-out controlled-vocabulary / authority lookup, show a dismissible error toast ("Something went wrong while loading the options. Please try again.") instead of silently showing an empty "No results found", so the user knows to retry. Added a shared notifyVocabularyLoadError() helper on the DsDynamicVocabularyComponent base (via inject()) and wired it into the scrollable-dropdown and lookup loads. A genuinely empty (succeeded) result is unchanged -> still "No results found", no error toast. - Harden dynamic-onebox vocabulary$ (findVocabularyById): getFirstSucceededRemoteDataPayload -> getFirstCompletedRemoteData, so a failed vocabulary-metadata lookup resolves to "not hierarchical" (no tree button) instead of leaving isHierarchicalVocabulary$ pending forever. Null-safe guards on result?.hierarchical and vocabulary?.preloadLevel. tsc --noEmit and eslint pass with 0 errors. Verified in the local repro: on a 500, the Type dropdown now clears immediately, shows "No results found", AND a red "Something went wrong ... Please try again." toast appears. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * docs(repro): refresh dropdown "after" clip to show the 15s + error-toast behaviour The after clip now shows the updated recovery: on a failed vocabulary load the Type dropdown clears immediately, shows "No results found", and a dismissible "Something went wrong ... Please try again." error toast appears. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * docs(repro): refresh never-responding after clip for the 15s timeout (was 30s) Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * refactor(item-page): collapse DOI/default URI rendering into a single wrapper Address review nits on the pending-DOI fix without changing behaviour: - Merge the doiField and default template branches into one ds-metadata-field-wrapper ([hideIfNoTextContent]="!doiField"), removing the duplicated link markup. - Add a `doiValues` getter that filters mdValues to https://doi.org values (restoring the `typeof value === 'string'` guard). In DOI mode the *ngFor and the `!last` separator are computed against this visible-DOI subset, so a trailing non-DOI value can no longer emit a stray separator after the last DOI. - Tighten the registered-case spec to assert the label header, and add a regression spec for multiple DOIs followed by a non-DOI value (only one separator between the DOIs). Verified locally: metadata-uri-values + item-page-uri-field specs 23/23 green, lint clean, build:prod (SSR production build) exits 0. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * test: provide NotificationsService stub in vocabulary component specs The DsDynamicVocabularyComponent base now injects NotificationsService (for the "something went wrong" error notification on a failed vocabulary load). Its subclasses' unit tests (scrollable-dropdown, onebox, tag, lookup) did not provide it, so component construction failed with "NullInjectorError: NotificationsService -> Store -> No provider for Store" (NotificationsService is providedIn root and depends on the ngrx Store, which the isolated TestBeds don't set up). Provide the existing NotificationsServiceStub in each of the four specs. Verified locally: those 4 spec files now run 54/54 SUCCESS (were 54 FAILED). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * chore: remove reproduction media from the code branch The before/after clips are now hosted on the separate 'pr-repro-media' branch and referenced from this PR's description, so they are no longer part of the code diff. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * chore: remove reproduction media from the code branch The before/after clips are now hosted on the separate 'pr-repro-media' branch and referenced from this PR's description, so they are no longer part of the code diff. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * feat(item-page): load DOI resolver from backend config instead of hard-coding it The DOI field decides which dc.identifier.uri values are DOIs by matching the DOI resolver base URL. That URL is a backend setting (identifier.doi.resolver), so read it from the REST config endpoint instead of hard-coding "https://doi.org" in the UI. - MetadataUriValuesComponent now fetches identifier.doi.resolver via ConfigurationDataService on init (only in doiField mode) and uses it in doiValues. Falls back to the DSpace default https://doi.org (DOIServiceImpl#RESOLVER_DEFAULT) when the property is not exposed/defined, so it stays correct without any backend change. - Added a spec proving a value matching a custom configured resolver is treated as a DOI, and provided ConfigurationDataService stubs to the dependent specs (item-page-uri-field, publication, untyped-item). Requires the backend to expose identifier.doi.resolver in rest.properties.exposed (separate backend PR); until then the frontend uses the default resolver. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * feat(item-page): show "DOI registration in progress" while a DOI is pending Instead of showing an empty DOI ("Persistent Identifier") field while the DOI is still queued for registration by the scheduled task, show a "DOI registration in progress" message. Once registration completes and the resolver value appears in dc.identifier.uri, the DOI link is shown instead. - metadata-uri-values: in doiField mode, render the item.page.doi.pending message when there is no DOI value yet (doiValues is empty). - add the item.page.doi.pending i18n key to the datashare theme and core en.json5. - specs: assert the message is shown when pending / no identifier, and NOT shown once a DOI is present. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Juraj Roka <95219754+jr-rk@users.noreply.github.com> Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com> Co-authored-by: milanmajchrak <minptai7@gmail.com>
1 parent 0817ead commit 508977c

30 files changed

Lines changed: 512 additions & 96 deletions

src/app/core/shared/bitstream.model.ts

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,11 @@ import { ChildHALResource } from './child-hal-resource.model';
2121
import { DSpaceObject } from './dspace-object.model';
2222
import { HALLink } from './hal-link.model';
2323

24+
export interface ChecksumInfo {
25+
checkSumAlgorithm: string;
26+
value: string;
27+
}
28+
2429
@typedObject
2530
@inheritSerialization(DSpaceObject)
2631
export class Bitstream extends DSpaceObject implements ChildHALResource {
@@ -38,6 +43,12 @@ export class Bitstream extends DSpaceObject implements ChildHALResource {
3843
@autoserialize
3944
description: string;
4045

46+
/**
47+
* The checksum information of this Bitstream
48+
*/
49+
@autoserialize
50+
checkSum: ChecksumInfo;
51+
4152
/**
4253
* The name of the Bundle this Bitstream is part of
4354
*/
Lines changed: 18 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -1,15 +1,20 @@
11
<!-- DATASHARE - start -->
2-
<!-- Added *ngIf="hasDoiLink" and *ngIf="mdValue.value.startsWith('https://doi.org')" to only render DOIs-->
3-
<ds-metadata-field-wrapper *ngIf="hasDoiLink" [label]="label | translate">
4-
<!-- <a class="dont-break-out" *ngFor="let mdValue of mdValues; let last=last;" [href]="mdValue.value" [target]="hasInternalLink(mdValue.value) ? '_self' : '_blank'" role="link" tabindex="0">
5-
{{ linktext || mdValue.value }}<span *ngIf="!last" [innerHTML]="separator"></span>
6-
</a> -->
7-
<ng-container *ngFor="let mdValue of mdValues; let last=last;">
8-
<a class="dont-break-out" *ngIf="mdValue.value.startsWith('https://doi.org')" [href]="mdValue.value"
9-
[target]="hasInternalLink(mdValue.value) ? '_self' : '_blank'" role="link" tabindex="0">
10-
{{ linktext || mdValue.value }}
11-
<span *ngIf="!last" [innerHTML]="separator"></span>
12-
</a>
13-
</ng-container>
2+
<!--
3+
doiField mode (the DOI / "Persistent Identifier" field on the simple item view):
4+
- only DOI values (starting with the configured resolver, see doiValues) are shown as links;
5+
the handle is hidden;
6+
- hideIfNoTextContent is disabled, so the field/label is always shown;
7+
- while a DOI is still queued for registration by the scheduled task (no DOI value yet), a
8+
"DOI registration in progress" message is shown instead of an empty value. Once registration
9+
completes the DOI link is shown instead.
10+
Default mode (doiField = false) keeps the upstream generic behaviour: every URI value is rendered
11+
as a link and the field is hidden when it has no value.
12+
-->
13+
<ds-metadata-field-wrapper [label]="label | translate" [hideIfNoTextContent]="!doiField">
14+
<a class="dont-break-out" *ngFor="let mdValue of (doiField ? doiValues : mdValues); let last=last;"
15+
[href]="mdValue.value" [target]="hasInternalLink(mdValue.value) ? '_self' : '_blank'" role="link" tabindex="0">
16+
{{ linktext || mdValue.value }}<span *ngIf="!last" [innerHTML]="separator"></span>
17+
</a>
18+
<span *ngIf="doiField && doiValues.length === 0">{{ 'item.page.doi.pending' | translate }}</span>
1419
</ds-metadata-field-wrapper>
15-
<!-- DATASHARE - end -->
20+
<!-- DATASHARE - end -->

src/app/item-page/field-components/metadata-uri-values/metadata-uri-values.component.spec.ts

Lines changed: 155 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,9 +16,12 @@ import {
1616

1717
import { APP_CONFIG } from '../../../../config/app-config.interface';
1818
import { environment } from '../../../../environments/environment';
19+
import { ConfigurationDataService } from '../../../core/data/configuration-data.service';
20+
import { ConfigurationProperty } from '../../../core/shared/configuration-property.model';
1921
import { MetadataValue } from '../../../core/shared/metadata.models';
2022
import { isNotEmpty } from '../../../shared/empty.util';
2123
import { TranslateLoaderMock } from '../../../shared/mocks/translate-loader.mock';
24+
import { createSuccessfulRemoteDataObject$ } from '../../../shared/remote-data.utils';
2225
import { MetadataUriValuesComponent } from './metadata-uri-values.component';
2326

2427
let comp: MetadataUriValuesComponent;
@@ -38,8 +41,18 @@ const mockSeperator = '<br/>';
3841
const mockLabel = 'fake.message';
3942
const mockLinkText = 'fake link text';
4043

44+
// Controls what the (stubbed) backend returns for the identifier.doi.resolver config property.
45+
// Empty => the component falls back to its default resolver (https://doi.org).
46+
let doiResolverConfigValues: string[] = [];
47+
const configurationServiceStub = {
48+
findByPropertyName: (name: string) => createSuccessfulRemoteDataObject$(
49+
Object.assign(new ConfigurationProperty(), { name, values: doiResolverConfigValues }),
50+
),
51+
};
52+
4153
describe('MetadataUriValuesComponent', () => {
4254
beforeEach(waitForAsync(() => {
55+
doiResolverConfigValues = [];
4356
TestBed.configureTestingModule({
4457
imports: [TranslateModule.forRoot({
4558
loader: {
@@ -49,6 +62,7 @@ describe('MetadataUriValuesComponent', () => {
4962
}), MetadataUriValuesComponent],
5063
providers: [
5164
{ provide: APP_CONFIG, useValue: environment },
65+
{ provide: ConfigurationDataService, useValue: configurationServiceStub },
5266
],
5367
schemas: [NO_ERRORS_SCHEMA],
5468
}).overrideComponent(MetadataUriValuesComponent, {
@@ -98,6 +112,147 @@ describe('MetadataUriValuesComponent', () => {
98112

99113
});
100114

115+
// DATASHARE - start
116+
// The DOI / "Persistent Identifier" field on the simple item view is rendered through this
117+
// component. When a record is created its DOI is only registered asynchronously by a scheduled
118+
// task, so for a while the item has no https://doi.org value yet. The field must still be shown
119+
// (with an empty value) so users can see that a DOI exists / is pending, matching the behaviour
120+
// of the previous DataShare release.
121+
describe('when used as a DOI field (doiField = true)', () => {
122+
123+
describe('and a registered DOI is present', () => {
124+
beforeEach(() => {
125+
comp.doiField = true;
126+
comp.mdValues = [
127+
{ language: 'en_US', value: 'https://hdl.handle.net/123456789/99' },
128+
{ language: 'en_US', value: 'https://doi.org/10.1234/registered' },
129+
] as MetadataValue[];
130+
fixture.detectChanges();
131+
});
132+
133+
it('should render the field wrapper and show the label', () => {
134+
const wrapper = fixture.debugElement.query(By.css('.simple-view-element'));
135+
expect(wrapper).not.toBeNull();
136+
expect(wrapper.nativeElement.classList).not.toContain('d-none');
137+
expect(fixture.debugElement.query(By.css('.simple-view-element-header'))).not.toBeNull();
138+
});
139+
140+
it('should render only the DOI value as a link (not the handle)', () => {
141+
const links = fixture.debugElement.queryAll(By.css('a'));
142+
expect(links.length).toBe(1);
143+
expect(links[0].nativeElement.getAttribute('href')).toBe('https://doi.org/10.1234/registered');
144+
});
145+
146+
it('should not show the "registration in progress" message once a DOI is present', () => {
147+
expect(fixture.nativeElement.textContent).not.toContain('item.page.doi.pending');
148+
});
149+
});
150+
151+
describe('and the DOI has not been registered yet (scheduled task pending)', () => {
152+
beforeEach(() => {
153+
comp.doiField = true;
154+
// Only a handle is present, the DOI is still queued for registration by the CRON job
155+
comp.mdValues = [
156+
{ language: 'en_US', value: 'https://hdl.handle.net/123456789/99' },
157+
] as MetadataValue[];
158+
fixture.detectChanges();
159+
});
160+
161+
it('should still display the DOI field (label visible) even without a DOI link', () => {
162+
const wrapper = fixture.debugElement.query(By.css('.simple-view-element'));
163+
expect(wrapper).not.toBeNull();
164+
expect(wrapper.nativeElement.classList).not.toContain('d-none');
165+
expect(fixture.debugElement.query(By.css('.simple-view-element-header'))).not.toBeNull();
166+
});
167+
168+
it('should not render the non-DOI (handle) value as a link', () => {
169+
expect(fixture.debugElement.queryAll(By.css('a')).length).toBe(0);
170+
});
171+
172+
it('should show a "DOI registration in progress" message instead of an empty value', () => {
173+
expect(fixture.nativeElement.textContent).toContain('item.page.doi.pending');
174+
});
175+
});
176+
177+
describe('and the item has no identifier metadata at all', () => {
178+
beforeEach(() => {
179+
comp.doiField = true;
180+
comp.mdValues = [] as MetadataValue[];
181+
fixture.detectChanges();
182+
});
183+
184+
it('should still display the DOI field wrapper with the "registration in progress" message', () => {
185+
const wrapper = fixture.debugElement.query(By.css('.simple-view-element'));
186+
expect(wrapper).not.toBeNull();
187+
expect(wrapper.nativeElement.classList).not.toContain('d-none');
188+
expect(fixture.nativeElement.textContent).toContain('item.page.doi.pending');
189+
});
190+
});
191+
192+
describe('and multiple DOIs are present followed by a non-DOI value', () => {
193+
beforeEach(() => {
194+
comp.doiField = true;
195+
comp.separator = '<br/>';
196+
comp.mdValues = [
197+
{ language: 'en_US', value: 'https://doi.org/10.1234/one' },
198+
{ language: 'en_US', value: 'https://doi.org/10.5678/two' },
199+
{ language: 'en_US', value: 'https://hdl.handle.net/123456789/99' },
200+
] as MetadataValue[];
201+
fixture.detectChanges();
202+
});
203+
204+
it('should render only the DOI values as links', () => {
205+
expect(fixture.debugElement.queryAll(By.css('a')).length).toBe(2);
206+
});
207+
208+
it('should only put a separator between the DOIs, not a trailing one after the last DOI', () => {
209+
// exactly one separator between the two visible DOIs (computed against the DOI subset,
210+
// not the full metadata array, so the trailing handle cannot add a stray separator)
211+
expect(fixture.debugElement.queryAll(By.css('a span')).length).toBe(1);
212+
});
213+
});
214+
215+
describe('and a custom DOI resolver is configured in the backend (identifier.doi.resolver)', () => {
216+
beforeEach(() => {
217+
// The backend resolver is not the default https://doi.org
218+
doiResolverConfigValues = ['https://doi.example.org'];
219+
// Re-create the component so ngOnInit reads the configured resolver with doiField already set
220+
fixture = TestBed.createComponent(MetadataUriValuesComponent);
221+
comp = fixture.componentInstance;
222+
comp.doiField = true;
223+
comp.label = mockLabel;
224+
comp.mdValues = [
225+
{ language: 'en_US', value: 'https://doi.example.org/10.1234/configured' },
226+
{ language: 'en_US', value: 'https://doi.org/10.5678/default-resolver' },
227+
] as MetadataValue[];
228+
fixture.detectChanges();
229+
});
230+
231+
it('should treat values matching the configured resolver as DOIs', () => {
232+
const links = fixture.debugElement.queryAll(By.css('a'));
233+
expect(links.length).toBe(1);
234+
expect(links[0].nativeElement.getAttribute('href')).toBe('https://doi.example.org/10.1234/configured');
235+
});
236+
});
237+
});
238+
239+
describe('when NOT used as a DOI field (doiField = false, the default)', () => {
240+
beforeEach(() => {
241+
comp.doiField = false;
242+
comp.mdValues = [
243+
{ language: 'en_US', value: 'https://example.com/endorsement' },
244+
] as MetadataValue[];
245+
fixture.detectChanges();
246+
});
247+
248+
it('should render every URI value as a link (upstream behaviour)', () => {
249+
const links = fixture.debugElement.queryAll(By.css('a'));
250+
expect(links.length).toBe(1);
251+
expect(links[0].nativeElement.getAttribute('href')).toBe('https://example.com/endorsement');
252+
});
253+
});
254+
// DATASHARE - end
255+
101256
});
102257

103258
function containsHref(links: DebugElement[], href: string): boolean {

src/app/item-page/field-components/metadata-uri-values/metadata-uri-values.component.ts

Lines changed: 71 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -3,15 +3,38 @@ import {
33
NgIf,
44
} from '@angular/common';
55
import {
6+
ChangeDetectorRef,
67
Component,
8+
Inject,
79
Input,
10+
OnInit,
811
} from '@angular/core';
912
import { TranslateModule } from '@ngx-translate/core';
1013

14+
import {
15+
APP_CONFIG,
16+
AppConfig,
17+
} from '../../../../config/app-config.interface';
18+
import { ConfigurationDataService } from '../../../core/data/configuration-data.service';
1119
import { MetadataValue } from '../../../core/shared/metadata.models';
20+
import { getFirstCompletedRemoteData } from '../../../core/shared/operators';
21+
import { isNotEmpty } from '../../../shared/empty.util';
1222
import { MetadataFieldWrapperComponent } from '../../../shared/metadata-field-wrapper/metadata-field-wrapper.component';
1323
import { MetadataValuesComponent } from '../metadata-values/metadata-values.component';
1424

25+
// DATASHARE - start
26+
/**
27+
* Default DOI resolver, used when the backend does not expose/define {@link DOI_RESOLVER_PROPERTY}.
28+
* Kept in sync with the DSpace default (DOIServiceImpl#RESOLVER_DEFAULT).
29+
*/
30+
export const DEFAULT_DOI_RESOLVER = 'https://doi.org';
31+
32+
/**
33+
* Backend configuration property holding the DOI resolver base URL.
34+
*/
35+
export const DOI_RESOLVER_PROPERTY = 'identifier.doi.resolver';
36+
// DATASHARE - end
37+
1538
/**
1639
* This component renders the configured 'values' into the ds-metadata-field-wrapper component as a link.
1740
* It puts the given 'separator' between each two values
@@ -31,7 +54,7 @@ import { MetadataValuesComponent } from '../metadata-values/metadata-values.comp
3154
],
3255
standalone: true,
3356
})
34-
export class MetadataUriValuesComponent extends MetadataValuesComponent {
57+
export class MetadataUriValuesComponent extends MetadataValuesComponent implements OnInit {
3558

3659
/**
3760
* Optional text to replace the links with
@@ -55,9 +78,53 @@ export class MetadataUriValuesComponent extends MetadataValuesComponent {
5578
@Input() label: string;
5679

5780
// DATASHARE - start
58-
// get makes it accessible from the template as a property.
59-
get hasDoiLink(): boolean {
60-
return this.mdValues?.some(v => typeof v.value === 'string' && v.value.startsWith('https://doi.org'));
81+
/**
82+
* When true, this component renders a DOI ("Persistent Identifier") field:
83+
* - only DOI values (starting with the configured {@link doiResolver}) are rendered as links
84+
* (the handle is hidden);
85+
* - the field label/wrapper is always shown, even while a DOI is still queued for
86+
* registration by the scheduled task (i.e. no DOI value is present yet),
87+
* so users can see that a DOI exists / is pending.
88+
* When false (the default) the upstream generic behaviour is kept: every URI value is
89+
* rendered as a link and the field is hidden when it has no value.
90+
*/
91+
@Input() doiField = false;
92+
93+
/**
94+
* The DOI resolver base URL. Loaded from the backend configuration ({@link DOI_RESOLVER_PROPERTY})
95+
* so that the same value drives the frontend as the backend, instead of hard-coding it here.
96+
* Falls back to {@link DEFAULT_DOI_RESOLVER} when the property is not exposed/defined.
97+
*/
98+
doiResolver = DEFAULT_DOI_RESOLVER;
99+
100+
constructor(
101+
@Inject(APP_CONFIG) appConfig: AppConfig,
102+
private configurationService: ConfigurationDataService,
103+
private cdr: ChangeDetectorRef,
104+
) {
105+
super(appConfig);
106+
}
107+
108+
ngOnInit(): void {
109+
if (this.doiField) {
110+
this.configurationService.findByPropertyName(DOI_RESOLVER_PROPERTY).pipe(
111+
getFirstCompletedRemoteData(),
112+
).subscribe((rd) => {
113+
if (rd.hasSucceeded && isNotEmpty(rd.payload?.values)) {
114+
this.doiResolver = rd.payload.values[0];
115+
this.cdr.markForCheck();
116+
}
117+
});
118+
}
119+
}
120+
121+
/**
122+
* The DOI values (starting with {@link doiResolver}) among {@link mdValues}. Used in
123+
* {@link doiField} mode so that only DOIs are shown as links and the separator is computed
124+
* against the visible DOIs only.
125+
*/
126+
get doiValues(): MetadataValue[] {
127+
return (this.mdValues ?? []).filter(v => typeof v.value === 'string' && v.value.startsWith(this.doiResolver));
61128
}
62129
// DATASHARE - end
63130
}

src/app/item-page/full/field-components/file-section/full-file-section.component.html

Lines changed: 8 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -23,8 +23,10 @@ <h3 class="h5 simple-view-element-header">{{"item.page.filesection.original.bund
2323
<dd class="col-md-8">{{(file.sizeBytes) | dsFileSize }}</dd>
2424

2525

26-
<dt class="col-md-4">{{"item.page.filesection.format" | translate}}</dt>
27-
<dd class="col-md-8">{{(file.format | async)?.payload?.description}}</dd>
26+
<ng-container *ngIf="file.checkSum">
27+
<dt class="col-md-4">{{"item.page.filesection.checksum" | translate}}</dt>
28+
<dd class="col-md-8">({{ file.checkSum.checkSumAlgorithm }}):{{ file.checkSum.value }}</dd>
29+
</ng-container>
2830

2931
<ng-container *ngIf="file.hasMetadata('dc.description')">
3032
<dt class="col-md-4">{{"item.page.filesection.description" | translate}}</dt>
@@ -64,8 +66,10 @@ <h3 class="h5 simple-view-element-header">{{"item.page.filesection.license.bundl
6466
<dt class="col-md-4">{{"item.page.filesection.size" | translate}}</dt>
6567
<dd class="col-md-8">{{(file.sizeBytes) | dsFileSize }}</dd>
6668

67-
<dt class="col-md-4">{{"item.page.filesection.format" | translate}}</dt>
68-
<dd class="col-md-8">{{(file.format | async)?.payload?.description}}</dd>
69+
<ng-container *ngIf="file.checkSum">
70+
<dt class="col-md-4">{{"item.page.filesection.checksum" | translate}}</dt>
71+
<dd class="col-md-8">({{ file.checkSum.checkSumAlgorithm }}):{{ file.checkSum.value }}</dd>
72+
</ng-container>
6973

7074

7175
<dt class="col-md-4">{{"item.page.filesection.description" | translate}}</dt>
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]="item?.allMetadata(fields)" [separator]="separator" [label]="label" [doiField]="doiField"></ds-metadata-uri-values>
33
</div>

0 commit comments

Comments
 (0)