Skip to content

Commit

Permalink
SF-3126 Combine Add Draft dialogs (#2954)
Browse files Browse the repository at this point in the history
  • Loading branch information
josephmyers authored Jan 28, 2025
1 parent fa16e6a commit efa579c
Show file tree
Hide file tree
Showing 7 changed files with 101 additions and 102 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -94,6 +94,7 @@ export class ProjectSelectComponent extends SubscriptionDisposable implements Co
}

@Input() set value(id: string) {
if (this.paratextIdControl?.value.paratextId === id) return;
const project =
this.projects?.find(p => p.paratextId === id) ||
this.resources?.find(r => r.paratextId === id) ||
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6,35 +6,35 @@
<h2 mat-dialog-title>{{ t("select_alternate_project") }}</h2>
<div mat-dialog-content>
<form [formGroup]="addToProjectForm">
<app-project-select
[projects]="projects"
[placeholder]="t('choose_project')"
[isDisabled]="projects.length === 0"
[invalidMessageMapper]="invalidMessageMapper"
(projectSelect)="projectSelected($event.paratextId)"
formControlName="targetParatextId"
></app-project-select>
@if (!isLoading) {
<app-project-select
[projects]="projects"
[placeholder]="t('choose_project')"
[isDisabled]="projects.length === 0"
[invalidMessageMapper]="invalidMessageMapper"
(projectSelect)="projectSelected($event.paratextId)"
formControlName="targetParatextId"
></app-project-select>
}
@if (!isAppOnline) {
<mat-error class="offline-message">{{ t("connect_to_the_internet") }}</mat-error>
}
@if (targetProject$ | async; as project) {
@if (isValid) {
<div class="target-project-content">
@if (targetChapters$ | async; as chapters) {
<app-notice icon="warning" type="warning"
>{{
i18n.getPluralRule(chapters) !== "one"
? t("project_has_text_in_chapters", { bookName, numChapters: chapters, projectName: project.name })
: t("project_has_text_in_one_chapter", { bookName, projectName: project.name })
? t("project_has_text_in_chapters", { bookName, numChapters: chapters, projectName })
: t("project_has_text_in_one_chapter", { bookName, projectName })
}}
</app-notice>
} @else {
<app-notice [icon]="'verified'">{{
t("book_is_empty", { bookName, projectName: project.name })
}}</app-notice>
<app-notice [icon]="'verified'">{{ t("book_is_empty", { bookName, projectName }) }}</app-notice>
}
</div>
<mat-checkbox class="overwrite-content" formControlName="overwrite">{{
t("i_understand_overwrite_book", { projectName: project.name, bookName })
t("i_understand_overwrite_book", { projectName, bookName })
}}</mat-checkbox>
<mat-error class="form-error" [ngClass]="{ visible: addToProjectClicked && !overwriteConfirmed }">{{
t("confirm_overwrite")
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import { CommonModule } from '@angular/common';
import { Component, Inject, OnInit, ViewChild } from '@angular/core';
import { FormControl, FormGroup, Validators } from '@angular/forms';
import { MAT_DIALOG_DATA, MatDialogRef } from '@angular/material/dialog';
import { TranslocoModule, translate } from '@ngneat/transloco';
import { translate, TranslocoModule } from '@ngneat/transloco';
import { SFProjectProfile } from 'realtime-server/lib/esm/scriptureforge/models/sf-project';
import { Chapter, TextInfo } from 'realtime-server/lib/esm/scriptureforge/models/text-info';
import { TextInfoPermission } from 'realtime-server/lib/esm/scriptureforge/models/text-info-permission';
Expand All @@ -29,6 +29,7 @@ export interface DraftApplyDialogResult {
}

export interface DraftApplyDialogConfig {
initialParatextId?: string;
bookNum: number;
chapters: number[];
}
Expand All @@ -41,12 +42,12 @@ export interface DraftApplyDialogConfig {
styleUrl: './draft-apply-dialog.component.scss'
})
export class DraftApplyDialogComponent implements OnInit {
@ViewChild(ProjectSelectComponent) projectSelect!: ProjectSelectComponent;
@ViewChild(ProjectSelectComponent) projectSelect?: ProjectSelectComponent;

_projects?: SFProjectProfile[];
isLoading: boolean = false;
protected isLoading: boolean = true;
addToProjectForm = new FormGroup({
targetParatextId: new FormControl<string | undefined>('', Validators.required),
targetParatextId: new FormControl<string | undefined>(this.data.initialParatextId, Validators.required),
overwrite: new FormControl(false, Validators.requiredTrue)
});
/** An observable that emits the number of chapters in the target project that have some text. */
Expand All @@ -69,6 +70,7 @@ export class DraftApplyDialogComponent implements OnInit {
// the project id to add the draft to
private targetProjectId?: string;
private paratextIdToProjectId: Map<string, string> = new Map<string, string>();
isValid: boolean = false;

constructor(
@Inject(MAT_DIALOG_DATA) private data: DraftApplyDialogConfig,
Expand Down Expand Up @@ -106,6 +108,10 @@ export class DraftApplyDialogComponent implements OnInit {
return this.addToProjectForm.controls.targetParatextId.valid;
}

get projectName(): string {
return this.targetProject$.value?.name ?? '';
}

get isAppOnline(): boolean {
return this.onlineStatusService.isOnline;
}
Expand All @@ -128,12 +134,15 @@ export class DraftApplyDialogComponent implements OnInit {
return projects.sort(compareProjectsForSorting);
})
)
.subscribe(projects => (this._projects = projects));
.subscribe(projects => {
this._projects = projects;
this.isLoading = false;
});
}

addToProject(): void {
this.addToProjectClicked = true;
this.projectSelect.customValidate(SFValidators.customValidator(this.getCustomErrorState()));
this.validateProject();
if (!this.isAppOnline || !this.isFormValid || this.targetProjectId == null || !this.canEditProject) {
return;
}
Expand All @@ -150,7 +159,7 @@ export class DraftApplyDialogComponent implements OnInit {
this.canEditProject = false;
this.targetBookExists = false;
this.targetProject$.next(undefined);
this.projectSelect.customValidate(SFValidators.customValidator(this.getCustomErrorState()));
this.validateProject();
return;
}

Expand All @@ -172,13 +181,21 @@ export class DraftApplyDialogComponent implements OnInit {
} else {
this.targetProject$.next(undefined);
}
this.projectSelect.customValidate(SFValidators.customValidator(this.getCustomErrorState(paratextId)));
this.validateProject();
}

close(): void {
this.dialogRef.close();
}

private validateProject(): void {
// setTimeout prevents a "changed after checked" exception (may be removable after SF-3014)
setTimeout(() => {
this.isValid = this.getCustomErrorState() === CustomErrorState.None;
this.projectSelect?.customValidate(SFValidators.customValidator(this.getCustomErrorState()));
});
}

private async chaptersWithTextAsync(project: SFProjectProfile): Promise<number> {
if (this.targetProjectId == null) return 0;
const chapters: Chapter[] | undefined = project.texts.find(t => t.bookNum === this.data.bookNum)?.chapters;
Expand All @@ -196,8 +213,8 @@ export class DraftApplyDialogComponent implements OnInit {
return textDoc.getNonEmptyVerses().length > 0;
}

private getCustomErrorState(paratextId?: string): CustomErrorState {
if (!this.projectSelectValid && paratextId == null) {
private getCustomErrorState(): CustomErrorState {
if (!this.projectSelectValid) {
return CustomErrorState.InvalidProject;
}
if (!this.targetBookExists) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -9,11 +9,11 @@
</mat-button-toggle>
</mat-button-toggle-group>
<mat-menu #menu="matMenu">
<button mat-menu-item (click)="confirmAndAddToProjectAsync(book)">
<button mat-menu-item (click)="chooseProjectToAddDraft(book, projectParatextId)">
<mat-icon>input</mat-icon>{{ book.draftApplied ? t("readd_to_project") : t("add_to_project") }}
</button>
@if ((isProjectAdmin$ | async) === true) {
<button mat-menu-item (click)="chooseAlternateProjectToAddDraft(book)">
<button mat-menu-item (click)="chooseProjectToAddDraft(book)">
<mat-icon>output</mat-icon>{{ t("add_to_different_project") }}
</button>
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -89,75 +89,79 @@ describe('DraftPreviewBooks', () => {
it('does not apply draft if user cancels', fakeAsync(() => {
env = new TestEnvironment();
const bookWithDraft: BookWithDraft = env.booksWithDrafts[0];
when(mockedDialogService.confirmWithOptions(anything())).thenResolve(false);
setupDialog();
expect(env.getBookButtonAtIndex(0).querySelector('.book-more')).toBeTruthy();
env.component.confirmAndAddToProjectAsync(bookWithDraft);
env.component.chooseProjectToAddDraft(bookWithDraft);
tick();
env.fixture.detectChanges();
verify(mockedDialogService.confirmWithOptions(anything())).once();
verify(mockedDialogService.openMatDialog(DraftApplyDialogComponent, anything())).once();
verify(mockedDraftHandlingService.getAndApplyDraftAsync(anything(), anything(), anything())).never();
}));

it('notifies user if applying a draft failed due to an error', fakeAsync(() => {
env = new TestEnvironment();
const bookWithDraft: BookWithDraft = env.booksWithDrafts[0];
when(mockedDialogService.confirmWithOptions(anything())).thenResolve(true);
setupDialog('project01');
when(mockedDraftHandlingService.getAndApplyDraftAsync(anything(), anything(), anything()))
.thenReject(new Error('Draft error'))
.thenResolve(true)
.thenResolve(false);
expect(env.getBookButtonAtIndex(0).querySelector('.book-more')).toBeTruthy();
env.component.confirmAndAddToProjectAsync(bookWithDraft);
env.component.chooseProjectToAddDraft(bookWithDraft);
tick();
env.fixture.detectChanges();
expect(env.draftApplyProgress!.chaptersApplied).toEqual([2]);
expect(env.draftApplyProgress!.completed).toBe(true);
verify(mockedDialogService.confirmWithOptions(anything())).once();
verify(mockedDialogService.openMatDialog(DraftApplyDialogComponent, anything())).once();
verify(mockedDraftHandlingService.getAndApplyDraftAsync(anything(), anything(), anything())).times(3);
verify(mockedErrorReportingService.silentError(anything(), anything())).once();
}));

it('notifies user if they do not have permission to edit a book when applying a draft', fakeAsync(() => {
env = new TestEnvironment();
const bookWithDraft: BookWithDraft = env.booksWithDrafts[2];
when(mockedDialogService.message(anything())).thenResolve();
expect(env.getBookButtonAtIndex(2).querySelector('.book-more')).toBeTruthy();
env.component.confirmAndAddToProjectAsync(bookWithDraft);
tick();
env.fixture.detectChanges();
verify(mockedDialogService.confirmWithOptions(anything())).never();
verify(mockedDraftHandlingService.getAndApplyDraftAsync(anything(), anything(), anything())).never();
verify(mockedErrorReportingService.silentError(anything(), anything())).never();
}));

it('can apply all chapters of a draft to a book', fakeAsync(() => {
env = new TestEnvironment();
const bookWithDraft: BookWithDraft = env.booksWithDrafts[0];
when(mockedDialogService.confirmWithOptions(anything())).thenResolve(true);
setupDialog('project01');
when(mockedDraftHandlingService.getAndApplyDraftAsync(anything(), anything(), anything())).thenResolve(true);
expect(env.getBookButtonAtIndex(0).querySelector('.book-more')).toBeTruthy();
env.component.confirmAndAddToProjectAsync(bookWithDraft);
env.component.chooseProjectToAddDraft(bookWithDraft);
tick();
env.fixture.detectChanges();
expect(env.draftApplyProgress!.chaptersApplied).toEqual([1, 2, 3]);
expect(env.draftApplyProgress!.completed).toBe(true);
verify(mockedDialogService.confirmWithOptions(anything())).once();
verify(mockedDialogService.openMatDialog(DraftApplyDialogComponent, anything())).once();
verify(mockedDraftHandlingService.getAndApplyDraftAsync(anything(), anything(), anything())).times(3);
}));

it('can apply chapters with drafts and skips chapters without drafts', fakeAsync(() => {
env = new TestEnvironment();
const bookWithDraft: BookWithDraft = env.booksWithDrafts[1];
when(mockedDialogService.confirmWithOptions(anything())).thenResolve(true);
setupDialog('project01');
when(mockedDraftHandlingService.getAndApplyDraftAsync(anything(), anything(), anything())).thenResolve(true);
expect(env.getBookButtonAtIndex(1).querySelector('.book-more')).toBeTruthy();
env.component.confirmAndAddToProjectAsync(bookWithDraft);
env.component.chooseProjectToAddDraft(bookWithDraft);
tick();
env.fixture.detectChanges();
verify(mockedDialogService.confirmWithOptions(anything())).once();
verify(mockedDialogService.openMatDialog(DraftApplyDialogComponent, anything())).once();
verify(mockedDraftHandlingService.getAndApplyDraftAsync(anything(), anything(), anything())).times(1);
}));

it('can open dialog with the current project', fakeAsync(() => {
env = new TestEnvironment();
expect(env.getBookButtonAtIndex(0).querySelector('.book-more')).toBeTruthy();
const mockedDialogRef: MatDialogRef<DraftApplyDialogComponent> = mock(MatDialogRef<DraftApplyDialogComponent>);
when(mockedDialogRef.afterClosed()).thenReturn(of({ projectId: 'project01' }));
when(mockedDialogService.openMatDialog(DraftApplyDialogComponent, anything())).thenReturn(
instance(mockedDialogRef)
);
env.component.chooseProjectToAddDraft(env.booksWithDrafts[0], 'project01');
tick();
env.fixture.detectChanges();
verify(mockedDialogService.openMatDialog(DraftApplyDialogComponent, anything())).once();
verify(mockedDraftHandlingService.getAndApplyDraftAsync(anything(), anything(), anything())).times(
env.booksWithDrafts[0].chaptersWithDrafts.length
);
}));

it('can open dialog to apply draft to a different project', fakeAsync(() => {
env = new TestEnvironment();
expect(env.getBookButtonAtIndex(0).querySelector('.book-more')).toBeTruthy();
Expand All @@ -166,7 +170,7 @@ describe('DraftPreviewBooks', () => {
when(mockedDialogService.openMatDialog(DraftApplyDialogComponent, anything())).thenReturn(
instance(mockedDialogRef)
);
env.component.chooseAlternateProjectToAddDraft(env.booksWithDrafts[0]);
env.component.chooseProjectToAddDraft(env.booksWithDrafts[0]);
tick();
env.fixture.detectChanges();
verify(mockedDialogService.openMatDialog(DraftApplyDialogComponent, anything())).once();
Expand All @@ -192,12 +196,8 @@ describe('DraftPreviewBooks', () => {

it('does not apply draft if user cancels applying to a different project', fakeAsync(() => {
env = new TestEnvironment();
const mockedDialogRef: MatDialogRef<DraftApplyDialogComponent> = mock(MatDialogRef<DraftApplyDialogComponent>);
when(mockedDialogRef.afterClosed()).thenReturn(of(undefined));
when(mockedDialogService.openMatDialog(DraftApplyDialogComponent, anything())).thenReturn(
instance(mockedDialogRef)
);
env.component.chooseAlternateProjectToAddDraft(env.booksWithDrafts[0]);
setupDialog();
env.component.chooseProjectToAddDraft(env.booksWithDrafts[0]);
tick();
env.fixture.detectChanges();
verify(mockedDialogService.openMatDialog(DraftApplyDialogComponent, anything())).once();
Expand All @@ -208,20 +208,20 @@ describe('DraftPreviewBooks', () => {
it('shows message to generate a new draft if legacy USFM draft', fakeAsync(() => {
env = new TestEnvironment();
const bookWithDraft: BookWithDraft = env.booksWithDrafts[0];
when(mockedDialogService.confirmWithOptions(anything())).thenResolve(true);
setupDialog('project01');
when(mockedDraftHandlingService.getAndApplyDraftAsync(anything(), anything(), anything())).thenResolve(false);
expect(env.getBookButtonAtIndex(0).querySelector('.book-more')).toBeTruthy();
env.component.confirmAndAddToProjectAsync(bookWithDraft);
env.component.chooseProjectToAddDraft(bookWithDraft);
tick();
env.fixture.detectChanges();
verify(mockedDialogService.confirmWithOptions(anything())).once();
verify(mockedDialogService.openMatDialog(DraftApplyDialogComponent, anything())).once();
verify(mockedDraftHandlingService.getAndApplyDraftAsync(anything(), anything(), anything())).times(3);
}));

it('can track progress of chapters applied', fakeAsync(() => {
env = new TestEnvironment();
const bookWithDraft: BookWithDraft = env.booksWithDrafts[0];
when(mockedDialogService.confirmWithOptions(anything())).thenResolve(true);
setupDialog('project01');
const resolveSubject$: BehaviorSubject<boolean> = new BehaviorSubject<boolean>(false);
const promise: Promise<boolean> = new Promise<boolean>(resolve => {
resolveSubject$.pipe(filter(value => value)).subscribe(() => resolve(true));
Expand All @@ -230,10 +230,10 @@ describe('DraftPreviewBooks', () => {
.thenReturn(Promise.resolve(true))
.thenReturn(promise);
expect(env.getBookButtonAtIndex(0).querySelector('.book-more')).toBeTruthy();
env.component.confirmAndAddToProjectAsync(bookWithDraft);
env.component.chooseProjectToAddDraft(bookWithDraft);
tick();
env.fixture.detectChanges();
verify(mockedDialogService.confirmWithOptions(anything())).once();
verify(mockedDialogService.openMatDialog(DraftApplyDialogComponent, anything())).once();
verify(mockedDraftHandlingService.getAndApplyDraftAsync(anything(), anything(), anything())).times(3);
expect(env.component.numChaptersApplied).toEqual(1);
resolveSubject$.next(true);
Expand All @@ -242,6 +242,14 @@ describe('DraftPreviewBooks', () => {
env.fixture.detectChanges();
expect(env.component.numChaptersApplied).toEqual(3);
}));

function setupDialog(projectId?: string): void {
const mockedDialogRef: MatDialogRef<DraftApplyDialogComponent> = mock(MatDialogRef<DraftApplyDialogComponent>);
when(mockedDialogRef.afterClosed()).thenReturn(of(projectId ? { projectId } : undefined));
when(mockedDialogService.openMatDialog(DraftApplyDialogComponent, anything())).thenReturn(
instance(mockedDialogRef)
);
}
});

class TestEnvironment {
Expand Down
Loading

0 comments on commit efa579c

Please sign in to comment.