From 2db96bc6efccc52b000b61297b8825ce853c3a3d Mon Sep 17 00:00:00 2001 From: Peter Chapman Date: Tue, 7 Jul 2026 14:58:50 +1200 Subject: [PATCH] SF-3835 Do not allow uploading training data when offline --- .../configure-sources.component.html | 1 + .../training-data-multi-select.component.html | 2 +- ...aining-data-multi-select.component.spec.ts | 12 +++++++ .../training-data-multi-select.component.ts | 7 ++++ ...training-data-upload-dialog.component.html | 2 +- ...ining-data-upload-dialog.component.spec.ts | 35 ++++++++++++++++--- .../training-data-upload-dialog.component.ts | 8 ++++- 7 files changed, 59 insertions(+), 8 deletions(-) diff --git a/src/SIL.XForge.Scripture/ClientApp/src/app/translate/draft-generation/configure-sources/configure-sources.component.html b/src/SIL.XForge.Scripture/ClientApp/src/app/translate/draft-generation/configure-sources/configure-sources.component.html index f89ce34e056..88e9ad3ae34 100644 --- a/src/SIL.XForge.Scripture/ClientApp/src/app/translate/draft-generation/configure-sources/configure-sources.component.html +++ b/src/SIL.XForge.Scripture/ClientApp/src/app/translate/draft-generation/configure-sources/configure-sources.component.html @@ -25,6 +25,7 @@

{{ t("overview_reference") }} {{ preHyphenate(re diff --git a/src/SIL.XForge.Scripture/ClientApp/src/app/translate/draft-generation/training-data/training-data-multi-select.component.spec.ts b/src/SIL.XForge.Scripture/ClientApp/src/app/translate/draft-generation/training-data/training-data-multi-select.component.spec.ts index 8924a1f961d..799654a712b 100644 --- a/src/SIL.XForge.Scripture/ClientApp/src/app/translate/draft-generation/training-data/training-data-multi-select.component.spec.ts +++ b/src/SIL.XForge.Scripture/ClientApp/src/app/translate/draft-generation/training-data/training-data-multi-select.component.spec.ts @@ -11,6 +11,7 @@ import { FileService } from 'xforge-common/file.service'; import { I18nService } from 'xforge-common/i18n.service'; import { FileType } from 'xforge-common/models/file-offline-data'; import { Locale } from 'xforge-common/models/i18n-locale'; +import { OnlineStatusService } from 'xforge-common/online-status.service'; import { configureTestingModule, getTestTranslocoModule } from 'xforge-common/test-utils'; import { UserService } from 'xforge-common/user.service'; import { SFProjectProfileDoc } from '../../../core/models/sf-project-profile-doc'; @@ -22,6 +23,7 @@ const mockActivatedProjectService = mock(ActivatedProjectService); const mockDialogService = mock(DialogService); const mockFileService = mock(FileService); const mockI18nService = mock(I18nService); +const mockOnlineStatusService = mock(OnlineStatusService); const mockTrainingDataService = mock(TrainingDataService); const mockTrainingDataUploadDialogComponent = mock>(MatDialogRef); const mockUserService = mock(UserService); @@ -56,6 +58,7 @@ describe('TrainingDataMultiSelectComponent', () => { { provide: DialogService, useMock: mockDialogService }, { provide: FileService, useMock: mockFileService }, { provide: I18nService, useMock: mockI18nService }, + { provide: OnlineStatusService, useMock: mockOnlineStatusService }, { provide: TrainingDataService, useMock: mockTrainingDataService }, { provide: UserService, useMock: mockUserService } ] @@ -105,6 +108,7 @@ describe('TrainingDataMultiSelectComponent', () => { mockDialogService.confirm('training_data_multi_select.confirm_delete', 'training_data_multi_select.delete') ).thenResolve(true); when(mockI18nService.locale$).thenReturn(of(locale)); + when(mockOnlineStatusService.isOnline).thenReturn(true); when(mockTrainingDataUploadDialogComponent.afterClosed()).thenReturn(of({ dataId: 'data04' })); fixture = TestBed.createComponent(TrainingDataMultiSelectComponent); component = fixture.componentInstance; @@ -138,6 +142,14 @@ describe('TrainingDataMultiSelectComponent', () => { expect(result).not.toContain(mockTrainingData[0]); })); + it('cannot show the upload dialog if offline', fakeAsync(() => { + when(mockOnlineStatusService.isOnline).thenReturn(false); + component.openUploadDialog(); + tick(); + + verify(mockDialogService.openMatDialog(TrainingDataUploadDialogComponent, anything())).never(); + })); + it('should show the upload dialog', fakeAsync(() => { let result: TrainingData[] = []; component.trainingDataSelect.subscribe((_result: TrainingData[]) => { diff --git a/src/SIL.XForge.Scripture/ClientApp/src/app/translate/draft-generation/training-data/training-data-multi-select.component.ts b/src/SIL.XForge.Scripture/ClientApp/src/app/translate/draft-generation/training-data/training-data-multi-select.component.ts index b742572315c..c2f4819246f 100644 --- a/src/SIL.XForge.Scripture/ClientApp/src/app/translate/draft-generation/training-data/training-data-multi-select.component.ts +++ b/src/SIL.XForge.Scripture/ClientApp/src/app/translate/draft-generation/training-data/training-data-multi-select.component.ts @@ -13,6 +13,7 @@ import { ActivatedProjectService } from 'xforge-common/activated-project.service import { DialogService } from 'xforge-common/dialog.service'; import { FileService } from 'xforge-common/file.service'; import { FileType } from 'xforge-common/models/file-offline-data'; +import { OnlineStatusService } from 'xforge-common/online-status.service'; import { UserService } from 'xforge-common/user.service'; import { TrainingDataUploadDialogComponent, @@ -33,9 +34,14 @@ export class TrainingDataMultiSelectComponent { private readonly activatedProjectService: ActivatedProjectService, private readonly dialogService: DialogService, private readonly fileService: FileService, + private readonly onlineStatus: OnlineStatusService, private readonly userService: UserService ) {} + get appOnline(): boolean { + return this.onlineStatus.isOnline; + } + canDeleteTrainingData(trainingData: TrainingData): boolean { const userId: string = this.userService.currentUserId; const project: SFProjectProfile | undefined = this.activatedProjectService.projectDoc?.data; @@ -61,6 +67,7 @@ export class TrainingDataMultiSelectComponent { openUploadDialog(): void { if (this.activatedProjectService.projectId == null) return; + if (!this.appOnline) return; const dialogConfig: MatDialogConfig = { data: { projectId: this.activatedProjectService.projectId, availableTrainingData: this.availableTrainingData }, width: '320px' diff --git a/src/SIL.XForge.Scripture/ClientApp/src/app/translate/draft-generation/training-data/training-data-upload-dialog.component.html b/src/SIL.XForge.Scripture/ClientApp/src/app/translate/draft-generation/training-data/training-data-upload-dialog.component.html index 602a93cb60e..41b4914d951 100644 --- a/src/SIL.XForge.Scripture/ClientApp/src/app/translate/draft-generation/training-data/training-data-upload-dialog.component.html +++ b/src/SIL.XForge.Scripture/ClientApp/src/app/translate/draft-generation/training-data/training-data-upload-dialog.component.html @@ -36,7 +36,7 @@

{{ t("upload_training_data") }}

- diff --git a/src/SIL.XForge.Scripture/ClientApp/src/app/translate/draft-generation/training-data/training-data-upload-dialog.component.spec.ts b/src/SIL.XForge.Scripture/ClientApp/src/app/translate/draft-generation/training-data/training-data-upload-dialog.component.spec.ts index 18840b0e8fe..2094583a0dd 100644 --- a/src/SIL.XForge.Scripture/ClientApp/src/app/translate/draft-generation/training-data/training-data-upload-dialog.component.spec.ts +++ b/src/SIL.XForge.Scripture/ClientApp/src/app/translate/draft-generation/training-data/training-data-upload-dialog.component.spec.ts @@ -1,14 +1,13 @@ import { OverlayContainer } from '@angular/cdk/overlay'; -import { provideHttpClient, withInterceptorsFromDi } from '@angular/common/http'; -import { provideHttpClientTesting } from '@angular/common/http/testing'; import { NgZone } from '@angular/core'; import { ComponentFixture, TestBed } from '@angular/core/testing'; import { MatDialog, MatDialogRef } from '@angular/material/dialog'; import { ngfModule } from 'angular-file'; import { TrainingData } from 'realtime-server/lib/esm/scriptureforge/models/training-data'; -import { anything, mock, when } from 'ts-mockito'; +import { anything, mock, verify, when } from 'ts-mockito'; import { FileService } from 'xforge-common/file.service'; import { FileType } from 'xforge-common/models/file-offline-data'; +import { OnlineStatusService } from 'xforge-common/online-status.service'; import { ChildViewContainerComponent, configureTestingModule, getTestTranslocoModule } from 'xforge-common/test-utils'; import { UserService } from 'xforge-common/user.service'; import { TrainingDataDoc } from '../../../core/models/training-data-doc'; @@ -16,6 +15,7 @@ import { TrainingDataFileUpload, TrainingDataUploadDialogComponent } from './tra import { TrainingDataService } from './training-data.service'; const mockedFileService = mock(FileService); +const mockOnlineStatusService = mock(OnlineStatusService); const mockedTrainingDataService = mock(TrainingDataService); const mockedUserService = mock(UserService); @@ -23,9 +23,8 @@ describe('TrainingDataUploadDialogComponent', () => { configureTestingModule(() => ({ imports: [ngfModule, getTestTranslocoModule()], providers: [ - provideHttpClient(withInterceptorsFromDi()), - provideHttpClientTesting(), { provide: FileService, useMock: mockedFileService }, + { provide: OnlineStatusService, useMock: mockOnlineStatusService }, { provide: TrainingDataService, useMock: mockedTrainingDataService }, { provide: UserService, useMock: mockedUserService } ] @@ -40,6 +39,31 @@ describe('TrainingDataUploadDialogComponent', () => { overlayContainer.ngOnDestroy(); }); + it('cannot save if offline', async () => { + const env = new TestEnvironment(); + when(mockOnlineStatusService.isOnline).thenReturn(false); + let result: TrainingData = { dataId: '' } as TrainingData; + env.dialogRef.afterClosed().subscribe((_result: TrainingData) => { + result = _result; + }); + env.component.updateTrainingData(env.trainingDataFile); + await env.component.save(); + await env.wait(); + + verify( + mockedFileService.onlineUploadFileOrFail( + anything(), + anything(), + anything(), + anything(), + anything(), + anything(), + anything() + ) + ).never(); + expect(result.dataId).toEqual(''); + }); + it('should upload training data and return the object on save', async () => { const env = new TestEnvironment(); let result: TrainingData = { dataId: '' } as TrainingData; @@ -121,6 +145,7 @@ class TestEnvironment { true ) ).thenResolve('training data file url'); + when(mockOnlineStatusService.isOnline).thenReturn(true); when(mockedUserService.currentUserId).thenReturn('user01'); const blob = new Blob(['source_1,target_2\nsource_2,target_2'], { type: 'text/csv' }); diff --git a/src/SIL.XForge.Scripture/ClientApp/src/app/translate/draft-generation/training-data/training-data-upload-dialog.component.ts b/src/SIL.XForge.Scripture/ClientApp/src/app/translate/draft-generation/training-data/training-data-upload-dialog.component.ts index cac7d4ba3cc..dc1f1ac17f0 100644 --- a/src/SIL.XForge.Scripture/ClientApp/src/app/translate/draft-generation/training-data/training-data-upload-dialog.component.ts +++ b/src/SIL.XForge.Scripture/ClientApp/src/app/translate/draft-generation/training-data/training-data-upload-dialog.component.ts @@ -18,6 +18,7 @@ import { DialogService } from 'xforge-common/dialog.service'; import { FileService } from 'xforge-common/file.service'; import { I18nService } from 'xforge-common/i18n.service'; import { FileType } from 'xforge-common/models/file-offline-data'; +import { OnlineStatusService } from 'xforge-common/online-status.service'; import { UserService } from 'xforge-common/user.service'; import { objectId } from 'xforge-common/utils'; import { TrainingDataDoc } from '../../../core/models/training-data-doc'; @@ -66,9 +67,14 @@ export class TrainingDataUploadDialogComponent implements AfterViewInit { private readonly dialogRef: MatDialogRef, private readonly dialogService: DialogService, private readonly fileService: FileService, + private readonly onlineStatus: OnlineStatusService, private readonly userService: UserService ) {} + get appOnline(): boolean { + return this.onlineStatus.isOnline; + } + get hasBeenUploaded(): boolean { return this.trainingDataFile?.blob != null && this.trainingDataFile?.fileName != null; } @@ -113,7 +119,7 @@ export class TrainingDataUploadDialogComponent implements AfterViewInit { async save(): Promise { // We cannot save a file if it has not been uploaded, or if offline - if (!this.hasBeenUploaded) { + if (!this.hasBeenUploaded || !this.appOnline) { return; }