Skip to content

Commit f58325d

Browse files
authored
[ENG-9637] Visible linked projects/registrations on project overview page is limited to 10. All linked projects/registrations need to be available on the overview page. (#762)
- Ticket: [ENG-9637] - Feature flag: n/a ## Summary of Changes 1. Added load more logic to linked projects. 2. Update add link to project and registration. 3. Fixed unit tests.
1 parent ccc5a39 commit f58325d

16 files changed

+580
-159
lines changed

src/app/features/project/overview/components/component-card/component-card.component.html

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -35,7 +35,7 @@ <h2 class="flex align-items-center gap-2">
3535
}
3636
</div>
3737

38-
<div class="flex flex-wrap gap-1">
38+
<div class="flex gap-1">
3939
<p class="font-bold">{{ 'common.labels.contributors' | translate }}:</p>
4040

4141
<osf-contributors-list
Lines changed: 87 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,22 +1,104 @@
1-
import { ComponentFixture, TestBed } from '@angular/core/testing';
1+
import { Store } from '@ngxs/store';
2+
3+
import { MockProvider } from 'ng-mocks';
4+
5+
import { DynamicDialogConfig, DynamicDialogRef } from 'primeng/dynamicdialog';
6+
7+
import { of } from 'rxjs';
8+
9+
import { ComponentFixture, fakeAsync, TestBed, tick } from '@angular/core/testing';
10+
11+
import { ToastService } from '@osf/shared/services/toast.service';
12+
import { DeleteNodeLink, NodeLinksSelectors } from '@osf/shared/stores/node-links';
13+
14+
import { ProjectOverviewSelectors } from '../../store';
215

316
import { DeleteNodeLinkDialogComponent } from './delete-node-link-dialog.component';
417

5-
describe.skip('DeleteNodeLinkDialogComponent', () => {
18+
import { DynamicDialogRefMock } from '@testing/mocks/dynamic-dialog-ref.mock';
19+
import { MOCK_NODE_WITH_ADMIN } from '@testing/mocks/node.mock';
20+
import { ToastServiceMock } from '@testing/mocks/toast.service.mock';
21+
import { OSFTestingModule } from '@testing/osf.testing.module';
22+
import { provideMockStore } from '@testing/providers/store-provider.mock';
23+
24+
describe('DeleteNodeLinkDialogComponent', () => {
625
let component: DeleteNodeLinkDialogComponent;
726
let fixture: ComponentFixture<DeleteNodeLinkDialogComponent>;
27+
let store: jest.Mocked<Store>;
28+
let dialogRef: jest.Mocked<DynamicDialogRef>;
29+
let dialogConfig: jest.Mocked<DynamicDialogConfig>;
30+
let toastService: jest.Mocked<ToastService>;
31+
32+
const mockProject = { ...MOCK_NODE_WITH_ADMIN, id: 'test-project-id' };
33+
const mockCurrentLink = { ...MOCK_NODE_WITH_ADMIN, id: 'linked-resource-id', title: 'Linked Resource' };
834

935
beforeEach(async () => {
36+
dialogConfig = {
37+
data: { currentLink: mockCurrentLink },
38+
} as jest.Mocked<DynamicDialogConfig>;
39+
1040
await TestBed.configureTestingModule({
11-
imports: [DeleteNodeLinkDialogComponent],
41+
imports: [DeleteNodeLinkDialogComponent, OSFTestingModule],
42+
providers: [
43+
DynamicDialogRefMock,
44+
ToastServiceMock,
45+
MockProvider(DynamicDialogConfig, dialogConfig),
46+
provideMockStore({
47+
signals: [
48+
{ selector: ProjectOverviewSelectors.getProject, value: mockProject },
49+
{ selector: NodeLinksSelectors.getNodeLinksSubmitting, value: false },
50+
],
51+
}),
52+
],
1253
}).compileComponents();
1354

55+
store = TestBed.inject(Store) as jest.Mocked<Store>;
56+
store.dispatch = jest.fn().mockReturnValue(of(true));
1457
fixture = TestBed.createComponent(DeleteNodeLinkDialogComponent);
1558
component = fixture.componentInstance;
59+
dialogRef = TestBed.inject(DynamicDialogRef) as jest.Mocked<DynamicDialogRef>;
60+
toastService = TestBed.inject(ToastService) as jest.Mocked<ToastService>;
1661
fixture.detectChanges();
1762
});
1863

19-
it('should create', () => {
20-
expect(component).toBeTruthy();
64+
afterEach(() => {
65+
jest.clearAllMocks();
66+
});
67+
68+
it('should initialize currentProject selector', () => {
69+
expect(component.currentProject()).toEqual(mockProject);
2170
});
71+
72+
it('should initialize isSubmitting selector', () => {
73+
expect(component.isSubmitting()).toBe(false);
74+
});
75+
76+
it('should initialize actions with deleteNodeLink mapping', () => {
77+
expect(component.actions.deleteNodeLink).toBeDefined();
78+
});
79+
80+
it('should dispatch DeleteNodeLink action with correct parameters on successful deletion', () => {
81+
component.handleDeleteNodeLink();
82+
83+
expect(store.dispatch).toHaveBeenCalledWith(expect.any(DeleteNodeLink));
84+
const call = (store.dispatch as jest.Mock).mock.calls.find((call) => call[0] instanceof DeleteNodeLink);
85+
expect(call).toBeDefined();
86+
const action = call[0] as DeleteNodeLink;
87+
expect(action.projectId).toBe('test-project-id');
88+
expect(action.linkedResource).toEqual(mockCurrentLink);
89+
});
90+
91+
it('should show success toast on successful deletion', fakeAsync(() => {
92+
component.handleDeleteNodeLink();
93+
tick();
94+
95+
expect(toastService.showSuccess).toHaveBeenCalledWith('project.overview.dialog.toast.deleteNodeLink.success');
96+
}));
97+
98+
it('should close dialog with hasChanges true on successful deletion', fakeAsync(() => {
99+
component.handleDeleteNodeLink();
100+
tick();
101+
102+
expect(dialogRef.close).toHaveBeenCalledWith({ hasChanges: true });
103+
}));
22104
});

src/app/features/project/overview/components/delete-node-link-dialog/delete-node-link-dialog.component.ts

Lines changed: 3 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@ import { DynamicDialogConfig, DynamicDialogRef } from 'primeng/dynamicdialog';
88
import { ChangeDetectionStrategy, Component, DestroyRef, inject } from '@angular/core';
99

1010
import { ToastService } from '@osf/shared/services/toast.service';
11-
import { DeleteNodeLink, GetLinkedResources, NodeLinksSelectors } from '@osf/shared/stores/node-links';
11+
import { DeleteNodeLink, NodeLinksSelectors } from '@osf/shared/stores/node-links';
1212

1313
import { ProjectOverviewSelectors } from '../../store';
1414

@@ -28,7 +28,7 @@ export class DeleteNodeLinkDialogComponent {
2828
currentProject = select(ProjectOverviewSelectors.getProject);
2929
isSubmitting = select(NodeLinksSelectors.getNodeLinksSubmitting);
3030

31-
actions = createDispatchMap({ deleteNodeLink: DeleteNodeLink, getLinkedResources: GetLinkedResources });
31+
actions = createDispatchMap({ deleteNodeLink: DeleteNodeLink });
3232

3333
handleDeleteNodeLink(): void {
3434
const project = this.currentProject();
@@ -38,9 +38,8 @@ export class DeleteNodeLinkDialogComponent {
3838

3939
this.actions.deleteNodeLink(project.id, currentLink).subscribe({
4040
next: () => {
41-
this.dialogRef.close();
42-
this.actions.getLinkedResources(project.id);
4341
this.toastService.showSuccess('project.overview.dialog.toast.deleteNodeLink.success');
42+
this.dialogRef.close({ hasChanges: true });
4443
},
4544
});
4645
}

src/app/features/project/overview/components/link-resource-dialog/link-resource-dialog.component.html

Lines changed: 12 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -39,15 +39,14 @@
3939
<p-table
4040
class="link-project-table"
4141
[value]="isCurrentTableLoading() ? skeletonData : currentTableItems()"
42-
[rows]="tableRows"
43-
[first]="(currentPage() - 1) * 10"
44-
[paginator]="currentTotalCount() > tableRows"
45-
[totalRecords]="currentTotalCount()"
42+
[rows]="tableParams().rows"
43+
[first]="tableParams().firstRowIndex"
44+
[paginator]="tableParams().paginator"
45+
[totalRecords]="tableParams().totalRecords"
4646
paginatorDropdownAppendTo="body"
4747
[resizableColumns]="true"
4848
[autoLayout]="true"
49-
[scrollable]="true"
50-
[sortMode]="'single'"
49+
[scrollable]="tableParams().scrollable"
5150
[lazy]="true"
5251
[lazyLoadOnInit]="true"
5352
(onPage)="onPageChange($event)"
@@ -74,7 +73,7 @@
7473
<p-checkbox
7574
[disabled]="isNodeLinksSubmitting()"
7675
[inputId]="item.id"
77-
[ngModel]="isItemLinked()(item.id)"
76+
[ngModel]="isItemLinked(item.id)"
7877
[binary]="true"
7978
(onChange)="handleToggleNodeLink(item)"
8079
/>
@@ -84,15 +83,19 @@
8483
<td>{{ item.dateCreated | date: 'MMM d, y' }}</td>
8584
<td>{{ item.dateModified | date: 'MMM d, y' }}</td>
8685
<td>
87-
@for (contributor of item.contributors; track contributor.id) {
86+
@for (contributor of item.contributors.slice(0, 3); track contributor.id) {
8887
{{ contributor.fullName }}{{ $last ? '' : ', ' }}
8988
}
89+
@if (item.contributors.length > 3) {
90+
, {{ 'common.labels.and' | translate }} {{ item.contributors.length - 3 }}
91+
{{ 'common.labels.more' | translate }}
92+
}
9093
</td>
9194
</tr>
9295
} @else {
9396
<tr class="loading-row">
9497
<td colspan="4">
95-
<p-skeleton width="100%" height="3.3rem" borderRadius="0" />
98+
<p-skeleton width="100%" height="2.75rem" borderRadius="0" />
9699
</td>
97100
</tr>
98101
}
Lines changed: 163 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,26 +1,185 @@
1+
import { Store } from '@ngxs/store';
2+
13
import { MockComponents } from 'ng-mocks';
24

5+
import { TablePageEvent } from 'primeng/table';
6+
37
import { ComponentFixture, TestBed } from '@angular/core/testing';
48

59
import { SearchInputComponent } from '@osf/shared/components/search-input/search-input.component';
10+
import { ResourceSearchMode } from '@osf/shared/enums/resource-search-mode.enum';
11+
import { ResourceType } from '@osf/shared/enums/resource-type.enum';
12+
import { MyResourcesSelectors } from '@osf/shared/stores/my-resources';
13+
import { NodeLinksSelectors } from '@osf/shared/stores/node-links';
14+
import { MyResourcesItem } from '@shared/models/my-resources/my-resources.models';
15+
16+
import { ProjectOverviewSelectors } from '../../store';
617

718
import { LinkResourceDialogComponent } from './link-resource-dialog.component';
819

9-
describe.skip('LinkProjectDialogComponent', () => {
20+
import { DynamicDialogRefMock } from '@testing/mocks/dynamic-dialog-ref.mock';
21+
import {
22+
MOCK_MY_RESOURCES_ITEM_PROJECT,
23+
MOCK_MY_RESOURCES_ITEM_PROJECT_PRIVATE,
24+
MOCK_MY_RESOURCES_ITEM_REGISTRATION,
25+
} from '@testing/mocks/my-resources.mock';
26+
import { MOCK_NODE_WITH_ADMIN } from '@testing/mocks/node.mock';
27+
import { OSFTestingModule } from '@testing/osf.testing.module';
28+
import { provideMockStore } from '@testing/providers/store-provider.mock';
29+
30+
describe('LinkResourceDialogComponent', () => {
1031
let component: LinkResourceDialogComponent;
1132
let fixture: ComponentFixture<LinkResourceDialogComponent>;
33+
let store: Store;
34+
let dialogRef: { close: jest.Mock };
35+
36+
const mockProjects: MyResourcesItem[] = [MOCK_MY_RESOURCES_ITEM_PROJECT, MOCK_MY_RESOURCES_ITEM_PROJECT_PRIVATE];
37+
38+
const mockRegistrations: MyResourcesItem[] = [MOCK_MY_RESOURCES_ITEM_REGISTRATION];
39+
40+
const mockLinkedResources = [{ ...MOCK_NODE_WITH_ADMIN, id: 'project-1', title: 'Linked Project 1' }];
41+
42+
const mockCurrentProject = { ...MOCK_NODE_WITH_ADMIN, id: 'current-project-id' };
1243

1344
beforeEach(async () => {
45+
dialogRef = { close: jest.fn() };
46+
1447
await TestBed.configureTestingModule({
15-
imports: [LinkResourceDialogComponent, ...MockComponents(SearchInputComponent)],
48+
imports: [LinkResourceDialogComponent, OSFTestingModule, ...MockComponents(SearchInputComponent)],
49+
providers: [
50+
provideMockStore({
51+
signals: [
52+
{ selector: MyResourcesSelectors.getProjects, value: mockProjects },
53+
{ selector: MyResourcesSelectors.getProjectsLoading, value: false },
54+
{ selector: MyResourcesSelectors.getRegistrations, value: mockRegistrations },
55+
{ selector: MyResourcesSelectors.getRegistrationsLoading, value: false },
56+
{ selector: MyResourcesSelectors.getTotalProjects, value: 2 },
57+
{ selector: MyResourcesSelectors.getTotalRegistrations, value: 1 },
58+
{ selector: NodeLinksSelectors.getNodeLinksSubmitting, value: false },
59+
{ selector: NodeLinksSelectors.getLinkedResources, value: mockLinkedResources },
60+
{ selector: ProjectOverviewSelectors.getProject, value: mockCurrentProject },
61+
],
62+
}),
63+
{ provide: DynamicDialogRefMock.provide, useValue: dialogRef },
64+
],
1665
}).compileComponents();
1766

1867
fixture = TestBed.createComponent(LinkResourceDialogComponent);
1968
component = fixture.componentInstance;
69+
store = TestBed.inject(Store);
70+
fixture.detectChanges();
71+
});
72+
73+
it('should initialize with default values', () => {
74+
expect(component.currentPage()).toBe(1);
75+
expect(component.searchMode()).toBe(ResourceSearchMode.User);
76+
expect(component.resourceType()).toBe(ResourceType.Project);
77+
expect(component.searchControl.value).toBe('');
78+
});
79+
80+
it('should initialize skeleton data with correct length', () => {
81+
expect(component.skeletonData.length).toBe(component.tableRows);
82+
});
83+
84+
it('should compute currentResourceId from currentProject', () => {
85+
expect(component.currentResourceId()).toBe('current-project-id');
86+
});
87+
88+
it('should compute isCurrentTableLoading for registrations', () => {
89+
component.resourceType.set(ResourceType.Registration);
2090
fixture.detectChanges();
91+
expect(component.isCurrentTableLoading()).toBe(false);
2192
});
2293

23-
it('should create', () => {
24-
expect(component).toBeTruthy();
94+
it('should compute currentTotalCount for registrations', () => {
95+
component.resourceType.set(ResourceType.Registration);
96+
fixture.detectChanges();
97+
expect(component.currentTotalCount()).toBe(1);
98+
});
99+
100+
it('should compute tableParams correctly', () => {
101+
const params = component.tableParams();
102+
expect(params.rows).toBe(component.tableRows);
103+
expect(params.firstRowIndex).toBe(0);
104+
expect(params.paginator).toBe(false);
105+
expect(params.totalRecords).toBe(2);
106+
});
107+
108+
it('should compute linkedResourceIds as a Set', () => {
109+
const linkedIds = component.linkedResourceIds();
110+
expect(linkedIds).toBeInstanceOf(Set);
111+
expect(linkedIds.has('project-1')).toBe(true);
112+
expect(linkedIds.has('project-2')).toBe(false);
113+
});
114+
115+
it('should update searchMode and reset to first page', () => {
116+
component.currentPage.set(2);
117+
component.onSearchModeChange(ResourceSearchMode.All);
118+
119+
expect(component.searchMode()).toBe(ResourceSearchMode.All);
120+
expect(component.currentPage()).toBe(1);
121+
});
122+
123+
it('should update resourceType and reset to first page', () => {
124+
component.currentPage.set(2);
125+
component.onObjectTypeChange(ResourceType.Registration);
126+
127+
expect(component.resourceType()).toBe(ResourceType.Registration);
128+
expect(component.currentPage()).toBe(1);
129+
});
130+
131+
it('should update currentPage and trigger search', () => {
132+
const dispatchSpy = jest.spyOn(store, 'dispatch');
133+
const event: TablePageEvent = {
134+
first: 6,
135+
rows: 6,
136+
};
137+
138+
component.onPageChange(event);
139+
140+
expect(component.currentPage()).toBe(2);
141+
expect(dispatchSpy).toHaveBeenCalled();
142+
});
143+
144+
it('should return true for linked items', () => {
145+
expect(component.isItemLinked('project-1')).toBe(true);
146+
});
147+
148+
it('should return false for non-linked items', () => {
149+
expect(component.isItemLinked('project-2')).toBe(false);
150+
});
151+
152+
it('should close the dialog', () => {
153+
component.handleCloseDialog();
154+
expect(dialogRef.close).toHaveBeenCalled();
155+
});
156+
157+
it('should trigger search when searchMode changes', () => {
158+
const dispatchSpy = jest.spyOn(store, 'dispatch');
159+
160+
component.onSearchModeChange(ResourceSearchMode.All);
161+
162+
expect(dispatchSpy).toHaveBeenCalled();
163+
});
164+
165+
it('should trigger search when resourceType changes', () => {
166+
const dispatchSpy = jest.spyOn(store, 'dispatch');
167+
168+
component.onObjectTypeChange(ResourceType.Registration);
169+
170+
expect(dispatchSpy).toHaveBeenCalled();
171+
});
172+
173+
it('should handle page change with zero first value', () => {
174+
const dispatchSpy = jest.spyOn(store, 'dispatch');
175+
const event: TablePageEvent = {
176+
first: 0,
177+
rows: 6,
178+
};
179+
180+
component.onPageChange(event);
181+
182+
expect(component.currentPage()).toBe(1);
183+
expect(dispatchSpy).toHaveBeenCalled();
25184
});
26185
});

0 commit comments

Comments
 (0)