Skip to content

Commit e7a5145

Browse files
authored
SF-3568 Move Serval config to Serval project page (#3494)
1 parent ef1f596 commit e7a5145

File tree

15 files changed

+157
-285
lines changed

15 files changed

+157
-285
lines changed

src/SIL.XForge.Scripture/ClientApp/src/app/app.module.ts

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import { OverlayContainer } from '@angular/cdk/overlay';
22
import { DatePipe } from '@angular/common';
33
import { provideHttpClient, withInterceptorsFromDi } from '@angular/common/http';
4-
import { APP_ID, ErrorHandler, NgModule, inject, provideAppInitializer } from '@angular/core';
4+
import { APP_ID, ErrorHandler, inject, NgModule, provideAppInitializer } from '@angular/core';
55
import { MatRipple } from '@angular/material/core';
66
import { BrowserAnimationsModule } from '@angular/platform-browser/animations';
77
import { ServiceWorkerModule } from '@angular/service-worker';
@@ -23,6 +23,7 @@ import { EmTextTranspiler } from 'xforge-common/i18n-transpilers/em-text.transpi
2323
import { InAppRootOverlayContainer } from 'xforge-common/overlay-container';
2424
import { SupportedBrowsersDialogComponent } from 'xforge-common/supported-browsers-dialog/supported-browsers-dialog.component';
2525
import { UICommonModule } from 'xforge-common/ui-common.module';
26+
import { WriteStatusComponent } from 'xforge-common/write-status/write-status.component';
2627
import { XForgeCommonModule } from 'xforge-common/xforge-common.module';
2728
import { environment } from '../environments/environment';
2829
import { AppRoutingModule } from './app-routing.module';
@@ -85,7 +86,8 @@ import { UsersModule } from './users/users.module';
8586
MatRipple,
8687
GlobalNoticesComponent,
8788
QuillModule.forRoot(),
88-
LynxInsightsModule.forRoot()
89+
LynxInsightsModule.forRoot(),
90+
WriteStatusComponent
8991
],
9092
providers: [
9193
{ provide: APP_ID, useValue: 'ng-cli-universal' },

src/SIL.XForge.Scripture/ClientApp/src/app/serval-administration/serval-project.component.html

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -52,6 +52,28 @@ <h2>Pre-Translation Configuration</h2>
5252
<div class="zip-progress">Zipping file {{ downloadBooksProgress }} of {{ downloadBooksTotal }}...</div>
5353
}
5454
</div>
55+
<div class="admin-tool-control">
56+
<mat-card>
57+
<mat-card-header>
58+
<mat-card-title>Serval Configuration</mat-card-title>
59+
</mat-card-header>
60+
<mat-card-content>
61+
<p>
62+
This value must be a valid JSON string. See the
63+
<a href="https://github.com/sillsdev/serval/wiki/NMT-Build-Options" target="_blank">Serval Wiki</a> for
64+
configuration values.
65+
</p>
66+
<mat-form-field [formGroup]="form" appearance="outline" class="serval-config-field">
67+
<mat-label>NMT Build Options Override (JSON)</mat-label>
68+
<textarea matInput cdkTextareaAutosize id="serval-config" formControlName="servalConfig"></textarea>
69+
<app-write-status [state]="updateState" [formGroup]="form" id="serval-config-status"></app-write-status>
70+
</mat-form-field>
71+
</mat-card-content>
72+
<mat-card-actions>
73+
<button mat-flat-button id="save-serval-config" color="primary" (click)="updateServalConfig()">Save</button>
74+
</mat-card-actions>
75+
</mat-card>
76+
</div>
5577
</div>
5678

5779
<h2>Downloads</h2>

src/SIL.XForge.Scripture/ClientApp/src/app/serval-administration/serval-project.component.scss

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,10 @@
77
display: flex;
88
flex-direction: row;
99
column-gap: 10px;
10+
11+
.serval-config-field {
12+
width: 100%;
13+
}
1014
}
1115
}
1216

src/SIL.XForge.Scripture/ClientApp/src/app/serval-administration/serval-project.component.spec.ts

Lines changed: 68 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,7 @@
11
import { HttpErrorResponse } from '@angular/common/http';
2+
import { DebugElement } from '@angular/core';
23
import { ComponentFixture, fakeAsync, TestBed, tick } from '@angular/core/testing';
4+
import { By } from '@angular/platform-browser';
35
import { NoopAnimationsModule } from '@angular/platform-browser/animations';
46
import { ActivatedRoute } from '@angular/router';
57
import { saveAs } from 'file-saver';
@@ -233,6 +235,44 @@ describe('ServalProjectComponent', () => {
233235
expect(translationSources.length).toEqual(1);
234236
expect(env.getTranslationBookNames(translationSources[0])).toEqual('Leviticus - Numbers');
235237
}));
238+
239+
describe('serval configuration', () => {
240+
it('should change serval config value', fakeAsync(() => {
241+
const env = new TestEnvironment();
242+
expect(env.servalConfigTextArea.value).toBe('');
243+
expect(env.statusDone(env.servalConfigStatus)).toBeNull();
244+
245+
env.setServalConfigValue('{}');
246+
env.clickElement(env.saveServalConfigButton);
247+
248+
verify(mockSFProjectService.onlineSetServalConfig(env.mockProjectId, anything())).once();
249+
expect(env.statusDone(env.servalConfigStatus)).not.toBeNull();
250+
}));
251+
252+
it('should clear the serval config value', fakeAsync(() => {
253+
const env = new TestEnvironment({ preTranslate: true, draftConfig: { servalConfig: '{}' } });
254+
expect(env.servalConfigTextArea.value).toBe('{}');
255+
expect(env.statusDone(env.servalConfigStatus)).toBeNull();
256+
257+
env.setServalConfigValue('');
258+
env.clickElement(env.saveServalConfigButton);
259+
260+
verify(mockSFProjectService.onlineSetServalConfig(env.mockProjectId, anything())).once();
261+
expect(env.statusDone(env.servalConfigStatus)).not.toBeNull();
262+
}));
263+
264+
it('should not update an unchanged serval config value', fakeAsync(() => {
265+
const env = new TestEnvironment();
266+
expect(env.servalConfigTextArea.value).toBe('');
267+
expect(env.statusDone(env.servalConfigStatus)).toBeNull();
268+
269+
env.setServalConfigValue('');
270+
env.clickElement(env.saveServalConfigButton);
271+
272+
verify(mockSFProjectService.onlineSetServalConfig(env.mockProjectId, anything())).never();
273+
expect(env.statusDone(env.servalConfigStatus)).toBeNull();
274+
}));
275+
});
236276
});
237277

238278
class TestEnvironment {
@@ -285,7 +325,8 @@ describe('ServalProjectComponent', () => {
285325
},
286326
lastSelectedTrainingScriptureRanges: args.draftConfig?.lastSelectedTrainingScriptureRanges ?? undefined,
287327
lastSelectedTranslationScriptureRanges:
288-
args.draftConfig?.lastSelectedTranslationScriptureRanges ?? undefined
328+
args.draftConfig?.lastSelectedTranslationScriptureRanges ?? undefined,
329+
servalConfig: args.draftConfig?.servalConfig ?? undefined
289330
},
290331
preTranslate: args.preTranslate,
291332
source: {
@@ -315,6 +356,8 @@ describe('ServalProjectComponent', () => {
315356
when(mockServalAdministrationService.downloadProject(anything())).thenReturn(of(new Blob()));
316357
when(mockAuthService.currentUserRoles).thenReturn([SystemRole.ServalAdmin]);
317358
when(mockDraftGenerationService.getBuildProgress(anything())).thenReturn(of({ additionalInfo: {} } as BuildDto));
359+
when(mockSFProjectService.onlineSetServalConfig(this.mockProjectId, anything())).thenResolve();
360+
318361
spyOn(saveAs, 'saveAs').and.stub();
319362

320363
this.fixture = TestBed.createComponent(ServalProjectComponent);
@@ -346,6 +389,18 @@ describe('ServalProjectComponent', () => {
346389
return this.fixture.nativeElement.querySelector('#download-draft');
347390
}
348391

392+
get saveServalConfigButton(): HTMLInputElement {
393+
return this.fixture.nativeElement.querySelector('#save-serval-config');
394+
}
395+
396+
get servalConfigStatus(): DebugElement {
397+
return this.fixture.debugElement.query(By.css('#serval-config-status'));
398+
}
399+
400+
get servalConfigTextArea(): HTMLTextAreaElement {
401+
return this.fixture.nativeElement.querySelector('#serval-config') as HTMLTextAreaElement;
402+
}
403+
349404
get trainingSources(): NodeListOf<HTMLElement> {
350405
return this.fixture.nativeElement.querySelectorAll('.training');
351406
}
@@ -374,5 +429,17 @@ describe('ServalProjectComponent', () => {
374429
getTranslationBookNames(node: HTMLElement): string {
375430
return node.querySelector('.translation-range')?.textContent ?? '';
376431
}
432+
433+
setServalConfigValue(value: string): void {
434+
this.servalConfigTextArea.value = value;
435+
this.servalConfigTextArea.dispatchEvent(new Event('input'));
436+
this.fixture.detectChanges();
437+
tick();
438+
this.fixture.detectChanges();
439+
}
440+
441+
statusDone(element: DebugElement): HTMLElement {
442+
return element.nativeElement.querySelector('.check-icon') as HTMLElement;
443+
}
377444
}
378445
});

src/SIL.XForge.Scripture/ClientApp/src/app/serval-administration/serval-project.component.ts

Lines changed: 40 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import { CommonModule } from '@angular/common';
22
import { Component, DestroyRef, Input, OnInit } from '@angular/core';
3+
import { FormControl, FormGroup } from '@angular/forms';
34
import { Router } from '@angular/router';
45
import { saveAs } from 'file-saver';
56
import { SFProjectProfile } from 'realtime-server/lib/esm/scriptureforge/models/sf-project';
@@ -8,10 +9,12 @@ import { catchError, lastValueFrom, Observable, of, Subscription, switchMap, thr
89
import { ActivatedProjectService } from 'xforge-common/activated-project.service';
910
import { DataLoadingComponent } from 'xforge-common/data-loading-component';
1011
import { I18nService } from 'xforge-common/i18n.service';
12+
import { ElementState } from 'xforge-common/models/element-state';
1113
import { NoticeService } from 'xforge-common/notice.service';
1214
import { OnlineStatusService } from 'xforge-common/online-status.service';
1315
import { UICommonModule } from 'xforge-common/ui-common.module';
1416
import { filterNullish, quietTakeUntilDestroyed } from 'xforge-common/util/rxjs-util';
17+
import { WriteStatusComponent } from 'xforge-common/write-status/write-status.component';
1518
import { ParatextService } from '../core/paratext.service';
1619
import { SFProjectService } from '../core/sf-project.service';
1720
import { BuildDto } from '../machine-api/build-dto';
@@ -52,7 +55,8 @@ function projectType(project: TranslateSource | SFProjectProfile): string {
5255
SharedModule,
5356
UICommonModule,
5457
DraftInformationComponent,
55-
MobileNotSupportedComponent
58+
MobileNotSupportedComponent,
59+
WriteStatusComponent
5660
]
5761
})
5862
export class ServalProjectComponent extends DataLoadingComponent implements OnInit {
@@ -64,6 +68,12 @@ export class ServalProjectComponent extends DataLoadingComponent implements OnIn
6468
columnsToDisplay = ['category', 'type', 'name', 'languageCode', 'id'];
6569
rows: Row[] = [];
6670

71+
servalConfig = new FormControl<string | undefined>(undefined);
72+
form = new FormGroup({
73+
servalConfig: this.servalConfig
74+
});
75+
updateState = ElementState.InSync;
76+
6777
trainingBooksByProject: ProjectAndRange[] = [];
6878
trainingFiles: string[] = [];
6979
translationBooksByProject: ProjectAndRange[] = [];
@@ -182,6 +192,9 @@ export class ServalProjectComponent extends DataLoadingComponent implements OnIn
182192
this.draftConfig = draftConfig;
183193
this.draftJob$ = SFProjectService.hasDraft(project) ? this.getDraftJob(projectDoc.id) : of(undefined);
184194

195+
// Setup the serval config value
196+
this.servalConfig.setValue(project.translateConfig.draftConfig.servalConfig);
197+
185198
// Get the last completed build
186199
if (this.isOnline && SFProjectService.hasDraft(project)) {
187200
return this.draftGenerationService.getLastCompletedBuild(projectDoc.id);
@@ -229,7 +242,7 @@ export class ServalProjectComponent extends DataLoadingComponent implements OnIn
229242

230243
// If the blob is undefined, display an error
231244
if (blob == null) {
232-
this.noticeService.showError('The project was never synced successfully and does not exist on disk.');
245+
void this.noticeService.showError('The project was never synced successfully and does not exist on disk.');
233246
return;
234247
}
235248

@@ -257,6 +270,24 @@ export class ServalProjectComponent extends DataLoadingComponent implements OnIn
257270
});
258271
}
259272

273+
updateServalConfig(): void {
274+
if (
275+
this.activatedProjectService.projectDoc?.data == null ||
276+
(this.form.value.servalConfig ?? '') ===
277+
(this.activatedProjectService.projectDoc.data.translateConfig.draftConfig.servalConfig ?? '')
278+
) {
279+
// Do not save if we do not have the project doc or if the configuration has not changed
280+
return;
281+
}
282+
283+
// Update Serval Configuration
284+
const updateTaskPromise = this.projectService.onlineSetServalConfig(
285+
this.activatedProjectService.projectDoc.id,
286+
this.form.value.servalConfig
287+
);
288+
this.checkUpdateStatus(updateTaskPromise);
289+
}
290+
260291
keys(obj: Object): string[] {
261292
return Object.keys(obj);
262293
}
@@ -274,6 +305,13 @@ export class ServalProjectComponent extends DataLoadingComponent implements OnIn
274305
return '[' + contents + ']';
275306
}
276307

308+
private checkUpdateStatus(updatePromise: Promise<void>): void {
309+
this.updateState = ElementState.Submitting;
310+
updatePromise
311+
.then(() => (this.updateState = ElementState.Submitted))
312+
.catch(() => (this.updateState = ElementState.Error));
313+
}
314+
277315
private getDraftJob(projectId: string): Observable<BuildDto | undefined> {
278316
return this.draftGenerationService.getBuildProgress(projectId);
279317
}

src/SIL.XForge.Scripture/ClientApp/src/app/settings/settings.component.html

Lines changed: 0 additions & 56 deletions
Original file line numberDiff line numberDiff line change
@@ -109,62 +109,6 @@ <h1>{{ t("settings") }}</h1>
109109
</mat-card-content>
110110
</mat-card>
111111

112-
<!-- Pre-Translation Settings Card -->
113-
@if (showPreTranslationSettings) {
114-
<mat-card>
115-
<mat-card-content>
116-
<mat-card-title>{{ t("pre_translation_drafting") }}</mat-card-title>
117-
<div>
118-
@if (mainSettingsLoaded) {
119-
@if (showDraftGenerationSettingsMovedMessage) {
120-
<ng-template #templateDraftSettingsRelocatedMessage>
121-
@for (part of draftSettingsRelocatedMessage | async; track part) {
122-
<!-- To not have whitespace around the text, -->
123-
<!-- prettier-ignore -->
124-
@if (part.id == null) {{{ part.text }}}
125-
@else if (part.id === 1) {
126-
<a [appRouterLink]="['/projects', projectId, 'draft-generation']">{{ part.text }}</a>
127-
}
128-
}
129-
</ng-template>
130-
131-
@if (showHighlightedDraftGenerationSettingsMovedMessage) {
132-
<app-notice icon="info" mode="fill-dark">
133-
<ng-container *ngTemplateOutlet="templateDraftSettingsRelocatedMessage"></ng-container>
134-
</app-notice>
135-
} @else {
136-
<p class="helper-text">
137-
<ng-container *ngTemplateOutlet="templateDraftSettingsRelocatedMessage"></ng-container>
138-
</p>
139-
}
140-
}
141-
}
142-
@if (canUpdateServalConfig) {
143-
<p
144-
class="helper-text"
145-
[innerHTML]="i18n.translateAndInsertTags('settings.serval_config_description')"
146-
></p>
147-
<mat-form-field [formGroup]="form" appearance="outline">
148-
<mat-label>{{ t("serval_config") }}</mat-label>
149-
<textarea
150-
matInput
151-
cdkTextareaAutosize
152-
id="serval-config"
153-
formControlName="servalConfig"
154-
(blur)="updateServalConfig()"
155-
></textarea>
156-
<app-write-status
157-
[state]="getControlState('servalConfig')"
158-
[formGroup]="form"
159-
id="serval-config-status"
160-
></app-write-status>
161-
</mat-form-field>
162-
}
163-
</div>
164-
</mat-card-content>
165-
</mat-card>
166-
}
167-
168112
<!-- Community Checking Settings Card -->
169113
<mat-card>
170114
<mat-card-content>

0 commit comments

Comments
 (0)