Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
22 commits
Select commit Hold shift + click to select a range
23b4566
feat: show file checksum instead of format in full item view
jr-rk Jul 3, 2026
1d70051
refactor: guard checksum display and show value only
jr-rk Jul 3, 2026
2ae1ba0
refactor: align Bitstream checkSum with upstream ChecksumInfo
jr-rk Jul 6, 2026
97392fe
revert: drop non-en filesection.checksum i18n key
jr-rk Jul 6, 2026
482e4f3
refactor: drop redundant optional chaining on checkSum
jr-rk Jul 6, 2026
cdb6ac7
fix: changed - File checksum: - to - Checksum: -
jr-rk Jul 6, 2026
f7ea17f
fix: added checksum algorithm (same style as upstream)
jr-rk Jul 6, 2026
bff4faa
Fix submission stuck on "Saving..." by serializing save effects (swit…
Jul 8, 2026
f0a509a
Harden submission form/vocabulary loading against permanent stuck spi…
Jul 8, 2026
8a9d438
docs(repro): add before/after reproduction of the dropdown loading hang
Jul 8, 2026
dd97c46
docs(repro): add before/after reproduction of the stuck "Saving..." hang
Jul 8, 2026
2620679
docs(repro): add never-responding-backend (timeout backstop) before/a…
Jul 8, 2026
562dff4
fix(item-page): show DOI field on simple item view while DOI registra…
milanmajchrak Jul 8, 2026
50b172f
Reduce vocab-load timeout to 15s + show a user-friendly error notific…
Jul 8, 2026
e618f83
docs(repro): refresh dropdown "after" clip to show the 15s + error-to…
Jul 8, 2026
e479376
docs(repro): refresh never-responding after clip for the 15s timeout …
Jul 8, 2026
4a60637
refactor(item-page): collapse DOI/default URI rendering into a single…
milanmajchrak Jul 8, 2026
f516f5d
test: provide NotificationsService stub in vocabulary component specs
Jul 8, 2026
27a49f7
chore: remove reproduction media from the code branch
Jul 8, 2026
86063aa
chore: remove reproduction media from the code branch
Jul 8, 2026
df6d0d7
feat(item-page): load DOI resolver from backend config instead of har…
milanmajchrak Jul 8, 2026
7e67176
feat(item-page): show "DOI registration in progress" while a DOI is p…
milanmajchrak Jul 9, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 11 additions & 0 deletions src/app/core/shared/bitstream.model.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,11 @@ import { ChildHALResource } from './child-hal-resource.model';
import { DSpaceObject } from './dspace-object.model';
import { HALLink } from './hal-link.model';

export interface ChecksumInfo {
checkSumAlgorithm: string;
value: string;
}

@typedObject
@inheritSerialization(DSpaceObject)
export class Bitstream extends DSpaceObject implements ChildHALResource {
Expand All @@ -38,6 +43,12 @@ export class Bitstream extends DSpaceObject implements ChildHALResource {
@autoserialize
description: string;

/**
* The checksum information of this Bitstream
*/
@autoserialize
checkSum: ChecksumInfo;

/**
* The name of the Bundle this Bitstream is part of
*/
Expand Down
Original file line number Diff line number Diff line change
@@ -1,15 +1,20 @@
<!-- DATASHARE - start -->
<!-- Added *ngIf="hasDoiLink" and *ngIf="mdValue.value.startsWith('https://doi.org')" to only render DOIs-->
<ds-metadata-field-wrapper *ngIf="hasDoiLink" [label]="label | translate">
<!-- <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">
{{ linktext || mdValue.value }}<span *ngIf="!last" [innerHTML]="separator"></span>
</a> -->
<ng-container *ngFor="let mdValue of mdValues; let last=last;">
<a class="dont-break-out" *ngIf="mdValue.value.startsWith('https://doi.org')" [href]="mdValue.value"
[target]="hasInternalLink(mdValue.value) ? '_self' : '_blank'" role="link" tabindex="0">
{{ linktext || mdValue.value }}
<span *ngIf="!last" [innerHTML]="separator"></span>
</a>
</ng-container>
<!--
doiField mode (the DOI / "Persistent Identifier" field on the simple item view):
- only DOI values (starting with the configured resolver, see doiValues) are shown as links;
the handle is hidden;
- hideIfNoTextContent is disabled, so the field/label is always shown;
- while a DOI is still queued for registration by the scheduled task (no DOI value yet), a
"DOI registration in progress" message is shown instead of an empty value. Once registration
completes the DOI link is shown instead.
Default mode (doiField = false) keeps the upstream generic behaviour: every URI value is rendered
as a link and the field is hidden when it has no value.
-->
<ds-metadata-field-wrapper [label]="label | translate" [hideIfNoTextContent]="!doiField">
<a class="dont-break-out" *ngFor="let mdValue of (doiField ? doiValues : mdValues); let last=last;"
[href]="mdValue.value" [target]="hasInternalLink(mdValue.value) ? '_self' : '_blank'" role="link" tabindex="0">
{{ linktext || mdValue.value }}<span *ngIf="!last" [innerHTML]="separator"></span>
</a>
<span *ngIf="doiField && doiValues.length === 0">{{ 'item.page.doi.pending' | translate }}</span>
</ds-metadata-field-wrapper>
<!-- DATASHARE - end -->
<!-- DATASHARE - end -->
Original file line number Diff line number Diff line change
Expand Up @@ -16,9 +16,12 @@ import {

import { APP_CONFIG } from '../../../../config/app-config.interface';
import { environment } from '../../../../environments/environment';
import { ConfigurationDataService } from '../../../core/data/configuration-data.service';
import { ConfigurationProperty } from '../../../core/shared/configuration-property.model';
import { MetadataValue } from '../../../core/shared/metadata.models';
import { isNotEmpty } from '../../../shared/empty.util';
import { TranslateLoaderMock } from '../../../shared/mocks/translate-loader.mock';
import { createSuccessfulRemoteDataObject$ } from '../../../shared/remote-data.utils';
import { MetadataUriValuesComponent } from './metadata-uri-values.component';

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

// Controls what the (stubbed) backend returns for the identifier.doi.resolver config property.
// Empty => the component falls back to its default resolver (https://doi.org).
let doiResolverConfigValues: string[] = [];
const configurationServiceStub = {
findByPropertyName: (name: string) => createSuccessfulRemoteDataObject$(
Object.assign(new ConfigurationProperty(), { name, values: doiResolverConfigValues }),
),
};

describe('MetadataUriValuesComponent', () => {
beforeEach(waitForAsync(() => {
doiResolverConfigValues = [];
TestBed.configureTestingModule({
imports: [TranslateModule.forRoot({
loader: {
Expand All @@ -49,6 +62,7 @@ describe('MetadataUriValuesComponent', () => {
}), MetadataUriValuesComponent],
providers: [
{ provide: APP_CONFIG, useValue: environment },
{ provide: ConfigurationDataService, useValue: configurationServiceStub },
],
schemas: [NO_ERRORS_SCHEMA],
}).overrideComponent(MetadataUriValuesComponent, {
Expand Down Expand Up @@ -98,6 +112,147 @@ describe('MetadataUriValuesComponent', () => {

});

// DATASHARE - start
// The DOI / "Persistent Identifier" field on the simple item view is rendered through this
// component. When a record is created its DOI is only registered asynchronously by a scheduled
// task, so for a while the item has no https://doi.org value yet. The field must still be shown
// (with an empty value) so users can see that a DOI exists / is pending, matching the behaviour
// of the previous DataShare release.
describe('when used as a DOI field (doiField = true)', () => {

describe('and a registered DOI is present', () => {
beforeEach(() => {
comp.doiField = true;
comp.mdValues = [
{ language: 'en_US', value: 'https://hdl.handle.net/123456789/99' },
{ language: 'en_US', value: 'https://doi.org/10.1234/registered' },
] as MetadataValue[];
fixture.detectChanges();
});

it('should render the field wrapper and show the label', () => {
const wrapper = fixture.debugElement.query(By.css('.simple-view-element'));
expect(wrapper).not.toBeNull();
expect(wrapper.nativeElement.classList).not.toContain('d-none');
expect(fixture.debugElement.query(By.css('.simple-view-element-header'))).not.toBeNull();
});

it('should render only the DOI value as a link (not the handle)', () => {
const links = fixture.debugElement.queryAll(By.css('a'));
expect(links.length).toBe(1);
expect(links[0].nativeElement.getAttribute('href')).toBe('https://doi.org/10.1234/registered');
});

it('should not show the "registration in progress" message once a DOI is present', () => {
expect(fixture.nativeElement.textContent).not.toContain('item.page.doi.pending');
});
});

describe('and the DOI has not been registered yet (scheduled task pending)', () => {
beforeEach(() => {
comp.doiField = true;
// Only a handle is present, the DOI is still queued for registration by the CRON job
comp.mdValues = [
{ language: 'en_US', value: 'https://hdl.handle.net/123456789/99' },
] as MetadataValue[];
fixture.detectChanges();
});

it('should still display the DOI field (label visible) even without a DOI link', () => {
const wrapper = fixture.debugElement.query(By.css('.simple-view-element'));
expect(wrapper).not.toBeNull();
expect(wrapper.nativeElement.classList).not.toContain('d-none');
expect(fixture.debugElement.query(By.css('.simple-view-element-header'))).not.toBeNull();
});

it('should not render the non-DOI (handle) value as a link', () => {
expect(fixture.debugElement.queryAll(By.css('a')).length).toBe(0);
});

it('should show a "DOI registration in progress" message instead of an empty value', () => {
expect(fixture.nativeElement.textContent).toContain('item.page.doi.pending');
});
});

describe('and the item has no identifier metadata at all', () => {
beforeEach(() => {
comp.doiField = true;
comp.mdValues = [] as MetadataValue[];
fixture.detectChanges();
});

it('should still display the DOI field wrapper with the "registration in progress" message', () => {
const wrapper = fixture.debugElement.query(By.css('.simple-view-element'));
expect(wrapper).not.toBeNull();
expect(wrapper.nativeElement.classList).not.toContain('d-none');
expect(fixture.nativeElement.textContent).toContain('item.page.doi.pending');
});
});

describe('and multiple DOIs are present followed by a non-DOI value', () => {
beforeEach(() => {
comp.doiField = true;
comp.separator = '<br/>';
comp.mdValues = [
{ language: 'en_US', value: 'https://doi.org/10.1234/one' },
{ language: 'en_US', value: 'https://doi.org/10.5678/two' },
{ language: 'en_US', value: 'https://hdl.handle.net/123456789/99' },
] as MetadataValue[];
fixture.detectChanges();
});

it('should render only the DOI values as links', () => {
expect(fixture.debugElement.queryAll(By.css('a')).length).toBe(2);
});

it('should only put a separator between the DOIs, not a trailing one after the last DOI', () => {
// exactly one separator between the two visible DOIs (computed against the DOI subset,
// not the full metadata array, so the trailing handle cannot add a stray separator)
expect(fixture.debugElement.queryAll(By.css('a span')).length).toBe(1);
});
});

describe('and a custom DOI resolver is configured in the backend (identifier.doi.resolver)', () => {
beforeEach(() => {
// The backend resolver is not the default https://doi.org
doiResolverConfigValues = ['https://doi.example.org'];
// Re-create the component so ngOnInit reads the configured resolver with doiField already set
fixture = TestBed.createComponent(MetadataUriValuesComponent);
comp = fixture.componentInstance;
comp.doiField = true;
comp.label = mockLabel;
comp.mdValues = [
{ language: 'en_US', value: 'https://doi.example.org/10.1234/configured' },
{ language: 'en_US', value: 'https://doi.org/10.5678/default-resolver' },
] as MetadataValue[];
fixture.detectChanges();
});

it('should treat values matching the configured resolver as DOIs', () => {
const links = fixture.debugElement.queryAll(By.css('a'));
expect(links.length).toBe(1);
expect(links[0].nativeElement.getAttribute('href')).toBe('https://doi.example.org/10.1234/configured');
});
});
});

describe('when NOT used as a DOI field (doiField = false, the default)', () => {
beforeEach(() => {
comp.doiField = false;
comp.mdValues = [
{ language: 'en_US', value: 'https://example.com/endorsement' },
] as MetadataValue[];
fixture.detectChanges();
});

it('should render every URI value as a link (upstream behaviour)', () => {
const links = fixture.debugElement.queryAll(By.css('a'));
expect(links.length).toBe(1);
expect(links[0].nativeElement.getAttribute('href')).toBe('https://example.com/endorsement');
});
});
// DATASHARE - end

});

function containsHref(links: DebugElement[], href: string): boolean {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,15 +3,38 @@ import {
NgIf,
} from '@angular/common';
import {
ChangeDetectorRef,
Component,
Inject,
Input,
OnInit,
} from '@angular/core';
import { TranslateModule } from '@ngx-translate/core';

import {
APP_CONFIG,
AppConfig,
} from '../../../../config/app-config.interface';
import { ConfigurationDataService } from '../../../core/data/configuration-data.service';
import { MetadataValue } from '../../../core/shared/metadata.models';
import { getFirstCompletedRemoteData } from '../../../core/shared/operators';
import { isNotEmpty } from '../../../shared/empty.util';
import { MetadataFieldWrapperComponent } from '../../../shared/metadata-field-wrapper/metadata-field-wrapper.component';
import { MetadataValuesComponent } from '../metadata-values/metadata-values.component';

// DATASHARE - start
/**
* Default DOI resolver, used when the backend does not expose/define {@link DOI_RESOLVER_PROPERTY}.
* Kept in sync with the DSpace default (DOIServiceImpl#RESOLVER_DEFAULT).
*/
export const DEFAULT_DOI_RESOLVER = 'https://doi.org';

/**
* Backend configuration property holding the DOI resolver base URL.
*/
export const DOI_RESOLVER_PROPERTY = 'identifier.doi.resolver';
// DATASHARE - end

/**
* This component renders the configured 'values' into the ds-metadata-field-wrapper component as a link.
* It puts the given 'separator' between each two values
Expand All @@ -31,7 +54,7 @@ import { MetadataValuesComponent } from '../metadata-values/metadata-values.comp
],
standalone: true,
})
export class MetadataUriValuesComponent extends MetadataValuesComponent {
export class MetadataUriValuesComponent extends MetadataValuesComponent implements OnInit {

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

// DATASHARE - start
// get makes it accessible from the template as a property.
get hasDoiLink(): boolean {
return this.mdValues?.some(v => typeof v.value === 'string' && v.value.startsWith('https://doi.org'));
/**
* When true, this component renders a DOI ("Persistent Identifier") field:
* - only DOI values (starting with the configured {@link doiResolver}) are rendered as links
* (the handle is hidden);
* - the field label/wrapper is always shown, even while a DOI is still queued for
* registration by the scheduled task (i.e. no DOI value is present yet),
* so users can see that a DOI exists / is pending.
* When false (the default) the upstream generic behaviour is kept: every URI value is
* rendered as a link and the field is hidden when it has no value.
*/
@Input() doiField = false;

/**
* The DOI resolver base URL. Loaded from the backend configuration ({@link DOI_RESOLVER_PROPERTY})
* so that the same value drives the frontend as the backend, instead of hard-coding it here.
* Falls back to {@link DEFAULT_DOI_RESOLVER} when the property is not exposed/defined.
*/
doiResolver = DEFAULT_DOI_RESOLVER;

constructor(
@Inject(APP_CONFIG) appConfig: AppConfig,
private configurationService: ConfigurationDataService,
private cdr: ChangeDetectorRef,
) {
super(appConfig);
}

ngOnInit(): void {
if (this.doiField) {
this.configurationService.findByPropertyName(DOI_RESOLVER_PROPERTY).pipe(
getFirstCompletedRemoteData(),
).subscribe((rd) => {
if (rd.hasSucceeded && isNotEmpty(rd.payload?.values)) {
this.doiResolver = rd.payload.values[0];
this.cdr.markForCheck();
}
});
}
}

/**
* The DOI values (starting with {@link doiResolver}) among {@link mdValues}. Used in
* {@link doiField} mode so that only DOIs are shown as links and the separator is computed
* against the visible DOIs only.
*/
get doiValues(): MetadataValue[] {
return (this.mdValues ?? []).filter(v => typeof v.value === 'string' && v.value.startsWith(this.doiResolver));
}
// DATASHARE - end
}
Original file line number Diff line number Diff line change
Expand Up @@ -23,8 +23,10 @@ <h3 class="h5 simple-view-element-header">{{"item.page.filesection.original.bund
<dd class="col-md-8">{{(file.sizeBytes) | dsFileSize }}</dd>


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

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

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


<dt class="col-md-4">{{"item.page.filesection.description" | translate}}</dt>
Expand Down
Original file line number Diff line number Diff line change
@@ -1,3 +1,3 @@
<div class="item-page-field">
<ds-metadata-uri-values [mdValues]="item?.allMetadata(fields)" [separator]="separator" [label]="label"></ds-metadata-uri-values>
<ds-metadata-uri-values [mdValues]="item?.allMetadata(fields)" [separator]="separator" [label]="label" [doiField]="doiField"></ds-metadata-uri-values>
</div>
Loading
Loading