From 5151ef68ab26f1f18d601397835ca44980eede8c Mon Sep 17 00:00:00 2001 From: Peter Chapman Date: Mon, 6 Jan 2025 12:54:59 +1300 Subject: [PATCH] WIP: Event Metrics Log Frontend --- .../src/app/core/sf-project.service.spec.ts | 12 ++ .../src/app/core/sf-project.service.ts | 5 + .../src/app/event-metrics/event-metric.ts | 17 +++ .../event-metrics-log.component.html | 29 +++++ .../event-metrics-log.component.scss | 0 .../event-metrics-log.component.spec.ts | 102 +++++++++++++++++ .../event-metrics-log.component.ts | 108 ++++++++++++++++++ .../event-metrics.component.html | 1 + .../event-metrics/event-metrics.component.ts | 3 +- 9 files changed, 276 insertions(+), 1 deletion(-) create mode 100644 src/SIL.XForge.Scripture/ClientApp/src/app/event-metrics/event-metric.ts create mode 100644 src/SIL.XForge.Scripture/ClientApp/src/app/event-metrics/event-metrics-log.component.html create mode 100644 src/SIL.XForge.Scripture/ClientApp/src/app/event-metrics/event-metrics-log.component.scss create mode 100644 src/SIL.XForge.Scripture/ClientApp/src/app/event-metrics/event-metrics-log.component.spec.ts create mode 100644 src/SIL.XForge.Scripture/ClientApp/src/app/event-metrics/event-metrics-log.component.ts diff --git a/src/SIL.XForge.Scripture/ClientApp/src/app/core/sf-project.service.spec.ts b/src/SIL.XForge.Scripture/ClientApp/src/app/core/sf-project.service.spec.ts index 77e5328f81..78fff20274 100644 --- a/src/SIL.XForge.Scripture/ClientApp/src/app/core/sf-project.service.spec.ts +++ b/src/SIL.XForge.Scripture/ClientApp/src/app/core/sf-project.service.spec.ts @@ -50,6 +50,18 @@ describe('SFProjectService', () => { })); }); + describe('onlineEventMetrics', () => { + it('should invoke the command service', fakeAsync(async () => { + const env = new TestEnvironment(); + const projectId = 'project01'; + const pageIndex = 0; + const pageSize = 20; + await env.service.onlineEventMetrics(projectId, pageIndex, pageSize); + verify(mockedCommandService.onlineInvoke(anything(), 'eventMetrics', anything())).once(); + expect().nothing(); + })); + }); + class TestEnvironment { readonly httpTestingController: HttpTestingController; readonly service: SFProjectService; diff --git a/src/SIL.XForge.Scripture/ClientApp/src/app/core/sf-project.service.ts b/src/SIL.XForge.Scripture/ClientApp/src/app/core/sf-project.service.ts index fb8cbdb245..cfd5c0b00c 100644 --- a/src/SIL.XForge.Scripture/ClientApp/src/app/core/sf-project.service.ts +++ b/src/SIL.XForge.Scripture/ClientApp/src/app/core/sf-project.service.ts @@ -22,6 +22,7 @@ import { QueryParameters } from 'xforge-common/query-parameters'; import { RealtimeService } from 'xforge-common/realtime.service'; import { RetryingRequest, RetryingRequestService } from 'xforge-common/retrying-request.service'; import { TransceleratorQuestion } from '../checking/import-questions-dialog/import-questions-dialog.component'; +import { EventMetric } from '../event-metrics/event-metric'; import { ShareLinkType } from '../shared/share/share-dialog.component'; import { InviteeStatus } from '../users/collaborators/collaborators.component'; import { BiblicalTermDoc } from './models/biblical-term-doc'; @@ -309,4 +310,8 @@ export class SFProjectService extends ProjectService { isValid }); } + + async onlineEventMetrics(projectId: string, pageIndex: number, pageSize: number): Promise { + return await this.onlineInvoke('eventMetrics', { projectId, pageIndex, pageSize }); + } } diff --git a/src/SIL.XForge.Scripture/ClientApp/src/app/event-metrics/event-metric.ts b/src/SIL.XForge.Scripture/ClientApp/src/app/event-metrics/event-metric.ts new file mode 100644 index 0000000000..7794516da8 --- /dev/null +++ b/src/SIL.XForge.Scripture/ClientApp/src/app/event-metrics/event-metric.ts @@ -0,0 +1,17 @@ +export interface EventMetric { + id: string; + eventType: string; + payload: { [key: string]: any }; + projectId?: string; + scope: EventScope; + timeStamp: string; + userId?: string; +} + +export enum EventScope { + None = 'None', + Settings = 'Settings', + Sync = 'Sync', + Drafting = 'Drafting', + Checking = 'Checking' +} diff --git a/src/SIL.XForge.Scripture/ClientApp/src/app/event-metrics/event-metrics-log.component.html b/src/SIL.XForge.Scripture/ClientApp/src/app/event-metrics/event-metrics-log.component.html new file mode 100644 index 0000000000..8d231df6b5 --- /dev/null +++ b/src/SIL.XForge.Scripture/ClientApp/src/app/event-metrics/event-metrics-log.component.html @@ -0,0 +1,29 @@ +@if (!isLoading) { + + + + + + + + + + + + + + + +
Scope + {{ row.scope }} + Event + {{ row.eventType }} + Author + +
+} diff --git a/src/SIL.XForge.Scripture/ClientApp/src/app/event-metrics/event-metrics-log.component.scss b/src/SIL.XForge.Scripture/ClientApp/src/app/event-metrics/event-metrics-log.component.scss new file mode 100644 index 0000000000..e69de29bb2 diff --git a/src/SIL.XForge.Scripture/ClientApp/src/app/event-metrics/event-metrics-log.component.spec.ts b/src/SIL.XForge.Scripture/ClientApp/src/app/event-metrics/event-metrics-log.component.spec.ts new file mode 100644 index 0000000000..2bb76dd65a --- /dev/null +++ b/src/SIL.XForge.Scripture/ClientApp/src/app/event-metrics/event-metrics-log.component.spec.ts @@ -0,0 +1,102 @@ +import { provideHttpClient, withInterceptorsFromDi } from '@angular/common/http'; +import { provideHttpClientTesting } from '@angular/common/http/testing'; +import { DebugElement, getDebugNode } from '@angular/core'; +import { ComponentFixture, fakeAsync, TestBed, tick } from '@angular/core/testing'; +import { By } from '@angular/platform-browser'; +import { NoopAnimationsModule } from '@angular/platform-browser/animations'; +import { RouterModule } from '@angular/router'; +import { BehaviorSubject } from 'rxjs'; +import { anything, mock, when } from 'ts-mockito'; +import { ActivatedProjectService } from 'xforge-common/activated-project.service'; +import { AuthService } from 'xforge-common/auth.service'; +import { NoticeService } from 'xforge-common/notice.service'; +import { TestRealtimeModule } from 'xforge-common/test-realtime.module'; +import { configureTestingModule, TestTranslocoModule } from 'xforge-common/test-utils'; +import { UICommonModule } from 'xforge-common/ui-common.module'; +import { UserService } from 'xforge-common/user.service'; +import { SF_TYPE_REGISTRY } from '../core/models/sf-type-registry'; +import { SFProjectService } from '../core/sf-project.service'; +import { EventMetric, EventScope } from './event-metric'; +import { EventMetricsLogComponent } from './event-metrics-log.component'; + +const mockedActivatedProjectService = mock(ActivatedProjectService); +const mockedAuthService = mock(AuthService); +const mockedNoticeService = mock(NoticeService); +const mockedProjectService = mock(SFProjectService); +const mockedUserService = mock(UserService); + +describe('EventMetricsLogComponent', () => { + configureTestingModule(() => ({ + imports: [ + EventMetricsLogComponent, + NoopAnimationsModule, + RouterModule.forRoot([]), + UICommonModule, + TestTranslocoModule, + TestRealtimeModule.forRoot(SF_TYPE_REGISTRY) + ], + providers: [ + { provide: AuthService, useMock: mockedAuthService }, + { provide: ActivatedProjectService, useMock: mockedActivatedProjectService }, + { provide: NoticeService, useMock: mockedNoticeService }, + { provide: SFProjectService, useMock: mockedProjectService }, + { provide: UserService, useMock: mockedUserService }, + provideHttpClient(withInterceptorsFromDi()), + provideHttpClientTesting() + ] + })); + + it('should not display table if no event metrics', fakeAsync(() => { + const env = new TestEnvironment(); + env.wait(); + env.wait(); + + expect(env.table).toBeNull(); + })); + + it('should display event metrics', fakeAsync(() => { + const env = new TestEnvironment(); + env.populateEventMetrics(); + env.wait(); + env.wait(); + + expect(env.rows.length).toEqual(1); + })); +}); + +class TestEnvironment { + readonly component: EventMetricsLogComponent; + readonly fixture: ComponentFixture; + + mockProjectId = 'project01'; + + constructor() { + const mockProjectId$ = new BehaviorSubject(this.mockProjectId); + when(mockedActivatedProjectService.projectId).thenReturn(this.mockProjectId); + when(mockedActivatedProjectService.projectId$).thenReturn(mockProjectId$); + when(mockedUserService.currentUserId).thenReturn('user01'); + when(mockedProjectService.onlineEventMetrics(anything(), anything(), anything())).thenReturn(null); + + this.fixture = TestBed.createComponent(EventMetricsLogComponent); + this.component = this.fixture.componentInstance; + } + + get table(): DebugElement { + return this.fixture.debugElement.query(By.css('#event-metrics-log-table')); + } + + get rows(): DebugElement[] { + return Array.from(this.table.nativeElement.querySelectorAll('tbody tr')).map(r => getDebugNode(r) as DebugElement); + } + + populateEventMetrics(): void { + when(mockedProjectService.onlineEventMetrics(anything(), anything(), anything())).thenReturn( + Promise.resolve([{ scope: EventScope.Settings, timeStamp: new Date().toISOString() } as EventMetric]) + ); + } + + wait(): void { + this.fixture.detectChanges(); + tick(); + } +} diff --git a/src/SIL.XForge.Scripture/ClientApp/src/app/event-metrics/event-metrics-log.component.ts b/src/SIL.XForge.Scripture/ClientApp/src/app/event-metrics/event-metrics-log.component.ts new file mode 100644 index 0000000000..7dc9108b68 --- /dev/null +++ b/src/SIL.XForge.Scripture/ClientApp/src/app/event-metrics/event-metrics-log.component.ts @@ -0,0 +1,108 @@ +import { Component, OnInit } from '@angular/core'; +import { switchMap } from 'rxjs'; +import { ActivatedProjectService } from 'xforge-common/activated-project.service'; +import { DataLoadingComponent } from 'xforge-common/data-loading-component'; +import { I18nService } from 'xforge-common/i18n.service'; +import { NoticeService } from 'xforge-common/notice.service'; +import { OwnerComponent } from 'xforge-common/owner/owner.component'; +import { UICommonModule } from 'xforge-common/ui-common.module'; +import { filterNullish } from 'xforge-common/util/rxjs-util'; +import { SFProjectService } from '../core/sf-project.service'; +import { EventMetric } from './event-metric'; + +interface Row { + eventType: string; + scope: string; + timeStamp: string; + userId: string; +} + +@Component({ + selector: 'app-event-metrics-log', + templateUrl: './event-metrics-log.component.html', + styleUrls: ['./event-metrics-log.component.scss'], + standalone: true, + imports: [UICommonModule, OwnerComponent] +}) +export class EventMetricsLogComponent extends DataLoadingComponent implements OnInit { + columnsToDisplay: string[] = ['scope', 'eventType', 'author']; + rows: Row[] = []; + + pageIndex: number = 0; + pageSize: number = 50; + + private eventMetrics?: Readonly; + + constructor( + noticeService: NoticeService, + private readonly i18n: I18nService, + private readonly activatedProjectService: ActivatedProjectService, + private readonly projectService: SFProjectService + ) { + super(noticeService); + } + + get isLoading(): boolean { + return this.eventMetrics == null; + } + + ngOnInit(): void { + this.loadingStarted(); + this.subscribe( + this.activatedProjectService.projectId$.pipe( + filterNullish(), + switchMap(async projectId => { + this.eventMetrics = await this.projectService.onlineEventMetrics(projectId, this.pageIndex, this.pageSize); + this.generateRows(); + this.loadingFinished(); + }) + ) + ); + } + + private generateRows(): void { + if (this.eventMetrics == null) { + return; + } + + const rows: Row[] = []; + for (const eventMetric of this.eventMetrics) { + rows.push({ + eventType: this.getEventType(eventMetric.eventType), + scope: eventMetric.scope, + timeStamp: this.i18n.formatDate(new Date(eventMetric.timeStamp), { showTimeZone: true }), + userId: eventMetric.userId + }); + } + this.rows = rows; + } + + private getEventType(eventType: string): string { + // These values are the functions that have the LogEventMetric attribute, where: + // - The case is the name of the method + // - The return value is a user friendly description of what the method does + // NOTE: These values are not localized, but can be localized if needed. + switch (eventType) { + case 'CancelPreTranslationBuildAsync': + return 'Cancel draft generation'; + case 'CancelSyncAsync': + return 'Cancel synchronization with Paratext'; + case 'SetDraftAppliedAsync': + return "Updated the chapter's draft applied status"; + case 'SetIsValidAsync': + return 'Marked chapter as valid/invalid'; + case 'SetPreTranslateAsync': + return 'Set drafting as enabled/disabled for the project'; + case 'SetServalConfigAsync': + return 'Manually update drafting configuration for the project'; + case 'StartBuildAsync': + return 'Begin training translation suggestions'; + case 'StartPreTranslationBuildAsync': + return 'Start draft generation'; + case 'SyncAsync': + return 'Synchronize with Paratext'; + default: + return eventType; + } + } +} diff --git a/src/SIL.XForge.Scripture/ClientApp/src/app/event-metrics/event-metrics.component.html b/src/SIL.XForge.Scripture/ClientApp/src/app/event-metrics/event-metrics.component.html index d1ee64c0d1..6eb50f3e42 100644 --- a/src/SIL.XForge.Scripture/ClientApp/src/app/event-metrics/event-metrics.component.html +++ b/src/SIL.XForge.Scripture/ClientApp/src/app/event-metrics/event-metrics.component.html @@ -1 +1,2 @@

Event Log

+ diff --git a/src/SIL.XForge.Scripture/ClientApp/src/app/event-metrics/event-metrics.component.ts b/src/SIL.XForge.Scripture/ClientApp/src/app/event-metrics/event-metrics.component.ts index 0b007eac01..5bcf58f4bf 100644 --- a/src/SIL.XForge.Scripture/ClientApp/src/app/event-metrics/event-metrics.component.ts +++ b/src/SIL.XForge.Scripture/ClientApp/src/app/event-metrics/event-metrics.component.ts @@ -1,10 +1,11 @@ import { Component } from '@angular/core'; +import { EventMetricsLogComponent } from './event-metrics-log.component'; @Component({ selector: 'app-event-metrics', templateUrl: './event-metrics.component.html', styleUrls: ['./event-metrics.component.scss'], standalone: true, - imports: [] + imports: [EventMetricsLogComponent] }) export class EventMetricsComponent {}