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) {
}
@@ -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,
+};