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) {
-
-
-
-
- {{ activityLog.date | date: 'MMM d, y hh:mm a' }}
-
-
- } @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) {
+
+
+
+
+ {{ activityLog.date | date: 'MMM d, y hh:mm a' }}
+
+
+ } @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',
+ }),
+];