1
1
import PropTypes from 'prop-types' ;
2
2
import React from "react" ;
3
3
import { FormattedMessage , injectIntl } from "react-intl" ;
4
- import { Alert , Button , ControlLabel , FormControl , FormGroup , Modal } from "react-bootstrap" ;
4
+ import { Alert , Button , Checkbox , ControlLabel , FormControl , FormGroup , HelpBlock , Modal } from "react-bootstrap" ;
5
5
import SearchParamsStore from "../../stores/workbench/SearchParamsStore" ;
6
6
import { buildTextUnitSearcherParameters } from "../../utils/TextUnitSearcherParametersBuilder" ;
7
7
import TextUnitClient from "../../sdk/TextUnitClient" ;
8
+ import RepositoryStore from "../../stores/RepositoryStore" ;
9
+ import { buildZipFile } from "../../utils/ZipBuilder" ;
8
10
9
11
const DEFAULT_LIMIT = 10000 ;
10
12
const DEFAULT_FIELDS = [
@@ -98,15 +100,38 @@ class ExportSearchResultsModal extends React.Component {
98
100
}
99
101
100
102
getDefaultState ( ) {
103
+ const { availableLocales, defaultSelectedLocales} = this . getInitialLocales ( ) ;
101
104
return {
102
105
selectedFields : DEFAULT_FIELDS . slice ( ) ,
103
106
limit : DEFAULT_LIMIT . toString ( ) ,
104
107
isExporting : false ,
105
108
errorMessage : null ,
106
109
format : EXPORT_FORMATS . CSV ,
110
+ splitByLocale : false ,
111
+ availableLocales,
112
+ selectedLocales : defaultSelectedLocales ,
107
113
} ;
108
114
}
109
115
116
+ getInitialLocales ( ) {
117
+ const searchParams = { ...SearchParamsStore . getState ( ) } ;
118
+ const repoIds = Array . isArray ( searchParams . repoIds ) ? searchParams . repoIds : [ ] ;
119
+ let availableLocales = [ ] ;
120
+ if ( repoIds && repoIds . length ) {
121
+ availableLocales = RepositoryStore . getAllBcp47TagsForRepositoryIds ( repoIds ) || [ ] ;
122
+ }
123
+ availableLocales = Array . from ( new Set ( availableLocales ) ) . sort ( ) ;
124
+
125
+ let defaultSelectedLocales = [ ] ;
126
+ if ( Array . isArray ( searchParams . bcp47Tags ) && searchParams . bcp47Tags . length ) {
127
+ defaultSelectedLocales = searchParams . bcp47Tags . filter ( tag => availableLocales . indexOf ( tag ) !== - 1 ) ;
128
+ } else {
129
+ defaultSelectedLocales = availableLocales . slice ( ) ;
130
+ }
131
+
132
+ return { availableLocales, defaultSelectedLocales} ;
133
+ }
134
+
110
135
toggleField ( fieldName ) {
111
136
this . setState ( ( prevState ) => {
112
137
let nextFields ;
@@ -142,8 +167,28 @@ class ExportSearchResultsModal extends React.Component {
142
167
return ;
143
168
}
144
169
145
- const searchParams = { ...SearchParamsStore . getState ( ) } ;
146
- const { textUnitSearcherParameters, returnEmpty} = buildTextUnitSearcherParameters ( searchParams ) ;
170
+ const baseSearchParams = { ...SearchParamsStore . getState ( ) } ;
171
+ const fieldsForExport = EXPORT_FIELD_PRIORITY
172
+ . filter ( field => selectedFields . indexOf ( field ) !== - 1 )
173
+ . concat ( selectedFields . filter ( field => EXPORT_FIELD_PRIORITY . indexOf ( field ) === - 1 ) ) ;
174
+
175
+ if ( this . state . splitByLocale && this . state . selectedLocales . length ) {
176
+ this . setState ( { isExporting : true , errorMessage : null } ) ;
177
+ try {
178
+ await this . exportPerLocaleWithFetch ( parsedLimit , fieldsForExport , format , baseSearchParams ) ;
179
+ } catch ( error ) {
180
+ console . error ( "Failed to export search results" , error ) ;
181
+ const fallbackMessage = intl . formatMessage ( { id : "workbench.export.modal.error.generic" } ) ;
182
+ const normalizedError = ( error && error . message ) ? error . message : null ;
183
+ this . setState ( {
184
+ isExporting : false ,
185
+ errorMessage : normalizedError ? `${ fallbackMessage } (${ normalizedError } )` : fallbackMessage ,
186
+ } ) ;
187
+ }
188
+ return ;
189
+ }
190
+
191
+ const { textUnitSearcherParameters, returnEmpty} = buildTextUnitSearcherParameters ( baseSearchParams ) ;
147
192
148
193
if ( returnEmpty ) {
149
194
this . setState ( { errorMessage : intl . formatMessage ( { id : "workbench.export.modal.error.searchNotReady" } ) } ) ;
@@ -156,26 +201,9 @@ class ExportSearchResultsModal extends React.Component {
156
201
157
202
try {
158
203
const textUnits = await this . fetchAllTextUnits ( textUnitSearcherParameters , parsedLimit ) ;
159
- const fieldsForExport = EXPORT_FIELD_PRIORITY
160
- . filter ( field => selectedFields . indexOf ( field ) !== - 1 )
161
- . concat ( selectedFields . filter ( field => EXPORT_FIELD_PRIORITY . indexOf ( field ) === - 1 ) ) ;
162
204
const rows = this . buildRows ( textUnits , fieldsForExport ) ;
163
-
164
- let blob ;
165
- let extension ;
166
-
167
- if ( format === EXPORT_FORMATS . CSV ) {
168
- const csv = this . convertRowsToCsv ( rows , fieldsForExport ) ;
169
- blob = new Blob ( [ csv ] , { type : "text/csv;charset=utf-8;" } ) ;
170
- extension = "csv" ;
171
- } else {
172
- const json = JSON . stringify ( rows , null , 2 ) ;
173
- blob = new Blob ( [ json ] , { type : "application/json;charset=utf-8;" } ) ;
174
- extension = "json" ;
175
- }
176
-
205
+ const { blob, extension} = this . buildExportPayload ( rows , fieldsForExport , format ) ;
177
206
this . triggerDownload ( blob , `workbench-export-${ Date . now ( ) } .${ extension } ` ) ;
178
-
179
207
this . setState ( { isExporting : false } , ( ) => this . props . onClose ( ) ) ;
180
208
} catch ( error ) {
181
209
console . error ( "Failed to export search results" , error ) ;
@@ -188,6 +216,128 @@ class ExportSearchResultsModal extends React.Component {
188
216
}
189
217
}
190
218
219
+ isExportDisabled ( ) {
220
+ if ( this . state . isExporting ) {
221
+ return true ;
222
+ }
223
+ if ( ! this . state . selectedFields . length ) {
224
+ return true ;
225
+ }
226
+ if ( this . state . splitByLocale && ! this . state . selectedLocales . length ) {
227
+ return true ;
228
+ }
229
+ return false ;
230
+ }
231
+
232
+ onSplitByLocaleToggle ( splitByLocale ) {
233
+ if ( this . state . isExporting ) {
234
+ return ;
235
+ }
236
+ if ( splitByLocale && ! this . state . availableLocales . length ) {
237
+ this . setState ( { splitByLocale : false } ) ;
238
+ return ;
239
+ }
240
+ let nextSelectedLocales = this . state . selectedLocales ;
241
+ if ( splitByLocale && nextSelectedLocales . length === 0 ) {
242
+ nextSelectedLocales = this . state . availableLocales . slice ( ) ;
243
+ }
244
+ this . setState ( { splitByLocale, selectedLocales : nextSelectedLocales } ) ;
245
+ }
246
+
247
+ onLocalesChange ( event ) {
248
+ if ( this . state . isExporting ) {
249
+ return ;
250
+ }
251
+ const options = event . target . options ;
252
+ const selectedLocales = [ ] ;
253
+ for ( let i = 0 ; i < options . length ; i ++ ) {
254
+ if ( options [ i ] . selected ) {
255
+ selectedLocales . push ( options [ i ] . value ) ;
256
+ }
257
+ }
258
+ this . setState ( { selectedLocales} ) ;
259
+ }
260
+
261
+ buildExportPayload ( rows , fieldsForExport , format ) {
262
+ const encoder = new TextEncoder ( ) ;
263
+ if ( format === EXPORT_FORMATS . CSV ) {
264
+ const csv = this . convertRowsToCsv ( rows , fieldsForExport ) ;
265
+ const bytes = encoder . encode ( csv ) ;
266
+ return {
267
+ blob : new Blob ( [ bytes ] , { type : "text/csv;charset=utf-8;" } ) ,
268
+ extension : "csv" ,
269
+ bytes,
270
+ } ;
271
+ }
272
+ const json = JSON . stringify ( rows , null , 2 ) ;
273
+ const bytes = encoder . encode ( json ) ;
274
+ return {
275
+ blob : new Blob ( [ bytes ] , { type : "application/json;charset=utf-8;" } ) ,
276
+ extension : "json" ,
277
+ bytes,
278
+ } ;
279
+ }
280
+
281
+ async exportPerLocaleWithFetch ( limit , fieldsForExport , format , baseSearchParams ) {
282
+ const { intl} = this . props ;
283
+ const { selectedLocales} = this . state ;
284
+ const timestamp = Date . now ( ) ;
285
+ let filesGenerated = 0 ;
286
+ const missingLocales = [ ] ;
287
+ const files = [ ] ;
288
+
289
+ for ( const locale of selectedLocales ) {
290
+ const localeSearchParams = { ...baseSearchParams , bcp47Tags : [ locale ] } ;
291
+ const { textUnitSearcherParameters, returnEmpty} = buildTextUnitSearcherParameters ( localeSearchParams ) ;
292
+
293
+ if ( returnEmpty ) {
294
+ missingLocales . push ( locale ) ;
295
+ continue ;
296
+ }
297
+
298
+ textUnitSearcherParameters . offset ( 0 ) ;
299
+
300
+ const localeTextUnits = await this . fetchAllTextUnits ( textUnitSearcherParameters , limit ) ;
301
+
302
+ if ( ! localeTextUnits . length ) {
303
+ missingLocales . push ( locale ) ;
304
+ continue ;
305
+ }
306
+
307
+ const rows = this . buildRows ( localeTextUnits , fieldsForExport ) ;
308
+ const payload = this . buildExportPayload ( rows , fieldsForExport , format ) ;
309
+ const safeLocale = locale . replace ( / [ ^ A - Z a - z 0 - 9 . _ - ] / g, '_' ) ;
310
+ files . push ( {
311
+ name : `workbench-export-${ safeLocale } -${ timestamp } .${ payload . extension } ` ,
312
+ content : payload . bytes ,
313
+ } ) ;
314
+ filesGenerated += 1 ;
315
+ }
316
+
317
+ if ( ! filesGenerated ) {
318
+ const message = selectedLocales . length
319
+ ? intl . formatMessage ( { id : "workbench.export.modal.error.localesEmpty" } )
320
+ : intl . formatMessage ( { id : "workbench.export.modal.error.localesMissingSelection" } ) ;
321
+ this . setState ( { isExporting : false , errorMessage : message } ) ;
322
+ return ;
323
+ }
324
+
325
+ const zipArray = buildZipFile ( files ) ;
326
+ const zipBlob = new Blob ( [ zipArray ] , { type : "application/zip" } ) ;
327
+ this . triggerDownload ( zipBlob , `workbench-export-locales-${ timestamp } .zip` ) ;
328
+
329
+ if ( missingLocales . length ) {
330
+ const message = intl . formatMessage (
331
+ { id : "workbench.export.modal.error.localesSkipped" } ,
332
+ { locales : missingLocales . join ( ', ' ) }
333
+ ) ;
334
+ this . setState ( { isExporting : false , errorMessage : message } ) ;
335
+ return ;
336
+ }
337
+
338
+ this . setState ( { isExporting : false } , ( ) => this . props . onClose ( ) ) ;
339
+ }
340
+
191
341
renderFields ( ) {
192
342
const { intl} = this . props ;
193
343
const containerStyle = {
@@ -237,6 +387,8 @@ class ExportSearchResultsModal extends React.Component {
237
387
238
388
render ( ) {
239
389
const { intl} = this . props ;
390
+ const { splitByLocale, availableLocales, selectedLocales} = this . state ;
391
+ const localesEnabled = availableLocales . length > 0 ;
240
392
return (
241
393
< Modal show = { this . props . show }
242
394
onHide = { ( ) => ! this . state . isExporting && this . props . onClose ( ) }
@@ -259,6 +411,31 @@ class ExportSearchResultsModal extends React.Component {
259
411
< option value = { EXPORT_FORMATS . JSON } > { intl . formatMessage ( { id : "workbench.export.modal.format.json" } ) } </ option >
260
412
< option value = { EXPORT_FORMATS . CSV } > { intl . formatMessage ( { id : "workbench.export.modal.format.csv" } ) } </ option >
261
413
</ FormControl >
414
+ < Checkbox
415
+ className = "mtm"
416
+ checked = { splitByLocale }
417
+ onChange = { ( e ) => this . onSplitByLocaleToggle ( e . target . checked ) }
418
+ disabled = { this . state . isExporting || ! localesEnabled } >
419
+ < FormattedMessage id = "workbench.export.modal.splitByLocale" />
420
+ </ Checkbox >
421
+ { ! localesEnabled &&
422
+ < HelpBlock > < FormattedMessage id = "workbench.export.modal.splitByLocale.disabled" /> </ HelpBlock >
423
+ }
424
+ { splitByLocale && localesEnabled &&
425
+ < div className = "mtm" >
426
+ < ControlLabel > < FormattedMessage id = "workbench.export.modal.localesLabel" /> </ ControlLabel >
427
+ < FormControl componentClass = "select"
428
+ multiple
429
+ value = { selectedLocales }
430
+ onChange = { ( e ) => this . onLocalesChange ( e ) }
431
+ disabled = { this . state . isExporting } >
432
+ { availableLocales . map ( locale => (
433
+ < option key = { locale } value = { locale } > { locale } </ option >
434
+ ) ) }
435
+ </ FormControl >
436
+ < HelpBlock > < FormattedMessage id = "workbench.export.modal.localesHelp" /> </ HelpBlock >
437
+ </ div >
438
+ }
262
439
</ FormGroup >
263
440
< FormGroup >
264
441
< ControlLabel > < FormattedMessage id = "workbench.export.modal.fieldsLabel" /> </ ControlLabel >
@@ -285,7 +462,7 @@ class ExportSearchResultsModal extends React.Component {
285
462
</ Button >
286
463
< Button bsStyle = "primary"
287
464
onClick = { ( ) => this . startExport ( ) }
288
- disabled = { this . state . isExporting || this . state . selectedFields . length === 0 } >
465
+ disabled = { this . isExportDisabled ( ) } >
289
466
< FormattedMessage id = "workbench.export.modal.export" />
290
467
</ Button >
291
468
</ Modal . Footer >
0 commit comments