From 762d68488ded06237e7ad3bb82373148a1cbcb9d Mon Sep 17 00:00:00 2001 From: Raymond Luong Date: Fri, 17 Jan 2025 08:53:01 -0700 Subject: [PATCH] SF-3103 Warn user when project does not contain all draft chapters (#2903) --- .../ClientApp/src/app/shared/sfvalidators.ts | 3 + .../draft-apply-dialog.component.html | 6 +- .../draft-apply-dialog.component.spec.ts | 62 +++++++++++++++++-- .../draft-apply-dialog.component.ts | 21 ++++++- .../draft-preview-books.component.ts | 12 +++- .../src/assets/i18n/non_checking_en.json | 2 + .../src/xforge-common/i18n.service.spec.ts | 20 ++++++ .../src/xforge-common/i18n.service.ts | 7 +++ 8 files changed, 122 insertions(+), 11 deletions(-) diff --git a/src/SIL.XForge.Scripture/ClientApp/src/app/shared/sfvalidators.ts b/src/SIL.XForge.Scripture/ClientApp/src/app/shared/sfvalidators.ts index dc7020361b..57564194e8 100644 --- a/src/SIL.XForge.Scripture/ClientApp/src/app/shared/sfvalidators.ts +++ b/src/SIL.XForge.Scripture/ClientApp/src/app/shared/sfvalidators.ts @@ -16,6 +16,7 @@ export enum CustomValidatorState { InvalidProject, BookNotFound, NoWritePermissions, + MissingChapters, None } @@ -87,6 +88,8 @@ export class SFValidators { return { bookNotFound: true }; case CustomValidatorState.NoWritePermissions: return { noWritePermissions: true }; + case CustomValidatorState.MissingChapters: + return { missingChapters: true }; default: return null; } diff --git a/src/SIL.XForge.Scripture/ClientApp/src/app/translate/draft-generation/draft-apply-dialog/draft-apply-dialog.component.html b/src/SIL.XForge.Scripture/ClientApp/src/app/translate/draft-generation/draft-apply-dialog/draft-apply-dialog.component.html index 1ee016f0c9..e018dfdcf9 100644 --- a/src/SIL.XForge.Scripture/ClientApp/src/app/translate/draft-generation/draft-apply-dialog/draft-apply-dialog.component.html +++ b/src/SIL.XForge.Scripture/ClientApp/src/app/translate/draft-generation/draft-apply-dialog/draft-apply-dialog.component.html @@ -21,7 +21,11 @@

{{ t("select_alternate_project") }}

@if (targetChapters$ | async; as chapters) { {{ t("project_has_text_in_chapters", { bookName, numChapters: chapters, projectName: project.name }) }} + >{{ + 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 }) + }} } @else { {{ diff --git a/src/SIL.XForge.Scripture/ClientApp/src/app/translate/draft-generation/draft-apply-dialog/draft-apply-dialog.component.spec.ts b/src/SIL.XForge.Scripture/ClientApp/src/app/translate/draft-generation/draft-apply-dialog/draft-apply-dialog.component.spec.ts index aef2477e21..ef681a1bca 100644 --- a/src/SIL.XForge.Scripture/ClientApp/src/app/translate/draft-generation/draft-apply-dialog/draft-apply-dialog.component.spec.ts +++ b/src/SIL.XForge.Scripture/ClientApp/src/app/translate/draft-generation/draft-apply-dialog/draft-apply-dialog.component.spec.ts @@ -63,7 +63,7 @@ describe('DraftApplyDialogComponent', () => { { provide: I18nService, useMock: mockedI18nService }, { provide: OnlineStatusService, useClass: TestOnlineStatusService }, { provide: MatDialogRef, useMock: mockedDialogRef }, - { provide: MAT_DIALOG_DATA, useValue: { bookNum: 1 } } + { provide: MAT_DIALOG_DATA, useValue: { bookNum: 1, chapters: [1, 2] } } ] })); @@ -147,6 +147,52 @@ describe('DraftApplyDialogComponent', () => { expect(env.component['getCustomErrorState']()).toBe(CustomValidatorState.InvalidProject); })); + it('notifies user if book has missing chapters', fakeAsync(() => { + const projectDoc = { + id: 'project03', + data: createTestProjectProfile({ + paratextId: 'paratextId3', + userRoles: { user01: SFProjectRole.ParatextAdministrator }, + texts: [ + { + bookNum: 1, + chapters: [{ number: 1, permissions: { user01: TextInfoPermission.Write }, lastVerse: 31 }], + permissions: { user01: TextInfoPermission.Write } + } + ] + }) + } as SFProjectProfileDoc; + env = new TestEnvironment({ projectDoc }); + env.selectParatextProject('paratextId3'); + expect(env.component['targetProjectId']).toBe('project03'); + tick(); + env.fixture.detectChanges(); + expect(env.component['getCustomErrorState']()).toBe(CustomValidatorState.MissingChapters); + })); + + it('notifies user if book is empty', fakeAsync(() => { + const projectDoc = { + id: 'project03', + data: createTestProjectProfile({ + paratextId: 'paratextId3', + userRoles: { user01: SFProjectRole.ParatextAdministrator }, + texts: [ + { + bookNum: 1, + chapters: [{ number: 1, permissions: { user01: TextInfoPermission.Write }, lastVerse: 0 }], + permissions: { user01: TextInfoPermission.Write } + } + ] + }) + } as SFProjectProfileDoc; + env = new TestEnvironment({ projectDoc }); + env.selectParatextProject('paratextId3'); + expect(env.component['targetProjectId']).toBe('project03'); + tick(); + env.fixture.detectChanges(); + expect(env.component['getCustomErrorState']()).toBe(CustomValidatorState.MissingChapters); + })); + it('updates the target project info when updating the project in the selector', fakeAsync(() => { env.selectParatextProject('paratextId1'); expect(env.targetProjectContent.textContent).toContain('Test project 1'); @@ -176,10 +222,10 @@ class TestEnvironment { onlineStatusService = TestBed.inject(OnlineStatusService) as TestOnlineStatusService; - constructor() { + constructor(args: { projectDoc?: SFProjectProfileDoc } = {}) { when(mockedUserService.currentUserId).thenReturn('user01'); when(mockedI18nService.localizeBook(anything())).thenReturn('Genesis'); - this.setupProject(); + this.setupProject(args.projectDoc); this.fixture = TestBed.createComponent(DraftApplyDialogComponent); this.loader = TestbedHarnessEnvironment.loader(this.fixture); this.component = this.fixture.componentInstance; @@ -230,7 +276,7 @@ class TestEnvironment { this.fixture.detectChanges(); } - private setupProject(): void { + private setupProject(projectDoc?: SFProjectProfileDoc): void { const projectPermissions = [ { id: 'project01', permission: TextInfoPermission.Write }, { id: 'project02', permission: TextInfoPermission.Read }, @@ -248,7 +294,10 @@ class TestEnvironment { texts: [ { bookNum: 1, - chapters: [{ number: 1, permissions: { user01: permission } }], + chapters: [ + { number: 1, permissions: { user01: permission }, lastVerse: 31 }, + { number: 2, permissions: { user01: permission }, lastVerse: 25 } + ], permissions: { user01: permission } } ] @@ -258,6 +307,9 @@ class TestEnvironment { } as SFProjectProfileDoc; mockProjectDocs.push(mockedProject); } + if (projectDoc != null) { + mockProjectDocs.push(projectDoc); + } when(mockedUserProjectsService.projectDocs$).thenReturn(of(mockProjectDocs)); const mockedTextDoc = { getNonEmptyVerses: (): string[] => ['verse_1_1', 'verse_1_2', 'verse_1_3'] diff --git a/src/SIL.XForge.Scripture/ClientApp/src/app/translate/draft-generation/draft-apply-dialog/draft-apply-dialog.component.ts b/src/SIL.XForge.Scripture/ClientApp/src/app/translate/draft-generation/draft-apply-dialog/draft-apply-dialog.component.ts index c9a1785ece..1f7492d2f3 100644 --- a/src/SIL.XForge.Scripture/ClientApp/src/app/translate/draft-generation/draft-apply-dialog/draft-apply-dialog.component.ts +++ b/src/SIL.XForge.Scripture/ClientApp/src/app/translate/draft-generation/draft-apply-dialog/draft-apply-dialog.component.ts @@ -28,6 +28,11 @@ export interface DraftApplyDialogResult { projectId: string; } +export interface DraftApplyDialogConfig { + bookNum: number; + chapters: number[]; +} + @Component({ selector: 'app-draft-apply-dialog', standalone: true, @@ -48,6 +53,7 @@ export class DraftApplyDialogComponent implements OnInit { targetChapters$: BehaviorSubject = new BehaviorSubject(0); canEditProject: boolean = true; targetBookExists: boolean = true; + projectHasMissingChapters: boolean = false; addToProjectClicked: boolean = false; /** An observable that emits the target project profile if the user has permission to write to the book. */ targetProject$: BehaviorSubject = new BehaviorSubject( @@ -56,7 +62,8 @@ export class DraftApplyDialogComponent implements OnInit { invalidMessageMapper: { [key: string]: string } = { invalidProject: translate('draft_apply_dialog.please_select_valid_project'), bookNotFound: translate('draft_apply_dialog.book_does_not_exist', { bookName: this.bookName }), - noWritePermissions: translate('draft_apply_dialog.no_write_permissions') + noWritePermissions: translate('draft_apply_dialog.no_write_permissions'), + missingChapters: translate('draft_apply_dialog.project_has_chapters_missing', { bookName: this.bookName }) }; // the project id to add the draft to @@ -64,7 +71,7 @@ export class DraftApplyDialogComponent implements OnInit { private paratextIdToProjectId: Map = new Map(); constructor( - @Inject(MAT_DIALOG_DATA) private data: { bookNum: number }, + @Inject(MAT_DIALOG_DATA) private data: DraftApplyDialogConfig, @Inject(MatDialogRef) private dialogRef: MatDialogRef, private readonly userProjectsService: SFUserProjectsService, private readonly projectService: SFProjectService, @@ -154,8 +161,13 @@ export class DraftApplyDialogComponent implements OnInit { this.textDocService.userHasGeneralEditRight(project) && targetBook?.permissions[this.userService.currentUserId] === TextInfoPermission.Write; + // also check if this is an empty book + const bookIsEmpty: boolean = targetBook?.chapters.length === 1 && targetBook?.chapters[0].lastVerse < 1; + const targetBookChapters: number[] = targetBook?.chapters.map(c => c.number) ?? []; + this.projectHasMissingChapters = + bookIsEmpty || this.data.chapters.filter(c => !targetBookChapters.includes(c)).length > 0; // emit the project profile document - if (this.canEditProject) { + if (this.canEditProject && !this.projectHasMissingChapters) { this.targetProject$.next(project); } else { this.targetProject$.next(undefined); @@ -194,6 +206,9 @@ export class DraftApplyDialogComponent implements OnInit { if (!this.canEditProject) { return CustomErrorState.NoWritePermissions; } + if (this.projectHasMissingChapters) { + return CustomErrorState.MissingChapters; + } return CustomErrorState.None; } } diff --git a/src/SIL.XForge.Scripture/ClientApp/src/app/translate/draft-generation/draft-preview-books/draft-preview-books.component.ts b/src/SIL.XForge.Scripture/ClientApp/src/app/translate/draft-generation/draft-preview-books/draft-preview-books.component.ts index 47aa08727c..2ff98d9267 100644 --- a/src/SIL.XForge.Scripture/ClientApp/src/app/translate/draft-generation/draft-preview-books/draft-preview-books.component.ts +++ b/src/SIL.XForge.Scripture/ClientApp/src/app/translate/draft-generation/draft-preview-books/draft-preview-books.component.ts @@ -16,7 +16,11 @@ import { UICommonModule } from 'xforge-common/ui-common.module'; import { UserService } from 'xforge-common/user.service'; import { filterNullish } from 'xforge-common/util/rxjs-util'; import { TextDocId } from '../../../core/models/text-doc'; -import { DraftApplyDialogComponent, DraftApplyDialogResult } from '../draft-apply-dialog/draft-apply-dialog.component'; +import { + DraftApplyDialogComponent, + DraftApplyDialogConfig as DraftApplyDialogData, + DraftApplyDialogResult +} from '../draft-apply-dialog/draft-apply-dialog.component'; import { DraftApplyProgress, DraftApplyProgressDialogComponent @@ -104,9 +108,13 @@ export class DraftPreviewBooksComponent { } async chooseAlternateProjectToAddDraft(bookWithDraft: BookWithDraft): Promise { + const dialogData: DraftApplyDialogData = { + bookNum: bookWithDraft.bookNumber, + chapters: bookWithDraft.chaptersWithDrafts + }; const dialogRef: MatDialogRef = this.dialogService.openMatDialog( DraftApplyDialogComponent, - { data: { bookNum: bookWithDraft.bookNumber }, width: '600px' } + { data: dialogData, width: '600px' } ); const result: DraftApplyDialogResult | undefined = await firstValueFrom(dialogRef.afterClosed()); if (result == null || result.projectId == null) { diff --git a/src/SIL.XForge.Scripture/ClientApp/src/assets/i18n/non_checking_en.json b/src/SIL.XForge.Scripture/ClientApp/src/assets/i18n/non_checking_en.json index 56068d0d85..db9b01a864 100644 --- a/src/SIL.XForge.Scripture/ClientApp/src/assets/i18n/non_checking_en.json +++ b/src/SIL.XForge.Scripture/ClientApp/src/assets/i18n/non_checking_en.json @@ -162,7 +162,9 @@ "looking_for_unlisted_project": "Looking for a project that is not listed? Connect it on {{ underlineStart }}the projects page{{ underlineEnd }} first.", "no_write_permissions": "You do not have permission to write to this book on this project. Contact the project's administrator to get permission.", "please_select_valid_project": "Please select a valid project", + "project_has_chapters_missing": "{{ bookName }} in this project has chapters missing. Please add the missing chapters in Paratext", "project_has_text_in_chapters": "{{ bookName }} in {{ projectName }} has text in {{ numChapters }} chapters", + "project_has_text_in_one_chapter": "{{ bookName }} in {{ projectName }} has text in 1 chapter", "select_alternate_project": "Select the project you want to add the draft to" }, "draft_apply_progress-dialog": { diff --git a/src/SIL.XForge.Scripture/ClientApp/src/xforge-common/i18n.service.spec.ts b/src/SIL.XForge.Scripture/ClientApp/src/xforge-common/i18n.service.spec.ts index 30e98aa8c0..75bfce3ba5 100644 --- a/src/SIL.XForge.Scripture/ClientApp/src/xforge-common/i18n.service.spec.ts +++ b/src/SIL.XForge.Scripture/ClientApp/src/xforge-common/i18n.service.spec.ts @@ -206,6 +206,26 @@ describe('I18nService', () => { expect(service.getLanguageDisplayName('123')).toBe('123'); }); }); + + describe('getPluralRule', () => { + it('should return rule for zero, one and other', () => { + const service = getI18nService(); + service.setLocale('en'); + expect(service.getPluralRule(0)).toEqual('other'); + expect(service.getPluralRule(1)).toEqual('one'); + expect(service.getPluralRule(2)).toEqual('other'); + }); + + it('supports arabic plural rules', () => { + const service = getI18nService(); + service.setLocale('ar'); + expect(service.getPluralRule(0)).toEqual('zero'); + expect(service.getPluralRule(1)).toEqual('one'); + expect(service.getPluralRule(2)).toEqual('two'); + expect(service.getPluralRule(6)).toEqual('few'); + expect(service.getPluralRule(18)).toEqual('many'); + }); + }); }); function getI18nService(): I18nService { diff --git a/src/SIL.XForge.Scripture/ClientApp/src/xforge-common/i18n.service.ts b/src/SIL.XForge.Scripture/ClientApp/src/xforge-common/i18n.service.ts index e0e096de45..1431685af6 100644 --- a/src/SIL.XForge.Scripture/ClientApp/src/xforge-common/i18n.service.ts +++ b/src/SIL.XForge.Scripture/ClientApp/src/xforge-common/i18n.service.ts @@ -418,6 +418,13 @@ export class I18nService { .find(e => e.type === 'timeZoneName').value; } + /** Takes a number and returns a rule representing the plural-related rule for the current locale. + * Possible values include 'zero', 'one', 'two', 'few', 'many', and 'other'. + */ + getPluralRule(number: number): Intl.LDMLPluralRule { + return new Intl.PluralRules(this.locale.canonicalTag).select(number); + } + private getTranslation(key: I18nKey): string { return ( this.transloco.getTranslation(this.transloco.getActiveLang())[key] ??