diff --git a/src/SIL.XForge.Scripture/ClientApp/src/app/checking/attach-audio/attach-audio.component.ts b/src/SIL.XForge.Scripture/ClientApp/src/app/checking/attach-audio/attach-audio.component.ts index 7c1f8b08712..b5a7a189a46 100644 --- a/src/SIL.XForge.Scripture/ClientApp/src/app/checking/attach-audio/attach-audio.component.ts +++ b/src/SIL.XForge.Scripture/ClientApp/src/app/checking/attach-audio/attach-audio.component.ts @@ -1,5 +1,5 @@ import { NgClass } from '@angular/common'; -import { Component, Input, ViewChild } from '@angular/core'; +import { ChangeDetectorRef, Component, Input, ViewChild } from '@angular/core'; import { MatIconButton } from '@angular/material/button'; import { MatDialogRef } from '@angular/material/dialog'; import { MatIcon } from '@angular/material/icon'; @@ -36,7 +36,10 @@ export class AttachAudioComponent { protected uploadAudioFile: File = {} as File; - constructor(private readonly dialogService: DialogService) {} + constructor( + private readonly changeDetector: ChangeDetectorRef, + private readonly dialogService: DialogService + ) {} get audioUrl(): string | undefined { return this.textAndAudio?.input?.audioUrl; @@ -52,6 +55,7 @@ export class AttachAudioComponent { >(AudioRecorderDialogComponent, { data: config }); const result: AudioRecorderDialogResult | undefined = await firstValueFrom(recorderDialogRef.afterClosed()); if (result?.audio != null && this.textAndAudio != null) { + this.changeDetector.markForCheck(); this.textAndAudio.setAudioAttachment(result.audio); } } diff --git a/src/SIL.XForge.Scripture/ClientApp/src/app/checking/checking-overview/checking-overview.component.spec.ts b/src/SIL.XForge.Scripture/ClientApp/src/app/checking/checking-overview/checking-overview.component.spec.ts index 37ffe4e591b..ff1d4851781 100644 --- a/src/SIL.XForge.Scripture/ClientApp/src/app/checking/checking-overview/checking-overview.component.spec.ts +++ b/src/SIL.XForge.Scripture/ClientApp/src/app/checking/checking-overview/checking-overview.component.spec.ts @@ -1,6 +1,7 @@ import { Location } from '@angular/common'; import { DebugElement, NgZone } from '@angular/core'; import { ComponentFixture, discardPeriodicTasks, fakeAsync, flush, TestBed, tick } from '@angular/core/testing'; +import { MatDialogRef } from '@angular/material/dialog'; import { MatExpansionPanel } from '@angular/material/expansion'; import { By } from '@angular/platform-browser'; import { ActivatedRoute } from '@angular/router'; @@ -26,7 +27,7 @@ import { createTestProjectUserConfig } from 'realtime-server/lib/esm/scripturefo import { TextInfo } from 'realtime-server/lib/esm/scriptureforge/models/text-info'; import { VerseRefData } from 'realtime-server/lib/esm/scriptureforge/models/verse-ref-data'; import { of } from 'rxjs'; -import { anything, mock, resetCalls, verify, when } from 'ts-mockito'; +import { anything, instance, mock, resetCalls, verify, when } from 'ts-mockito'; import { DialogService } from 'xforge-common/dialog.service'; import { NoticeService } from 'xforge-common/notice.service'; import { OnlineStatusService } from 'xforge-common/online-status.service'; @@ -51,6 +52,7 @@ import { CheckingOverviewComponent } from './checking-overview.component'; const mockedActivatedRoute = mock(ActivatedRoute); const mockedDialogService = mock(DialogService); +const mockedImportQuestionsDialogRef = mock(MatDialogRef); const mockedNoticeService = mock(NoticeService); const mockedProjectService = mock(SFProjectService); const mockedQuestionsService = mock(CheckingQuestionsService); @@ -88,13 +90,13 @@ describe('CheckingOverviewComponent', () => { })); it('should not display loading if user is offline', fakeAsync(() => { - const env = new TestEnvironment(); - env.testOnlineStatusService.setIsOnline(false); - tick(); - env.fixture.detectChanges(); + const env = new TestEnvironment(false); + env.onlineStatus = false; + expect(env.component.showQuestionsLoadingMessage).toBe(false); + expect(env.component.showNoQuestionsMessage).toBe(true); + env.waitForQuestions(); expect(env.loadingQuestionsLabel).toBeNull(); expect(env.noQuestionsLabel).not.toBeNull(); - env.waitForQuestions(); })); it('should not display "Add question" button for community checker', fakeAsync(() => { @@ -425,10 +427,7 @@ describe('CheckingOverviewComponent', () => { await questionDoc.submitJson0Op(op => { op.set(d => d.isArchived, false); }); - env.testOnlineStatusService.setIsOnline(false); - env.fixture.detectChanges(); - tick(); - env.fixture.detectChanges(); + env.onlineStatus = false; expect(env.loadingArchivedQuestionsLabel).toBeNull(); expect(env.noArchivedQuestionsLabel).not.toBeNull(); @@ -985,6 +984,10 @@ class TestEnvironment { projectDoc.submitJson0Op(op => op.set(p => p.texts[textIndex].chapters[chapterIndex].hasAudio, false), false); } ); + when(mockedImportQuestionsDialogRef.afterClosed()).thenReturn(of(undefined)); + when(mockedDialogService.openMatDialog(ImportQuestionsDialogComponent, anything())).thenReturn( + instance(mockedImportQuestionsDialogRef) + ); this.setCurrentUser(this.adminUser); this.testOnlineStatusService.setIsOnline(true); @@ -1112,6 +1115,8 @@ class TestEnvironment { this.testOnlineStatusService.setIsOnline(isOnline); tick(); this.fixture.detectChanges(); + tick(); + this.fixture.detectChanges(); } get warningSomeActionsUnavailableOffline(): DebugElement { diff --git a/src/SIL.XForge.Scripture/ClientApp/src/app/checking/checking-overview/checking-overview.component.ts b/src/SIL.XForge.Scripture/ClientApp/src/app/checking/checking-overview/checking-overview.component.ts index 47b67641472..b983acae9cf 100644 --- a/src/SIL.XForge.Scripture/ClientApp/src/app/checking/checking-overview/checking-overview.component.ts +++ b/src/SIL.XForge.Scripture/ClientApp/src/app/checking/checking-overview/checking-overview.component.ts @@ -1,5 +1,6 @@ import { NgClass } from '@angular/common'; -import { Component, DestroyRef, OnDestroy, OnInit } from '@angular/core'; +import { ChangeDetectionStrategy, ChangeDetectorRef, Component, DestroyRef, OnDestroy, OnInit } from '@angular/core'; +import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; import { MatButton, MatIconButton, MatMiniFabButton } from '@angular/material/button'; import { MatCard, MatCardContent } from '@angular/material/card'; import { @@ -20,8 +21,8 @@ import { Operation } from 'realtime-server/lib/esm/common/models/project-rights' import { SF_PROJECT_RIGHTS, SFProjectDomain } from 'realtime-server/lib/esm/scriptureforge/models/sf-project-rights'; import { Chapter, TextInfo } from 'realtime-server/lib/esm/scriptureforge/models/text-info'; import { toVerseRef, VerseRefData } from 'realtime-server/lib/esm/scriptureforge/models/verse-ref-data'; -import { asyncScheduler, merge, Subscription } from 'rxjs'; -import { map, tap, throttleTime } from 'rxjs/operators'; +import { asyncScheduler, combineLatest, merge, Subscription } from 'rxjs'; +import { map, startWith, tap, throttleTime } from 'rxjs/operators'; import { DataLoadingComponent } from 'xforge-common/data-loading-component'; import { DialogService } from 'xforge-common/dialog.service'; import { DonutChartComponent } from 'xforge-common/donut-chart/donut-chart.component'; @@ -73,22 +74,26 @@ import { QuestionDialogService } from '../question-dialog/question-dialog.servic MatCard, MatCardContent, L10nNumberPipe - ] + ], + changeDetection: ChangeDetectionStrategy.OnPush }) export class CheckingOverviewComponent extends DataLoadingComponent implements OnInit, OnDestroy { texts: TextInfo[] = []; projectId?: string; + questionsLoaded: boolean = false; private questionDocs = new Map(); private textsByBookId?: TextsByBookId; private projectDoc?: SFProjectProfileDoc; private dataChangesSub?: Subscription; + private questionsLoadedSub?: Subscription; private projectUserConfigDoc?: SFProjectUserConfigDoc; private questionsQuery?: RealtimeQuery; constructor( private readonly destroyRef: DestroyRef, private readonly activatedRoute: ActivatedRoute, + private readonly changeDetector: ChangeDetectorRef, private readonly dialogService: DialogService, noticeService: NoticeService, readonly i18n: I18nService, @@ -207,11 +212,6 @@ export class CheckingOverviewComponent extends DataLoadingComponent implements O return this.questionsQuery.docs.filter(qd => qd.data != null && !qd.data.isArchived); } - private get questionsLoaded(): boolean { - // if the user is offline, 'ready' will never be true, but the query will still return the offline docs - return !this.onlineStatusService.isOnline || this.questionsQuery?.ready === true; - } - ngOnInit(): void { let projectDocPromise: Promise; const projectId$ = this.activatedRoute.params.pipe( @@ -223,6 +223,7 @@ export class CheckingOverviewComponent extends DataLoadingComponent implements O ); projectId$.pipe(quietTakeUntilDestroyed(this.destroyRef)).subscribe(async projectId => { this.loadingStarted(); + this.changeDetector.markForCheck(); this.projectId = projectId; try { this.projectDoc = await projectDocPromise; @@ -238,14 +239,13 @@ export class CheckingOverviewComponent extends DataLoadingComponent implements O this.loadingFinished(); } - if (this.dataChangesSub != null) { - this.dataChangesSub.unsubscribe(); - } + this.dataChangesSub?.unsubscribe(); this.dataChangesSub = merge( this.projectDoc.remoteChanges$, this.questionsQuery.remoteChanges$, this.questionsQuery.localChanges$ ) + .pipe(quietTakeUntilDestroyed(this.destroyRef)) // TODO Find a better solution than merely throttling remote changes .pipe(throttleTime(1000, asyncScheduler, { leading: true, trailing: true })) .subscribe(() => { @@ -255,11 +255,30 @@ export class CheckingOverviewComponent extends DataLoadingComponent implements O } } }); + + this.questionsLoadedSub?.unsubscribe(); + this.questionsLoadedSub = combineLatest([ + this.onlineStatusService.onlineStatus$.pipe(startWith(this.onlineStatusService.isOnline)), + this.questionsQuery.ready$.pipe(startWith(this.questionsQuery?.ready)) + ]) + .pipe(quietTakeUntilDestroyed(this.destroyRef)) + .pipe(map(([isOnline, ready]) => !isOnline || ready === true)) + .subscribe(loaded => { + // Show the loading indicator if the questions are not yet ready + if (!loaded) { + this.loadingStarted(); + } else { + this.loadingFinished(); + } + + // if the user is offline, 'ready' will never be true, but the query will still return the offline docs + this.questionsLoaded = loaded; + this.changeDetector.markForCheck(); + }); }); } ngOnDestroy(): void { - this.dataChangesSub?.unsubscribe(); this.questionsQuery?.dispose(); } @@ -356,6 +375,8 @@ export class CheckingOverviewComponent extends DataLoadingComponent implements O if (questionDoc.data!.isArchived !== archive) this.setQuestionArchiveStatus(questionDoc, archive); } } + + this.changeDetector.markForCheck(); } } @@ -364,6 +385,8 @@ export class CheckingOverviewComponent extends DataLoadingComponent implements O for (const questionDoc of this.getQuestionDocs(this.getTextDocIdType(text.bookNum, chapter.number), !archive)) { if (questionDoc.data!.isArchived !== archive) this.setQuestionArchiveStatus(questionDoc, archive); } + + this.changeDetector.markForCheck(); } } @@ -454,7 +477,15 @@ export class CheckingOverviewComponent extends DataLoadingComponent implements O userId: this.userService.currentUserId, textsByBookId: this.textsByBookId }; - this.dialogService.openMatDialog(ImportQuestionsDialogComponent, { data }); + this.changeDetector.detach(); + const dialogRef = this.dialogService.openMatDialog(ImportQuestionsDialogComponent, { data }); + dialogRef + .afterClosed() + .pipe(takeUntilDestroyed(this.destroyRef)) + .subscribe(() => { + this.changeDetector.reattach(); + this.changeDetector.markForCheck(); + }); } getBookName(text: TextInfo): string { @@ -511,6 +542,8 @@ export class CheckingOverviewComponent extends DataLoadingComponent implements O for (const questionDoc of this.questionsQuery.docs) { this.addQuestionDoc(questionDoc); } + + this.changeDetector.markForCheck(); } private addQuestionDoc(questionDoc: QuestionDoc): void { diff --git a/src/SIL.XForge.Scripture/ClientApp/src/app/checking/checking/checking-answers/checking-answers.component.ts b/src/SIL.XForge.Scripture/ClientApp/src/app/checking/checking/checking-answers/checking-answers.component.ts index 24b82a6a2e4..4a8ad1385af 100644 --- a/src/SIL.XForge.Scripture/ClientApp/src/app/checking/checking/checking-answers/checking-answers.component.ts +++ b/src/SIL.XForge.Scripture/ClientApp/src/app/checking/checking/checking-answers/checking-answers.component.ts @@ -1,6 +1,7 @@ import { Dir } from '@angular/cdk/bidi'; import { NgClass } from '@angular/common'; import { + ChangeDetectorRef, Component, DestroyRef, EventEmitter, @@ -155,6 +156,7 @@ export class CheckingAnswersComponent implements OnInit { private readonly fileService: FileService, private readonly onlineStatusService: OnlineStatusService, private readonly projectService: SFProjectService, + private readonly changeDetector: ChangeDetectorRef, private destroyRef: DestroyRef ) {} @@ -192,14 +194,16 @@ export class CheckingAnswersComponent implements OnInit { if (questionDoc == null) { return; } - void this.updateQuestionDocAudioUrls(); + this.changeDetector.markForCheck(); + this.updateQuestionDocAudioUrls(); if (this.questionChangeSubscription != null) { this.questionChangeSubscription!.unsubscribe(); } this.questionChangeSubscription = questionDoc.remoteChanges$ .pipe(quietTakeUntilDestroyed(this.destroyRef)) .subscribe(ops => { - void this.updateQuestionDocAudioUrls(); + this.changeDetector.markForCheck(); + this.updateQuestionDocAudioUrls(); // If the user hasn't added an answer yet and is able to, then // don't hold back any incoming answers from appearing right away // as soon as the user adds their answer. @@ -215,7 +219,10 @@ export class CheckingAnswersComponent implements OnInit { const answer = this.allAnswers[op.p[1]]; if (this.answersHighlightStatus.has(answer.dataId)) { this.answersHighlightStatus.set(answer.dataId, false); - setTimeout(() => this.answersHighlightStatus.set(answer.dataId, true)); + setTimeout(() => { + this.answersHighlightStatus.set(answer.dataId, true); + this.changeDetector.markForCheck(); + }); } } } @@ -401,7 +408,8 @@ export class CheckingAnswersComponent implements OnInit { }; const dialogResponseDoc: QuestionDoc | undefined = await this.questionDialogService.questionDialog(data); if (dialogResponseDoc?.data != null) { - void this.updateQuestionDocAudioUrls(); + this.changeDetector.markForCheck(); + this.updateQuestionDocAudioUrls(); this.action.emit({ action: 'edit', questionDoc: dialogResponseDoc }); } } @@ -526,6 +534,7 @@ export class CheckingAnswersComponent implements OnInit { const userDoc = await this.userService.getCurrentUser(); if (this.onlineStatusService.isOnline && userDoc.data?.isDisplayNameConfirmed !== true) { await this.userService.editDisplayName(true); + this.changeDetector.markForCheck(); } this.emitAnswerToSave(response); } @@ -577,14 +586,18 @@ export class CheckingAnswersComponent implements OnInit { return result; } - private async updateQuestionDocAudioUrls(): Promise { + private updateQuestionDocAudioUrls(): void { this.fileSources.clear(); if (this.questionDoc?.data == null) { return; } - void this.cacheFileSource(this.questionDoc, this.questionDoc.data.dataId, this.questionDoc.data.audioUrl); + void this.cacheFileSource(this.questionDoc, this.questionDoc.data.dataId, this.questionDoc.data.audioUrl).then(() => + this.changeDetector.markForCheck() + ); for (const answer of this.questionDoc.getAnswers()) { - void this.cacheFileSource(this.questionDoc, answer.dataId, answer.audioUrl); + void this.cacheFileSource(this.questionDoc, answer.dataId, answer.audioUrl).then(() => + this.changeDetector.markForCheck() + ); } } @@ -606,6 +619,7 @@ export class CheckingAnswersComponent implements OnInit { for (const answer of this.answers) { this.answersHighlightStatus.set(answer.dataId, this.shouldDrawAttentionToAnswer(answer)); } + this.changeDetector.markForCheck(); }); } @@ -624,7 +638,8 @@ export class CheckingAnswersComponent implements OnInit { this.hideAnswerForm(); this.submittingAnswer = false; this.justEditedAnswer = true; - void this.updateQuestionDocAudioUrls(); + this.changeDetector.markForCheck(); + this.updateQuestionDocAudioUrls(); } }); } @@ -635,6 +650,7 @@ export class CheckingAnswersComponent implements OnInit { } void this.projectService.isProjectAdmin(this.projectId, this.userService.currentUserId).then(isProjectAdmin => { this.isProjectAdmin = isProjectAdmin; + this.changeDetector.markForCheck(); }); } diff --git a/src/SIL.XForge.Scripture/ClientApp/src/app/checking/checking/checking-answers/checking-input-form/checking-input-form.component.ts b/src/SIL.XForge.Scripture/ClientApp/src/app/checking/checking/checking-answers/checking-input-form/checking-input-form.component.ts index c5dc626ed88..f976e3c7d28 100644 --- a/src/SIL.XForge.Scripture/ClientApp/src/app/checking/checking/checking-answers/checking-input-form/checking-input-form.component.ts +++ b/src/SIL.XForge.Scripture/ClientApp/src/app/checking/checking/checking-answers/checking-input-form/checking-input-form.component.ts @@ -1,6 +1,6 @@ import { BreakpointObserver, BreakpointState } from '@angular/cdk/layout'; import { NgClass } from '@angular/common'; -import { Component, DestroyRef, EventEmitter, Input, Output, ViewChild } from '@angular/core'; +import { ChangeDetectorRef, Component, DestroyRef, EventEmitter, Input, Output, ViewChild } from '@angular/core'; import { FormsModule } from '@angular/forms'; import { MatButton, MatIconButton } from '@angular/material/button'; import { MatIcon } from '@angular/material/icon'; @@ -84,6 +84,7 @@ export class CheckingInputFormComponent { private readonly i18n: I18nService, private readonly breakpointObserver: BreakpointObserver, private readonly mediaBreakpointService: MediaBreakpointService, + private readonly changeDetector: ChangeDetectorRef, private destroyRef: DestroyRef ) { this.breakpointObserver @@ -131,6 +132,7 @@ export class CheckingInputFormComponent { this.selectedText = selection.text; this.selectionStartClipped = selection.startClipped; this.selectionEndClipped = selection.endClipped; + this.changeDetector.markForCheck(); } }); } diff --git a/src/SIL.XForge.Scripture/ClientApp/src/app/checking/checking/checking-audio-player/checking-audio-player.component.ts b/src/SIL.XForge.Scripture/ClientApp/src/app/checking/checking/checking-audio-player/checking-audio-player.component.ts index 16cafca0917..fa5de2d7b84 100644 --- a/src/SIL.XForge.Scripture/ClientApp/src/app/checking/checking/checking-audio-player/checking-audio-player.component.ts +++ b/src/SIL.XForge.Scripture/ClientApp/src/app/checking/checking/checking-audio-player/checking-audio-player.component.ts @@ -1,5 +1,5 @@ import { Dir } from '@angular/cdk/bidi'; -import { AfterViewInit, Component, DestroyRef, Input, ViewChild } from '@angular/core'; +import { AfterViewInit, ChangeDetectorRef, Component, DestroyRef, Input, ViewChild } from '@angular/core'; import { MatIconButton } from '@angular/material/button'; import { MatIcon } from '@angular/material/icon'; import { TranslocoModule } from '@ngneat/transloco'; @@ -27,12 +27,16 @@ export class CheckingAudioPlayerComponent implements AfterViewInit { constructor( readonly i18n: I18nService, + private readonly changeDetector: ChangeDetectorRef, private destroyRef: DestroyRef ) {} ngAfterViewInit(): void { this.audioPlayer!.isAudioAvailable$.pipe(quietTakeUntilDestroyed(this.destroyRef)).subscribe(newValue => { - setTimeout(() => (this._isAudioAvailable = newValue)); + setTimeout(() => { + this._isAudioAvailable = newValue; + this.changeDetector.markForCheck(); + }); }); this._isAudioAvailable = this.audioPlayer!.isAudioAvailable$.value; } diff --git a/src/SIL.XForge.Scripture/ClientApp/src/app/checking/checking/checking.component.spec.ts b/src/SIL.XForge.Scripture/ClientApp/src/app/checking/checking/checking.component.spec.ts index 5785a086ba5..63cede9b2be 100644 --- a/src/SIL.XForge.Scripture/ClientApp/src/app/checking/checking/checking.component.spec.ts +++ b/src/SIL.XForge.Scripture/ClientApp/src/app/checking/checking/checking.component.spec.ts @@ -455,15 +455,14 @@ describe('CheckingComponent', () => { // Question 5 has been stored as the question to start at expect(env.component.questionsList!.activeQuestionDoc!.data!.dataId).toBe('q5Id'); expect(env.questions.length).toEqual(16); - - let question = env.selectQuestion(1); // Trigger route change that should happen when activating question from a different book/chapter env.setBookChapter('MAT', 1); + let question = env.selectQuestion(1); expect(env.getQuestionText(question)).toBe('Matthew question relating to chapter 1'); expect(await env.getCurrentBookAndChapter()).toBe('Matthew 1'); - question = env.selectQuestion(16); env.setBookChapter('JHN', 2); + question = env.selectQuestion(16); expect(env.getQuestionText(question)).toBe('John 2'); expect(await env.getCurrentBookAndChapter()).toBe('John 2'); env.waitForQuestionTimersToComplete(); @@ -495,6 +494,7 @@ describe('CheckingComponent', () => { env.setBookChapter('JHN', 2); env.fixture.detectChanges(); expect(env.component.questionsList!.activeQuestionDoc!.data!.dataId).toBe('q15Id'); + tick(); flush(); discardPeriodicTasks(); })); @@ -654,6 +654,7 @@ describe('CheckingComponent', () => { expect(env.component.answersPanel?.getFileSource(questionDoc.data?.audioUrl)).toBeDefined(); verify(mockedFileService.findOrUpdateCache(FileType.Audio, 'questions', questionId, 'anAudioFile.mp3')).once(); env.waitForAudioPlayer(); + tick(100); flush(); discardPeriodicTasks(); })); @@ -1731,6 +1732,7 @@ describe('CheckingComponent', () => { expect(env.scriptureText).toBe('John 2:2-5'); env.clickButton(env.saveAnswerButton); expect(env.getAnswerScriptureText(0)).toBe('…The selected text(John 2:2-5)'); + tick(100); flush(); discardPeriodicTasks(); })); @@ -2237,6 +2239,7 @@ describe('CheckingComponent', () => { it('update answer audio cache after save', fakeAsync(() => { const env = new TestEnvironment({ user: CHECKER_USER }); const questionDoc = spy(env.getQuestionDoc('q6Id')); + verify(questionDoc!.updateAnswerFileCache()).never(); env.selectQuestion(6); env.clickButton(env.getAnswerEditButton(0)); env.waitForSliderUpdate(); @@ -2298,6 +2301,7 @@ describe('CheckingComponent', () => { expect(env.getExportAnswerButton(buttonIndex).classes['status-exportable']).toBe(true); const questionDoc = env.component.questionsList!.activeQuestionDoc!; expect(questionDoc.data!.answers[0].status).toEqual(AnswerStatus.Exportable); + tick(100); flush(); discardPeriodicTasks(); })); @@ -2312,6 +2316,7 @@ describe('CheckingComponent', () => { expect(env.getResolveAnswerButton(buttonIndex).classes['status-resolved']).toBe(true); const questionDoc = env.component.questionsList!.activeQuestionDoc!; expect(questionDoc.data!.answers[0].status).toEqual(AnswerStatus.Resolved); + tick(100); flush(); discardPeriodicTasks(); })); @@ -2341,6 +2346,7 @@ describe('CheckingComponent', () => { expect(env.getExportAnswerButton(buttonIndex).classes['status-exportable']).toBeUndefined(); questionDoc = env.component.questionsList!.activeQuestionDoc!; expect(questionDoc.data!.answers[0].status).toEqual(AnswerStatus.None); + tick(100); flush(); discardPeriodicTasks(); })); @@ -2400,6 +2406,7 @@ describe('CheckingComponent', () => { env.waitForSliderUpdate(); tick(); env.fixture.detectChanges(); + tick(); segment = env.getVerse(1, 3); expect(segment.classList.contains('question-segment')).toBe(false); expect(segment.classList.contains('highlight-segment')).toBe(false); @@ -2608,7 +2615,7 @@ describe('CheckingComponent', () => { discardPeriodicTasks(); })); - it('notifies admin if chapter audio is absent and hide scripture text is enabled', fakeAsync(() => { + it('notifies admin if chapter audio is absent and hide scripture text is enabled', fakeAsync(async () => { const env = new TestEnvironment({ user: ADMIN_USER, projectBookRoute: 'MAT', @@ -2628,8 +2635,9 @@ describe('CheckingComponent', () => { op.set(p => p.texts[matTextIndex].chapters[0].hasAudio, true); }); }); + env.waitForQuestionTimersToComplete(); - env.component.addAudioTimingData(); + await env.component.addAudioTimingData(); env.waitForQuestionTimersToComplete(); env.fixture.detectChanges(); @@ -3454,6 +3462,9 @@ class TestEnvironment { questionDoc.submitJson0Op(op => op.set(q => q.answers[answerIndex].deleted, true)); this.fixture.detectChanges(); + tick(); + this.fixture.detectChanges(); + tick(); } setQuestionFilter(filter: QuestionFilter): void { diff --git a/src/SIL.XForge.Scripture/ClientApp/src/app/checking/checking/checking.component.ts b/src/SIL.XForge.Scripture/ClientApp/src/app/checking/checking/checking.component.ts index 529d7cd726c..ff00cceae62 100644 --- a/src/SIL.XForge.Scripture/ClientApp/src/app/checking/checking/checking.component.ts +++ b/src/SIL.XForge.Scripture/ClientApp/src/app/checking/checking/checking.component.ts @@ -1,7 +1,17 @@ import { Dir } from '@angular/cdk/bidi'; import { BreakpointObserver, BreakpointState } from '@angular/cdk/layout'; import { AsyncPipe, KeyValuePipe, NgClass } from '@angular/common'; -import { AfterViewInit, Component, DestroyRef, ElementRef, OnDestroy, OnInit, ViewChild } from '@angular/core'; +import { + AfterViewInit, + ChangeDetectionStrategy, + ChangeDetectorRef, + Component, + DestroyRef, + ElementRef, + OnDestroy, + OnInit, + ViewChild +} from '@angular/core'; import { MatButton, MatIconButton } from '@angular/material/button'; import { MatIcon } from '@angular/material/icon'; import { MatListSubheaderCssMatStyler, MatSelectionList } from '@angular/material/list'; @@ -97,7 +107,8 @@ interface Summary { DonutChartComponent, AsyncPipe, KeyValuePipe - ] + ], + changeDetection: ChangeDetectionStrategy.OnPush }) export class CheckingComponent extends DataLoadingComponent implements OnInit, AfterViewInit, OnDestroy { @ViewChild('answerPanelContainer') set answersPanelElement(answersPanelContainerElement: ElementRef) { @@ -192,6 +203,7 @@ export class CheckingComponent extends DataLoadingComponent implements OnInit, A private _showScriptureAudioPlayer: boolean = false; constructor( + private readonly changeDetector: ChangeDetectorRef, private readonly destroyRef: DestroyRef, private readonly activatedRoute: ActivatedRoute, private readonly projectService: SFProjectService, @@ -252,6 +264,7 @@ export class CheckingComponent extends DataLoadingComponent implements OnInit, A if (!this._isDrawerPermanent) { this.setQuestionsOverlayVisibility(false); } + this.changeDetector.markForCheck(); } } @@ -778,7 +791,10 @@ export class CheckingComponent extends DataLoadingComponent implements OnInit, A .pipe(quietTakeUntilDestroyed(this.destroyRef)) .subscribe((state: BreakpointState) => { // setting isScreenSmall causes `ExpressionChangedAfterItHasBeenCheckedError`, so wrap in setTimeout - setTimeout(() => (this.isScreenSmall = state.matches)); + setTimeout(() => { + this.isScreenSmall = state.matches; + this.changeDetector.markForCheck(); + }); }); this.activeQuestionDoc$ @@ -800,6 +816,7 @@ export class CheckingComponent extends DataLoadingComponent implements OnInit, A if (prevInScope != null) { this.prevQuestionOutOfScope = undefined; this.prevQuestion$ = of(prevInScope); + this.changeDetector.markForCheck(); } else { const prevQuestionQuery = await this.checkingQuestionsService.queryAdjacentQuestions( this.projectDoc!.id, @@ -816,6 +833,7 @@ export class CheckingComponent extends DataLoadingComponent implements OnInit, A ) .subscribe(async query => { this.prevQuestion$ = of(this.filterQuestions(query.docs)[0]); + this.changeDetector.markForCheck(); }); } @@ -824,6 +842,7 @@ export class CheckingComponent extends DataLoadingComponent implements OnInit, A if (nextInScope != null) { this.nextQuestionOutOfScope = undefined; this.nextQuestion$ = of(nextInScope); + this.changeDetector.markForCheck(); } else { const nextQuestionQuery = await this.checkingQuestionsService.queryAdjacentQuestions( this.projectDoc!.id, @@ -840,6 +859,7 @@ export class CheckingComponent extends DataLoadingComponent implements OnInit, A ) .subscribe(async query => { this.nextQuestion$ = of(this.filterQuestions(query.docs)[0]); + this.changeDetector.markForCheck(); }); } }); @@ -930,6 +950,7 @@ export class CheckingComponent extends DataLoadingComponent implements OnInit, A break; } this.calculateScriptureSliderPosition(true); + this.changeDetector.markForCheck(); } setQuestionsOverlayVisibility(visible: boolean): void { @@ -987,6 +1008,7 @@ export class CheckingComponent extends DataLoadingComponent implements OnInit, A break; } this.calculateScriptureSliderPosition(true); + this.changeDetector.markForCheck(); } checkSliderPosition(event: any): void { @@ -1190,6 +1212,7 @@ export class CheckingComponent extends DataLoadingComponent implements OnInit, A } else { this.scripturePanel.activeVerse = this.activeQuestionVerseRef; } + this.changeDetector.markForCheck(); } /** @@ -1257,6 +1280,7 @@ export class CheckingComponent extends DataLoadingComponent implements OnInit, A this.totalVisibleQuestionsString = '0'; this.updateQuestionRefs(); this.refreshSummary(); + this.changeDetector.markForCheck(); return; } @@ -1279,6 +1303,7 @@ export class CheckingComponent extends DataLoadingComponent implements OnInit, A this.updateQuestionRefs(); this.refreshSummary(); + this.changeDetector.markForCheck(); } private filterQuestions(unfilteredQuestions: readonly QuestionDoc[]): QuestionDoc[] { @@ -1313,6 +1338,7 @@ export class CheckingComponent extends DataLoadingComponent implements OnInit, A t.chapters.find(c => c.number === q.data!.verseRef.chapterNum && c.hasAudio !== true) != null ) != null ); + this.changeDetector.markForCheck(); } private getAnswerIndex(answer: Answer): number { @@ -1512,6 +1538,7 @@ export class CheckingComponent extends DataLoadingComponent implements OnInit, A this.showScriptureAudioPlayer ? this.scriptureAudioPlayerAreaHeight : answerPanelHeight ]); }, changeUpdateDelayMs); + this.changeDetector.markForCheck(); } // Unbind this component from the data when a user is removed from the project, otherwise console @@ -1526,6 +1553,7 @@ export class CheckingComponent extends DataLoadingComponent implements OnInit, A } this.questionsQuery = undefined; this.projectDoc = undefined; + this.changeDetector.markForCheck(); } private refreshSummary(): void { diff --git a/src/SIL.XForge.Scripture/ClientApp/src/app/checking/checking/single-button-audio-player/single-button-audio-player.component.ts b/src/SIL.XForge.Scripture/ClientApp/src/app/checking/checking/single-button-audio-player/single-button-audio-player.component.ts index 1c2dec57e92..78a3a9b8756 100644 --- a/src/SIL.XForge.Scripture/ClientApp/src/app/checking/checking/single-button-audio-player/single-button-audio-player.component.ts +++ b/src/SIL.XForge.Scripture/ClientApp/src/app/checking/checking/single-button-audio-player/single-button-audio-player.component.ts @@ -1,5 +1,5 @@ import { NgClass } from '@angular/common'; -import { Component, Input, OnChanges, OnDestroy } from '@angular/core'; +import { ChangeDetectorRef, Component, Input, OnChanges, OnDestroy } from '@angular/core'; import { MatIcon } from '@angular/material/icon'; import { MatProgressSpinner } from '@angular/material/progress-spinner'; import { MatTooltip } from '@angular/material/tooltip'; @@ -31,7 +31,10 @@ export class SingleButtonAudioPlayerComponent extends AudioPlayerBaseComponent i this._source = source; } - constructor(onlineStatusService: OnlineStatusService) { + constructor( + onlineStatusService: OnlineStatusService, + private readonly changeDetector: ChangeDetectorRef + ) { super(onlineStatusService); } @@ -45,14 +48,17 @@ export class SingleButtonAudioPlayerComponent extends AudioPlayerBaseComponent i calculateProgress(): void { this._progressInDegrees = this.audio?.seek !== undefined ? `${(this.audio?.seek / 100) * 360}deg` : ''; + this.changeDetector.markForCheck(); } play(): void { this.audio?.play(); + this.changeDetector.markForCheck(); } stop(): void { this.audio?.stop(); + this.changeDetector.markForCheck(); } togglePlay(): void { diff --git a/src/SIL.XForge.Scripture/ClientApp/src/app/core/models/question-doc.ts b/src/SIL.XForge.Scripture/ClientApp/src/app/core/models/question-doc.ts index ba5acc36a53..5c48d20cf5d 100644 --- a/src/SIL.XForge.Scripture/ClientApp/src/app/core/models/question-doc.ts +++ b/src/SIL.XForge.Scripture/ClientApp/src/app/core/models/question-doc.ts @@ -15,7 +15,16 @@ import { Snapshot } from 'xforge-common/models/snapshot'; */ export class QuestionDoc extends ProjectDataDoc { static readonly COLLECTION = QUESTIONS_COLLECTION; - static readonly INDEX_PATHS = QUESTION_INDEX_PATHS; + static readonly INDEX_PATHS = [ + ...QUESTION_INDEX_PATHS, + // Index for CheckingQuestionsService.queryQuestions() and CheckingQuestionsService.queryAdjacentQuestions() + // As IndexedDB does not support boolean fields in indexes (see https://github.com/w3c/IndexedDB/issues/76) + { + [obj().pathStr(n => n.projectRef)]: 1, + [obj().pathStr(n => n.verseRef.bookNum)]: 1, + [obj().pathStr(n => n.verseRef.chapterNum)]: 1 + } + ]; alwaysKeepFileOffline(fileType: FileType, dataId: string): boolean { return this.data != null && fileType === FileType.Audio && !this.data.isArchived && this.data.dataId === dataId; diff --git a/src/SIL.XForge.Scripture/ClientApp/src/app/shared/audio/audio-player/audio-player.component.ts b/src/SIL.XForge.Scripture/ClientApp/src/app/shared/audio/audio-player/audio-player.component.ts index 2850b107ab8..a3ed383014d 100644 --- a/src/SIL.XForge.Scripture/ClientApp/src/app/shared/audio/audio-player/audio-player.component.ts +++ b/src/SIL.XForge.Scripture/ClientApp/src/app/shared/audio/audio-player/audio-player.component.ts @@ -1,5 +1,5 @@ import { Dir } from '@angular/cdk/bidi'; -import { Component, OnDestroy } from '@angular/core'; +import { ChangeDetectorRef, Component, OnDestroy } from '@angular/core'; import { MatIcon } from '@angular/material/icon'; import { MatSlider, MatSliderDragEvent, MatSliderThumb } from '@angular/material/slider'; import { TranslocoModule } from '@ngneat/transloco'; @@ -23,6 +23,7 @@ export class AudioPlayerComponent extends AudioPlayerBaseComponent implements On constructor( onlineStatusService: OnlineStatusService, + private readonly changeDetector: ChangeDetectorRef, readonly i18n: I18nService ) { super(onlineStatusService); @@ -52,6 +53,7 @@ export class AudioPlayerComponent extends AudioPlayerBaseComponent implements On this._timeUpdatedSubscription = this.audio?.timeUpdated$.subscribe(() => { this._currentTime = this.audio?.currentTime ?? 0; this._seek = this.audio?.seek ?? 0; + this.changeDetector.markForCheck(); }); } @@ -63,5 +65,6 @@ export class AudioPlayerComponent extends AudioPlayerBaseComponent implements On onSeek(event: MatSliderDragEvent): void { this._seek = event.value; this.audio?.setSeek(this._seek); + this.changeDetector.markForCheck(); } }