expensify-common 2.0.188 → 2.0.190

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.
Files changed (78) hide show
  1. package/dist/Logger.js +11 -1
  2. package/dist/esm/API.d.ts +11 -0
  3. package/dist/esm/API.js +786 -0
  4. package/dist/esm/APIDeferred.d.ts +7 -0
  5. package/dist/esm/APIDeferred.js +216 -0
  6. package/dist/esm/BrowserDetect.d.ts +19 -0
  7. package/dist/esm/BrowserDetect.js +105 -0
  8. package/dist/esm/CLI.js +37 -29
  9. package/dist/esm/CONST.d.ts +1727 -0
  10. package/dist/esm/CONST.js +1847 -0
  11. package/dist/esm/Cookie.d.ts +68 -0
  12. package/dist/esm/Cookie.js +159 -0
  13. package/dist/esm/CredentialsWrapper.d.ts +32 -0
  14. package/dist/esm/CredentialsWrapper.js +46 -0
  15. package/dist/esm/Device.d.ts +8 -0
  16. package/dist/esm/Device.js +24 -0
  17. package/dist/esm/ExpenseRule.d.ts +39 -0
  18. package/dist/esm/ExpenseRule.js +77 -0
  19. package/dist/esm/ExpensiMark.d.ts +197 -0
  20. package/dist/esm/ExpensiMark.js +1374 -0
  21. package/dist/esm/Func.d.ts +40 -0
  22. package/dist/esm/Func.js +69 -0
  23. package/dist/esm/Log.d.ts +3 -0
  24. package/dist/esm/Log.js +35 -0
  25. package/dist/esm/Logger.d.ts +82 -0
  26. package/dist/esm/Logger.js +148 -0
  27. package/dist/esm/Network.d.ts +6 -0
  28. package/dist/esm/Network.js +168 -0
  29. package/dist/esm/Num.d.ts +95 -0
  30. package/dist/esm/Num.js +190 -0
  31. package/dist/esm/PageEvent.d.ts +25 -0
  32. package/dist/esm/PageEvent.js +22 -0
  33. package/dist/esm/PubSub.d.ts +2 -0
  34. package/dist/esm/PubSub.js +111 -0
  35. package/dist/esm/ReportHistoryStore.d.ts +8 -0
  36. package/dist/esm/ReportHistoryStore.js +199 -0
  37. package/dist/esm/SafeString.js +2 -2
  38. package/dist/esm/Templates.d.ts +58 -0
  39. package/dist/esm/Templates.js +196 -0
  40. package/dist/esm/Url.d.ts +10 -0
  41. package/dist/esm/Url.js +15 -0
  42. package/dist/esm/components/CopyText.d.ts +45 -0
  43. package/dist/esm/components/CopyText.js +56 -0
  44. package/dist/esm/components/StepProgressBar.d.ts +26 -0
  45. package/dist/esm/components/StepProgressBar.js +40 -0
  46. package/dist/esm/components/form/element/combobox.d.ts +231 -0
  47. package/dist/esm/components/form/element/combobox.js +812 -0
  48. package/dist/esm/components/form/element/dropdown.d.ts +35 -0
  49. package/dist/esm/components/form/element/dropdown.js +61 -0
  50. package/dist/esm/components/form/element/dropdownItem.d.ts +55 -0
  51. package/dist/esm/components/form/element/dropdownItem.js +113 -0
  52. package/dist/esm/components/form/element/onOffSwitch.d.ts +94 -0
  53. package/dist/esm/components/form/element/onOffSwitch.js +167 -0
  54. package/dist/esm/components/form/element/switch.d.ts +58 -0
  55. package/dist/esm/components/form/element/switch.js +98 -0
  56. package/dist/esm/fastMerge.d.ts +9 -0
  57. package/dist/esm/fastMerge.js +58 -0
  58. package/dist/esm/index.d.ts +22 -0
  59. package/dist/esm/index.js +22 -0
  60. package/dist/esm/jquery.expensifyIframify.d.ts +9 -0
  61. package/dist/esm/jquery.expensifyIframify.js +397 -0
  62. package/dist/esm/md5.d.ts +2 -0
  63. package/dist/esm/md5.js +170 -0
  64. package/dist/esm/mixins/PubSub.d.ts +20 -0
  65. package/dist/esm/mixins/PubSub.js +47 -0
  66. package/dist/esm/mixins/extraClasses.d.ts +8 -0
  67. package/dist/esm/mixins/extraClasses.js +37 -0
  68. package/dist/esm/mixins/validationClasses.d.ts +12 -0
  69. package/dist/esm/mixins/validationClasses.js +30 -0
  70. package/dist/esm/str.d.ts +604 -0
  71. package/dist/esm/str.js +1036 -0
  72. package/dist/esm/tlds.d.ts +2 -0
  73. package/dist/esm/tlds.js +2 -0
  74. package/dist/esm/utils.d.ts +30 -0
  75. package/dist/esm/utils.js +65 -0
  76. package/dist/index.d.ts +1 -0
  77. package/dist/index.js +8 -1
  78. package/package.json +221 -4
@@ -0,0 +1,812 @@
1
+ import React from 'react';
2
+ import ReactDOM from 'react-dom';
3
+ import PropTypes from 'prop-types';
4
+ import cn from 'classnames';
5
+ import defer from 'lodash/defer';
6
+ import get from 'lodash/get';
7
+ import has from 'lodash/has';
8
+ import isEqual from 'lodash/isEqual';
9
+ import template from 'lodash/template';
10
+ import uniqBy from 'lodash/uniqBy';
11
+ import Str from '../../../str';
12
+ import DropDown from './dropdown';
13
+ import * as Utils from '../../../utils';
14
+ /**
15
+ * Stops events from doing anything outside of the current function (in theory).
16
+ * This doesn't work between jquery and React events because React's event propagation is a separate system.
17
+ *
18
+ * @param {SyntheticEvent} e
19
+ */
20
+ function stopEvent(e) {
21
+ e.preventDefault();
22
+ if (e.nativeEvent) {
23
+ e.nativeEvent.stopImmediatePropagation();
24
+ }
25
+ else {
26
+ e.stopImmediatePropagation();
27
+ }
28
+ e.stopPropagation();
29
+ }
30
+ const propTypes = {
31
+ // These are the elements to show in the dropdown
32
+ options: PropTypes.arrayOf(PropTypes.shape({
33
+ // The value of the option, should be unique
34
+ value: PropTypes.oneOfType([PropTypes.string, PropTypes.number]),
35
+ // The human readable text of the option
36
+ text: PropTypes.string,
37
+ // If we need more than text to style more
38
+ children: PropTypes.element,
39
+ // Whether or not this tag has an error (like out of policy)
40
+ hasError: PropTypes.bool,
41
+ // If this option is disabled, then it can't be selected and needs styled differently
42
+ disabled: PropTypes.bool,
43
+ // An id to use when checking if the options changed. It's used to avoid triggering changes when passing
44
+ // children to it, if the id didn't change we assume no other properties changed either.
45
+ id: PropTypes.oneOfType([PropTypes.string, PropTypes.number]),
46
+ // Whether or not the object should be displayed as a divider
47
+ // Used to identify the divider in some UI elements that need a separation between listed options
48
+ divider: PropTypes.bool,
49
+ // Whether or not the option is represented more than once in the list of options and should be selected
50
+ // Used in some UI elements that may display the same user details multiple times to identify which ones
51
+ // are duplicates
52
+ selected: PropTypes.bool,
53
+ })).isRequired,
54
+ // A default value to have selected
55
+ defaultValue: PropTypes.oneOfType([PropTypes.string, PropTypes.number]),
56
+ // A default value to have selected - Use this one rather than defaultValue when the parent view might change the value passed.
57
+ value: PropTypes.oneOfType([PropTypes.string, PropTypes.number]),
58
+ // A list of options to show as selected too. This is used when we are using this component to implement a multi
59
+ // select, where another component holds all the selected options and this component only has to show the ones
60
+ // that are already selected with the check mark.
61
+ alreadySelectedOptions: PropTypes.arrayOf(PropTypes.shape({
62
+ id: PropTypes.oneOfType([PropTypes.string, PropTypes.number]),
63
+ value: PropTypes.oneOfType([PropTypes.string, PropTypes.number]),
64
+ text: PropTypes.string,
65
+ })),
66
+ // A callback that is fired when the value of the selector has changed
67
+ onChange: PropTypes.func,
68
+ // A callback that is fired when the value is cleared out by deleting text in the input
69
+ onClear: PropTypes.func,
70
+ // Callback fired when the dropdown is open/closed. It will pass a boolean param with true if it's open
71
+ onDropdownStateChange: PropTypes.func,
72
+ // Maximum amount of items to show
73
+ maxItemsToShow: PropTypes.number,
74
+ // Maximum amount of search results
75
+ maxSearchResults: PropTypes.number,
76
+ // By default we show the selected option's value
77
+ // You can overwrite it to display a different property (like 'text')
78
+ propertyToDisplay: PropTypes.oneOf(['text', 'value']),
79
+ // Whether or not the combobox should be open when we initialize it
80
+ openOnInit: PropTypes.bool,
81
+ // A property to allow the combobox set to any manual entry the user chooses
82
+ allowAnyValue: PropTypes.bool,
83
+ // What text should we show when the text entered doesn't match any of the results. It's an underscore template
84
+ // that will be passed `value` as the current entered text.
85
+ noResultsText: PropTypes.string,
86
+ // A property to determine if the selector is user editable
87
+ isReadOnly: PropTypes.bool,
88
+ // Text to use for the placeholder, defaults to "Type to search..."
89
+ placeholder: PropTypes.string,
90
+ // An array of extra classes to put on the combobox
91
+ extraClasses: PropTypes.oneOfType([PropTypes.string, PropTypes.array, PropTypes.object]),
92
+ // This makes this component hidden till someone manually calls openDropdown
93
+ hideUntilManuallyOpened: PropTypes.bool,
94
+ // Do we want to always show the selected options on top?
95
+ alwaysShowSelectedOnTop: PropTypes.bool,
96
+ // Should this combobox have an error state on init?
97
+ hasInitialError: PropTypes.bool,
98
+ // Bootstrap 4 compatibility flag
99
+ bs4: PropTypes.bool,
100
+ // Always include the disabled options in search results
101
+ showDisabledOptionsInResults: PropTypes.bool,
102
+ // Do we want to autoscroll to the top of the selector on item selection
103
+ autoScrollToTop: PropTypes.bool,
104
+ // Classes to apply to the dropdown in the combobox
105
+ dropDownClasses: PropTypes.oneOfType([PropTypes.string, PropTypes.array, PropTypes.object]),
106
+ };
107
+ const defaultProps = {
108
+ defaultValue: '',
109
+ value: '',
110
+ onChange: () => { },
111
+ onClear: () => { },
112
+ onDropdownStateChange: () => { },
113
+ maxItemsToShow: 500,
114
+ maxSearchResults: 200,
115
+ propertyToDisplay: 'value',
116
+ allowAnyValue: false,
117
+ openOnInit: false,
118
+ isReadOnly: false,
119
+ alreadySelectedOptions: [],
120
+ noResultsText: 'No results',
121
+ placeholder: 'Type to search...',
122
+ extraClasses: [],
123
+ hideUntilManuallyOpened: false,
124
+ alwaysShowSelectedOnTop: false,
125
+ hasInitialError: false,
126
+ bs4: false,
127
+ showDisabledOptionsInResults: false,
128
+ autoScrollToTop: true,
129
+ dropDownClasses: [],
130
+ };
131
+ /**
132
+ * A combobox useful for searching for values in a large list
133
+ */
134
+ class Combobox extends React.Component {
135
+ constructor(props) {
136
+ super(props);
137
+ // Bind to our private methods
138
+ this.getStartState = this.getStartState.bind(this);
139
+ this.getTruncatedOptions = this.getTruncatedOptions.bind(this);
140
+ this.getValue = this.getValue.bind(this);
141
+ this.setValue = this.setValue.bind(this);
142
+ this.setText = this.setText.bind(this);
143
+ this.setDisabled = this.setDisabled.bind(this);
144
+ this.setError = this.setError.bind(this);
145
+ this.deselectCurrentOption = this.deselectCurrentOption.bind(this);
146
+ this.switchFocusedIndex = this.switchFocusedIndex.bind(this);
147
+ this.reset = this.reset.bind(this);
148
+ this.resetFocusedElements = this.resetFocusedElements.bind(this);
149
+ this.handleClickAway = this.handleClickAway.bind(this);
150
+ this.resetClickAwayHandler = this.resetClickAwayHandler.bind(this);
151
+ this.clearError = this.clearError.bind(this);
152
+ this.toggleDropdown = this.toggleDropdown.bind(this);
153
+ this.openDropdown = this.openDropdown.bind(this);
154
+ this.closeDropdown = this.closeDropdown.bind(this);
155
+ this.closeDropdownOnTabOut = this.closeDropdownOnTabOut.bind(this);
156
+ this.handleKeyDown = this.handleKeyDown.bind(this);
157
+ this.performSearch = this.performSearch.bind(this);
158
+ this.handleClick = this.handleClick.bind(this);
159
+ this.state = this.getStartState();
160
+ this.scrollPosition = 0;
161
+ }
162
+ componentDidMount() {
163
+ if (!this.props.openOnInit) {
164
+ return;
165
+ }
166
+ // Our dropdown will be open, so we need to listen for our click away events
167
+ // and put focus on the input
168
+ defer(this.resetClickAwayHandler);
169
+ $(this.value).focus().select();
170
+ }
171
+ // eslint-disable-next-line react/no-unsafe
172
+ UNSAFE_componentWillReceiveProps(nextProps) {
173
+ if (nextProps.value && !isEqual(nextProps.value, this.state.currentValue)) {
174
+ this.setValue(nextProps.value);
175
+ }
176
+ if (nextProps.options !== undefined) {
177
+ // If the options have an id property, we use that to compare them and determine if they changed, if not
178
+ // we'll use the whole options array.
179
+ const optionsChanged = has(nextProps.options, '0.id')
180
+ ? nextProps.options.some((option, index) => !isEqual(option.id, this.props.options[index] && this.props.options[index].id))
181
+ : !isEqual(nextProps.options, this.props.options);
182
+ // Check if alreadySelectedOptions changed - compare by id if available, otherwise by value or full comparison
183
+ const hasAlreadySelectedId = nextProps.alreadySelectedOptions && nextProps.alreadySelectedOptions.length > 0 && has(nextProps.alreadySelectedOptions[0], 'id');
184
+ const alreadySelectedChanged = hasAlreadySelectedId
185
+ ? nextProps.alreadySelectedOptions.length !== this.props.alreadySelectedOptions.length ||
186
+ nextProps.alreadySelectedOptions.some((alreadySelectedOption, index) => {
187
+ const prevOption = this.props.alreadySelectedOptions[index];
188
+ return !prevOption || !isEqual(alreadySelectedOption.id, prevOption.id);
189
+ })
190
+ : !isEqual(nextProps.alreadySelectedOptions, this.props.alreadySelectedOptions);
191
+ if (optionsChanged || alreadySelectedChanged) {
192
+ // If only alreadySelectedOptions changed and dropdown is open, just update options without resetting
193
+ if (!optionsChanged && alreadySelectedChanged && this.state.isDropdownOpen) {
194
+ const { currentValue } = this.state;
195
+ this.setState(() => ({
196
+ options: this.getTruncatedOptions(currentValue, nextProps.alreadySelectedOptions),
197
+ }));
198
+ }
199
+ else {
200
+ // Options changed or dropdown is closed - do full reset
201
+ this.reset(false, nextProps.options, nextProps.alreadySelectedOptions);
202
+ }
203
+ }
204
+ }
205
+ if (nextProps.openOnInit !== undefined && !isEqual(nextProps.openOnInit, this.props.openOnInit)) {
206
+ this.setState({
207
+ isDropdownOpen: nextProps.openOnInit,
208
+ });
209
+ }
210
+ if (nextProps.isReadOnly !== undefined && !isEqual(nextProps.isReadOnly, this.props.isReadOnly)) {
211
+ this.setState({
212
+ isDisabled: nextProps.isReadOnly,
213
+ });
214
+ }
215
+ }
216
+ componentDidUpdate() {
217
+ // Set scroll position to the last known location when applicable
218
+ if (!this.dropDown || this.props.autoScrollToTop) {
219
+ return;
220
+ }
221
+ this.dropDown.scrollTop = this.scrollPosition;
222
+ }
223
+ componentWillUnmount() {
224
+ // Don't ever let this handler linger after unmounting
225
+ $('body').off('click.clickaway', this.handleClickAway);
226
+ }
227
+ /**
228
+ * Handle state changes for when a user clicks on an item in the dropdown
229
+ *
230
+ * @param {String|Number} selectedValue
231
+ */
232
+ handleClick(selectedValue) {
233
+ this.deselectCurrentOption();
234
+ // Select the new item, set our new indexes, close the dropdown
235
+ // Unselect all other options
236
+ let newSelectedIndex = this.options.findIndex((option) => option.value === selectedValue);
237
+ let currentlySelectedOption = this.options.find((option) => option.value === selectedValue);
238
+ // If allowAnyValue is true and currentValue is absent then set it manually to what the user has entered.
239
+ if (newSelectedIndex === -1 && this.props.allowAnyValue && selectedValue) {
240
+ newSelectedIndex = 0;
241
+ currentlySelectedOption = {
242
+ text: selectedValue,
243
+ value: selectedValue,
244
+ };
245
+ }
246
+ // Get the scroll position of the currently selected value
247
+ this.scrollPosition = this.dropDown.scrollTop;
248
+ const stateUpdateCallback = () => {
249
+ this.resetClickAwayHandler();
250
+ // Fire our onChange callback
251
+ this.initialValue = selectedValue;
252
+ this.props.onChange(selectedValue);
253
+ };
254
+ this.setState({
255
+ options: this.getTruncatedOptions(selectedValue),
256
+ selectedIndex: newSelectedIndex,
257
+ focusedIndex: newSelectedIndex,
258
+ currentValue: selectedValue,
259
+ currentText: (currentlySelectedOption && currentlySelectedOption.text) || '',
260
+ isDropdownOpen: false,
261
+ hasError: (currentlySelectedOption && currentlySelectedOption.hasError) || false,
262
+ }, stateUpdateCallback);
263
+ }
264
+ /**
265
+ * Fired when we "click away" with the dropdown open
266
+ *
267
+ * @param {SyntheticEvent} e
268
+ */
269
+ handleClickAway(e) {
270
+ // Ignore the click away event if something within our combobox was clicked
271
+ // BIG HACK! I had to add the check for the support password click because it was causing a near
272
+ // unreproducible bug: https://github.com/Expensify/Expensify/issues/76745 that only I could reproduce.
273
+ // It is 100% unexplainable, but works for me
274
+ if ($(e.target).attr('id') === 'supportPassword' ||
275
+ ($(e.target).parents('.expensify-input-group').length && $(e.target).parents('.expensify-input-group')[0] === ReactDOM.findDOMNode(this)) // eslint-disable-line react/no-find-dom-node
276
+ ) {
277
+ return;
278
+ }
279
+ // If the dropdown is open then we don't really care and can do an early return
280
+ if (!this.state.isDropdownOpen) {
281
+ return;
282
+ }
283
+ this.reset(true);
284
+ // Trigger a change so that inline editor knows to cancel itself
285
+ this.props.onChange(this.initialValue || this.props.value || this.props.defaultValue);
286
+ }
287
+ /**
288
+ * This is triggered whenever there is a key up event in the text input
289
+ *
290
+ * @param {SyntheticEvent} e
291
+ */
292
+ handleKeyDown(e) {
293
+ let oldFocusedIndex;
294
+ let newFocusedIndex;
295
+ let currentValue;
296
+ let currentText;
297
+ const updateStateDownKey = (state) => ({
298
+ focusedIndex: newFocusedIndex,
299
+ options: state.options,
300
+ isDropdownOpen: true,
301
+ });
302
+ const updateStateUpKey = (state) => ({
303
+ focusedIndex: newFocusedIndex,
304
+ options: state.options,
305
+ isDropdownOpen: true,
306
+ });
307
+ const updateStateEnterKey = (state) => ({
308
+ options: this.getTruncatedOptions(currentValue),
309
+ selectedIndex: state.focusedIndex,
310
+ currentValue,
311
+ currentText,
312
+ isDropdownOpen: false,
313
+ });
314
+ const resetStateEnterKey = () => {
315
+ this.resetClickAwayHandler();
316
+ // Fire our onChange callback
317
+ this.props.onChange(currentValue);
318
+ this.initialValue = currentValue;
319
+ };
320
+ // Handle the arrow keys
321
+ switch (e.which) {
322
+ case 40:
323
+ // Down - move focused selection down
324
+ oldFocusedIndex = this.state.focusedIndex;
325
+ newFocusedIndex = oldFocusedIndex + 1;
326
+ // Wrap around to the top of the list
327
+ if (newFocusedIndex > this.state.options.length - 1) {
328
+ newFocusedIndex = 0;
329
+ }
330
+ this.switchFocusedIndex(oldFocusedIndex, newFocusedIndex);
331
+ this.setState(updateStateDownKey, this.resetClickAwayHandler);
332
+ stopEvent(e);
333
+ break;
334
+ case 38:
335
+ // Up - move focused selection up
336
+ oldFocusedIndex = this.state.focusedIndex;
337
+ newFocusedIndex = oldFocusedIndex - 1;
338
+ // Wrap around to the bottom of the list
339
+ if (newFocusedIndex < 0) {
340
+ newFocusedIndex = this.state.options.length - 1;
341
+ }
342
+ this.switchFocusedIndex(oldFocusedIndex, newFocusedIndex);
343
+ this.setState(updateStateUpKey, this.resetClickAwayHandler);
344
+ stopEvent(e);
345
+ break;
346
+ case 13: {
347
+ // Enter - set selection
348
+ if (!this.state.isDropdownOpen) {
349
+ return;
350
+ }
351
+ // Don't select disabled things
352
+ if (this.state.options[this.state.focusedIndex].disabled) {
353
+ break;
354
+ }
355
+ this.deselectCurrentOption();
356
+ currentValue = this.state.options[this.state.focusedIndex].value;
357
+ currentText = this.state.options[this.state.focusedIndex].text;
358
+ // if allowAnyValue is true and currentValue is absent then set it manually to what the user has entered
359
+ if (!currentValue && this.props.allowAnyValue) {
360
+ currentValue = e.target.value;
361
+ currentText = currentValue;
362
+ }
363
+ this.setState(updateStateEnterKey, resetStateEnterKey);
364
+ stopEvent(e);
365
+ break;
366
+ }
367
+ case 9:
368
+ case 27:
369
+ // Tab & Escape - clear selection
370
+ this.reset(true);
371
+ // Fire our onChange callback
372
+ this.props.onChange(this.initialValue || this.props.value || this.props.defaultValue);
373
+ // Stop the event from propagating if the escape key is pressed
374
+ if (e.which === 27) {
375
+ stopEvent(e);
376
+ }
377
+ break;
378
+ default:
379
+ break;
380
+ }
381
+ }
382
+ /**
383
+ * @param {Boolean} noDefaultValue Overrides the default value to have '' as the currentValue
384
+ * @param {Object[]} [newOptions] when resetting this component, we can specify new options to use
385
+ * @param {Object[]} [newAlreadySelectedOptions] when resetting this component, we can specify new already selected options to use
386
+ *
387
+ * @returns {Object}
388
+ */
389
+ getStartState(noDefaultValue, newOptions, newAlreadySelectedOptions) {
390
+ this.options = newOptions || this.props.options;
391
+ const value = this.props.value || this.props.defaultValue || '';
392
+ const currentValue = this.initialValue || value;
393
+ const matchingOptionWithoutSMSDomain = (o) => (Str.isString(o) ? Str.removeSMSDomain(o.value) : o.value) === currentValue && !o.isFake;
394
+ // We use removeSMSDomain here in case currentValue is a phone number
395
+ let defaultSelectedOption = this.options.find(matchingOptionWithoutSMSDomain);
396
+ // If no default was found and initialText was present then we can use initialText values
397
+ if (!defaultSelectedOption && this.initialText) {
398
+ defaultSelectedOption = {};
399
+ defaultSelectedOption.text = this.initialText;
400
+ }
401
+ return {
402
+ isDropdownOpen: this.props.openOnInit,
403
+ currentValue: noDefaultValue ? this.initialValue || '' : value,
404
+ currentText: defaultSelectedOption ? defaultSelectedOption.text : '',
405
+ options: this.getTruncatedOptions(currentValue, newAlreadySelectedOptions),
406
+ focusedIndex: 0,
407
+ selectedIndex: null,
408
+ isDisabled: this.props.isReadOnly,
409
+ hasError: (defaultSelectedOption && defaultSelectedOption.hasError) || this.props.hasInitialError,
410
+ };
411
+ }
412
+ /**
413
+ * Returns a set of our full options according to the maxItemsToShow
414
+ *
415
+ * @param {String|Number} currentValue
416
+ * @param {Object[]} [newAlreadySelectedOptions]
417
+ * @returns {Array}
418
+ */
419
+ getTruncatedOptions(currentValue, newAlreadySelectedOptions) {
420
+ const alreadySelected = newAlreadySelectedOptions || this.props.alreadySelectedOptions;
421
+ // Get the divider index if we have one
422
+ const findDivider = (option) => option.divider;
423
+ const dividerIndex = this.options.findIndex(findDivider);
424
+ // Split into two arrays everything before and after the divider (if the divider does not exist then we'll return a single array)
425
+ const splitOptions = dividerIndex ? [this.options.slice(0, dividerIndex + 1), this.options.slice(dividerIndex + 1)] : [this.options];
426
+ const formatOption = (option, index) => (Object.assign({ focused: false, isSelected: (option.selected && isEqual(option.value, currentValue)) || !!alreadySelected.find((item) => item.value === option.value), originalIndex: index }, option));
427
+ const sortByOption = (o) => {
428
+ // Unselectable text-only entries (isFake: true) go to the bottom and selected entries go to the top only if alwaysShowSelectedOnTop was passed
429
+ if (o.showLast) {
430
+ return 2;
431
+ }
432
+ return o.isSelected && this.props.alwaysShowSelectedOnTop ? 0 : 1;
433
+ };
434
+ // Take each array and format it, sort it, and move selected items to top (if applicable)
435
+ const formatOptions = (array, arrayStartIndex) => array
436
+ .map((option, index) => formatOption(option, arrayStartIndex + index))
437
+ .sort((a, b) => {
438
+ const sortDiff = sortByOption(a) - sortByOption(b);
439
+ // If sort priority is the same, preserve original order using originalIndex
440
+ return sortDiff !== 0 ? sortDiff : a.originalIndex - b.originalIndex;
441
+ })
442
+ .slice(0, this.props.maxItemsToShow);
443
+ let cumulativeIndex = 0;
444
+ const truncatedOptions = splitOptions
445
+ .map((array) => {
446
+ const result = formatOptions(array, cumulativeIndex);
447
+ cumulativeIndex += array.length;
448
+ return result;
449
+ })
450
+ .flat();
451
+ if (!truncatedOptions.length) {
452
+ truncatedOptions.push({
453
+ text: 'No items',
454
+ value: '',
455
+ isSelectable: false,
456
+ isFake: true,
457
+ showLast: true,
458
+ });
459
+ }
460
+ else if (this.options.length > this.props.maxItemsToShow) {
461
+ truncatedOptions.push({
462
+ text: `Viewing first ${this.props.maxItemsToShow} items. Search to see more.`,
463
+ value: '',
464
+ isSelectable: false,
465
+ isFake: true,
466
+ showLast: true,
467
+ });
468
+ }
469
+ return truncatedOptions;
470
+ }
471
+ /**
472
+ * Returns the selected value(s)
473
+ *
474
+ * @returns {String}
475
+ */
476
+ getValue() {
477
+ return this.state.currentValue;
478
+ }
479
+ /**
480
+ * Sets the current value
481
+ *
482
+ * @param {String} val
483
+ */
484
+ setValue(val) {
485
+ // We need to look in `this.options` for the matching option because `this.state.options` is a truncated list
486
+ // and might not have every option
487
+ const optionMatchingVal = this.options.find((option) => option.value === val);
488
+ const currentText = get(optionMatchingVal, 'text', '');
489
+ const deselectOption = (initialOption) => {
490
+ const option = initialOption;
491
+ const isSelected = isEqual(option.value, val);
492
+ option.isSelected = isSelected || !!this.props.alreadySelectedOptions.find((optionItem) => optionItem.value === option.value);
493
+ return option;
494
+ };
495
+ const deselectOptions = (options) => options.map(deselectOption);
496
+ const setValueState = (state) => ({
497
+ currentValue: val,
498
+ currentText,
499
+ // Deselect all other options but the one matching our value
500
+ options: deselectOptions(state.options),
501
+ });
502
+ this.initialValue = val;
503
+ this.setState(setValueState);
504
+ }
505
+ /**
506
+ * Hard sets the current text to what we want
507
+ *
508
+ * @param {String} val
509
+ */
510
+ setText(val) {
511
+ // See if there is a value in the options that matches the text we want to set. If the option
512
+ // does exist, then use the text property of that option for the text to display. If the option
513
+ // does not exist, then just display whatever value was passed
514
+ const optionMatchingVal = this.options.find((option) => option.value === val);
515
+ const currentText = (optionMatchingVal && optionMatchingVal.text) || val;
516
+ this.initialValue = currentText;
517
+ this.initialText = currentText;
518
+ this.setState({ currentText });
519
+ }
520
+ /**
521
+ * Sets the disabled state of this component
522
+ *
523
+ * @param {Boolean} isDisabled
524
+ */
525
+ setDisabled(isDisabled) {
526
+ this.setState({ isDisabled });
527
+ }
528
+ /**
529
+ * Set an error for a specific tag
530
+ */
531
+ setError() {
532
+ this.setState({
533
+ hasError: true,
534
+ });
535
+ }
536
+ /**
537
+ * Deselects any current option if it exists
538
+ */
539
+ deselectCurrentOption() {
540
+ if (this.state.selectedIndex === null || !this.state.options[this.state.selectedIndex]) {
541
+ return;
542
+ }
543
+ const setValueState = (state) => {
544
+ const newOptions = [...state.options];
545
+ newOptions[state.selectedIndex].isSelected = false;
546
+ return {
547
+ options: newOptions,
548
+ };
549
+ };
550
+ this.setState(setValueState);
551
+ }
552
+ /**
553
+ * Swaps the focused index from {oldFocusedIndex} to {newFocusedIndex}
554
+ *
555
+ * @param {number} oldFocusedIndex
556
+ * @param {number} newFocusedIndex
557
+ */
558
+ switchFocusedIndex(oldFocusedIndex, newFocusedIndex) {
559
+ const setFocusedState = (state) => {
560
+ const newOptions = [...state.options];
561
+ newOptions[oldFocusedIndex].focused = false;
562
+ newOptions[newFocusedIndex].focused = true;
563
+ return {
564
+ options: newOptions,
565
+ };
566
+ };
567
+ this.setState(setFocusedState);
568
+ }
569
+ /**
570
+ * Returns the selector to it's original state
571
+ *
572
+ * @param {Boolean} noDefaultValue Overrides the initials state defaultValue by setting the currentValue to ''
573
+ * @param {Object[]} [newOptions] we can recreate it with new options
574
+ * @param {Object[]} [newAlreadySelectedOptions] we can recreate it with new already selected options
575
+ */
576
+ reset(noDefaultValue, newOptions, newAlreadySelectedOptions) {
577
+ if (newOptions) {
578
+ this.options = newOptions;
579
+ }
580
+ const state = this.getStartState(noDefaultValue, this.options, newAlreadySelectedOptions);
581
+ const handleDropdownStateChange = () => {
582
+ this.props.onDropdownStateChange(!!state.isDropdownOpen);
583
+ };
584
+ this.setState(state, handleDropdownStateChange);
585
+ }
586
+ /**
587
+ * When the dropdown is closed, we reset the focused property of all of our options
588
+ */
589
+ resetFocusedElements() {
590
+ const resetFocusedProperty = (o) => {
591
+ const option = o;
592
+ option.focused = false;
593
+ return option;
594
+ };
595
+ const setValueState = (state) => ({
596
+ options: state.options.map(resetFocusedProperty),
597
+ });
598
+ this.setState(setValueState);
599
+ }
600
+ /**
601
+ * Sets a clicklistener to close the dropdown if it is currently open
602
+ */
603
+ resetClickAwayHandler() {
604
+ $('body').off('click.clickaway', this.handleClickAway);
605
+ if (this.state.isDropdownOpen) {
606
+ $('body').on('click.clickaway', this.handleClickAway);
607
+ }
608
+ else {
609
+ this.resetFocusedElements();
610
+ }
611
+ }
612
+ /**
613
+ * Clear an error for a specific tag
614
+ */
615
+ clearError() {
616
+ this.setState({
617
+ hasError: false,
618
+ });
619
+ }
620
+ /**
621
+ * Opens or closes the dropdown
622
+ */
623
+ toggleDropdown() {
624
+ if (this.state.isDropdownOpen) {
625
+ this.closeDropdown();
626
+ }
627
+ else {
628
+ this.openDropdown();
629
+ }
630
+ }
631
+ /**
632
+ * Just open the dropdown (when the input gets focus)
633
+ */
634
+ openDropdown() {
635
+ if (this.state.isDropdownOpen) {
636
+ return;
637
+ }
638
+ const setValueState = () => ({
639
+ isDropdownOpen: true,
640
+ });
641
+ const resetState = () => {
642
+ this.props.onDropdownStateChange(true);
643
+ this.resetClickAwayHandler();
644
+ $(this.value).focus().select();
645
+ };
646
+ this.setState(setValueState, resetState);
647
+ }
648
+ /**
649
+ * Closes the dropdown and removes the click handler that calls this function after the dropdown is closed
650
+ *
651
+ * @param {SyntheticEvent} [e]
652
+ */
653
+ closeDropdown(e) {
654
+ if (!this.state.isDropdownOpen) {
655
+ return;
656
+ }
657
+ // If the dropdown is being closed from clicking on our input, then we actually don't close the dropdown. This
658
+ // occurs because when first opening the dropdown when the input receives focus, it fires off the clickaway
659
+ // event and I couldn't stop it. So this is the work around.
660
+ if (e && ($(e.target)[0] === $(this.value)[0] || $(e.target).parent().hasClass('disabled'))) {
661
+ return;
662
+ }
663
+ const setValueState = () => ({
664
+ isDropdownOpen: false,
665
+ });
666
+ const resetState = () => {
667
+ this.props.onDropdownStateChange(false);
668
+ this.resetClickAwayHandler();
669
+ };
670
+ this.setState(setValueState, resetState);
671
+ // The value a user selects is set in state prior to this function running so we want to always treat this as if
672
+ // it were just a blur event and reset the input to an empty value and then let onChange handle showing the proper value
673
+ // This is because users who click the arrow used to be able to save incorrect values in the combobox: https://github.com/Expensify/Expensify/issues/75793#issuecomment-380260662
674
+ this.reset(true);
675
+ this.props.onChange(this.initialValue || this.props.value || this.props.defaultValue);
676
+ }
677
+ /**
678
+ * If the user presses tab when the dropdown button is focused, then we close the dropdown
679
+ * because they are trying to get to the next form components
680
+ *
681
+ * @param {SyntheticEvent} e
682
+ */
683
+ closeDropdownOnTabOut(e) {
684
+ if (e.which !== 9) {
685
+ return;
686
+ }
687
+ this.handleClick(this.state.currentValue);
688
+ }
689
+ /**
690
+ * This is triggered whenever there is a change event in the text input
691
+ * (from any valid input that's not being handled from the keyUp event)
692
+ *
693
+ * @param {SyntheticEvent} e
694
+ */
695
+ performSearch(e) {
696
+ // If empty value, hide the dropdown, update the initial fields and show the original options.
697
+ const value = e.target.value;
698
+ if (value === '') {
699
+ this.initialValue = '';
700
+ this.initialText = '';
701
+ // This will show all the original items while the input field stays empty. Also the dropdown will remain open.
702
+ this.setState({
703
+ currentValue: '',
704
+ currentText: '',
705
+ isDropdownOpen: true,
706
+ options: this.getTruncatedOptions(''),
707
+ }, this.resetClickAwayHandler);
708
+ // Trigger the callback to clear out the value
709
+ this.props.onClear();
710
+ return;
711
+ }
712
+ // Search our original options. We want:
713
+ // Exact matches first
714
+ // beginning-of-string matches second
715
+ // middle-of-string matches last
716
+ const matchRegexes = [new RegExp(`^${Str.escapeForRegExp(value)}$`, 'i'), new RegExp(`^${Str.escapeForRegExp(value)}`, 'i'), new RegExp(Str.escapeForRegExp(value), 'i')];
717
+ let hasMoreResults = false;
718
+ let matches = new Set();
719
+ const searchOptions = uniqBy(this.options, 'value');
720
+ for (let i = 0; i < matchRegexes.length; i++) {
721
+ if (matches.size < this.props.maxSearchResults) {
722
+ for (let j = 0; j < searchOptions.length; j++) {
723
+ const option = searchOptions[j];
724
+ const isDisabled = option.disabled;
725
+ const isMatch = matchRegexes[i].test(option.text.toString().replaceAll('&nbsp;', ''));
726
+ // Don't include the disabled options that match the regex unless we specified we want them to show.
727
+ // If we want them to show then add them whether they match the regex or not.
728
+ if ((!isDisabled && isMatch) || (isDisabled && this.props.showDisabledOptionsInResults && isMatch)) {
729
+ matches.add(option);
730
+ }
731
+ if (matches.size === this.props.maxSearchResults) {
732
+ hasMoreResults = true;
733
+ break;
734
+ }
735
+ }
736
+ }
737
+ else {
738
+ hasMoreResults = true;
739
+ break;
740
+ }
741
+ }
742
+ matches = Array.from(matches);
743
+ const formatOption = (option) => (Object.assign({ focused: false, isSelected: isEqual(option.value ? option.value.toUpperCase : '', value.toUpperCase()) || !!this.props.alreadySelectedOptions.find((optionItem) => optionItem.value === option.value) }, option));
744
+ const options = matches.map(formatOption);
745
+ // Focus the first option if there is one and show a message dependent on what options are present
746
+ if (options.length) {
747
+ options[0].focused = true;
748
+ if (hasMoreResults) {
749
+ options.push({
750
+ text: `Viewing first ${options.length} results`,
751
+ value: '',
752
+ isSelectable: false,
753
+ isFake: true,
754
+ showLast: true,
755
+ });
756
+ }
757
+ }
758
+ else {
759
+ options.push({
760
+ text: this.props.allowAnyValue ? value : template(this.props.noResultsText)({ value }),
761
+ value: this.props.allowAnyValue ? value : '',
762
+ isSelectable: this.props.allowAnyValue,
763
+ isFake: true,
764
+ showLast: true,
765
+ });
766
+ }
767
+ const setValueState = (state) => {
768
+ let shouldShowDropdown = state.isDropdownOpen;
769
+ // If we don't have an empty value, show the dropdown.
770
+ if (value.trim() !== '' && !shouldShowDropdown) {
771
+ shouldShowDropdown = true;
772
+ }
773
+ return {
774
+ currentValue: value,
775
+ currentText: value,
776
+ isDropdownOpen: shouldShowDropdown,
777
+ focusedIndex: 0,
778
+ options,
779
+ };
780
+ };
781
+ this.setState(setValueState, this.resetClickAwayHandler);
782
+ }
783
+ render() {
784
+ if (this.props.hideUntilManuallyOpened && !this.state.isDropdownOpen) {
785
+ return null;
786
+ }
787
+ const inputGroupClasses = {
788
+ 'input-group': true,
789
+ 'expensify-input-group': true,
790
+ open: this.state.isDropdownOpen,
791
+ };
792
+ const inputGroupBtnClasses = {
793
+ 'input-group-btn': !this.props.bs4,
794
+ 'input-group-append': this.props.bs4,
795
+ open: this.state.isDropdownOpen,
796
+ };
797
+ const toggleBtnClasses = this.props.bs4 ? 'dropdown-toggle dropdown-toggle-split btn btn-outline-primary' : 'dropdown-toggle btn btn-default';
798
+ return (React.createElement("div", { className: cn(inputGroupClasses, this.props.extraClasses), onKeyDown: this.handleKeyDown, role: "presentation" },
799
+ React.createElement("input", { ref: (ref) => (this.value = ref), type: "text", className: cn(['form-control', { error: this.state.hasError }]), disabled: this.state.isDisabled, "aria-label": "...", onChange: this.performSearch, onKeyDown: this.closeDropdownOnTabOut, value: this.props.propertyToDisplay === 'value' ? Utils.unescapeText(this.state.currentValue) : Utils.unescapeText(this.state.currentText.replaceAll('&nbsp;', '')), onFocus: this.openDropdown, autoComplete: "off", placeholder: this.props.placeholder, tabIndex: "0" }),
800
+ React.createElement("div", { className: cn(inputGroupBtnClasses) },
801
+ React.createElement("button", { type: "button", className: cn(toggleBtnClasses, { error: this.state.hasError }), disabled: this.state.isDisabled, onClick: this.toggleDropdown, onKeyDown: this.closeDropdownOnTabOut, tabIndex: "-1" },
802
+ !this.props.bs4 && React.createElement("span", { className: "caret" }),
803
+ React.createElement("span", { className: "sr-only" }, "Toggle Dropdown")),
804
+ this.state.isDropdownOpen && (React.createElement(DropDown, { ref: (ref) => (this.dropDown = ref), options: this.state.options, bs4: this.props.bs4, extraClasses: cn({
805
+ 'expensify-dropdown': true,
806
+ show: this.props.bs4 && this.state.isDropdownOpen,
807
+ }, this.props.dropDownClasses), onChange: this.handleClick })))));
808
+ }
809
+ }
810
+ Combobox.propTypes = propTypes;
811
+ Combobox.defaultProps = defaultProps;
812
+ export default Combobox;