Skip to content

SF-3306 Add initial draft history UI #3180

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 11 commits into from
May 18, 2025
3 changes: 2 additions & 1 deletion scripts/db_tools/parse-version.ts
Original file line number Diff line number Diff line change
Expand Up @@ -39,7 +39,8 @@ class ParseVersion {
'Allow mixing in an additional training source',
'Updated Learning Rate For Serval',
'Dark Mode',
'Enable Lynx insights'
'Enable Lynx insights',
'Preview new draft history interface'
];

constructor() {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -159,15 +159,18 @@ export class EventMetricsLogComponent extends DataLoadingComponent implements On
// I have not localized at this time because these strings are likely to change based on feedback.
// When this feature is mature, these should be localized to help Project Administrators.
const eventTypeMap: { [key: string]: string } = {
BuildProjectAsync: 'Start draft generation on Serval',
CancelPreTranslationBuildAsync: 'Cancel draft generation',
CancelSyncAsync: 'Cancel synchronization with Paratext',
RetrievePreTranslationStatusAsync: 'Save drafts to Scripture Forge',
SetDraftAppliedAsync: "Updated the chapter's draft applied status",
SetIsValidAsync: 'Marked chapter as valid/invalid',
SetPreTranslateAsync: 'Set drafting as enabled/disabled for the project',
SetServalConfigAsync: 'Manually update drafting configuration for the project',
StartBuildAsync: 'Begin training translation suggestions',
StartPreTranslationBuildAsync: 'Start draft generation',
SyncAsync: 'Start synchronization with Paratext'
StartPreTranslationBuildAsync: 'Request draft generation',
SyncAsync: 'Start synchronization with Paratext',
UpdateSettingsAsync: 'Update Scripture Forge settings'
};

// Allow specific cases based on payload values
Expand Down
Original file line number Diff line number Diff line change
@@ -1,11 +1,13 @@
import { ProjectScriptureRange } from 'realtime-server/lib/esm/scriptureforge/models/translate-config';
import { BuildStates } from './build-states';
import { ResourceDto } from './resource-dto';

export interface BuildDto extends ResourceDto {
revision: number;
engine: ResourceDto;
percentCompleted: number;
message: string;
state: string;
state: BuildStates;
queueDepth: number;
additionalInfo?: ServalBuildAdditionalInfo;
}
Expand All @@ -14,7 +16,12 @@ export interface ServalBuildAdditionalInfo {
buildId: string;
corporaIds?: string[];
dateFinished?: string;
dateGenerated?: string;
dateRequested?: string;
parallelCorporaIds?: string[];
step: number;
trainingScriptureRanges: ProjectScriptureRange[];
translationEngineId: string;
translationScriptureRanges: ProjectScriptureRange[];
requestedByUserId?: string;
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
<ng-container *transloco="let t; read: 'draft_generation'">
@if (flat) {
<button
mat-flat-button
(click)="downloadDraft()"
[disabled]="downloadProgress > 0"
data-test-id="download-button"
color="primary"
>
@if (downloadProgress === 0) {
<mat-icon class="material-icons-outlined">cloud_download</mat-icon>
} @else if (downloadProgress > 0) {
<mat-icon>
<mat-spinner
diameter="18"
[value]="downloadProgress"
mode="determinate"
color="accent"
data-test-id="download-spinner"
></mat-spinner>
</mat-icon>
}
{{ t("download_draft") }}
</button>
} @else {
<button
mat-button
(click)="downloadDraft()"
[disabled]="downloadProgress > 0"
data-test-id="download-button"
color="primary"
>
@if (downloadProgress === 0) {
<mat-icon>cloud_download</mat-icon>
} @else if (downloadProgress > 0) {
<mat-icon>
<mat-spinner
diameter="18"
[value]="downloadProgress"
mode="determinate"
color="accent"
data-test-id="download-spinner"
></mat-spinner>
</mat-icon>
}
{{ t("download_draft") }}
</button>
}
</ng-container>
Original file line number Diff line number Diff line change
@@ -0,0 +1,129 @@
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { createTestProjectProfile } from 'realtime-server/lib/esm/scriptureforge/models/sf-project-test-data';
import { of, throwError } from 'rxjs';
import { anything, mock, verify, when } from 'ts-mockito';
import { ActivatedProjectService } from 'xforge-common/activated-project.service';
import { NoticeService } from 'xforge-common/notice.service';
import { configureTestingModule, TestTranslocoModule } from 'xforge-common/test-utils';
import { SFProjectProfileDoc } from '../../../core/models/sf-project-profile-doc';
import { DraftGenerationService } from '../draft-generation.service';
import { DraftDownloadButtonComponent } from './draft-download-button.component';

const mockActivatedProjectService: ActivatedProjectService = mock(ActivatedProjectService);
const mockDraftGenerationService: DraftGenerationService = mock(DraftGenerationService);
const mockNoticeService: NoticeService = mock(NoticeService);

describe('DraftDownloadButtonComponent', () => {
configureTestingModule(() => ({
imports: [TestTranslocoModule],
providers: [
{ provide: ActivatedProjectService, useMock: mockActivatedProjectService },
{ provide: DraftGenerationService, useMock: mockDraftGenerationService },
{ provide: NoticeService, useMock: mockNoticeService }
]
}));

it('should create', () => {
const env = new TestEnvironment();
expect(env.component).toBeTruthy();
});

describe('downloadProgress', () => {
it('should show number between 0 and 100', () => {
const env = new TestEnvironment();
env.component.downloadBooksProgress = 4;
env.component.downloadBooksTotal = 8;
expect(env.component.downloadProgress).toBe(50);
});

it('should not divide by zero', () => {
const env = new TestEnvironment();
env.component.downloadBooksProgress = 4;
env.component.downloadBooksTotal = 0;
expect(env.component.downloadProgress).toBe(0);
});
});

describe('download draft button', () => {
it('button should start the download', () => {
const env = new TestEnvironment();
spyOn(env.component, 'downloadDraft').and.stub();
env.fixture.detectChanges();

env.downloadButton!.click();
expect(env.component.downloadDraft).toHaveBeenCalled();
});

it('spinner should display while the download is in progress', () => {
const env = new TestEnvironment();
env.component.downloadBooksProgress = 2;
env.component.downloadBooksTotal = 4;
env.fixture.detectChanges();

expect(env.downloadSpinner).not.toBeNull();
});

it('spinner should not display while no download is in progress', () => {
const env = new TestEnvironment();
env.component.downloadBooksProgress = 0;
env.component.downloadBooksTotal = 0;
env.fixture.detectChanges();

expect(env.downloadSpinner).toBeNull();
});
});

describe('downloadDraft', () => {
it('should display an error if one occurs', () => {
const env = new TestEnvironment();
when(mockDraftGenerationService.downloadGeneratedDraftZip(anything(), anything())).thenReturn(
throwError(() => new Error())
);

env.component.downloadDraft();
expect(env.component.downloadBooksProgress).toBe(0);
expect(env.component.downloadBooksTotal).toBe(0);
verify(mockNoticeService.showError(anything())).once();
});

it('should emit draft progress', () => {
const env = new TestEnvironment();
when(mockDraftGenerationService.downloadGeneratedDraftZip(anything(), anything())).thenReturn(
of({
current: 1,
total: 2
})
);

env.component.downloadDraft();
expect(env.component.downloadBooksProgress).toBe(1);
expect(env.component.downloadBooksTotal).toBe(2);
});
});

class TestEnvironment {
component: DraftDownloadButtonComponent;
fixture: ComponentFixture<DraftDownloadButtonComponent>;

constructor() {
const mockProjectDoc = { id: 'project01', data: createTestProjectProfile() } as SFProjectProfileDoc;
when(mockActivatedProjectService.projectDoc).thenReturn(mockProjectDoc);

this.fixture = TestBed.createComponent(DraftDownloadButtonComponent);
this.component = this.fixture.componentInstance;
this.fixture.detectChanges();
}

get downloadButton(): HTMLElement | null {
return this.getElementByTestId('download-button');
}

get downloadSpinner(): HTMLElement | null {
return this.getElementByTestId('download-spinner');
}

getElementByTestId(testId: string): HTMLElement | null {
return this.fixture.nativeElement.querySelector(`[data-test-id="${testId}"]`);
}
}
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
import { CommonModule } from '@angular/common';
import { Component, Input } from '@angular/core';
import { MatButtonModule } from '@angular/material/button';
import { MatIconModule } from '@angular/material/icon';
import { MatProgressSpinnerModule } from '@angular/material/progress-spinner';
import { TranslocoModule } from '@ngneat/transloco';
import { Subscription } from 'rxjs';
import { ActivatedProjectService } from 'xforge-common/activated-project.service';
import { NoticeService } from 'xforge-common/notice.service';
import { BuildDto } from '../../../machine-api/build-dto';
import { DraftZipProgress } from '../draft-generation';
import { DraftGenerationService } from '../draft-generation.service';

@Component({
selector: 'app-draft-download-button',
templateUrl: './draft-download-button.component.html',
styleUrls: ['./draft-download-button.component.scss'],
standalone: true,
imports: [CommonModule, MatButtonModule, MatIconModule, MatProgressSpinnerModule, TranslocoModule]
})
export class DraftDownloadButtonComponent {
/**
* Tracks how many books have been downloaded for the zip file.
*/
downloadBooksProgress: number = 0;
downloadBooksTotal: number = 0;

zipSubscription?: Subscription;

@Input() build: BuildDto | undefined;
@Input() flat: boolean = false;

constructor(
private readonly activatedProject: ActivatedProjectService,
private readonly draftGenerationService: DraftGenerationService,
private readonly noticeService: NoticeService
) {}

get downloadProgress(): number {
if (this.downloadBooksTotal === 0) return 0;
return (this.downloadBooksProgress / this.downloadBooksTotal) * 100;
}

downloadDraft(): void {
this.zipSubscription?.unsubscribe();
this.zipSubscription = this.draftGenerationService
.downloadGeneratedDraftZip(this.activatedProject.projectDoc, this.build)
.subscribe({
next: (draftZipProgress: DraftZipProgress) => {
this.downloadBooksProgress = draftZipProgress.current;
this.downloadBooksTotal = draftZipProgress.total;
},
error: (error: Error) => this.noticeService.showError(error.message)
});
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -261,7 +261,7 @@ <h3>{{ t("draft_finishing_header") }}</h3>
<app-working-animated-indicator></app-working-animated-indicator>
</section>
}
@if (isDraftComplete(draftJob) || hasAnyCompletedBuild) {
@if ((isDraftComplete(draftJob) || hasAnyCompletedBuild) && !featureFlags.newDraftHistory.enabled) {
<section class="draft-complete">
<mat-card class="preview-card">
<mat-card-header>
Expand All @@ -278,28 +278,7 @@ <h3>{{ t("draft_finishing_header") }}</h3>
</mat-card-content>
<mat-card-actions>
@if (hasDraftBooksAvailable) {
<button
mat-button
type="button"
(click)="downloadDraft()"
[disabled]="downloadProgress > 0"
data-test-id="download-button"
>
@if (downloadProgress === 0) {
<mat-icon class="material-icons-outlined">cloud_download</mat-icon>
} @else if (downloadProgress > 0) {
<mat-icon>
<mat-spinner
diameter="18"
[value]="downloadProgress"
mode="determinate"
color="accent"
data-test-id="download-spinner"
></mat-spinner>
</mat-icon>
}
<span>{{ t("download_draft") }}</span>
</button>
<app-draft-download-button [build]="lastCompletedBuild" />
}
</mat-card-actions>
</mat-card>
Expand Down Expand Up @@ -354,6 +333,13 @@ <h3>{{ t("draft_finishing_header") }}</h3>
}
</section>

@if (featureFlags.newDraftHistory.enabled) {
<app-draft-history-list />
@if (isServalAdmin()) {
<h2>Further information</h2>
}
}

@if (canShowAdditionalInfo(draftJob)) {
<section>
<mat-expansion-panel class="diagnostics-info">
Expand Down
Loading
Loading