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