diff --git a/src/app/core/constants/environment.token.ts b/src/app/core/constants/environment.token.ts index 50b782688..bebb4a284 100644 --- a/src/app/core/constants/environment.token.ts +++ b/src/app/core/constants/environment.token.ts @@ -1,8 +1,10 @@ import { InjectionToken } from '@angular/core'; +import { AppEnvironment } from '@shared/models/environment.model'; + import { environment } from 'src/environments/environment'; -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/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/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..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,22 +1,100 @@ +import { provideStore, Store } from '@ngxs/store'; + +import { TranslateService } from '@ngx-translate/core'; + +import { of } from 'rxjs'; + +import { provideHttpClient, withInterceptorsFromDi } from '@angular/common/http'; +import { provideHttpClientTesting } from '@angular/common/http/testing'; import { ComponentFixture, TestBed } from '@angular/core/testing'; +import { ActivatedRoute } from '@angular/router'; + +import { ActivityLogDisplayService } from '@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 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/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.spec.ts b/src/app/features/project/overview/project-overview.component.spec.ts index 96a420dba..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,26 +1,71 @@ -import { MockComponent } from 'ng-mocks'; +import { provideStore, Store } from '@ngxs/store'; +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 { SubHeaderComponent } from '@osf/shared/components'; +import { DataciteService, ToastService } from '@osf/shared/services'; +import { GetActivityLogs } from '@shared/stores/activity-logs'; import { ProjectOverviewComponent } from './project-overview.component'; 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/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 new file mode 100644 index 000000000..168513636 --- /dev/null +++ b/src/app/features/registry/pages/recent-activity/registration-recent-activity.component.html @@ -0,0 +1,50 @@ +
+

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

+ + @if (!isLoading()) { +
+ @for (activityLog of formattedActivityLogs(); track activityLog.id) { +
+
+ + +
+ } @empty { +
+ {{ 'project.overview.recentActivity.noActivity' | translate }} +
+ } +
+ + @if (totalCount() > pageSize) { + + } + } @else { +
+ + + + + +
+ } +
diff --git a/src/app/features/registry/pages/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..be42f705e --- /dev/null +++ b/src/app/features/registry/pages/recent-activity/registration-recent-activity.component.spec.ts @@ -0,0 +1,197 @@ +import { provideStore, Store } from '@ngxs/store'; + +import { TranslateService } from '@ngx-translate/core'; + +import { of } from 'rxjs'; + +import { provideHttpClient, withInterceptorsFromDi } from '@angular/common/http'; +import { provideHttpClientTesting } from '@angular/common/http/testing'; +import { ComponentFixture, TestBed } from '@angular/core/testing'; +import { ActivatedRoute } from '@angular/router'; + +import { ActivityLogDisplayService } from '@shared/services'; +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: [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(); + + store = TestBed.inject(Store); + jest.spyOn(store, 'dispatch'); + + fixture = TestBed.createComponent(RegistrationRecentActivityComponent); + fixture.detectChanges(); + }); + + it('dispatches initial registration logs fetch', () => { + const dispatchSpy = store.dispatch as jest.Mock; + expect(dispatchSpy).toHaveBeenCalledWith(expect.any(GetRegistrationActivityLogs)); + const action = dispatchSpy.mock.calls.at(-1)?.[0] as GetRegistrationActivityLogs; + expect(action.registrationId).toBe('reg123'); + expect(action.page).toBe(1); + }); + + it('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-01T12:34: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"]'); + 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', () => { + const dispatchSpy = store.dispatch as jest.Mock; + dispatchSpy.mockClear(); + + fixture.componentInstance.onPageChange({ page: 2 } as any); + expect(dispatchSpy).toHaveBeenCalledWith(expect.any(GetRegistrationActivityLogs)); + + const action = dispatchSpy.mock.calls.at(-1)?.[0] as GetRegistrationActivityLogs; + 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(); + + 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 new file mode 100644 index 000000000..27a9cfb34 --- /dev/null +++ b/src/app/features/registry/pages/recent-activity/registration-recent-activity.component.ts @@ -0,0 +1,64 @@ +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 { 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'; + +@Component({ + selector: 'osf-registration-recent-activity', + imports: [TranslatePipe, DatePipe, CustomPaginatorComponent, Skeleton], + templateUrl: './registration-recent-activity.component.html', + changeDetection: ChangeDetectionStrategy.OnPush, +}) +export class RegistrationRecentActivityComponent implements OnDestroy { + private readonly route = inject(ActivatedRoute); + readonly #environment = inject(ENVIRONMENT); + + readonly pageSize = this.#environment.activityLogs?.pageSize ?? ACTIVITY_LOGS_DEFAULT_PAGE_SIZE; + + private readonly registrationId: string = (this.route.snapshot.params['id'] ?? + this.route.parent?.snapshot.params['id']) as string; + + protected currentPage = signal(1); + + 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({ + getRegistrationActivityLogs: GetRegistrationActivityLogs, + clearActivityLogsStore: ClearActivityLogsStore, + }); + + constructor() { + this.actions.getRegistrationActivityLogs(this.registrationId, 1, this.pageSize); + } + + onPageChange(event: PaginatorState) { + if (event.page !== undefined) { + const pageNumber = event.page + 1; + this.currentPage.set(pageNumber); + this.actions.getRegistrationActivityLogs(this.registrationId, pageNumber, this.pageSize); + } + } + + ngOnDestroy(): void { + this.actions.clearActivityLogsStore(); + } +} diff --git a/src/app/features/registry/registry.routes.ts b/src/app/features/registry/registry.routes.ts index 22da6cde0..49f129e81 100644 --- a/src/app/features/registry/registry.routes.ts +++ b/src/app/features/registry/registry.routes.ts @@ -12,6 +12,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 +29,7 @@ export const registryRoutes: Routes = [ { path: '', component: RegistryComponent, - providers: [provideStates([RegistryOverviewState])], + providers: [provideStates([RegistryOverviewState, ActivityLogsState])], children: [ { path: '', @@ -113,6 +114,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/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..153591fbe --- /dev/null +++ b/src/app/shared/models/activity-logs/activity-log-with-display.model.ts @@ -0,0 +1,7 @@ +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..72df0c1ec 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,33 @@ 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; - } - | null; + preprintProvider?: 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..93274a749 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-log-with-display.model'; export * from './activity-logs.model'; export * from './activity-logs-json-api.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 new file mode 100644 index 000000000..ccc1f4bb3 --- /dev/null +++ b/src/app/shared/services/activity-logs/activity-logs.service.spec.ts @@ -0,0 +1,90 @@ +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 { + buildNodeLogsUrl, + buildRegistrationLogsUrl, + getActivityLogsResponse, +} from '@testing/data/activity-logs/activity-logs.data'; +import { OSFTestingStoreModule } from '@testing/osf.testing.module'; + +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(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'); + + 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(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'); + + req.flush(getActivityLogsResponse()); + + expect(result.data.length).toBe(2); + expect(result.data[1].formattedActivity).toBe('FMT'); + + 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 a36b4dd2c..f1db74165 100644 --- a/src/app/shared/services/activity-logs/activity-logs.service.ts +++ b/src/app/shared/services/activity-logs/activity-logs.service.ts @@ -11,18 +11,29 @@ 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'; -@Injectable({ - providedIn: 'root', -}) +@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 = 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,6 +43,29 @@ 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: number + ): Observable> { + const url = `${this.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)), + 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 50d9c2e0b..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,17 @@ export class GetActivityLogs { constructor( public projectId: string, - public page = '1', - public pageSize: string + public page = 1, + public pageSize: number + ) {} +} + +export class GetRegistrationActivityLogs { + static readonly type = '[ActivityLogs] Get Registration Activity Logs'; + constructor( + public registrationId: string, + public page = 1, + public pageSize: number ) {} } 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..d3a4f32ef --- /dev/null +++ b/src/app/shared/stores/activity-logs/activity-logs.selectors.spec.ts @@ -0,0 +1,35 @@ +import { provideStore, Store } from '@ngxs/store'; + +import { TestBed } from '@angular/core/testing'; + +import { ActivityLogsSelectors } from './activity-logs.selectors'; +import { ActivityLogsState } from './activity-logs.state'; + +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 61e746506..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,6 +1,6 @@ import { Selector } from '@ngxs/store'; -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'; @@ -11,6 +11,11 @@ export class ActivityLogsSelectors { return state.activityLogs.data; } + @Selector([ActivityLogsState]) + static getFormattedActivityLogs(state: ActivityLogsStateModel): ActivityLogWithDisplay[] { + return state.activityLogs.data; + } + @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..71064d228 --- /dev/null +++ b/src/app/shared/stores/activity-logs/activity-logs.state.spec.ts @@ -0,0 +1,135 @@ +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, GetActivityLogs, GetRegistrationActivityLogs } from './activity-logs.actions'; +import { ActivityLogsState } from './activity-logs.state'; + +import { + buildNodeLogsUrl, + buildRegistrationLogsUrl, + getActivityLogsResponse, +} from '@testing/data/activity-logs/activity-logs.data'; + +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; + }); + + expect(store.selectSnapshot((s: any) => s.activityLogs.activityLogs.isLoading)).toBe(true); + + const req = httpMock.expectOne(buildRegistrationLogsUrl('reg123', 1, 10)); + expect(req.request.method).toBe('GET'); + + req.flush(getActivityLogsResponse()); + + expect(snapshot.isLoading).toBe(false); + expect(snapshot.totalCount).toBe(2); + expect(snapshot.data[0].formattedActivity).toContain('formatted'); + + httpMock.verify(); + } + )); + + 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: { + 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 0ac91e5c6..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,12 +1,13 @@ import { Action, State, StateContext } from '@ngxs/store'; +import { catchError, EMPTY } from 'rxjs'; import { tap } from 'rxjs/operators'; 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({ @@ -20,24 +21,41 @@ 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 }, }); + }), + catchError((error) => { + ctx.patchState({ + activityLogs: { data: [], isLoading: false, error, totalCount: 0 }, + }); + return EMPTY; + }) + ); + } + + @Action(GetRegistrationActivityLogs) + getRegistrationActivityLogs(ctx: StateContext, action: GetRegistrationActivityLogs) { + ctx.patchState({ + activityLogs: { data: [], isLoading: true, error: null, totalCount: 0 }, + }); + + return this.activityLogsService.fetchRegistrationLogs(action.registrationId, action.page, action.pageSize).pipe( + tap((res) => { + ctx.patchState({ + activityLogs: { data: res.data, isLoading: false, error: null, totalCount: res.totalCount }, + }); + }), + catchError((error) => { + ctx.patchState({ + activityLogs: { data: [], isLoading: false, error, totalCount: 0 }, + }); + return EMPTY; }) ); } 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 new file mode 100644 index 000000000..c2ce8570f --- /dev/null +++ b/src/testing/data/activity-logs/activity-logs.data.ts @@ -0,0 +1,64 @@ +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'; + +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({ + 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', + attributes: { action: 'create', date: '2024-01-02T00:00:00Z', params: {} }, + }), + ]; + + return structuredClone({ + data, + meta: { + total: total ?? data.length, + anonymous: false, + }, + included: null, + }); +} + +export function getActivityLogsResponse() { + return makeActivityLogsResponse(); +}