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 (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))
);