diff --git a/src/app/features/moderation/components/add-moderator-dialog/add-moderator-dialog.component.html b/src/app/features/moderation/components/add-moderator-dialog/add-moderator-dialog.component.html index d9ede60d9..0f0ba531b 100644 --- a/src/app/features/moderation/components/add-moderator-dialog/add-moderator-dialog.component.html +++ b/src/app/features/moderation/components/add-moderator-dialog/add-moderator-dialog.component.html @@ -11,7 +11,7 @@ @for (item of users(); track $index) {
- + {{ item.fullName }}
} @@ -27,6 +27,7 @@ [first]="first()" [rows]="rows()" [totalCount]="totalUsersCount()" + [showPageLinks]="false" (pageChanged)="pageChanged($event)" > } diff --git a/src/app/features/moderation/components/add-moderator-dialog/add-moderator-dialog.component.spec.ts b/src/app/features/moderation/components/add-moderator-dialog/add-moderator-dialog.component.spec.ts index 9ec93dd0c..40a6e07a0 100644 --- a/src/app/features/moderation/components/add-moderator-dialog/add-moderator-dialog.component.spec.ts +++ b/src/app/features/moderation/components/add-moderator-dialog/add-moderator-dialog.component.spec.ts @@ -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'; @@ -23,17 +26,18 @@ import { provideMockStore } from '@testing/providers/store-provider.mock'; describe('AddModeratorDialogComponent', () => { let component: AddModeratorDialogComponent; let fixture: ComponentFixture; - let mockDialogRef: jest.Mocked; - let mockDialogConfig: jest.Mocked; + let dialogRef: jest.Mocked; + let dialogConfig: DynamicDialogConfig; + let store: Store; const mockUsers = [MOCK_USER]; beforeEach(async () => { - mockDialogRef = DynamicDialogRefMock.useValue as unknown as jest.Mocked; + dialogRef = DynamicDialogRefMock.useValue as unknown as jest.Mocked; - mockDialogConfig = { + dialogConfig = { data: [], - } as jest.Mocked; + } as DynamicDialogConfig; await TestBed.configureTestingModule({ imports: [ @@ -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); @@ -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', @@ -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(); }); }); diff --git a/src/app/features/moderation/components/add-moderator-dialog/add-moderator-dialog.component.ts b/src/app/features/moderation/components/add-moderator-dialog/add-moderator-dialog.component.ts index f0f6e3be3..8008762d9 100644 --- a/src/app/features/moderation/components/add-moderator-dialog/add-moderator-dialog.component.ts +++ b/src/app/features/moderation/components/add-moderator-dialog/add-moderator-dialog.component.ts @@ -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', @@ -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); @@ -53,7 +55,11 @@ export class AddModeratorDialogComponent implements OnInit, OnDestroy { selectedUsers = signal([]); searchControl = new FormControl(''); - actions = createDispatchMap({ searchUsers: SearchUsers, clearUsers: ClearUsers }); + actions = createDispatchMap({ + searchUsers: SearchUsers, + searchUsersPageChange: SearchUsersPageChange, + clearUsers: ClearUsers, + }); ngOnInit(): void { this.setSearchSubscription(); @@ -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() { @@ -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(() => { diff --git a/src/app/features/moderation/services/moderators.service.ts b/src/app/features/moderation/services/moderators.service.ts index 420251188..dfb86fca8 100644 --- a/src/app/features/moderation/services/moderators.service.ts +++ b/src/app/features/moderation/services/moderators.service.ts @@ -1,16 +1,19 @@ -import { map, Observable } from 'rxjs'; +import { forkJoin, map, Observable } from 'rxjs'; import { inject, Injectable } from '@angular/core'; import { ENVIRONMENT } from '@core/provider/environment.provider'; import { ResourceType } from '@osf/shared/enums/resource-type.enum'; -import { JsonApiResponse, ResponseJsonApi } from '@osf/shared/models/common/json-api.model'; +import { parseSearchTotalCount } from '@osf/shared/helpers/search-total-count.helper'; +import { MapResources } from '@osf/shared/mappers/search'; +import { JsonApiResponse } from '@osf/shared/models/common/json-api.model'; import { PaginatedData } from '@osf/shared/models/paginated-data.model'; -import { UserDataJsonApi } from '@osf/shared/models/user/user-json-api.model'; +import { IndexCardSearchResponseJsonApi } from '@osf/shared/models/search/index-card-search-json-api.models'; +import { SearchUserDataModel } from '@osf/shared/models/user/search-user-data.model'; import { JsonApiService } from '@osf/shared/services/json-api.service'; import { StringOrNull } from '@shared/helpers/types.helper'; -import { AddModeratorType } from '../enums'; +import { AddModeratorType, ModeratorPermission } from '../enums'; import { ModerationMapper } from '../mappers'; import { ModeratorAddModel, ModeratorDataJsonApi, ModeratorModel, ModeratorResponseJsonApi } from '../models'; @@ -25,6 +28,14 @@ export class ModeratorsService { return `${this.environment.apiDomainUrl}/v2`; } + get shareTroveUrl() { + return this.environment.shareTroveUrl; + } + + get webUrl() { + return this.environment.webUrl; + } + private readonly urlMap = new Map([ [ResourceType.Collection, 'providers/collections'], [ResourceType.Registration, 'providers/registrations'], @@ -84,11 +95,86 @@ export class ModeratorsService { return this.jsonApiService.delete(baseUrl); } - searchUsers(value: string, page = 1): Observable> { - const baseUrl = `${this.apiUrl}/search/users/?q=${value}*&page=${page}`; + searchUsers(value: string, pageSize = 10): Observable> { + if (value.length === 5) { + return forkJoin([this.searchUsersByName(value, pageSize), this.searchUsersById(value, pageSize)]).pipe( + map(([nameResults, idResults]) => { + const users = [...nameResults.users]; + const existingIds = new Set(users.map((u) => u.id)); + + idResults.users.forEach((user) => { + if (!existingIds.has(user.id)) { + users.push(user); + existingIds.add(user.id); + } + }); + + return { + users, + totalCount: nameResults.totalCount + idResults.totalCount, + next: nameResults.next, + previous: nameResults.previous, + }; + }) + ); + } else { + return this.searchUsersByName(value, pageSize); + } + } + + searchUsersByName(value: string, pageSize = 10): Observable> { + const baseUrl = `${this.shareTroveUrl}/index-card-search`; + const params = { + 'cardSearchFilter[resourceType]': 'Person', + 'cardSearchFilter[accessService]': this.webUrl, + 'cardSearchText[name]': `${value}*`, + acceptMediatype: 'application/vnd.api+json', + 'page[size]': pageSize, + }; return this.jsonApiService - .get>(baseUrl) - .pipe(map((response) => ModerationMapper.fromUsersWithPaginationGetResponse(response))); + .get(baseUrl, params) + .pipe(map((response) => this.handleResourcesRawResponse(response))); + } + + searchUsersById(value: string, pageSize = 10): Observable> { + const baseUrl = `${this.shareTroveUrl}/index-card-search`; + const params = { + 'cardSearchFilter[resourceType]': 'Person', + 'cardSearchFilter[accessService]': this.webUrl, + 'cardSearchFilter[sameAs]': `${this.webUrl}/${value}`, + acceptMediatype: 'application/vnd.api+json', + 'page[size]': pageSize, + }; + + return this.jsonApiService + .get(baseUrl, params) + .pipe(map((response) => this.handleResourcesRawResponse(response))); + } + + getUsersByLink(link: string): Observable> { + return this.jsonApiService + .get(link) + .pipe(map((response) => this.handleResourcesRawResponse(response))); + } + + private handleResourcesRawResponse( + response: IndexCardSearchResponseJsonApi + ): SearchUserDataModel { + const users = MapResources(response).map( + (user) => + ({ + id: user.absoluteUrl.split('/').pop(), + fullName: user.name, + permission: ModeratorPermission.Moderator, + }) as ModeratorAddModel + ); + + return { + users, + totalCount: parseSearchTotalCount(response), + next: response.data?.relationships?.searchResultPage.links?.next?.href ?? null, + previous: response.data?.relationships?.searchResultPage.links?.prev?.href ?? null, + }; } } diff --git a/src/app/features/moderation/store/moderators/moderators.actions.ts b/src/app/features/moderation/store/moderators/moderators.actions.ts index 8a871265e..f019fb404 100644 --- a/src/app/features/moderation/store/moderators/moderators.actions.ts +++ b/src/app/features/moderation/store/moderators/moderators.actions.ts @@ -54,10 +54,13 @@ export class UpdateModeratorsSearchValue { export class SearchUsers { static readonly type = `${ACTION_SCOPE} Search Users`; - constructor( - public searchValue: string | null, - public page: number - ) {} + constructor(public searchValue: string | null) {} +} + +export class SearchUsersPageChange { + static readonly type = `${ACTION_SCOPE} Search Users Page Change`; + + constructor(public link: string) {} } export class ClearUsers { diff --git a/src/app/features/moderation/store/moderators/moderators.model.ts b/src/app/features/moderation/store/moderators/moderators.model.ts index 0adc50668..51723ce75 100644 --- a/src/app/features/moderation/store/moderators/moderators.model.ts +++ b/src/app/features/moderation/store/moderators/moderators.model.ts @@ -4,13 +4,18 @@ import { ModeratorAddModel, ModeratorModel } from '../../models'; export interface ModeratorsStateModel { moderators: ModeratorsDataStateModel; - users: AsyncStateWithTotalCount; + users: UserListModel; } interface ModeratorsDataStateModel extends AsyncStateWithTotalCount { searchValue: string | null; } +interface UserListModel extends AsyncStateWithTotalCount { + next: string | null; + previous: string | null; +} + export const MODERATORS_STATE_DEFAULTS: ModeratorsStateModel = { moderators: { data: [], @@ -24,5 +29,7 @@ export const MODERATORS_STATE_DEFAULTS: ModeratorsStateModel = { isLoading: false, error: null, totalCount: 0, + next: null, + previous: null, }, }; diff --git a/src/app/features/moderation/store/moderators/moderators.selectors.ts b/src/app/features/moderation/store/moderators/moderators.selectors.ts index 834d6bf6d..79009ddc8 100644 --- a/src/app/features/moderation/store/moderators/moderators.selectors.ts +++ b/src/app/features/moderation/store/moderators/moderators.selectors.ts @@ -39,4 +39,14 @@ export class ModeratorsSelectors { static getUsersTotalCount(state: ModeratorsStateModel): number { return state.users.totalCount; } + + @Selector([ModeratorsState]) + static getUsersNextLink(state: ModeratorsStateModel) { + return state?.users?.next || null; + } + + @Selector([ModeratorsState]) + static getUsersPreviousLink(state: ModeratorsStateModel) { + return state?.users?.previous || null; + } } diff --git a/src/app/features/moderation/store/moderators/moderators.state.ts b/src/app/features/moderation/store/moderators/moderators.state.ts index 27d4aee17..240e4dbc7 100644 --- a/src/app/features/moderation/store/moderators/moderators.state.ts +++ b/src/app/features/moderation/store/moderators/moderators.state.ts @@ -16,6 +16,7 @@ import { DeleteModerator, LoadModerators, SearchUsers, + SearchUsersPageChange, UpdateModerator, UpdateModeratorsSearchValue, } from './moderators.actions'; @@ -141,14 +142,43 @@ export class ModeratorsState { return of([]); } - return this.moderatorsService.searchUsers(action.searchValue, action.page).pipe( - tap((users) => { + return this.moderatorsService.searchUsers(action.searchValue).pipe( + tap((response) => { ctx.patchState({ users: { - data: users.data.filter((user) => !addedModeratorsIds.includes(user.id!)), + data: response.users.filter((user) => !addedModeratorsIds.includes(user.id!)), isLoading: false, error: '', - totalCount: users.totalCount, + totalCount: response.totalCount, + next: response.next, + previous: response.previous, + }, + }); + }), + catchError((error) => handleSectionError(ctx, 'users', error)) + ); + } + + @Action(SearchUsersPageChange) + searchUsersPageChange(ctx: StateContext, action: SearchUsersPageChange) { + const state = ctx.getState(); + + ctx.patchState({ + users: { ...state.users, isLoading: true, error: null }, + }); + + const addedModeratorsIds = state.moderators.data.map((moderator) => moderator.userId); + + return this.moderatorsService.getUsersByLink(action.link).pipe( + tap((response) => { + ctx.patchState({ + users: { + data: response.users.filter((user) => !addedModeratorsIds.includes(user.id!)), + isLoading: false, + error: '', + totalCount: response.totalCount, + next: response.next, + previous: response.previous, }, }); }), @@ -158,6 +188,8 @@ export class ModeratorsState { @Action(ClearUsers) clearUsers(ctx: StateContext) { - ctx.patchState({ users: { data: [], isLoading: false, error: null, totalCount: 0 } }); + ctx.patchState({ + users: { data: [], isLoading: false, error: null, totalCount: 0, next: null, previous: null }, + }); } } diff --git a/src/app/shared/components/contributors/add-contributor-dialog/add-contributor-dialog.component.html b/src/app/shared/components/contributors/add-contributor-dialog/add-contributor-dialog.component.html index b235be0d0..7066cee4c 100644 --- a/src/app/shared/components/contributors/add-contributor-dialog/add-contributor-dialog.component.html +++ b/src/app/shared/components/contributors/add-contributor-dialog/add-contributor-dialog.component.html @@ -31,6 +31,7 @@ } diff --git a/src/app/shared/components/contributors/add-contributor-dialog/add-contributor-dialog.component.spec.ts b/src/app/shared/components/contributors/add-contributor-dialog/add-contributor-dialog.component.spec.ts index 040a6ddaf..943aa2603 100644 --- a/src/app/shared/components/contributors/add-contributor-dialog/add-contributor-dialog.component.spec.ts +++ b/src/app/shared/components/contributors/add-contributor-dialog/add-contributor-dialog.component.spec.ts @@ -1,13 +1,15 @@ -import { MockComponents, MockProviders } from 'ng-mocks'; +import { Store } from '@ngxs/store'; + +import { MockComponents } from 'ng-mocks'; import { DynamicDialogConfig, DynamicDialogRef } from 'primeng/dynamicdialog'; +import { PaginatorState } from 'primeng/paginator'; import { signal } from '@angular/core'; -import { ComponentFixture, TestBed } from '@angular/core/testing'; +import { ComponentFixture, fakeAsync, TestBed, tick } from '@angular/core/testing'; import { AddContributorType } from '@osf/shared/enums/contributors/add-contributor-type.enum'; import { AddDialogState } from '@osf/shared/enums/contributors/add-dialog-state.enum'; -import { ContributorAddModel } from '@osf/shared/models/contributors/contributor-add.model'; import { AddContributorItemComponent } from '@shared/components/contributors/add-contributor-item/add-contributor-item.component'; import { ContributorsSelectors } from '@shared/stores/contributors'; @@ -18,16 +20,31 @@ import { SearchInputComponent } from '../../search-input/search-input.component' import { AddContributorDialogComponent } from './add-contributor-dialog.component'; +import { + MOCK_COMPONENT_CHECKBOX_ITEM, + MOCK_COMPONENT_CHECKBOX_ITEM_CURRENT, + MOCK_CONTRIBUTOR_ADD, + MOCK_CONTRIBUTOR_ADD_DISABLED, +} from '@testing/mocks/contributors.mock'; import { OSFTestingModule } from '@testing/osf.testing.module'; import { provideMockStore } from '@testing/providers/store-provider.mock'; describe('AddContributorDialogComponent', () => { let component: AddContributorDialogComponent; let fixture: ComponentFixture; - let dialogRef: DynamicDialogRef; - let closeSpy: jest.SpyInstance; + let dialogRef: jest.Mocked; + let dialogConfig: DynamicDialogConfig; + let store: Store; beforeEach(async () => { + dialogRef = { + close: jest.fn(), + } as any; + + dialogConfig = { + data: {}, + } as DynamicDialogConfig; + await TestBed.configureTestingModule({ imports: [ AddContributorDialogComponent, @@ -46,74 +63,244 @@ describe('AddContributorDialogComponent', () => { { selector: ContributorsSelectors.getUsers, value: signal([]) }, { selector: ContributorsSelectors.isUsersLoading, value: false }, { selector: ContributorsSelectors.getUsersTotalCount, value: 0 }, + { selector: ContributorsSelectors.getUsersNextLink, value: signal(null) }, + { selector: ContributorsSelectors.getUsersPreviousLink, value: signal(null) }, ], }), - MockProviders(DynamicDialogRef, DynamicDialogConfig), + { provide: DynamicDialogRef, useValue: dialogRef }, + { provide: DynamicDialogConfig, useValue: dialogConfig }, ], }).compileComponents(); + store = TestBed.inject(Store); fixture = TestBed.createComponent(AddContributorDialogComponent); component = fixture.componentInstance; - dialogRef = TestBed.inject(DynamicDialogRef); - closeSpy = jest.spyOn(dialogRef, 'close'); - - fixture.detectChanges(); }); it('should create', () => { expect(component).toBeTruthy(); }); - it('should have search control initialized', () => { - expect(component['searchControl']).toBeDefined(); - expect(component['searchControl'].value).toBe(''); + it('should initialize with default values', () => { + expect(component.currentState()).toBe(AddDialogState.Search); + expect(component.isInitialState()).toBe(true); + expect(component.selectedUsers()).toEqual([]); }); - it('should have config injected', () => { - expect(component['config']).toBeDefined(); + it('should initialize dialog data from config', () => { + const mockComponents = [MOCK_COMPONENT_CHECKBOX_ITEM]; + dialogConfig.data = { + components: mockComponents, + resourceName: 'Test Resource', + parentResourceName: 'Parent Resource', + allowAddingContributorsFromParentProject: true, + }; + + fixture = TestBed.createComponent(AddContributorDialogComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + + expect(component.components()).toEqual(mockComponents); + expect(component.resourceName()).toBe('Test Resource'); }); - it('should have dialogRef injected', () => { - expect(component['dialogRef']).toBeDefined(); + it('should compute contributorNames correctly', () => { + component.selectedUsers.set([MOCK_CONTRIBUTOR_ADD, MOCK_CONTRIBUTOR_ADD_DISABLED]); + expect(component.contributorNames()).toBe('John Doe, Jane Smith'); }); - it('should check isSearchState getter', () => { - expect(component['isSearchState']()).toBe(true); + it('should compute state flags correctly', () => { + component.currentState.set(AddDialogState.Search); + expect(component.isSearchState()).toBe(true); + expect(component.isDetailsState()).toBe(false); - component['currentState'].set(AddDialogState.Details); - expect(component['isSearchState']()).toBe(false); + component.currentState.set(AddDialogState.Details); + expect(component.isDetailsState()).toBe(true); + expect(component.isSearchState()).toBe(false); }); - it('should add contributor and close dialog when in details state', () => { - const mockUsers: ContributorAddModel[] = [ - { id: '1', fullName: 'Test User', isBibliographic: true, permission: 'read' }, - ]; + it('should compute hasComponents correctly', () => { + component.components.set([MOCK_COMPONENT_CHECKBOX_ITEM, MOCK_COMPONENT_CHECKBOX_ITEM_CURRENT]); + expect(component.hasComponents()).toBe(true); + + component.components.set([MOCK_COMPONENT_CHECKBOX_ITEM]); + expect(component.hasComponents()).toBe(false); + }); - component['selectedUsers'].set(mockUsers); - component['currentState'].set(AddDialogState.Details); + it('should compute buttonLabel based on state and components', () => { + component.currentState.set(AddDialogState.Search); + expect(component.buttonLabel()).toBe('common.buttons.next'); - component['addContributor'](); + component.currentState.set(AddDialogState.Details); + component.components.set([]); + expect(component.buttonLabel()).toBe('common.buttons.done'); + + component.components.set([MOCK_COMPONENT_CHECKBOX_ITEM, MOCK_COMPONENT_CHECKBOX_ITEM_CURRENT]); + expect(component.buttonLabel()).toBe('common.buttons.next'); + + component.currentState.set(AddDialogState.Components); + expect(component.buttonLabel()).toBe('common.buttons.done'); + }); - expect(closeSpy).toHaveBeenCalledWith({ - data: mockUsers, + it('should transition states and close dialog appropriately', () => { + component.currentState.set(AddDialogState.Search); + component.addContributor(); + expect(component.currentState()).toBe(AddDialogState.Details); + + component.currentState.set(AddDialogState.Details); + component.components.set([MOCK_COMPONENT_CHECKBOX_ITEM, MOCK_COMPONENT_CHECKBOX_ITEM_CURRENT]); + component.addContributor(); + expect(component.currentState()).toBe(AddDialogState.Components); + + component.currentState.set(AddDialogState.Details); + component.components.set([]); + component.selectedUsers.set([MOCK_CONTRIBUTOR_ADD]); + component.addContributor(); + expect(dialogRef.close).toHaveBeenCalledWith({ + data: [MOCK_CONTRIBUTOR_ADD], type: AddContributorType.Registered, + childNodeIds: undefined, }); + + component.currentState.set(AddDialogState.Components); + component.components.set([{ ...MOCK_COMPONENT_CHECKBOX_ITEM, checked: true }]); + component.addContributor(); + expect(dialogRef.close).toHaveBeenCalledTimes(2); }); - it('should add unregistered contributor and close dialog', () => { - component['addUnregistered'](); + it('should close dialog with correct data for different actions', () => { + component.selectedUsers.set([MOCK_CONTRIBUTOR_ADD]); - expect(closeSpy).toHaveBeenCalledWith({ + component.addSourceProjectContributors(); + expect(dialogRef.close).toHaveBeenCalledWith({ + data: [MOCK_CONTRIBUTOR_ADD], + type: AddContributorType.ParentProject, + childNodeIds: undefined, + }); + + component.addUnregistered(); + expect(dialogRef.close).toHaveBeenCalledWith({ data: [], type: AddContributorType.Unregistered, }); }); - it('should handle search state transitions', () => { - expect(component['isSearchState']()).toBe(true); - expect(component['isInitialState']()).toBe(true); + it('should handle pagination correctly', () => { + const dispatchSpy = jest.spyOn(store, 'dispatch'); + + 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); + }); + + 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 === ContributorsSelectors.getUsersNextLink) { + return signal(nextLink); + } + return originalSelect(selector); + }); + + Object.defineProperty(component, 'usersNextLink', { + get: () => signal(nextLink), + configurable: true, + }); + + const dispatchSpy = jest.spyOn(store, 'dispatch'); + component.currentPage.set(2); + component.pageChanged({ page: 2, first: 20, rows: 10 } as PaginatorState); + + expect(dispatchSpy).toHaveBeenCalled(); + expect(component.currentPage()).toBe(3); + expect(component.first()).toBe(20); + }); + + it('should debounce and filter search input', fakeAsync(() => { + fixture.detectChanges(); + const dispatchSpy = jest.spyOn(store, 'dispatch'); + + component.searchControl.setValue('t'); + tick(200); + component.searchControl.setValue('test'); + tick(500); + + expect(dispatchSpy).toHaveBeenCalledTimes(1); + expect(component.isInitialState()).toBe(false); + expect(component.selectedUsers()).toEqual([]); + })); + + it('should not search empty or whitespace values', fakeAsync(() => { + fixture.detectChanges(); + const dispatchSpy = jest.spyOn(store, 'dispatch'); + + component.searchControl.setValue(''); + tick(500); + expect(dispatchSpy).not.toHaveBeenCalled(); + + component.searchControl.setValue(' '); + tick(500); + expect(dispatchSpy).not.toHaveBeenCalled(); + })); + + it('should reset pagination on search', fakeAsync(() => { + fixture.detectChanges(); + component.currentPage.set(3); + component.first.set(20); + + component.searchControl.setValue('test'); + tick(500); + + expect(component.currentPage()).toBe(1); + expect(component.first()).toBe(0); + })); + + it('should update selectedUsers from checked users', () => { + const checkedUsers = [MOCK_CONTRIBUTOR_ADD]; + const usersSignal = signal(checkedUsers); + + Object.defineProperty(component, 'users', { + get: () => usersSignal, + configurable: true, + }); + + fixture.detectChanges(); + usersSignal.set(checkedUsers); + fixture.detectChanges(); + + expect(component.selectedUsers().length).toBeGreaterThan(0); + }); + + it('should filter disabled users and include childNodeIds', () => { + component.selectedUsers.set([MOCK_CONTRIBUTOR_ADD, MOCK_CONTRIBUTOR_ADD_DISABLED]); + component.components.set([]); + component['closeDialogWithData'](); + + expect(dialogRef.close).toHaveBeenCalledWith({ + data: [MOCK_CONTRIBUTOR_ADD], + type: AddContributorType.Registered, + childNodeIds: undefined, + }); + + component.components.set([{ ...MOCK_COMPONENT_CHECKBOX_ITEM, checked: true }]); + component['closeDialogWithData'](AddContributorType.ParentProject); + + expect(dialogRef.close).toHaveBeenCalledWith({ + data: [MOCK_CONTRIBUTOR_ADD], + type: AddContributorType.ParentProject, + childNodeIds: [MOCK_COMPONENT_CHECKBOX_ITEM.id], + }); + }); - component['currentState'].set(AddDialogState.Details); - expect(component['isSearchState']()).toBe(false); + it('should clear users on destroy', () => { + const dispatchSpy = jest.spyOn(store, 'dispatch'); + component.ngOnDestroy(); + expect(dispatchSpy).toHaveBeenCalled(); }); }); diff --git a/src/app/shared/components/contributors/add-contributor-dialog/add-contributor-dialog.component.ts b/src/app/shared/components/contributors/add-contributor-dialog/add-contributor-dialog.component.ts index dd6c908a5..024044f78 100644 --- a/src/app/shared/components/contributors/add-contributor-dialog/add-contributor-dialog.component.ts +++ b/src/app/shared/components/contributors/add-contributor-dialog/add-contributor-dialog.component.ts @@ -26,7 +26,7 @@ import { FormControl, FormsModule } from '@angular/forms'; import { DEFAULT_TABLE_PARAMS } from '@osf/shared/constants/default-table-params.constants'; import { AddContributorType } from '@osf/shared/enums/contributors/add-contributor-type.enum'; import { AddDialogState } from '@osf/shared/enums/contributors/add-dialog-state.enum'; -import { ClearUsers, ContributorsSelectors, SearchUsers } from '@osf/shared/stores/contributors'; +import { ClearUsers, ContributorsSelectors, SearchUsers, SearchUsersPageChange } from '@osf/shared/stores/contributors'; import { ComponentCheckboxItemModel } from '@shared/models/component-checkbox-item.model'; import { ContributorAddModel } from '@shared/models/contributors/contributor-add.model'; import { ContributorDialogAddModel } from '@shared/models/contributors/contributor-dialog-add.model'; @@ -58,11 +58,17 @@ export class AddContributorDialogComponent implements OnInit, OnDestroy { readonly dialogRef = inject(DynamicDialogRef); private readonly destroyRef = inject(DestroyRef); private readonly config = inject(DynamicDialogConfig); - private readonly actions = createDispatchMap({ searchUsers: SearchUsers, clearUsers: ClearUsers }); + private readonly actions = createDispatchMap({ + searchUsers: SearchUsers, + searchUsersPageChange: SearchUsersPageChange, + clearUsers: ClearUsers, + }); readonly users = select(ContributorsSelectors.getUsers); readonly isLoading = select(ContributorsSelectors.isUsersLoading); readonly totalUsersCount = select(ContributorsSelectors.getUsersTotalCount); + readonly usersNextLink = select(ContributorsSelectors.getUsersNextLink); + readonly usersPreviousLink = select(ContributorsSelectors.getUsersPreviousLink); readonly searchControl = new FormControl(''); readonly isInitialState = signal(true); @@ -139,9 +145,31 @@ export class AddContributorDialogComponent implements OnInit, OnDestroy { } pageChanged(event: PaginatorState): void { - this.currentPage.set(event.page ? this.currentPage() + 1 : 1); - this.first.set(event.first ?? 0); - this.actions.searchUsers(this.searchControl.value, this.currentPage()); + 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 initializeDialogData(): void { @@ -189,7 +217,7 @@ export class AddContributorDialogComponent implements OnInit, OnDestroy { distinctUntilChanged(), switchMap((searchTerm) => { this.resetPagination(); - return this.actions.searchUsers(searchTerm, this.currentPage()); + return this.actions.searchUsers(searchTerm); }), takeUntilDestroyed(this.destroyRef) ) diff --git a/src/app/shared/components/custom-paginator/custom-paginator.component.html b/src/app/shared/components/custom-paginator/custom-paginator.component.html index 98e4bac60..635f9de9e 100644 --- a/src/app/shared/components/custom-paginator/custom-paginator.component.html +++ b/src/app/shared/components/custom-paginator/custom-paginator.component.html @@ -4,4 +4,5 @@ [rows]="rows()" [first]="first()" [totalRecords]="totalCount()" + [showPageLinks]="showPageLinks()" /> diff --git a/src/app/shared/components/custom-paginator/custom-paginator.component.spec.ts b/src/app/shared/components/custom-paginator/custom-paginator.component.spec.ts index 8ba32db52..b81ef2248 100644 --- a/src/app/shared/components/custom-paginator/custom-paginator.component.spec.ts +++ b/src/app/shared/components/custom-paginator/custom-paginator.component.spec.ts @@ -27,34 +27,7 @@ describe('CustomPaginatorComponent', () => { expect(component.rows()).toBe(10); expect(component.totalCount()).toBe(0); expect(component.showFirstLastIcon()).toBe(false); - }); - - it('should accept first input', () => { - fixture.componentRef.setInput('first', 20); - fixture.detectChanges(); - - expect(component.first()).toBe(20); - }); - - it('should accept rows input', () => { - fixture.componentRef.setInput('rows', 25); - fixture.detectChanges(); - - expect(component.rows()).toBe(25); - }); - - it('should accept totalCount input', () => { - fixture.componentRef.setInput('totalCount', 100); - fixture.detectChanges(); - - expect(component.totalCount()).toBe(100); - }); - - it('should accept showFirstLastIcon input', () => { - fixture.componentRef.setInput('showFirstLastIcon', true); - fixture.detectChanges(); - - expect(component.showFirstLastIcon()).toBe(true); + expect(component.showPageLinks()).toBe(true); }); it('should emit pageChanged event', () => { @@ -76,12 +49,14 @@ describe('CustomPaginatorComponent', () => { fixture.componentRef.setInput('rows', 15); fixture.componentRef.setInput('totalCount', 150); fixture.componentRef.setInput('showFirstLastIcon', true); + fixture.componentRef.setInput('showPageLinks', false); fixture.detectChanges(); expect(component.first()).toBe(30); expect(component.rows()).toBe(15); expect(component.totalCount()).toBe(150); expect(component.showFirstLastIcon()).toBe(true); + expect(component.showPageLinks()).toBe(false); }); it('should handle input updates', () => { diff --git a/src/app/shared/components/custom-paginator/custom-paginator.component.ts b/src/app/shared/components/custom-paginator/custom-paginator.component.ts index 187755275..a99474043 100644 --- a/src/app/shared/components/custom-paginator/custom-paginator.component.ts +++ b/src/app/shared/components/custom-paginator/custom-paginator.component.ts @@ -14,6 +14,7 @@ export class CustomPaginatorComponent { rows = input(10); totalCount = input(0); showFirstLastIcon = input(false); + showPageLinks = input(true); pageChanged = output(); } diff --git a/src/app/shared/helpers/search-total-count.helper.ts b/src/app/shared/helpers/search-total-count.helper.ts new file mode 100644 index 000000000..49ede8e45 --- /dev/null +++ b/src/app/shared/helpers/search-total-count.helper.ts @@ -0,0 +1,19 @@ +import { IndexCardSearchResponseJsonApi } from '../models/search/index-card-search-json-api.models'; + +export function parseSearchTotalCount(response: IndexCardSearchResponseJsonApi): number { + let totalCount = 0; + const rawTotalCount = response.data.attributes.totalResultCount; + + if (typeof rawTotalCount === 'number') { + totalCount = rawTotalCount; + } else if ( + typeof rawTotalCount === 'object' && + rawTotalCount !== null && + '@id' in rawTotalCount && + String(rawTotalCount['@id']).includes('ten-thousands-and-more') + ) { + totalCount = 10000; + } + + return totalCount; +} diff --git a/src/app/shared/models/user/search-user-data.model.ts b/src/app/shared/models/user/search-user-data.model.ts new file mode 100644 index 000000000..76a47ff41 --- /dev/null +++ b/src/app/shared/models/user/search-user-data.model.ts @@ -0,0 +1,6 @@ +export interface SearchUserDataModel { + users: T; + totalCount: number; + next: string | null; + previous: string | null; +} diff --git a/src/app/shared/services/contributors.service.ts b/src/app/shared/services/contributors.service.ts index f527c445b..3ab5d6145 100644 --- a/src/app/shared/services/contributors.service.ts +++ b/src/app/shared/services/contributors.service.ts @@ -1,18 +1,21 @@ -import { map, Observable, of } from 'rxjs'; +import { forkJoin, map, Observable, of } from 'rxjs'; import { inject, Injectable } from '@angular/core'; import { ENVIRONMENT } from '@core/provider/environment.provider'; import { AddContributorType } from '../enums/contributors/add-contributor-type.enum'; +import { ContributorPermission } from '../enums/contributors/contributor-permission.enum'; import { ResourceType } from '../enums/resource-type.enum'; +import { parseSearchTotalCount } from '../helpers/search-total-count.helper'; import { ContributorsMapper } from '../mappers/contributors'; -import { ResponseJsonApi } from '../models/common/json-api.model'; +import { MapResources } from '../mappers/search'; import { ContributorModel } from '../models/contributors/contributor.model'; import { ContributorAddModel } from '../models/contributors/contributor-add.model'; import { ContributorsResponseJsonApi } from '../models/contributors/contributor-response-json-api.model'; import { PaginatedData } from '../models/paginated-data.model'; -import { UserDataJsonApi } from '../models/user/user-json-api.model'; +import { IndexCardSearchResponseJsonApi } from '../models/search/index-card-search-json-api.models'; +import { SearchUserDataModel } from '../models/user/search-user-data.model'; import { JsonApiService } from './json-api.service'; @@ -27,6 +30,14 @@ export class ContributorsService { return `${this.environment.apiDomainUrl}/v2`; } + get shareTroveUrl() { + return this.environment.shareTroveUrl; + } + + get webUrl() { + return this.environment.webUrl; + } + private readonly urlMap = new Map([ [ResourceType.Project, 'nodes'], [ResourceType.Registration, 'registrations'], @@ -90,11 +101,87 @@ export class ContributorsService { ); } - searchUsers(value: string, page = 1): Observable> { - const baseUrl = `${this.apiUrl}/search/users/?q=${value}*&page=${page}`; + searchUsers(value: string, pageSize = 10): Observable> { + if (value.length === 5) { + return forkJoin([this.searchUsersByName(value, pageSize), this.searchUsersById(value, pageSize)]).pipe( + map(([nameResults, idResults]) => { + const users = [...nameResults.users]; + const existingIds = new Set(users.map((u) => u.id)); + + idResults.users.forEach((user) => { + if (!existingIds.has(user.id)) { + users.push(user); + existingIds.add(user.id); + } + }); + + return { + users, + totalCount: nameResults.totalCount + idResults.totalCount, + next: nameResults.next, + previous: nameResults.previous, + }; + }) + ); + } else { + return this.searchUsersByName(value, pageSize); + } + } + + searchUsersByName(value: string, pageSize = 10): Observable> { + const baseUrl = `${this.shareTroveUrl}/index-card-search`; + const params = { + 'cardSearchFilter[resourceType]': 'Person', + 'cardSearchFilter[accessService]': this.webUrl, + 'cardSearchText[name]': `${value}*`, + acceptMediatype: 'application/vnd.api+json', + 'page[size]': pageSize, + }; + + return this.jsonApiService + .get(baseUrl, params) + .pipe(map((response) => this.handleResourcesRawResponse(response))); + } + + searchUsersById(value: string, pageSize = 10): Observable> { + const baseUrl = `${this.shareTroveUrl}/index-card-search`; + const params = { + 'cardSearchFilter[resourceType]': 'Person', + 'cardSearchFilter[accessService]': this.webUrl, + 'cardSearchFilter[sameAs]': `${this.webUrl}/${value}`, + acceptMediatype: 'application/vnd.api+json', + 'page[size]': pageSize, + }; + + return this.jsonApiService + .get(baseUrl, params) + .pipe(map((response) => this.handleResourcesRawResponse(response))); + } + + getUsersByLink(link: string): Observable> { return this.jsonApiService - .get>(baseUrl) - .pipe(map((response) => ContributorsMapper.getPaginatedUsers(response))); + .get(link) + .pipe(map((response) => this.handleResourcesRawResponse(response))); + } + + private handleResourcesRawResponse( + response: IndexCardSearchResponseJsonApi + ): SearchUserDataModel { + const users = MapResources(response).map( + (user) => + ({ + id: user.absoluteUrl.split('/').pop(), + fullName: user.name, + permission: ContributorPermission.Write, + }) as ContributorAddModel + ); + + return { + users, + totalCount: parseSearchTotalCount(response), + next: response.data?.relationships?.searchResultPage.links?.next?.href ?? null, + previous: response.data?.relationships?.searchResultPage.links?.prev?.href ?? null, + }; } bulkUpdateContributors( diff --git a/src/app/shared/services/global-search.service.ts b/src/app/shared/services/global-search.service.ts index dce5596df..fa2f73cf0 100644 --- a/src/app/shared/services/global-search.service.ts +++ b/src/app/shared/services/global-search.service.ts @@ -4,6 +4,7 @@ import { inject, Injectable } from '@angular/core'; import { ENVIRONMENT } from '@core/provider/environment.provider'; +import { parseSearchTotalCount } from '../helpers/search-total-count.helper'; import { mapFilterOptions } from '../mappers/filters/filter-option.mapper'; import { MapFilters } from '../mappers/filters/filters.mapper'; import { MapResources } from '../mappers/search'; @@ -80,29 +81,11 @@ export class GlobalSearchService { return { resources: MapResources(response), filters: MapFilters(response), - count: this.parseTotalCount(response), + count: parseSearchTotalCount(response), self: response.data.links.self, first: response.data?.relationships?.searchResultPage.links?.first?.href ?? null, next: response.data?.relationships?.searchResultPage.links?.next?.href ?? null, previous: response.data?.relationships?.searchResultPage.links?.prev?.href ?? null, }; } - - private parseTotalCount(response: IndexCardSearchResponseJsonApi) { - let totalCount = 0; - const rawTotalCount = response.data.attributes.totalResultCount; - - if (typeof rawTotalCount === 'number') { - totalCount = rawTotalCount; - } else if ( - typeof rawTotalCount === 'object' && - rawTotalCount !== null && - '@id' in rawTotalCount && - String(rawTotalCount['@id']).includes('ten-thousands-and-more') - ) { - totalCount = 10000; - } - - return totalCount; - } } diff --git a/src/app/shared/stores/contributors/contributors.actions.ts b/src/app/shared/stores/contributors/contributors.actions.ts index 561e6dcec..6dc7b323f 100644 --- a/src/app/shared/stores/contributors/contributors.actions.ts +++ b/src/app/shared/stores/contributors/contributors.actions.ts @@ -78,10 +78,13 @@ export class DeleteContributor { export class SearchUsers { static readonly type = '[Contributors] Search Users'; - constructor( - public searchValue: string | null, - public page: number - ) {} + constructor(public searchValue: string | null) {} +} + +export class SearchUsersPageChange { + static readonly type = '[Contributors] Search Users Page Change'; + + constructor(public link: string) {} } export class ClearUsers { diff --git a/src/app/shared/stores/contributors/contributors.model.ts b/src/app/shared/stores/contributors/contributors.model.ts index 28efa9ed3..40c295091 100644 --- a/src/app/shared/stores/contributors/contributors.model.ts +++ b/src/app/shared/stores/contributors/contributors.model.ts @@ -5,23 +5,28 @@ import { RequestAccessModel } from '@shared/models/request-access/request-access import { AsyncStateModel } from '@shared/models/store/async-state.model'; import { AsyncStateWithTotalCount } from '@shared/models/store/async-state-with-total-count.model'; -export interface ContributorsList extends AsyncStateWithTotalCount { +interface ContributorsList extends AsyncStateWithTotalCount { page: number; pageSize: number; } -export interface ContributorsListWithFiltersModel extends ContributorsList { +interface ContributorsListWithFiltersModel extends ContributorsList { searchValue: string | null; permissionFilter: string | null; bibliographyFilter: boolean | null; isLoadingMore: boolean; } +interface UserListModel extends AsyncStateWithTotalCount { + next: string | null; + previous: string | null; +} + export interface ContributorsStateModel { contributorsList: ContributorsListWithFiltersModel; bibliographicContributorsList: ContributorsList; requestAccessList: AsyncStateModel; - users: AsyncStateWithTotalCount; + users: UserListModel; } export const CONTRIBUTORS_STATE_DEFAULTS: ContributorsStateModel = { @@ -56,5 +61,7 @@ export const CONTRIBUTORS_STATE_DEFAULTS: ContributorsStateModel = { isLoading: false, error: null, totalCount: 0, + next: null, + previous: null, }, }; diff --git a/src/app/shared/stores/contributors/contributors.selectors.ts b/src/app/shared/stores/contributors/contributors.selectors.ts index 57f026482..f9a7894ef 100644 --- a/src/app/shared/stores/contributors/contributors.selectors.ts +++ b/src/app/shared/stores/contributors/contributors.selectors.ts @@ -87,6 +87,16 @@ export class ContributorsSelectors { return state?.users?.data || []; } + @Selector([ContributorsState]) + static getUsersNextLink(state: ContributorsStateModel) { + return state?.users?.next || null; + } + + @Selector([ContributorsState]) + static getUsersPreviousLink(state: ContributorsStateModel) { + return state?.users?.previous || null; + } + @Selector([ContributorsState]) static getUsersTotalCount(state: ContributorsStateModel) { return state?.users?.totalCount || 0; diff --git a/src/app/shared/stores/contributors/contributors.state.ts b/src/app/shared/stores/contributors/contributors.state.ts index c5ec57c26..0f9b29652 100644 --- a/src/app/shared/stores/contributors/contributors.state.ts +++ b/src/app/shared/stores/contributors/contributors.state.ts @@ -23,6 +23,7 @@ import { RejectRequestAccess, ResetContributorsState, SearchUsers, + SearchUsersPageChange, UpdateBibliographyFilter, UpdateContributorsSearchValue, UpdatePermissionFilter, @@ -272,20 +273,53 @@ export class ContributorsState { return of([]); } - return this.contributorsService.searchUsers(action.searchValue, action.page).pipe( - tap((users) => { + return this.contributorsService.searchUsers(action.searchValue).pipe( + tap((response) => { const addedContributorsIds = state.contributorsList.data.map((contributor) => contributor.userId); ctx.patchState({ users: { - data: users.data.map((user) => ({ + data: response.users.map((user) => ({ ...user, checked: addedContributorsIds.includes(user.id!), disabled: addedContributorsIds.includes(user.id!), })), isLoading: false, error: '', - totalCount: users.totalCount, + totalCount: response.totalCount, + next: response.next, + previous: response.previous, + }, + }); + }), + catchError((error) => handleSectionError(ctx, 'users', error)) + ); + } + + @Action(SearchUsersPageChange) + searchUsersPageChange(ctx: StateContext, action: SearchUsersPageChange) { + const state = ctx.getState(); + + ctx.patchState({ + users: { ...state.users, isLoading: true, error: null }, + }); + + return this.contributorsService.getUsersByLink(action.link).pipe( + tap((response) => { + const addedContributorsIds = state.contributorsList.data.map((contributor) => contributor.userId); + + ctx.patchState({ + users: { + data: response.users.map((user) => ({ + ...user, + checked: addedContributorsIds.includes(user.id!), + disabled: addedContributorsIds.includes(user.id!), + })), + isLoading: false, + error: '', + totalCount: response.totalCount, + next: response.next, + previous: response.previous, }, }); }), @@ -295,7 +329,9 @@ export class ContributorsState { @Action(ClearUsers) clearUsers(ctx: StateContext) { - ctx.patchState({ users: { data: [], isLoading: false, error: null, totalCount: 0 } }); + ctx.patchState({ + users: { data: [], isLoading: false, error: null, totalCount: 0, next: null, previous: null }, + }); } @Action(GetBibliographicContributors) diff --git a/src/testing/mocks/contributors.mock.ts b/src/testing/mocks/contributors.mock.ts index 81210d08f..09aed4450 100644 --- a/src/testing/mocks/contributors.mock.ts +++ b/src/testing/mocks/contributors.mock.ts @@ -1,5 +1,7 @@ import { ContributorPermission } from '@osf/shared/enums/contributors/contributor-permission.enum'; +import { ComponentCheckboxItemModel } from '@osf/shared/models/component-checkbox-item.model'; import { ContributorModel } from '@osf/shared/models/contributors/contributor.model'; +import { ContributorAddModel } from '@osf/shared/models/contributors/contributor-add.model'; export const MOCK_CONTRIBUTOR: ContributorModel = { id: 'contributor-1', @@ -34,3 +36,57 @@ export const MOCK_CONTRIBUTOR_WITHOUT_HISTORY: ContributorModel = { employment: [], deactivated: false, }; + +export const MOCK_CONTRIBUTOR_ADD: ContributorAddModel = { + id: 'user-1', + fullName: 'John Doe', + isBibliographic: true, + permission: 'read', + checked: true, + disabled: false, +}; + +export const MOCK_CONTRIBUTOR_ADD_DISABLED: ContributorAddModel = { + id: 'user-2', + fullName: 'Jane Smith', + isBibliographic: false, + permission: 'write', + checked: true, + disabled: true, +}; + +export const MOCK_CONTRIBUTOR_ADD_UNCHECKED: ContributorAddModel = { + id: 'user-3', + fullName: 'Bob Johnson', + isBibliographic: true, + permission: 'admin', + checked: false, + disabled: false, +}; + +export const MOCK_COMPONENT_CHECKBOX_ITEM: ComponentCheckboxItemModel = { + id: 'component-1', + title: 'Component 1', + isCurrent: false, + disabled: false, + checked: true, + parentId: null, +}; + +export const MOCK_COMPONENT_CHECKBOX_ITEM_CURRENT: ComponentCheckboxItemModel = { + id: 'component-2', + title: 'Component 2', + isCurrent: true, + disabled: false, + checked: false, + parentId: 'parent-1', +}; + +export const MOCK_COMPONENT_CHECKBOX_ITEM_UNCHECKED: ComponentCheckboxItemModel = { + id: 'component-3', + title: 'Component 3', + isCurrent: false, + disabled: false, + checked: false, + parentId: null, +};