From 68b4b41410f63a2fe252a66f0a0f9706a407b6a3 Mon Sep 17 00:00:00 2001 From: Peter Chapman Date: Tue, 7 Jul 2026 09:48:29 +1200 Subject: [PATCH 1/2] SF-3820 Fix latency issues with history and draft tab date dropdowns --- .../editor/editor-draft/editor-draft.component.html | 4 ++-- .../editor/editor-draft/editor-draft.component.ts | 3 +++ .../history-chooser/history-chooser.component.html | 4 ++-- .../history-chooser/history-chooser.component.ts | 2 ++ .../history-chooser/history-revision-format.pipe.ts | 12 ++++++++++-- 5 files changed, 19 insertions(+), 6 deletions(-) diff --git a/src/SIL.XForge.Scripture/ClientApp/src/app/translate/editor/editor-draft/editor-draft.component.html b/src/SIL.XForge.Scripture/ClientApp/src/app/translate/editor/editor-draft/editor-draft.component.html index 05217dc8d13..3e77d70b8f9 100644 --- a/src/SIL.XForge.Scripture/ClientApp/src/app/translate/editor/editor-draft/editor-draft.component.html +++ b/src/SIL.XForge.Scripture/ClientApp/src/app/translate/editor/editor-draft/editor-draft.component.html @@ -16,12 +16,12 @@ hideSingleSelectionIndicator > - {{ selectedRevision ?? "" | revisionFormat }} + {{ selectedRevision ?? "" | revisionFormat: (locale$ | async) }} @for (r of draftRevisions; track r) {
- {{ r | revisionFormat }} + {{ r | revisionFormat: (locale$ | async) }}
} diff --git a/src/SIL.XForge.Scripture/ClientApp/src/app/translate/editor/editor-draft/editor-draft.component.ts b/src/SIL.XForge.Scripture/ClientApp/src/app/translate/editor/editor-draft/editor-draft.component.ts index 3dc877e0da6..e009acb776c 100644 --- a/src/SIL.XForge.Scripture/ClientApp/src/app/translate/editor/editor-draft/editor-draft.component.ts +++ b/src/SIL.XForge.Scripture/ClientApp/src/app/translate/editor/editor-draft/editor-draft.component.ts @@ -39,6 +39,7 @@ import { DialogService } from 'xforge-common/dialog.service'; import { ErrorReportingService } from 'xforge-common/error-reporting.service'; import { FontService } from 'xforge-common/font.service'; import { I18nService } from 'xforge-common/i18n.service'; +import { Locale } from 'xforge-common/models/i18n-locale'; import { NoticeService } from 'xforge-common/notice.service'; import { OnlineStatusService } from 'xforge-common/online-status.service'; import { filterNullish, quietTakeUntilDestroyed } from 'xforge-common/util/rxjs-util'; @@ -120,6 +121,8 @@ export class EditorDraftComponent implements AfterViewInit, OnChanges { map(build => this.draftOptionsService.areFormattingOptionsSupportedForBuild(build)) ); + locale$: Observable = this.i18n.locale$; + private draftDelta?: Delta; private targetDelta?: Delta; private _latestPreTranslationBuild: BuildDto | undefined; diff --git a/src/SIL.XForge.Scripture/ClientApp/src/app/translate/editor/editor-history/history-chooser/history-chooser.component.html b/src/SIL.XForge.Scripture/ClientApp/src/app/translate/editor/editor-history/history-chooser/history-chooser.component.html index 50767915123..a19e2c1946b 100644 --- a/src/SIL.XForge.Scripture/ClientApp/src/app/translate/editor/editor-history/history-chooser/history-chooser.component.html +++ b/src/SIL.XForge.Scripture/ClientApp/src/app/translate/editor/editor-history/history-chooser/history-chooser.component.html @@ -11,12 +11,12 @@ hideSingleSelectionIndicator > - {{ selectedRevision ?? "" | revisionFormat }} + {{ selectedRevision ?? "" | revisionFormat: (locale$ | async) }} @for (r of historyRevisions; track r) {
- {{ r | revisionFormat }} + {{ r | revisionFormat: (locale$ | async) }} {{ r.source === "Draft" ? "auto_awesome" : r.source === "History" ? "history" : "" }} diff --git a/src/SIL.XForge.Scripture/ClientApp/src/app/translate/editor/editor-history/history-chooser/history-chooser.component.ts b/src/SIL.XForge.Scripture/ClientApp/src/app/translate/editor/editor-history/history-chooser/history-chooser.component.ts index 0844f362121..03ad99afd00 100644 --- a/src/SIL.XForge.Scripture/ClientApp/src/app/translate/editor/editor-history/history-chooser/history-chooser.component.ts +++ b/src/SIL.XForge.Scripture/ClientApp/src/app/translate/editor/editor-history/history-chooser/history-chooser.component.ts @@ -36,6 +36,7 @@ import { isNetworkError } from 'xforge-common/command.service'; import { DialogService } from 'xforge-common/dialog.service'; import { ErrorReportingService } from 'xforge-common/error-reporting.service'; import { I18nService } from 'xforge-common/i18n.service'; +import { Locale } from 'xforge-common/models/i18n-locale'; import { Snapshot } from 'xforge-common/models/snapshot'; import { TextSnapshot } from 'xforge-common/models/textsnapshot'; import { NoticeService } from 'xforge-common/notice.service'; @@ -98,6 +99,7 @@ export class HistoryChooserComponent implements AfterViewInit, OnChanges { @Output() revisionSelect = new EventEmitter(); historyRevisions: Revision[] = []; + locale$: Observable = this.i18n.locale$; selectedRevision: Revision | undefined; selectedSnapshot: TextSnapshot | undefined; diff --git a/src/SIL.XForge.Scripture/ClientApp/src/app/translate/editor/editor-history/history-chooser/history-revision-format.pipe.ts b/src/SIL.XForge.Scripture/ClientApp/src/app/translate/editor/editor-history/history-chooser/history-revision-format.pipe.ts index c903f2315e2..95cd93c8736 100644 --- a/src/SIL.XForge.Scripture/ClientApp/src/app/translate/editor/editor-history/history-chooser/history-revision-format.pipe.ts +++ b/src/SIL.XForge.Scripture/ClientApp/src/app/translate/editor/editor-history/history-chooser/history-revision-format.pipe.ts @@ -1,15 +1,23 @@ import { Pipe, PipeTransform } from '@angular/core'; import { I18nService } from 'xforge-common/i18n.service'; +import { Locale } from 'xforge-common/models/i18n-locale'; import { Revision } from '../../../../core/paratext.service'; @Pipe({ name: 'revisionFormat', - pure: false + pure: true }) export class HistoryRevisionFormatPipe implements PipeTransform { constructor(private readonly i18n: I18nService) {} - transform(revision: Revision | string): string { + /** + * Performs the transformation. + * + * @param revision The revision to display the date of. + * @param _locale This parameter is so that the pipe will re-evaluate on locale changes. It is not used directly. + * @returns The date string formatted using the user's current locale. + */ + transform(revision: Revision | string, _locale: Locale | null): string { const revisionKey = typeof revision === 'string' ? revision : revision.timestamp; return this.i18n.formatDate(new Date(revisionKey)); } From efff7c8965b22d00cf8b1194375f05fbd00c7adb Mon Sep 17 00:00:00 2001 From: Peter Chapman Date: Tue, 7 Jul 2026 11:07:52 +1200 Subject: [PATCH 2/2] SF-3820 Fix race condition when the editor loads --- .../src/app/shared/text/text.component.ts | 6 +++--- .../draft-usfm-format.component.ts | 4 ++-- .../editor-draft/editor-draft.component.ts | 6 +++--- .../editor-history.component.spec.ts | 19 +++++++++++-------- .../editor-history.component.ts | 11 +++++------ .../editor-resource.component.spec.ts | 4 ++-- 6 files changed, 26 insertions(+), 24 deletions(-) diff --git a/src/SIL.XForge.Scripture/ClientApp/src/app/shared/text/text.component.ts b/src/SIL.XForge.Scripture/ClientApp/src/app/shared/text/text.component.ts index 800d32b6e97..df3df59976d 100644 --- a/src/SIL.XForge.Scripture/ClientApp/src/app/shared/text/text.component.ts +++ b/src/SIL.XForge.Scripture/ClientApp/src/app/shared/text/text.component.ts @@ -23,7 +23,7 @@ import { SFProjectProfile } from 'realtime-server/lib/esm/scriptureforge/models/ import { SFProjectRole } from 'realtime-server/lib/esm/scriptureforge/models/sf-project-role'; import { TextAnchor } from 'realtime-server/lib/esm/scriptureforge/models/text-anchor'; import { StringMap } from 'rich-text'; -import { fromEvent, Subject, Subscription, timer } from 'rxjs'; +import { fromEvent, ReplaySubject, Subject, Subscription, timer } from 'rxjs'; import { takeUntil, tap } from 'rxjs/operators'; import { LocalPresence, Presence } from 'sharedb/lib/sharedb'; import tinyColor from 'tinycolor2'; @@ -112,7 +112,7 @@ export class TextComponent implements AfterViewInit, OnDestroy { @Output() loaded = new EventEmitter(true); @Output() focused = new EventEmitter(true); @Output() presenceChange = new EventEmitter(true); - @Output() editorCreated = new EventEmitter(); + @Output() readonly editorCreated = new ReplaySubject(1); lang: string = ''; @@ -686,7 +686,7 @@ export class TextComponent implements AfterViewInit, OnDestroy { this.viewModel.editor = editor; void this.bindQuill(); // not awaited editor.container.addEventListener('beforeinput', (ev: Event) => this.onBeforeinput(ev)); - this.editorCreated.emit(); + this.editorCreated.next(); } focus(): void { diff --git a/src/SIL.XForge.Scripture/ClientApp/src/app/translate/draft-generation/draft-usfm-format/draft-usfm-format.component.ts b/src/SIL.XForge.Scripture/ClientApp/src/app/translate/draft-generation/draft-usfm-format/draft-usfm-format.component.ts index 5ba92882cfa..3bdc015fe38 100644 --- a/src/SIL.XForge.Scripture/ClientApp/src/app/translate/draft-generation/draft-usfm-format/draft-usfm-format.component.ts +++ b/src/SIL.XForge.Scripture/ClientApp/src/app/translate/draft-generation/draft-usfm-format/draft-usfm-format.component.ts @@ -1,5 +1,5 @@ import { Location } from '@angular/common'; -import { AfterViewInit, Component, DestroyRef, EventEmitter, ViewChild } from '@angular/core'; +import { AfterViewInit, Component, DestroyRef, ViewChild } from '@angular/core'; import { FormControl, FormGroup, FormsModule, ReactiveFormsModule } from '@angular/forms'; import { MatButton } from '@angular/material/button'; import { MatCard, MatCardContent, MatCardHeader, MatCardTitle } from '@angular/material/card'; @@ -151,7 +151,7 @@ export class DraftUsfmFormatComponent extends DataLoadingComponent implements Af } ngAfterViewInit(): void { - combineLatest([this.activatedRoute.params, this.draftText.editorCreated as EventEmitter]) + combineLatest([this.activatedRoute.params, this.draftText.editorCreated]) .pipe(first(), quietTakeUntilDestroyed(this.destroyRef)) .subscribe(async ([params]) => { const projectDoc = this.activatedProjectService.projectDoc; diff --git a/src/SIL.XForge.Scripture/ClientApp/src/app/translate/editor/editor-draft/editor-draft.component.ts b/src/SIL.XForge.Scripture/ClientApp/src/app/translate/editor/editor-draft/editor-draft.component.ts index e009acb776c..f01bce796c5 100644 --- a/src/SIL.XForge.Scripture/ClientApp/src/app/translate/editor/editor-draft/editor-draft.component.ts +++ b/src/SIL.XForge.Scripture/ClientApp/src/app/translate/editor/editor-draft/editor-draft.component.ts @@ -1,5 +1,5 @@ import { AsyncPipe, NgClass } from '@angular/common'; -import { AfterViewInit, Component, DestroyRef, EventEmitter, Input, OnChanges, ViewChild } from '@angular/core'; +import { AfterViewInit, Component, DestroyRef, Input, OnChanges, ViewChild } from '@angular/core'; import { MatOption } from '@angular/material/autocomplete'; import { MatButton } from '@angular/material/button'; import { MatFormField } from '@angular/material/form-field'; @@ -231,7 +231,7 @@ export class EditorDraftComponent implements AfterViewInit, OnChanges { this.onlineStatusService.onlineStatus$, this.draftGenerationService.pollBuildProgress(this.textDocId!.projectId), this.inputChanged$.pipe(filterNullish()), - this.draftText.editorCreated as EventEmitter + this.draftText.editorCreated ]) .pipe( quietTakeUntilDestroyed(this.destroyRef), @@ -313,7 +313,7 @@ export class EditorDraftComponent implements AfterViewInit, OnChanges { combineLatest([ this.onlineStatusService.onlineStatus$, this.activatedProjectService.projectDoc$, - this.draftText.editorCreated as EventEmitter, + this.draftText.editorCreated, this.inputChanged$ ]) .pipe( diff --git a/src/SIL.XForge.Scripture/ClientApp/src/app/translate/editor/editor-history/editor-history.component.spec.ts b/src/SIL.XForge.Scripture/ClientApp/src/app/translate/editor/editor-history/editor-history.component.spec.ts index 9e7853db390..244c5930fe6 100644 --- a/src/SIL.XForge.Scripture/ClientApp/src/app/translate/editor/editor-history/editor-history.component.spec.ts +++ b/src/SIL.XForge.Scripture/ClientApp/src/app/translate/editor/editor-history/editor-history.component.spec.ts @@ -1,9 +1,9 @@ -import { EventEmitter, NO_ERRORS_SCHEMA } from '@angular/core'; +import { EventEmitter } from '@angular/core'; import { ComponentFixture, fakeAsync, TestBed, tick } from '@angular/core/testing'; import Quill from 'quill'; import { createTestProjectProfile } from 'realtime-server/lib/esm/scriptureforge/models/sf-project-test-data'; import { TextData } from 'realtime-server/lib/esm/scriptureforge/models/text-data'; -import { Subject } from 'rxjs'; +import { ReplaySubject, Subject } from 'rxjs'; import { anything, mock, when } from 'ts-mockito'; import { I18nService } from 'xforge-common/i18n.service'; import { FileType } from 'xforge-common/models/file-offline-data'; @@ -12,7 +12,7 @@ import { OnlineStatusService } from 'xforge-common/online-status.service'; import { provideTestOnlineStatus } from 'xforge-common/test-online-status-providers'; import { TestOnlineStatusService } from 'xforge-common/test-online-status.service'; import { provideTestRealtime } from 'xforge-common/test-realtime-providers'; -import { configureTestingModule } from 'xforge-common/test-utils'; +import { configureTestingModule, getTestTranslocoModule } from 'xforge-common/test-utils'; import { TypeRegistry } from 'xforge-common/type-registry'; import { SFProjectProfileDoc } from '../../../core/models/sf-project-profile-doc'; import { TextDoc, TextDocId } from '../../../core/models/text-doc'; @@ -35,8 +35,6 @@ describe('EditorHistoryComponent', () => { const showDiffChange$ = new Subject(); configureTestingModule(() => ({ - imports: [EditorHistoryComponent], - schemas: [NO_ERRORS_SCHEMA], // Ignore undeclared child components providers: [ provideTestRealtime(new TypeRegistry([TextDoc], [FileType.Audio], [])), provideTestOnlineStatus(), @@ -44,7 +42,8 @@ describe('EditorHistoryComponent', () => { { provide: ProjectNotificationService, useMock: mockProjectNotificationService }, { provide: SFProjectService, useMock: mockSFProjectService }, { provide: I18nService, useMock: mockI18nService } - ] + ], + imports: [getTestTranslocoModule()] })); beforeEach(() => { @@ -101,7 +100,8 @@ describe('EditorHistoryComponent', () => { const mockEditor = jasmine.createSpyObj(['updateContents']); const mockTextComponent = jasmine.createSpyObj(['applyEditorStyles', 'setContents'], { id: {} as TextDocId, - editor: mockEditor + editor: mockEditor, + editorCreated: new ReplaySubject(1) }); when(mockSFProjectService.getText(anything())).thenResolve(textDoc); @@ -109,6 +109,7 @@ describe('EditorHistoryComponent', () => { component.historyChooser = mockHistoryChooserComponent; component.snapshotText = mockTextComponent; component.diffText = mockTextComponent; + mockTextComponent.editorCreated.next(); component.ngAfterViewInit(); revisionSelect$.next({ revision, snapshot }); @@ -132,7 +133,8 @@ describe('EditorHistoryComponent', () => { const mockEditor = jasmine.createSpyObj(['updateContents']); const mockTextComponent = jasmine.createSpyObj(['applyEditorStyles', 'setContents'], { id: {} as TextDocId, - editor: mockEditor + editor: mockEditor, + editorCreated: new ReplaySubject(1) }); when(mockSFProjectService.getText(anything())).thenResolve(textDoc); @@ -140,6 +142,7 @@ describe('EditorHistoryComponent', () => { component.historyChooser = mockHistoryChooserComponent; component.snapshotText = mockTextComponent; component.diffText = mockTextComponent; + mockTextComponent.editorCreated.next(); component.ngAfterViewInit(); revisionSelect$.next({ revision, snapshot }); diff --git a/src/SIL.XForge.Scripture/ClientApp/src/app/translate/editor/editor-history/editor-history.component.ts b/src/SIL.XForge.Scripture/ClientApp/src/app/translate/editor/editor-history/editor-history.component.ts index a833dcb216e..a71bb3ca339 100644 --- a/src/SIL.XForge.Scripture/ClientApp/src/app/translate/editor/editor-history/editor-history.component.ts +++ b/src/SIL.XForge.Scripture/ClientApp/src/app/translate/editor/editor-history/editor-history.component.ts @@ -14,7 +14,6 @@ import { MatProgressBar } from '@angular/material/progress-bar'; import { TranslocoModule } from '@ngneat/transloco'; import { Delta } from 'quill'; import { combineLatest, startWith, tap } from 'rxjs'; -import { FontService } from 'xforge-common/font.service'; import { I18nService } from 'xforge-common/i18n.service'; import { OnlineStatusService } from 'xforge-common/online-status.service'; import { quietTakeUntilDestroyed } from 'xforge-common/util/rxjs-util'; @@ -66,7 +65,6 @@ export class EditorHistoryComponent implements OnChanges, OnInit, AfterViewInit constructor( private readonly destroyRef: DestroyRef, private readonly editorHistoryService: EditorHistoryService, - readonly fontService: FontService, private readonly i18nService: I18nService, readonly onlineStatusService: OnlineStatusService, private readonly projectService: SFProjectService @@ -95,11 +93,12 @@ export class EditorHistoryComponent implements OnChanges, OnInit, AfterViewInit } private loadHistory(): void { - if (this.historyChooser == null) { + if (this.historyChooser == null || this.snapshotText == null) { return; } combineLatest([ + this.snapshotText.editorCreated, this.historyChooser.revisionSelect.pipe( tap((e: RevisionSelectEvent) => { this.revisionSelect.emit(e.revision); @@ -108,10 +107,10 @@ export class EditorHistoryComponent implements OnChanges, OnInit, AfterViewInit this.historyChooser.showDiffChange.pipe(startWith(this.historyChooser.showDiff)) ]) .pipe(quietTakeUntilDestroyed(this.destroyRef)) - .subscribe(async ([e, showDiff]: [RevisionSelectEvent, boolean]) => { - const snapshotContents: Delta = new Delta(e.snapshot?.data.ops); + .subscribe(async ([, revisionEvent, showDiff]) => { + const snapshotContents: Delta = new Delta(revisionEvent.snapshot?.data.ops); this.snapshotText?.setContents(snapshotContents, 'api'); - this.loadedRevision = e.revision; + this.loadedRevision = revisionEvent.revision; // Show the diff, if requested if (showDiff && this.diffText?.id != null) { diff --git a/src/SIL.XForge.Scripture/ClientApp/src/app/translate/editor/editor-resource/editor-resource.component.spec.ts b/src/SIL.XForge.Scripture/ClientApp/src/app/translate/editor/editor-resource/editor-resource.component.spec.ts index 169dda056cb..553e3492a61 100644 --- a/src/SIL.XForge.Scripture/ClientApp/src/app/translate/editor/editor-resource/editor-resource.component.spec.ts +++ b/src/SIL.XForge.Scripture/ClientApp/src/app/translate/editor/editor-resource/editor-resource.component.spec.ts @@ -1,6 +1,6 @@ import { ComponentFixture, fakeAsync, TestBed, tick } from '@angular/core/testing'; import { createTestProjectProfile } from 'realtime-server/lib/esm/scriptureforge/models/sf-project-test-data'; -import { Subject } from 'rxjs'; +import { ReplaySubject } from 'rxjs'; import { anything, mock, verify, when } from 'ts-mockito'; import { DialogService } from 'xforge-common/dialog.service'; import { FontService } from 'xforge-common/font.service'; @@ -33,7 +33,7 @@ describe('EditorResourceComponent', () => { beforeEach(async () => { fixture = TestBed.createComponent(EditorResourceComponent); component = fixture.componentInstance; - component.resourceText = { editorCreated: new Subject() } as any; + component.resourceText = { editorCreated: new ReplaySubject(1) } as any; }); afterEach(() => {