tableRows"
- [totalRecords]="currentTotalCount()"
+ [rows]="tableParams().rows"
+ [first]="tableParams().firstRowIndex"
+ [paginator]="tableParams().paginator"
+ [totalRecords]="tableParams().totalRecords"
paginatorDropdownAppendTo="body"
[resizableColumns]="true"
[autoLayout]="true"
- [scrollable]="true"
- [sortMode]="'single'"
+ [scrollable]="tableParams().scrollable"
[lazy]="true"
[lazyLoadOnInit]="true"
(onPage)="onPageChange($event)"
@@ -74,7 +73,7 @@
@@ -84,15 +83,19 @@
| {{ item.dateCreated | date: 'MMM d, y' }} |
{{ item.dateModified | date: 'MMM d, y' }} |
- @for (contributor of item.contributors; track contributor.id) {
+ @for (contributor of item.contributors.slice(0, 3); track contributor.id) {
{{ contributor.fullName }}{{ $last ? '' : ', ' }}
}
+ @if (item.contributors.length > 3) {
+ , {{ 'common.labels.and' | translate }} {{ item.contributors.length - 3 }}
+ {{ 'common.labels.more' | translate }}
+ }
|
} @else {
|
-
+
|
}
diff --git a/src/app/features/project/overview/components/link-resource-dialog/link-resource-dialog.component.spec.ts b/src/app/features/project/overview/components/link-resource-dialog/link-resource-dialog.component.spec.ts
index 14f9d04ba..f94d9f4c9 100644
--- a/src/app/features/project/overview/components/link-resource-dialog/link-resource-dialog.component.spec.ts
+++ b/src/app/features/project/overview/components/link-resource-dialog/link-resource-dialog.component.spec.ts
@@ -1,26 +1,185 @@
+import { Store } from '@ngxs/store';
+
import { MockComponents } from 'ng-mocks';
+import { TablePageEvent } from 'primeng/table';
+
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { SearchInputComponent } from '@osf/shared/components/search-input/search-input.component';
+import { ResourceSearchMode } from '@osf/shared/enums/resource-search-mode.enum';
+import { ResourceType } from '@osf/shared/enums/resource-type.enum';
+import { MyResourcesSelectors } from '@osf/shared/stores/my-resources';
+import { NodeLinksSelectors } from '@osf/shared/stores/node-links';
+import { MyResourcesItem } from '@shared/models/my-resources/my-resources.models';
+
+import { ProjectOverviewSelectors } from '../../store';
import { LinkResourceDialogComponent } from './link-resource-dialog.component';
-describe.skip('LinkProjectDialogComponent', () => {
+import { DynamicDialogRefMock } from '@testing/mocks/dynamic-dialog-ref.mock';
+import {
+ MOCK_MY_RESOURCES_ITEM_PROJECT,
+ MOCK_MY_RESOURCES_ITEM_PROJECT_PRIVATE,
+ MOCK_MY_RESOURCES_ITEM_REGISTRATION,
+} from '@testing/mocks/my-resources.mock';
+import { MOCK_NODE_WITH_ADMIN } from '@testing/mocks/node.mock';
+import { OSFTestingModule } from '@testing/osf.testing.module';
+import { provideMockStore } from '@testing/providers/store-provider.mock';
+
+describe('LinkResourceDialogComponent', () => {
let component: LinkResourceDialogComponent;
let fixture: ComponentFixture;
+ let store: Store;
+ let dialogRef: { close: jest.Mock };
+
+ const mockProjects: MyResourcesItem[] = [MOCK_MY_RESOURCES_ITEM_PROJECT, MOCK_MY_RESOURCES_ITEM_PROJECT_PRIVATE];
+
+ const mockRegistrations: MyResourcesItem[] = [MOCK_MY_RESOURCES_ITEM_REGISTRATION];
+
+ const mockLinkedResources = [{ ...MOCK_NODE_WITH_ADMIN, id: 'project-1', title: 'Linked Project 1' }];
+
+ const mockCurrentProject = { ...MOCK_NODE_WITH_ADMIN, id: 'current-project-id' };
beforeEach(async () => {
+ dialogRef = { close: jest.fn() };
+
await TestBed.configureTestingModule({
- imports: [LinkResourceDialogComponent, ...MockComponents(SearchInputComponent)],
+ imports: [LinkResourceDialogComponent, OSFTestingModule, ...MockComponents(SearchInputComponent)],
+ providers: [
+ provideMockStore({
+ signals: [
+ { selector: MyResourcesSelectors.getProjects, value: mockProjects },
+ { selector: MyResourcesSelectors.getProjectsLoading, value: false },
+ { selector: MyResourcesSelectors.getRegistrations, value: mockRegistrations },
+ { selector: MyResourcesSelectors.getRegistrationsLoading, value: false },
+ { selector: MyResourcesSelectors.getTotalProjects, value: 2 },
+ { selector: MyResourcesSelectors.getTotalRegistrations, value: 1 },
+ { selector: NodeLinksSelectors.getNodeLinksSubmitting, value: false },
+ { selector: NodeLinksSelectors.getLinkedResources, value: mockLinkedResources },
+ { selector: ProjectOverviewSelectors.getProject, value: mockCurrentProject },
+ ],
+ }),
+ { provide: DynamicDialogRefMock.provide, useValue: dialogRef },
+ ],
}).compileComponents();
fixture = TestBed.createComponent(LinkResourceDialogComponent);
component = fixture.componentInstance;
+ store = TestBed.inject(Store);
+ fixture.detectChanges();
+ });
+
+ it('should initialize with default values', () => {
+ expect(component.currentPage()).toBe(1);
+ expect(component.searchMode()).toBe(ResourceSearchMode.User);
+ expect(component.resourceType()).toBe(ResourceType.Project);
+ expect(component.searchControl.value).toBe('');
+ });
+
+ it('should initialize skeleton data with correct length', () => {
+ expect(component.skeletonData.length).toBe(component.tableRows);
+ });
+
+ it('should compute currentResourceId from currentProject', () => {
+ expect(component.currentResourceId()).toBe('current-project-id');
+ });
+
+ it('should compute isCurrentTableLoading for registrations', () => {
+ component.resourceType.set(ResourceType.Registration);
fixture.detectChanges();
+ expect(component.isCurrentTableLoading()).toBe(false);
});
- it('should create', () => {
- expect(component).toBeTruthy();
+ it('should compute currentTotalCount for registrations', () => {
+ component.resourceType.set(ResourceType.Registration);
+ fixture.detectChanges();
+ expect(component.currentTotalCount()).toBe(1);
+ });
+
+ it('should compute tableParams correctly', () => {
+ const params = component.tableParams();
+ expect(params.rows).toBe(component.tableRows);
+ expect(params.firstRowIndex).toBe(0);
+ expect(params.paginator).toBe(false);
+ expect(params.totalRecords).toBe(2);
+ });
+
+ it('should compute linkedResourceIds as a Set', () => {
+ const linkedIds = component.linkedResourceIds();
+ expect(linkedIds).toBeInstanceOf(Set);
+ expect(linkedIds.has('project-1')).toBe(true);
+ expect(linkedIds.has('project-2')).toBe(false);
+ });
+
+ it('should update searchMode and reset to first page', () => {
+ component.currentPage.set(2);
+ component.onSearchModeChange(ResourceSearchMode.All);
+
+ expect(component.searchMode()).toBe(ResourceSearchMode.All);
+ expect(component.currentPage()).toBe(1);
+ });
+
+ it('should update resourceType and reset to first page', () => {
+ component.currentPage.set(2);
+ component.onObjectTypeChange(ResourceType.Registration);
+
+ expect(component.resourceType()).toBe(ResourceType.Registration);
+ expect(component.currentPage()).toBe(1);
+ });
+
+ it('should update currentPage and trigger search', () => {
+ const dispatchSpy = jest.spyOn(store, 'dispatch');
+ const event: TablePageEvent = {
+ first: 6,
+ rows: 6,
+ };
+
+ component.onPageChange(event);
+
+ expect(component.currentPage()).toBe(2);
+ expect(dispatchSpy).toHaveBeenCalled();
+ });
+
+ it('should return true for linked items', () => {
+ expect(component.isItemLinked('project-1')).toBe(true);
+ });
+
+ it('should return false for non-linked items', () => {
+ expect(component.isItemLinked('project-2')).toBe(false);
+ });
+
+ it('should close the dialog', () => {
+ component.handleCloseDialog();
+ expect(dialogRef.close).toHaveBeenCalled();
+ });
+
+ it('should trigger search when searchMode changes', () => {
+ const dispatchSpy = jest.spyOn(store, 'dispatch');
+
+ component.onSearchModeChange(ResourceSearchMode.All);
+
+ expect(dispatchSpy).toHaveBeenCalled();
+ });
+
+ it('should trigger search when resourceType changes', () => {
+ const dispatchSpy = jest.spyOn(store, 'dispatch');
+
+ component.onObjectTypeChange(ResourceType.Registration);
+
+ expect(dispatchSpy).toHaveBeenCalled();
+ });
+
+ it('should handle page change with zero first value', () => {
+ const dispatchSpy = jest.spyOn(store, 'dispatch');
+ const event: TablePageEvent = {
+ first: 0,
+ rows: 6,
+ };
+
+ component.onPageChange(event);
+
+ expect(component.currentPage()).toBe(1);
+ expect(dispatchSpy).toHaveBeenCalled();
});
});
diff --git a/src/app/features/project/overview/components/link-resource-dialog/link-resource-dialog.component.ts b/src/app/features/project/overview/components/link-resource-dialog/link-resource-dialog.component.ts
index 7c880f723..fe5542a89 100644
--- a/src/app/features/project/overview/components/link-resource-dialog/link-resource-dialog.component.ts
+++ b/src/app/features/project/overview/components/link-resource-dialog/link-resource-dialog.component.ts
@@ -26,12 +26,14 @@ import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
import { FormControl, FormsModule } from '@angular/forms';
import { SearchInputComponent } from '@osf/shared/components/search-input/search-input.component';
+import { DEFAULT_TABLE_PARAMS } from '@osf/shared/constants/default-table-params.constants';
import { ResourceSearchMode } from '@osf/shared/enums/resource-search-mode.enum';
import { ResourceType } from '@osf/shared/enums/resource-type.enum';
import { GetMyProjects, GetMyRegistrations, MyResourcesSelectors } from '@osf/shared/stores/my-resources';
-import { CreateNodeLink, DeleteNodeLink, GetLinkedResources, NodeLinksSelectors } from '@osf/shared/stores/node-links';
+import { CreateNodeLink, DeleteNodeLink, NodeLinksSelectors } from '@osf/shared/stores/node-links';
import { MyResourcesItem } from '@shared/models/my-resources/my-resources.models';
import { MyResourcesSearchFilters } from '@shared/models/my-resources/my-resources-search-filters.models';
+import { TableParameters } from '@shared/models/table-parameters.model';
import { ProjectOverviewSelectors } from '../../store';
@@ -67,7 +69,6 @@ export class LinkResourceDialogComponent {
skeletonData: MyResourcesItem[] = Array.from({ length: this.tableRows }, () => ({}) as MyResourcesItem);
- currentProject = select(ProjectOverviewSelectors.getProject);
myProjects = select(MyResourcesSelectors.getProjects);
isMyProjectsLoading = select(MyResourcesSelectors.getProjectsLoading);
myRegistrations = select(MyResourcesSelectors.getRegistrations);
@@ -76,10 +77,16 @@ export class LinkResourceDialogComponent {
totalRegistrationsCount = select(MyResourcesSelectors.getTotalRegistrations);
isNodeLinksSubmitting = select(NodeLinksSelectors.getNodeLinksSubmitting);
linkedResources = select(NodeLinksSelectors.getLinkedResources);
+ hasChanges = select(NodeLinksSelectors.getNodeLinksHasChanges);
+ currentProject = select(ProjectOverviewSelectors.getProject);
- currentTableItems = computed(() =>
- this.resourceType() === ResourceType.Project ? this.myProjects() : this.myRegistrations()
- );
+ currentResourceId = computed(() => this.currentProject()?.id);
+
+ currentTableItems = computed(() => {
+ const items = this.resourceType() === ResourceType.Project ? this.myProjects() : this.myRegistrations();
+ const currentId = this.currentResourceId();
+ return currentId ? items.filter((item) => item.id !== currentId) : items;
+ });
isCurrentTableLoading = computed(() =>
this.resourceType() === ResourceType.Project ? this.isMyProjectsLoading() : this.isMyRegistrationsLoading()
@@ -89,11 +96,17 @@ export class LinkResourceDialogComponent {
this.resourceType() === ResourceType.Project ? this.totalProjectsCount() : this.totalRegistrationsCount()
);
- isItemLinked = computed(() => {
- const linkedResources = this.linkedResources();
- const linkedTargetIds = new Set(linkedResources.map((resource) => resource.id));
+ tableParams = computed(() => ({
+ ...DEFAULT_TABLE_PARAMS,
+ rows: this.tableRows,
+ firstRowIndex: (this.currentPage() - 1) * this.tableRows,
+ paginator: this.currentTotalCount() > this.tableRows,
+ totalRecords: this.currentTotalCount(),
+ }));
- return (itemId: string) => linkedTargetIds.has(itemId);
+ linkedResourceIds = computed(() => {
+ const linkedResources = this.linkedResources();
+ return new Set(linkedResources.map((resource) => resource.id));
});
actions = createDispatchMap({
@@ -101,23 +114,21 @@ export class LinkResourceDialogComponent {
getRegistrations: GetMyRegistrations,
createNodeLink: CreateNodeLink,
deleteNodeLink: DeleteNodeLink,
- getLinkedProjects: GetLinkedResources,
});
constructor() {
this.setupSearchEffect();
this.setupSearchSubscription();
- this.setupNodeLinksEffect();
}
onSearchModeChange(mode: ResourceSearchMode): void {
this.searchMode.set(mode);
- this.currentPage.set(1);
+ this.resetToFirstPage();
}
onObjectTypeChange(type: ResourceType): void {
this.resourceType.set(type);
- this.currentPage.set(1);
+ this.resetToFirstPage();
}
onPageChange(event: TablePageEvent): void {
@@ -126,19 +137,18 @@ export class LinkResourceDialogComponent {
this.handleSearch(this.searchControl.value || '', this.searchMode(), this.resourceType());
}
- setupSearchEffect() {
- effect(() => {
- this.currentPage.set(1);
- this.handleSearch(this.searchControl.value || '', this.searchMode(), this.resourceType());
- });
+ isItemLinked(itemId: string): boolean {
+ return this.linkedResourceIds().has(itemId);
+ }
+
+ private resetToFirstPage(): void {
+ this.currentPage.set(1);
}
- setupNodeLinksEffect() {
+ setupSearchEffect() {
effect(() => {
- const currentProject = this.currentProject();
- if (currentProject) {
- this.actions.getLinkedProjects(currentProject.id);
- }
+ this.resetToFirstPage();
+ this.handleSearch(this.searchControl.value || '', this.searchMode(), this.resourceType());
});
}
@@ -146,31 +156,23 @@ export class LinkResourceDialogComponent {
this.searchControl.valueChanges
.pipe(debounceTime(300), distinctUntilChanged(), takeUntilDestroyed(this.destroyRef))
.subscribe((searchValue) => {
- this.currentPage.set(1);
+ this.resetToFirstPage();
this.handleSearch(searchValue ?? '', this.searchMode(), this.resourceType());
});
}
handleCloseDialog() {
- const currentProjectId = this.currentProject()?.id;
-
- if (!currentProjectId) {
- this.dialogRef.close();
- return;
- }
-
- this.actions.getLinkedProjects(currentProjectId);
- this.dialogRef.close();
+ this.dialogRef.close({ hasChanges: this.hasChanges() });
}
handleToggleNodeLink(resource: MyResourcesItem) {
- const currentProjectId = this.currentProject()?.id;
+ const currentResourceId = this.currentResourceId();
- if (!currentProjectId) {
+ if (!currentResourceId) {
return;
}
- const isCurrentlyLinked = this.isItemLinked()(resource.id);
+ const isCurrentlyLinked = this.isItemLinked(resource.id);
if (isCurrentlyLinked) {
const resources = this.linkedResources();
@@ -178,9 +180,9 @@ export class LinkResourceDialogComponent {
if (!linkToDelete) return;
- this.actions.deleteNodeLink(currentProjectId, linkToDelete);
+ this.actions.deleteNodeLink(currentResourceId, linkToDelete);
} else {
- this.actions.createNodeLink(currentProjectId, resource);
+ this.actions.createNodeLink(currentResourceId, resource);
}
}
@@ -191,13 +193,13 @@ export class LinkResourceDialogComponent {
};
untracked(() => {
- const currentProjectId = this.currentProject()?.id;
- if (!currentProjectId) return;
+ const currentResourceId = this.currentResourceId();
+ if (!currentResourceId) return;
if (resourceType === ResourceType.Project) {
- this.actions.getProjects(this.currentPage(), this.tableRows, searchFilters, searchMode, currentProjectId);
+ this.actions.getProjects(this.currentPage(), this.tableRows, searchFilters, searchMode, undefined);
} else if (resourceType === ResourceType.Registration) {
- this.actions.getRegistrations(this.currentPage(), this.tableRows, searchFilters, searchMode, currentProjectId);
+ this.actions.getRegistrations(this.currentPage(), this.tableRows, searchFilters, searchMode, undefined);
}
});
}
diff --git a/src/app/features/project/overview/components/linked-resources/linked-resources.component.html b/src/app/features/project/overview/components/linked-resources/linked-resources.component.html
index b02a69622..550c84a5c 100644
--- a/src/app/features/project/overview/components/linked-resources/linked-resources.component.html
+++ b/src/app/features/project/overview/components/linked-resources/linked-resources.component.html
@@ -21,6 +21,7 @@
+
@if (canEdit()) {
-
+
{{ 'common.labels.contributors' | translate }}:
@@ -53,4 +54,14 @@
}
}
+ @if (hasMoreLinkedResources() && !isLinkedResourcesLoading()) {
+
+ }
diff --git a/src/app/features/project/overview/components/linked-resources/linked-resources.component.spec.ts b/src/app/features/project/overview/components/linked-resources/linked-resources.component.spec.ts
index 42f53a8f4..14e948bab 100644
--- a/src/app/features/project/overview/components/linked-resources/linked-resources.component.spec.ts
+++ b/src/app/features/project/overview/components/linked-resources/linked-resources.component.spec.ts
@@ -7,6 +7,7 @@ import { IconComponent } from '@osf/shared/components/icon/icon.component';
import { CustomDialogService } from '@osf/shared/services/custom-dialog.service';
import { NodeLinksSelectors } from '@osf/shared/stores/node-links';
+import { ProjectOverviewSelectors } from '../../store';
import { DeleteNodeLinkDialogComponent } from '../delete-node-link-dialog/delete-node-link-dialog.component';
import { LinkResourceDialogComponent } from '../link-resource-dialog/link-resource-dialog.component';
@@ -42,6 +43,9 @@ describe('LinkedProjectsComponent', () => {
signals: [
{ selector: NodeLinksSelectors.getLinkedResources, value: mockLinkedResources },
{ selector: NodeLinksSelectors.getLinkedResourcesLoading, value: false },
+ { selector: NodeLinksSelectors.hasMoreLinkedResources, value: false },
+ { selector: NodeLinksSelectors.isLoadingMoreLinkedResources, value: false },
+ { selector: ProjectOverviewSelectors.getProject, value: MOCK_NODE_WITH_ADMIN },
],
}),
MockProvider(CustomDialogService, customDialogServiceMock),
@@ -60,6 +64,7 @@ describe('LinkedProjectsComponent', () => {
expect(customDialogServiceMock.open).toHaveBeenCalledWith(LinkResourceDialogComponent, {
header: 'project.overview.dialog.linkProject.header',
width: '850px',
+ closable: false,
});
});
diff --git a/src/app/features/project/overview/components/linked-resources/linked-resources.component.ts b/src/app/features/project/overview/components/linked-resources/linked-resources.component.ts
index eee5c3f0b..cd1d88944 100644
--- a/src/app/features/project/overview/components/linked-resources/linked-resources.component.ts
+++ b/src/app/features/project/overview/components/linked-resources/linked-resources.component.ts
@@ -1,18 +1,22 @@
-import { select } from '@ngxs/store';
+import { createDispatchMap, select } from '@ngxs/store';
import { TranslatePipe } from '@ngx-translate/core';
import { Button } from 'primeng/button';
import { Skeleton } from 'primeng/skeleton';
-import { ChangeDetectionStrategy, Component, inject, input } from '@angular/core';
+import { filter } from 'rxjs';
+
+import { ChangeDetectionStrategy, Component, DestroyRef, inject, input } from '@angular/core';
+import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
import { ContributorsListComponent } from '@osf/shared/components/contributors-list/contributors-list.component';
import { IconComponent } from '@osf/shared/components/icon/icon.component';
import { TruncatedTextComponent } from '@osf/shared/components/truncated-text/truncated-text.component';
import { CustomDialogService } from '@osf/shared/services/custom-dialog.service';
-import { NodeLinksSelectors } from '@osf/shared/stores/node-links';
+import { GetLinkedResources, LoadMoreLinkedResources, NodeLinksSelectors } from '@osf/shared/stores/node-links';
+import { ProjectOverviewSelectors } from '../../store';
import { DeleteNodeLinkDialogComponent } from '../delete-node-link-dialog/delete-node-link-dialog.component';
import { LinkResourceDialogComponent } from '../link-resource-dialog/link-resource-dialog.component';
@@ -25,28 +29,62 @@ import { LinkResourceDialogComponent } from '../link-resource-dialog/link-resour
})
export class LinkedResourcesComponent {
private customDialogService = inject(CustomDialogService);
+ private readonly destroyRef = inject(DestroyRef);
canEdit = input.required();
linkedResources = select(NodeLinksSelectors.getLinkedResources);
isLinkedResourcesLoading = select(NodeLinksSelectors.getLinkedResourcesLoading);
+ hasMoreLinkedResources = select(NodeLinksSelectors.hasMoreLinkedResources);
+ isLoadingMoreLinkedResources = select(NodeLinksSelectors.isLoadingMoreLinkedResources);
+ currentProject = select(ProjectOverviewSelectors.getProject);
+
+ private readonly actions = createDispatchMap({
+ getLinkedResources: GetLinkedResources,
+ loadMoreLinkedResources: LoadMoreLinkedResources,
+ });
openLinkProjectModal() {
- this.customDialogService.open(LinkResourceDialogComponent, {
- header: 'project.overview.dialog.linkProject.header',
- width: '850px',
- });
+ const project = this.currentProject();
+
+ if (!project) return;
+
+ this.customDialogService
+ .open(LinkResourceDialogComponent, {
+ header: 'project.overview.dialog.linkProject.header',
+ width: '850px',
+ closable: false,
+ })
+ .onClose.pipe(
+ filter((data) => !!data?.hasChanges),
+ takeUntilDestroyed(this.destroyRef)
+ )
+ .subscribe(() => this.actions.getLinkedResources(project.id));
}
openDeleteResourceModal(resourceId: string): void {
const currentLink = this.linkedResources().find((resource) => resource.id === resourceId);
+ const project = this.currentProject();
+
+ if (!currentLink || !project) return;
+
+ this.customDialogService
+ .open(DeleteNodeLinkDialogComponent, {
+ header: 'project.overview.dialog.deleteNodeLink.header',
+ width: '650px',
+ data: { currentLink },
+ })
+ .onClose.pipe(
+ filter((data) => !!data?.hasChanges),
+ takeUntilDestroyed(this.destroyRef)
+ )
+ .subscribe(() => this.actions.getLinkedResources(project.id));
+ }
- if (!currentLink) return;
+ loadMore(): void {
+ const project = this.currentProject();
+ if (!project) return;
- this.customDialogService.open(DeleteNodeLinkDialogComponent, {
- header: 'project.overview.dialog.deleteNodeLink.header',
- width: '650px',
- data: { currentLink },
- });
+ this.actions.loadMoreLinkedResources(project.id);
}
}
diff --git a/src/app/shared/services/node-links.service.ts b/src/app/shared/services/node-links.service.ts
index 3ced22bb2..870e9b7db 100644
--- a/src/app/shared/services/node-links.service.ts
+++ b/src/app/shared/services/node-links.service.ts
@@ -11,6 +11,7 @@ import { MyResourcesItem } from '../models/my-resources/my-resources.models';
import { NodeLinkJsonApi } from '../models/node-links/node-link-json-api.model';
import { NodeModel } from '../models/nodes/base-node.model';
import { NodesResponseJsonApi } from '../models/nodes/nodes-json-api.model';
+import { PaginatedData } from '../models/paginated-data.model';
import { JsonApiService } from './json-api.service';
@@ -60,25 +61,29 @@ export class NodeLinksService {
);
}
- fetchLinkedProjects(projectId: string): Observable {
+ fetchLinkedProjects(projectId: string, page: number, pageSize: number): Observable> {
const params: Record = {
embed: 'bibliographic_contributors',
'fields[users]': 'family_name,full_name,given_name,middle_name',
+ page,
+ 'page[size]': pageSize,
};
return this.jsonApiService
.get(`${this.apiUrl}/nodes/${projectId}/linked_nodes/`, params)
- .pipe(map((response) => BaseNodeMapper.getNodesWithEmbedContributors(response.data)));
+ .pipe(map((response) => BaseNodeMapper.getNodesWithEmbedsAndTotalData(response)));
}
- fetchLinkedRegistrations(projectId: string): Observable {
+ fetchLinkedRegistrations(projectId: string, page: number, pageSize: number): Observable> {
const params: Record = {
embed: 'bibliographic_contributors',
'fields[users]': 'family_name,full_name,given_name,middle_name',
+ page,
+ 'page[size]': pageSize,
};
return this.jsonApiService
.get(`${this.apiUrl}/nodes/${projectId}/linked_registrations/`, params)
- .pipe(map((response) => BaseNodeMapper.getNodesWithEmbedContributors(response.data)));
+ .pipe(map((response) => BaseNodeMapper.getNodesWithEmbedsAndTotalData(response)));
}
}
diff --git a/src/app/shared/stores/node-links/node-links.actions.ts b/src/app/shared/stores/node-links/node-links.actions.ts
index ed3d10145..9515fb1eb 100644
--- a/src/app/shared/stores/node-links/node-links.actions.ts
+++ b/src/app/shared/stores/node-links/node-links.actions.ts
@@ -1,3 +1,4 @@
+import { DEFAULT_TABLE_PARAMS } from '@osf/shared/constants/default-table-params.constants';
import { MyResourcesItem } from '@osf/shared/models/my-resources/my-resources.models';
import { NodeModel } from '@osf/shared/models/nodes/base-node.model';
@@ -13,6 +14,16 @@ export class CreateNodeLink {
export class GetLinkedResources {
static readonly type = '[Node Links] Get Linked Resources';
+ constructor(
+ public projectId: string,
+ public page = 1,
+ public pageSize = DEFAULT_TABLE_PARAMS.rows
+ ) {}
+}
+
+export class LoadMoreLinkedResources {
+ static readonly type = '[Node Links] Load More Linked Resources';
+
constructor(public projectId: string) {}
}
diff --git a/src/app/shared/stores/node-links/node-links.model.ts b/src/app/shared/stores/node-links/node-links.model.ts
index 5e892a858..1f65b0780 100644
--- a/src/app/shared/stores/node-links/node-links.model.ts
+++ b/src/app/shared/stores/node-links/node-links.model.ts
@@ -1,8 +1,18 @@
+import { DEFAULT_TABLE_PARAMS } from '@osf/shared/constants/default-table-params.constants';
import { NodeModel } from '@osf/shared/models/nodes/base-node.model';
import { AsyncStateModel } from '@osf/shared/models/store/async-state.model';
+export interface LinkedResourcesState extends AsyncStateModel {
+ page: number;
+ pageSize: number;
+ projectsTotalCount: number;
+ registrationsTotalCount: number;
+ isLoadingMore: boolean;
+ hasChanges: boolean;
+}
+
export interface NodeLinksStateModel {
- linkedResources: AsyncStateModel;
+ linkedResources: LinkedResourcesState;
}
export const NODE_LINKS_DEFAULTS: NodeLinksStateModel = {
@@ -11,5 +21,11 @@ export const NODE_LINKS_DEFAULTS: NodeLinksStateModel = {
isLoading: false,
isSubmitting: false,
error: null,
+ page: 1,
+ pageSize: DEFAULT_TABLE_PARAMS.rows,
+ projectsTotalCount: 0,
+ registrationsTotalCount: 0,
+ isLoadingMore: false,
+ hasChanges: false,
},
};
diff --git a/src/app/shared/stores/node-links/node-links.selectors.ts b/src/app/shared/stores/node-links/node-links.selectors.ts
index 1a6bdfd3d..e2e814db3 100644
--- a/src/app/shared/stores/node-links/node-links.selectors.ts
+++ b/src/app/shared/stores/node-links/node-links.selectors.ts
@@ -23,4 +23,22 @@ export class NodeLinksSelectors {
static getLinkedResourcesSubmitting(state: NodeLinksStateModel) {
return state.linkedResources.isSubmitting;
}
+
+ @Selector([NodeLinksState])
+ static hasMoreLinkedResources(state: NodeLinksStateModel) {
+ const { page, pageSize, projectsTotalCount, registrationsTotalCount } = state.linkedResources;
+ const hasMoreProjects = projectsTotalCount > page * pageSize;
+ const hasMoreRegistrations = registrationsTotalCount > page * pageSize;
+ return hasMoreProjects || hasMoreRegistrations;
+ }
+
+ @Selector([NodeLinksState])
+ static isLoadingMoreLinkedResources(state: NodeLinksStateModel) {
+ return state.linkedResources.isLoadingMore;
+ }
+
+ @Selector([NodeLinksState])
+ static getNodeLinksHasChanges(state: NodeLinksStateModel) {
+ return state.linkedResources.hasChanges;
+ }
}
diff --git a/src/app/shared/stores/node-links/node-links.state.ts b/src/app/shared/stores/node-links/node-links.state.ts
index 213a2cf4a..bbdbde0ea 100644
--- a/src/app/shared/stores/node-links/node-links.state.ts
+++ b/src/app/shared/stores/node-links/node-links.state.ts
@@ -1,12 +1,21 @@
import { Action, State, StateContext } from '@ngxs/store';
-import { catchError, forkJoin, tap, throwError } from 'rxjs';
+import { catchError, forkJoin, of, tap } from 'rxjs';
import { inject, Injectable } from '@angular/core';
+import { handleSectionError } from '@osf/shared/helpers/state-error.handler';
+import { NodeModel } from '@osf/shared/models/nodes/base-node.model';
+import { PaginatedData } from '@osf/shared/models/paginated-data.model';
import { NodeLinksService } from '@osf/shared/services/node-links.service';
-import { ClearNodeLinks, CreateNodeLink, DeleteNodeLink, GetLinkedResources } from './node-links.actions';
+import {
+ ClearNodeLinks,
+ CreateNodeLink,
+ DeleteNodeLink,
+ GetLinkedResources,
+ LoadMoreLinkedResources,
+} from './node-links.actions';
import { NODE_LINKS_DEFAULTS, NodeLinksStateModel } from './node-links.model';
@State({
@@ -17,50 +26,108 @@ import { NODE_LINKS_DEFAULTS, NodeLinksStateModel } from './node-links.model';
export class NodeLinksState {
nodeLinksService = inject(NodeLinksService);
- @Action(CreateNodeLink)
- createNodeLink(ctx: StateContext, action: CreateNodeLink) {
+ @Action(GetLinkedResources)
+ getLinkedResources(ctx: StateContext, action: GetLinkedResources) {
const state = ctx.getState();
+
+ const page = action.page ?? state.linkedResources.page;
+ const pageSize = action.pageSize ?? state.linkedResources.pageSize;
+
ctx.patchState({
linkedResources: {
...state.linkedResources,
- isSubmitting: true,
+ data: page === 1 ? [] : state.linkedResources.data,
+ isLoading: page === 1,
+ isLoadingMore: page > 1,
+ error: null,
+ hasChanges: false,
},
});
- return this.nodeLinksService.createNodeLink(action.currentProjectId, action.resource).pipe(
- tap(() => {
- ctx.dispatch(new GetLinkedResources(action.currentProjectId));
+ const emptyPaginatedData: PaginatedData = {
+ data: [],
+ totalCount: 0,
+ pageSize: pageSize,
+ };
+
+ const shouldFetchProjects =
+ page === 1 ||
+ state.linkedResources.projectsTotalCount === 0 ||
+ page <= Math.ceil(state.linkedResources.projectsTotalCount / pageSize);
+
+ const shouldFetchRegistrations =
+ page === 1 ||
+ state.linkedResources.registrationsTotalCount === 0 ||
+ page <= Math.ceil(state.linkedResources.registrationsTotalCount / pageSize);
+
+ const projectsObservable = shouldFetchProjects
+ ? this.nodeLinksService
+ .fetchLinkedProjects(action.projectId, page, pageSize)
+ .pipe(catchError(() => of(emptyPaginatedData)))
+ : of(emptyPaginatedData);
+
+ const registrationsObservable = shouldFetchRegistrations
+ ? this.nodeLinksService
+ .fetchLinkedRegistrations(action.projectId, page, pageSize)
+ .pipe(catchError(() => of(emptyPaginatedData)))
+ : of(emptyPaginatedData);
+
+ return forkJoin({
+ linkedProjects: projectsObservable,
+ linkedRegistrations: registrationsObservable,
+ }).pipe(
+ tap(({ linkedProjects, linkedRegistrations }) => {
+ const data =
+ page === 1
+ ? [...linkedProjects.data, ...linkedRegistrations.data]
+ : [...state.linkedResources.data, ...linkedProjects.data, ...linkedRegistrations.data];
+
+ ctx.patchState({
+ linkedResources: {
+ ...state.linkedResources,
+ data,
+ isLoading: false,
+ isLoadingMore: false,
+ isSubmitting: false,
+ error: null,
+ page: page,
+ pageSize: pageSize,
+ projectsTotalCount: linkedProjects.totalCount,
+ registrationsTotalCount: linkedRegistrations.totalCount,
+ hasChanges: false,
+ },
+ });
}),
- catchError((error) => this.handleError(ctx, 'linkedResources', error))
+ catchError((error) => handleSectionError(ctx, 'linkedResources', error))
);
}
- @Action(GetLinkedResources)
- getLinkedResources(ctx: StateContext, action: GetLinkedResources) {
+ @Action(LoadMoreLinkedResources)
+ loadMoreLinkedResources(ctx: StateContext, action: LoadMoreLinkedResources) {
+ const state = ctx.getState();
+ const nextPage = state.linkedResources.page + 1;
+ const nextPageSize = state.linkedResources.pageSize;
+
+ return ctx.dispatch(new GetLinkedResources(action.projectId, nextPage, nextPageSize));
+ }
+
+ @Action(CreateNodeLink)
+ createNodeLink(ctx: StateContext, action: CreateNodeLink) {
const state = ctx.getState();
ctx.patchState({
linkedResources: {
...state.linkedResources,
- isLoading: true,
+ isSubmitting: true,
},
});
- return forkJoin({
- linkedProjects: this.nodeLinksService.fetchLinkedProjects(action.projectId),
- linkedRegistrations: this.nodeLinksService.fetchLinkedRegistrations(action.projectId),
- }).pipe(
- tap(({ linkedProjects, linkedRegistrations }) => {
- const combinedResources = [...linkedProjects, ...linkedRegistrations];
+ return this.nodeLinksService.createNodeLink(action.currentProjectId, action.resource).pipe(
+ tap(() => {
ctx.patchState({
- linkedResources: {
- data: combinedResources,
- isLoading: false,
- isSubmitting: false,
- error: null,
- },
+ linkedResources: { ...ctx.getState().linkedResources, isSubmitting: false, hasChanges: true },
});
}),
- catchError((error) => this.handleError(ctx, 'linkedResources', error))
+ catchError((error) => handleSectionError(ctx, 'linkedResources', error))
);
}
@@ -72,23 +139,17 @@ export class NodeLinksState {
linkedResources: {
...state.linkedResources,
isSubmitting: true,
+ error: null,
},
});
return this.nodeLinksService.deleteNodeLink(action.projectId, action.linkedResource).pipe(
tap(() => {
- const updatedResources = state.linkedResources.data.filter(
- (resource) => resource.id !== action.linkedResource.id
- );
ctx.patchState({
- linkedResources: {
- data: updatedResources,
- isLoading: false,
- isSubmitting: false,
- error: null,
- },
+ linkedResources: { ...ctx.getState().linkedResources, isSubmitting: false, hasChanges: true },
});
- })
+ }),
+ catchError((error) => handleSectionError(ctx, 'linkedResources', error))
);
}
@@ -96,16 +157,4 @@ export class NodeLinksState {
clearNodeLinks(ctx: StateContext) {
ctx.patchState(NODE_LINKS_DEFAULTS);
}
-
- private handleError(ctx: StateContext, section: keyof NodeLinksStateModel, error: Error) {
- ctx.patchState({
- [section]: {
- ...ctx.getState()[section],
- isLoading: false,
- isSubmitting: false,
- error: error.message,
- },
- });
- return throwError(() => error);
- }
}
diff --git a/src/app/shared/stores/wiki/wiki.state.ts b/src/app/shared/stores/wiki/wiki.state.ts
index 6c0b109ca..050308677 100644
--- a/src/app/shared/stores/wiki/wiki.state.ts
+++ b/src/app/shared/stores/wiki/wiki.state.ts
@@ -1,9 +1,10 @@
import { Action, State, StateContext } from '@ngxs/store';
-import { catchError, map, tap, throwError } from 'rxjs';
+import { catchError, map, tap } from 'rxjs';
import { inject, Injectable } from '@angular/core';
+import { handleSectionError } from '@osf/shared/helpers/state-error.handler';
import { WikiService } from '@osf/shared/services/wiki.service';
import {
@@ -52,7 +53,7 @@ export class WikiState {
currentWikiId: wiki.id,
});
}),
- catchError((error) => this.handleError(ctx, error))
+ catchError((error) => handleSectionError(ctx, 'wikiList', error))
);
}
@@ -78,7 +79,7 @@ export class WikiState {
currentWikiId: updatedWiki.id,
});
}),
- catchError((error) => this.handleError(ctx, error))
+ catchError((error) => handleSectionError(ctx, 'wikiList', error))
);
}
@@ -106,7 +107,7 @@ export class WikiState {
currentWikiId: updatedList.length > 0 ? updatedList[0].id : '',
});
}),
- catchError((error) => this.handleError(ctx, error))
+ catchError((error) => handleSectionError(ctx, 'wikiList', error))
);
}
@@ -131,7 +132,7 @@ export class WikiState {
},
});
}),
- catchError((error) => this.handleError(ctx, error))
+ catchError((error) => handleSectionError(ctx, 'homeWikiContent', error))
);
}
@@ -182,7 +183,7 @@ export class WikiState {
});
}),
map((wiki) => wiki),
- catchError((error) => this.handleError(ctx, error))
+ catchError((error) => handleSectionError(ctx, 'wikiList', error))
);
}
@@ -208,7 +209,7 @@ export class WikiState {
},
});
}),
- catchError((error) => this.handleError(ctx, error))
+ catchError((error) => handleSectionError(ctx, 'componentsWikiList', error))
);
}
@@ -233,23 +234,6 @@ export class WikiState {
});
}
- private handleError(ctx: StateContext, error: Error) {
- ctx.patchState({
- homeWikiContent: {
- ...ctx.getState().homeWikiContent,
- isLoading: false,
- error: error.message,
- },
- wikiList: {
- ...ctx.getState().wikiList,
- isLoading: false,
- isSubmitting: false,
- error: error.message,
- },
- });
- return throwError(() => error);
- }
-
@Action(GetWikiVersions)
getWikiVersions(ctx: StateContext, action: GetWikiVersions) {
const state = ctx.getState();
@@ -271,7 +255,7 @@ export class WikiState {
},
});
}),
- catchError((error) => this.handleError(ctx, error))
+ catchError((error) => handleSectionError(ctx, 'wikiVersions', error))
);
}
@@ -292,7 +276,7 @@ export class WikiState {
},
});
}),
- catchError((error) => this.handleError(ctx, error))
+ catchError((error) => handleSectionError(ctx, 'versionContent', error))
);
}
@@ -318,7 +302,7 @@ export class WikiState {
},
});
}),
- catchError((error) => this.handleError(ctx, error))
+ catchError((error) => handleSectionError(ctx, 'versionContent', error))
);
}
@@ -343,7 +327,7 @@ export class WikiState {
},
});
}),
- catchError((error) => this.handleError(ctx, error))
+ catchError((error) => handleSectionError(ctx, 'compareVersionContent', error))
);
}
}
diff --git a/src/testing/mocks/my-resources.mock.ts b/src/testing/mocks/my-resources.mock.ts
new file mode 100644
index 000000000..fff22e697
--- /dev/null
+++ b/src/testing/mocks/my-resources.mock.ts
@@ -0,0 +1,39 @@
+import { MyResourcesItem } from '@shared/models/my-resources/my-resources.models';
+
+import { MOCK_CONTRIBUTOR } from './contributors.mock';
+
+export const MOCK_MY_RESOURCES_ITEM_PROJECT: MyResourcesItem = {
+ id: 'project-1',
+ type: 'nodes',
+ title: 'Test Project',
+ dateCreated: '2024-01-01T00:00:00.000Z',
+ dateModified: '2024-01-02T00:00:00.000Z',
+ isPublic: true,
+ contributors: [MOCK_CONTRIBUTOR],
+};
+
+export const MOCK_MY_RESOURCES_ITEM_PROJECT_PRIVATE: MyResourcesItem = {
+ id: 'project-2',
+ type: 'nodes',
+ title: 'Private Test Project',
+ dateCreated: '2024-01-03T00:00:00.000Z',
+ dateModified: '2024-01-04T00:00:00.000Z',
+ isPublic: false,
+ contributors: [],
+};
+
+export const MOCK_MY_RESOURCES_ITEM_REGISTRATION: MyResourcesItem = {
+ id: 'registration-1',
+ type: 'registrations',
+ title: 'Test Registration',
+ dateCreated: '2024-01-05T00:00:00.000Z',
+ dateModified: '2024-01-06T00:00:00.000Z',
+ isPublic: true,
+ contributors: [MOCK_CONTRIBUTOR],
+};
+
+export const MOCK_MY_RESOURCES_ITEMS: MyResourcesItem[] = [
+ MOCK_MY_RESOURCES_ITEM_PROJECT,
+ MOCK_MY_RESOURCES_ITEM_PROJECT_PRIVATE,
+ MOCK_MY_RESOURCES_ITEM_REGISTRATION,
+];