diff --git a/src/app/features/files/pages/files/files.component.ts b/src/app/features/files/pages/files/files.component.ts index 728831377..4bf8617cd 100644 --- a/src/app/features/files/pages/files/files.component.ts +++ b/src/app/features/files/pages/files/files.component.ts @@ -261,7 +261,7 @@ export class FilesComponent { ); constructor() { - this.activeRoute.parent?.parent?.parent?.params.subscribe((params) => { + this.activeRoute.parent?.parent?.parent?.params.pipe(takeUntilDestroyed(this.destroyRef)).subscribe((params) => { if (params['id']) { this.resourceId.set(params['id']); } diff --git a/src/app/features/project/overview/components/files-widget/files-widget.component.html b/src/app/features/project/overview/components/files-widget/files-widget.component.html index 16ffccc9f..45d14eec0 100644 --- a/src/app/features/project/overview/components/files-widget/files-widget.component.html +++ b/src/app/features/project/overview/components/files-widget/files-widget.component.html @@ -18,7 +18,7 @@

{{ 'project.overview.files.filesPreview' | translate }}

@for (option of storageAddons(); track option.folder.id) { - + +

+ {{ 'project.overview.recentActivity.title' | translate }} +

+ + + diff --git a/src/app/features/project/overview/components/project-recent-activity/project-recent-activity.component.spec.ts b/src/app/features/project/overview/components/project-recent-activity/project-recent-activity.component.spec.ts new file mode 100644 index 000000000..596f67dbc --- /dev/null +++ b/src/app/features/project/overview/components/project-recent-activity/project-recent-activity.component.spec.ts @@ -0,0 +1,199 @@ +import { Store } from '@ngxs/store'; + +import { ComponentFixture, TestBed } from '@angular/core/testing'; + +import { CurrentResourceType } from '@osf/shared/enums/resource-type.enum'; +import { ActivityLogsSelectors, ClearActivityLogs } from '@osf/shared/stores/activity-logs'; + +import { ProjectRecentActivityComponent } from './project-recent-activity.component'; + +import { MOCK_ACTIVITY_LOGS_WITH_DISPLAY } from '@testing/mocks/activity-log-with-display.mock'; +import { OSFTestingModule } from '@testing/osf.testing.module'; +import { provideMockStore } from '@testing/providers/store-provider.mock'; + +describe('ProjectRecentActivityComponent', () => { + let component: ProjectRecentActivityComponent; + let fixture: ComponentFixture; + let store: Store; + + beforeEach(async () => { + await TestBed.configureTestingModule({ + imports: [ProjectRecentActivityComponent, OSFTestingModule], + providers: [ + provideMockStore({ + signals: [ + { selector: ActivityLogsSelectors.getActivityLogs, value: [] }, + { selector: ActivityLogsSelectors.getActivityLogsTotalCount, value: 0 }, + { selector: ActivityLogsSelectors.getActivityLogsLoading, value: false }, + ], + }), + ], + }).compileComponents(); + + store = TestBed.inject(Store); + jest.spyOn(store, 'dispatch'); + + fixture = TestBed.createComponent(ProjectRecentActivityComponent); + component = fixture.componentInstance; + }); + + it('should initialize with default values', () => { + expect(component.pageSize()).toBe(5); + expect(component.currentPage()).toBe(1); + expect(component.firstIndex()).toBe(0); + }); + + it('should dispatch GetActivityLogs when projectId is provided', () => { + fixture.componentRef.setInput('projectId', 'project123'); + fixture.detectChanges(); + + expect(store.dispatch).toHaveBeenCalledWith( + expect.objectContaining({ + resourceId: 'project123', + resourceType: CurrentResourceType.Projects, + page: 1, + pageSize: 5, + }) + ); + }); + + it('should not dispatch when projectId is not provided', () => { + fixture.detectChanges(); + + expect(store.dispatch).not.toHaveBeenCalled(); + }); + + it('should dispatch GetActivityLogs when currentPage changes', () => { + fixture.componentRef.setInput('projectId', 'project123'); + fixture.detectChanges(); + + (store.dispatch as jest.Mock).mockClear(); + + component.currentPage.set(2); + fixture.detectChanges(); + + expect(store.dispatch).toHaveBeenCalledWith( + expect.objectContaining({ + resourceId: 'project123', + resourceType: CurrentResourceType.Projects, + page: 2, + pageSize: 5, + }) + ); + }); + + it('should update currentPage and dispatch on page change', () => { + fixture.componentRef.setInput('projectId', 'project123'); + fixture.detectChanges(); + + (store.dispatch as jest.Mock).mockClear(); + + component.onPageChange({ page: 1 } as any); + fixture.detectChanges(); + + expect(component.currentPage()).toBe(2); + expect(store.dispatch).toHaveBeenCalledWith( + expect.objectContaining({ + page: 2, + }) + ); + }); + + it('should not update currentPage when page is undefined', () => { + fixture.componentRef.setInput('projectId', 'project123'); + fixture.detectChanges(); + + const initialPage = component.currentPage(); + component.onPageChange({} as any); + + expect(component.currentPage()).toBe(initialPage); + }); + + it('should compute firstIndex correctly', () => { + component.currentPage.set(1); + expect(component.firstIndex()).toBe(0); + + component.currentPage.set(2); + expect(component.firstIndex()).toBe(5); + + component.currentPage.set(3); + expect(component.firstIndex()).toBe(10); + }); + + it('should clear store on destroy', () => { + fixture.componentRef.setInput('projectId', 'project123'); + fixture.detectChanges(); + + (store.dispatch as jest.Mock).mockClear(); + + fixture.destroy(); + + expect(store.dispatch).toHaveBeenCalledWith(expect.any(ClearActivityLogs)); + }); + + it('should return activity logs from selector', () => { + TestBed.resetTestingModule(); + TestBed.configureTestingModule({ + imports: [ProjectRecentActivityComponent, OSFTestingModule], + providers: [ + provideMockStore({ + signals: [ + { selector: ActivityLogsSelectors.getActivityLogs, value: MOCK_ACTIVITY_LOGS_WITH_DISPLAY }, + { selector: ActivityLogsSelectors.getActivityLogsTotalCount, value: 2 }, + { selector: ActivityLogsSelectors.getActivityLogsLoading, value: false }, + ], + }), + ], + }).compileComponents(); + + fixture = TestBed.createComponent(ProjectRecentActivityComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + + expect(component.activityLogs()).toEqual(MOCK_ACTIVITY_LOGS_WITH_DISPLAY); + }); + + it('should return totalCount from selector', () => { + TestBed.resetTestingModule(); + TestBed.configureTestingModule({ + imports: [ProjectRecentActivityComponent, OSFTestingModule], + providers: [ + provideMockStore({ + signals: [ + { selector: ActivityLogsSelectors.getActivityLogs, value: [] }, + { selector: ActivityLogsSelectors.getActivityLogsTotalCount, value: 10 }, + { selector: ActivityLogsSelectors.getActivityLogsLoading, value: false }, + ], + }), + ], + }).compileComponents(); + + fixture = TestBed.createComponent(ProjectRecentActivityComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + + expect(component.totalCount()).toBe(10); + }); + + it('should return isLoading from selector', () => { + TestBed.resetTestingModule(); + TestBed.configureTestingModule({ + imports: [ProjectRecentActivityComponent, OSFTestingModule], + providers: [ + provideMockStore({ + signals: [ + { selector: ActivityLogsSelectors.getActivityLogs, value: [] }, + { selector: ActivityLogsSelectors.getActivityLogsTotalCount, value: 0 }, + { selector: ActivityLogsSelectors.getActivityLogsLoading, value: true }, + ], + }), + ], + }).compileComponents(); + + fixture = TestBed.createComponent(ProjectRecentActivityComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + + expect(component.isLoading()).toBe(true); + }); +}); diff --git a/src/app/features/project/overview/components/project-recent-activity/project-recent-activity.component.ts b/src/app/features/project/overview/components/project-recent-activity/project-recent-activity.component.ts new file mode 100644 index 000000000..5b4305004 --- /dev/null +++ b/src/app/features/project/overview/components/project-recent-activity/project-recent-activity.component.ts @@ -0,0 +1,54 @@ +import { createDispatchMap, select } from '@ngxs/store'; + +import { TranslatePipe } from '@ngx-translate/core'; + +import { PaginatorState } from 'primeng/paginator'; + +import { ChangeDetectionStrategy, Component, computed, effect, input, OnDestroy, signal } from '@angular/core'; + +import { RecentActivityListComponent } from '@osf/shared/components/recent-activity/recent-activity-list.component'; +import { CurrentResourceType } from '@osf/shared/enums/resource-type.enum'; +import { ActivityLogsSelectors, ClearActivityLogs, GetActivityLogs } from '@osf/shared/stores/activity-logs'; + +@Component({ + selector: 'osf-project-recent-activity', + imports: [RecentActivityListComponent, TranslatePipe], + templateUrl: './project-recent-activity.component.html', + changeDetection: ChangeDetectionStrategy.OnPush, +}) +export class ProjectRecentActivityComponent implements OnDestroy { + projectId = input(); + + pageSize = signal(5); + currentPage = signal(1); + + activityLogs = select(ActivityLogsSelectors.getActivityLogs); + totalCount = select(ActivityLogsSelectors.getActivityLogsTotalCount); + isLoading = select(ActivityLogsSelectors.getActivityLogsLoading); + + actions = createDispatchMap({ getActivityLogs: GetActivityLogs, clearActivityLogsStore: ClearActivityLogs }); + + firstIndex = computed(() => (this.currentPage() - 1) * this.pageSize()); + + constructor() { + effect(() => { + const projectId = this.projectId(); + const page = this.currentPage(); + + if (projectId) { + this.actions.getActivityLogs(projectId, CurrentResourceType.Projects, page, this.pageSize()); + } + }); + } + + ngOnDestroy(): void { + this.actions.clearActivityLogsStore(); + } + + onPageChange(event: PaginatorState) { + if (event.page !== undefined) { + const pageNumber = event.page + 1; + this.currentPage.set(pageNumber); + } + } +} diff --git a/src/app/features/project/overview/components/recent-activity/recent-activity.component.html b/src/app/features/project/overview/components/recent-activity/recent-activity.component.html deleted file mode 100644 index a8994a873..000000000 --- a/src/app/features/project/overview/components/recent-activity/recent-activity.component.html +++ /dev/null @@ -1,36 +0,0 @@ -
-

{{ 'project.overview.recentActivity.title' | translate }}

- - @if (!isLoading()) { - @if (formattedActivityLogs().length) { - @for (activityLog of formattedActivityLogs(); track activityLog.id) { -
-
- -
- } - } @else { -
- {{ 'project.overview.recentActivity.noActivity' | translate }} -
- } - - @if (totalCount() > pageSize()) { - - } - } @else { -
- - - - - -
- } -
diff --git a/src/app/features/project/overview/components/recent-activity/recent-activity.component.scss b/src/app/features/project/overview/components/recent-activity/recent-activity.component.scss deleted file mode 100644 index 3453a1a51..000000000 --- a/src/app/features/project/overview/components/recent-activity/recent-activity.component.scss +++ /dev/null @@ -1,16 +0,0 @@ -.activities { - border: 1px solid var(--grey-2); - border-radius: 0.75rem; - - &-activity { - border-bottom: 1px solid var(--grey-2); - - .activity-date { - min-width: 27%; - } - } - - &-description { - line-height: 1.5rem; - } -} diff --git a/src/app/features/project/overview/components/recent-activity/recent-activity.component.spec.ts b/src/app/features/project/overview/components/recent-activity/recent-activity.component.spec.ts deleted file mode 100644 index 1a57528e0..000000000 --- a/src/app/features/project/overview/components/recent-activity/recent-activity.component.spec.ts +++ /dev/null @@ -1,99 +0,0 @@ -import { provideStore, Store } from '@ngxs/store'; - -import { TranslateService } from '@ngx-translate/core'; -import { MockComponent } from 'ng-mocks'; - -import { of } from 'rxjs'; - -import { provideHttpClient, withInterceptorsFromDi } from '@angular/common/http'; -import { provideHttpClientTesting } from '@angular/common/http/testing'; -import { ComponentFixture, TestBed } from '@angular/core/testing'; -import { ActivatedRoute } from '@angular/router'; - -import { CustomPaginatorComponent } from '@osf/shared/components/custom-paginator/custom-paginator.component'; -import { ActivityLogDisplayService } from '@osf/shared/services/activity-logs/activity-log-display.service'; -import { GetActivityLogs } from '@shared/stores/activity-logs'; -import { ActivityLogsState } from '@shared/stores/activity-logs/activity-logs.state'; - -import { RecentActivityComponent } from './recent-activity.component'; - -describe.skip('RecentActivityComponent', () => { - let fixture: ComponentFixture; - let store: Store; - - beforeEach(async () => { - await TestBed.configureTestingModule({ - imports: [RecentActivityComponent, MockComponent(CustomPaginatorComponent)], - providers: [ - provideStore([ActivityLogsState]), - provideHttpClient(withInterceptorsFromDi()), - provideHttpClientTesting(), - { - provide: TranslateService, - useValue: { - instant: (k: string) => k, - get: () => of(''), - stream: () => of(''), - onLangChange: of({}), - onDefaultLangChange: of({}), - onTranslationChange: of({}), - }, - }, - { provide: ActivatedRoute, useValue: { snapshot: { params: { id: 'proj123' } }, parent: null } }, - { provide: ActivityLogDisplayService, useValue: { getActivityDisplay: jest.fn().mockReturnValue('FMT') } }, - ], - }).compileComponents(); - - store = TestBed.inject(Store); - store.reset({ - activityLogs: { - activityLogs: { data: [], isLoading: false, error: null, totalCount: 0 }, - }, - } as any); - - fixture = TestBed.createComponent(RecentActivityComponent); - fixture.componentRef.setInput('pageSize', 10); - fixture.detectChanges(); - }); - - it('should create', () => { - expect(fixture.componentInstance).toBeTruthy(); - }); - - it('formats activity logs using ActivityLogDisplayService', () => { - store.reset({ - activityLogs: { - activityLogs: { - data: [{ id: 'log1', date: '2024-01-01T00:00:00Z' }], - isLoading: false, - error: null, - totalCount: 1, - }, - }, - } as any); - - fixture.detectChanges(); - - const formatted = fixture.componentInstance.formattedActivityLogs(); - expect(formatted.length).toBe(1); - expect(formatted[0].formattedActivity).toBe('FMT'); - }); - - it('dispatches GetActivityLogs with numeric page and pageSize on page change', () => { - const dispatchSpy = jest.spyOn(store, 'dispatch'); - fixture.componentInstance.onPageChange({ page: 2 } as any); - - expect(dispatchSpy).toHaveBeenCalled(); - const action = dispatchSpy.mock.calls.at(-1)?.[0] as GetActivityLogs; - - expect(action).toBeInstanceOf(GetActivityLogs); - expect(action.projectId).toBe('proj123'); - expect(action.page).toBe(3); - expect(action.pageSize).toBe(10); - }); - - it('computes firstIndex correctly', () => { - fixture.componentInstance['currentPage'].set(3); - expect(fixture.componentInstance['firstIndex']()).toBe(20); - }); -}); diff --git a/src/app/features/project/overview/components/recent-activity/recent-activity.component.ts b/src/app/features/project/overview/components/recent-activity/recent-activity.component.ts deleted file mode 100644 index eb2b434f4..000000000 --- a/src/app/features/project/overview/components/recent-activity/recent-activity.component.ts +++ /dev/null @@ -1,57 +0,0 @@ -import { createDispatchMap, select } from '@ngxs/store'; - -import { TranslatePipe } from '@ngx-translate/core'; - -import { PaginatorState } from 'primeng/paginator'; -import { Skeleton } from 'primeng/skeleton'; - -import { DatePipe } from '@angular/common'; -import { ChangeDetectionStrategy, Component, computed, inject, input, signal } from '@angular/core'; -import { ActivatedRoute } from '@angular/router'; - -import { CustomPaginatorComponent } from '@osf/shared/components/custom-paginator/custom-paginator.component'; -import { ActivityLogDisplayService } from '@osf/shared/services/activity-logs/activity-log-display.service'; -import { ActivityLogsSelectors, GetActivityLogs } from '@osf/shared/stores/activity-logs'; - -@Component({ - selector: 'osf-recent-activity-list', - imports: [TranslatePipe, Skeleton, DatePipe, CustomPaginatorComponent], - templateUrl: './recent-activity.component.html', - styleUrl: './recent-activity.component.scss', - changeDetection: ChangeDetectionStrategy.OnPush, -}) -export class RecentActivityComponent { - private readonly activityLogDisplayService = inject(ActivityLogDisplayService); - private readonly route = inject(ActivatedRoute); - - readonly pageSize = input.required(); - currentPage = signal(1); - - activityLogs = select(ActivityLogsSelectors.getActivityLogs); - totalCount = select(ActivityLogsSelectors.getActivityLogsTotalCount); - isLoading = select(ActivityLogsSelectors.getActivityLogsLoading); - - firstIndex = computed(() => (this.currentPage() - 1) * this.pageSize()); - - actions = createDispatchMap({ getActivityLogs: GetActivityLogs }); - - formattedActivityLogs = computed(() => { - const logs = this.activityLogs(); - return logs.map((log) => ({ - ...log, - formattedActivity: this.activityLogDisplayService.getActivityDisplay(log), - })); - }); - - onPageChange(event: PaginatorState) { - if (event.page !== undefined) { - const pageNumber = event.page + 1; - this.currentPage.set(pageNumber); - - const projectId = this.route.snapshot.params['id'] || this.route.parent?.snapshot.params['id']; - if (projectId) { - this.actions.getActivityLogs(projectId, pageNumber, this.pageSize()); - } - } - } -} diff --git a/src/app/features/project/overview/project-overview.component.html b/src/app/features/project/overview/project-overview.component.html index 7837acf4d..28942c854 100644 --- a/src/app/features/project/overview/project-overview.component.html +++ b/src/app/features/project/overview/project-overview.component.html @@ -79,7 +79,7 @@ } } - +
diff --git a/src/app/features/project/overview/project-overview.component.spec.ts b/src/app/features/project/overview/project-overview.component.spec.ts index eff3c50f8..f6bb02721 100644 --- a/src/app/features/project/overview/project-overview.component.spec.ts +++ b/src/app/features/project/overview/project-overview.component.spec.ts @@ -18,7 +18,6 @@ import { Mode } from '@osf/shared/enums/mode.enum'; import { AnalyticsService } from '@osf/shared/services/analytics.service'; import { CustomDialogService } from '@osf/shared/services/custom-dialog.service'; import { ToastService } from '@osf/shared/services/toast.service'; -import { GetActivityLogs } from '@osf/shared/stores/activity-logs'; import { AddonsSelectors, ClearConfiguredAddons } from '@osf/shared/stores/addons'; import { GetBookmarksCollectionId } from '@osf/shared/stores/bookmarks'; import { ClearCollections, CollectionsSelectors } from '@osf/shared/stores/collections'; @@ -34,7 +33,7 @@ import { OverviewParentProjectComponent } from './components/overview-parent-pro import { OverviewWikiComponent } from './components/overview-wiki/overview-wiki.component'; import { ProjectOverviewMetadataComponent } from './components/project-overview-metadata/project-overview-metadata.component'; import { ProjectOverviewToolbarComponent } from './components/project-overview-toolbar/project-overview-toolbar.component'; -import { RecentActivityComponent } from './components/recent-activity/recent-activity.component'; +import { ProjectRecentActivityComponent } from './components/project-recent-activity/project-recent-activity.component'; import { ProjectOverviewModel } from './models'; import { ProjectOverviewComponent } from './project-overview.component'; import { ClearProjectOverview, GetComponents, GetProjectById, ProjectOverviewSelectors } from './store'; @@ -81,7 +80,7 @@ describe('ProjectOverviewComponent', () => { OverviewWikiComponent, OverviewComponentsComponent, LinkedResourcesComponent, - RecentActivityComponent, + ProjectRecentActivityComponent, ProjectOverviewToolbarComponent, ProjectOverviewMetadataComponent, FilesWidgetComponent, @@ -136,7 +135,6 @@ describe('ProjectOverviewComponent', () => { expect(store.dispatch).toHaveBeenCalledWith(expect.any(GetBookmarksCollectionId)); expect(store.dispatch).toHaveBeenCalledWith(expect.any(GetComponents)); expect(store.dispatch).toHaveBeenCalledWith(expect.any(GetLinkedResources)); - expect(store.dispatch).toHaveBeenCalledWith(expect.any(GetActivityLogs)); }); it('should dispatch actions when projectId exists in parent route params', () => { @@ -150,20 +148,6 @@ describe('ProjectOverviewComponent', () => { component.ngOnInit(); expect(store.dispatch).toHaveBeenCalledWith(expect.any(GetProjectById)); - expect(store.dispatch).toHaveBeenCalledWith(expect.any(GetActivityLogs)); - }); - - it('should dispatch GetActivityLogs with correct parameters', () => { - component.ngOnInit(); - - const activityLogsCall = (store.dispatch as jest.Mock).mock.calls.find( - (call) => call[0] instanceof GetActivityLogs - ); - expect(activityLogsCall).toBeDefined(); - const action = activityLogsCall[0] as GetActivityLogs; - expect(action.projectId).toBe('project-123'); - expect(action.page).toBe(1); - expect(action.pageSize).toBe(5); }); it('should return true for isModerationMode when query param mode is moderation', () => { diff --git a/src/app/features/project/overview/project-overview.component.ts b/src/app/features/project/overview/project-overview.component.ts index b4ee62c9a..07f6a046a 100644 --- a/src/app/features/project/overview/project-overview.component.ts +++ b/src/app/features/project/overview/project-overview.component.ts @@ -5,6 +5,8 @@ import { TranslatePipe } from '@ngx-translate/core'; import { Button } from 'primeng/button'; import { Message } from 'primeng/message'; +import { map, of } from 'rxjs'; + import { ChangeDetectionStrategy, Component, @@ -15,7 +17,7 @@ import { inject, OnInit, } from '@angular/core'; -import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; +import { takeUntilDestroyed, toSignal } from '@angular/core/rxjs-interop'; import { ActivatedRoute, Router, RouterLink } from '@angular/router'; import { SubmissionReviewStatus } from '@osf/features/moderation/enums'; @@ -33,7 +35,6 @@ import { ResourceType } from '@osf/shared/enums/resource-type.enum'; import { hasViewOnlyParam } from '@osf/shared/helpers/view-only.helper'; import { CustomDialogService } from '@osf/shared/services/custom-dialog.service'; import { ToastService } from '@osf/shared/services/toast.service'; -import { GetActivityLogs } from '@osf/shared/stores/activity-logs'; import { AddonsSelectors, ClearConfiguredAddons, @@ -56,7 +57,7 @@ import { OverviewParentProjectComponent } from './components/overview-parent-pro import { OverviewWikiComponent } from './components/overview-wiki/overview-wiki.component'; import { ProjectOverviewMetadataComponent } from './components/project-overview-metadata/project-overview-metadata.component'; import { ProjectOverviewToolbarComponent } from './components/project-overview-toolbar/project-overview-toolbar.component'; -import { RecentActivityComponent } from './components/recent-activity/recent-activity.component'; +import { ProjectRecentActivityComponent } from './components/project-recent-activity/project-recent-activity.component'; import { SUBMISSION_REVIEW_STATUS_OPTIONS } from './constants'; import { ClearProjectOverview, @@ -81,7 +82,7 @@ import { OverviewWikiComponent, OverviewComponentsComponent, LinkedResourcesComponent, - RecentActivityComponent, + ProjectRecentActivityComponent, ProjectOverviewToolbarComponent, ProjectOverviewMetadataComponent, FilesWidgetComponent, @@ -126,7 +127,6 @@ export class ProjectOverviewComponent implements OnInit { getHomeWiki: GetHomeWiki, getComponents: GetComponents, getLinkedProjects: GetLinkedResources, - getActivityLogs: GetActivityLogs, getCollectionProvider: GetCollectionProvider, getCurrentReviewAction: GetSubmissionsReviewActions, @@ -143,8 +143,6 @@ export class ProjectOverviewComponent implements OnInit { getConfiguredCitationAddons: GetConfiguredCitationAddons, }); - readonly activityPageSize = 5; - readonly activityDefaultPage = 1; readonly SubmissionReviewStatusOptions = SUBMISSION_REVIEW_STATUS_OPTIONS; readonly isCollectionsRoute = computed(() => this.router.url.includes('/collections')); @@ -171,6 +169,10 @@ export class ProjectOverviewComponent implements OnInit { label: this.currentProject()?.title ?? '', })); + readonly projectId = toSignal( + this.route.parent?.params.pipe(map((params) => params['id'])) ?? of(undefined) + ); + constructor() { this.setupCollectionsEffects(); this.setupProjectEffects(); @@ -179,14 +181,13 @@ export class ProjectOverviewComponent implements OnInit { } ngOnInit(): void { - const projectId = this.route.snapshot.params['id'] || this.route.parent?.snapshot.params['id']; + const projectId = this.projectId(); if (projectId) { this.actions.getProject(projectId); this.actions.getBookmarksId(); this.actions.getComponents(projectId); this.actions.getLinkedProjects(projectId); - this.actions.getActivityLogs(projectId, this.activityDefaultPage, this.activityPageSize); } } diff --git a/src/app/features/registry/pages/registration-recent-activity/registration-recent-activity.component.html b/src/app/features/registry/pages/registration-recent-activity/registration-recent-activity.component.html index f46a02896..8934d6c84 100644 --- a/src/app/features/registry/pages/registration-recent-activity/registration-recent-activity.component.html +++ b/src/app/features/registry/pages/registration-recent-activity/registration-recent-activity.component.html @@ -1,50 +1,14 @@ -
-

+
+

{{ 'project.overview.recentActivity.title' | translate }}

- @if (!isLoading()) { -
- @for (activityLog of formattedActivityLogs(); track activityLog.id) { -
-
- - -
- } @empty { -
- {{ 'project.overview.recentActivity.noActivity' | translate }} -
- } -
- - @if (totalCount() > pageSize) { - - } - } @else { -
- - - - - -
- } +
diff --git a/src/app/features/registry/pages/registration-recent-activity/registration-recent-activity.component.spec.ts b/src/app/features/registry/pages/registration-recent-activity/registration-recent-activity.component.spec.ts index 18a25b67b..65ba6cf2a 100644 --- a/src/app/features/registry/pages/registration-recent-activity/registration-recent-activity.component.spec.ts +++ b/src/app/features/registry/pages/registration-recent-activity/registration-recent-activity.component.spec.ts @@ -1,47 +1,38 @@ -import { provideStore, Store } from '@ngxs/store'; +import { Store } from '@ngxs/store'; -import { TranslateService } from '@ngx-translate/core'; - -import { of } from 'rxjs'; - -import { provideHttpClient, withInterceptorsFromDi } from '@angular/common/http'; -import { provideHttpClientTesting } from '@angular/common/http/testing'; import { ComponentFixture, TestBed } from '@angular/core/testing'; import { ActivatedRoute } from '@angular/router'; -import { ActivityLogDisplayService } from '@osf/shared/services/activity-logs/activity-log-display.service'; -import { ClearActivityLogsStore, GetRegistrationActivityLogs } from '@shared/stores/activity-logs'; -import { ActivityLogsState } from '@shared/stores/activity-logs/activity-logs.state'; +import { CurrentResourceType } from '@osf/shared/enums/resource-type.enum'; +import { ActivityLogsSelectors, ClearActivityLogs } from '@osf/shared/stores/activity-logs'; import { RegistrationRecentActivityComponent } from './registration-recent-activity.component'; +import { MOCK_ACTIVITY_LOGS_WITH_DISPLAY } from '@testing/mocks/activity-log-with-display.mock'; +import { OSFTestingModule } from '@testing/osf.testing.module'; +import { ActivatedRouteMockBuilder } from '@testing/providers/route-provider.mock'; +import { provideMockStore } from '@testing/providers/store-provider.mock'; + describe('RegistrationRecentActivityComponent', () => { + let component: RegistrationRecentActivityComponent; let fixture: ComponentFixture; let store: Store; beforeEach(async () => { await TestBed.configureTestingModule({ - imports: [RegistrationRecentActivityComponent], + imports: [RegistrationRecentActivityComponent, OSFTestingModule], providers: [ - provideStore([ActivityLogsState]), - provideHttpClient(withInterceptorsFromDi()), - provideHttpClientTesting(), - { - provide: TranslateService, - useValue: { - instant: (k: string) => k, - get: () => of(''), - stream: () => of(''), - onLangChange: of({}), - onDefaultLangChange: of({}), - onTranslationChange: of({}), - }, - }, + provideMockStore({ + signals: [ + { selector: ActivityLogsSelectors.getActivityLogs, value: [] }, + { selector: ActivityLogsSelectors.getActivityLogsTotalCount, value: 0 }, + { selector: ActivityLogsSelectors.getActivityLogsLoading, value: false }, + ], + }), { - provide: ActivityLogDisplayService, - useValue: { getActivityDisplay: jest.fn(() => 'formatted') }, + provide: ActivatedRoute, + useValue: ActivatedRouteMockBuilder.create().withParams({ id: 'reg123' }).build(), }, - { provide: ActivatedRoute, useValue: { snapshot: { params: { id: 'reg123' } }, parent: null } }, ], }).compileComponents(); @@ -49,145 +40,196 @@ describe('RegistrationRecentActivityComponent', () => { jest.spyOn(store, 'dispatch'); fixture = TestBed.createComponent(RegistrationRecentActivityComponent); - fixture.detectChanges(); + component = fixture.componentInstance; }); - it('dispatches initial registration logs fetch', () => { - const dispatchSpy = store.dispatch as jest.Mock; - expect(dispatchSpy).toHaveBeenCalledWith(expect.any(GetRegistrationActivityLogs)); - const action = dispatchSpy.mock.calls.at(-1)?.[0] as GetRegistrationActivityLogs; - expect(action.registrationId).toBe('reg123'); - expect(action.page).toBe(1); + it('should initialize with default values', () => { + expect(component.pageSize).toBe(10); + expect(component.currentPage()).toBe(1); + expect(component.firstIndex()).toBe(0); }); - it('renders empty state when no logs and not loading', () => { - store.reset({ - activityLogs: { - activityLogs: { data: [], isLoading: false, error: null, totalCount: 0 }, - }, - } as any); + it('should dispatch GetActivityLogs when registrationId is available', () => { fixture.detectChanges(); - const empty = fixture.nativeElement.querySelector('[data-test="recent-activity-empty"]'); - expect(empty).toBeTruthy(); + expect(store.dispatch).toHaveBeenCalledWith( + expect.objectContaining({ + resourceId: 'reg123', + resourceType: CurrentResourceType.Registrations, + page: 1, + pageSize: 10, + }) + ); }); - it('renders item & paginator when logs exist and totalCount > pageSize', () => { - store.reset({ - activityLogs: { - activityLogs: { - data: [ - { - id: 'log1', - date: '2024-01-01T12:34:00Z', - formattedActivity: 'formatted', - }, + it('should not dispatch when registrationId is not available', () => { + TestBed.resetTestingModule(); + TestBed.configureTestingModule({ + imports: [RegistrationRecentActivityComponent, OSFTestingModule], + providers: [ + provideMockStore({ + signals: [ + { selector: ActivityLogsSelectors.getActivityLogs, value: [] }, + { selector: ActivityLogsSelectors.getActivityLogsTotalCount, value: 0 }, + { selector: ActivityLogsSelectors.getActivityLogsLoading, value: false }, ], - isLoading: false, - error: null, - totalCount: 25, + }), + { + provide: ActivatedRoute, + useValue: { parent: null } as Partial, }, - }, - } as any); - fixture.detectChanges(); + ], + }).compileComponents(); + + store = TestBed.inject(Store); + jest.spyOn(store, 'dispatch'); - const item = fixture.nativeElement.querySelector('[data-test="recent-activity-item"]'); - const content = fixture.nativeElement.querySelector('[data-test="recent-activity-item-content"]'); - const paginator = fixture.nativeElement.querySelector('[data-test="recent-activity-paginator"]'); - const dateText = fixture.nativeElement.querySelector('[data-test="recent-activity-item-date"]')?.textContent ?? ''; + fixture = TestBed.createComponent(RegistrationRecentActivityComponent); + component = fixture.componentInstance; + fixture.detectChanges(); - expect(item).toBeTruthy(); - expect(content?.innerHTML).toContain('formatted'); - expect(paginator).toBeTruthy(); - expect(dateText).toMatch(/\w{3} \d{1,2}, \d{4} \d{1,2}:\d{2} [AP]M/); + expect(store.dispatch).not.toHaveBeenCalled(); }); - it('does not render paginator when totalCount <= pageSize', () => { - store.reset({ - activityLogs: { - activityLogs: { - data: [{ id: 'log1', date: '2024-01-01T12:34:00Z', formattedActivity: 'formatted' }], - isLoading: false, - error: null, - totalCount: 10, - }, - }, - } as any); + it('should dispatch GetActivityLogs when currentPage changes', () => { + fixture.detectChanges(); + + (store.dispatch as jest.Mock).mockClear(); + + component.currentPage.set(2); fixture.detectChanges(); - const paginator = fixture.nativeElement.querySelector('[data-test="recent-activity-paginator"]'); - expect(paginator).toBeFalsy(); + expect(store.dispatch).toHaveBeenCalledWith( + expect.objectContaining({ + resourceId: 'reg123', + resourceType: CurrentResourceType.Registrations, + page: 2, + pageSize: 10, + }) + ); }); - it('dispatches on page change', () => { - const dispatchSpy = store.dispatch as jest.Mock; - dispatchSpy.mockClear(); + it('should update currentPage and dispatch on page change', () => { + fixture.detectChanges(); + + (store.dispatch as jest.Mock).mockClear(); - fixture.componentInstance.onPageChange({ page: 2 } as any); - expect(dispatchSpy).toHaveBeenCalledWith(expect.any(GetRegistrationActivityLogs)); + component.onPageChange({ page: 1 } as any); + fixture.detectChanges(); - const action = dispatchSpy.mock.calls.at(-1)?.[0] as GetRegistrationActivityLogs; - expect(action.page).toBe(3); + expect(component.currentPage()).toBe(2); + expect(store.dispatch).toHaveBeenCalledWith( + expect.objectContaining({ + page: 2, + }) + ); }); - it('does not dispatch when page change event has undefined page', () => { - const dispatchSpy = store.dispatch as jest.Mock; - dispatchSpy.mockClear(); + it('should not update currentPage when page is undefined', () => { + fixture.detectChanges(); - fixture.componentInstance.onPageChange({} as any); - expect(dispatchSpy).not.toHaveBeenCalled(); + const initialPage = component.currentPage(); + component.onPageChange({} as any); + + expect(component.currentPage()).toBe(initialPage); }); - it('computes firstIndex correctly after page change', () => { - fixture.componentInstance.onPageChange({ page: 1 } as any); - const firstIndex = (fixture.componentInstance as any)['firstIndex'](); - expect(firstIndex).toBe(10); + it('should compute firstIndex correctly', () => { + component.currentPage.set(1); + expect(component.firstIndex()).toBe(0); + + component.currentPage.set(2); + expect(component.firstIndex()).toBe(10); + + component.currentPage.set(3); + expect(component.firstIndex()).toBe(20); }); - it('clears store on destroy', () => { - const dispatchSpy = store.dispatch as jest.Mock; - dispatchSpy.mockClear(); + it('should clear store on destroy', () => { + fixture.detectChanges(); + + (store.dispatch as jest.Mock).mockClear(); fixture.destroy(); - expect(dispatchSpy).toHaveBeenCalledWith(expect.any(ClearActivityLogsStore)); + + expect(store.dispatch).toHaveBeenCalledWith(expect.any(ClearActivityLogs)); }); - it('shows skeleton while loading', () => { - store.reset({ - activityLogs: { - activityLogs: { data: [], isLoading: true, error: null, totalCount: 0 }, - }, - } as any); + it('should return activity logs from selector', () => { + TestBed.resetTestingModule(); + TestBed.configureTestingModule({ + imports: [RegistrationRecentActivityComponent, OSFTestingModule], + providers: [ + provideMockStore({ + signals: [ + { selector: ActivityLogsSelectors.getActivityLogs, value: MOCK_ACTIVITY_LOGS_WITH_DISPLAY }, + { selector: ActivityLogsSelectors.getActivityLogsTotalCount, value: 2 }, + { selector: ActivityLogsSelectors.getActivityLogsLoading, value: false }, + ], + }), + { + provide: ActivatedRoute, + useValue: ActivatedRouteMockBuilder.create().withParams({ id: 'reg123' }).build(), + }, + ], + }).compileComponents(); + fixture = TestBed.createComponent(RegistrationRecentActivityComponent); + component = fixture.componentInstance; fixture.detectChanges(); - expect(fixture.nativeElement.querySelector('[data-test="recent-activity-skeleton"]')).toBeTruthy(); - expect(fixture.nativeElement.querySelector('[data-test="recent-activity-list"]')).toBeFalsy(); - expect(fixture.nativeElement.querySelector('[data-test="recent-activity-paginator"]')).toBeFalsy(); + expect(component.activityLogs()).toEqual(MOCK_ACTIVITY_LOGS_WITH_DISPLAY); }); - it('renders expected ARIA roles/labels', () => { - store.reset({ - activityLogs: { - activityLogs: { - data: [{ id: 'log1', date: '2024-01-01T12:34:00Z', formattedActivity: 'formatted' }], - isLoading: false, - error: null, - totalCount: 1, + it('should return totalCount from selector', () => { + TestBed.resetTestingModule(); + TestBed.configureTestingModule({ + imports: [RegistrationRecentActivityComponent, OSFTestingModule], + providers: [ + provideMockStore({ + signals: [ + { selector: ActivityLogsSelectors.getActivityLogs, value: [] }, + { selector: ActivityLogsSelectors.getActivityLogsTotalCount, value: 15 }, + { selector: ActivityLogsSelectors.getActivityLogsLoading, value: false }, + ], + }), + { + provide: ActivatedRoute, + useValue: ActivatedRouteMockBuilder.create().withParams({ id: 'reg123' }).build(), }, - }, - } as any); + ], + }).compileComponents(); + + fixture = TestBed.createComponent(RegistrationRecentActivityComponent); + component = fixture.componentInstance; fixture.detectChanges(); - const region = fixture.nativeElement.querySelector('[role="region"]'); - const heading = fixture.nativeElement.querySelector('#recent-activity-title'); - const list = fixture.nativeElement.querySelector('[role="list"]'); - const listitem = fixture.nativeElement.querySelector('[role="listitem"]'); + expect(component.totalCount()).toBe(15); + }); + + it('should return isLoading from selector', () => { + TestBed.resetTestingModule(); + TestBed.configureTestingModule({ + imports: [RegistrationRecentActivityComponent, OSFTestingModule], + providers: [ + provideMockStore({ + signals: [ + { selector: ActivityLogsSelectors.getActivityLogs, value: [] }, + { selector: ActivityLogsSelectors.getActivityLogsTotalCount, value: 0 }, + { selector: ActivityLogsSelectors.getActivityLogsLoading, value: true }, + ], + }), + { + provide: ActivatedRoute, + useValue: ActivatedRouteMockBuilder.create().withParams({ id: 'reg123' }).build(), + }, + ], + }).compileComponents(); + + fixture = TestBed.createComponent(RegistrationRecentActivityComponent); + component = fixture.componentInstance; + fixture.detectChanges(); - expect(region).toBeTruthy(); - expect(region.getAttribute('aria-labelledby')).toBe('recent-activity-title'); - expect(heading).toBeTruthy(); - expect(list).toBeTruthy(); - expect(listitem).toBeTruthy(); + expect(component.isLoading()).toBe(true); }); }); diff --git a/src/app/features/registry/pages/registration-recent-activity/registration-recent-activity.component.ts b/src/app/features/registry/pages/registration-recent-activity/registration-recent-activity.component.ts index 2ca855c53..eac381e8d 100644 --- a/src/app/features/registry/pages/registration-recent-activity/registration-recent-activity.component.ts +++ b/src/app/features/registry/pages/registration-recent-activity/registration-recent-activity.component.ts @@ -3,62 +3,62 @@ import { createDispatchMap, select } from '@ngxs/store'; import { TranslatePipe } from '@ngx-translate/core'; import { PaginatorState } from 'primeng/paginator'; -import { Skeleton } from 'primeng/skeleton'; -import { DatePipe } from '@angular/common'; -import { ChangeDetectionStrategy, Component, computed, inject, OnDestroy, signal } from '@angular/core'; +import { map, of } from 'rxjs'; + +import { ChangeDetectionStrategy, Component, computed, effect, inject, OnDestroy, signal } from '@angular/core'; +import { toSignal } from '@angular/core/rxjs-interop'; import { ActivatedRoute } from '@angular/router'; -import { ENVIRONMENT } from '@core/provider/environment.provider'; -import { CustomPaginatorComponent } from '@osf/shared/components/custom-paginator/custom-paginator.component'; -import { ACTIVITY_LOGS_DEFAULT_PAGE_SIZE } from '@shared/constants/activity-logs'; -import { - ActivityLogsSelectors, - ClearActivityLogsStore, - GetRegistrationActivityLogs, -} from '@shared/stores/activity-logs'; +import { RecentActivityListComponent } from '@osf/shared/components/recent-activity/recent-activity-list.component'; +import { ACTIVITY_LOGS_DEFAULT_PAGE_SIZE } from '@osf/shared/constants/activity-logs'; +import { CurrentResourceType } from '@osf/shared/enums/resource-type.enum'; +import { ActivityLogsSelectors, ClearActivityLogs, GetActivityLogs } from '@osf/shared/stores/activity-logs'; @Component({ selector: 'osf-registration-recent-activity', - imports: [TranslatePipe, DatePipe, CustomPaginatorComponent, Skeleton], + imports: [RecentActivityListComponent, TranslatePipe], templateUrl: './registration-recent-activity.component.html', changeDetection: ChangeDetectionStrategy.OnPush, }) export class RegistrationRecentActivityComponent implements OnDestroy { private readonly route = inject(ActivatedRoute); - readonly environment = inject(ENVIRONMENT); - readonly pageSize = this.environment.activityLogs?.pageSize ?? ACTIVITY_LOGS_DEFAULT_PAGE_SIZE; + readonly pageSize = ACTIVITY_LOGS_DEFAULT_PAGE_SIZE; - private readonly registrationId: string = (this.route.snapshot.params['id'] ?? - this.route.parent?.snapshot.params['id']) as string; + readonly registrationId = toSignal( + this.route.parent?.params.pipe(map((params) => params['id'])) ?? of(undefined) + ); currentPage = signal(1); - formattedActivityLogs = select(ActivityLogsSelectors.getFormattedActivityLogs); + activityLogs = select(ActivityLogsSelectors.getActivityLogs); totalCount = select(ActivityLogsSelectors.getActivityLogsTotalCount); isLoading = select(ActivityLogsSelectors.getActivityLogsLoading); firstIndex = computed(() => (this.currentPage() - 1) * this.pageSize); - actions = createDispatchMap({ - getRegistrationActivityLogs: GetRegistrationActivityLogs, - clearActivityLogsStore: ClearActivityLogsStore, - }); + actions = createDispatchMap({ getActivityLogs: GetActivityLogs, clearActivityLogsStore: ClearActivityLogs }); constructor() { - this.actions.getRegistrationActivityLogs(this.registrationId, 1, this.pageSize); + effect(() => { + const registrationId = this.registrationId(); + const page = this.currentPage(); + + if (registrationId) { + this.actions.getActivityLogs(registrationId, CurrentResourceType.Registrations, page, this.pageSize); + } + }); + } + + ngOnDestroy(): void { + this.actions.clearActivityLogsStore(); } onPageChange(event: PaginatorState) { if (event.page !== undefined) { const pageNumber = event.page + 1; this.currentPage.set(pageNumber); - this.actions.getRegistrationActivityLogs(this.registrationId, pageNumber, this.pageSize); } } - - ngOnDestroy(): void { - this.actions.clearActivityLogsStore(); - } } diff --git a/src/app/shared/components/recent-activity/recent-activity-list.component.html b/src/app/shared/components/recent-activity/recent-activity-list.component.html new file mode 100644 index 000000000..409006aa3 --- /dev/null +++ b/src/app/shared/components/recent-activity/recent-activity-list.component.html @@ -0,0 +1,40 @@ +@if (!isLoading()) { +
+ @for (activityLog of activityLogs(); track activityLog.id) { +
+
+ + +
+ } @empty { +
+ {{ 'project.overview.recentActivity.noActivity' | translate }} +
+ } +
+ + @if (totalCount() > pageSize()) { + + } +} @else { +
+ + + + + +
+} diff --git a/src/app/shared/components/recent-activity/recent-activity-list.component.scss b/src/app/shared/components/recent-activity/recent-activity-list.component.scss new file mode 100644 index 000000000..26131210d --- /dev/null +++ b/src/app/shared/components/recent-activity/recent-activity-list.component.scss @@ -0,0 +1,8 @@ +.activity-item { + border-bottom: 1px solid var(--grey-2); + line-height: 1.5rem; + + .activity-date { + min-width: 25%; + } +} diff --git a/src/app/shared/components/recent-activity/recent-activity-list.component.spec.ts b/src/app/shared/components/recent-activity/recent-activity-list.component.spec.ts new file mode 100644 index 000000000..7ac02a83b --- /dev/null +++ b/src/app/shared/components/recent-activity/recent-activity-list.component.spec.ts @@ -0,0 +1,140 @@ +import { MockComponent } from 'ng-mocks'; + +import { PaginatorState } from 'primeng/paginator'; + +import { ComponentFixture, TestBed } from '@angular/core/testing'; + +import { CustomPaginatorComponent } from '@osf/shared/components/custom-paginator/custom-paginator.component'; + +import { RecentActivityListComponent } from './recent-activity-list.component'; + +import { + makeActivityLogWithDisplay, + MOCK_ACTIVITY_LOGS_WITH_DISPLAY, +} from '@testing/mocks/activity-log-with-display.mock'; +import { OSFTestingModule } from '@testing/osf.testing.module'; + +describe('RecentActivityListComponent', () => { + let component: RecentActivityListComponent; + let fixture: ComponentFixture; + + beforeEach(async () => { + await TestBed.configureTestingModule({ + imports: [RecentActivityListComponent, OSFTestingModule, MockComponent(CustomPaginatorComponent)], + }).compileComponents(); + + fixture = TestBed.createComponent(RecentActivityListComponent); + component = fixture.componentInstance; + fixture.componentRef.setInput('activityLogs', MOCK_ACTIVITY_LOGS_WITH_DISPLAY); + fixture.componentRef.setInput('isLoading', false); + fixture.componentRef.setInput('totalCount', 10); + fixture.componentRef.setInput('pageSize', 5); + fixture.componentRef.setInput('firstIndex', 0); + fixture.detectChanges(); + }); + + it('should have input values set correctly', () => { + expect(component.activityLogs()).toEqual(MOCK_ACTIVITY_LOGS_WITH_DISPLAY); + expect(component.isLoading()).toBe(false); + expect(component.totalCount()).toBe(10); + expect(component.pageSize()).toBe(5); + expect(component.firstIndex()).toBe(0); + }); + + it('should emit pageChange event when onPageChange is called', () => { + jest.spyOn(component.pageChange, 'emit'); + const mockEvent: PaginatorState = { page: 1, first: 5, rows: 5 }; + + component.onPageChange(mockEvent); + + expect(component.pageChange.emit).toHaveBeenCalledWith(mockEvent); + }); + + it('should handle empty activity logs', () => { + fixture.componentRef.setInput('activityLogs', []); + fixture.componentRef.setInput('totalCount', 0); + fixture.detectChanges(); + + expect(component.activityLogs()).toEqual([]); + expect(component.totalCount()).toBe(0); + }); + + it('should handle loading state', () => { + fixture.componentRef.setInput('isLoading', true); + fixture.detectChanges(); + + expect(component.isLoading()).toBe(true); + }); + + it('should update inputs dynamically', () => { + const newActivityLogs = [makeActivityLogWithDisplay({ id: 'log3', action: 'delete' })]; + + fixture.componentRef.setInput('activityLogs', newActivityLogs); + fixture.componentRef.setInput('isLoading', true); + fixture.componentRef.setInput('totalCount', 50); + fixture.componentRef.setInput('pageSize', 20); + fixture.componentRef.setInput('firstIndex', 20); + fixture.detectChanges(); + + expect(component.activityLogs()).toEqual(newActivityLogs); + expect(component.isLoading()).toBe(true); + expect(component.totalCount()).toBe(50); + expect(component.pageSize()).toBe(20); + expect(component.firstIndex()).toBe(20); + }); + + it('should handle PaginatorState with undefined or null values', () => { + jest.spyOn(component.pageChange, 'emit'); + const undefinedEvent: PaginatorState = { page: undefined, first: 0, rows: 5 }; + const nullEvent: PaginatorState = { page: null as any, first: null as any, rows: 5 }; + + component.onPageChange(undefinedEvent); + component.onPageChange(nullEvent); + + expect(component.pageChange.emit).toHaveBeenCalledWith(undefinedEvent); + expect(component.pageChange.emit).toHaveBeenCalledWith(nullEvent); + expect(component.pageChange.emit).toHaveBeenCalledTimes(2); + }); + + it('should handle activity logs without formattedActivity', () => { + const logsWithoutFormatted = [ + makeActivityLogWithDisplay({ id: 'log4', action: 'remove', formattedActivity: undefined }), + ]; + + fixture.componentRef.setInput('activityLogs', logsWithoutFormatted); + fixture.detectChanges(); + + expect(component.activityLogs()).toEqual(logsWithoutFormatted); + expect(component.activityLogs()[0].formattedActivity).toBeUndefined(); + }); + + it('should handle activity logs with different action types', () => { + const logsWithDifferentActions = [ + makeActivityLogWithDisplay({ id: 'log5', action: 'add', formattedActivity: 'Added activity' }), + makeActivityLogWithDisplay({ id: 'log6', action: 'remove', formattedActivity: 'Removed activity' }), + ]; + + fixture.componentRef.setInput('activityLogs', logsWithDifferentActions); + fixture.detectChanges(); + + expect(component.activityLogs()).toEqual(logsWithDifferentActions); + expect(component.activityLogs()[0].action).toBe('add'); + expect(component.activityLogs()[1].action).toBe('remove'); + }); + + it('should handle multiple consecutive page changes', () => { + jest.spyOn(component.pageChange, 'emit'); + const event1: PaginatorState = { page: 0, first: 0, rows: 5 }; + const event2: PaginatorState = { page: 1, first: 5, rows: 5 }; + const event3: PaginatorState = { page: 2, first: 10, rows: 5 }; + + component.onPageChange(event1); + component.onPageChange(event2); + component.onPageChange(event3); + + expect(component.pageChange.emit).toHaveBeenCalledTimes(3); + expect(component.pageChange.emit).toHaveBeenNthCalledWith(1, event1); + expect(component.pageChange.emit).toHaveBeenNthCalledWith(2, event2); + expect(component.pageChange.emit).toHaveBeenNthCalledWith(3, event3); + }); +}); diff --git a/src/app/shared/components/recent-activity/recent-activity-list.component.ts b/src/app/shared/components/recent-activity/recent-activity-list.component.ts new file mode 100644 index 000000000..78f20fb1f --- /dev/null +++ b/src/app/shared/components/recent-activity/recent-activity-list.component.ts @@ -0,0 +1,31 @@ +import { TranslatePipe } from '@ngx-translate/core'; + +import { PaginatorState } from 'primeng/paginator'; +import { Skeleton } from 'primeng/skeleton'; + +import { DatePipe } from '@angular/common'; +import { ChangeDetectionStrategy, Component, input, output } from '@angular/core'; + +import { CustomPaginatorComponent } from '@osf/shared/components/custom-paginator/custom-paginator.component'; +import { ActivityLogWithDisplay } from '@osf/shared/models/activity-logs/activity-log-with-display.model'; + +@Component({ + selector: 'osf-recent-activity-list', + imports: [TranslatePipe, DatePipe, CustomPaginatorComponent, Skeleton], + templateUrl: './recent-activity-list.component.html', + styleUrl: './recent-activity-list.component.scss', + changeDetection: ChangeDetectionStrategy.OnPush, +}) +export class RecentActivityListComponent { + activityLogs = input.required(); + isLoading = input.required(); + totalCount = input.required(); + pageSize = input.required(); + firstIndex = input.required(); + + pageChange = output(); + + onPageChange(event: PaginatorState): void { + this.pageChange.emit(event); + } +} diff --git a/src/app/shared/mappers/activity-logs.mapper.ts b/src/app/shared/mappers/activity-logs.mapper.ts index a4c1fc1c6..ba0caf8ea 100644 --- a/src/app/shared/mappers/activity-logs.mapper.ts +++ b/src/app/shared/mappers/activity-logs.mapper.ts @@ -3,7 +3,7 @@ import { replaceBadEncodedChars } from '@shared/helpers/format-bad-encoding.help import { DEFAULT_TABLE_PARAMS } from '../constants/default-table-params.constants'; import { ActivityLog, LogContributor } from '../models/activity-logs/activity-logs.model'; import { ActivityLogJsonApi, LogContributorJsonApi } from '../models/activity-logs/activity-logs-json-api.model'; -import { JsonApiResponseWithMeta, MetaAnonymousJsonApi } from '../models/common/json-api.model'; +import { ResponseJsonApi } from '../models/common/json-api.model'; import { PaginatedData } from '../models/paginated-data.model'; export class ActivityLogsMapper { @@ -154,9 +154,7 @@ export class ActivityLogsMapper { }; } - static fromGetActivityLogsResponse( - logs: JsonApiResponseWithMeta - ): PaginatedData { + static fromGetActivityLogsResponse(logs: ResponseJsonApi): PaginatedData { const isAnonymous = logs.meta.anonymous ?? false; return { data: logs.data.map((log) => this.fromActivityLogJsonApi(log, isAnonymous)), diff --git a/src/app/shared/services/activity-logs/activity-logs.service.spec.ts b/src/app/shared/services/activity-logs/activity-logs.service.spec.ts index 4b32f5ba7..2cb8979f4 100644 --- a/src/app/shared/services/activity-logs/activity-logs.service.spec.ts +++ b/src/app/shared/services/activity-logs/activity-logs.service.spec.ts @@ -1,6 +1,8 @@ import { HttpTestingController } from '@angular/common/http/testing'; import { inject, TestBed } from '@angular/core/testing'; +import { CurrentResourceType } from '@osf/shared/enums/resource-type.enum'; + import { ActivityLogDisplayService } from './activity-log-display.service'; import { ActivityLogsService } from './activity-logs.service'; @@ -15,6 +17,7 @@ import { OSFTestingStoreModule } from '@testing/osf.testing.module'; describe('Service: ActivityLogs', () => { let service: ActivityLogsService; const environment = EnvironmentTokenMock; + const apiBase = environment.useValue.apiDomainUrl; beforeEach(() => { TestBed.configureTestingModule({ @@ -27,31 +30,54 @@ describe('Service: ActivityLogs', () => { service = TestBed.inject(ActivityLogsService); }); - it('fetchRegistrationLogs maps + formats', inject([HttpTestingController], (httpMock: HttpTestingController) => { - let result: any; - service.fetchRegistrationLogs('reg1', 1, 10).subscribe((res) => (result = res)); - - const req = httpMock.expectOne(buildRegistrationLogsUrl('reg1', 1, 10, environment.useValue.apiDomainUrl)); - expect(req.request.method).toBe('GET'); - expect(req.request.params.get('page')).toBe('1'); - expect(req.request.params.get('page[size]')).toBe('10'); - - req.flush(getActivityLogsResponse()); - - expect(result.totalCount).toBe(2); - expect(result.data[0].formattedActivity).toBe('FMT'); - - httpMock.verify(); - })); - - it('fetchLogs maps + formats', inject([HttpTestingController], (httpMock: HttpTestingController) => { + it('fetchLogs for registrations maps + formats', inject( + [HttpTestingController], + (httpMock: HttpTestingController) => { + let result: any; + service.fetchLogs(CurrentResourceType.Registrations, 'reg1', 1, 10).subscribe((res) => (result = res)); + + const baseUrl = buildRegistrationLogsUrl('reg1', 1, 10, apiBase).split('?')[0]; + const req = httpMock.expectOne((request) => { + return request.url.startsWith(baseUrl) && request.method === 'GET'; + }); + expect(req.request.method).toBe('GET'); + expect(req.request.params.get('page')).toBe('1'); + expect(req.request.params.get('page[size]')).toBe('10'); + expect(req.request.params.getAll('embed[]')).toEqual([ + 'original_node', + 'user', + 'linked_node', + 'linked_registration', + 'template_node', + ]); + + req.flush(getActivityLogsResponse()); + + expect(result.totalCount).toBe(2); + expect(result.data[0].formattedActivity).toBe('FMT'); + + httpMock.verify(); + } + )); + + it('fetchLogs for projects maps + formats', inject([HttpTestingController], (httpMock: HttpTestingController) => { let result: any; - service.fetchLogs('proj1', 2, 5).subscribe((res) => (result = res)); + service.fetchLogs(CurrentResourceType.Projects, 'proj1', 2, 5).subscribe((res) => (result = res)); - const req = httpMock.expectOne(buildNodeLogsUrl('proj1', 2, 5, environment.useValue.apiDomainUrl)); + const baseUrl = buildNodeLogsUrl('proj1', 2, 5, apiBase).split('?')[0]; + const req = httpMock.expectOne((request) => { + return request.url.startsWith(baseUrl) && request.method === 'GET'; + }); expect(req.request.method).toBe('GET'); expect(req.request.params.get('page')).toBe('2'); expect(req.request.params.get('page[size]')).toBe('5'); + expect(req.request.params.getAll('embed[]')).toEqual([ + 'original_node', + 'user', + 'linked_node', + 'linked_registration', + 'template_node', + ]); req.flush(getActivityLogsResponse()); @@ -61,28 +87,37 @@ describe('Service: ActivityLogs', () => { httpMock.verify(); })); - it('fetchRegistrationLogs propagates error', inject([HttpTestingController], (httpMock: HttpTestingController) => { + it('fetchLogs for registrations propagates error', inject( + [HttpTestingController], + (httpMock: HttpTestingController) => { + let errorObj: any; + service.fetchLogs(CurrentResourceType.Registrations, 'reg2', 1, 10).subscribe({ + next: () => {}, + error: (e) => (errorObj = e), + }); + + const baseUrl = buildRegistrationLogsUrl('reg2', 1, 10, apiBase).split('?')[0]; + const req = httpMock.expectOne((request) => { + return request.url.startsWith(baseUrl) && request.method === 'GET'; + }); + req.flush({ errors: [{ detail: 'boom' }] }, { status: 500, statusText: 'Server Error' }); + + expect(errorObj).toBeTruthy(); + httpMock.verify(); + } + )); + + it('fetchLogs for projects propagates error', inject([HttpTestingController], (httpMock: HttpTestingController) => { let errorObj: any; - service.fetchRegistrationLogs('reg2', 1, 10).subscribe({ + service.fetchLogs(CurrentResourceType.Projects, 'proj500', 1, 10).subscribe({ next: () => {}, error: (e) => (errorObj = e), }); - const req = httpMock.expectOne(buildRegistrationLogsUrl('reg2', 1, 10, environment.useValue.apiDomainUrl)); - req.flush({ errors: [{ detail: 'boom' }] }, { status: 500, statusText: 'Server Error' }); - - expect(errorObj).toBeTruthy(); - httpMock.verify(); - })); - - it('fetchLogs propagates error', inject([HttpTestingController], (httpMock: HttpTestingController) => { - let errorObj: any; - service.fetchLogs('proj500', 1, 10).subscribe({ - next: () => {}, - error: (e) => (errorObj = e), + const baseUrl = buildNodeLogsUrl('proj500', 1, 10, apiBase).split('?')[0]; + const req = httpMock.expectOne((request) => { + return request.url.startsWith(baseUrl) && request.method === 'GET'; }); - - const req = httpMock.expectOne(buildNodeLogsUrl('proj500', 1, 10, environment.useValue.apiDomainUrl)); req.flush({ errors: [{ detail: 'boom' }] }, { status: 500, statusText: 'Server Error' }); expect(errorObj).toBeTruthy(); diff --git a/src/app/shared/services/activity-logs/activity-logs.service.ts b/src/app/shared/services/activity-logs/activity-logs.service.ts index 98f26c473..5f50ed5a0 100644 --- a/src/app/shared/services/activity-logs/activity-logs.service.ts +++ b/src/app/shared/services/activity-logs/activity-logs.service.ts @@ -4,11 +4,11 @@ import { map } from 'rxjs/operators'; import { inject, Injectable } from '@angular/core'; import { ENVIRONMENT } from '@core/provider/environment.provider'; +import { CurrentResourceType } from '@osf/shared/enums/resource-type.enum'; import { ActivityLogsMapper } from '@osf/shared/mappers/activity-logs.mapper'; import { ActivityLogWithDisplay } from '@osf/shared/models/activity-logs/activity-log-with-display.model'; -import { ActivityLog } from '@osf/shared/models/activity-logs/activity-logs.model'; import { ActivityLogJsonApi } from '@osf/shared/models/activity-logs/activity-logs-json-api.model'; -import { JsonApiResponseWithMeta, MetaAnonymousJsonApi } from '@osf/shared/models/common/json-api.model'; +import { ResponseJsonApi } from '@osf/shared/models/common/json-api.model'; import { PaginatedData } from '@osf/shared/models/paginated-data.model'; import { JsonApiService } from '../json-api.service'; @@ -18,42 +18,17 @@ import { ActivityLogDisplayService } from './activity-log-display.service'; @Injectable({ providedIn: 'root' }) export class ActivityLogsService { private jsonApiService = inject(JsonApiService); - private display = inject(ActivityLogDisplayService); + private activityDisplayService = inject(ActivityLogDisplayService); private readonly environment = inject(ENVIRONMENT); private readonly apiUrl = `${this.environment.apiDomainUrl}/v2`; - private formatActivities(result: PaginatedData): PaginatedData { - return { - ...result, - data: result.data.map((log) => ({ - ...log, - formattedActivity: this.display.getActivityDisplay(log), - })), - }; - } - - fetchLogs(projectId: string, page = 1, pageSize: number): Observable> { - const url = `${this.apiUrl}/nodes/${projectId}/logs/`; - const params: Record = { - 'embed[]': ['original_node', 'user', 'linked_node', 'linked_registration', 'template_node'], - page, - 'page[size]': pageSize, - }; - - return this.jsonApiService - .get>(url, params) - .pipe( - map((res) => ActivityLogsMapper.fromGetActivityLogsResponse(res)), - map((mapped) => this.formatActivities(mapped)) - ); - } - - fetchRegistrationLogs( - registrationId: string, + fetchLogs( + resourceType: CurrentResourceType.Projects | CurrentResourceType.Registrations, + resourceId: string, page = 1, pageSize: number ): Observable> { - const url = `${this.apiUrl}/registrations/${registrationId}/logs/`; + const url = `${this.apiUrl}/${resourceType}/${resourceId}/logs/`; const params: Record = { 'embed[]': ['original_node', 'user', 'linked_node', 'linked_registration', 'template_node'], page, @@ -61,10 +36,19 @@ export class ActivityLogsService { }; return this.jsonApiService - .get>(url, params) - .pipe( - map((res) => ActivityLogsMapper.fromGetActivityLogsResponse(res)), - map((mapped) => this.formatActivities(mapped)) - ); + .get>(url, params) + .pipe(map((res) => this.formatActivities(res))); + } + + private formatActivities(response: ResponseJsonApi): PaginatedData { + const mapped = ActivityLogsMapper.fromGetActivityLogsResponse(response); + + return { + ...mapped, + data: mapped.data.map((log) => ({ + ...log, + formattedActivity: this.activityDisplayService.getActivityDisplay(log), + })), + }; } } diff --git a/src/app/shared/stores/activity-logs/activity-logs.actions.ts b/src/app/shared/stores/activity-logs/activity-logs.actions.ts index 48197e5fc..695c4432e 100644 --- a/src/app/shared/stores/activity-logs/activity-logs.actions.ts +++ b/src/app/shared/stores/activity-logs/activity-logs.actions.ts @@ -1,22 +1,16 @@ +import { CurrentResourceType } from '@shared/enums/resource-type.enum'; + export class GetActivityLogs { static readonly type = '[ActivityLogs] Get Activity Logs'; constructor( - public projectId: string, - public page = 1, - public pageSize: number - ) {} -} - -export class GetRegistrationActivityLogs { - static readonly type = '[ActivityLogs] Get Registration Activity Logs'; - constructor( - public registrationId: string, + public resourceId: string, + public resourceType: CurrentResourceType.Projects | CurrentResourceType.Registrations, public page = 1, public pageSize: number ) {} } -export class ClearActivityLogsStore { - static readonly type = '[ActivityLogs] Clear Store'; +export class ClearActivityLogs { + static readonly type = '[ActivityLogs] Clear Activity Logs'; } diff --git a/src/app/shared/stores/activity-logs/activity-logs.selectors.spec.ts b/src/app/shared/stores/activity-logs/activity-logs.selectors.spec.ts index 78da1f606..6d18a2bf3 100644 --- a/src/app/shared/stores/activity-logs/activity-logs.selectors.spec.ts +++ b/src/app/shared/stores/activity-logs/activity-logs.selectors.spec.ts @@ -1,35 +1,121 @@ import { provideStore, Store } from '@ngxs/store'; +import { provideHttpClient } from '@angular/common/http'; +import { provideHttpClientTesting } from '@angular/common/http/testing'; import { TestBed } from '@angular/core/testing'; +import { ActivityLogDisplayService } from '@osf/shared/services/activity-logs/activity-log-display.service'; + import { ActivityLogsSelectors } from './activity-logs.selectors'; import { ActivityLogsState } from './activity-logs.state'; -describe.skip('ActivityLogsSelectors', () => { +import { + makeActivityLogWithDisplay, + MOCK_ACTIVITY_LOGS_WITH_DISPLAY, +} from '@testing/mocks/activity-log-with-display.mock'; + +describe('ActivityLogsSelectors', () => { let store: Store; beforeEach(() => { TestBed.configureTestingModule({ - providers: [provideStore([ActivityLogsState])], + providers: [ + provideStore([ActivityLogsState]), + provideHttpClient(), + provideHttpClientTesting(), + { + provide: ActivityLogDisplayService, + useValue: { getActivityDisplay: jest.fn().mockReturnValue('formatted') }, + }, + ], }); store = TestBed.inject(Store); }); - it('selects logs, formatted logs, totalCount, and loading', () => { + it('selects activity logs', () => { store.reset({ activityLogs: { activityLogs: { - data: [{ id: '1', date: '2024-01-01T00:00:00Z', formattedActivity: 'FMT' }], + data: MOCK_ACTIVITY_LOGS_WITH_DISPLAY, isLoading: false, error: null, - totalCount: 1, + totalCount: 2, + }, + }, + } as any); + + const logs = store.selectSnapshot(ActivityLogsSelectors.getActivityLogs); + expect(logs.length).toBe(2); + expect(logs).toEqual(MOCK_ACTIVITY_LOGS_WITH_DISPLAY); + expect(logs[0].formattedActivity).toBe('Test activity 1'); + expect(logs[1].formattedActivity).toBe('Test activity 2'); + }); + + it('selects total count', () => { + store.reset({ + activityLogs: { + activityLogs: { + data: MOCK_ACTIVITY_LOGS_WITH_DISPLAY, + isLoading: false, + error: null, + totalCount: 10, + }, + }, + } as any); + + expect(store.selectSnapshot(ActivityLogsSelectors.getActivityLogsTotalCount)).toBe(10); + }); + + it('selects loading state', () => { + store.reset({ + activityLogs: { + activityLogs: { + data: [], + isLoading: true, + error: null, + totalCount: 0, + }, + }, + } as any); + + expect(store.selectSnapshot(ActivityLogsSelectors.getActivityLogsLoading)).toBe(true); + }); + + it('selects empty activity logs', () => { + store.reset({ + activityLogs: { + activityLogs: { + data: [], + isLoading: false, + error: null, + totalCount: 0, }, }, } as any); - expect(store.selectSnapshot(ActivityLogsSelectors.getActivityLogs).length).toBe(1); - expect(store.selectSnapshot(ActivityLogsSelectors.getFormattedActivityLogs)[0].formattedActivity).toBe('FMT'); - expect(store.selectSnapshot(ActivityLogsSelectors.getActivityLogsTotalCount)).toBe(1); + const logs = store.selectSnapshot(ActivityLogsSelectors.getActivityLogs); + expect(logs.length).toBe(0); + expect(logs).toEqual([]); + expect(store.selectSnapshot(ActivityLogsSelectors.getActivityLogsTotalCount)).toBe(0); expect(store.selectSnapshot(ActivityLogsSelectors.getActivityLogsLoading)).toBe(false); }); + + it('selects activity logs with different states', () => { + const customLog = makeActivityLogWithDisplay({ id: 'custom-log', action: 'delete' }); + store.reset({ + activityLogs: { + activityLogs: { + data: [customLog], + isLoading: false, + error: null, + totalCount: 1, + }, + }, + } as any); + + const logs = store.selectSnapshot(ActivityLogsSelectors.getActivityLogs); + expect(logs.length).toBe(1); + expect(logs[0].id).toBe('custom-log'); + expect(logs[0].action).toBe('delete'); + }); }); diff --git a/src/app/shared/stores/activity-logs/activity-logs.selectors.ts b/src/app/shared/stores/activity-logs/activity-logs.selectors.ts index f6c9de8e4..3e93b7da0 100644 --- a/src/app/shared/stores/activity-logs/activity-logs.selectors.ts +++ b/src/app/shared/stores/activity-logs/activity-logs.selectors.ts @@ -1,19 +1,13 @@ import { Selector } from '@ngxs/store'; import { ActivityLogWithDisplay } from '@osf/shared/models/activity-logs/activity-log-with-display.model'; -import { ActivityLog } from '@osf/shared/models/activity-logs/activity-logs.model'; import { ActivityLogsStateModel } from './activity-logs.model'; import { ActivityLogsState } from './activity-logs.state'; export class ActivityLogsSelectors { @Selector([ActivityLogsState]) - static getActivityLogs(state: ActivityLogsStateModel): ActivityLog[] { - return state.activityLogs.data; - } - - @Selector([ActivityLogsState]) - static getFormattedActivityLogs(state: ActivityLogsStateModel): ActivityLogWithDisplay[] { + static getActivityLogs(state: ActivityLogsStateModel): ActivityLogWithDisplay[] { return state.activityLogs.data; } diff --git a/src/app/shared/stores/activity-logs/activity-logs.state.spec.ts b/src/app/shared/stores/activity-logs/activity-logs.state.spec.ts index 96dced6e2..d3536146b 100644 --- a/src/app/shared/stores/activity-logs/activity-logs.state.spec.ts +++ b/src/app/shared/stores/activity-logs/activity-logs.state.spec.ts @@ -4,9 +4,10 @@ import { provideHttpClient } from '@angular/common/http'; import { HttpTestingController, provideHttpClientTesting } from '@angular/common/http/testing'; import { inject, TestBed } from '@angular/core/testing'; +import { CurrentResourceType } from '@osf/shared/enums/resource-type.enum'; import { ActivityLogDisplayService } from '@osf/shared/services/activity-logs/activity-log-display.service'; -import { ClearActivityLogsStore, GetActivityLogs, GetRegistrationActivityLogs } from './activity-logs.actions'; +import { ClearActivityLogs, GetActivityLogs } from './activity-logs.actions'; import { ActivityLogsState } from './activity-logs.state'; import { @@ -16,9 +17,10 @@ import { } from '@testing/data/activity-logs/activity-logs.data'; import { EnvironmentTokenMock } from '@testing/mocks/environment.token.mock'; -describe.skip('State: ActivityLogs', () => { +describe('State: ActivityLogs', () => { let store: Store; const environment = EnvironmentTokenMock; + const apiBase = environment.useValue.apiDomainUrl; beforeEach(() => { TestBed.configureTestingModule({ @@ -41,13 +43,17 @@ describe.skip('State: ActivityLogs', () => { (httpMock: HttpTestingController) => { let snapshot: any; - store.dispatch(new GetRegistrationActivityLogs('reg123', 1, 10)).subscribe(() => { + store.dispatch(new GetActivityLogs('reg123', CurrentResourceType.Registrations, 1, 10)).subscribe(() => { snapshot = store.snapshot().activityLogs.activityLogs; }); expect(store.selectSnapshot((s: any) => s.activityLogs.activityLogs.isLoading)).toBe(true); - const req = httpMock.expectOne(buildRegistrationLogsUrl('reg123', 1, 10, environment.useValue.apiDomainUrl)); + const fullUrl = buildRegistrationLogsUrl('reg123', 1, 10, apiBase); + const urlPath = fullUrl.split('?')[0].replace(apiBase, ''); + const req = httpMock.expectOne((request) => { + return request.url.includes(urlPath) && request.method === 'GET'; + }); expect(req.request.method).toBe('GET'); req.flush(getActivityLogsResponse()); @@ -63,11 +69,15 @@ describe.skip('State: ActivityLogs', () => { it('handles error when loading registration logs', inject( [HttpTestingController], (httpMock: HttpTestingController) => { - store.dispatch(new GetRegistrationActivityLogs('reg500', 1, 10)).subscribe(); + store.dispatch(new GetActivityLogs('reg500', CurrentResourceType.Registrations, 1, 10)).subscribe(); expect(store.selectSnapshot((s: any) => s.activityLogs.activityLogs.isLoading)).toBe(true); - const req = httpMock.expectOne(buildRegistrationLogsUrl('reg500', 1, 10, environment.useValue.apiDomainUrl)); + const fullUrl = buildRegistrationLogsUrl('reg500', 1, 10, apiBase); + const urlPath = fullUrl.split('?')[0].replace(apiBase, ''); + const req = httpMock.expectOne((request) => { + return request.url.includes(urlPath) && request.method === 'GET'; + }); req.flush({ errors: [{ detail: 'boom' }] }, { status: 500, statusText: 'Server Error' }); const snap = store.snapshot().activityLogs.activityLogs; @@ -83,13 +93,17 @@ describe.skip('State: ActivityLogs', () => { [HttpTestingController], (httpMock: HttpTestingController) => { let snapshot: any; - store.dispatch(new GetActivityLogs('proj123', 1, 10)).subscribe(() => { + store.dispatch(new GetActivityLogs('proj123', CurrentResourceType.Projects, 1, 10)).subscribe(() => { snapshot = store.snapshot().activityLogs.activityLogs; }); expect(store.selectSnapshot((s: any) => s.activityLogs.activityLogs.isLoading)).toBe(true); - const req = httpMock.expectOne(buildNodeLogsUrl('proj123', 1, 10, environment.useValue.apiDomainUrl)); + const fullUrl = buildNodeLogsUrl('proj123', 1, 10, apiBase); + const urlPath = fullUrl.split('?')[0].replace(apiBase, ''); + const req = httpMock.expectOne((request) => { + return request.url.includes(urlPath) && request.method === 'GET'; + }); expect(req.request.method).toBe('GET'); req.flush(getActivityLogsResponse()); @@ -105,11 +119,15 @@ describe.skip('State: ActivityLogs', () => { it('handles error when loading project logs (nodes)', inject( [HttpTestingController], (httpMock: HttpTestingController) => { - store.dispatch(new GetActivityLogs('proj500', 1, 10)).subscribe(); + store.dispatch(new GetActivityLogs('proj500', CurrentResourceType.Projects, 1, 10)).subscribe(); expect(store.selectSnapshot((s: any) => s.activityLogs.activityLogs.isLoading)).toBe(true); - const req = httpMock.expectOne(buildNodeLogsUrl('proj500', 1, 10, environment.useValue.apiDomainUrl)); + const fullUrl = buildNodeLogsUrl('proj500', 1, 10, apiBase); + const urlPath = fullUrl.split('?')[0].replace(apiBase, ''); + const req = httpMock.expectOne((request) => { + return request.url.includes(urlPath) && request.method === 'GET'; + }); req.flush({ errors: [{ detail: 'boom' }] }, { status: 500, statusText: 'Server Error' }); const snap = store.snapshot().activityLogs.activityLogs; @@ -128,7 +146,7 @@ describe.skip('State: ActivityLogs', () => { }, } as any); - store.dispatch(new ClearActivityLogsStore()); + store.dispatch(new ClearActivityLogs()); const snap = store.snapshot().activityLogs.activityLogs; expect(snap.data).toEqual([]); expect(snap.totalCount).toBe(0); diff --git a/src/app/shared/stores/activity-logs/activity-logs.state.ts b/src/app/shared/stores/activity-logs/activity-logs.state.ts index e73bb69d2..f9baccd4c 100644 --- a/src/app/shared/stores/activity-logs/activity-logs.state.ts +++ b/src/app/shared/stores/activity-logs/activity-logs.state.ts @@ -1,13 +1,14 @@ import { Action, State, StateContext } from '@ngxs/store'; -import { catchError, EMPTY } from 'rxjs'; +import { catchError } from 'rxjs'; import { tap } from 'rxjs/operators'; import { inject, Injectable } from '@angular/core'; +import { handleSectionError } from '@osf/shared/helpers/state-error.handler'; import { ActivityLogsService } from '@osf/shared/services/activity-logs/activity-logs.service'; -import { ClearActivityLogsStore, GetActivityLogs, GetRegistrationActivityLogs } from './activity-logs.actions'; +import { ClearActivityLogs, GetActivityLogs } from './activity-logs.actions'; import { ACTIVITY_LOGS_STATE_DEFAULT, ActivityLogsStateModel } from './activity-logs.model'; @State({ @@ -24,43 +25,19 @@ export class ActivityLogsState { activityLogs: { data: [], isLoading: true, error: null, totalCount: 0 }, }); - return this.activityLogsService.fetchLogs(action.projectId, action.page, action.pageSize).pipe( - tap((res) => { - ctx.patchState({ - activityLogs: { data: res.data, isLoading: false, error: null, totalCount: res.totalCount }, - }); - }), - catchError((error) => { - ctx.patchState({ - activityLogs: { data: [], isLoading: false, error, totalCount: 0 }, - }); - return EMPTY; - }) - ); + return this.activityLogsService + .fetchLogs(action.resourceType, action.resourceId, action.page, action.pageSize) + .pipe( + tap((res) => { + ctx.patchState({ + activityLogs: { data: res.data, isLoading: false, error: null, totalCount: res.totalCount }, + }); + }), + catchError((error) => handleSectionError(ctx, 'activityLogs', error)) + ); } - @Action(GetRegistrationActivityLogs) - getRegistrationActivityLogs(ctx: StateContext, action: GetRegistrationActivityLogs) { - ctx.patchState({ - activityLogs: { data: [], isLoading: true, error: null, totalCount: 0 }, - }); - - return this.activityLogsService.fetchRegistrationLogs(action.registrationId, action.page, action.pageSize).pipe( - tap((res) => { - ctx.patchState({ - activityLogs: { data: res.data, isLoading: false, error: null, totalCount: res.totalCount }, - }); - }), - catchError((error) => { - ctx.patchState({ - activityLogs: { data: [], isLoading: false, error, totalCount: 0 }, - }); - return EMPTY; - }) - ); - } - - @Action(ClearActivityLogsStore) + @Action(ClearActivityLogs) clearActivityLogsStore(ctx: StateContext) { ctx.setState(ACTIVITY_LOGS_STATE_DEFAULT); } diff --git a/src/styles/components/nodes.scss b/src/styles/components/nodes.scss index 15b959ab4..87f4d0ccc 100644 --- a/src/styles/components/nodes.scss +++ b/src/styles/components/nodes.scss @@ -19,3 +19,9 @@ color: var(--dark-blue-1); } } + +.activity-item { + a { + font-weight: bold; + } +} diff --git a/src/testing/mocks/activity-log-with-display.mock.ts b/src/testing/mocks/activity-log-with-display.mock.ts new file mode 100644 index 000000000..293e7228f --- /dev/null +++ b/src/testing/mocks/activity-log-with-display.mock.ts @@ -0,0 +1,41 @@ +import { ActivityLogWithDisplay } from '@osf/shared/models/activity-logs/activity-log-with-display.model'; + +import structuredClone from 'structured-clone'; + +export function makeActivityLogWithDisplay(overrides: Partial = {}): ActivityLogWithDisplay { + return structuredClone({ + id: 'log1', + type: 'logs', + action: 'update', + date: '2024-01-01T00:00:00Z', + params: { + contributors: [], + paramsNode: { id: 'node1', title: 'Test Node' }, + paramsProject: null, + pointer: null, + }, + formattedActivity: 'Test activity', + ...overrides, + }); +} + +export const MOCK_ACTIVITY_LOGS_WITH_DISPLAY: ActivityLogWithDisplay[] = [ + makeActivityLogWithDisplay({ + id: 'log1', + action: 'update', + date: '2024-01-01T00:00:00Z', + formattedActivity: 'Test activity 1', + }), + makeActivityLogWithDisplay({ + id: 'log2', + action: 'create', + date: '2024-01-02T00:00:00Z', + params: { + contributors: [], + paramsNode: { id: 'node2', title: 'Test Node 2' }, + paramsProject: null, + pointer: null, + }, + formattedActivity: 'Test activity 2', + }), +];