From 948dc345c9ff578ea0d08acaa69b2d2cc01ea99b Mon Sep 17 00:00:00 2001 From: Andriy Sheredko Date: Tue, 2 Sep 2025 17:42:24 +0300 Subject: [PATCH 1/4] feat(registration-recent-activity): add Recent Activity to registrations --- src/app/core/constants/nav-items.constant.ts | 7 ++ ...egistration-recent-activity.component.html | 38 ++++++++++ ...egistration-recent-activity.component.scss | 16 ++++ ...stration-recent-activity.component.spec.ts | 49 +++++++++++++ .../registration-recent-activity.component.ts | 73 +++++++++++++++++++ src/app/features/registry/registry.routes.ts | 11 ++- .../activity-logs/activity-logs.service.ts | 17 +++++ .../activity-logs/activity-logs.actions.ts | 9 +++ .../activity-logs/activity-logs.state.ts | 17 ++++- 9 files changed, 234 insertions(+), 3 deletions(-) create mode 100644 src/app/features/registry/pages/recent-activity/registration-recent-activity.component.html create mode 100644 src/app/features/registry/pages/recent-activity/registration-recent-activity.component.scss create mode 100644 src/app/features/registry/pages/recent-activity/registration-recent-activity.component.spec.ts create mode 100644 src/app/features/registry/pages/recent-activity/registration-recent-activity.component.ts diff --git a/src/app/core/constants/nav-items.constant.ts b/src/app/core/constants/nav-items.constant.ts index 0ce0a1e9c..aab7a6220 100644 --- a/src/app/core/constants/nav-items.constant.ts +++ b/src/app/core/constants/nav-items.constant.ts @@ -172,6 +172,13 @@ export const REGISTRATION_MENU_ITEMS: MenuItem[] = [ visible: true, routerLinkActiveOptions: { exact: false }, }, + { + id: 'registration-recent-activity', + label: 'navigation.recentActivity', + routerLink: 'recent-activity', + visible: true, + routerLinkActiveOptions: { exact: true }, + }, ]; export const MENU_ITEMS: MenuItem[] = [ diff --git a/src/app/features/registry/pages/recent-activity/registration-recent-activity.component.html b/src/app/features/registry/pages/recent-activity/registration-recent-activity.component.html new file mode 100644 index 000000000..b98ec800f --- /dev/null +++ b/src/app/features/registry/pages/recent-activity/registration-recent-activity.component.html @@ -0,0 +1,38 @@ +
+

{{ '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/registry/pages/recent-activity/registration-recent-activity.component.scss b/src/app/features/registry/pages/recent-activity/registration-recent-activity.component.scss new file mode 100644 index 000000000..128342917 --- /dev/null +++ b/src/app/features/registry/pages/recent-activity/registration-recent-activity.component.scss @@ -0,0 +1,16 @@ +@use "/assets/styles/variables" as var; +@use "/assets/styles/mixins" as mix; + +.activities { + border: 1px solid var.$grey-2; + border-radius: mix.rem(12px); + color: var.$dark-blue-1; + + &-activity { + border-bottom: 1px solid var.$grey-2; + + .activity-date { + width: 30%; + } + } +} diff --git a/src/app/features/registry/pages/recent-activity/registration-recent-activity.component.spec.ts b/src/app/features/registry/pages/recent-activity/registration-recent-activity.component.spec.ts new file mode 100644 index 000000000..6b02780a1 --- /dev/null +++ b/src/app/features/registry/pages/recent-activity/registration-recent-activity.component.spec.ts @@ -0,0 +1,49 @@ +import { NgxsModule, Store } from '@ngxs/store'; + +import { ComponentFixture, TestBed } from '@angular/core/testing'; +import { ActivatedRoute } from '@angular/router'; + +import { ClearActivityLogsStore, GetRegistrationActivityLogs } from '@shared/stores/activity-logs'; +import { ActivityLogsState } from '@shared/stores/activity-logs/activity-logs.state'; + +import { RegistrationRecentActivityComponent } from './registration-recent-activity.component'; + +describe('RegistrationRecentActivityComponent', () => { + let fixture: ComponentFixture; + let store: Store; + + beforeEach(async () => { + await TestBed.configureTestingModule({ + imports: [NgxsModule.forRoot([ActivityLogsState]), RegistrationRecentActivityComponent], + providers: [{ provide: ActivatedRoute, useValue: { snapshot: { params: { id: 'reg123' } }, parent: null } }], + }).compileComponents(); + + store = TestBed.inject(Store); + spyOn(store, 'dispatch').and.callThrough(); + + fixture = TestBed.createComponent(RegistrationRecentActivityComponent); + fixture.detectChanges(); + }); + + it('creates and dispatches initial registration logs fetch', () => { + expect(fixture.componentInstance).toBeTruthy(); + expect(store.dispatch).toHaveBeenCalledWith(jasmine.any(GetRegistrationActivityLogs)); + const action = (store.dispatch as jasmine.Spy).calls.mostRecent().args[0] as GetRegistrationActivityLogs; + expect(action.registrationId).toBe('reg123'); + expect(action.page).toBe('1'); + }); + + it('dispatches on page change', () => { + (store.dispatch as jasmine.Spy).calls.reset(); + fixture.componentInstance.onPageChange({ page: 2 } as any); + expect(store.dispatch).toHaveBeenCalledWith(jasmine.any(GetRegistrationActivityLogs)); + const action = (store.dispatch as jasmine.Spy).calls.mostRecent().args[0] as GetRegistrationActivityLogs; + expect(action.page).toBe('3'); + }); + + it('clears store on destroy', () => { + (store.dispatch as jasmine.Spy).calls.reset(); + fixture.destroy(); + expect(store.dispatch).toHaveBeenCalledWith(jasmine.any(ClearActivityLogsStore)); + }); +}); diff --git a/src/app/features/registry/pages/recent-activity/registration-recent-activity.component.ts b/src/app/features/registry/pages/recent-activity/registration-recent-activity.component.ts new file mode 100644 index 000000000..1c81ca836 --- /dev/null +++ b/src/app/features/registry/pages/recent-activity/registration-recent-activity.component.ts @@ -0,0 +1,73 @@ +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 { ActivatedRoute } from '@angular/router'; + +import { CustomPaginatorComponent } from '@shared/components'; +import { ActivityLogDisplayService } from '@shared/services'; +import { + ActivityLogsSelectors, + ClearActivityLogsStore, + GetRegistrationActivityLogs, +} from '@shared/stores/activity-logs'; + +@Component({ + selector: 'osf-registration-recent-activity', + imports: [TranslatePipe, Skeleton, DatePipe, CustomPaginatorComponent], + templateUrl: './registration-recent-activity.component.html', + styleUrl: './registration-recent-activity.component.scss', + changeDetection: ChangeDetectionStrategy.OnPush, +}) +export class RegistrationRecentActivityComponent implements OnDestroy { + private readonly activityLogDisplayService = inject(ActivityLogDisplayService); + private readonly route = inject(ActivatedRoute); + + readonly pageSize = 10; + + protected currentPage = signal(1); + protected activityLogs = select(ActivityLogsSelectors.getActivityLogs); + protected totalCount = select(ActivityLogsSelectors.getActivityLogsTotalCount); + protected isLoading = select(ActivityLogsSelectors.getActivityLogsLoading); + protected firstIndex = computed(() => (this.currentPage() - 1) * this.pageSize); + + protected actions = createDispatchMap({ + getRegistrationActivityLogs: GetRegistrationActivityLogs, + clearActivityLogsStore: ClearActivityLogsStore, + }); + + protected formattedActivityLogs = computed(() => { + const logs = this.activityLogs(); + return logs.map((log) => ({ + ...log, + formattedActivity: this.activityLogDisplayService.getActivityDisplay(log), + })); + }); + + constructor() { + const registrationId = this.route.snapshot.params['id'] || this.route.parent?.snapshot.params['id']; + if (registrationId) { + this.actions.getRegistrationActivityLogs(registrationId, '1', String(this.pageSize)); + } + } + + onPageChange(event: PaginatorState) { + if (event.page !== undefined) { + const pageNumber = event.page + 1; + this.currentPage.set(pageNumber); + const registrationId = this.route.snapshot.params['id'] || this.route.parent?.snapshot.params['id']; + if (registrationId) { + this.actions.getRegistrationActivityLogs(registrationId, String(pageNumber), String(this.pageSize)); + } + } + } + + ngOnDestroy(): void { + this.actions.clearActivityLogsStore(); + } +} diff --git a/src/app/features/registry/registry.routes.ts b/src/app/features/registry/registry.routes.ts index 22da6cde0..c4e3172d0 100644 --- a/src/app/features/registry/registry.routes.ts +++ b/src/app/features/registry/registry.routes.ts @@ -1,5 +1,4 @@ import { provideStates } from '@ngxs/store'; - import { Routes } from '@angular/router'; import { viewOnlyGuard } from '@osf/core/guards'; @@ -12,6 +11,7 @@ import { SubjectsState, ViewOnlyLinkState, } from '@osf/shared/stores'; +import { ActivityLogsState } from '@shared/stores/activity-logs'; import { AnalyticsState } from '../analytics/store'; import { RegistriesState } from '../registries/store'; @@ -28,7 +28,7 @@ export const registryRoutes: Routes = [ { path: '', component: RegistryComponent, - providers: [provideStates([RegistryOverviewState])], + providers: [provideStates([RegistryOverviewState, ActivityLogsState])], children: [ { path: '', @@ -113,6 +113,13 @@ export const registryRoutes: Routes = [ loadComponent: () => import('./pages/registry-wiki/registry-wiki.component').then((c) => c.RegistryWikiComponent), }, + { + path: 'recent-activity', + loadComponent: () => + import('./pages/recent-activity/registration-recent-activity.component').then( + (c) => c.RegistrationRecentActivityComponent + ), + }, ], }, ]; 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 a36b4dd2c..a1596d58f 100644 --- a/src/app/shared/services/activity-logs/activity-logs.service.ts +++ b/src/app/shared/services/activity-logs/activity-logs.service.ts @@ -34,4 +34,21 @@ export class ActivityLogsService { .get>(url, params) .pipe(map((res) => ActivityLogsMapper.fromGetActivityLogsResponse(res))); } + + fetchRegistrationLogs( + registrationId: string, + page = '1', + pageSize: string + ): Observable> { + const url = `${environment.apiUrl}/registrations/${registrationId}/logs/`; + const params: Record = { + 'embed[]': ['original_node', 'user', 'linked_node', 'linked_registration', 'template_node', 'group'], + page, + 'page[size]': pageSize, + }; + + return this.jsonApiService + .get>(url, params) + .pipe(map((res) => ActivityLogsMapper.fromGetActivityLogsResponse(res))); + } } 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 50d9c2e0b..0ae1b1b4e 100644 --- a/src/app/shared/stores/activity-logs/activity-logs.actions.ts +++ b/src/app/shared/stores/activity-logs/activity-logs.actions.ts @@ -8,6 +8,15 @@ export class GetActivityLogs { ) {} } +export class GetRegistrationActivityLogs { + static readonly type = '[ActivityLogs] Get Registration Activity Logs'; + constructor( + public registrationId: string, + public page = '1', + public pageSize: string + ) {} +} + export class ClearActivityLogsStore { static readonly type = '[ActivityLogs] Clear Store'; } 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 0ac91e5c6..745cfc308 100644 --- a/src/app/shared/stores/activity-logs/activity-logs.state.ts +++ b/src/app/shared/stores/activity-logs/activity-logs.state.ts @@ -6,7 +6,7 @@ import { inject, Injectable } from '@angular/core'; import { ActivityLogsService } from '@shared/services'; -import { ClearActivityLogsStore, GetActivityLogs } from './activity-logs.actions'; +import { ClearActivityLogsStore, GetActivityLogs, GetRegistrationActivityLogs } from './activity-logs.actions'; import { ACTIVITY_LOGS_STATE_DEFAULT, ActivityLogsStateModel } from './activity-logs.model'; @State({ @@ -42,6 +42,21 @@ export class ActivityLogsState { ); } + @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 }, + }); + }) + ); + } + @Action(ClearActivityLogsStore) clearActivityLogsStore(ctx: StateContext) { ctx.setState(ACTIVITY_LOGS_STATE_DEFAULT); From e9f5c8ded612bff97009f846f3eab2452f59eec0 Mon Sep 17 00:00:00 2001 From: Andriy Sheredko Date: Mon, 8 Sep 2025 17:18:27 +0300 Subject: [PATCH 2/4] feat(registration-recent-activity): code fixes regarding comments --- .../guards/require-registration-id.guard.ts | 9 +++ .../recent-activity.component.ts | 8 +- .../overview/project-overview.component.ts | 2 +- ...egistration-recent-activity.component.html | 44 ++++++----- ...egistration-recent-activity.component.scss | 16 ---- ...stration-recent-activity.component.spec.ts | 78 +++++++++++++++---- .../registration-recent-activity.component.ts | 37 ++++----- src/app/features/registry/registry.routes.ts | 3 + .../activity-logs.service.spec.ts | 61 +++++++++++++++ .../activity-logs/activity-logs.service.ts | 35 +++++++-- .../activity-logs/activity-logs.actions.ts | 8 +- .../activity-logs/activity-logs.selectors.ts | 9 +++ .../activity-logs/activity-logs.state.spec.ts | 75 ++++++++++++++++++ .../activity-logs/activity-logs.state.ts | 14 +--- .../data/activity-logs/activity-logs.data.ts | 33 ++++++++ 15 files changed, 331 insertions(+), 101 deletions(-) create mode 100644 src/app/core/guards/require-registration-id.guard.ts create mode 100644 src/app/shared/services/activity-logs/activity-logs.service.spec.ts create mode 100644 src/app/shared/stores/activity-logs/activity-logs.state.spec.ts create mode 100644 src/testing/data/activity-logs/activity-logs.data.ts diff --git a/src/app/core/guards/require-registration-id.guard.ts b/src/app/core/guards/require-registration-id.guard.ts new file mode 100644 index 000000000..33ba338c9 --- /dev/null +++ b/src/app/core/guards/require-registration-id.guard.ts @@ -0,0 +1,9 @@ +import { inject } from '@angular/core'; +import { CanActivateFn, Router, UrlTree } from '@angular/router'; + +export const requireRegistrationIdGuard: CanActivateFn = (route): boolean | UrlTree => { + const id = route.paramMap.get('id'); + if (id) return true; + + return inject(Router).parseUrl('/registries/discover'); +}; 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 index e8858d724..7b363709d 100644 --- 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 @@ -26,14 +26,14 @@ export class RecentActivityComponent { 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, - }); + actions = createDispatchMap({ getActivityLogs: GetActivityLogs }); formattedActivityLogs = computed(() => { const logs = this.activityLogs(); @@ -50,7 +50,7 @@ export class RecentActivityComponent { const projectId = this.route.snapshot.params['id'] || this.route.parent?.snapshot.params['id']; if (projectId) { - this.actions.getActivityLogs(projectId, pageNumber.toString(), this.pageSize().toString()); + this.actions.getActivityLogs(projectId, pageNumber, this.pageSize()); } } } diff --git a/src/app/features/project/overview/project-overview.component.ts b/src/app/features/project/overview/project-overview.component.ts index 5513a813f..b1cdf605d 100644 --- a/src/app/features/project/overview/project-overview.component.ts +++ b/src/app/features/project/overview/project-overview.component.ts @@ -244,7 +244,7 @@ export class ProjectOverviewComponent extends DataciteTrackerComponent implement this.actions.getHomeWiki(ResourceType.Project, projectId); this.actions.getComponents(projectId); this.actions.getLinkedProjects(projectId); - this.actions.getActivityLogs(projectId, this.activityDefaultPage.toString(), this.activityPageSize.toString()); + this.actions.getActivityLogs(projectId, this.activityDefaultPage, this.activityPageSize); this.setupDataciteViewTrackerEffect().subscribe(); } } diff --git a/src/app/features/registry/pages/recent-activity/registration-recent-activity.component.html b/src/app/features/registry/pages/recent-activity/registration-recent-activity.component.html index b98ec800f..20d43567d 100644 --- a/src/app/features/registry/pages/recent-activity/registration-recent-activity.component.html +++ b/src/app/features/registry/pages/recent-activity/registration-recent-activity.component.html @@ -1,24 +1,36 @@ -
-

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

+
+

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

- @if (!isLoading()) { - @if (formattedActivityLogs().length) { + @defer (when !isLoading()) { +
@for (activityLog of formattedActivityLogs(); track activityLog.id) { -
-
-
+
+ +
+ } @empty { +
+ {{ 'project.overview.recentActivity.noActivity' | translate }} +
} - } @else { -
- {{ 'project.overview.recentActivity.noActivity' | translate }} -
- } +
@if (totalCount() > pageSize) { {{ 'project.overview.recentActivity.title' | translate }} (pageChanged)="onPageChange($event)" /> } - } @else { -
- - - - - -
}
diff --git a/src/app/features/registry/pages/recent-activity/registration-recent-activity.component.scss b/src/app/features/registry/pages/recent-activity/registration-recent-activity.component.scss index 128342917..e69de29bb 100644 --- a/src/app/features/registry/pages/recent-activity/registration-recent-activity.component.scss +++ b/src/app/features/registry/pages/recent-activity/registration-recent-activity.component.scss @@ -1,16 +0,0 @@ -@use "/assets/styles/variables" as var; -@use "/assets/styles/mixins" as mix; - -.activities { - border: 1px solid var.$grey-2; - border-radius: mix.rem(12px); - color: var.$dark-blue-1; - - &-activity { - border-bottom: 1px solid var.$grey-2; - - .activity-date { - width: 30%; - } - } -} diff --git a/src/app/features/registry/pages/recent-activity/registration-recent-activity.component.spec.ts b/src/app/features/registry/pages/recent-activity/registration-recent-activity.component.spec.ts index 6b02780a1..da87c2c59 100644 --- a/src/app/features/registry/pages/recent-activity/registration-recent-activity.component.spec.ts +++ b/src/app/features/registry/pages/recent-activity/registration-recent-activity.component.spec.ts @@ -1,4 +1,4 @@ -import { NgxsModule, Store } from '@ngxs/store'; +import { provideStore, Store } from '@ngxs/store'; import { ComponentFixture, TestBed } from '@angular/core/testing'; import { ActivatedRoute } from '@angular/router'; @@ -14,36 +14,84 @@ describe('RegistrationRecentActivityComponent', () => { beforeEach(async () => { await TestBed.configureTestingModule({ - imports: [NgxsModule.forRoot([ActivityLogsState]), RegistrationRecentActivityComponent], - providers: [{ provide: ActivatedRoute, useValue: { snapshot: { params: { id: 'reg123' } }, parent: null } }], + imports: [RegistrationRecentActivityComponent], + providers: [ + provideStore([ActivityLogsState]), + { provide: ActivatedRoute, useValue: { snapshot: { params: { id: 'reg123' } }, parent: null } }, + ], }).compileComponents(); store = TestBed.inject(Store); - spyOn(store, 'dispatch').and.callThrough(); + jest.spyOn(store, 'dispatch'); fixture = TestBed.createComponent(RegistrationRecentActivityComponent); fixture.detectChanges(); }); - it('creates and dispatches initial registration logs fetch', () => { - expect(fixture.componentInstance).toBeTruthy(); - expect(store.dispatch).toHaveBeenCalledWith(jasmine.any(GetRegistrationActivityLogs)); - const action = (store.dispatch as jasmine.Spy).calls.mostRecent().args[0] as GetRegistrationActivityLogs; + 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'); + expect(action.page).toBe(1); + }); + + it('renders empty state when no logs and not loading', () => { + store.reset({ + activityLogs: { + activityLogs: { data: [], isLoading: false, error: null, totalCount: 0 }, + }, + } as any); + fixture.detectChanges(); + + const empty = fixture.nativeElement.querySelector('[data-test="recent-activity-empty"]'); + expect(empty).toBeTruthy(); + }); + + it('renders item & paginator when logs exist and totalCount > pageSize', () => { + store.reset({ + activityLogs: { + activityLogs: { + data: [ + { + id: 'log1', + date: '2024-01-01T00:00:00Z', + formattedActivity: 'formatted', + }, + ], + isLoading: false, + error: null, + totalCount: 25, + }, + }, + } as any); + fixture.detectChanges(); + + 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"]'); + + expect(item).toBeTruthy(); + expect(content?.innerHTML).toContain('formatted'); + expect(paginator).toBeTruthy(); }); it('dispatches on page change', () => { - (store.dispatch as jasmine.Spy).calls.reset(); + const dispatchSpy = store.dispatch as jest.Mock; + dispatchSpy.mockClear(); + fixture.componentInstance.onPageChange({ page: 2 } as any); - expect(store.dispatch).toHaveBeenCalledWith(jasmine.any(GetRegistrationActivityLogs)); - const action = (store.dispatch as jasmine.Spy).calls.mostRecent().args[0] as GetRegistrationActivityLogs; - expect(action.page).toBe('3'); + expect(dispatchSpy).toHaveBeenCalledWith(expect.any(GetRegistrationActivityLogs)); + + const action = dispatchSpy.mock.calls.at(-1)?.[0] as GetRegistrationActivityLogs; + expect(action.page).toBe(3); }); it('clears store on destroy', () => { - (store.dispatch as jasmine.Spy).calls.reset(); + const dispatchSpy = store.dispatch as jest.Mock; + dispatchSpy.mockClear(); + fixture.destroy(); - expect(store.dispatch).toHaveBeenCalledWith(jasmine.any(ClearActivityLogsStore)); + expect(dispatchSpy).toHaveBeenCalledWith(expect.any(ClearActivityLogsStore)); }); }); diff --git a/src/app/features/registry/pages/recent-activity/registration-recent-activity.component.ts b/src/app/features/registry/pages/recent-activity/registration-recent-activity.component.ts index 1c81ca836..28137cc7c 100644 --- a/src/app/features/registry/pages/recent-activity/registration-recent-activity.component.ts +++ b/src/app/features/registry/pages/recent-activity/registration-recent-activity.component.ts @@ -3,37 +3,44 @@ 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 { ActivatedRoute } from '@angular/router'; import { CustomPaginatorComponent } from '@shared/components'; -import { ActivityLogDisplayService } from '@shared/services'; import { ActivityLogsSelectors, ClearActivityLogsStore, GetRegistrationActivityLogs, } from '@shared/stores/activity-logs'; +import { environment } from 'src/environments/environment'; + @Component({ selector: 'osf-registration-recent-activity', - imports: [TranslatePipe, Skeleton, DatePipe, CustomPaginatorComponent], + imports: [TranslatePipe, DatePipe, CustomPaginatorComponent], templateUrl: './registration-recent-activity.component.html', styleUrl: './registration-recent-activity.component.scss', changeDetection: ChangeDetectionStrategy.OnPush, }) export class RegistrationRecentActivityComponent implements OnDestroy { - private readonly activityLogDisplayService = inject(ActivityLogDisplayService); private readonly route = inject(ActivatedRoute); - readonly pageSize = 10; + private static readonly DEFAULT_PAGE_SIZE = 10; + readonly pageSize = + (environment as unknown as { activityLogs?: { pageSize?: number } })?.activityLogs?.pageSize ?? + RegistrationRecentActivityComponent.DEFAULT_PAGE_SIZE; + + private readonly registrationId: string = (this.route.snapshot.params['id'] ?? + this.route.parent?.snapshot.params['id']) as string; protected currentPage = signal(1); - protected activityLogs = select(ActivityLogsSelectors.getActivityLogs); + + protected formattedActivityLogs = select(ActivityLogsSelectors.getFormattedActivityLogs); protected totalCount = select(ActivityLogsSelectors.getActivityLogsTotalCount); protected isLoading = select(ActivityLogsSelectors.getActivityLogsLoading); + protected firstIndex = computed(() => (this.currentPage() - 1) * this.pageSize); protected actions = createDispatchMap({ @@ -41,29 +48,15 @@ export class RegistrationRecentActivityComponent implements OnDestroy { clearActivityLogsStore: ClearActivityLogsStore, }); - protected formattedActivityLogs = computed(() => { - const logs = this.activityLogs(); - return logs.map((log) => ({ - ...log, - formattedActivity: this.activityLogDisplayService.getActivityDisplay(log), - })); - }); - constructor() { - const registrationId = this.route.snapshot.params['id'] || this.route.parent?.snapshot.params['id']; - if (registrationId) { - this.actions.getRegistrationActivityLogs(registrationId, '1', String(this.pageSize)); - } + this.actions.getRegistrationActivityLogs(this.registrationId, 1, this.pageSize); } onPageChange(event: PaginatorState) { if (event.page !== undefined) { const pageNumber = event.page + 1; this.currentPage.set(pageNumber); - const registrationId = this.route.snapshot.params['id'] || this.route.parent?.snapshot.params['id']; - if (registrationId) { - this.actions.getRegistrationActivityLogs(registrationId, String(pageNumber), String(this.pageSize)); - } + this.actions.getRegistrationActivityLogs(this.registrationId, pageNumber, this.pageSize); } } diff --git a/src/app/features/registry/registry.routes.ts b/src/app/features/registry/registry.routes.ts index c4e3172d0..a5f4066ed 100644 --- a/src/app/features/registry/registry.routes.ts +++ b/src/app/features/registry/registry.routes.ts @@ -1,7 +1,9 @@ import { provideStates } from '@ngxs/store'; + import { Routes } from '@angular/router'; import { viewOnlyGuard } from '@osf/core/guards'; +import { requireRegistrationIdGuard } from '@osf/core/guards/require-registration-id.guard'; import { ResourceType } from '@osf/shared/enums'; import { LicensesService } from '@osf/shared/services'; import { @@ -115,6 +117,7 @@ export const registryRoutes: Routes = [ }, { path: 'recent-activity', + canActivate: [requireRegistrationIdGuard], loadComponent: () => import('./pages/recent-activity/registration-recent-activity.component').then( (c) => c.RegistrationRecentActivityComponent 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 new file mode 100644 index 000000000..daf20d28e --- /dev/null +++ b/src/app/shared/services/activity-logs/activity-logs.service.spec.ts @@ -0,0 +1,61 @@ +import { HttpTestingController } from '@angular/common/http/testing'; +import { inject, TestBed } from '@angular/core/testing'; + +import { ActivityLogDisplayService } from '@shared/services'; + +import { ActivityLogsService } from './activity-logs.service'; + +import { getActivityLogsResponse } from '@testing/data/activity-logs/activity-logs.data'; +import { OSFTestingStoreModule } from '@testing/osf.testing.module'; +import { environment } from 'src/environments/environment'; + +describe('Service: ActivityLogs', () => { + let service: ActivityLogsService; + + beforeEach(() => { + TestBed.configureTestingModule({ + imports: [OSFTestingStoreModule], + providers: [ + ActivityLogsService, + { provide: ActivityLogDisplayService, useValue: { getActivityDisplay: jest.fn().mockReturnValue('FMT') } }, + ], + }); + 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( + (r) => r.method === 'GET' && r.url === `${environment.apiUrl}/registrations/reg1/logs/` + ); + 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) => { + let result: any; + service.fetchLogs('proj1', 2, 5).subscribe((res) => (result = res)); + + const req = httpMock.expectOne((r) => r.method === 'GET' && r.url === `${environment.apiUrl}/nodes/proj1/logs/`); + expect(req.request.method).toBe('GET'); + expect(req.request.params.get('page')).toBe('2'); + expect(req.request.params.get('page[size]')).toBe('5'); + + req.flush(getActivityLogsResponse()); + + expect(result.data.length).toBe(2); + expect(result.data[1].formattedActivity).toBe('FMT'); + + httpMock.verify(); + })); +}); 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 a1596d58f..14ed7a69a 100644 --- a/src/app/shared/services/activity-logs/activity-logs.service.ts +++ b/src/app/shared/services/activity-logs/activity-logs.service.ts @@ -2,6 +2,7 @@ import { Observable } from 'rxjs'; import { map } from 'rxjs/operators'; import { inject, Injectable } from '@angular/core'; +import { SafeHtml } from '@angular/platform-browser'; import { ActivityLogsMapper } from '@shared/mappers/activity-logs.mapper'; import { @@ -11,18 +12,32 @@ import { MetaAnonymousJsonApi, PaginatedData, } from '@shared/models'; +import { ActivityLogDisplayService } from '@shared/services'; import { JsonApiService } from '@shared/services/json-api.service'; import { environment } from 'src/environments/environment'; +type ActivityLogWithDisplay = ActivityLog & { formattedActivity?: SafeHtml }; + @Injectable({ providedIn: 'root', }) export class ActivityLogsService { private jsonApiService = inject(JsonApiService); + private display = inject(ActivityLogDisplayService); private apiUrl = `${environment.apiDomainUrl}/v2`; - fetchLogs(projectId: string, page = '1', pageSize: string): Observable> { + private formatActivities(result: PaginatedData): PaginatedData { + return { + ...result, + data: result.data.map((log) => ({ + ...log, + formattedActivity: this.display.getActivityDisplay(log), + })), + }; + } + + fetchLogs(projectId: string, page: number = 1, pageSize: number): Observable> { const url = `${this.apiUrl}/nodes/${projectId}/logs/`; const params: Record = { 'embed[]': ['original_node', 'user', 'linked_node', 'linked_registration', 'template_node', 'group'], @@ -32,14 +47,17 @@ export class ActivityLogsService { return this.jsonApiService .get>(url, params) - .pipe(map((res) => ActivityLogsMapper.fromGetActivityLogsResponse(res))); + .pipe( + map((res) => ActivityLogsMapper.fromGetActivityLogsResponse(res)), + map((mapped) => this.formatActivities(mapped)) + ); } fetchRegistrationLogs( registrationId: string, - page = '1', - pageSize: string - ): Observable> { + page: number = 1, + pageSize: number + ): Observable> { const url = `${environment.apiUrl}/registrations/${registrationId}/logs/`; const params: Record = { 'embed[]': ['original_node', 'user', 'linked_node', 'linked_registration', 'template_node', 'group'], @@ -48,7 +66,10 @@ export class ActivityLogsService { }; return this.jsonApiService - .get>(url, params) - .pipe(map((res) => ActivityLogsMapper.fromGetActivityLogsResponse(res))); + .get>(url, params) + .pipe( + map((res) => ActivityLogsMapper.fromGetActivityLogsResponse(res)), + map((mapped) => this.formatActivities(mapped)) + ); } } 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 0ae1b1b4e..48197e5fc 100644 --- a/src/app/shared/stores/activity-logs/activity-logs.actions.ts +++ b/src/app/shared/stores/activity-logs/activity-logs.actions.ts @@ -3,8 +3,8 @@ export class GetActivityLogs { constructor( public projectId: string, - public page = '1', - public pageSize: string + public page = 1, + public pageSize: number ) {} } @@ -12,8 +12,8 @@ export class GetRegistrationActivityLogs { static readonly type = '[ActivityLogs] Get Registration Activity Logs'; constructor( public registrationId: string, - public page = '1', - public pageSize: string + public page = 1, + public pageSize: number ) {} } 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 61e746506..09175f23f 100644 --- a/src/app/shared/stores/activity-logs/activity-logs.selectors.ts +++ b/src/app/shared/stores/activity-logs/activity-logs.selectors.ts @@ -1,16 +1,25 @@ import { Selector } from '@ngxs/store'; +import { SafeHtml } from '@angular/platform-browser'; + import { ActivityLog } from '@shared/models'; import { ActivityLogsStateModel } from './activity-logs.model'; import { ActivityLogsState } from './activity-logs.state'; +type ActivityLogWithDisplay = ActivityLog & { formattedActivity?: SafeHtml }; + export class ActivityLogsSelectors { @Selector([ActivityLogsState]) static getActivityLogs(state: ActivityLogsStateModel): ActivityLog[] { return state.activityLogs.data; } + @Selector([ActivityLogsState]) + static getFormattedActivityLogs(state: ActivityLogsStateModel): ActivityLogWithDisplay[] { + return state.activityLogs.data as ActivityLogWithDisplay[]; + } + @Selector([ActivityLogsState]) static getActivityLogsTotalCount(state: ActivityLogsStateModel): number { return state.activityLogs.totalCount; 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 new file mode 100644 index 000000000..a16139352 --- /dev/null +++ b/src/app/shared/stores/activity-logs/activity-logs.state.spec.ts @@ -0,0 +1,75 @@ +import { provideStore, Store } from '@ngxs/store'; + +import { provideHttpClient } from '@angular/common/http'; +import { HttpTestingController, provideHttpClientTesting } from '@angular/common/http/testing'; +import { inject, TestBed } from '@angular/core/testing'; + +import { ActivityLogDisplayService } from '@shared/services'; + +import { ClearActivityLogsStore, GetRegistrationActivityLogs } from './activity-logs.actions'; +import { ActivityLogsState } from './activity-logs.state'; + +import { getActivityLogsResponse } from '@testing/data/activity-logs/activity-logs.data'; +import { environment } from 'src/environments/environment'; + +describe('State: ActivityLogs', () => { + let store: Store; + + beforeEach(() => { + TestBed.configureTestingModule({ + providers: [ + provideStore([ActivityLogsState]), + provideHttpClient(), + provideHttpClientTesting(), + { + provide: ActivityLogDisplayService, + useValue: { getActivityDisplay: jest.fn().mockReturnValue('formatted') }, + }, + ], + }); + + store = TestBed.inject(Store); + }); + + it('loads registration logs and formats activities', inject( + [HttpTestingController], + (httpMock: HttpTestingController) => { + let snapshot: any; + + store.dispatch(new GetRegistrationActivityLogs('reg123', 1, 10)).subscribe(() => { + snapshot = store.snapshot().activityLogs.activityLogs; + }); + + // loading true + expect(store.selectSnapshot((s: any) => s.activityLogs.activityLogs.isLoading)).toBe(true); + + const req = httpMock.expectOne( + (r) => r.method === 'GET' && r.url === `${environment.apiUrl}/registrations/reg123/logs/` + ); + 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(snapshot.isLoading).toBe(false); + expect(snapshot.totalCount).toBe(2); + expect(snapshot.data[0].formattedActivity).toContain('formatted'); + + httpMock.verify(); + } + )); + + it('clears store', () => { + store.reset({ + activityLogs: { + activityLogs: { data: [{ id: 'x' }], isLoading: false, error: null, totalCount: 1 }, + }, + } as any); + + store.dispatch(new ClearActivityLogsStore()); + 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 745cfc308..b0ad3b05d 100644 --- a/src/app/shared/stores/activity-logs/activity-logs.state.ts +++ b/src/app/shared/stores/activity-logs/activity-logs.state.ts @@ -20,23 +20,13 @@ export class ActivityLogsState { @Action(GetActivityLogs) getActivityLogs(ctx: StateContext, action: GetActivityLogs) { ctx.patchState({ - activityLogs: { - data: [], - isLoading: true, - error: null, - totalCount: 0, - }, + 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, - }, + activityLogs: { data: res.data, isLoading: false, error: null, totalCount: res.totalCount }, }); }) ); diff --git a/src/testing/data/activity-logs/activity-logs.data.ts b/src/testing/data/activity-logs/activity-logs.data.ts new file mode 100644 index 000000000..3966c4f55 --- /dev/null +++ b/src/testing/data/activity-logs/activity-logs.data.ts @@ -0,0 +1,33 @@ +import structuredClone from 'structured-clone'; + +export function getActivityLogsResponse() { + return structuredClone({ + data: [ + { + id: 'log1', + type: 'logs', + attributes: { + action: 'update', + date: '2024-01-01T00:00:00Z', + params: {}, + }, + embeds: {}, + }, + { + id: 'log2', + type: 'logs', + attributes: { + action: 'create', + date: '2024-01-02T00:00:00Z', + params: {}, + }, + embeds: {}, + }, + ], + meta: { + total: 2, + anonymous: false, + }, + included: null, + }); +} From 03215af4b5fb671623b44acbbf8b7074c78b3a6d Mon Sep 17 00:00:00 2001 From: Andriy Sheredko Date: Wed, 10 Sep 2025 16:56:56 +0300 Subject: [PATCH 3/4] feat(registration-recent-activity): second round of code fixes regarding review comments --- src/app/core/constants/environment.token.ts | 5 +- .../guards/require-registration-id.guard.ts | 9 -- .../recent-activity.component.spec.ts | 82 +++++++++++++- .../project-overview.component.spec.ts | 55 ++++++++-- ...egistration-recent-activity.component.html | 10 +- ...egistration-recent-activity.component.scss | 0 ...stration-recent-activity.component.spec.ts | 101 +++++++++++++++++- .../registration-recent-activity.component.ts | 13 ++- src/app/features/registry/registry.routes.ts | 2 - .../google-file-picker.component.ts | 21 +++- src/app/shared/constants/activity-logs.ts | 1 + .../activity-log-with-display.model.ts | 6 ++ .../activity-logs/activity-logs.model.ts | 54 ++-------- src/app/shared/models/activity-logs/index.ts | 1 + src/app/shared/models/environment.model.ts | 27 +++++ .../activity-logs.service.spec.ts | 42 ++++++-- .../activity-logs/activity-logs.service.ts | 11 +- .../activity-logs/activity-logs.model.ts | 11 +- .../activity-logs.selectors.spec.ts | 34 ++++++ .../activity-logs/activity-logs.selectors.ts | 9 +- .../activity-logs/activity-logs.state.spec.ts | 81 ++++++++++++-- .../activity-logs/activity-logs.state.ts | 14 ++- src/environments/environment.local.ts | 6 ++ .../data/activity-logs/activity-logs.data.ts | 72 +++++++++---- 24 files changed, 529 insertions(+), 138 deletions(-) delete mode 100644 src/app/core/guards/require-registration-id.guard.ts delete mode 100644 src/app/features/registry/pages/recent-activity/registration-recent-activity.component.scss create mode 100644 src/app/shared/constants/activity-logs.ts create mode 100644 src/app/shared/models/activity-logs/activity-log-with-display.model.ts create mode 100644 src/app/shared/models/environment.model.ts create mode 100644 src/app/shared/stores/activity-logs/activity-logs.selectors.spec.ts diff --git a/src/app/core/constants/environment.token.ts b/src/app/core/constants/environment.token.ts index 50b782688..e48a698a4 100644 --- a/src/app/core/constants/environment.token.ts +++ b/src/app/core/constants/environment.token.ts @@ -1,8 +1,9 @@ import { InjectionToken } from '@angular/core'; import { environment } from 'src/environments/environment'; +import { AppEnvironment } from '@shared/models/environment.model'; -export const ENVIRONMENT = new InjectionToken('App Environment', { +export const ENVIRONMENT = new InjectionToken('App Environment', { providedIn: 'root', - factory: () => environment, + factory: () => environment as AppEnvironment, }); diff --git a/src/app/core/guards/require-registration-id.guard.ts b/src/app/core/guards/require-registration-id.guard.ts deleted file mode 100644 index 33ba338c9..000000000 --- a/src/app/core/guards/require-registration-id.guard.ts +++ /dev/null @@ -1,9 +0,0 @@ -import { inject } from '@angular/core'; -import { CanActivateFn, Router, UrlTree } from '@angular/router'; - -export const requireRegistrationIdGuard: CanActivateFn = (route): boolean | UrlTree => { - const id = route.paramMap.get('id'); - if (id) return true; - - return inject(Router).parseUrl('/registries/discover'); -}; 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 index a80aaae24..2e039f61c 100644 --- 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 @@ -1,22 +1,98 @@ import { ComponentFixture, TestBed } from '@angular/core/testing'; +import { provideStore, Store } from '@ngxs/store'; +import { ActivatedRoute } from '@angular/router'; + +import { provideHttpClient, withInterceptorsFromDi } from '@angular/common/http'; +import { provideHttpClientTesting } from '@angular/common/http/testing'; + +import { TranslateService } from '@ngx-translate/core'; +import { of } from 'rxjs'; import { RecentActivityComponent } from './recent-activity.component'; +import { ActivityLogDisplayService } from '@shared/services'; +import { GetActivityLogs } from '@shared/stores/activity-logs'; +import { ActivityLogsState } from '@shared/stores/activity-logs/activity-logs.state'; describe('RecentActivityComponent', () => { - let component: RecentActivityComponent; let fixture: ComponentFixture; + let store: Store; beforeEach(async () => { await TestBed.configureTestingModule({ imports: [RecentActivityComponent], + 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); - component = fixture.componentInstance; + fixture.componentRef.setInput('pageSize', 10); fixture.detectChanges(); }); it('should create', () => { - expect(component).toBeTruthy(); + 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/project-overview.component.spec.ts b/src/app/features/project/overview/project-overview.component.spec.ts index 96a420dba..df6706e89 100644 --- a/src/app/features/project/overview/project-overview.component.spec.ts +++ b/src/app/features/project/overview/project-overview.component.spec.ts @@ -1,26 +1,69 @@ -import { MockComponent } from 'ng-mocks'; - import { ComponentFixture, TestBed } from '@angular/core/testing'; +import { RouterTestingModule } from '@angular/router/testing'; +import { provideStore, Store } from '@ngxs/store'; +import { ActivatedRoute } from '@angular/router'; +import { of } from 'rxjs'; -import { SubHeaderComponent } from '@osf/shared/components'; +import { provideHttpClient, withInterceptorsFromDi } from '@angular/common/http'; +import { provideHttpClientTesting } from '@angular/common/http/testing'; import { ProjectOverviewComponent } from './project-overview.component'; +import { GetActivityLogs } from '@shared/stores/activity-logs'; + +import { DataciteService } from '@osf/shared/services'; +import { DialogService } from 'primeng/dynamicdialog'; +import { TranslateService } from '@ngx-translate/core'; +import { ToastService } from '@osf/shared/services'; describe('ProjectOverviewComponent', () => { - let component: ProjectOverviewComponent; let fixture: ComponentFixture; + let component: ProjectOverviewComponent; + let store: Store; beforeEach(async () => { + TestBed.overrideComponent(ProjectOverviewComponent, { set: { template: '' } }); + await TestBed.configureTestingModule({ - imports: [ProjectOverviewComponent, MockComponent(SubHeaderComponent)], + imports: [ProjectOverviewComponent, RouterTestingModule], + providers: [ + provideStore([]), + + { provide: ActivatedRoute, useValue: { snapshot: { params: { id: 'proj123' } }, parent: null } }, + + provideHttpClient(withInterceptorsFromDi()), + provideHttpClientTesting(), + + { provide: DataciteService, useValue: {} }, + { provide: DialogService, useValue: { open: () => ({ onClose: of(null) }) } }, + { provide: TranslateService, useValue: { instant: (k: string) => k } }, + { provide: ToastService, useValue: { showSuccess: jest.fn() } }, + ], }).compileComponents(); + store = TestBed.inject(Store); fixture = TestBed.createComponent(ProjectOverviewComponent); component = fixture.componentInstance; - fixture.detectChanges(); }); it('should create', () => { expect(component).toBeTruthy(); }); + + it('dispatches GetActivityLogs with numeric page and pageSize on init', () => { + const dispatchSpy = jest.spyOn(store, 'dispatch'); + + jest.spyOn(component as any, 'setupDataciteViewTrackerEffect').mockReturnValue(of(null)); + + component.ngOnInit(); + + const actions = dispatchSpy.mock.calls.map((c) => c[0]); + const activityAction = actions.find((a) => a instanceof GetActivityLogs) as GetActivityLogs; + + expect(activityAction).toBeDefined(); + expect(activityAction.projectId).toBe('proj123'); + expect(activityAction.page).toBe(1); + expect(activityAction.pageSize).toBe(5); + expect(typeof activityAction.page).toBe('number'); + expect(typeof activityAction.pageSize).toBe('number'); + }); }); diff --git a/src/app/features/registry/pages/recent-activity/registration-recent-activity.component.html b/src/app/features/registry/pages/recent-activity/registration-recent-activity.component.html index 20d43567d..168513636 100644 --- a/src/app/features/registry/pages/recent-activity/registration-recent-activity.component.html +++ b/src/app/features/registry/pages/recent-activity/registration-recent-activity.component.html @@ -7,7 +7,7 @@

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

- @defer (when !isLoading()) { + @if (!isLoading()) {
@for (activityLog of formattedActivityLogs(); track activityLog.id) {
(pageChanged)="onPageChange($event)" /> } + } @else { +
+ + + + + +
}
diff --git a/src/app/features/registry/pages/recent-activity/registration-recent-activity.component.scss b/src/app/features/registry/pages/recent-activity/registration-recent-activity.component.scss deleted file mode 100644 index e69de29bb..000000000 diff --git a/src/app/features/registry/pages/recent-activity/registration-recent-activity.component.spec.ts b/src/app/features/registry/pages/recent-activity/registration-recent-activity.component.spec.ts index da87c2c59..708717eb7 100644 --- a/src/app/features/registry/pages/recent-activity/registration-recent-activity.component.spec.ts +++ b/src/app/features/registry/pages/recent-activity/registration-recent-activity.component.spec.ts @@ -1,3 +1,9 @@ +import { provideHttpClient, withInterceptorsFromDi } from '@angular/common/http'; +import { provideHttpClientTesting } from '@angular/common/http/testing'; +import { TranslateService } from '@ngx-translate/core'; +import { of } from 'rxjs'; +import { ActivityLogDisplayService } from '@shared/services'; + import { provideStore, Store } from '@ngxs/store'; import { ComponentFixture, TestBed } from '@angular/core/testing'; @@ -17,6 +23,27 @@ describe('RegistrationRecentActivityComponent', () => { imports: [RegistrationRecentActivityComponent], providers: [ provideStore([ActivityLogsState]), + + provideHttpClient(withInterceptorsFromDi()), + provideHttpClientTesting(), + + { + provide: TranslateService, + useValue: { + instant: (k: string) => k, + get: () => of(''), + stream: () => of(''), + onLangChange: of({}), + onDefaultLangChange: of({}), + onTranslationChange: of({}), + }, + }, + + { + provide: ActivityLogDisplayService, + useValue: { getActivityDisplay: jest.fn(() => 'formatted') }, + }, + { provide: ActivatedRoute, useValue: { snapshot: { params: { id: 'reg123' } }, parent: null } }, ], }).compileComponents(); @@ -55,7 +82,7 @@ describe('RegistrationRecentActivityComponent', () => { data: [ { id: 'log1', - date: '2024-01-01T00:00:00Z', + date: '2024-01-01T12:34:00Z', formattedActivity: 'formatted', }, ], @@ -70,10 +97,29 @@ describe('RegistrationRecentActivityComponent', () => { 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 ?? ''; 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/); + }); + + 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); + fixture.detectChanges(); + + const paginator = fixture.nativeElement.querySelector('[data-test="recent-activity-paginator"]'); + expect(paginator).toBeFalsy(); }); it('dispatches on page change', () => { @@ -87,6 +133,20 @@ describe('RegistrationRecentActivityComponent', () => { expect(action.page).toBe(3); }); + it('does not dispatch when page change event has undefined page', () => { + const dispatchSpy = store.dispatch as jest.Mock; + dispatchSpy.mockClear(); + + fixture.componentInstance.onPageChange({} as any); + expect(dispatchSpy).not.toHaveBeenCalled(); + }); + + 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('clears store on destroy', () => { const dispatchSpy = store.dispatch as jest.Mock; dispatchSpy.mockClear(); @@ -94,4 +154,43 @@ describe('RegistrationRecentActivityComponent', () => { fixture.destroy(); expect(dispatchSpy).toHaveBeenCalledWith(expect.any(ClearActivityLogsStore)); }); + + it('shows skeleton while loading', () => { + store.reset({ + activityLogs: { + activityLogs: { data: [], isLoading: true, error: null, totalCount: 0 }, + }, + } as any); + + 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(); + }); + + 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, + }, + }, + } as any); + 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(region).toBeTruthy(); + expect(region.getAttribute('aria-labelledby')).toBe('recent-activity-title'); + expect(heading).toBeTruthy(); + expect(list).toBeTruthy(); + expect(listitem).toBeTruthy(); + }); }); diff --git a/src/app/features/registry/pages/recent-activity/registration-recent-activity.component.ts b/src/app/features/registry/pages/recent-activity/registration-recent-activity.component.ts index 28137cc7c..2c46ce334 100644 --- a/src/app/features/registry/pages/recent-activity/registration-recent-activity.component.ts +++ b/src/app/features/registry/pages/recent-activity/registration-recent-activity.component.ts @@ -5,6 +5,7 @@ import { TranslatePipe } from '@ngx-translate/core'; import { PaginatorState } from 'primeng/paginator'; import { DatePipe } from '@angular/common'; +import { Skeleton } from 'primeng/skeleton'; import { ChangeDetectionStrategy, Component, computed, inject, OnDestroy, signal } from '@angular/core'; import { ActivatedRoute } from '@angular/router'; @@ -15,22 +16,20 @@ import { GetRegistrationActivityLogs, } from '@shared/stores/activity-logs'; -import { environment } from 'src/environments/environment'; +import { ENVIRONMENT } from '@core/constants/environment.token'; +import { ACTIVITY_LOGS_DEFAULT_PAGE_SIZE } from '@shared/constants/activity-logs'; @Component({ selector: 'osf-registration-recent-activity', - imports: [TranslatePipe, DatePipe, CustomPaginatorComponent], + imports: [TranslatePipe, DatePipe, CustomPaginatorComponent, Skeleton], templateUrl: './registration-recent-activity.component.html', - styleUrl: './registration-recent-activity.component.scss', changeDetection: ChangeDetectionStrategy.OnPush, }) export class RegistrationRecentActivityComponent implements OnDestroy { private readonly route = inject(ActivatedRoute); + readonly #environment = inject(ENVIRONMENT); - private static readonly DEFAULT_PAGE_SIZE = 10; - readonly pageSize = - (environment as unknown as { activityLogs?: { pageSize?: number } })?.activityLogs?.pageSize ?? - RegistrationRecentActivityComponent.DEFAULT_PAGE_SIZE; + readonly pageSize = this.#environment.activityLogs?.pageSize ?? ACTIVITY_LOGS_DEFAULT_PAGE_SIZE; private readonly registrationId: string = (this.route.snapshot.params['id'] ?? this.route.parent?.snapshot.params['id']) as string; diff --git a/src/app/features/registry/registry.routes.ts b/src/app/features/registry/registry.routes.ts index a5f4066ed..49f129e81 100644 --- a/src/app/features/registry/registry.routes.ts +++ b/src/app/features/registry/registry.routes.ts @@ -3,7 +3,6 @@ import { provideStates } from '@ngxs/store'; import { Routes } from '@angular/router'; import { viewOnlyGuard } from '@osf/core/guards'; -import { requireRegistrationIdGuard } from '@osf/core/guards/require-registration-id.guard'; import { ResourceType } from '@osf/shared/enums'; import { LicensesService } from '@osf/shared/services'; import { @@ -117,7 +116,6 @@ export const registryRoutes: Routes = [ }, { path: 'recent-activity', - canActivate: [requireRegistrationIdGuard], loadComponent: () => import('./pages/recent-activity/registration-recent-activity.component').then( (c) => c.RegistrationRecentActivityComponent diff --git a/src/app/shared/components/addons/storage-item-selector/google-file-picker/google-file-picker.component.ts b/src/app/shared/components/addons/storage-item-selector/google-file-picker/google-file-picker.component.ts index 70333fcb7..9825b30ec 100644 --- a/src/app/shared/components/addons/storage-item-selector/google-file-picker/google-file-picker.component.ts +++ b/src/app/shared/components/addons/storage-item-selector/google-file-picker/google-file-picker.component.ts @@ -35,14 +35,24 @@ export class GoogleFilePickerComponent implements OnInit { public accessToken = signal(null); public visible = signal(false); public isGFPDisabled = signal(true); - private readonly apiKey = this.#environment.google.GOOGLE_FILE_PICKER_API_KEY; - private readonly appId = this.#environment.google.GOOGLE_FILE_PICKER_APP_ID; + private readonly apiKey = this.#environment.google?.GOOGLE_FILE_PICKER_API_KEY ?? ''; + private readonly appId = this.#environment.google?.GOOGLE_FILE_PICKER_APP_ID ?? 0; + private readonly store = inject(Store); private parentId = ''; private isMultipleSelect!: boolean; private title!: string; + private get isPickerConfigured() { + return !!this.apiKey && !!this.appId; + } + ngOnInit(): void { + if (!this.isPickerConfigured) { + this.isGFPDisabled.set(true); + return; + } + this.parentId = this.isFolderPicker() ? '' : this.rootFolder()?.itemId || ''; this.title = this.isFolderPicker() ? this.#translateService.instant('settings.addons.configureAddon.google-file-picker.root-folder-title') @@ -72,7 +82,8 @@ export class GoogleFilePickerComponent implements OnInit { } public createPicker(): void { - const google = window.google; + if (!this.isPickerConfigured) return; + const google = (window as any).google; const googlePickerView = new google.picker.DocsView(google.picker.ViewId.DOCS); googlePickerView.setSelectFolderEnabled(true); @@ -105,7 +116,7 @@ export class GoogleFilePickerComponent implements OnInit { this.accessToken.set( this.store.selectSnapshot(AddonsSelectors.getAuthorizedStorageAddonOauthToken(this.accountId())) ); - this.isGFPDisabled.set(this.accessToken() ? false : true); + this.isGFPDisabled.set(!this.accessToken()); }, }); } @@ -121,7 +132,7 @@ export class GoogleFilePickerComponent implements OnInit { } pickerCallback(data: GoogleFilePickerModel) { - if (data.action === window.google.picker.Action.PICKED) { + if (data.action === (window as any).google.picker.Action.PICKED) { this.#filePickerCallback(data.docs[0]); } } diff --git a/src/app/shared/constants/activity-logs.ts b/src/app/shared/constants/activity-logs.ts new file mode 100644 index 000000000..b07e21473 --- /dev/null +++ b/src/app/shared/constants/activity-logs.ts @@ -0,0 +1 @@ +export const ACTIVITY_LOGS_DEFAULT_PAGE_SIZE = 10; diff --git a/src/app/shared/models/activity-logs/activity-log-with-display.model.ts b/src/app/shared/models/activity-logs/activity-log-with-display.model.ts new file mode 100644 index 000000000..c3d4dca87 --- /dev/null +++ b/src/app/shared/models/activity-logs/activity-log-with-display.model.ts @@ -0,0 +1,6 @@ +import { SafeHtml } from '@angular/platform-browser'; +import { ActivityLog } from './activity-logs.model'; + +export interface ActivityLogWithDisplay extends ActivityLog { + formattedActivity?: SafeHtml; +} diff --git a/src/app/shared/models/activity-logs/activity-logs.model.ts b/src/app/shared/models/activity-logs/activity-logs.model.ts index 039643f32..b5e47585d 100644 --- a/src/app/shared/models/activity-logs/activity-logs.model.ts +++ b/src/app/shared/models/activity-logs/activity-logs.model.ts @@ -5,68 +5,36 @@ export interface ActivityLog { type: string; action: string; date: string; + params: { contributors: LogContributor[]; license?: string; tag?: string; - institution?: { - id: string; - name: string; - }; - paramsNode: { - id: string; - title: string; - }; + institution?: { id: string; name: string }; + paramsNode: { id: string; title: string }; paramsProject: null; pointer: Pointer | null; preprintProvider?: | string - | { - url: string; - name: string; - } + | { url: string; name: string } | null; addon?: string; anonymousLink?: boolean; - file?: { - name: string; - url: string; - }; - wiki?: { - name: string; - url: string; - }; - destination?: { - materialized: string; - addon: string; - url: string; - }; - identifiers?: { - doi?: string; - ark?: string; - }; + file?: { name: string; url: string }; + wiki?: { name: string; url: string }; + destination?: { materialized: string; addon: string; url: string }; + identifiers?: { doi?: string; ark?: string }; kind?: string; oldPage?: string; page?: string; pageId?: string; path?: string; - urls?: { - view: string; - }; + urls?: { view: string }; preprint?: string; - source?: { - materialized: string; - addon: string; - }; + source?: { materialized: string; addon: string }; titleNew?: string; titleOriginal?: string; - updatedFields?: Record< - string, - { - new: string; - old: string; - } - >; + updatedFields?: Record; value?: string; version?: string; githubUser?: string; diff --git a/src/app/shared/models/activity-logs/index.ts b/src/app/shared/models/activity-logs/index.ts index c84f515ab..54ba32573 100644 --- a/src/app/shared/models/activity-logs/index.ts +++ b/src/app/shared/models/activity-logs/index.ts @@ -1,2 +1,3 @@ export * from './activity-logs.model'; export * from './activity-logs-json-api.model'; +export * from './activity-log-with-display.model'; diff --git a/src/app/shared/models/environment.model.ts b/src/app/shared/models/environment.model.ts new file mode 100644 index 000000000..7671308db --- /dev/null +++ b/src/app/shared/models/environment.model.ts @@ -0,0 +1,27 @@ +export interface AppEnvironment { + production: boolean; + webUrl: string; + apiDomainUrl: string; + shareTroveUrl: string; + addonsApiUrl: string; + fileApiUrl: string; + funderApiUrl: string; + casUrl: string; + recaptchaSiteKey: string; + twitterHandle: string; + facebookAppId: string; + supportEmail: string; + defaultProvider: string; + dataciteTrackerRepoId: string | null; + dataciteTrackerAddress: string; + + google?: { + GOOGLE_FILE_PICKER_CLIENT_ID: string; + GOOGLE_FILE_PICKER_API_KEY: string; + GOOGLE_FILE_PICKER_APP_ID: number; + }; + + activityLogs?: { + pageSize?: number; + }; +} 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 daf20d28e..65520be4f 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 @@ -2,12 +2,14 @@ import { HttpTestingController } from '@angular/common/http/testing'; import { inject, TestBed } from '@angular/core/testing'; import { ActivityLogDisplayService } from '@shared/services'; - import { ActivityLogsService } from './activity-logs.service'; -import { getActivityLogsResponse } from '@testing/data/activity-logs/activity-logs.data'; +import { + getActivityLogsResponse, + buildRegistrationLogsUrl, + buildNodeLogsUrl, +} from '@testing/data/activity-logs/activity-logs.data'; import { OSFTestingStoreModule } from '@testing/osf.testing.module'; -import { environment } from 'src/environments/environment'; describe('Service: ActivityLogs', () => { let service: ActivityLogsService; @@ -27,9 +29,7 @@ describe('Service: ActivityLogs', () => { let result: any; service.fetchRegistrationLogs('reg1', 1, 10).subscribe((res) => (result = res)); - const req = httpMock.expectOne( - (r) => r.method === 'GET' && r.url === `${environment.apiUrl}/registrations/reg1/logs/` - ); + const req = httpMock.expectOne(buildRegistrationLogsUrl('reg1', 1, 10)); expect(req.request.method).toBe('GET'); expect(req.request.params.get('page')).toBe('1'); expect(req.request.params.get('page[size]')).toBe('10'); @@ -46,7 +46,7 @@ describe('Service: ActivityLogs', () => { let result: any; service.fetchLogs('proj1', 2, 5).subscribe((res) => (result = res)); - const req = httpMock.expectOne((r) => r.method === 'GET' && r.url === `${environment.apiUrl}/nodes/proj1/logs/`); + const req = httpMock.expectOne(buildNodeLogsUrl('proj1', 2, 5)); expect(req.request.method).toBe('GET'); expect(req.request.params.get('page')).toBe('2'); expect(req.request.params.get('page[size]')).toBe('5'); @@ -58,4 +58,32 @@ describe('Service: ActivityLogs', () => { httpMock.verify(); })); + + it('fetchRegistrationLogs propagates error', inject([HttpTestingController], (httpMock: HttpTestingController) => { + let errorObj: any; + service.fetchRegistrationLogs('reg2', 1, 10).subscribe({ + next: () => {}, + error: (e) => (errorObj = e), + }); + + const req = httpMock.expectOne(buildRegistrationLogsUrl('reg2', 1, 10)); + 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 req = httpMock.expectOne(buildNodeLogsUrl('proj500', 1, 10)); + req.flush({ errors: [{ detail: 'boom' }] }, { status: 500, statusText: 'Server Error' }); + + expect(errorObj).toBeTruthy(); + httpMock.verify(); + })); }); 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 14ed7a69a..71e0a07aa 100644 --- a/src/app/shared/services/activity-logs/activity-logs.service.ts +++ b/src/app/shared/services/activity-logs/activity-logs.service.ts @@ -1,8 +1,6 @@ import { Observable } from 'rxjs'; import { map } from 'rxjs/operators'; - import { inject, Injectable } from '@angular/core'; -import { SafeHtml } from '@angular/platform-browser'; import { ActivityLogsMapper } from '@shared/mappers/activity-logs.mapper'; import { @@ -12,16 +10,13 @@ import { MetaAnonymousJsonApi, PaginatedData, } from '@shared/models'; +import { ActivityLogWithDisplay } from '@shared/models/activity-logs/activity-log-with-display.model'; import { ActivityLogDisplayService } from '@shared/services'; import { JsonApiService } from '@shared/services/json-api.service'; import { environment } from 'src/environments/environment'; -type ActivityLogWithDisplay = ActivityLog & { formattedActivity?: SafeHtml }; - -@Injectable({ - providedIn: 'root', -}) +@Injectable({ providedIn: 'root' }) export class ActivityLogsService { private jsonApiService = inject(JsonApiService); private display = inject(ActivityLogDisplayService); @@ -58,7 +53,7 @@ export class ActivityLogsService { page: number = 1, pageSize: number ): Observable> { - const url = `${environment.apiUrl}/registrations/${registrationId}/logs/`; + const url = `${this.apiUrl}/registrations/${registrationId}/logs/`; const params: Record = { 'embed[]': ['original_node', 'user', 'linked_node', 'linked_registration', 'template_node', 'group'], page, diff --git a/src/app/shared/stores/activity-logs/activity-logs.model.ts b/src/app/shared/stores/activity-logs/activity-logs.model.ts index 23c480ff0..75c2b67fa 100644 --- a/src/app/shared/stores/activity-logs/activity-logs.model.ts +++ b/src/app/shared/stores/activity-logs/activity-logs.model.ts @@ -1,7 +1,14 @@ -import { ActivityLog, AsyncStateWithTotalCount } from '@shared/models'; +import { ActivityLogWithDisplay } from '@shared/models/activity-logs/activity-log-with-display.model'; + +export interface ActivityLogsSlice { + data: T; + isLoading: boolean; + error: unknown | null; + totalCount: number; +} export interface ActivityLogsStateModel { - activityLogs: AsyncStateWithTotalCount; + activityLogs: ActivityLogsSlice; } export const ACTIVITY_LOGS_STATE_DEFAULT: ActivityLogsStateModel = { 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 new file mode 100644 index 000000000..68771041a --- /dev/null +++ b/src/app/shared/stores/activity-logs/activity-logs.selectors.spec.ts @@ -0,0 +1,34 @@ +import { TestBed } from '@angular/core/testing'; +import { provideStore, Store } from '@ngxs/store'; + +import { ActivityLogsState } from './activity-logs.state'; +import { ActivityLogsSelectors } from './activity-logs.selectors'; + +describe('ActivityLogsSelectors', () => { + let store: Store; + + beforeEach(() => { + TestBed.configureTestingModule({ + providers: [provideStore([ActivityLogsState])], + }); + store = TestBed.inject(Store); + }); + + it('selects logs, formatted logs, totalCount, and loading', () => { + store.reset({ + activityLogs: { + activityLogs: { + data: [{ id: '1', date: '2024-01-01T00:00:00Z', formattedActivity: 'FMT' }], + isLoading: false, + error: null, + totalCount: 1, + }, + }, + } 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); + expect(store.selectSnapshot(ActivityLogsSelectors.getActivityLogsLoading)).toBe(false); + }); +}); 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 09175f23f..5679474a8 100644 --- a/src/app/shared/stores/activity-logs/activity-logs.selectors.ts +++ b/src/app/shared/stores/activity-logs/activity-logs.selectors.ts @@ -1,14 +1,9 @@ import { Selector } from '@ngxs/store'; - -import { SafeHtml } from '@angular/platform-browser'; - -import { ActivityLog } from '@shared/models'; +import { ActivityLog, ActivityLogWithDisplay } from '@shared/models/activity-logs'; import { ActivityLogsStateModel } from './activity-logs.model'; import { ActivityLogsState } from './activity-logs.state'; -type ActivityLogWithDisplay = ActivityLog & { formattedActivity?: SafeHtml }; - export class ActivityLogsSelectors { @Selector([ActivityLogsState]) static getActivityLogs(state: ActivityLogsStateModel): ActivityLog[] { @@ -17,7 +12,7 @@ export class ActivityLogsSelectors { @Selector([ActivityLogsState]) static getFormattedActivityLogs(state: ActivityLogsStateModel): ActivityLogWithDisplay[] { - return state.activityLogs.data as ActivityLogWithDisplay[]; + return state.activityLogs.data; } @Selector([ActivityLogsState]) 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 a16139352..b6c4b5c7b 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 @@ -1,16 +1,18 @@ import { provideStore, Store } from '@ngxs/store'; - -import { provideHttpClient } from '@angular/common/http'; import { HttpTestingController, provideHttpClientTesting } from '@angular/common/http/testing'; import { inject, TestBed } from '@angular/core/testing'; +import { provideHttpClient } from '@angular/common/http'; import { ActivityLogDisplayService } from '@shared/services'; -import { ClearActivityLogsStore, GetRegistrationActivityLogs } from './activity-logs.actions'; +import { ClearActivityLogsStore, GetActivityLogs, GetRegistrationActivityLogs } from './activity-logs.actions'; import { ActivityLogsState } from './activity-logs.state'; -import { getActivityLogsResponse } from '@testing/data/activity-logs/activity-logs.data'; -import { environment } from 'src/environments/environment'; +import { + getActivityLogsResponse, + buildRegistrationLogsUrl, + buildNodeLogsUrl, +} from '@testing/data/activity-logs/activity-logs.data'; describe('State: ActivityLogs', () => { let store: Store; @@ -40,15 +42,10 @@ describe('State: ActivityLogs', () => { snapshot = store.snapshot().activityLogs.activityLogs; }); - // loading true expect(store.selectSnapshot((s: any) => s.activityLogs.activityLogs.isLoading)).toBe(true); - const req = httpMock.expectOne( - (r) => r.method === 'GET' && r.url === `${environment.apiUrl}/registrations/reg123/logs/` - ); + const req = httpMock.expectOne(buildRegistrationLogsUrl('reg123', 1, 10)); 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()); @@ -60,6 +57,68 @@ describe('State: ActivityLogs', () => { } )); + it('handles error when loading registration logs', inject( + [HttpTestingController], + (httpMock: HttpTestingController) => { + store.dispatch(new GetRegistrationActivityLogs('reg500', 1, 10)).subscribe(); + + // loading true + expect(store.selectSnapshot((s: any) => s.activityLogs.activityLogs.isLoading)).toBe(true); + + const req = httpMock.expectOne(buildRegistrationLogsUrl('reg500', 1, 10)); + req.flush({ errors: [{ detail: 'boom' }] }, { status: 500, statusText: 'Server Error' }); + + const snap = store.snapshot().activityLogs.activityLogs; + expect(snap.isLoading).toBe(false); + expect(snap.error).toBeTruthy(); + expect(snap.totalCount).toBe(0); + + httpMock.verify(); + } + )); + + it('loads project logs (nodes) and formats activities', inject( + [HttpTestingController], + (httpMock: HttpTestingController) => { + let snapshot: any; + store.dispatch(new GetActivityLogs('proj123', 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)); + expect(req.request.method).toBe('GET'); + + req.flush(getActivityLogsResponse()); + + expect(snapshot.isLoading).toBe(false); + expect(snapshot.totalCount).toBe(2); + expect(snapshot.data[1].formattedActivity).toContain('formatted'); + + httpMock.verify(); + } + )); + + it('handles error when loading project logs (nodes)', inject( + [HttpTestingController], + (httpMock: HttpTestingController) => { + store.dispatch(new GetActivityLogs('proj500', 1, 10)).subscribe(); + + expect(store.selectSnapshot((s: any) => s.activityLogs.activityLogs.isLoading)).toBe(true); + + const req = httpMock.expectOne(buildNodeLogsUrl('proj500', 1, 10)); + req.flush({ errors: [{ detail: 'boom' }] }, { status: 500, statusText: 'Server Error' }); + + const snap = store.snapshot().activityLogs.activityLogs; + expect(snap.isLoading).toBe(false); + expect(snap.error).toBeTruthy(); + expect(snap.totalCount).toBe(0); + + httpMock.verify(); + } + )); + it('clears store', () => { store.reset({ activityLogs: { 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 b0ad3b05d..91092b6db 100644 --- a/src/app/shared/stores/activity-logs/activity-logs.state.ts +++ b/src/app/shared/stores/activity-logs/activity-logs.state.ts @@ -1,5 +1,5 @@ import { Action, State, StateContext } from '@ngxs/store'; - +import { catchError, EMPTY } from 'rxjs'; import { tap } from 'rxjs/operators'; import { inject, Injectable } from '@angular/core'; @@ -28,6 +28,12 @@ export class ActivityLogsState { 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; }) ); } @@ -43,6 +49,12 @@ export class ActivityLogsState { 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; }) ); } diff --git a/src/environments/environment.local.ts b/src/environments/environment.local.ts index 05ff0cb9e..43acf884a 100644 --- a/src/environments/environment.local.ts +++ b/src/environments/environment.local.ts @@ -14,4 +14,10 @@ export const environment = { defaultProvider: 'osf', dataciteTrackerRepoId: null, dataciteTrackerAddress: 'https://analytics.datacite.org/api/metric', + + google: { + GOOGLE_FILE_PICKER_CLIENT_ID: 'local-client-id', + GOOGLE_FILE_PICKER_API_KEY: 'local-api-key', + GOOGLE_FILE_PICKER_APP_ID: 1234567890, + }, }; diff --git a/src/testing/data/activity-logs/activity-logs.data.ts b/src/testing/data/activity-logs/activity-logs.data.ts index 3966c4f55..bdb43e526 100644 --- a/src/testing/data/activity-logs/activity-logs.data.ts +++ b/src/testing/data/activity-logs/activity-logs.data.ts @@ -1,33 +1,59 @@ import structuredClone from 'structured-clone'; +import { environment } from 'src/environments/environment'; -export function getActivityLogsResponse() { +export const ACTIVITY_LOGS_EMBEDS_QS = + 'embed%5B%5D=original_node&embed%5B%5D=user&embed%5B%5D=linked_node&embed%5B%5D=linked_registration&embed%5B%5D=template_node&embed%5B%5D=group'; + +export function buildRegistrationLogsUrl( + registrationId: string, + page: number, + pageSize: number, + apiBase = environment.apiDomainUrl +) { + return `${apiBase}/v2/registrations/${registrationId}/logs/?${ACTIVITY_LOGS_EMBEDS_QS}&page=${page}&page%5Bsize%5D=${pageSize}`; +} + +export function buildNodeLogsUrl(projectId: string, page: number, pageSize: number, apiBase = environment.apiDomainUrl) { + return `${apiBase}/v2/nodes/${projectId}/logs/?${ACTIVITY_LOGS_EMBEDS_QS}&page=${page}&page%5Bsize%5D=${pageSize}`; +} + +type AnyObj = Record; + +export function makeActivityLog(overrides: AnyObj = {}) { return structuredClone({ - data: [ - { - id: 'log1', - type: 'logs', - attributes: { - action: 'update', - date: '2024-01-01T00:00:00Z', - params: {}, - }, - embeds: {}, - }, - { + id: 'log1', + type: 'logs', + attributes: { + action: 'update', + date: '2024-01-01T00:00:00Z', + params: {}, + }, + embeds: {}, + ...overrides, + }); +} + +export function makeActivityLogsResponse(logs: AnyObj[] = [], total?: number) { + const data = logs.length + ? logs + : [ + makeActivityLog(), + makeActivityLog({ id: 'log2', - type: 'logs', - attributes: { - action: 'create', - date: '2024-01-02T00:00:00Z', - params: {}, - }, - embeds: {}, - }, - ], + attributes: { action: 'create', date: '2024-01-02T00:00:00Z', params: {} }, + }), + ]; + + return structuredClone({ + data, meta: { - total: 2, + total: total ?? data.length, anonymous: false, }, included: null, }); } + +export function getActivityLogsResponse() { + return makeActivityLogsResponse(); +} From 5ad3caacf602bb15c8da201ce9524eb47d867e89 Mon Sep 17 00:00:00 2001 From: Andriy Sheredko Date: Wed, 10 Sep 2025 17:06:06 +0300 Subject: [PATCH 4/4] feat(registration-recent-activity): run lint fix --- src/app/core/constants/environment.token.ts | 3 ++- .../recent-activity.component.spec.ts | 14 +++++++------ .../project-overview.component.spec.ts | 18 +++++++++------- ...stration-recent-activity.component.spec.ts | 11 +++++----- .../registration-recent-activity.component.ts | 7 +++---- .../activity-log-with-display.model.ts | 1 + .../activity-logs/activity-logs.model.ts | 5 +---- src/app/shared/models/activity-logs/index.ts | 2 +- .../activity-logs.service.spec.ts | 5 +++-- .../activity-logs/activity-logs.service.ts | 5 +++-- .../activity-logs.selectors.spec.ts | 5 +++-- .../activity-logs/activity-logs.selectors.ts | 1 + .../activity-logs/activity-logs.state.spec.ts | 7 ++++--- .../activity-logs/activity-logs.state.ts | 1 + .../data/activity-logs/activity-logs.data.ts | 21 ++++++++++++------- 15 files changed, 60 insertions(+), 46 deletions(-) diff --git a/src/app/core/constants/environment.token.ts b/src/app/core/constants/environment.token.ts index e48a698a4..bebb4a284 100644 --- a/src/app/core/constants/environment.token.ts +++ b/src/app/core/constants/environment.token.ts @@ -1,8 +1,9 @@ import { InjectionToken } from '@angular/core'; -import { environment } from 'src/environments/environment'; import { AppEnvironment } from '@shared/models/environment.model'; +import { environment } from 'src/environments/environment'; + export const ENVIRONMENT = new InjectionToken('App Environment', { providedIn: 'root', factory: () => environment as AppEnvironment, 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 index 2e039f61c..4ee925800 100644 --- 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 @@ -1,18 +1,20 @@ -import { ComponentFixture, TestBed } from '@angular/core/testing'; import { provideStore, Store } from '@ngxs/store'; -import { ActivatedRoute } from '@angular/router'; - -import { provideHttpClient, withInterceptorsFromDi } from '@angular/common/http'; -import { provideHttpClientTesting } from '@angular/common/http/testing'; import { TranslateService } from '@ngx-translate/core'; + import { of } from 'rxjs'; -import { RecentActivityComponent } from './recent-activity.component'; +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 '@shared/services'; import { GetActivityLogs } from '@shared/stores/activity-logs'; import { ActivityLogsState } from '@shared/stores/activity-logs/activity-logs.state'; +import { RecentActivityComponent } from './recent-activity.component'; + describe('RecentActivityComponent', () => { let fixture: ComponentFixture; let store: Store; 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 df6706e89..3bbb58cf9 100644 --- a/src/app/features/project/overview/project-overview.component.spec.ts +++ b/src/app/features/project/overview/project-overview.component.spec.ts @@ -1,19 +1,21 @@ -import { ComponentFixture, TestBed } from '@angular/core/testing'; -import { RouterTestingModule } from '@angular/router/testing'; import { provideStore, Store } from '@ngxs/store'; -import { ActivatedRoute } from '@angular/router'; + +import { TranslateService } from '@ngx-translate/core'; + +import { DialogService } from 'primeng/dynamicdialog'; + 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 { RouterTestingModule } from '@angular/router/testing'; -import { ProjectOverviewComponent } from './project-overview.component'; +import { DataciteService, ToastService } from '@osf/shared/services'; import { GetActivityLogs } from '@shared/stores/activity-logs'; -import { DataciteService } from '@osf/shared/services'; -import { DialogService } from 'primeng/dynamicdialog'; -import { TranslateService } from '@ngx-translate/core'; -import { ToastService } from '@osf/shared/services'; +import { ProjectOverviewComponent } from './project-overview.component'; describe('ProjectOverviewComponent', () => { let fixture: ComponentFixture; diff --git a/src/app/features/registry/pages/recent-activity/registration-recent-activity.component.spec.ts b/src/app/features/registry/pages/recent-activity/registration-recent-activity.component.spec.ts index 708717eb7..be42f705e 100644 --- a/src/app/features/registry/pages/recent-activity/registration-recent-activity.component.spec.ts +++ b/src/app/features/registry/pages/recent-activity/registration-recent-activity.component.spec.ts @@ -1,14 +1,15 @@ -import { provideHttpClient, withInterceptorsFromDi } from '@angular/common/http'; -import { provideHttpClientTesting } from '@angular/common/http/testing'; +import { provideStore, Store } from '@ngxs/store'; + import { TranslateService } from '@ngx-translate/core'; -import { of } from 'rxjs'; -import { ActivityLogDisplayService } from '@shared/services'; -import { provideStore, Store } from '@ngxs/store'; +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 '@shared/services'; import { ClearActivityLogsStore, GetRegistrationActivityLogs } from '@shared/stores/activity-logs'; import { ActivityLogsState } from '@shared/stores/activity-logs/activity-logs.state'; diff --git a/src/app/features/registry/pages/recent-activity/registration-recent-activity.component.ts b/src/app/features/registry/pages/recent-activity/registration-recent-activity.component.ts index 2c46ce334..27a9cfb34 100644 --- a/src/app/features/registry/pages/recent-activity/registration-recent-activity.component.ts +++ b/src/app/features/registry/pages/recent-activity/registration-recent-activity.component.ts @@ -3,22 +3,21 @@ 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 { Skeleton } from 'primeng/skeleton'; import { ChangeDetectionStrategy, Component, computed, inject, OnDestroy, signal } from '@angular/core'; import { ActivatedRoute } from '@angular/router'; +import { ENVIRONMENT } from '@core/constants/environment.token'; import { CustomPaginatorComponent } from '@shared/components'; +import { ACTIVITY_LOGS_DEFAULT_PAGE_SIZE } from '@shared/constants/activity-logs'; import { ActivityLogsSelectors, ClearActivityLogsStore, GetRegistrationActivityLogs, } from '@shared/stores/activity-logs'; -import { ENVIRONMENT } from '@core/constants/environment.token'; -import { ACTIVITY_LOGS_DEFAULT_PAGE_SIZE } from '@shared/constants/activity-logs'; - @Component({ selector: 'osf-registration-recent-activity', imports: [TranslatePipe, DatePipe, CustomPaginatorComponent, Skeleton], diff --git a/src/app/shared/models/activity-logs/activity-log-with-display.model.ts b/src/app/shared/models/activity-logs/activity-log-with-display.model.ts index c3d4dca87..153591fbe 100644 --- a/src/app/shared/models/activity-logs/activity-log-with-display.model.ts +++ b/src/app/shared/models/activity-logs/activity-log-with-display.model.ts @@ -1,4 +1,5 @@ import { SafeHtml } from '@angular/platform-browser'; + import { ActivityLog } from './activity-logs.model'; export interface ActivityLogWithDisplay extends ActivityLog { diff --git a/src/app/shared/models/activity-logs/activity-logs.model.ts b/src/app/shared/models/activity-logs/activity-logs.model.ts index b5e47585d..72df0c1ec 100644 --- a/src/app/shared/models/activity-logs/activity-logs.model.ts +++ b/src/app/shared/models/activity-logs/activity-logs.model.ts @@ -14,10 +14,7 @@ export interface ActivityLog { paramsNode: { id: string; title: string }; paramsProject: null; pointer: Pointer | null; - preprintProvider?: - | string - | { url: string; name: string } - | null; + preprintProvider?: string | { url: string; name: string } | null; addon?: string; anonymousLink?: boolean; file?: { name: string; url: string }; diff --git a/src/app/shared/models/activity-logs/index.ts b/src/app/shared/models/activity-logs/index.ts index 54ba32573..93274a749 100644 --- a/src/app/shared/models/activity-logs/index.ts +++ b/src/app/shared/models/activity-logs/index.ts @@ -1,3 +1,3 @@ +export * from './activity-log-with-display.model'; export * from './activity-logs.model'; export * from './activity-logs-json-api.model'; -export * from './activity-log-with-display.model'; 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 65520be4f..ccc1f4bb3 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 @@ -2,12 +2,13 @@ import { HttpTestingController } from '@angular/common/http/testing'; import { inject, TestBed } from '@angular/core/testing'; import { ActivityLogDisplayService } from '@shared/services'; + import { ActivityLogsService } from './activity-logs.service'; import { - getActivityLogsResponse, - buildRegistrationLogsUrl, buildNodeLogsUrl, + buildRegistrationLogsUrl, + getActivityLogsResponse, } from '@testing/data/activity-logs/activity-logs.data'; import { OSFTestingStoreModule } from '@testing/osf.testing.module'; 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 71e0a07aa..f1db74165 100644 --- a/src/app/shared/services/activity-logs/activity-logs.service.ts +++ b/src/app/shared/services/activity-logs/activity-logs.service.ts @@ -1,5 +1,6 @@ import { Observable } from 'rxjs'; import { map } from 'rxjs/operators'; + import { inject, Injectable } from '@angular/core'; import { ActivityLogsMapper } from '@shared/mappers/activity-logs.mapper'; @@ -32,7 +33,7 @@ export class ActivityLogsService { }; } - fetchLogs(projectId: string, page: number = 1, pageSize: number): Observable> { + 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', 'group'], @@ -50,7 +51,7 @@ export class ActivityLogsService { fetchRegistrationLogs( registrationId: string, - page: number = 1, + page = 1, pageSize: number ): Observable> { const url = `${this.apiUrl}/registrations/${registrationId}/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 68771041a..d3a4f32ef 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,8 +1,9 @@ -import { TestBed } from '@angular/core/testing'; import { provideStore, Store } from '@ngxs/store'; -import { ActivityLogsState } from './activity-logs.state'; +import { TestBed } from '@angular/core/testing'; + import { ActivityLogsSelectors } from './activity-logs.selectors'; +import { ActivityLogsState } from './activity-logs.state'; describe('ActivityLogsSelectors', () => { let store: Store; 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 5679474a8..e007614ae 100644 --- a/src/app/shared/stores/activity-logs/activity-logs.selectors.ts +++ b/src/app/shared/stores/activity-logs/activity-logs.selectors.ts @@ -1,4 +1,5 @@ import { Selector } from '@ngxs/store'; + import { ActivityLog, ActivityLogWithDisplay } from '@shared/models/activity-logs'; import { ActivityLogsStateModel } from './activity-logs.model'; 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 b6c4b5c7b..71064d228 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 @@ -1,7 +1,8 @@ import { provideStore, Store } from '@ngxs/store'; + +import { provideHttpClient } from '@angular/common/http'; import { HttpTestingController, provideHttpClientTesting } from '@angular/common/http/testing'; import { inject, TestBed } from '@angular/core/testing'; -import { provideHttpClient } from '@angular/common/http'; import { ActivityLogDisplayService } from '@shared/services'; @@ -9,9 +10,9 @@ import { ClearActivityLogsStore, GetActivityLogs, GetRegistrationActivityLogs } import { ActivityLogsState } from './activity-logs.state'; import { - getActivityLogsResponse, - buildRegistrationLogsUrl, buildNodeLogsUrl, + buildRegistrationLogsUrl, + getActivityLogsResponse, } from '@testing/data/activity-logs/activity-logs.data'; describe('State: ActivityLogs', () => { 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 91092b6db..e69be7da8 100644 --- a/src/app/shared/stores/activity-logs/activity-logs.state.ts +++ b/src/app/shared/stores/activity-logs/activity-logs.state.ts @@ -1,4 +1,5 @@ import { Action, State, StateContext } from '@ngxs/store'; + import { catchError, EMPTY } from 'rxjs'; import { tap } from 'rxjs/operators'; diff --git a/src/testing/data/activity-logs/activity-logs.data.ts b/src/testing/data/activity-logs/activity-logs.data.ts index bdb43e526..c2ce8570f 100644 --- a/src/testing/data/activity-logs/activity-logs.data.ts +++ b/src/testing/data/activity-logs/activity-logs.data.ts @@ -1,5 +1,5 @@ -import structuredClone from 'structured-clone'; import { environment } from 'src/environments/environment'; +import structuredClone from 'structured-clone'; export const ACTIVITY_LOGS_EMBEDS_QS = 'embed%5B%5D=original_node&embed%5B%5D=user&embed%5B%5D=linked_node&embed%5B%5D=linked_registration&embed%5B%5D=template_node&embed%5B%5D=group'; @@ -13,7 +13,12 @@ export function buildRegistrationLogsUrl( return `${apiBase}/v2/registrations/${registrationId}/logs/?${ACTIVITY_LOGS_EMBEDS_QS}&page=${page}&page%5Bsize%5D=${pageSize}`; } -export function buildNodeLogsUrl(projectId: string, page: number, pageSize: number, apiBase = environment.apiDomainUrl) { +export function buildNodeLogsUrl( + projectId: string, + page: number, + pageSize: number, + apiBase = environment.apiDomainUrl +) { return `${apiBase}/v2/nodes/${projectId}/logs/?${ACTIVITY_LOGS_EMBEDS_QS}&page=${page}&page%5Bsize%5D=${pageSize}`; } @@ -37,12 +42,12 @@ export function makeActivityLogsResponse(logs: AnyObj[] = [], total?: number) { const data = logs.length ? logs : [ - makeActivityLog(), - makeActivityLog({ - id: 'log2', - attributes: { action: 'create', date: '2024-01-02T00:00:00Z', params: {} }, - }), - ]; + makeActivityLog(), + makeActivityLog({ + id: 'log2', + attributes: { action: 'create', date: '2024-01-02T00:00:00Z', params: {} }, + }), + ]; return structuredClone({ data,