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

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