Skip to content
Merged
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@ import { SFProjectService } from '../../../../core/sf-project.service';
import { BuildDto } from '../../../../machine-api/build-dto';
import { BuildStates } from '../../../../machine-api/build-states';
import { DraftGenerationService } from '../../draft-generation.service';
import { FORMATTING_OPTIONS_SUPPORTED_DATE } from '../../draft-utils';
import { DraftOptionsService, FORMATTING_OPTIONS_SUPPORTED_DATE } from '../../draft-options.service';
import { TrainingDataService } from '../../training-data/training-data.service';
import { DraftHistoryEntryComponent } from './draft-history-entry.component';

Expand All @@ -33,6 +33,7 @@ const mockedUserService = mock(UserService);
const mockedTrainingDataService = mock(TrainingDataService);
const mockedActivatedProjectService = mock(ActivatedProjectService);
const mockedFeatureFlagsService = mock(FeatureFlagService);
const mockedDraftOptionsService = mock(DraftOptionsService);

const oneDay = 1000 * 60 * 60 * 24;
const dateBeforeFormattingSupported = new Date(FORMATTING_OPTIONS_SUPPORTED_DATE.getTime() - oneDay).toISOString();
Expand All @@ -56,7 +57,8 @@ describe('DraftHistoryEntryComponent', () => {
{ provide: UserService, useMock: mockedUserService },
{ provide: TrainingDataService, useMock: mockedTrainingDataService },
{ provide: ActivatedProjectService, useMock: mockedActivatedProjectService },
{ provide: FeatureFlagService, useMock: mockedFeatureFlagsService }
{ provide: FeatureFlagService, useMock: mockedFeatureFlagsService },
{ provide: DraftOptionsService, useMock: mockedDraftOptionsService }
]
}));

Expand All @@ -81,6 +83,7 @@ describe('DraftHistoryEntryComponent', () => {
when(mockedTrainingDataService.queryTrainingDataAsync(anything(), anything())).thenResolve(
instance(trainingDataQuery)
);
when(mockedDraftOptionsService.areFormattingOptionsAvailableButUnselected()).thenReturn(true);
fixture = TestBed.createComponent(DraftHistoryEntryComponent);
component = fixture.componentInstance;
fixture.detectChanges();
Expand Down Expand Up @@ -165,6 +168,8 @@ describe('DraftHistoryEntryComponent', () => {
}));

it('should show the USFM format option when the project is the latest draft', fakeAsync(() => {
when(mockedDraftOptionsService.areFormattingOptionsAvailableButUnselected()).thenReturn(false);
when(mockedDraftOptionsService.areFormattingOptionsSupportedForBuild(anything())).thenReturn(true);
const user = 'user-display-name';
const date = dateAfterFormattingSupported;
const trainingBooks = ['EXO'];
Expand Down Expand Up @@ -236,6 +241,8 @@ describe('DraftHistoryEntryComponent', () => {
}));

it('should handle builds with additional info referencing a deleted user', fakeAsync(() => {
when(mockedDraftOptionsService.areFormattingOptionsAvailableButUnselected()).thenReturn(false);
when(mockedDraftOptionsService.areFormattingOptionsSupportedForBuild(anything())).thenReturn(true);
when(mockedI18nService.formatDate(anything())).thenReturn('formatted-date');
when(mockedI18nService.formatAndLocalizeScriptureRange('GEN')).thenReturn('Genesis');
when(mockedI18nService.formatAndLocalizeScriptureRange('EXO')).thenReturn('Exodus');
Expand Down Expand Up @@ -281,7 +288,6 @@ describe('DraftHistoryEntryComponent', () => {
expect(component.buildRequestedByUserName).toBeUndefined();
expect(component.buildRequestedAtDate).toBe('');
expect(component.draftIsAvailable).toBe(false);
expect(fixture.nativeElement.querySelector('.format-usfm')).toBeNull();
expect(component.hasDetails).toBe(false);
expect(component.entry).toBe(entry);
});
Expand Down Expand Up @@ -347,6 +353,8 @@ describe('DraftHistoryEntryComponent', () => {
});

it('should show set draft format UI', fakeAsync(() => {
when(mockedDraftOptionsService.areFormattingOptionsAvailableButUnselected()).thenReturn(false);
when(mockedDraftOptionsService.areFormattingOptionsSupportedForBuild(anything())).thenReturn(true);
const date = dateAfterFormattingSupported;
component.entry = {
id: 'build01',
Expand Down Expand Up @@ -413,6 +421,7 @@ describe('DraftHistoryEntryComponent', () => {
}));

it('should not show the USFM format option for drafts created before the supported date', fakeAsync(() => {
when(mockedDraftOptionsService.areFormattingOptionsSupportedForBuild(anything())).thenReturn(false);
const user = 'user-display-name';
const date = dateBeforeFormattingSupported;
const trainingBooks = ['EXO'];
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -18,8 +18,8 @@ import { BuildDto } from '../../../../machine-api/build-dto';
import { BuildStates } from '../../../../machine-api/build-states';
import { RIGHT_TO_LEFT_MARK } from '../../../../shared/utils';
import { DraftDownloadButtonComponent } from '../../draft-download-button/draft-download-button.component';
import { DraftOptionsService } from '../../draft-options.service';
import { DraftPreviewBooksComponent } from '../../draft-preview-books/draft-preview-books.component';
import { FORMATTING_OPTIONS_SUPPORTED_DATE } from '../../draft-utils';
import { TrainingDataService } from '../../training-data/training-data.service';

const STATUS_INFO: Record<BuildStates, { icons: string; text: string; color: string }> = {
Expand Down Expand Up @@ -269,13 +269,11 @@ export class DraftHistoryEntryComponent {
}

get formattingOptionsSelected(): boolean {
return this.activatedProjectService.projectDoc?.data?.translateConfig.draftConfig.usfmConfig != null;
return this.draftOptionsService.areFormattingOptionsSelected();
}

get formattingOptionsSupported(): boolean {
return this.featureFlags.usfmFormat.enabled && this.entry?.additionalInfo?.dateFinished != null
? new Date(this.entry.additionalInfo.dateFinished) > FORMATTING_OPTIONS_SUPPORTED_DATE
: false;
return this.draftOptionsService.areFormattingOptionsSupportedForBuild(this.entry);
}

@Input() isLatestBuild: boolean = false;
Expand All @@ -295,6 +293,7 @@ export class DraftHistoryEntryComponent {
private readonly trainingDataService: TrainingDataService,
private readonly activatedProjectService: ActivatedProjectService,
readonly featureFlags: FeatureFlagService,
private readonly draftOptionsService: DraftOptionsService,
private readonly destroyRef: DestroyRef
) {}

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,130 @@
import { TestBed } from '@angular/core/testing';
import {
DraftUsfmConfig,
ParagraphBreakFormat,
QuoteFormat
} from 'realtime-server/lib/esm/scriptureforge/models/translate-config';
import { instance, mock, when } from 'ts-mockito';
import { ActivatedProjectService } from 'xforge-common/activated-project.service';
import { createTestFeatureFlag, FeatureFlagService } from 'xforge-common/feature-flags/feature-flag.service';
import { SFProjectProfileDoc } from '../../core/models/sf-project-profile-doc';
import { BuildDto } from '../../machine-api/build-dto';
import { DraftOptionsService, FORMATTING_OPTIONS_SUPPORTED_DATE } from './draft-options.service';

const mockedActivatedProject = mock(ActivatedProjectService);
const mockedFeatureFlagService = mock(FeatureFlagService);

describe('DraftOptionsService', () => {
let service: DraftOptionsService;

beforeEach(() => {
TestBed.configureTestingModule({
providers: [
DraftOptionsService,
{ provide: ActivatedProjectService, useValue: instance(mockedActivatedProject) },
{ provide: FeatureFlagService, useValue: instance(mockedFeatureFlagService) }
]
});
when(mockedFeatureFlagService.usfmFormat).thenReturn(createTestFeatureFlag(true));
service = TestBed.inject(DraftOptionsService);
});

function buildProjectDoc(usfmConfig: Partial<DraftUsfmConfig> | 'absent'): SFProjectProfileDoc {
const draftConfig: any = {};
if (usfmConfig === 'absent') {
} else {
draftConfig.usfmConfig = { ...usfmConfig };
}
const doc = {
data: {
translateConfig: {
draftConfig
}
}
} as unknown as SFProjectProfileDoc;
return doc;
}

const PROJECT_DOC_BOTH_FORMATS: SFProjectProfileDoc = buildProjectDoc({
paragraphFormat: ParagraphBreakFormat.BestGuess,
quoteFormat: QuoteFormat.Normalized
});
const PROJECT_DOC_PARAGRAPH_ONLY: SFProjectProfileDoc = buildProjectDoc({
paragraphFormat: ParagraphBreakFormat.BestGuess
});
const PROJECT_DOC_QUOTE_ONLY: SFProjectProfileDoc = buildProjectDoc({
quoteFormat: QuoteFormat.Normalized
});
const PROJECT_DOC_EMPTY_USFM: SFProjectProfileDoc = buildProjectDoc({});

describe('areFormattingOptionsSelected', () => {
it('returns true when flag enabled and both options set', () => {
when(mockedActivatedProject.projectDoc).thenReturn(PROJECT_DOC_BOTH_FORMATS);
expect(service.areFormattingOptionsSelected()).toBe(true);
});

it('returns false when flag enabled and one option missing', () => {
when(mockedActivatedProject.projectDoc).thenReturn(PROJECT_DOC_PARAGRAPH_ONLY);
expect(service.areFormattingOptionsSelected()).toBe(false);
});

it('returns false when flag enabled and both options missing', () => {
when(mockedActivatedProject.projectDoc).thenReturn(PROJECT_DOC_EMPTY_USFM);
expect(service.areFormattingOptionsSelected()).toBe(false);
});

it('returns false when flag disabled even if both options set', () => {
when(mockedFeatureFlagService.usfmFormat).thenReturn(createTestFeatureFlag(false));
when(mockedActivatedProject.projectDoc).thenReturn(PROJECT_DOC_BOTH_FORMATS);
expect(service.areFormattingOptionsSelected()).toBe(false);
});
});

describe('areFormattingOptionsAvailableButUnselected', () => {
it('returns true when flag enabled and both options missing', () => {
when(mockedActivatedProject.projectDoc).thenReturn(PROJECT_DOC_EMPTY_USFM);
expect(service.areFormattingOptionsAvailableButUnselected()).toBe(true);
});

it('returns true when flag enabled and one option missing', () => {
when(mockedActivatedProject.projectDoc).thenReturn(PROJECT_DOC_QUOTE_ONLY);
expect(service.areFormattingOptionsAvailableButUnselected()).toBe(true);
});

it('returns false when flag enabled and both options set', () => {
when(mockedActivatedProject.projectDoc).thenReturn(PROJECT_DOC_BOTH_FORMATS);
expect(service.areFormattingOptionsAvailableButUnselected()).toBe(false);
});

it('returns false when flag disabled', () => {
when(mockedFeatureFlagService.usfmFormat).thenReturn(createTestFeatureFlag(false));
when(mockedActivatedProject.projectDoc).thenReturn(PROJECT_DOC_EMPTY_USFM);
expect(service.areFormattingOptionsAvailableButUnselected()).toBe(false);
});
});

describe('areFormattingOptionsSupportedForBuild', () => {
function buildWith(date: Date | undefined, flagEnabled: boolean = true): BuildDto | undefined {
when(mockedFeatureFlagService.usfmFormat).thenReturn(createTestFeatureFlag(flagEnabled));
if (date == null) {
return { additionalInfo: {} } as BuildDto;
}
return { additionalInfo: { dateFinished: date.toJSON() } } as BuildDto;
}

it('returns true when flag enabled and date after supported date', () => {
const entry = buildWith(new Date(FORMATTING_OPTIONS_SUPPORTED_DATE.getTime() + 1));
expect(service.areFormattingOptionsSupportedForBuild(entry)).toBe(true);
});

it('returns false when flag disabled even if date after supported date', () => {
const entry = buildWith(new Date(FORMATTING_OPTIONS_SUPPORTED_DATE.getTime() + 1), false);
expect(service.areFormattingOptionsSupportedForBuild(entry)).toBe(false);
});

it('returns false when date before supported date', () => {
const entry = buildWith(new Date(FORMATTING_OPTIONS_SUPPORTED_DATE.getTime() - 1));
expect(service.areFormattingOptionsSupportedForBuild(entry)).toBe(false);
});
});
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
import { Injectable } from '@angular/core';
import { ActivatedProjectService } from 'xforge-common/activated-project.service';
import { FeatureFlagService } from 'xforge-common/feature-flags/feature-flag.service';
import { BuildDto } from '../../machine-api/build-dto';

// Corresponds to Serval 1.11.0 release
export const FORMATTING_OPTIONS_SUPPORTED_DATE: Date = new Date('2025-09-25T00:00:00Z');

@Injectable({
providedIn: 'root'
})
export class DraftOptionsService {
constructor(
private readonly activatedProjectService: ActivatedProjectService,
private readonly featureFlags: FeatureFlagService
) {}

areFormattingOptionsSelected(): boolean {
return (
this.featureFlags.usfmFormat.enabled &&
this.activatedProjectService.projectDoc?.data?.translateConfig.draftConfig.usfmConfig?.paragraphFormat != null &&
this.activatedProjectService.projectDoc?.data?.translateConfig.draftConfig.usfmConfig?.quoteFormat != null
);
}

areFormattingOptionsAvailableButUnselected(): boolean {
return (
this.featureFlags.usfmFormat.enabled &&
(this.activatedProjectService.projectDoc?.data?.translateConfig.draftConfig.usfmConfig?.paragraphFormat == null ||
this.activatedProjectService.projectDoc?.data?.translateConfig.draftConfig.usfmConfig?.quoteFormat == null)
);
}

areFormattingOptionsSupportedForBuild(entry: BuildDto | undefined): boolean {
return this.featureFlags.usfmFormat.enabled && entry?.additionalInfo?.dateFinished != null
? new Date(entry.additionalInfo.dateFinished) > FORMATTING_OPTIONS_SUPPORTED_DATE
: false;
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -3,9 +3,6 @@ import { TranslateSource } from 'realtime-server/lib/esm/scriptureforge/models/t
import language_code_mapping from '../../../../../language_code_mapping.json';
import { SelectableProjectWithLanguageCode } from '../../core/paratext.service';

// Corresponds to Serval 1.11.0 release
export const FORMATTING_OPTIONS_SUPPORTED_DATE: Date = new Date('2025-09-25T00:00:00Z');

/** Represents draft sources as a set of two {@link TranslateSource} arrays, and one {@link SFProjectProfile} array. */
export interface DraftSourcesAsTranslateSourceArrays {
trainingSources: TranslateSource[];
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -44,24 +44,15 @@
}
<div class="apply-draft-button-container">
@if (featureFlags.usfmFormat.enabled) {
@if (mustChooseFormattingOptions) {
<button mat-flat-button (click)="navigateToFormatting()">
<span
[matTooltip]="t(doesLatestHaveDraft ? 'format_draft_can' : 'format_draft_cannot')"
[style.cursor]="doesLatestHaveDraft ? 'pointer' : 'not-allowed'"
>
<button mat-button (click)="navigateToFormatting()" [disabled]="!doesLatestHaveDraft">
<mat-icon>build</mat-icon>
<transloco class="hide-lt-md" key="editor_draft_tab.format_draft"></transloco>
<transloco class="hide-gt-md" key="editor_draft_tab.formatting"></transloco>
<transloco key="editor_draft_tab.format_draft"></transloco>
</button>
} @else if (formattingOptionsSupported) {
<span
[matTooltip]="t(doesLatestBuildHaveDraft ? 'format_draft_can' : 'format_draft_cannot')"
[style.cursor]="doesLatestBuildHaveDraft ? 'pointer' : 'not-allowed'"
>
<button mat-button (click)="navigateToFormatting()" [disabled]="!doesLatestBuildHaveDraft">
<mat-icon>build</mat-icon>
<transloco class="hide-lt-md" key="editor_draft_tab.format_draft"></transloco>
<transloco class="hide-gt-md" key="editor_draft_tab.formatting"></transloco>
</button>
</span>
}
</span>
}
@if (userAppliedDraft) {
<span class="draft-indicator">
Expand All @@ -70,19 +61,14 @@
</span>
}
@if (canApplyDraft) {
<span
[matTooltip]="t(mustChooseFormattingOptions ? 'format_draft_before' : 'add_chapter_to_project')"
[style.cursor]="mustChooseFormattingOptions ? 'not-allowed' : 'pointer'"
>
<button mat-flat-button color="primary" (click)="applyDraft()" [disabled]="mustChooseFormattingOptions">
<mat-icon>auto_awesome</mat-icon>
@if (isDraftApplied) {
<transloco key="editor_draft_tab.reapply_to_project"></transloco>
} @else {
<transloco key="editor_draft_tab.apply_to_project"></transloco>
}
</button>
</span>
<button mat-flat-button color="primary" (click)="applyDraft()">
<mat-icon>auto_awesome</mat-icon>
@if (isDraftApplied) {
<transloco key="editor_draft_tab.reapply_to_project"></transloco>
} @else {
<transloco key="editor_draft_tab.apply_to_project"></transloco>
}
</button>
}
</div>
@if (!canApplyDraft) {
Expand Down
Loading
Loading