Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@
@for (item of users(); track $index) {
<div class="border-divider flex pb-3">
<p-checkbox variant="filled" [value]="item" [inputId]="item.id" [(ngModel)]="selectedUsers"></p-checkbox>
<label class="label mb-0 ml-2 cursor-pointer" [for]="item.id">{{ item.fullName }}</label>
<a class="ml-2 font-bold" [href]="item.id" target="_blank">{{ item.fullName }}</a>
</div>
}

Expand All @@ -27,6 +27,7 @@
[first]="first()"
[rows]="rows()"
[totalCount]="totalUsersCount()"
[showPageLinks]="false"
(pageChanged)="pageChanged($event)"
></osf-custom-paginator>
}
Expand Down
Original file line number Diff line number Diff line change
@@ -1,15 +1,18 @@
import { Store } from '@ngxs/store';

import { MockComponents, MockProvider } from 'ng-mocks';

import { DynamicDialogConfig, DynamicDialogRef } from 'primeng/dynamicdialog';
import { PaginatorState } from 'primeng/paginator';

import { of } from 'rxjs';

import { ComponentFixture, TestBed } from '@angular/core/testing';
import { signal } from '@angular/core';
import { ComponentFixture, fakeAsync, TestBed, tick } from '@angular/core/testing';

import { CustomPaginatorComponent } from '@osf/shared/components/custom-paginator/custom-paginator.component';
import { LoadingSpinnerComponent } from '@osf/shared/components/loading-spinner/loading-spinner.component';
import { SearchInputComponent } from '@osf/shared/components/search-input/search-input.component';

import { AddModeratorType } from '../../enums';
import { ModeratorAddModel } from '../../models';
import { ModeratorsSelectors } from '../../store/moderators';

Expand All @@ -23,17 +26,18 @@ import { provideMockStore } from '@testing/providers/store-provider.mock';
describe('AddModeratorDialogComponent', () => {
let component: AddModeratorDialogComponent;
let fixture: ComponentFixture<AddModeratorDialogComponent>;
let mockDialogRef: jest.Mocked<DynamicDialogRef>;
let mockDialogConfig: jest.Mocked<DynamicDialogConfig>;
let dialogRef: jest.Mocked<DynamicDialogRef>;
let dialogConfig: DynamicDialogConfig;
let store: Store;

const mockUsers = [MOCK_USER];

beforeEach(async () => {
mockDialogRef = DynamicDialogRefMock.useValue as unknown as jest.Mocked<DynamicDialogRef>;
dialogRef = DynamicDialogRefMock.useValue as unknown as jest.Mocked<DynamicDialogRef>;

mockDialogConfig = {
dialogConfig = {
data: [],
} as jest.Mocked<DynamicDialogConfig>;
} as DynamicDialogConfig;

await TestBed.configureTestingModule({
imports: [
Expand All @@ -43,34 +47,30 @@ describe('AddModeratorDialogComponent', () => {
],
providers: [
DynamicDialogRefMock,
MockProvider(DynamicDialogConfig, mockDialogConfig),
MockProvider(DynamicDialogConfig, dialogConfig),
provideMockStore({
signals: [
{ selector: ModeratorsSelectors.getUsers, value: mockUsers },
{ selector: ModeratorsSelectors.getUsers, value: signal(mockUsers) },
{ selector: ModeratorsSelectors.isUsersLoading, value: false },
{ selector: ModeratorsSelectors.getUsersTotalCount, value: 2 },
{ selector: ModeratorsSelectors.getUsersNextLink, value: signal(null) },
{ selector: ModeratorsSelectors.getUsersPreviousLink, value: signal(null) },
],
}),
],
}).compileComponents();

store = TestBed.inject(Store);
fixture = TestBed.createComponent(AddModeratorDialogComponent);
component = fixture.componentInstance;
});

afterEach(() => {
jest.clearAllMocks();
jest.useRealTimers();
fixture.detectChanges();
});

it('should create', () => {
fixture.detectChanges();
expect(component).toBeTruthy();
});

it('should initialize with default values', () => {
fixture.detectChanges();

expect(component.isInitialState()).toBe(true);
expect(component.currentPage()).toBe(1);
expect(component.first()).toBe(0);
Expand All @@ -79,15 +79,13 @@ describe('AddModeratorDialogComponent', () => {
expect(component.searchControl.value).toBe('');
});

it('should load users on initialization', () => {
fixture.detectChanges();

it('should load users from store', () => {
expect(component.users()).toEqual(mockUsers);
expect(component.isLoading()).toBe(false);
expect(component.totalUsersCount()).toBe(2);
});

it('should add moderator', () => {
it('should close dialog with correct data for addModerator', () => {
const mockSelectedUsers: ModeratorAddModel[] = [
{
id: '1',
Expand All @@ -100,100 +98,86 @@ describe('AddModeratorDialogComponent', () => {

component.addModerator();

expect(mockDialogRef.close).toHaveBeenCalledWith({
expect(dialogRef.close).toHaveBeenCalledWith({
data: mockSelectedUsers,
type: 1,
type: AddModeratorType.Search,
});
});

it('should invite moderator', () => {
it('should close dialog with correct data for inviteModerator', () => {
component.inviteModerator();

expect(mockDialogRef.close).toHaveBeenCalledWith({
expect(dialogRef.close).toHaveBeenCalledWith({
data: [],
type: 2,
type: AddModeratorType.Invite,
});
});

it('should handle page change correctly', () => {
const mockEvent = { page: 1, first: 10, rows: 10 };
const searchUsersSpy = jest.fn();
component.actions = {
...component.actions,
searchUsers: searchUsersSpy,
};

component.pageChanged(mockEvent);

expect(component.currentPage()).toBe(2);
expect(component.first()).toBe(10);
expect(searchUsersSpy).toHaveBeenCalledWith('', 2);
});

it('should handle page change when page is null', () => {
const mockEvent = { page: undefined, first: 0, rows: 10 };
const searchUsersSpy = jest.fn();
component.actions = {
...component.actions,
searchUsers: searchUsersSpy,
};
it('should handle pagination correctly', () => {
const dispatchSpy = jest.spyOn(store, 'dispatch');

component.pageChanged(mockEvent);
component.pageChanged({ first: 0 } as PaginatorState);
expect(dispatchSpy).not.toHaveBeenCalled();

component.searchControl.setValue('test');
component.pageChanged({ page: 0, first: 0, rows: 10 } as PaginatorState);
expect(dispatchSpy).toHaveBeenCalled();
expect(component.currentPage()).toBe(1);
expect(component.first()).toBe(0);
expect(searchUsersSpy).toHaveBeenCalledWith('', 1);
});

it('should clear users on destroy', () => {
const clearUsersSpy = jest.fn();
component.actions = {
...component.actions,
clearUsers: clearUsersSpy,
};
it('should navigate to next page when link is available', () => {
const nextLink = 'http://api.example.com/users?page=3';
const originalSelect = store.select.bind(store);
(store.select as jest.Mock) = jest.fn((selector) => {
if (selector === ModeratorsSelectors.getUsersNextLink) {
return signal(nextLink);
}
return originalSelect(selector);
});

component.ngOnDestroy();
Object.defineProperty(component, 'usersNextLink', {
get: () => signal(nextLink),
configurable: true,
});

expect(clearUsersSpy).toHaveBeenCalled();
});
const dispatchSpy = jest.spyOn(store, 'dispatch');
component.currentPage.set(2);
component.pageChanged({ page: 2, first: 20, rows: 10 } as PaginatorState);

it('should have actions defined', () => {
expect(component.actions).toBeDefined();
expect(component.actions.searchUsers).toBeDefined();
expect(component.actions.clearUsers).toBeDefined();
expect(dispatchSpy).toHaveBeenCalled();
expect(component.currentPage()).toBe(3);
expect(component.first()).toBe(20);
});

it('should handle search control value changes', () => {
jest.useFakeTimers();
fixture.detectChanges();
const searchUsersSpy = jest.fn().mockReturnValue(of({}));
component.actions = {
...component.actions,
searchUsers: searchUsersSpy,
};

component.searchControl.setValue('test search');
it('should debounce and filter search input', fakeAsync(() => {
const dispatchSpy = jest.spyOn(store, 'dispatch');

jest.advanceTimersByTime(600);
component.searchControl.setValue('t');
tick(200);
component.searchControl.setValue('test');
tick(500);

expect(searchUsersSpy).toHaveBeenCalledWith('test search', 1);
expect(dispatchSpy).toHaveBeenCalledTimes(1);
expect(component.isInitialState()).toBe(false);
expect(component.selectedUsers()).toEqual([]);
}));

jest.useRealTimers();
});

it('should not search when search term is empty', () => {
fixture.detectChanges();
const searchUsersSpy = jest.fn();
component.actions = {
...component.actions,
searchUsers: searchUsersSpy,
};
it('should not search empty or whitespace values', fakeAsync(() => {
const dispatchSpy = jest.spyOn(store, 'dispatch');

component.searchControl.setValue('');
tick(500);
expect(dispatchSpy).not.toHaveBeenCalled();

component.searchControl.setValue(' ');
tick(500);
expect(dispatchSpy).not.toHaveBeenCalled();
}));

expect(searchUsersSpy).not.toHaveBeenCalled();
it('should clear users on destroy', () => {
const dispatchSpy = jest.spyOn(store, 'dispatch');
component.ngOnDestroy();
expect(dispatchSpy).toHaveBeenCalled();
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@ import { SearchInputComponent } from '@osf/shared/components/search-input/search

import { AddModeratorType } from '../../enums';
import { ModeratorAddModel, ModeratorDialogAddModel } from '../../models';
import { ClearUsers, ModeratorsSelectors, SearchUsers } from '../../store/moderators';
import { ClearUsers, ModeratorsSelectors, SearchUsers, SearchUsersPageChange } from '../../store/moderators';

@Component({
selector: 'osf-add-moderator-dialog',
Expand All @@ -44,6 +44,8 @@ export class AddModeratorDialogComponent implements OnInit, OnDestroy {
users = select(ModeratorsSelectors.getUsers);
isLoading = select(ModeratorsSelectors.isUsersLoading);
totalUsersCount = select(ModeratorsSelectors.getUsersTotalCount);
usersNextLink = select(ModeratorsSelectors.getUsersNextLink);
usersPreviousLink = select(ModeratorsSelectors.getUsersPreviousLink);
isInitialState = signal(true);

currentPage = signal(1);
Expand All @@ -53,7 +55,11 @@ export class AddModeratorDialogComponent implements OnInit, OnDestroy {
selectedUsers = signal<ModeratorAddModel[]>([]);
searchControl = new FormControl<string>('');

actions = createDispatchMap({ searchUsers: SearchUsers, clearUsers: ClearUsers });
actions = createDispatchMap({
searchUsers: SearchUsers,
searchUsersPageChange: SearchUsersPageChange,
clearUsers: ClearUsers,
});

ngOnInit(): void {
this.setSearchSubscription();
Expand All @@ -74,10 +80,32 @@ export class AddModeratorDialogComponent implements OnInit, OnDestroy {
this.dialogRef.close(dialogData);
}

pageChanged(event: PaginatorState) {
this.currentPage.set(event.page ? this.currentPage() + 1 : 1);
this.first.set(event.first ?? 0);
this.actions.searchUsers(this.searchControl.value, this.currentPage());
pageChanged(event: PaginatorState): void {
if (event.page === undefined) {
return;
}

const eventPageOneBased = event.page + 1;

if (eventPageOneBased === 1) {
const searchTerm = this.searchControl.value?.trim();

if (searchTerm) {
this.actions.searchUsers(searchTerm);
this.currentPage.set(1);
this.first.set(0);
}

return;
}

const link = eventPageOneBased > this.currentPage() ? this.usersNextLink() : this.usersPreviousLink();

if (link) {
this.actions.searchUsersPageChange(link);
this.currentPage.set(eventPageOneBased);
this.first.set(event.first ?? 0);
}
}

private setSearchSubscription() {
Expand All @@ -86,7 +114,7 @@ export class AddModeratorDialogComponent implements OnInit, OnDestroy {
filter((searchTerm) => !!searchTerm && searchTerm.trim().length > 0),
debounceTime(500),
distinctUntilChanged(),
switchMap((searchTerm) => this.actions.searchUsers(searchTerm, this.currentPage())),
switchMap((searchTerm) => this.actions.searchUsers(searchTerm)),
takeUntilDestroyed(this.destroyRef)
)
.subscribe(() => {
Expand Down
Loading