diff --git a/src/app/core/shared/bitstream.model.ts b/src/app/core/shared/bitstream.model.ts index 7bc6ef964b7..5dcfe74a5fa 100644 --- a/src/app/core/shared/bitstream.model.ts +++ b/src/app/core/shared/bitstream.model.ts @@ -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 { @@ -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 */ diff --git a/src/app/item-page/field-components/metadata-uri-values/metadata-uri-values.component.html b/src/app/item-page/field-components/metadata-uri-values/metadata-uri-values.component.html index 17e81292e16..35b47554518 100644 --- a/src/app/item-page/field-components/metadata-uri-values/metadata-uri-values.component.html +++ b/src/app/item-page/field-components/metadata-uri-values/metadata-uri-values.component.html @@ -1,15 +1,20 @@ - - - - - - {{ linktext || mdValue.value }} - - - + + + + {{ linktext || mdValue.value }} + + {{ 'item.page.doi.pending' | translate }} - \ No newline at end of file + diff --git a/src/app/item-page/field-components/metadata-uri-values/metadata-uri-values.component.spec.ts b/src/app/item-page/field-components/metadata-uri-values/metadata-uri-values.component.spec.ts index 50ec39cd7af..6a17f3f0a15 100644 --- a/src/app/item-page/field-components/metadata-uri-values/metadata-uri-values.component.spec.ts +++ b/src/app/item-page/field-components/metadata-uri-values/metadata-uri-values.component.spec.ts @@ -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; @@ -38,8 +41,18 @@ const mockSeperator = '
'; 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: { @@ -49,6 +62,7 @@ describe('MetadataUriValuesComponent', () => { }), MetadataUriValuesComponent], providers: [ { provide: APP_CONFIG, useValue: environment }, + { provide: ConfigurationDataService, useValue: configurationServiceStub }, ], schemas: [NO_ERRORS_SCHEMA], }).overrideComponent(MetadataUriValuesComponent, { @@ -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 = '
'; + 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 { diff --git a/src/app/item-page/field-components/metadata-uri-values/metadata-uri-values.component.ts b/src/app/item-page/field-components/metadata-uri-values/metadata-uri-values.component.ts index 9c97229905a..7b4c2d2aeb3 100644 --- a/src/app/item-page/field-components/metadata-uri-values/metadata-uri-values.component.ts +++ b/src/app/item-page/field-components/metadata-uri-values/metadata-uri-values.component.ts @@ -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 @@ -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 @@ -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 } diff --git a/src/app/item-page/full/field-components/file-section/full-file-section.component.html b/src/app/item-page/full/field-components/file-section/full-file-section.component.html index 918993cf8ba..bde291e4127 100644 --- a/src/app/item-page/full/field-components/file-section/full-file-section.component.html +++ b/src/app/item-page/full/field-components/file-section/full-file-section.component.html @@ -23,8 +23,10 @@

{{"item.page.filesection.original.bund
{{(file.sizeBytes) | dsFileSize }}
-
{{"item.page.filesection.format" | translate}}
-
{{(file.format | async)?.payload?.description}}
+ +
{{"item.page.filesection.checksum" | translate}}
+
({{ file.checkSum.checkSumAlgorithm }}):{{ file.checkSum.value }}
+
{{"item.page.filesection.description" | translate}}
@@ -64,8 +66,10 @@

{{"item.page.filesection.license.bundl
{{"item.page.filesection.size" | translate}}
{{(file.sizeBytes) | dsFileSize }}
-
{{"item.page.filesection.format" | translate}}
-
{{(file.format | async)?.payload?.description}}
+ +
{{"item.page.filesection.checksum" | translate}}
+
({{ file.checkSum.checkSumAlgorithm }}):{{ file.checkSum.value }}
+
{{"item.page.filesection.description" | translate}}
diff --git a/src/app/item-page/simple/field-components/specific-field/uri/item-page-uri-field.component.html b/src/app/item-page/simple/field-components/specific-field/uri/item-page-uri-field.component.html index 2b197541276..bb479ff5c13 100644 --- a/src/app/item-page/simple/field-components/specific-field/uri/item-page-uri-field.component.html +++ b/src/app/item-page/simple/field-components/specific-field/uri/item-page-uri-field.component.html @@ -1,3 +1,3 @@
- +
diff --git a/src/app/item-page/simple/field-components/specific-field/uri/item-page-uri-field.component.spec.ts b/src/app/item-page/simple/field-components/specific-field/uri/item-page-uri-field.component.spec.ts index 59ae1c30ead..54fb769693b 100644 --- a/src/app/item-page/simple/field-components/specific-field/uri/item-page-uri-field.component.spec.ts +++ b/src/app/item-page/simple/field-components/specific-field/uri/item-page-uri-field.component.spec.ts @@ -7,6 +7,7 @@ import { TestBed, waitForAsync, } from '@angular/core/testing'; +import { By } from '@angular/platform-browser'; import { TranslateLoader, TranslateModule, @@ -16,8 +17,10 @@ import { APP_CONFIG } from '../../../../../../config/app-config.interface'; import { environment } from '../../../../../../environments/environment'; import { BrowseService } from '../../../../../core/browse/browse.service'; import { BrowseDefinitionDataService } from '../../../../../core/browse/browse-definition-data.service'; +import { ConfigurationDataService } from '../../../../../core/data/configuration-data.service'; import { BrowseDefinitionDataServiceStub } from '../../../../../shared/testing/browse-definition-data-service.stub'; import { BrowseServiceStub } from '../../../../../shared/testing/browse-service.stub'; +import { ConfigurationDataServiceStub } from '../../../../../shared/testing/configuration-data.service.stub'; import { TranslateLoaderMock } from '../../../../../shared/testing/translate-loader.mock'; import { MetadataUriValuesComponent } from '../../../../field-components/metadata-uri-values/metadata-uri-values.component'; import { mockItemWithMetadataFieldsAndValue } from '../item-page-field.component.spec'; @@ -43,6 +46,7 @@ describe('ItemPageUriFieldComponent', () => { { provide: APP_CONFIG, useValue: environment }, { provide: BrowseDefinitionDataService, useValue: BrowseDefinitionDataServiceStub }, { provide: BrowseService, useValue: BrowseServiceStub }, + { provide: ConfigurationDataService, useClass: ConfigurationDataServiceStub }, ], schemas: [NO_ERRORS_SCHEMA], }).overrideComponent(ItemPageUriFieldComponent, { @@ -62,4 +66,27 @@ describe('ItemPageUriFieldComponent', () => { it('should display display the correct metadata value', () => { expect(fixture.nativeElement.innerHTML).toContain(mockValue); }); + + // DATASHARE - start + describe('when used as a DOI field with a pending (unregistered) DOI', () => { + beforeEach(() => { + // The item only has a handle, the DOI is still queued for registration by the CRON job + comp.item = mockItemWithMetadataFieldsAndValue([mockField], 'https://hdl.handle.net/123456789/1'); + comp.fields = [mockField]; + comp.label = mockLabel; + comp.doiField = true; + fixture.detectChanges(); + }); + + it('should still display the DOI field wrapper even though no DOI link is present yet', () => { + const wrapper = fixture.debugElement.query(By.css('.simple-view-element')); + expect(wrapper).not.toBeNull(); + expect(wrapper.nativeElement.classList).not.toContain('d-none'); + }); + + it('should not render the handle as a link', () => { + expect(fixture.debugElement.queryAll(By.css('a')).length).toBe(0); + }); + }); + // DATASHARE - end }); diff --git a/src/app/item-page/simple/field-components/specific-field/uri/item-page-uri-field.component.ts b/src/app/item-page/simple/field-components/specific-field/uri/item-page-uri-field.component.ts index 1385659baf5..cb9d06f80ba 100644 --- a/src/app/item-page/simple/field-components/specific-field/uri/item-page-uri-field.component.ts +++ b/src/app/item-page/simple/field-components/specific-field/uri/item-page-uri-field.component.ts @@ -42,4 +42,13 @@ export class ItemPageUriFieldComponent extends ItemPageFieldComponent { */ @Input() label: string; + // DATASHARE - start + /** + * When true, this field is rendered as a DOI ("Persistent Identifier") field: only DOI links + * are shown and the field label is always displayed, even while the DOI is still queued for + * registration by the scheduled task (no DOI value yet). See {@link MetadataUriValuesComponent}. + */ + @Input() doiField = false; + // DATASHARE - end + } diff --git a/src/app/item-page/simple/item-types/publication/publication.component.spec.ts b/src/app/item-page/simple/item-types/publication/publication.component.spec.ts index 49c486b16b2..d86c722bb72 100644 --- a/src/app/item-page/simple/item-types/publication/publication.component.spec.ts +++ b/src/app/item-page/simple/item-types/publication/publication.component.spec.ts @@ -32,6 +32,7 @@ import { RemoteDataBuildService } from '../../../../core/cache/builders/remote-d import { ObjectCacheService } from '../../../../core/cache/object-cache.service'; import { BitstreamDataService } from '../../../../core/data/bitstream-data.service'; import { CommunityDataService } from '../../../../core/data/community-data.service'; +import { ConfigurationDataService } from '../../../../core/data/configuration-data.service'; import { DefaultChangeAnalyzer } from '../../../../core/data/default-change-analyzer.service'; import { DSOChangeAnalyzer } from '../../../../core/data/dso-change-analyzer.service'; import { ItemDataService } from '../../../../core/data/item-data.service'; @@ -55,6 +56,7 @@ import { NotificationsService } from '../../../../shared/notifications/notificat import { createSuccessfulRemoteDataObject$ } from '../../../../shared/remote-data.utils'; import { ThemedResultsBackButtonComponent } from '../../../../shared/results-back-button/themed-results-back-button.component'; import { BrowseDefinitionDataServiceStub } from '../../../../shared/testing/browse-definition-data-service.stub'; +import { ConfigurationDataServiceStub } from '../../../../shared/testing/configuration-data.service.stub'; import { createPaginatedList } from '../../../../shared/testing/utils.test'; import { TruncatableService } from '../../../../shared/truncatable/truncatable.service'; import { TruncatePipe } from '../../../../shared/utils/truncate.pipe'; @@ -131,6 +133,7 @@ describe('PublicationComponent', () => { { provide: SearchService, useValue: {} }, { provide: RouteService, useValue: mockRouteService }, { provide: BrowseDefinitionDataService, useValue: BrowseDefinitionDataServiceStub }, + { provide: ConfigurationDataService, useClass: ConfigurationDataServiceStub }, { provide: APP_CONFIG, useValue: environment }, { provide: APP_DATA_SERVICES_MAP, useValue: {} }, ], diff --git a/src/app/item-page/simple/item-types/untyped-item/untyped-item.component.spec.ts b/src/app/item-page/simple/item-types/untyped-item/untyped-item.component.spec.ts index a03b76e24eb..8b21d10f18b 100644 --- a/src/app/item-page/simple/item-types/untyped-item/untyped-item.component.spec.ts +++ b/src/app/item-page/simple/item-types/untyped-item/untyped-item.component.spec.ts @@ -29,6 +29,7 @@ import { RemoteDataBuildService } from '../../../../core/cache/builders/remote-d import { ObjectCacheService } from '../../../../core/cache/object-cache.service'; import { BitstreamDataService } from '../../../../core/data/bitstream-data.service'; import { CommunityDataService } from '../../../../core/data/community-data.service'; +import { ConfigurationDataService } from '../../../../core/data/configuration-data.service'; import { DefaultChangeAnalyzer } from '../../../../core/data/default-change-analyzer.service'; import { DSOChangeAnalyzer } from '../../../../core/data/dso-change-analyzer.service'; import { ItemDataService } from '../../../../core/data/item-data.service'; @@ -52,6 +53,7 @@ import { NotificationsService } from '../../../../shared/notifications/notificat import { createSuccessfulRemoteDataObject$ } from '../../../../shared/remote-data.utils'; import { ThemedResultsBackButtonComponent } from '../../../../shared/results-back-button/themed-results-back-button.component'; import { BrowseDefinitionDataServiceStub } from '../../../../shared/testing/browse-definition-data-service.stub'; +import { ConfigurationDataServiceStub } from '../../../../shared/testing/configuration-data.service.stub'; import { createPaginatedList } from '../../../../shared/testing/utils.test'; import { TruncatableService } from '../../../../shared/truncatable/truncatable.service'; import { TruncatePipe } from '../../../../shared/utils/truncate.pipe'; @@ -130,6 +132,7 @@ describe('UntypedItemComponent', () => { { provide: ItemVersionsSharedService, useValue: {} }, { provide: RouteService, useValue: mockRouteService }, { provide: BrowseDefinitionDataService, useValue: BrowseDefinitionDataServiceStub }, + { provide: ConfigurationDataService, useClass: ConfigurationDataServiceStub }, { provide: APP_CONFIG, useValue: environment }, ], schemas: [NO_ERRORS_SCHEMA], diff --git a/src/app/shared/collection-dropdown/collection-dropdown.component.ts b/src/app/shared/collection-dropdown/collection-dropdown.component.ts index 2c2214e78b2..7f5c5e82f68 100644 --- a/src/app/shared/collection-dropdown/collection-dropdown.component.ts +++ b/src/app/shared/collection-dropdown/collection-dropdown.component.ts @@ -29,14 +29,17 @@ import { Subscription, } from 'rxjs'; import { + catchError, debounceTime, distinctUntilChanged, + finalize, map, mergeMap, reduce, startWith, switchMap, take, + timeout, } from 'rxjs/operators'; import { DSONameService } from '../../core/breadcrumbs/dso-name.service'; @@ -258,6 +261,7 @@ export class CollectionDropdownComponent implements OnInit, OnDestroy { .getAuthorizedCollection(query, findOptions, true, true, this.searchHref, followLink('parentCommunity')); } this.searchListCollection$ = searchListService$.pipe( + timeout({ each: 15000 }), getFirstCompletedRemoteData(), switchMap((collectionsRD: RemoteData>) => { this.searchComplete.emit(); @@ -268,7 +272,10 @@ export class CollectionDropdownComponent implements OnInit, OnDestroy { this.emitSelectionEvents(collectionsRD); return observableFrom(collectionsRD.payload.page).pipe( mergeMap((collection: Collection) => collection.parentCommunity.pipe( - getFirstSucceededRemoteDataPayload(), + timeout({ each: 15000 }), + getFirstCompletedRemoteData(), + map((communityRD: RemoteData) => (communityRD.hasSucceeded && hasValue(communityRD.payload)) ? communityRD.payload : new Community()), + catchError(() => observableOf(new Community())), map((community: Community) => ({ communities: [{ id: community.id, name: this.dsoNameService.getName(community) }], collection: { id: collection.id, uuid: collection.id, name: this.dsoNameService.getName(collection) }, @@ -281,6 +288,8 @@ export class CollectionDropdownComponent implements OnInit, OnDestroy { return observableOf([]); } }), + catchError(() => observableOf([])), + finalize(() => this.hideShowLoader(false)), ); this.subs.push( this.searchListCollection$.subscribe((list: CollectionListEntry[]) => { diff --git a/src/app/shared/form/builder/ds-dynamic-form-ui/models/dynamic-vocabulary.component.ts b/src/app/shared/form/builder/ds-dynamic-form-ui/models/dynamic-vocabulary.component.ts index c846f053ef5..fb8fb3982e9 100644 --- a/src/app/shared/form/builder/ds-dynamic-form-ui/models/dynamic-vocabulary.component.ts +++ b/src/app/shared/form/builder/ds-dynamic-form-ui/models/dynamic-vocabulary.component.ts @@ -1,6 +1,7 @@ import { Component, EventEmitter, + inject, Input, Output, } from '@angular/core'; @@ -10,6 +11,7 @@ import { DynamicFormLayoutService, DynamicFormValidationService, } from '@ng-dynamic-forms/core'; +import { TranslateService } from '@ngx-translate/core'; import { Observable, of as observableOf, @@ -20,6 +22,7 @@ import { PageInfo } from '../../../../../core/shared/page-info.model'; import { VocabularyEntry } from '../../../../../core/submission/vocabularies/models/vocabulary-entry.model'; import { VocabularyService } from '../../../../../core/submission/vocabularies/vocabulary.service'; import { isNotEmpty } from '../../../../empty.util'; +import { NotificationsService } from '../../../../notifications/notifications.service'; import { FormFieldMetadataValueObject } from '../../models/form-field-metadata-value.model'; import { DsDynamicInputModel } from './ds-dynamic-input.model'; @@ -41,6 +44,9 @@ export abstract class DsDynamicVocabularyComponent extends DynamicFormControlCom public abstract pageInfo: PageInfo; + protected notificationsService = inject(NotificationsService); + protected translateService = inject(TranslateService); + protected constructor(protected vocabularyService: VocabularyService, protected layoutService: DynamicFormLayoutService, protected validationService: DynamicFormValidationService, @@ -48,6 +54,15 @@ export abstract class DsDynamicVocabularyComponent extends DynamicFormControlCom super(layoutService, validationService); } + /** + * Show a user-friendly error notification when a controlled-vocabulary / authority + * lookup fails or times out, so the user knows to retry instead of staring at a + * spinner (or a silently empty list). + */ + protected notifyVocabularyLoadError(): void { + this.notificationsService.error(this.translateService.instant('form.vocabulary.load-error')); + } + /** * Sets the current value with the given value. * @param value The value to set. diff --git a/src/app/shared/form/builder/ds-dynamic-form-ui/models/list/dynamic-list.component.ts b/src/app/shared/form/builder/ds-dynamic-form-ui/models/list/dynamic-list.component.ts index c1964db1e75..9eb8cc0a0d4 100644 --- a/src/app/shared/form/builder/ds-dynamic-form-ui/models/list/dynamic-list.component.ts +++ b/src/app/shared/form/builder/ds-dynamic-form-ui/models/list/dynamic-list.component.ts @@ -31,15 +31,21 @@ import { TranslateModule } from '@ngx-translate/core'; import findKey from 'lodash/findKey'; import { BehaviorSubject, + of as observableOf, Subscription, } from 'rxjs'; import { + catchError, map, tap, + timeout, } from 'rxjs/operators'; -import { PaginatedList } from '../../../../../../core/data/paginated-list.model'; -import { getFirstSucceededRemoteDataPayload } from '../../../../../../core/shared/operators'; +import { + buildPaginatedList, + PaginatedList, +} from '../../../../../../core/data/paginated-list.model'; +import { getFirstCompletedRemoteData } from '../../../../../../core/shared/operators'; import { PageInfo } from '../../../../../../core/shared/page-info.model'; import { VocabularyEntry } from '../../../../../../core/submission/vocabularies/models/vocabulary-entry.model'; import { VocabularyService } from '../../../../../../core/submission/vocabularies/vocabulary.service'; @@ -209,7 +215,10 @@ export class DsDynamicListComponent extends DynamicFormControlComponent implemen this.subs.push( this.vocabularyService.getVocabularyEntries(this.model.vocabularyOptions, this.nextPageInfo).pipe( - getFirstSucceededRemoteDataPayload(), + timeout({ each: 15000 }), + getFirstCompletedRemoteData(), + map((rd) => (rd.hasSucceeded && hasValue(rd.payload)) ? rd.payload : buildPaginatedList(new PageInfo(), [])), + catchError(() => observableOf(buildPaginatedList(new PageInfo(), []))), tap((response) => this.setPaginationInfo(response)), map(entries => entries.page), ).subscribe((allEntries: VocabularyEntry[]) => { diff --git a/src/app/shared/form/builder/ds-dynamic-form-ui/models/lookup/dynamic-lookup.component.spec.ts b/src/app/shared/form/builder/ds-dynamic-form-ui/models/lookup/dynamic-lookup.component.spec.ts index 493c56e29d6..8fc41eed6e9 100644 --- a/src/app/shared/form/builder/ds-dynamic-form-ui/models/lookup/dynamic-lookup.component.spec.ts +++ b/src/app/shared/form/builder/ds-dynamic-form-ui/models/lookup/dynamic-lookup.component.spec.ts @@ -34,10 +34,12 @@ import { VocabularyEntry } from '../../../../../../core/submission/vocabularies/ import { VocabularyOptions } from '../../../../../../core/submission/vocabularies/models/vocabulary-options.model'; import { VocabularyService } from '../../../../../../core/submission/vocabularies/vocabulary.service'; import { BtnDisabledDirective } from '../../../../../btn-disabled.directive'; +import { NotificationsService } from '../../../../../notifications/notifications.service'; import { mockDynamicFormLayoutService, mockDynamicFormValidationService, } from '../../../../../testing/dynamic-form-mock-services'; +import { NotificationsServiceStub } from '../../../../../testing/notifications-service.stub'; import { createTestComponent } from '../../../../../testing/utils.test'; import { VocabularyServiceStub } from '../../../../../testing/vocabulary-service.stub'; import { ObjNgFor } from '../../../../../utils/object-ngfor.pipe'; @@ -178,6 +180,7 @@ describe('Dynamic Lookup component', () => { BtnDisabledDirective, ], providers: [ + { provide: NotificationsService, useValue: new NotificationsServiceStub() }, ChangeDetectorRef, DsDynamicLookupComponent, { provide: VocabularyService, useValue: vocabularyServiceStub }, diff --git a/src/app/shared/form/builder/ds-dynamic-form-ui/models/lookup/dynamic-lookup.component.ts b/src/app/shared/form/builder/ds-dynamic-form-ui/models/lookup/dynamic-lookup.component.ts index 39c39cf8bc6..dd7597e4740 100644 --- a/src/app/shared/form/builder/ds-dynamic-form-ui/models/lookup/dynamic-lookup.component.ts +++ b/src/app/shared/form/builder/ds-dynamic-form-ui/models/lookup/dynamic-lookup.component.ts @@ -35,6 +35,9 @@ import { import { catchError, distinctUntilChanged, + finalize, + map, + timeout, } from 'rxjs/operators'; import { @@ -42,7 +45,7 @@ import { PaginatedList, } from '../../../../../../core/data/paginated-list.model'; import { ConfidenceType } from '../../../../../../core/shared/confidence-type'; -import { getFirstSucceededRemoteDataPayload } from '../../../../../../core/shared/operators'; +import { getFirstCompletedRemoteData } from '../../../../../../core/shared/operators'; import { PageInfo } from '../../../../../../core/shared/page-info.model'; import { VocabularyEntry } from '../../../../../../core/submission/vocabularies/models/vocabulary-entry.model'; import { VocabularyService } from '../../../../../../core/submission/vocabularies/vocabulary.service'; @@ -276,14 +279,20 @@ export class DsDynamicLookupComponent extends DsDynamicVocabularyComponent imple this.model.vocabularyOptions, this.pageInfo, ).pipe( - getFirstSucceededRemoteDataPayload(), - catchError(() => - observableOf(buildPaginatedList( - new PageInfo(), - [], - )), - ), - distinctUntilChanged()) + timeout({ each: 15000 }), + getFirstCompletedRemoteData(), + map((rd) => { + if (!rd.hasSucceeded) { + this.notifyVocabularyLoadError(); + } + return (rd.hasSucceeded && hasValue(rd.payload)) ? rd.payload : buildPaginatedList(new PageInfo(), []); + }), + catchError(() => { + this.notifyVocabularyLoadError(); + return observableOf(buildPaginatedList(new PageInfo(), [])); + }), + distinctUntilChanged(), + finalize(() => this.loading = false)) .subscribe((list: PaginatedList) => { this.optionsList = list.page; this.updatePageInfo( @@ -292,7 +301,6 @@ export class DsDynamicLookupComponent extends DsDynamicVocabularyComponent imple list.pageInfo.totalElements, list.pageInfo.totalPages, ); - this.loading = false; this.cdr.detectChanges(); })); } diff --git a/src/app/shared/form/builder/ds-dynamic-form-ui/models/onebox/dynamic-onebox.component.spec.ts b/src/app/shared/form/builder/ds-dynamic-form-ui/models/onebox/dynamic-onebox.component.spec.ts index 8cdb1d67188..859512423b7 100644 --- a/src/app/shared/form/builder/ds-dynamic-form-ui/models/onebox/dynamic-onebox.component.spec.ts +++ b/src/app/shared/form/builder/ds-dynamic-form-ui/models/onebox/dynamic-onebox.component.spec.ts @@ -38,11 +38,13 @@ import { TestScheduler } from 'rxjs/testing'; import { VocabularyEntry } from '../../../../../../core/submission/vocabularies/models/vocabulary-entry.model'; import { VocabularyOptions } from '../../../../../../core/submission/vocabularies/models/vocabulary-options.model'; import { VocabularyService } from '../../../../../../core/submission/vocabularies/vocabulary.service'; +import { NotificationsService } from '../../../../../notifications/notifications.service'; import { createSuccessfulRemoteDataObject$ } from '../../../../../remote-data.utils'; import { mockDynamicFormLayoutService, mockDynamicFormValidationService, } from '../../../../../testing/dynamic-form-mock-services'; +import { NotificationsServiceStub } from '../../../../../testing/notifications-service.stub'; import { createTestComponent } from '../../../../../testing/utils.test'; import { VocabularyServiceStub } from '../../../../../testing/vocabulary-service.stub'; import { ObjNgFor } from '../../../../../utils/object-ngfor.pipe'; @@ -163,6 +165,7 @@ describe('DsDynamicOneboxComponent test suite', () => { VocabularyTreeviewComponent, ], providers: [ + { provide: NotificationsService, useValue: new NotificationsServiceStub() }, ChangeDetectorRef, DsDynamicOneboxComponent, { provide: VocabularyService, useValue: vocabularyServiceStub }, diff --git a/src/app/shared/form/builder/ds-dynamic-form-ui/models/onebox/dynamic-onebox.component.ts b/src/app/shared/form/builder/ds-dynamic-form-ui/models/onebox/dynamic-onebox.component.ts index de6b50d589b..ecff9b5049d 100644 --- a/src/app/shared/form/builder/ds-dynamic-form-ui/models/onebox/dynamic-onebox.component.ts +++ b/src/app/shared/form/builder/ds-dynamic-form-ui/models/onebox/dynamic-onebox.component.ts @@ -31,6 +31,7 @@ import { } from '@ng-dynamic-forms/core'; import { TranslateModule } from '@ngx-translate/core'; import { + EMPTY, Observable, of as observableOf, Subject, @@ -41,11 +42,13 @@ import { debounceTime, distinctUntilChanged, filter, + finalize, map, merge, switchMap, take, tap, + timeout, } from 'rxjs/operators'; import { @@ -53,7 +56,7 @@ import { PaginatedList, } from '../../../../../../core/data/paginated-list.model'; import { ConfidenceType } from '../../../../../../core/shared/confidence-type'; -import { getFirstSucceededRemoteDataPayload } from '../../../../../../core/shared/operators'; +import { getFirstCompletedRemoteData } from '../../../../../../core/shared/operators'; import { PageInfo } from '../../../../../../core/shared/page-info.model'; import { Vocabulary } from '../../../../../../core/submission/vocabularies/models/vocabulary.model'; import { VocabularyEntry } from '../../../../../../core/submission/vocabularies/models/vocabulary-entry.model'; @@ -155,7 +158,9 @@ export class DsDynamicOneboxComponent extends DsDynamicVocabularyComponent imple false, this.model.vocabularyOptions, this.pageInfo).pipe( - getFirstSucceededRemoteDataPayload(), + timeout({ each: 15000 }), + getFirstCompletedRemoteData(), + map((rd) => (rd.hasSucceeded && hasValue(rd.payload)) ? rd.payload : buildPaginatedList(new PageInfo(), [])), tap(() => this.searchFailed = false), catchError(() => { this.searchFailed = true; @@ -181,12 +186,13 @@ export class DsDynamicOneboxComponent extends DsDynamicVocabularyComponent imple } this.vocabulary$ = this.vocabularyService.findVocabularyById(this.model.vocabularyOptions.name).pipe( - getFirstSucceededRemoteDataPayload(), + getFirstCompletedRemoteData(), + map((rd) => (rd.hasSucceeded && hasValue(rd.payload)) ? rd.payload : null), distinctUntilChanged(), ); this.isHierarchicalVocabulary$ = this.vocabulary$.pipe( - map((result: Vocabulary) => result.hierarchical), + map((result: Vocabulary) => result?.hierarchical ?? false), ); this.subs.push(this.group.get(this.model.id).valueChanges.pipe( @@ -290,7 +296,7 @@ export class DsDynamicOneboxComponent extends DsDynamicVocabularyComponent imple event.preventDefault(); event.stopImmediatePropagation(); this.subs.push(this.vocabulary$.pipe( - map((vocabulary: Vocabulary) => vocabulary.preloadLevel), + map((vocabulary: Vocabulary) => vocabulary?.preloadLevel), take(1), ).subscribe((preloadLevel) => { const modalRef: NgbModalRef = this.modalService.open(VocabularyTreeviewModalComponent, { size: 'lg', windowClass: 'treeview' }); @@ -326,12 +332,14 @@ export class DsDynamicOneboxComponent extends DsDynamicVocabularyComponent imple let result: string; if (init) { this.changeLoadingInitialValueStatus(true); - this.getInitValueFromModel(true) - .subscribe((formValue: FormFieldMetadataValueObject) => { - this.changeLoadingInitialValueStatus(false); - this.currentValue = formValue; - this.cdr.detectChanges(); - }); + this.getInitValueFromModel(true).pipe( + timeout({ each: 15000 }), + catchError(() => EMPTY), + finalize(() => this.changeLoadingInitialValueStatus(false)), + ).subscribe((formValue: FormFieldMetadataValueObject) => { + this.currentValue = formValue; + this.cdr.detectChanges(); + }); } else { if (isEmpty(value)) { result = ''; diff --git a/src/app/shared/form/builder/ds-dynamic-form-ui/models/relation-group/dynamic-relation-group.components.ts b/src/app/shared/form/builder/ds-dynamic-form-ui/models/relation-group/dynamic-relation-group.components.ts index f397687686d..6b139643f0a 100644 --- a/src/app/shared/form/builder/ds-dynamic-form-ui/models/relation-group/dynamic-relation-group.components.ts +++ b/src/app/shared/form/builder/ds-dynamic-form-ui/models/relation-group/dynamic-relation-group.components.ts @@ -34,16 +34,17 @@ import { Subscription, } from 'rxjs'; import { + catchError, filter, map, mergeMap, scan, + timeout, } from 'rxjs/operators'; import { environment } from '../../../../../../../environments/environment'; import { SubmissionFormsModel } from '../../../../../../core/config/models/config-submission-forms.model'; -import { getFirstSucceededRemoteDataPayload } from '../../../../../../core/shared/operators'; -import { VocabularyEntryDetail } from '../../../../../../core/submission/vocabularies/models/vocabulary-entry-detail.model'; +import { getFirstCompletedRemoteData } from '../../../../../../core/shared/operators'; import { VocabularyService } from '../../../../../../core/submission/vocabularies/vocabulary.service'; import { shrinkInOut } from '../../../../../animations/shrink'; import { BtnDisabledDirective } from '../../../../../btn-disabled.directive'; @@ -285,14 +286,16 @@ export class DsDynamicRelationGroupComponent extends DynamicFormControlComponent valueObj[fieldName].authority, (model as any).vocabularyOptions.name, ).pipe( - getFirstSucceededRemoteDataPayload(), - map((entryDetail: VocabularyEntryDetail) => Object.assign( + timeout({ each: 15000 }), + getFirstCompletedRemoteData(), + map((entryDetailRD) => (entryDetailRD.hasSucceeded && hasValue(entryDetailRD.payload)) ? Object.assign( new FormFieldMetadataValueObject(), valueObj[fieldName], { - otherInformation: entryDetail.otherInformation, - }), - )); + otherInformation: entryDetailRD.payload.otherInformation, + }) : valueObj[fieldName]), + catchError(() => observableOf(valueObj[fieldName])), + ); } else { return$ = observableOf(valueObj[fieldName]); } diff --git a/src/app/shared/form/builder/ds-dynamic-form-ui/models/scrollable-dropdown/dynamic-scrollable-dropdown.component.spec.ts b/src/app/shared/form/builder/ds-dynamic-form-ui/models/scrollable-dropdown/dynamic-scrollable-dropdown.component.spec.ts index b7e974ce695..f427c95cbaf 100644 --- a/src/app/shared/form/builder/ds-dynamic-form-ui/models/scrollable-dropdown/dynamic-scrollable-dropdown.component.spec.ts +++ b/src/app/shared/form/builder/ds-dynamic-form-ui/models/scrollable-dropdown/dynamic-scrollable-dropdown.component.spec.ts @@ -34,10 +34,12 @@ import { APP_DATA_SERVICES_MAP } from '../../../../../../../config/app-config.in import { VocabularyEntry } from '../../../../../../core/submission/vocabularies/models/vocabulary-entry.model'; import { VocabularyOptions } from '../../../../../../core/submission/vocabularies/models/vocabulary-options.model'; import { VocabularyService } from '../../../../../../core/submission/vocabularies/vocabulary.service'; +import { NotificationsService } from '../../../../../notifications/notifications.service'; import { mockDynamicFormLayoutService, mockDynamicFormValidationService, } from '../../../../../testing/dynamic-form-mock-services'; +import { NotificationsServiceStub } from '../../../../../testing/notifications-service.stub'; import { createTestComponent, hasClass, @@ -98,6 +100,7 @@ describe('Dynamic Dynamic Scrollable Dropdown component', () => { TestComponent, ], providers: [ + { provide: NotificationsService, useValue: new NotificationsServiceStub() }, Injector, ChangeDetectorRef, DsDynamicScrollableDropdownComponent, diff --git a/src/app/shared/form/builder/ds-dynamic-form-ui/models/scrollable-dropdown/dynamic-scrollable-dropdown.component.ts b/src/app/shared/form/builder/ds-dynamic-form-ui/models/scrollable-dropdown/dynamic-scrollable-dropdown.component.ts index b55c62ec48e..486f6b77b89 100644 --- a/src/app/shared/form/builder/ds-dynamic-form-ui/models/scrollable-dropdown/dynamic-scrollable-dropdown.component.ts +++ b/src/app/shared/form/builder/ds-dynamic-form-ui/models/scrollable-dropdown/dynamic-scrollable-dropdown.component.ts @@ -34,9 +34,10 @@ import { import { catchError, distinctUntilChanged, + finalize, map, take, - tap, + timeout, } from 'rxjs/operators'; import { APP_DATA_SERVICES_MAP, @@ -51,7 +52,7 @@ import { } from '../../../../../../core/data/paginated-list.model'; import { RemoteData } from '../../../../../../core/data/remote-data'; import { lazyDataService } from '../../../../../../core/lazy-data-service'; -import { getFirstSucceededRemoteDataPayload } from '../../../../../../core/shared/operators'; +import { getFirstCompletedRemoteData } from '../../../../../../core/shared/operators'; import { PageInfo } from '../../../../../../core/shared/page-info.model'; import { VocabularyService } from '../../../../../../core/submission/vocabularies/vocabulary.service'; import { BtnDisabledDirective } from '../../../../../btn-disabled.directive'; @@ -161,9 +162,19 @@ export class DsDynamicScrollableDropdownComponent extends DsDynamicVocabularyCom loadOptions(fromInit: boolean) { this.loading = true; this.getDataFromService().pipe( - getFirstSucceededRemoteDataPayload(), - catchError(() => observableOf(buildPaginatedList(new PageInfo(), []))), - tap(() => this.loading = false), + timeout({ each: 15000 }), + getFirstCompletedRemoteData(), + map((rd) => { + if (!rd.hasSucceeded) { + this.notifyVocabularyLoadError(); + } + return (rd.hasSucceeded && hasValue(rd.payload)) ? rd.payload : buildPaginatedList(new PageInfo(), []); + }), + catchError(() => { + this.notifyVocabularyLoadError(); + return observableOf(buildPaginatedList(new PageInfo(), [])); + }), + finalize(() => this.loading = false), ).subscribe((list: PaginatedList) => { this.optionsList = list.page; if (fromInit && this.model.value) { @@ -288,13 +299,19 @@ export class DsDynamicScrollableDropdownComponent extends DsDynamicVocabularyCom this.pageInfo.totalPages, ); this.getDataFromService().pipe( - getFirstSucceededRemoteDataPayload(), - catchError(() => observableOf(buildPaginatedList( - new PageInfo(), - [], - )), - ), - tap(() => this.loading = false)) + timeout({ each: 15000 }), + getFirstCompletedRemoteData(), + map((rd) => { + if (!rd.hasSucceeded) { + this.notifyVocabularyLoadError(); + } + return (rd.hasSucceeded && hasValue(rd.payload)) ? rd.payload : buildPaginatedList(new PageInfo(), []); + }), + catchError(() => { + this.notifyVocabularyLoadError(); + return observableOf(buildPaginatedList(new PageInfo(), [])); + }), + finalize(() => this.loading = false)) .subscribe((list: PaginatedList) => { this.optionsList = this.optionsList.concat(list.page); this.updatePageInfo( diff --git a/src/app/shared/form/builder/ds-dynamic-form-ui/models/tag/dynamic-tag.component.spec.ts b/src/app/shared/form/builder/ds-dynamic-form-ui/models/tag/dynamic-tag.component.spec.ts index 68a5d5bc84b..ec99e25d9e6 100644 --- a/src/app/shared/form/builder/ds-dynamic-form-ui/models/tag/dynamic-tag.component.spec.ts +++ b/src/app/shared/form/builder/ds-dynamic-form-ui/models/tag/dynamic-tag.component.spec.ts @@ -34,10 +34,12 @@ import { of as observableOf } from 'rxjs'; import { VocabularyEntry } from '../../../../../../core/submission/vocabularies/models/vocabulary-entry.model'; import { VocabularyOptions } from '../../../../../../core/submission/vocabularies/models/vocabulary-options.model'; import { VocabularyService } from '../../../../../../core/submission/vocabularies/vocabulary.service'; +import { NotificationsService } from '../../../../../notifications/notifications.service'; import { mockDynamicFormLayoutService, mockDynamicFormValidationService, } from '../../../../../testing/dynamic-form-mock-services'; +import { NotificationsServiceStub } from '../../../../../testing/notifications-service.stub'; import { createTestComponent } from '../../../../../testing/utils.test'; import { VocabularyServiceStub } from '../../../../../testing/vocabulary-service.stub'; import { Chips } from '../../../../chips/models/chips.model'; @@ -109,6 +111,7 @@ describe('DsDynamicTagComponent test suite', () => { TestComponent, ], providers: [ + { provide: NotificationsService, useValue: new NotificationsServiceStub() }, ChangeDetectorRef, DsDynamicTagComponent, { provide: VocabularyService, useValue: vocabularyServiceStub }, diff --git a/src/app/shared/form/builder/ds-dynamic-form-ui/models/tag/dynamic-tag.component.ts b/src/app/shared/form/builder/ds-dynamic-form-ui/models/tag/dynamic-tag.component.ts index 431ca32c375..f966b459489 100644 --- a/src/app/shared/form/builder/ds-dynamic-form-ui/models/tag/dynamic-tag.component.ts +++ b/src/app/shared/form/builder/ds-dynamic-form-ui/models/tag/dynamic-tag.component.ts @@ -34,6 +34,7 @@ import { merge, switchMap, tap, + timeout, } from 'rxjs/operators'; import { environment } from '../../../../../../../environments/environment'; @@ -41,7 +42,7 @@ import { buildPaginatedList, PaginatedList, } from '../../../../../../core/data/paginated-list.model'; -import { getFirstSucceededRemoteDataPayload } from '../../../../../../core/shared/operators'; +import { getFirstCompletedRemoteData } from '../../../../../../core/shared/operators'; import { PageInfo } from '../../../../../../core/shared/page-info.model'; import { VocabularyEntry } from '../../../../../../core/submission/vocabularies/models/vocabulary-entry.model'; import { VocabularyService } from '../../../../../../core/submission/vocabularies/vocabulary.service'; @@ -117,7 +118,9 @@ export class DsDynamicTagComponent extends DsDynamicVocabularyComponent implemen return observableOf({ list: [] }); } else { return this.vocabularyService.getVocabularyEntriesByValue(term, false, this.model.vocabularyOptions, new PageInfo()).pipe( - getFirstSucceededRemoteDataPayload(), + timeout({ each: 15000 }), + getFirstCompletedRemoteData(), + map((rd) => (rd.hasSucceeded && hasValue(rd.payload)) ? rd.payload : buildPaginatedList(new PageInfo(), [])), tap(() => this.searchFailed = false), catchError(() => { this.searchFailed = true; diff --git a/src/app/shared/form/vocabulary-treeview/vocabulary-treeview.service.ts b/src/app/shared/form/vocabulary-treeview/vocabulary-treeview.service.ts index 83266b1b5f6..7e84c82edfd 100644 --- a/src/app/shared/form/vocabulary-treeview/vocabulary-treeview.service.ts +++ b/src/app/shared/form/vocabulary-treeview/vocabulary-treeview.service.ts @@ -6,16 +6,22 @@ import { of as observableOf, } from 'rxjs'; import { + catchError, + finalize, map, merge, mergeMap, scan, + timeout, } from 'rxjs/operators'; -import { PaginatedList } from '../../../core/data/paginated-list.model'; import { + buildPaginatedList, + PaginatedList, +} from '../../../core/data/paginated-list.model'; +import { + getFirstCompletedRemoteData, getFirstSucceededRemoteDataPayload, - getFirstSucceededRemoteListPayload, } from '../../../core/shared/operators'; import { PageInfo } from '../../../core/shared/page-info.model'; import { VocabularyEntry } from '../../../core/submission/vocabularies/models/vocabulary-entry.model'; @@ -23,6 +29,7 @@ import { VocabularyEntryDetail } from '../../../core/submission/vocabularies/mod import { VocabularyOptions } from '../../../core/submission/vocabularies/models/vocabulary-options.model'; import { VocabularyService } from '../../../core/submission/vocabularies/vocabulary.service'; import { + hasValue, isEmpty, isNotEmpty, } from '../../empty.util'; @@ -209,12 +216,15 @@ export class VocabularyTreeviewService { this.dataChange.next([]); this.vocabularyService.getVocabularyEntriesByValue(query, false, this.vocabularyOptions, new PageInfo()).pipe( - getFirstSucceededRemoteListPayload(), + timeout({ each: 15000 }), + getFirstCompletedRemoteData(), + map((rd) => (rd.hasSucceeded && hasValue(rd.payload)) ? rd.payload.page : []), mergeMap((result: VocabularyEntry[]) => (result.length > 0) ? result : observableOf(null)), mergeMap((entry: VocabularyEntry) => - this.vocabularyService.findEntryDetailById(entry.otherInformation.id, this.vocabularyName).pipe( - getFirstSucceededRemoteDataPayload(), - ), + isNotEmpty(entry) ? this.vocabularyService.findEntryDetailById(entry.otherInformation.id, this.vocabularyName).pipe( + getFirstCompletedRemoteData(), + map((entryRd) => (entryRd.hasSucceeded && hasValue(entryRd.payload)) ? entryRd.payload : null), + ) : observableOf(null), ), mergeMap((entry: VocabularyEntryDetail) => this.getNodeHierarchy(entry, selectedItems)), scan((acc: TreeviewNode[], value: TreeviewNode) => { @@ -224,10 +234,11 @@ export class VocabularyTreeviewService { return [...acc, value]; } }, []), + catchError(() => observableOf([] as TreeviewNode[])), + finalize(() => this.loading.next(false)), merge(this.hideSearchingWhenUnsubscribed$), ).subscribe((nodes: TreeviewNode[]) => { this.dataChange.next(nodes); - this.loading.next(false); }); } @@ -285,7 +296,7 @@ export class VocabularyTreeviewService { private getNodeHierarchyById(id: string, selectedItems: string[]): Observable { return this.getById(id).pipe( mergeMap((entry: VocabularyEntryDetail) => this.getNodeHierarchy(entry, selectedItems,[], false)), - map((node: TreeviewNode) => this.getNodeHierarchyIds(node, selectedItems)), + map((node: TreeviewNode) => isNotEmpty(node) ? this.getNodeHierarchyIds(node, selectedItems) : []), ); } @@ -318,7 +329,10 @@ export class VocabularyTreeviewService { */ private getById(entryId: string): Observable { return this.vocabularyService.findEntryDetailById(entryId, this.vocabularyName).pipe( - getFirstSucceededRemoteDataPayload(), + timeout({ each: 15000 }), + getFirstCompletedRemoteData(), + map((rd) => (rd.hasSucceeded && hasValue(rd.payload)) ? rd.payload : null), + catchError(() => observableOf(null)), ); } @@ -330,7 +344,11 @@ export class VocabularyTreeviewService { */ private retrieveTopNodes(pageInfo: PageInfo, nodes: TreeviewNode[], selectedItems: string[]): void { this.vocabularyService.searchTopEntries(this.vocabularyName, pageInfo).pipe( - getFirstSucceededRemoteDataPayload(), + timeout({ each: 15000 }), + getFirstCompletedRemoteData(), + map((rd) => (rd.hasSucceeded && hasValue(rd.payload)) ? rd.payload : buildPaginatedList(new PageInfo(), [])), + catchError(() => observableOf(buildPaginatedList(new PageInfo(), []))), + finalize(() => this.loading.next(false)), ).subscribe((list: PaginatedList) => { this.vocabularyService.clearSearchTopRequests(); const newNodes: TreeviewNode[] = list.page.map((entry: VocabularyEntryDetail) => this._generateNode(entry, selectedItems)); @@ -345,7 +363,6 @@ export class VocabularyTreeviewService { loadMoreNode.updatePageInfo(newPageInfo); nodes.push(loadMoreNode); } - this.loading.next(false); // Notify the change. this.dataChange.next(nodes); }); diff --git a/src/app/submission/form/collection/submission-form-collection.component.ts b/src/app/submission/form/collection/submission-form-collection.component.ts index 1736b474d5f..48d21f03c57 100644 --- a/src/app/submission/form/collection/submission-form-collection.component.ts +++ b/src/app/submission/form/collection/submission-form-collection.component.ts @@ -20,9 +20,12 @@ import { Subscription, } from 'rxjs'; import { + catchError, + finalize, find, map, mergeMap, + timeout, } from 'rxjs/operators'; import { DSONameService } from '../../../core/breadcrumbs/dso-name.service'; @@ -31,7 +34,7 @@ import { RemoteData } from '../../../core/data/remote-data'; import { JsonPatchOperationPathCombiner } from '../../../core/json-patch/builder/json-patch-operation-path-combiner'; import { JsonPatchOperationsBuilder } from '../../../core/json-patch/builder/json-patch-operations-builder'; import { Collection } from '../../../core/shared/collection.model'; -import { getFirstSucceededRemoteDataPayload } from '../../../core/shared/operators'; +import { getFirstCompletedRemoteData } from '../../../core/shared/operators'; import { SubmissionObject } from '../../../core/submission/models/submission-object.model'; import { SubmissionJsonPatchOperationsService } from '../../../core/submission/submission-json-patch-operations.service'; import { BtnDisabledDirective } from '../../../shared/btn-disabled.directive'; @@ -196,16 +199,21 @@ export class SubmissionFormCollectionComponent implements OnDestroy, OnChanges, mergeMap((submissionObject: SubmissionObject[]) => { // retrieve the full submission object with embeds return this.submissionService.retrieveSubmission(submissionObject[0].id).pipe( - getFirstSucceededRemoteDataPayload(), + timeout({ each: 15000 }), + getFirstCompletedRemoteData(), + map((rd) => (rd.hasSucceeded && hasValue(rd.payload)) ? rd.payload : null), ); }), + catchError(() => observableOf(null)), + finalize(() => this.processingChange$.next(false)), ).subscribe((submissionObject: SubmissionObject) => { - this.selectedCollectionId = event.collection.id; - this.selectedCollectionName$ = observableOf(event.collection.name); - this.collectionChange.emit(submissionObject); - this.submissionService.changeSubmissionCollection(this.submissionId, event.collection.id); - this.processingChange$.next(false); - this.cdr.detectChanges(); + if (hasValue(submissionObject)) { + this.selectedCollectionId = event.collection.id; + this.selectedCollectionName$ = observableOf(event.collection.name); + this.collectionChange.emit(submissionObject); + this.submissionService.changeSubmissionCollection(this.submissionId, event.collection.id); + this.cdr.detectChanges(); + } }), ); } diff --git a/src/app/submission/objects/submission-objects.effects.ts b/src/app/submission/objects/submission-objects.effects.ts index f4880fb5898..c0f0b321219 100644 --- a/src/app/submission/objects/submission-objects.effects.ts +++ b/src/app/submission/objects/submission-objects.effects.ts @@ -16,6 +16,7 @@ import { } from 'rxjs'; import { catchError, + concatMap, filter, map, mergeMap, @@ -157,7 +158,7 @@ export class SubmissionObjectEffects { */ saveSubmission$ = createEffect(() => this.actions$.pipe( ofType(SubmissionObjectActionTypes.SAVE_SUBMISSION_FORM), - switchMap((action: SaveSubmissionFormAction) => { + concatMap((action: SaveSubmissionFormAction) => { return this.operationsService.jsonPatchByResourceType( this.submissionService.getSubmissionObjectLinkName(), action.payload.submissionId, @@ -171,7 +172,7 @@ export class SubmissionObjectEffects { */ saveForLaterSubmission$ = createEffect(() => this.actions$.pipe( ofType(SubmissionObjectActionTypes.SAVE_FOR_LATER_SUBMISSION_FORM), - switchMap((action: SaveForLaterSubmissionFormAction) => { + concatMap((action: SaveForLaterSubmissionFormAction) => { return this.operationsService.jsonPatchByResourceType( this.submissionService.getSubmissionObjectLinkName(), action.payload.submissionId, @@ -211,7 +212,7 @@ export class SubmissionObjectEffects { */ saveSection$ = createEffect(() => this.actions$.pipe( ofType(SubmissionObjectActionTypes.SAVE_SUBMISSION_SECTION_FORM), - switchMap((action: SaveSubmissionSectionFormAction) => { + concatMap((action: SaveSubmissionSectionFormAction) => { return this.operationsService.jsonPatchByResourceID( this.submissionService.getSubmissionObjectLinkName(), action.payload.submissionId, @@ -235,7 +236,7 @@ export class SubmissionObjectEffects { saveAndDeposit$ = createEffect(() => this.actions$.pipe( ofType(SubmissionObjectActionTypes.SAVE_AND_DEPOSIT_SUBMISSION), withLatestFrom(this.submissionService.hasUnsavedModification()), - switchMap(([action, hasUnsavedModification]: [SaveAndDepositSubmissionAction, boolean]) => { + concatMap(([action, hasUnsavedModification]: [SaveAndDepositSubmissionAction, boolean]) => { let response$: Observable; if (hasUnsavedModification) { response$ = this.operationsService.jsonPatchByResourceType( diff --git a/src/app/submission/sections/cc-license/submission-section-cc-licenses.component.ts b/src/app/submission/sections/cc-license/submission-section-cc-licenses.component.ts index 91d07f1aa32..01e52bd2348 100644 --- a/src/app/submission/sections/cc-license/submission-section-cc-licenses.component.ts +++ b/src/app/submission/sections/cc-license/submission-section-cc-licenses.component.ts @@ -25,22 +25,26 @@ import { Subscription, } from 'rxjs'; import { + catchError, distinctUntilChanged, filter, + finalize, map, take, tap, + timeout, } from 'rxjs/operators'; import { ConfigurationDataService } from '../../../core/data/configuration-data.service'; import { FindListOptions } from '../../../core/data/find-list-options.model'; +import { buildPaginatedList } from '../../../core/data/paginated-list.model'; import { JsonPatchOperationPathCombiner } from '../../../core/json-patch/builder/json-patch-operation-path-combiner'; import { JsonPatchOperationsBuilder } from '../../../core/json-patch/builder/json-patch-operations-builder'; import { getFirstCompletedRemoteData, - getFirstSucceededRemoteDataPayload, getRemoteDataPayload, } from '../../../core/shared/operators'; +import { PageInfo } from '../../../core/shared/page-info.model'; import { Field, Option, @@ -403,13 +407,16 @@ export class SubmissionSectionCcLicensesComponent extends SectionModelComponent this.subscriptions.push( this.submissionCcLicensesDataService.findAll(this.ccLicenceOptions).pipe( - getFirstSucceededRemoteDataPayload(), + timeout({ each: 15000 }), + getFirstCompletedRemoteData(), + map((rd) => (rd.hasSucceeded && hasValue(rd.payload)) ? rd.payload : buildPaginatedList(new PageInfo(), [])), tap((response) => this._isLastPage = response.pageInfo.currentPage === response.pageInfo.totalPages), map((list) => list.page), + catchError(() => observableOf([])), + finalize(() => this.isLoading = false), ).subscribe( (licenses) => { this.submissionCcLicenses = [...this.submissionCcLicenses, ...licenses]; - this.isLoading = false; this.ref.detectChanges(); }, ), diff --git a/src/app/submission/sections/form/section-form.component.ts b/src/app/submission/sections/form/section-form.component.ts index 26285320b05..dfa00618ffe 100644 --- a/src/app/submission/sections/form/section-form.component.ts +++ b/src/app/submission/sections/form/section-form.component.ts @@ -14,17 +14,21 @@ import findIndex from 'lodash/findIndex'; import isEqual from 'lodash/isEqual'; import { combineLatest as observableCombineLatest, + EMPTY, Observable, Subscription, } from 'rxjs'; import { + catchError, distinctUntilChanged, filter, + finalize, find, map, mergeMap, take, tap, + timeout, } from 'rxjs/operators'; import { environment } from '../../../../environments/environment'; @@ -217,7 +221,10 @@ export class SubmissionSectionFormComponent extends SectionModelComponent { getRemoteDataPayload()), this.sectionService.isSectionReadOnly(this.submissionId, this.sectionData.id, this.submissionService.getSubmissionScope()), ])), - take(1)) + take(1), + timeout({ each: 15000 }), + catchError(() => EMPTY), + finalize(() => this.isLoading = false)) .subscribe(([sectionData, submissionObject, isSectionReadOnly]: [WorkspaceitemSectionFormObject, SubmissionObject, boolean]) => { if (isUndefined(this.formModel)) { // this.sectionData.errorsToShow = []; diff --git a/src/assets/i18n/en.json5 b/src/assets/i18n/en.json5 index 04d7384174b..f93e14848f2 100644 --- a/src/assets/i18n/en.json5 +++ b/src/assets/i18n/en.json5 @@ -2000,6 +2000,8 @@ "form.first-name": "First name", + "form.vocabulary.load-error": "Something went wrong while loading the options. Please try again.", + "form.group-collapse": "Collapse", "form.group-collapse-help": "Click here to collapse", @@ -2806,6 +2808,8 @@ "item.page.files": "Files", + "item.page.filesection.checksum": "Checksum:", + "item.page.filesection.description": "Description:", "item.page.filesection.download": "Download", @@ -7152,6 +7156,8 @@ "item.page.doi": "Persistent Identifier", + "item.page.doi.pending": "DOI registration in progress", + "item.page.date.available": "Date Available", "item.page.replaced": "Relation (Is Replaced By)", diff --git a/src/themes/datashare/app/item-page/simple/item-types/untyped-item/untyped-item.component.html b/src/themes/datashare/app/item-page/simple/item-types/untyped-item/untyped-item.component.html index 25048911421..5bc4cdfbaf6 100644 --- a/src/themes/datashare/app/item-page/simple/item-types/untyped-item/untyped-item.component.html +++ b/src/themes/datashare/app/item-page/simple/item-types/untyped-item/untyped-item.component.html @@ -80,7 +80,8 @@ + [label]="'item.page.doi'" + [doiField]="true">