Skip to content

Commit 216b9ff

Browse files
authored
SF-3750 Improve import from spreadsheet error messages (#3769)
1 parent 30abe0f commit 216b9ff

5 files changed

Lines changed: 77 additions & 21 deletions

File tree

src/SIL.XForge.Scripture/ClientApp/src/app/checking/import-questions-dialog/import-questions-dialog.component.html

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -196,6 +196,10 @@ <h2 mat-dialog-title class="dialog-icon-title">
196196
<div>{{ t("cannot_convert_offline") }}</div>
197197
}
198198

199+
@case ("invalid_spreadsheet") {
200+
<div [innerHTML]="i18n.translateAndInsertTags('import_questions_dialog.invalid_spreadsheet')"></div>
201+
}
202+
199203
@case ("missing_header_row") {
200204
<div [innerHTML]="i18n.translateAndInsertTags('import_questions_dialog.missing_header_row')"></div>
201205
}
@@ -327,7 +331,13 @@ <h2 mat-dialog-title class="dialog-icon-title">
327331
{{ t("back") }}
328332
</button>
329333
}
330-
@if (status === "no_questions" || status === "update_transcelerator" || status === "missing_header_row") {
334+
@if (
335+
status === "no_questions" ||
336+
status === "update_transcelerator" ||
337+
status === "invalid_spreadsheet" ||
338+
status === "missing_header_row" ||
339+
status === "offline_conversion"
340+
) {
331341
<button mat-flat-button mat-dialog-close color="primary">
332342
{{ t("close") }}
333343
</button>

src/SIL.XForge.Scripture/ClientApp/src/app/checking/import-questions-dialog/import-questions-dialog.component.spec.ts

Lines changed: 29 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -394,6 +394,33 @@ describe('ImportQuestionsDialogComponent', () => {
394394
env.click(env.backButton);
395395
}));
396396

397+
it('does not import from a CSV file with unknown columns', fakeAsync(() => {
398+
const env = new TestEnvironment();
399+
400+
const genQuestions = [['Genesis 1:1', 'Question for Genesis 1:1']];
401+
const matQuestions = Array.from(Array(100), (_, i) => [`MAT 1:${i + 1}`, `Question for Matthew 1:${i + 1}`]);
402+
403+
env.selectFileWithContents([['Col1', 'Col2'], ...genQuestions, ...matQuestions]);
404+
405+
expect(env.component.errorState).toBe('missing_header_row');
406+
}));
407+
408+
it('does not import from an empty CSV file', fakeAsync(() => {
409+
const env = new TestEnvironment();
410+
411+
env.selectFileWithContents([]);
412+
413+
expect(env.component.errorState).toBe('missing_header_row');
414+
}));
415+
416+
it('does not import from an Excel 97-2003 file', fakeAsync(() => {
417+
const env = new TestEnvironment();
418+
419+
env.selectFileWithContents(undefined, 'test.xls');
420+
421+
expect(env.component.errorState).toBe('invalid_spreadsheet');
422+
}));
423+
397424
it('it informs the user about invalid rows in the CSV file and skips them', fakeAsync(() => {
398425
const env = new TestEnvironment();
399426

@@ -942,8 +969,8 @@ class TestEnvironment {
942969
tick();
943970
}
944971

945-
selectFileWithContents(contents: string[][], filename = 'filename.csv'): void {
946-
when(mockedCsvService.parse(anything())).thenResolve(contents);
972+
selectFileWithContents(contents: string[][] | undefined, filename = 'filename.csv'): void {
973+
when(mockedCsvService.parse(anything())).thenResolve(contents ?? []);
947974
when(mockedCsvService.convert(anything())).thenResolve(contents);
948975
this.component.fileSelected({ name: filename } as File);
949976
tick();

src/SIL.XForge.Scripture/ClientApp/src/app/checking/import-questions-dialog/import-questions-dialog.component.ts

Lines changed: 13 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -90,6 +90,7 @@ interface DialogListItem {
9090
type DialogErrorState =
9191
| 'update_transcelerator'
9292
| 'file_import_errors'
93+
| 'invalid_spreadsheet'
9394
| 'missing_header_row'
9495
| 'offline_conversion'
9596
| 'paratext_tag_load_error';
@@ -688,13 +689,22 @@ export class ImportQuestionsDialogComponent implements OnDestroy {
688689
return;
689690
}
690691

691-
result = await this.csvService.convert(file);
692+
const convertedData: string[][] | undefined = await this.csvService.convert(file);
693+
if (convertedData == null) {
694+
this.questionSource = 'csv_file';
695+
this.errorState = 'invalid_spreadsheet';
696+
return;
697+
}
698+
699+
result = convertedData;
692700
} else {
693701
result = await this.csvService.parse(file);
694702
}
695703

696-
const referenceColumn = result[0].findIndex(value => /^\s*References?\s*$/i.test(value));
697-
const questionColumn = result[0].findIndex(value => /^\s*Questions?\s*$/i.test(value));
704+
const referenceColumn: number =
705+
result.length === 0 ? -1 : result[0].findIndex(value => /^\s*References?\s*$/i.test(value));
706+
const questionColumn: number =
707+
result.length === 0 ? -1 : result[0].findIndex(value => /^\s*Questions?\s*$/i.test(value));
698708

699709
if (referenceColumn === -1 || questionColumn === -1) {
700710
this.questionSource = 'csv_file';

src/SIL.XForge.Scripture/ClientApp/src/assets/i18n/non_checking_en.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -582,6 +582,7 @@
582582
"import_questions": "Import Questions",
583583
"import": "Import",
584584
"invalid_csv_rows_will_be_skipped": "These rows in the CSV file were invalid and will be skipped.",
585+
"invalid_spreadsheet": "The file is missing text in the {{ boldStart }}Reference{{ boldEnd }} or {{ boldStart }}Question{{ boldEnd }} columns. Please check and update the file.",
585586
"learn_more": "Learn more",
586587
"loading": "Loading...",
587588
"missing_header_row": "The file you imported needs to have a header row with columns titled {{ boldStart }}Reference{{ boldEnd }} and {{ boldStart }}Question{{ boldEnd }}.",

src/SIL.XForge.Scripture/ClientApp/src/xforge-common/csv-service.service.ts

Lines changed: 23 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { HttpClient } from '@angular/common/http';
1+
import { HttpClient, HttpErrorResponse } from '@angular/common/http';
22
import { Injectable } from '@angular/core';
33
import Papa from 'papaparse';
44
import { lastValueFrom } from 'rxjs';
@@ -10,22 +10,30 @@ import { COMMAND_API_NAMESPACE, PROJECTS_URL } from './url-constants';
1010
export class CsvService {
1111
constructor(private readonly http: HttpClient) {}
1212

13-
async convert(file: File): Promise<string[][]> {
14-
const formData = new FormData();
15-
formData.append('file', file);
16-
const response = await lastValueFrom(
17-
this.http.post(`${COMMAND_API_NAMESPACE}/${PROJECTS_URL}/convert-to-csv`, formData, {
18-
headers: { Accept: 'text/csv' },
19-
observe: 'response',
20-
responseType: 'text'
21-
})
22-
);
13+
async convert(file: File): Promise<string[][] | undefined> {
14+
try {
15+
const formData = new FormData();
16+
formData.append('file', file);
17+
const response = await lastValueFrom(
18+
this.http.post(`${COMMAND_API_NAMESPACE}/${PROJECTS_URL}/convert-to-csv`, formData, {
19+
headers: { Accept: 'text/csv' },
20+
observe: 'response',
21+
responseType: 'text'
22+
})
23+
);
2324

24-
if (!response.ok || response.body == null) {
25-
throw new Error('Your spreadsheet could not be converted');
26-
}
25+
if (!response.ok || response.body == null) {
26+
throw new Error('Your spreadsheet could not be converted');
27+
}
2728

28-
return this.parse(new File([response.body], 'converted.csv', { type: 'text/csv' }));
29+
return this.parse(new File([response.body], 'converted.csv', { type: 'text/csv' }));
30+
} catch (e) {
31+
if (e instanceof HttpErrorResponse && e.status === 400) {
32+
return undefined;
33+
}
34+
35+
throw e;
36+
}
2937
}
3038

3139
parse(file: File): Promise<string[][]> {

0 commit comments

Comments
 (0)