@performant-software/semantic-components 1.0.3 → 1.0.4-beta.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,6 +1,11 @@
1
1
  // @flow
2
2
 
3
- import React, { Component, type ComponentType } from 'react';
3
+ import React, {
4
+ useCallback,
5
+ useMemo,
6
+ useState,
7
+ type ComponentType
8
+ } from 'react';
4
9
  import {
5
10
  Button,
6
11
  Dimmer,
@@ -11,357 +16,290 @@ import {
11
16
  Modal
12
17
  } from 'semantic-ui-react';
13
18
  import _ from 'underscore';
14
- import i18n from '../i18n/i18n';
15
19
  import FileUpload from './FileUpload';
20
+ import FileUploadStatus from './FileUploadStatus';
21
+ import FileUploadProgress from './FileUploadProgress';
22
+ import i18n from '../i18n/i18n';
16
23
  import ModalContext from '../context/ModalContext';
17
- import Toaster from './Toaster';
18
24
 
19
25
  type Props = {
20
26
  /**
21
- * Content to display on the button used to open the modal
27
+ * If <code>true</code>, the modal will close once the upload has completed.
22
28
  */
23
- button: string,
29
+ closeOnComplete?: boolean,
24
30
 
25
31
  /**
26
- * Determines if the component should render a button as a trigger for the modal
32
+ * Component to render within the modal.
27
33
  */
28
- includeButton?: boolean,
34
+ itemComponent: ComponentType<any>,
29
35
 
30
36
  /**
31
- * Component to render within the modal
37
+ * Callback fired when a file is added.
32
38
  */
33
- itemComponent: ComponentType<any>,
39
+ onAddFile: (file: File) => any,
34
40
 
35
41
  /**
36
- * Callback fired when a file is added
42
+ * Callback fired when the close button is clicked.
37
43
  */
38
- onAddFile: (file: File) => void,
44
+ onClose: () => void,
39
45
 
40
46
  /**
41
- * Callback fired when the close button is clicked
47
+ * Callback fired when the save button is clicked. See <code>strategy</code> prop.
42
48
  */
43
- onClose?: () => void,
49
+ onSave: (items: Array<any>) => Promise<any>,
44
50
 
45
51
  /**
46
- * Callback fired when the save button is clicked
52
+ * An object with keys containing the names of properties that are required.
47
53
  */
48
- onSave: (items: Array<any>) => Promise<any>,
54
+ required?: { [key: string]: string },
49
55
 
50
56
  /**
51
- * An object with keys containing the names of properties that are required
57
+ * If <code>true</code>, a full page loader will display while uploading is in progress.
52
58
  */
53
- required: { [string]: string },
59
+ showPageLoader?: boolean,
54
60
 
55
61
  /**
56
- * Title value to display in the modal header
62
+ * The upload strategy to use. If <code>batch</code>, we'll execute one <code>onSave</code> request with each item
63
+ * as an array in the body. If <code>single</code>, we'll execute an <code>onSave</code> request for each item.
57
64
  */
58
- title?: string
65
+ strategy?: string
59
66
  };
60
67
 
61
- type State = {
62
- items: Array<any>,
63
- modal: boolean,
64
- saving: boolean
68
+ const Strategy = {
69
+ batch: 'batch',
70
+ single: 'single'
65
71
  };
66
72
 
67
- const LIST_DELIMITER = ', ';
73
+ const Status = {
74
+ pending: 'pending',
75
+ processing: 'processing',
76
+ complete: 'complete',
77
+ error: 'error'
78
+ };
68
79
 
69
80
  /**
70
81
  * The <code>FileUploadModal</code> is a convenience wrapper for the <code>FileUpload</code> component, allowing
71
82
  * it to render in a modal.
72
83
  */
73
- class FileUploadModal extends Component<Props, State> {
84
+ const FileUploadModal: ComponentType<any> = (props: Props) => {
85
+ const [items, setItems] = useState([]);
86
+ const [uploadCount, setUploadCount] = useState(0);
87
+ const [uploading, setUploading] = useState(false);
88
+ const [statuses, setStatuses] = useState({});
89
+
74
90
  /**
75
- * Constructs a new FileUploadModal component.
76
- *
77
- * @param props
91
+ * Sets the <code>hasErrors</code> value to <code>true</code> if at least one item on the state contains errors.
78
92
  */
79
- constructor(props: Props) {
80
- super(props);
81
-
82
- this.state = this.getInitialState();
83
- }
93
+ const hasErrors = useMemo(() => !!_.find(items, (item) => !_.isEmpty(item.errors)), [items]);
84
94
 
85
95
  /**
86
- * Returns the initial component state.
96
+ * Calls the <code>onAddFile</code> prop for each item in the passed collection of files and adds them
97
+ * to the items on the state.
87
98
  *
88
- * @returns {{saving: boolean, items: [], modal: boolean}}
99
+ * @type {function(*): void}
89
100
  */
90
- getInitialState() {
91
- return {
92
- items: [],
93
- modal: !this.props.includeButton,
94
- saving: false
95
- };
96
- }
101
+ const onAddFiles = useCallback((files) => (
102
+ setItems((prevItems) => [
103
+ ...prevItems,
104
+ ..._.map(files, props.onAddFile)
105
+ ])
106
+ ), []);
97
107
 
98
108
  /**
99
- * Returns true if any of the items contain errors.
109
+ * Updates the passed association for the passed item.
100
110
  *
101
- * @returns {boolean}
111
+ * @type {function(*, string, string, *): void}
102
112
  */
103
- hasErrors() {
104
- return !!_.find(this.state.items, (item) => !_.isEmpty(item.errors));
105
- }
113
+ const onAssociationInputChange = useCallback((item: any, idAttribute: string, attribute: string, value: any) => (
114
+ setItems((prevItems) => _.map(prevItems, (i) => (i !== item ? i : {
115
+ ...i,
116
+ [idAttribute]: value.id,
117
+ [attribute]: value,
118
+ errors: _.without(item.errors, idAttribute)
119
+ })))
120
+ ), []);
106
121
 
107
122
  /**
108
- * Adds the passed collection of files to the state. Typically files will be added as a property of another model.
109
- * This component calls the onAddFiles prop to transform the items stored in the state.
123
+ * Calls the <code>onSave</code> prop with the current list of items.
110
124
  *
111
- * @param files
125
+ * @type {function(): *}
112
126
  */
113
- onAddFiles(files: Array<File>) {
114
- this.setState((state) => ({
115
- items: [
116
- ...state.items,
117
- ..._.map(files, this.props.onAddFile.bind(this))
118
- ]
119
- }));
120
- }
127
+ const onBatchUpload = useCallback(() => (
128
+ props
129
+ .onSave(items)
130
+ .then(() => setUploadCount(1))
131
+ ), [items]);
121
132
 
122
133
  /**
123
- * Updates the passed association for the passed item.
134
+ * Sets the uploading state <code>false</code> and calls the <code>onClose</code> prop if necessary.
124
135
  *
125
- * @param item
126
- * @param idAttribute
127
- * @param attribute
128
- * @param value
136
+ * @type {(function(): void)|*}
129
137
  */
130
- onAssociationInputChange(item: any, idAttribute: string, attribute: string, value: any) {
131
- this.setState((state) => ({
132
- items: _.map(state.items, (i) => (i !== item ? i : ({
133
- ...i,
134
- [idAttribute]: value.id,
135
- [attribute]: value,
136
- errors: _.without(item.errors, idAttribute)
137
- })))
138
- }));
139
- }
138
+ const onComplete = useCallback(() => {
139
+ setUploading(false);
140
140
 
141
- /**
142
- * Resets the state or calls the onClose prop.
143
- */
144
- onClose() {
145
- if (this.props.onClose) {
146
- this.props.onClose();
147
- } else {
148
- this.setState(this.getInitialState());
141
+ if (props.closeOnComplete) {
142
+ props.onClose();
149
143
  }
150
- }
144
+ }, [props.closeOnComplete, props.onClose]);
151
145
 
152
146
  /**
153
147
  * Deletes the passed item from the state.
154
148
  *
155
- * @param item
149
+ * @type {function(*): void}
156
150
  */
157
- onDelete(item: any) {
158
- this.setState((state) => ({
159
- items: _.filter(state.items, (i) => i !== item)
160
- }));
161
- }
151
+ const onDelete = useCallback((item) => (
152
+ setItems((prevItems) => _.filter(prevItems, (i) => i !== item))
153
+ ), []);
162
154
 
163
155
  /**
164
- * Clears the errors for all items in the state.
156
+ * Sets the status for the item at the passed index.
157
+ *
158
+ * @type {function(*, *): void}
165
159
  */
166
- onDismissErrors() {
167
- this.setState((state) => ({
168
- items: _.map(state.items, (item) => _.omit(item, 'errors'))
169
- }));
170
- }
160
+ const setStatus = useCallback((index, status) => (
161
+ setStatuses((prevStatuses) => ({ ...prevStatuses, [index]: status }))
162
+ ));
171
163
 
172
164
  /**
173
- * Validates the items and saves.
165
+ * Iterates of the list of items and sequentially calls the <code>onSave</code> prop for each.
166
+ *
167
+ * @type {function(): Promise<unknown>}
174
168
  */
175
- onSave() {
176
- this.setState((state) => ({
177
- items: _.map(state.items, this.validateItem.bind(this))
178
- }), this.save.bind(this));
179
- }
169
+ const onSingleUpload = useCallback(async () => {
170
+ for (let i = 0; i < items.length; i += 1) {
171
+ const item = items[i];
172
+
173
+ // Update the status for the item
174
+ setStatus(i, Status.processing);
175
+
176
+ let error = false;
177
+
178
+ // Do the upload
179
+ try {
180
+ // eslint-disable-next-line no-await-in-loop
181
+ await props.onSave(item);
182
+ } catch (e) {
183
+ error = true;
184
+ }
185
+
186
+ // Update the status for the item
187
+ if (error) {
188
+ setStatus(i, Status.error);
189
+ } else {
190
+ setStatus(i, Status.complete);
191
+ }
192
+
193
+ // Update the upload count
194
+ setUploadCount((prevCount) => prevCount + 1);
195
+ }
196
+
197
+ return Promise.resolve();
198
+ }, [items, props.onSave]);
180
199
 
181
200
  /**
182
201
  * Updates the text value for the passed item.
183
202
  *
184
- * @param item
185
- * @param attribute
186
- * @param e
187
- * @param value
203
+ * @type {function(*, string, Event, {value: *}): void}
188
204
  */
189
- onTextInputChange(item: any, attribute: string, e: Event, { value }: { value: any }) {
190
- this.setState((state) => ({
191
- items: _.map(state.items, (i) => (i !== item ? i : ({
192
- ...i,
193
- [attribute]: value,
194
- errors: _.without(item.errors, attribute)
195
- })))
196
- }));
197
- }
205
+ const onTextInputChange = useCallback((item: any, attribute: string, e: Event, { value }: { value: any }) => (
206
+ setItems((prevItems) => _.map(prevItems, (i) => (i !== item ? i : {
207
+ ...i,
208
+ [attribute]: value,
209
+ errors: _.without(item.errors, attribute)
210
+ })))
211
+ ), []);
198
212
 
199
213
  /**
200
- * Updates the passed item with the passed props.
214
+ * Updates the passed item with the passed object of attributes.
201
215
  *
202
- * @param item
203
- * @param props
216
+ * @type {function(*, *): void}
204
217
  */
205
- onUpdate(item: any, props: any) {
206
- this.setState((state) => ({
207
- items: _.map(state.items, (i) => (i !== item ? i : ({
208
- ...i,
209
- ...props,
210
- errors: _.without(item.errors, _.keys(props))
211
- })))
212
- }));
213
- }
218
+ const onUpdate = useCallback((item, attributes) => (
219
+ setItems((prevItems) => _.map(prevItems, (i) => (i !== item ? i : { ...i, ...attributes })))
220
+ ), []);
214
221
 
215
222
  /**
216
- * Renders the FileUploadModal component.
223
+ * Validates the passed item.
217
224
  *
218
- * @returns {*}
225
+ * @type {function(*): *&{errors: []}}
219
226
  */
220
- render() {
221
- return (
222
- <>
223
- { this.props.includeButton && (
224
- <Button
225
- content={this.props.button}
226
- icon='cloud upload'
227
- onClick={() => this.setState({ modal: true })}
228
- primary
229
- />
230
- )}
231
- { this.state.modal && (
232
- <ModalContext.Consumer>
233
- { (mountNode) => (
234
- <Modal
235
- centered={false}
236
- className='file-upload-modal'
237
- mountNode={mountNode}
238
- open
239
- >
240
- <Dimmer
241
- active={this.state.saving}
242
- inverted
243
- >
244
- <Loader
245
- content={i18n.t('FileUploadModal.loader')}
246
- />
247
- </Dimmer>
248
- { this.renderErrors() }
249
- <Modal.Header
250
- content={this.props.title || i18n.t('FileUploadModal.title')}
251
- />
252
- <Modal.Content>
253
- <FileUpload
254
- onFilesAdded={this.onAddFiles.bind(this)}
255
- />
256
- { this.renderItems() }
257
- </Modal.Content>
258
- <Modal.Actions>
259
- <Button
260
- content={i18n.t('Common.buttons.save')}
261
- disabled={!(this.state.items && this.state.items.length)}
262
- primary
263
- onClick={this.onSave.bind(this)}
264
- />
265
- <Button
266
- basic
267
- content={i18n.t('Common.buttons.cancel')}
268
- onClick={this.onClose.bind(this)}
269
- />
270
- </Modal.Actions>
271
- </Modal>
272
- )}
273
- </ModalContext.Consumer>
274
- )}
275
- </>
276
- );
277
- }
227
+ const validateItem = useCallback((item) => {
228
+ const errors = [];
278
229
 
279
- /**
280
- * Renders the error modal.
281
- *
282
- * @returns {null|*}
283
- */
284
- renderErrors() {
285
- if (!this.hasErrors()) {
286
- return null;
287
- }
230
+ _.each(_.keys(props.required), (key) => {
231
+ const value = item[key];
232
+ let invalid;
288
233
 
289
- return (
290
- <Toaster
291
- onDismiss={this.onDismissErrors.bind(this)}
292
- timeout={0}
293
- type={Toaster.MessageTypes.negative}
294
- >
295
- <Message.Header
296
- content={i18n.t('Common.messages.error.header')}
297
- />
298
- <Message.List>
299
- { _.map(this.state.items, this.renderMessageItem.bind(this)) }
300
- </Message.List>
301
- </Toaster>
302
- );
303
- }
234
+ if (_.isNumber(value)) {
235
+ invalid = _.isEmpty(value.toString());
236
+ } else {
237
+ invalid = _.isEmpty(value);
238
+ }
239
+
240
+ if (invalid) {
241
+ errors.push(key);
242
+ }
243
+ });
244
+
245
+ return {
246
+ ...item,
247
+ errors
248
+ };
249
+ }, [props.required]);
304
250
 
305
251
  /**
306
- * Renders the passed item.
252
+ * Validates the items on the state.
307
253
  *
308
- * @param item
309
- *
310
- * @returns {*}
254
+ * @type {function(): Promise<void>}
311
255
  */
312
- renderItem(item: any) {
313
- const FileItem = this.props.itemComponent;
256
+ const onValidate = useCallback(() => {
257
+ let error = false;
314
258
 
315
- return (
316
- <FileItem
317
- isError={(key) => _.contains(item.errors, key)}
318
- isRequired={(key) => !!this.props.required[key]}
319
- item={item}
320
- onAssociationInputChange={this.onAssociationInputChange.bind(this, item)}
321
- onDelete={this.onDelete.bind(this, item)}
322
- onTextInputChange={this.onTextInputChange.bind(this, item)}
323
- onUpdate={this.onUpdate.bind(this, item)}
324
- />
325
- );
326
- }
259
+ setItems((prevItems) => _.map(prevItems, (item) => {
260
+ const valid = validateItem(item);
261
+
262
+ if (!_.isEmpty(item.errors)) {
263
+ error = true;
264
+ }
265
+
266
+ return valid;
267
+ }));
268
+
269
+ return error ? Promise.reject() : Promise.resolve();
270
+ }, [validateItem]);
327
271
 
328
272
  /**
329
- * Renders the list of items.
273
+ * Updates the passed item with the passed props.
330
274
  *
331
- * @returns {null|*}
275
+ * @type {(function(): void)|*}
332
276
  */
333
- renderItems() {
334
- if (!(this.state.items && this.state.items.length)) {
335
- return null;
336
- }
337
-
338
- return (
339
- <Item.Group
340
- as={Form}
341
- divided
342
- noValidate
343
- relaxed='very'
344
- >
345
- { _.map(this.state.items, this.renderItem.bind(this)) }
346
- </Item.Group>
347
- );
348
- }
277
+ const onUpload = useCallback(() => {
278
+ // Set the uploading indicator
279
+ setUploading(true);
280
+
281
+ // Upload the files
282
+ onValidate()
283
+ .then(() => (
284
+ props.strategy === Strategy.batch
285
+ ? onBatchUpload()
286
+ : onSingleUpload()
287
+ ))
288
+ .finally(onComplete);
289
+ }, [onBatchUpload, onComplete, onSingleUpload, onValidate, props.strategy]);
349
290
 
350
291
  /**
351
- * Renders the errors message for the passed item.
352
- *
353
- * @param item
354
- * @param index
292
+ * Renders the error message for the passed item.
355
293
  *
356
- * @returns {null|*}
294
+ * @type {(function(*, *): (null|*))|*}
357
295
  */
358
- renderMessageItem(item: any, index: number) {
296
+ const renderMessageItem = useCallback((item, index) => {
359
297
  if (_.isEmpty(item.errors)) {
360
298
  return null;
361
299
  }
362
300
 
363
- const filename = !_.isEmpty(item.name) ? item.name : `File ${index}`;
364
- const fields = _.map(item.errors, (e) => this.props.required[e]).join(LIST_DELIMITER);
301
+ const filename = !_.isEmpty(item.name) ? item.name : index;
302
+ const fields = _.map(item.errors, (e) => props.required[e]).join(', ');
365
303
 
366
304
  return (
367
305
  <Message.Item
@@ -369,78 +307,115 @@ class FileUploadModal extends Component<Props, State> {
369
307
  key={index}
370
308
  />
371
309
  );
372
- }
373
-
374
- /**
375
- * Saves the uploaded items.
376
- */
377
- save() {
378
- if (this.hasErrors()) {
379
- return;
380
- }
381
-
382
- this.setState({ saving: true }, () => {
383
- this.props
384
- .onSave(this.state.items)
385
- .then(this.onClose.bind(this));
386
- });
387
- }
388
-
389
- /**
390
- * Validates the list of items.
391
- */
392
- validate() {
393
- this.setState((state) => {
394
- const items = _.map(state.items, this.validateItem.bind(this));
395
-
396
- return {
397
- items,
398
- saving: !_.find(items, (item) => !_.isEmpty(item.errors))
399
- };
400
- }, this.save.bind(this));
401
- }
310
+ }, []);
402
311
 
403
312
  /**
404
- * Validates the passed item.
313
+ * Memoization and case correction for the <code>itemComponent</code> prop.
405
314
  *
406
- * @param item
407
- *
408
- * @returns {{errors: []}}
315
+ * @type {React$AbstractComponent<*, *>}
409
316
  */
410
- validateItem(item: any) {
411
- const errors = [];
412
-
413
- _.each(_.keys(this.props.required), (key) => {
414
- const value = item[key];
415
- let invalid;
416
-
417
- if (_.isNumber(value)) {
418
- invalid = _.isEmpty(value.toString());
419
- } else {
420
- invalid = _.isEmpty(value);
421
- }
422
-
423
- if (invalid) {
424
- errors.push(key);
425
- }
426
- });
427
-
428
- return { ...item, errors };
429
- }
430
- }
431
-
432
- FileUploadModal.defaultProps = {
433
- includeButton: true
317
+ const UploadItem = useMemo(() => props.itemComponent, [props.itemComponent]);
318
+
319
+ return (
320
+ <ModalContext.Consumer>
321
+ { (mountNode) => (
322
+ <Modal
323
+ centered={false}
324
+ className='serial-upload-modal'
325
+ mountNode={mountNode}
326
+ open
327
+ >
328
+ { props.showPageLoader && (
329
+ <Dimmer
330
+ active={uploading}
331
+ inverted
332
+ >
333
+ <Loader
334
+ content={i18n.t('FileUploadModal.loader')}
335
+ />
336
+ </Dimmer>
337
+ )}
338
+ <Modal.Header>
339
+ { props.strategy === Strategy.batch && i18n.t('FileUploadModal.title') }
340
+ { props.strategy === Strategy.single && (
341
+ <FileUploadProgress
342
+ completed={uploadCount}
343
+ total={items.length}
344
+ uploading={uploading}
345
+ />
346
+ )}
347
+ </Modal.Header>
348
+ <Modal.Content
349
+ scrolling
350
+ >
351
+ { hasErrors && (
352
+ <Message
353
+ error
354
+ >
355
+ <Message.Header
356
+ content={i18n.t('FileUploadModal.errors.header')}
357
+ />
358
+ <Message.List>
359
+ { _.map(items, renderMessageItem) }
360
+ </Message.List>
361
+ </Message>
362
+ )}
363
+ <FileUpload
364
+ onFilesAdded={onAddFiles}
365
+ />
366
+ <Item.Group
367
+ as={Form}
368
+ divided
369
+ noValidate
370
+ relaxed='very'
371
+ >
372
+ { _.map(items, (item, index) => (
373
+ <UploadItem
374
+ isError={(key) => _.contains(item.errors, key)}
375
+ isRequired={(key) => !!(props.required && props.required[key])}
376
+ item={item}
377
+ key={index}
378
+ onAssociationInputChange={onAssociationInputChange.bind(this, item)}
379
+ onDelete={onDelete.bind(this, item)}
380
+ onTextInputChange={onTextInputChange.bind(this, item)}
381
+ onUpdate={onUpdate.bind(this, item)}
382
+ >
383
+ { props.strategy === Strategy.single && (
384
+ <FileUploadStatus
385
+ status={statuses[index]}
386
+ />
387
+ )}
388
+ </UploadItem>
389
+ ))}
390
+ </Item.Group>
391
+ </Modal.Content>
392
+ <Modal.Actions>
393
+ <Button
394
+ content={i18n.t('Common.buttons.upload')}
395
+ disabled={uploading || uploadCount > 0 || _.isEmpty(items)}
396
+ icon='cloud upload'
397
+ loading={uploading && !props.showPageLoader}
398
+ onClick={onUpload}
399
+ primary
400
+ />
401
+ <Button
402
+ content={uploadCount > 0
403
+ ? i18n.t('Common.buttons.close')
404
+ : i18n.t('Common.buttons.cancel')}
405
+ disabled={uploading}
406
+ onClick={props.onClose}
407
+ />
408
+ </Modal.Actions>
409
+ </Modal>
410
+ )}
411
+ </ModalContext.Consumer>
412
+ );
434
413
  };
435
414
 
436
- export type FileUploadProps = {
437
- isError: (key: string) => boolean,
438
- isRequired: (key: string) => boolean,
439
- item: any,
440
- onAssociationInputChange: (idKey: string, valueKey: string, item: any) => void,
441
- onDelete: (item: any) => void,
442
- onTextInputChange: (item: any, value: any) => void,
443
- onUpdate: (item: any, props: any) => void
415
+ FileUploadModal.defaultProps = {
416
+ closeOnComplete: true,
417
+ strategy: Strategy.batch,
418
+ showPageLoader: true
444
419
  };
445
420
 
446
421
  export default FileUploadModal;