diff --git a/src/RealtimeServer/scriptureforge/models/translate-config.ts b/src/RealtimeServer/scriptureforge/models/translate-config.ts index e734e659afc..580bcc5b68e 100644 --- a/src/RealtimeServer/scriptureforge/models/translate-config.ts +++ b/src/RealtimeServer/scriptureforge/models/translate-config.ts @@ -73,6 +73,8 @@ export interface DraftConfig { sendEmailOnBuildFinished?: boolean; currentScriptureRange?: string; draftedScriptureRange?: string; + draftInProgress?: boolean; + lastDraftSuccessful?: boolean; } export interface TranslateConfig { diff --git a/src/RealtimeServer/scriptureforge/services/sf-project-service.ts b/src/RealtimeServer/scriptureforge/services/sf-project-service.ts index deb2df14b33..63a505d1f05 100644 --- a/src/RealtimeServer/scriptureforge/services/sf-project-service.ts +++ b/src/RealtimeServer/scriptureforge/services/sf-project-service.ts @@ -281,6 +281,12 @@ export class SFProjectService extends ProjectService { }, draftedScriptureRange: { bsonType: 'string' + }, + lastDraftSuccessful: { + bsonType: 'bool' + }, + draftInProgress: { + bsonType: 'bool' } }, additionalProperties: false diff --git a/src/SIL.XForge.Scripture/ClientApp/src/app/navigation/navigation.component.html b/src/SIL.XForge.Scripture/ClientApp/src/app/navigation/navigation.component.html index 244ea07e5d1..83e6fb410f1 100644 --- a/src/SIL.XForge.Scripture/ClientApp/src/app/navigation/navigation.component.html +++ b/src/SIL.XForge.Scripture/ClientApp/src/app/navigation/navigation.component.html @@ -16,6 +16,11 @@ auto_awesome {{ t("generate_draft") }} + @if (draftInProgress) { + + } @else if (lastDraftFailed) { + error + } } } diff --git a/src/SIL.XForge.Scripture/ClientApp/src/app/navigation/navigation.component.scss b/src/SIL.XForge.Scripture/ClientApp/src/app/navigation/navigation.component.scss index f339acdca1f..ed321399685 100644 --- a/src/SIL.XForge.Scripture/ClientApp/src/app/navigation/navigation.component.scss +++ b/src/SIL.XForge.Scripture/ClientApp/src/app/navigation/navigation.component.scss @@ -91,3 +91,13 @@ mat-nav-list .mdc-list-item { color: var(--sf-disabled-foreground) !important; } } + +.status-icon { + margin-inline-start: auto; + margin-inline-end: 8px; +} + +:host-context([dir='rtl']) .status-icon { + margin-inline-end: auto; + margin-inline-start: 8px; +} diff --git a/src/SIL.XForge.Scripture/ClientApp/src/app/navigation/navigation.component.ts b/src/SIL.XForge.Scripture/ClientApp/src/app/navigation/navigation.component.ts index 708641cda4a..039e924ad37 100644 --- a/src/SIL.XForge.Scripture/ClientApp/src/app/navigation/navigation.component.ts +++ b/src/SIL.XForge.Scripture/ClientApp/src/app/navigation/navigation.component.ts @@ -3,6 +3,7 @@ import { Component, DestroyRef, EventEmitter, Output } from '@angular/core'; import { MatBadge } from '@angular/material/badge'; import { MatIcon } from '@angular/material/icon'; import { MatListItem, MatNavList } from '@angular/material/list'; +import { MatProgressSpinner } from '@angular/material/progress-spinner'; import { Router } from '@angular/router'; import { TranslocoModule } from '@ngneat/transloco'; import { Operation } from 'realtime-server/lib/esm/common/models/project-rights'; @@ -28,7 +29,16 @@ import { NmtDraftAuthGuard, SettingsAuthGuard, SyncAuthGuard, UsersAuthGuard } f selector: 'app-navigation', templateUrl: './navigation.component.html', styleUrls: ['./navigation.component.scss'], - imports: [TranslocoModule, MatNavList, MatListItem, RouterLinkDirective, MatIcon, MatBadge, AsyncPipe] + imports: [ + AsyncPipe, + MatBadge, + MatIcon, + MatListItem, + MatNavList, + MatProgressSpinner, + RouterLinkDirective, + TranslocoModule + ] }) export class NavigationComponent { private readonly projectChanges$ = this.activatedProjectService.changes$.pipe( @@ -92,6 +102,14 @@ export class NavigationComponent { return this.onlineStatusService.isOnline; } + get lastDraftFailed(): boolean { + return this.selectedProjectDoc?.data?.translateConfig.draftConfig.lastDraftSuccessful === false; + } + + get draftInProgress(): boolean { + return this.selectedProjectDoc?.data?.translateConfig.draftConfig.draftInProgress === true; + } + get lastSyncFailed(): boolean { return this.selectedProjectDoc?.data?.sync.lastSyncSuccessful === false; } diff --git a/src/SIL.XForge.Scripture/ClientApp/src/app/navigation/navigation.stories.ts b/src/SIL.XForge.Scripture/ClientApp/src/app/navigation/navigation.stories.ts index a2de1ddc947..22f397fdb97 100644 --- a/src/SIL.XForge.Scripture/ClientApp/src/app/navigation/navigation.stories.ts +++ b/src/SIL.XForge.Scripture/ClientApp/src/app/navigation/navigation.stories.ts @@ -47,6 +47,12 @@ function setUpMocks(args: StoryState): void { queuedCount: args.syncInProgress ? 1 : 0, lastSyncSuccessful: args.lastSyncSuccessful }, + translateConfig: { + draftConfig: { + draftInProgress: args.draftInProgress, + lastDraftSuccessful: args.lastDraftSuccessful + } + }, userRoles: { [userId]: args.role } @@ -162,6 +168,8 @@ export default meta; interface StoryState { role: SFProjectRole; online: boolean; + draftInProgress: boolean; + lastDraftSuccessful: boolean; syncInProgress: boolean; lastSyncSuccessful: boolean; checkingEnabled: boolean; @@ -174,6 +182,8 @@ export const Default: Story = { args: { role: SFProjectRole.ParatextAdministrator, online: true, + draftInProgress: false, + lastDraftSuccessful: true, syncInProgress: false, lastSyncSuccessful: true, checkingEnabled: true, @@ -246,6 +256,40 @@ export const Commenter: Story = { } }; +export const DraftNotGenerated: Story = { + args: { ...Default.args, lastDraftSuccessful: undefined, draftInProgress: false }, + play: async ({ canvasElement }) => { + expect(canvasElement.querySelector('mat-spinner.status-icon')).toBeNull(); + expect(canvasElement.querySelector('mat-icon.status-icon')).toBeNull(); + } +}; + +export const DraftInProgress: Story = { + args: { ...Default.args, draftInProgress: true }, + play: async ({ canvasElement }) => { + expect(canvasElement.querySelector('mat-icon.status-icon')).toBeNull(); + expect(canvasElement.querySelector('mat-spinner.status-icon')).toBeTruthy(); + } +}; + +export const DraftInProgressRTL: Story = { + args: { ...Default.args, draftInProgress: true }, + parameters: { locale: 'ar' } +}; + +export const DraftFailed: Story = { + args: { ...Default.args, lastDraftSuccessful: false }, + play: async ({ canvasElement }) => { + expect(canvasElement.querySelector('mat-spinner.status-icon')).toBeNull(); + expect(canvasElement.querySelector('mat-icon.status-icon')).toBeTruthy(); + } +}; + +export const DraftFailedRTL: Story = { + args: { ...Default.args, lastDraftSuccessful: false }, + parameters: { locale: 'ar' } +}; + export const SyncInProgress: Story = { args: { ...Default.args, syncInProgress: true }, play: async ({ canvasElement }) => { diff --git a/src/SIL.XForge.Scripture/Models/DraftConfig.cs b/src/SIL.XForge.Scripture/Models/DraftConfig.cs index 61c68e626c8..6ce66de9f2e 100644 --- a/src/SIL.XForge.Scripture/Models/DraftConfig.cs +++ b/src/SIL.XForge.Scripture/Models/DraftConfig.cs @@ -34,4 +34,6 @@ public class DraftConfig /// This is a combination of the scripture ranges of previous drafts. /// public string? DraftedScriptureRange { get; set; } + public bool? DraftInProgress { get; set; } + public bool? LastDraftSuccessful { get; set; } } diff --git a/src/SIL.XForge.Scripture/Services/MachineApiService.cs b/src/SIL.XForge.Scripture/Services/MachineApiService.cs index d33ea372a1a..4d6c7bd853b 100644 --- a/src/SIL.XForge.Scripture/Services/MachineApiService.cs +++ b/src/SIL.XForge.Scripture/Services/MachineApiService.cs @@ -793,6 +793,19 @@ CancellationToken cancellationToken Activity.Current?.AddTag(MachineProjectService.DraftGenerationRequestIdKey, draftGenerationRequestId); } + // Load the project from the realtime service + await using IConnection conn = await realtimeService.ConnectAsync(); + IDocument projectDoc = await conn.FetchAsync(sfProjectId); + if (projectDoc.IsLoaded) + { + // Notify the UI that a draft is in progress + await projectDoc.SubmitJson0OpAsync(op => + { + op.Set(pd => pd.TranslateConfig.DraftConfig.DraftInProgress, false); + op.Set(pd => pd.TranslateConfig.DraftConfig.LastDraftSuccessful, buildState != JobState.Faulted); + }); + } + // Retrieve the build started from the event metric. We do this as there may be multiple builds started, // and this ensures that only builds that want to send an email will have one sent. var eventMetrics = await eventMetricService.GetEventMetricsAsync( @@ -879,6 +892,17 @@ await projectSecrets.UpdateAsync( ); } + // Load the project from the realtime service + await using IConnection conn = await realtimeService.ConnectAsync(curUserId); + IDocument projectDoc = await conn.FetchAsync(sfProjectId); + if (projectDoc.IsLoaded) + { + // Notify the UI that the draft is no longer in progress + await projectDoc.SubmitJson0OpAsync(op => + op.Set(pd => pd.TranslateConfig.DraftConfig.DraftInProgress, false) + ); + } + // Get the translation engine id string translationEngineId = GetTranslationEngineId(projectSecret); @@ -3334,6 +3358,7 @@ await projectDoc.SubmitJson0OpAsync(op => [.. buildConfig.TranslationScriptureRanges], _listProjectScriptureRangeComparer ); + op.Set(pd => pd.TranslateConfig.DraftConfig.DraftInProgress, true); op.Set(p => p.TranslateConfig.DraftConfig.FastTraining, buildConfig.FastTraining); op.Set(p => p.TranslateConfig.DraftConfig.SendEmailOnBuildFinished, buildConfig.SendEmailOnBuildFinished); op.Set(p => p.TranslateConfig.DraftConfig.UseEcho, buildConfig.UseEcho); diff --git a/src/SIL.XForge.Scripture/Services/MachineProjectService.cs b/src/SIL.XForge.Scripture/Services/MachineProjectService.cs index 62abb595e38..550d1661770 100644 --- a/src/SIL.XForge.Scripture/Services/MachineProjectService.cs +++ b/src/SIL.XForge.Scripture/Services/MachineProjectService.cs @@ -119,6 +119,17 @@ await projectSecrets.UpdateAsync( cancellationToken: cancellationToken ); + // Load the project from the realtime service + await using IConnection conn = await realtimeService.ConnectAsync(curUserId); + IDocument projectDoc = await conn.FetchAsync(buildConfig.ProjectId); + if (projectDoc.IsLoaded) + { + // Notify the UI that the draft is no longer in progress + await projectDoc.SubmitJson0OpAsync(op => + op.Set(pd => pd.TranslateConfig.DraftConfig.DraftInProgress, false) + ); + } + // Send the cancellation email, if specified if (buildConfig.SendEmailOnBuildFinished) { @@ -188,6 +199,19 @@ await projectSecrets.UpdateAsync( cancellationToken: cancellationToken ); + // Load the project from the realtime service + await using IConnection conn = await realtimeService.ConnectAsync(curUserId); + IDocument projectDoc = await conn.FetchAsync(buildConfig.ProjectId); + if (projectDoc.IsLoaded) + { + // Notify the UI that the draft failed + await projectDoc.SubmitJson0OpAsync(op => + { + op.Set(pd => pd.TranslateConfig.DraftConfig.DraftInProgress, false); + op.Set(pd => pd.TranslateConfig.DraftConfig.LastDraftSuccessful, false); + }); + } + // Send the failure email, if specified if (buildConfig.SendEmailOnBuildFinished) { diff --git a/test/SIL.XForge.Scripture.Tests/Services/MachineApiServiceTests.cs b/test/SIL.XForge.Scripture.Tests/Services/MachineApiServiceTests.cs index e7651bcad96..5d60aea5d3d 100644 --- a/test/SIL.XForge.Scripture.Tests/Services/MachineApiServiceTests.cs +++ b/test/SIL.XForge.Scripture.Tests/Services/MachineApiServiceTests.cs @@ -468,6 +468,8 @@ await env.Service.BuildCompletedAsync( CancellationToken.None ); env.MockLogger.AssertHasEvent(logEvent => logEvent.LogLevel == LogLevel.Information); + Assert.That(env.Projects.Get(Project01).TranslateConfig.DraftConfig.DraftInProgress, Is.False); + Assert.That(env.Projects.Get(Project01).TranslateConfig.DraftConfig.LastDraftSuccessful, Is.True); } [Test] @@ -488,6 +490,8 @@ await env.Service.BuildCompletedAsync( CancellationToken.None ); env.MockLogger.AssertHasEvent(logEvent => logEvent.LogLevel == LogLevel.Information); + Assert.That(env.Projects.Get(Project01).TranslateConfig.DraftConfig.DraftInProgress, Is.False); + Assert.That(env.Projects.Get(Project01).TranslateConfig.DraftConfig.LastDraftSuccessful, Is.True); } [Test] @@ -510,6 +514,8 @@ await env.Service.BuildCompletedAsync( ); env.MockLogger.AssertHasEvent(logEvent => logEvent.Exception == ex); env.ExceptionHandler.Received().ReportException(ex); + Assert.That(env.Projects.Get(Project01).TranslateConfig.DraftConfig.DraftInProgress, Is.False); + Assert.That(env.Projects.Get(Project01).TranslateConfig.DraftConfig.LastDraftSuccessful, Is.True); } [Test] @@ -539,6 +545,8 @@ await env env.SiteOptions.Value.WebsiteUrl, CancellationToken.None ); + Assert.That(env.Projects.Get(Project01).TranslateConfig.DraftConfig.DraftInProgress, Is.False); + Assert.That(env.Projects.Get(Project01).TranslateConfig.DraftConfig.LastDraftSuccessful, Is.True); } [Test] @@ -568,6 +576,8 @@ await env Arg.Any(), CancellationToken.None ); + Assert.That(env.Projects.Get(Project01).TranslateConfig.DraftConfig.DraftInProgress, Is.False); + Assert.That(env.Projects.Get(Project01).TranslateConfig.DraftConfig.LastDraftSuccessful, Is.True); } [Test] @@ -691,6 +701,7 @@ public async Task CancelPreTranslationBuildAsync_Success() env.BackgroundJobClient.Received(1).ChangeState(HangfireJobId, Arg.Any(), null); // Same as Delete() Assert.IsNull(env.ProjectSecrets.Get(Project01).ServalData!.PreTranslationJobId); Assert.IsNull(env.ProjectSecrets.Get(Project01).ServalData!.PreTranslationQueuedAt); + Assert.IsFalse(env.Projects.Get(Project01).TranslateConfig.DraftConfig.DraftInProgress); } [Test] @@ -5166,6 +5177,7 @@ await env.Service.StartPreTranslationBuildAsync( Assert.AreEqual(HangfireJobId, env.ProjectSecrets.Get(Project01).ServalData!.PreTranslationJobId); Assert.IsNotNull(env.ProjectSecrets.Get(Project01).ServalData?.PreTranslationQueuedAt); Assert.IsNull(env.ProjectSecrets.Get(Project01).ServalData?.PreTranslationErrorMessage); + Assert.IsTrue(env.Projects.Get(Project01).TranslateConfig.DraftConfig.DraftInProgress); Assert.IsEmpty(env.Projects.Get(Project01).TranslateConfig.DraftConfig.LastSelectedTrainingScriptureRanges); Assert.IsEmpty(env.Projects.Get(Project01).TranslateConfig.DraftConfig.LastSelectedTrainingDataFiles); Assert.IsEmpty(env.Projects.Get(Project01).TranslateConfig.DraftConfig.LastSelectedTranslationScriptureRanges); @@ -5265,6 +5277,7 @@ await env.Service.StartPreTranslationBuildAsync( Data01, env.Projects.Get(Project01).TranslateConfig.DraftConfig.LastSelectedTrainingDataFiles.First() ); + Assert.IsTrue(env.Projects.Get(Project01).TranslateConfig.DraftConfig.DraftInProgress); } [Test] diff --git a/test/SIL.XForge.Scripture.Tests/Services/MachineProjectServiceTests.cs b/test/SIL.XForge.Scripture.Tests/Services/MachineProjectServiceTests.cs index ca628765588..2af31f395b2 100644 --- a/test/SIL.XForge.Scripture.Tests/Services/MachineProjectServiceTests.cs +++ b/test/SIL.XForge.Scripture.Tests/Services/MachineProjectServiceTests.cs @@ -141,6 +141,7 @@ await env.Service.BuildProjectForBackgroundJobAsync( Assert.IsNull(env.ProjectSecrets.Get(Project01).ServalData!.PreTranslationJobId); Assert.IsNull(env.ProjectSecrets.Get(Project01).ServalData!.PreTranslationQueuedAt); Assert.IsNull(env.ProjectSecrets.Get(Project01).ServalData!.PreTranslationErrorMessage); + Assert.IsFalse(env.Projects.Get(Project01).TranslateConfig.DraftConfig.DraftInProgress); } [Test] @@ -272,6 +273,8 @@ await env.Service.BuildProjectForBackgroundJobAsync( Assert.IsNull(env.ProjectSecrets.Get(Project01).ServalData!.PreTranslationJobId); Assert.IsNull(env.ProjectSecrets.Get(Project01).ServalData!.PreTranslationQueuedAt); Assert.AreEqual(ex.Message, env.ProjectSecrets.Get(Project01).ServalData!.PreTranslationErrorMessage); + Assert.IsFalse(env.Projects.Get(Project01).TranslateConfig.DraftConfig.DraftInProgress); + Assert.IsFalse(env.Projects.Get(Project01).TranslateConfig.DraftConfig.LastDraftSuccessful); } [Test]