diff --git a/src/app/features/registry/pages/registry-resources/registry-resources.component.html b/src/app/features/registry/pages/registry-resources/registry-resources.component.html index cf0747a89..89dd5037a 100644 --- a/src/app/features/registry/pages/registry-resources/registry-resources.component.html +++ b/src/app/features/registry/pages/registry-resources/registry-resources.component.html @@ -6,21 +6,21 @@ (buttonClick)="addResource()" /> -@if (isResourcesLoading()) { - -} @else { -
-

- @if (addButtonVisible()) { - {{ 'resources.linkDoi' | translate }} - } +

+

+ @if (addButtonVisible()) { + {{ 'resources.linkDoi' | translate }} + } - {{ 'resources.description' | translate }} - - {{ 'common.labels.learnMore' | translate }} - -

+ {{ 'resources.description' | translate }} + + {{ 'common.labels.learnMore' | translate }} + +

+ @if (isResourcesLoading()) { + + } @else {
@for (resource of resources(); track resource.id) {
@@ -60,5 +60,15 @@

{{ getResourceTypeTranslationKey(resource.type) | translate }}

}
-
-} + } + + @if (resourcesTotalCount() > rows()) { + + } +
diff --git a/src/app/features/registry/pages/registry-resources/registry-resources.component.spec.ts b/src/app/features/registry/pages/registry-resources/registry-resources.component.spec.ts index bcccca4b6..a251a0542 100644 --- a/src/app/features/registry/pages/registry-resources/registry-resources.component.spec.ts +++ b/src/app/features/registry/pages/registry-resources/registry-resources.component.spec.ts @@ -2,6 +2,9 @@ import { Store } from '@ngxs/store'; import { MockComponents, MockProvider } from 'ng-mocks'; +import { Button } from 'primeng/button'; +import { DynamicDialogRef } from 'primeng/dynamicdialog'; + import { Subject, throwError } from 'rxjs'; import { Mock } from 'vitest'; @@ -9,9 +12,11 @@ import { Mock } from 'vitest'; import { TestBed } from '@angular/core/testing'; import { ActivatedRoute } from '@angular/router'; +import { CustomPaginatorComponent } from '@osf/shared/components/custom-paginator/custom-paginator.component'; import { IconComponent } from '@osf/shared/components/icon/icon.component'; import { LoadingSpinnerComponent } from '@osf/shared/components/loading-spinner/loading-spinner.component'; import { SubHeaderComponent } from '@osf/shared/components/sub-header/sub-header.component'; +import { DEFAULT_TABLE_PARAMS } from '@osf/shared/constants/default-table-params.constants'; import { RegistryResourceType } from '@osf/shared/enums/registry-resource.enum'; import { CustomConfirmationService } from '@osf/shared/services/custom-confirmation.service'; import { CustomDialogService } from '@osf/shared/services/custom-dialog.service'; @@ -59,6 +64,7 @@ function setup(overrides: BaseSetupOverrides = {}) { const defaultSignals = [ { selector: RegistryResourcesSelectors.getResources, value: [] }, + { selector: RegistryResourcesSelectors.getResourcesTotalCount, value: 0 }, { selector: RegistryResourcesSelectors.isResourcesLoading, value: false }, { selector: RegistryResourcesSelectors.getCurrentResource, value: null }, { selector: RegistrySelectors.getRegistry, value: null }, @@ -71,7 +77,7 @@ function setup(overrides: BaseSetupOverrides = {}) { TestBed.configureTestingModule({ imports: [ RegistryResourcesComponent, - ...MockComponents(LoadingSpinnerComponent, SubHeaderComponent, IconComponent), + ...MockComponents(Button, LoadingSpinnerComponent, SubHeaderComponent, IconComponent, CustomPaginatorComponent), ], providers: [ provideOSFCore(), @@ -100,79 +106,71 @@ function setup(overrides: BaseSetupOverrides = {}) { } describe('RegistryResourcesComponent', () => { - it('should create with default values', () => { - const { component } = setup(); + it('should initialize defaults and load the first page', () => { + const { component, store, fixture } = setup(); - expect(component).toBeTruthy(); expect(component.isAddingResource()).toBe(false); - expect(component.doiDomain).toBe('https://doi.org/'); + expect(component.first()).toBe(0); + expect(component.rows()).toBe(DEFAULT_TABLE_PARAMS.rows); + expect(component.addButtonVisible()).toBe(true); + expect(store.dispatch).toHaveBeenCalledWith(expect.objectContaining({ registryId: 'reg-1', page: 1 })); + expect(fixture.nativeElement.querySelector('osf-custom-paginator')).toBeFalsy(); }); - it('should dispatch getResources when registryId is available', () => { - const { store } = setup(); + it('should skip resource actions when registryId is missing', () => { + const { component, store, mockDialogService, mockConfirmationService } = setup({ hasParent: false }); - expect(store.dispatch).toHaveBeenCalledWith(expect.objectContaining({ registryId: 'reg-1' })); - }); - - it('should not dispatch getResources when registryId is not available', () => { - const { store } = setup({ hasParent: false }); + (store.dispatch as Mock).mockClear(); + component.addResource(); + component.updateResource(MOCK_RESOURCE); + component.deleteResource('res-1'); + component.onPageChange({ page: 1, first: 10, rows: 10 }); expect(store.dispatch).not.toHaveBeenCalled(); + expect(mockDialogService.open).not.toHaveBeenCalled(); + expect(mockConfirmationService.confirmDelete).not.toHaveBeenCalled(); + expect(component.isAddingResource()).toBe(false); + expect(component.first()).toBe(10); }); - it('should compute addButtonVisible when identifiers exist and canEdit', () => { - const { component } = setup(); - - expect(component.addButtonVisible()).toBe(true); - }); - - it('should compute addButtonVisible as false when no identifiers', () => { - const { component } = setup({ + it('should hide add button when identifiers or write access are missing', () => { + const { component: withoutIdentifiers } = setup({ selectorOverrides: [{ selector: RegistrySelectors.getIdentifiers, value: [] }], }); - - expect(component.addButtonVisible()).toBe(false); - }); - - it('should compute addButtonVisible as false when canEdit is false', () => { - const { component } = setup({ + const { component: withoutWriteAccess } = setup({ selectorOverrides: [{ selector: RegistrySelectors.hasWriteAccess, value: false }], }); - expect(component.addButtonVisible()).toBe(false); + expect(withoutIdentifiers.addButtonVisible()).toBe(false); + expect(withoutWriteAccess.addButtonVisible()).toBe(false); }); - it('should add resource and show success toast on dialog confirm', () => { + it('should add a resource, reset pagination, and show a success toast', () => { const { component, dialogClose$, mockDialogService, mockToastService, store } = setup(); (store.dispatch as Mock).mockClear(); + component.first.set(20); component.addResource(); - - expect(component.isAddingResource()).toBe(true); - expect(store.dispatch).toHaveBeenCalled(); - expect(mockDialogService.open).toHaveBeenCalled(); - dialogClose$.next(true); dialogClose$.complete(); + expect(mockDialogService.open).toHaveBeenCalled(); expect(mockToastService.showSuccess).toHaveBeenCalledWith('resources.toastMessages.addResourceSuccess'); expect(component.isAddingResource()).toBe(false); + expect(component.first()).toBe(0); }); - it('should reset isAddingResource when dialog is dismissed', () => { + it('should reset isAddingResource when the add dialog is dismissed', () => { const { component, dialogClose$ } = setup(); component.addResource(); - - expect(component.isAddingResource()).toBe(true); - dialogClose$.next(null); dialogClose$.complete(); expect(component.isAddingResource()).toBe(false); }); - it('should show error toast when addResource dispatch errors', () => { + it('should show an error toast when addResource fails', () => { const { component, store, mockToastService } = setup(); vi.spyOn(store, 'dispatch').mockReturnValue(throwError(() => new Error('fail'))); @@ -181,21 +179,12 @@ describe('RegistryResourcesComponent', () => { expect(mockToastService.showError).toHaveBeenCalledWith('resources.toastMessages.addResourceError'); }); - it('should not add resource when registryId is not available', () => { - const { component, store, mockDialogService } = setup({ hasParent: false }); - - (store.dispatch as Mock).mockClear(); - component.addResource(); - - expect(component.isAddingResource()).toBe(false); - expect(store.dispatch).not.toHaveBeenCalled(); - expect(mockDialogService.open).not.toHaveBeenCalled(); - }); - - it('should open edit dialog on updateResource', () => { - const { component, mockDialogService } = setup(); + it('should update a resource and show a success toast', () => { + const { component, dialogClose$, mockDialogService, mockToastService } = setup(); component.updateResource(MOCK_RESOURCE); + dialogClose$.next(true); + dialogClose$.complete(); expect(mockDialogService.open).toHaveBeenCalledWith( expect.any(Function), @@ -204,40 +193,29 @@ describe('RegistryResourcesComponent', () => { data: { id: 'reg-1', resource: MOCK_RESOURCE }, }) ); - }); - - it('should show success toast on updateResource dialog confirm', () => { - const { component, dialogClose$, mockToastService } = setup(); - - component.updateResource(MOCK_RESOURCE); - dialogClose$.next(true); - dialogClose$.complete(); - expect(mockToastService.showSuccess).toHaveBeenCalledWith('resources.toastMessages.updatedResourceSuccess'); }); - it('should show error toast when updateResource dialog errors', () => { + it('should show an error toast when updateResource fails', () => { const errorSubject = new Subject(); const { component, mockDialogService, mockToastService } = setup(); - mockDialogService.open.mockReturnValue({ onClose: errorSubject.pipe() } as any); + mockDialogService.open.mockReturnValue({ + onClose: errorSubject.pipe(), + close: vi.fn(), + } as unknown as DynamicDialogRef); component.updateResource(MOCK_RESOURCE); errorSubject.error(new Error('fail')); expect(mockToastService.showError).toHaveBeenCalledWith('resources.toastMessages.updateResourceError'); }); - it('should not update resource when registryId is not available', () => { - const { component, mockDialogService } = setup({ hasParent: false }); - - component.updateResource(MOCK_RESOURCE); - - expect(mockDialogService.open).not.toHaveBeenCalled(); - }); - - it('should delete resource with confirmation', () => { - const { component, mockConfirmationService } = setup(); + it('should delete a resource, reset pagination, and show a success toast', () => { + const { component, mockConfirmationService, mockToastService, store } = setup(); + mockConfirmationService.confirmDelete.mockImplementation(({ onConfirm }: { onConfirm: () => void }) => onConfirm()); + (store.dispatch as Mock).mockClear(); + component.first.set(20); component.deleteResource('res-1'); expect(mockConfirmationService.confirmDelete).toHaveBeenCalledWith( @@ -245,43 +223,49 @@ describe('RegistryResourcesComponent', () => { headerKey: 'resources.delete', messageKey: 'resources.deleteText', acceptLabelKey: 'common.buttons.remove', - onConfirm: expect.any(Function), }) ); - }); - - it('should dispatch delete and show toast on confirm', () => { - const { component, mockConfirmationService, mockToastService, store } = setup(); - - mockConfirmationService.confirmDelete.mockImplementation(({ onConfirm }: { onConfirm: () => void }) => onConfirm()); - - (store.dispatch as Mock).mockClear(); - component.deleteResource('res-1'); - expect(store.dispatch).toHaveBeenCalled(); expect(mockToastService.showSuccess).toHaveBeenCalledWith('resources.toastMessages.deletedResourceSuccess'); + expect(component.first()).toBe(0); }); - it('should not delete resource when registryId is not available', () => { - const { component, mockConfirmationService } = setup({ hasParent: false }); - - component.deleteResource('res-1'); - - expect(mockConfirmationService.confirmDelete).not.toHaveBeenCalled(); - }); - - it('should return translation key for known resource type', () => { + it('should resolve resource type labels', () => { const { component } = setup(); expect(component.getResourceTypeTranslationKey(RegistryResourceType.Data)).toBe('resourceCard.resources.data'); expect(component.getResourceTypeTranslationKey(RegistryResourceType.Code)).toBe( 'resourceCard.resources.analyticCode' ); + expect(component.getResourceTypeTranslationKey('unknown')).toBe(''); }); - it('should return empty string for unknown resource type', () => { - const { component } = setup(); + it('should load the selected page and keep current rows when rows are omitted', () => { + const { component, store } = setup(); - expect(component.getResourceTypeTranslationKey('unknown')).toBe(''); + (store.dispatch as Mock).mockClear(); + component.rows.set(25); + component.onPageChange({ page: 1, first: 25, rows: undefined }); + + expect(component.first()).toBe(25); + expect(component.rows()).toBe(25); + expect(store.dispatch).toHaveBeenCalledWith(expect.objectContaining({ registryId: 'reg-1', page: 2 })); + }); + + it('should not load a page when the paginator page is undefined', () => { + const { component, store } = setup(); + + (store.dispatch as Mock).mockClear(); + component.onPageChange({ page: undefined, first: 0, rows: 10 }); + + expect(store.dispatch).not.toHaveBeenCalled(); + }); + + it('should render the paginator when total count exceeds page size', () => { + const { fixture } = setup({ + selectorOverrides: [{ selector: RegistryResourcesSelectors.getResourcesTotalCount, value: 25 }], + }); + + expect(fixture.nativeElement.querySelector('osf-custom-paginator')).toBeTruthy(); }); }); diff --git a/src/app/features/registry/pages/registry-resources/registry-resources.component.ts b/src/app/features/registry/pages/registry-resources/registry-resources.component.ts index c8cc676f5..00f559dbd 100644 --- a/src/app/features/registry/pages/registry-resources/registry-resources.component.ts +++ b/src/app/features/registry/pages/registry-resources/registry-resources.component.ts @@ -3,6 +3,7 @@ import { createDispatchMap, select } from '@ngxs/store'; import { TranslatePipe } from '@ngx-translate/core'; import { Button } from 'primeng/button'; +import { PaginatorState } from 'primeng/paginator'; import { filter, finalize, map, of, switchMap } from 'rxjs'; @@ -19,9 +20,11 @@ import { import { takeUntilDestroyed, toSignal } from '@angular/core/rxjs-interop'; import { ActivatedRoute } from '@angular/router'; +import { CustomPaginatorComponent } from '@osf/shared/components/custom-paginator/custom-paginator.component'; import { IconComponent } from '@osf/shared/components/icon/icon.component'; import { LoadingSpinnerComponent } from '@osf/shared/components/loading-spinner/loading-spinner.component'; import { SubHeaderComponent } from '@osf/shared/components/sub-header/sub-header.component'; +import { DEFAULT_TABLE_PARAMS } from '@osf/shared/constants/default-table-params.constants'; import { RegistryResourceType } from '@osf/shared/enums/registry-resource.enum'; import { CustomConfirmationService } from '@osf/shared/services/custom-confirmation.service'; import { CustomDialogService } from '@osf/shared/services/custom-dialog.service'; @@ -41,13 +44,21 @@ import { @Component({ selector: 'osf-registry-resources', - imports: [Button, SubHeaderComponent, LoadingSpinnerComponent, IconComponent, TranslatePipe], + imports: [ + Button, + SubHeaderComponent, + LoadingSpinnerComponent, + IconComponent, + TranslatePipe, + CustomPaginatorComponent, + ], templateUrl: './registry-resources.component.html', styleUrl: './registry-resources.component.scss', changeDetection: ChangeDetectionStrategy.OnPush, }) export class RegistryResourcesComponent { @HostBinding('class') classes = 'flex-1 flex flex-column w-full h-full'; + private readonly route = inject(ActivatedRoute); private readonly customDialogService = inject(CustomDialogService); private readonly toastService = inject(ToastService); @@ -55,10 +66,12 @@ export class RegistryResourcesComponent { private readonly destroyRef = inject(DestroyRef); readonly resources = select(RegistryResourcesSelectors.getResources); + readonly resourcesTotalCount = select(RegistryResourcesSelectors.getResourcesTotalCount); readonly isResourcesLoading = select(RegistryResourcesSelectors.isResourcesLoading); readonly currentResource = select(RegistryResourcesSelectors.getCurrentResource); readonly registry = select(RegistrySelectors.getRegistry); readonly identifiers = select(RegistrySelectors.getIdentifiers); + readonly canEdit = select(RegistrySelectors.hasWriteAccess); private readonly registryId = toSignal( this.route.parent?.params.pipe(map((params) => params['id'])) ?? of(undefined) @@ -66,6 +79,8 @@ export class RegistryResourcesComponent { isAddingResource = signal(false); doiDomain = 'https://doi.org/'; + first = signal(0); + rows = signal(DEFAULT_TABLE_PARAMS.rows); private readonly actions = createDispatchMap({ getResources: GetRegistryResources, @@ -75,8 +90,6 @@ export class RegistryResourcesComponent { readonly RegistryResourceType = RegistryResourceType; - canEdit = select(RegistrySelectors.hasWriteAccess); - addButtonVisible = computed(() => !!this.identifiers().length && this.canEdit()); getResourceTypeTranslationKey(type: string): string { @@ -88,6 +101,7 @@ export class RegistryResourcesComponent { const registryId = this.registryId(); if (registryId) { + this.resetPagination(); this.actions.getResources(registryId); } }); @@ -108,7 +122,10 @@ export class RegistryResourcesComponent { takeUntilDestroyed(this.destroyRef) ) .subscribe({ - next: () => this.toastService.showSuccess('resources.toastMessages.addResourceSuccess'), + next: () => { + this.resetPagination(); + this.toastService.showSuccess('resources.toastMessages.addResourceSuccess'); + }, error: () => this.toastService.showError('resources.toastMessages.addResourceError'), }); } @@ -145,11 +162,32 @@ export class RegistryResourcesComponent { this.actions .deleteResource(id, registryId) .pipe(takeUntilDestroyed(this.destroyRef)) - .subscribe(() => this.toastService.showSuccess('resources.toastMessages.deletedResourceSuccess')); + .subscribe(() => { + this.resetPagination(); + this.toastService.showSuccess('resources.toastMessages.deletedResourceSuccess'); + }); }, }); } + onPageChange(event: PaginatorState) { + this.first.set(event.first ?? 0); + this.rows.set(event.rows ?? this.rows()); + + if (event.page === undefined) { + return; + } + + const registryId = this.registryId(); + if (!registryId) return; + + this.actions.getResources(registryId, event.page + 1); + } + + private resetPagination() { + this.first.set(0); + } + private openAddResourceDialog(registryId: string) { return this.customDialogService.open(AddResourceDialogComponent, { header: 'resources.add', diff --git a/src/app/features/registry/services/registry-resources.service.spec.ts b/src/app/features/registry/services/registry-resources.service.spec.ts new file mode 100644 index 000000000..d6118670f --- /dev/null +++ b/src/app/features/registry/services/registry-resources.service.spec.ts @@ -0,0 +1,115 @@ +import { HttpTestingController } from '@angular/common/http/testing'; +import { TestBed } from '@angular/core/testing'; + +import { DEFAULT_TABLE_PARAMS } from '@osf/shared/constants/default-table-params.constants'; +import { RegistryResourceType } from '@osf/shared/enums/registry-resource.enum'; +import { PaginatedData } from '@osf/shared/models/paginated-data.model'; + +import { provideOSFCore, provideOSFHttp } from '@testing/osf.testing.provider'; +import { EnvironmentTokenMock } from '@testing/providers/environment.token.mock'; + +import { GetRegistryResourcesJsonApi, RegistryResource, RegistryResourceDataJsonApi } from '../models'; + +import { RegistryResourcesService } from './registry-resources.service'; + +const apiResource: RegistryResourceDataJsonApi = { + id: 'res-1', + type: 'resources', + attributes: { + description: 'Dataset description', + finalized: true, + pid: '10.123/test', + resource_type: RegistryResourceType.Data, + }, +}; + +const mappedResource: RegistryResource = { + id: 'res-1', + description: 'Dataset description', + finalized: true, + type: RegistryResourceType.Data, + pid: '10.123/test', +}; + +describe('RegistryResourcesService', () => { + let service: RegistryResourcesService; + let httpMock: HttpTestingController; + const apiBase = `${EnvironmentTokenMock.useValue.apiDomainUrl}/v2`; + + beforeEach(() => { + TestBed.configureTestingModule({ + providers: [provideOSFCore(), provideOSFHttp(), RegistryResourcesService], + }); + service = TestBed.inject(RegistryResourcesService); + httpMock = TestBed.inject(HttpTestingController); + }); + + it('should get resources with default pagination params and map the response', () => { + const response: GetRegistryResourcesJsonApi = { + data: [apiResource], + meta: { total: 12, per_page: 10 }, + }; + let result: PaginatedData | undefined; + + service.getResources('reg-1').subscribe((value) => (result = value)); + + const req = httpMock.expectOne( + (request) => + request.url === `${apiBase}/registrations/reg-1/resources/` && + request.params.get('fields[resources]') === 'description,finalized,resource_type,pid' && + request.params.get('page') === '1' && + request.params.get('page[size]') === String(DEFAULT_TABLE_PARAMS.rows) + ); + expect(req.request.method).toBe('GET'); + req.flush(response); + + expect(result).toEqual({ + data: [mappedResource], + totalCount: 12, + pageSize: 10, + }); + httpMock.verify(); + }); + + it('should get resources with custom page and page size', () => { + const response: GetRegistryResourcesJsonApi = { + data: [apiResource], + meta: { total: 25, per_page: 5 }, + }; + let result: PaginatedData | undefined; + + service.getResources('reg-1', 3, 5).subscribe((value) => (result = value)); + + const req = httpMock.expectOne( + (request) => + request.url === `${apiBase}/registrations/reg-1/resources/` && + request.params.get('page') === '3' && + request.params.get('page[size]') === '5' + ); + expect(req.request.method).toBe('GET'); + req.flush(response); + + expect(result).toEqual({ + data: [mappedResource], + totalCount: 25, + pageSize: 5, + }); + httpMock.verify(); + }); + + it('should fall back to default page size when per_page is missing', () => { + const response: GetRegistryResourcesJsonApi = { + data: [apiResource], + meta: { total: 1 }, + }; + let result: PaginatedData | undefined; + + service.getResources('reg-1').subscribe((value) => (result = value)); + + const req = httpMock.expectOne((request) => request.url === `${apiBase}/registrations/reg-1/resources/`); + req.flush(response); + + expect(result?.pageSize).toBe(DEFAULT_TABLE_PARAMS.rows); + httpMock.verify(); + }); +}); diff --git a/src/app/features/registry/services/registry-resources.service.ts b/src/app/features/registry/services/registry-resources.service.ts index 39366b50b..61d2d3305 100644 --- a/src/app/features/registry/services/registry-resources.service.ts +++ b/src/app/features/registry/services/registry-resources.service.ts @@ -3,6 +3,8 @@ import { map, Observable } from 'rxjs'; import { inject, Injectable } from '@angular/core'; import { ENVIRONMENT } from '@core/provider/environment.provider'; +import { DEFAULT_TABLE_PARAMS } from '@osf/shared/constants/default-table-params.constants'; +import { PaginatedData } from '@osf/shared/models/paginated-data.model'; import { JsonApiService } from '@osf/shared/services/json-api.service'; import { MapAddResourceRequest, MapRegistryResource, toAddResourceRequestBody } from '../mappers'; @@ -26,14 +28,26 @@ export class RegistryResourcesService { return `${this.environment.apiDomainUrl}/v2`; } - getResources(registryId: string): Observable { + getResources( + registryId: string, + page = 1, + pageSize = DEFAULT_TABLE_PARAMS.rows + ): Observable> { const params = { 'fields[resources]': 'description,finalized,resource_type,pid', + page, + 'page[size]': pageSize, }; return this.jsonApiService - .get(`${this.apiUrl}/registrations/${registryId}/resources/?page=1`, params) - .pipe(map((response) => response.data.map((resource) => MapRegistryResource(resource)))); + .get(`${this.apiUrl}/registrations/${registryId}/resources/`, params) + .pipe( + map((response) => ({ + data: response.data.map((resource) => MapRegistryResource(resource)), + totalCount: response.meta.total, + pageSize: response.meta.per_page ?? DEFAULT_TABLE_PARAMS.rows, + })) + ); } addRegistryResource(registryId: string): Observable { diff --git a/src/app/features/registry/store/registry-resources/registry-resources.actions.ts b/src/app/features/registry/store/registry-resources/registry-resources.actions.ts index 11109ed33..9960a6ebc 100644 --- a/src/app/features/registry/store/registry-resources/registry-resources.actions.ts +++ b/src/app/features/registry/store/registry-resources/registry-resources.actions.ts @@ -3,7 +3,10 @@ import { AddResource, ConfirmAddResource } from '../../models'; export class GetRegistryResources { static readonly type = '[Registry Resources] Get Registry Resources'; - constructor(public registryId: string) {} + constructor( + public registryId: string, + public page = 1 + ) {} } export class AddRegistryResource { diff --git a/src/app/features/registry/store/registry-resources/registry-resources.model.ts b/src/app/features/registry/store/registry-resources/registry-resources.model.ts index f51349805..349eae373 100644 --- a/src/app/features/registry/store/registry-resources/registry-resources.model.ts +++ b/src/app/features/registry/store/registry-resources/registry-resources.model.ts @@ -1,10 +1,12 @@ import { AsyncStateModel } from '@osf/shared/models/store/async-state.model'; +import { AsyncStateWithTotalCount } from '@osf/shared/models/store/async-state-with-total-count.model'; import { RegistryResource } from '../../models'; export interface RegistryResourcesStateModel { - resources: AsyncStateModel; + resources: AsyncStateWithTotalCount; currentResource: AsyncStateModel; + currentPage: number; } export const REGISTRY_RESOURCES_STATE_DEFAULTS = { @@ -12,10 +14,12 @@ export const REGISTRY_RESOURCES_STATE_DEFAULTS = { data: null, isLoading: false, error: null, + totalCount: 0, }, currentResource: { data: null, isLoading: false, error: null, }, + currentPage: 1, }; diff --git a/src/app/features/registry/store/registry-resources/registry-resources.selectors.ts b/src/app/features/registry/store/registry-resources/registry-resources.selectors.ts index 447c95c19..9ed29d6ed 100644 --- a/src/app/features/registry/store/registry-resources/registry-resources.selectors.ts +++ b/src/app/features/registry/store/registry-resources/registry-resources.selectors.ts @@ -11,6 +11,11 @@ export class RegistryResourcesSelectors { return state.resources.data; } + @Selector([RegistryResourcesState]) + static getResourcesTotalCount(state: RegistryResourcesStateModel): number { + return state.resources.totalCount; + } + @Selector([RegistryResourcesState]) static isResourcesLoading(state: RegistryResourcesStateModel): boolean { return state.resources.isLoading; diff --git a/src/app/features/registry/store/registry-resources/registry-resources.state.spec.ts b/src/app/features/registry/store/registry-resources/registry-resources.state.spec.ts new file mode 100644 index 000000000..3a87f9289 --- /dev/null +++ b/src/app/features/registry/store/registry-resources/registry-resources.state.spec.ts @@ -0,0 +1,132 @@ +import { provideStore, Store } from '@ngxs/store'; + +import { MockProvider } from 'ng-mocks'; + +import { firstValueFrom, of, Subject, throwError } from 'rxjs'; + +import { TestBed } from '@angular/core/testing'; + +import { RegistryResourceType } from '@osf/shared/enums/registry-resource.enum'; +import { PaginatedData } from '@osf/shared/models/paginated-data.model'; + +import { RegistryResource } from '../../models'; +import { RegistryResourcesService } from '../../services'; + +import { + ConfirmAddRegistryResource, + DeleteResource, + GetRegistryResources, + UpdateResource, +} from './registry-resources.actions'; +import { RegistryResourcesSelectors } from './registry-resources.selectors'; +import { RegistryResourcesState } from './registry-resources.state'; + +const MOCK_RESOURCE: RegistryResource = { + id: 'res-1', + description: 'Test resource', + finalized: true, + type: RegistryResourceType.Data, + pid: '10.123/test', +}; + +const MOCK_PAGINATED_RESOURCES: PaginatedData = { + data: [MOCK_RESOURCE], + totalCount: 21, + pageSize: 10, +}; + +describe('RegistryResourcesState', () => { + let store: Store; + let getResourcesMock: ReturnType>; + let deleteResourceMock: ReturnType>; + let confirmAddingResourceMock: ReturnType>; + let updateResourceMock: ReturnType>; + + beforeEach(() => { + getResourcesMock = vi.fn().mockReturnValue(of(MOCK_PAGINATED_RESOURCES)); + deleteResourceMock = vi.fn().mockReturnValue(of(undefined)); + confirmAddingResourceMock = vi + .fn() + .mockReturnValue(of(MOCK_RESOURCE)); + updateResourceMock = vi.fn().mockReturnValue(of(undefined)); + + const mockService: Pick< + RegistryResourcesService, + 'getResources' | 'deleteResource' | 'confirmAddingResource' | 'updateResource' + > = { + getResources: getResourcesMock, + deleteResource: deleteResourceMock, + confirmAddingResource: confirmAddingResourceMock, + updateResource: updateResourceMock, + }; + + TestBed.configureTestingModule({ + providers: [provideStore([RegistryResourcesState]), MockProvider(RegistryResourcesService, mockService)], + }); + + store = TestBed.inject(Store); + }); + + it('should fetch resources for a page and update total count', async () => { + const subject = new Subject>(); + getResourcesMock.mockReturnValue(subject.asObservable()); + + const dispatchPromise = firstValueFrom(store.dispatch(new GetRegistryResources('reg-1', 2))); + + expect(store.selectSnapshot(RegistryResourcesSelectors.isResourcesLoading)).toBe(true); + expect(getResourcesMock).toHaveBeenCalledWith('reg-1', 2); + + subject.next(MOCK_PAGINATED_RESOURCES); + subject.complete(); + await dispatchPromise; + + expect(store.selectSnapshot(RegistryResourcesSelectors.getResources)).toEqual([MOCK_RESOURCE]); + expect(store.selectSnapshot(RegistryResourcesSelectors.getResourcesTotalCount)).toBe(21); + expect(store.selectSnapshot(RegistryResourcesSelectors.isResourcesLoading)).toBe(false); + expect(store.snapshot().registryResources.currentPage).toBe(2); + }); + + it('should handle get resources error', async () => { + getResourcesMock.mockReturnValue(throwError(() => new Error('Failed to fetch resources'))); + + await expect(firstValueFrom(store.dispatch(new GetRegistryResources('reg-1', 1)))).rejects.toThrow( + 'Failed to fetch resources' + ); + + const snapshot = store.snapshot().registryResources.resources; + expect(snapshot.data).toBeNull(); + expect(snapshot.error).toBe('Failed to fetch resources'); + expect(snapshot.isLoading).toBe(false); + }); + + it('should refetch the first page after delete', async () => { + await firstValueFrom(store.dispatch(new DeleteResource('res-1', 'reg-1'))); + + expect(deleteResourceMock).toHaveBeenCalledWith('res-1'); + expect(getResourcesMock).toHaveBeenCalledWith('reg-1', 1); + }); + + it('should refetch the first page after confirm add', async () => { + await firstValueFrom(store.dispatch(new ConfirmAddRegistryResource({ finalized: true }, 'res-1', 'reg-1'))); + + expect(confirmAddingResourceMock).toHaveBeenCalledWith('res-1', { finalized: true }); + expect(getResourcesMock).toHaveBeenCalledWith('reg-1', 1); + }); + + it('should refetch the current page after update', async () => { + await firstValueFrom(store.dispatch(new GetRegistryResources('reg-1', 3))); + getResourcesMock.mockClear(); + + await firstValueFrom( + store.dispatch( + new UpdateResource('reg-1', 'res-1', { + pid: '10.123/updated', + resource_type: RegistryResourceType.Data, + }) + ) + ); + + expect(updateResourceMock).toHaveBeenCalled(); + expect(getResourcesMock).toHaveBeenCalledWith('reg-1', 3); + }); +}); diff --git a/src/app/features/registry/store/registry-resources/registry-resources.state.ts b/src/app/features/registry/store/registry-resources/registry-resources.state.ts index c54223f86..98fffdef7 100644 --- a/src/app/features/registry/store/registry-resources/registry-resources.state.ts +++ b/src/app/features/registry/store/registry-resources/registry-resources.state.ts @@ -38,14 +38,16 @@ export class RegistryResourcesState { }, }); - return this.registryResourcesService.getResources(action.registryId).pipe( + return this.registryResourcesService.getResources(action.registryId, action.page).pipe( tap((resources) => { ctx.patchState({ resources: { - data: resources, + data: resources.data, isLoading: false, error: null, + totalCount: resources.totalCount, }, + currentPage: action.page, }); }), catchError((err) => handleSectionError(ctx, 'resources', err)) @@ -105,7 +107,7 @@ export class RegistryResourcesState { confirmAddRegistryResource(ctx: StateContext, action: ConfirmAddRegistryResource) { return this.registryResourcesService.confirmAddingResource(action.resourceId, action.resource).pipe( tap(() => { - ctx.dispatch(new GetRegistryResources(action.registryId)); + ctx.dispatch(new GetRegistryResources(action.registryId, 1)); }), catchError((err) => handleSectionError(ctx, 'resources', err)) ); @@ -123,7 +125,7 @@ export class RegistryResourcesState { return this.registryResourcesService.deleteResource(action.resourceId).pipe( tap(() => { - ctx.dispatch(new GetRegistryResources(action.registryId)); + ctx.dispatch(new GetRegistryResources(action.registryId, 1)); }), catchError((err) => handleSectionError(ctx, 'resources', err)) ); @@ -152,7 +154,7 @@ export class RegistryResourcesState { isLoading: false, }, }); - ctx.dispatch(new GetRegistryResources(action.registryId)); + ctx.dispatch(new GetRegistryResources(action.registryId, ctx.getState().currentPage)); }), catchError((err) => handleSectionError(ctx, 'resources', err)) );