@performant-software/shared-components 0.5.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +0 -0
- package/build/index.js +2 -0
- package/build/index.js.map +1 -0
- package/build/main.css +11 -0
- package/index.js +1 -0
- package/package.json +36 -0
- package/src/api/Attachments.js +28 -0
- package/src/api/BaseService.js +127 -0
- package/src/api/BaseTransform.js +55 -0
- package/src/api/FormDataTransform.js +30 -0
- package/src/api/NestedAttributesTransform.js +63 -0
- package/src/components/EditContainer.css +0 -0
- package/src/components/EditContainer.js +448 -0
- package/src/components/GoogleAnalytics.css +0 -0
- package/src/components/GoogleAnalytics.js +118 -0
- package/src/components/GoogleScript.js +5 -0
- package/src/components/InfiniteScroll.css +1 -0
- package/src/components/InfiniteScroll.js +120 -0
- package/src/components/Keyboard.css +11 -0
- package/src/components/Keyboard.js +55 -0
- package/src/i18n/en.json +204 -0
- package/src/i18n/i18n.js +24 -0
- package/src/index.js +34 -0
- package/src/utils/Browser.js +8 -0
- package/src/utils/Calendar.js +232 -0
- package/src/utils/Date.js +10 -0
- package/src/utils/DragDrop.js +17 -0
- package/src/utils/Element.js +36 -0
- package/src/utils/Map.js +27 -0
- package/src/utils/Object.js +114 -0
- package/src/utils/String.js +20 -0
- package/src/utils/Timer.js +32 -0
- package/src/utils/Utility.js +14 -0
- package/test/api/Attachments.spec.js +32 -0
- package/types/api/Attachments.js.flow +28 -0
- package/types/api/BaseService.js.flow +127 -0
- package/types/api/BaseTransform.js.flow +55 -0
- package/types/api/FormDataTransform.js.flow +30 -0
- package/types/api/NestedAttributesTransform.js.flow +63 -0
- package/types/components/EditContainer.js.flow +448 -0
- package/types/components/GoogleAnalytics.js.flow +118 -0
- package/types/components/GoogleScript.js.flow +5 -0
- package/types/components/InfiniteScroll.js.flow +120 -0
- package/types/components/Keyboard.js.flow +55 -0
- package/types/i18n/i18n.js.flow +24 -0
- package/types/index.js.flow +34 -0
- package/types/utils/Browser.js.flow +8 -0
- package/types/utils/Calendar.js.flow +232 -0
- package/types/utils/Date.js.flow +10 -0
- package/types/utils/DragDrop.js.flow +17 -0
- package/types/utils/Element.js.flow +36 -0
- package/types/utils/Map.js.flow +27 -0
- package/types/utils/Object.js.flow +114 -0
- package/types/utils/String.js.flow +20 -0
- package/types/utils/Timer.js.flow +32 -0
- package/types/utils/Utility.js.flow +14 -0
- package/webpack.config.js +3 -0
|
@@ -0,0 +1,448 @@
|
|
|
1
|
+
// @flow
|
|
2
|
+
|
|
3
|
+
import React, { Component, type ComponentType, type Element } from 'react';
|
|
4
|
+
import _ from 'underscore';
|
|
5
|
+
import i18n from '../i18n/i18n';
|
|
6
|
+
import { isEqual } from '../utils/Object';
|
|
7
|
+
|
|
8
|
+
type Props = {
|
|
9
|
+
children?: Element<any>,
|
|
10
|
+
defaults?: any,
|
|
11
|
+
item?: any,
|
|
12
|
+
onClose: () => void,
|
|
13
|
+
onInitialize?: (id: number) => Promise<any>,
|
|
14
|
+
onSave: (item: any) => Promise<any>,
|
|
15
|
+
required?: Array<string>,
|
|
16
|
+
resolveValidationError?: ({ error: string, item: any, status: number, key: string }) => Array<string>,
|
|
17
|
+
validate?: (item: any) => Array<string>
|
|
18
|
+
};
|
|
19
|
+
|
|
20
|
+
type State = {
|
|
21
|
+
item: any,
|
|
22
|
+
loading: boolean,
|
|
23
|
+
originalItem: any,
|
|
24
|
+
saving: boolean,
|
|
25
|
+
validationErrors: any
|
|
26
|
+
};
|
|
27
|
+
|
|
28
|
+
const ERROR_EMPTY = 'can\'t be blank';
|
|
29
|
+
const ERROR_UNIQUE = 'has already been taken';
|
|
30
|
+
|
|
31
|
+
const useEditContainer = (WrappedComponent: ComponentType<any>) => (
|
|
32
|
+
class extends Component<Props, State> {
|
|
33
|
+
/**
|
|
34
|
+
* Constructs a new EditProvider component.
|
|
35
|
+
*
|
|
36
|
+
* @param props
|
|
37
|
+
*/
|
|
38
|
+
constructor(props: Props) {
|
|
39
|
+
super(props);
|
|
40
|
+
|
|
41
|
+
const item = _.defaults(props.item || {}, props.defaults || {});
|
|
42
|
+
|
|
43
|
+
this.state = {
|
|
44
|
+
item,
|
|
45
|
+
loading: false,
|
|
46
|
+
originalItem: item,
|
|
47
|
+
saving: false,
|
|
48
|
+
validationErrors: []
|
|
49
|
+
};
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* Loads the item from the API if an onInitialize prop is specified.
|
|
54
|
+
*/
|
|
55
|
+
componentDidMount() {
|
|
56
|
+
if (this.props.onInitialize && this.props.item && this.props.item.id) {
|
|
57
|
+
this.setState({ loading: true }, () => {
|
|
58
|
+
if (this.props.onInitialize && this.props.item) {
|
|
59
|
+
this.props
|
|
60
|
+
.onInitialize(this.props.item.id)
|
|
61
|
+
.then((item) => this.setState({ item, originalItem: item, loading: false }));
|
|
62
|
+
}
|
|
63
|
+
});
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* Resets the item on the state.
|
|
69
|
+
*
|
|
70
|
+
* @param prevProps
|
|
71
|
+
*/
|
|
72
|
+
componentDidUpdate(prevProps: Props) {
|
|
73
|
+
if (prevProps.item !== this.props.item) {
|
|
74
|
+
this.setState({ item: this.props.item, originalItem: this.props.item });
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* Sets the saving property to false.
|
|
80
|
+
*/
|
|
81
|
+
componentWillUnmount() {
|
|
82
|
+
this.onSetState({ saving: false });
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* Returns true if the IDs or UIDs for the passed records match.
|
|
87
|
+
*
|
|
88
|
+
* @param a
|
|
89
|
+
* @param b
|
|
90
|
+
*
|
|
91
|
+
* @returns {*|boolean}
|
|
92
|
+
*/
|
|
93
|
+
isChild(a: any, b: any) {
|
|
94
|
+
return (a.uid && b.uid && a.uid === b.uid) || (a.id && b.id && a.id === b.id);
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* Returns true if the passed property is required.
|
|
99
|
+
*
|
|
100
|
+
* @param prop
|
|
101
|
+
*
|
|
102
|
+
* @returns {*|boolean}
|
|
103
|
+
*/
|
|
104
|
+
isRequired(prop: string) {
|
|
105
|
+
return this.props.required && _.contains(this.props.required, prop);
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/**
|
|
109
|
+
* Returns true if the passed property is listed in the hash of validation errors.
|
|
110
|
+
*
|
|
111
|
+
* @param prop
|
|
112
|
+
*
|
|
113
|
+
* @returns {*|boolean}
|
|
114
|
+
*/
|
|
115
|
+
isError(prop: string) {
|
|
116
|
+
return _.has(this.state.validationErrors, prop);
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/**
|
|
120
|
+
* Closes the provider.
|
|
121
|
+
*/
|
|
122
|
+
onClose() {
|
|
123
|
+
this.props.onClose();
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/**
|
|
127
|
+
* Adds the passed child to the passed association for the current item.
|
|
128
|
+
*
|
|
129
|
+
* @param association
|
|
130
|
+
* @param child
|
|
131
|
+
*/
|
|
132
|
+
onCreateChildAssociation(association: string, child: any) {
|
|
133
|
+
this.setState((state) => ({
|
|
134
|
+
item: {
|
|
135
|
+
...state.item,
|
|
136
|
+
[association]: [
|
|
137
|
+
...(state.item[association] || []),
|
|
138
|
+
child
|
|
139
|
+
]
|
|
140
|
+
}
|
|
141
|
+
}));
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
/**
|
|
145
|
+
* Removes the passed child from the passed association.
|
|
146
|
+
*
|
|
147
|
+
* @param association
|
|
148
|
+
* @param child
|
|
149
|
+
*/
|
|
150
|
+
onDeleteChildAssociation(association: string, child: any) {
|
|
151
|
+
return child.id
|
|
152
|
+
? this.onMarkChildAssociationForDelete(association, child)
|
|
153
|
+
: this.onRemoveChildAssociation(association, child);
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
/**
|
|
157
|
+
* Resolves the validation errors from the passed HTTP response.
|
|
158
|
+
*
|
|
159
|
+
* @param errors
|
|
160
|
+
* @param status
|
|
161
|
+
*/
|
|
162
|
+
onError({ response: { data: { errors = {} }, status } }: any) {
|
|
163
|
+
const validationErrors = {};
|
|
164
|
+
|
|
165
|
+
_.each(Object.keys(errors), (key) => {
|
|
166
|
+
const fieldErrors = errors[key];
|
|
167
|
+
const value = this.state.item[key];
|
|
168
|
+
|
|
169
|
+
_.each(fieldErrors, (error) => {
|
|
170
|
+
if (error === ERROR_UNIQUE) {
|
|
171
|
+
_.extend(validationErrors, { [key]: i18n.t('EditContainer.errors.unique', { key, value }) });
|
|
172
|
+
} else if (error === ERROR_EMPTY) {
|
|
173
|
+
_.extend(validationErrors, { [key]: i18n.t('EditContainer.errors.required', { key }) });
|
|
174
|
+
} else if (this.props.resolveValidationError) {
|
|
175
|
+
_.extend(validationErrors, this.props.resolveValidationError({
|
|
176
|
+
key,
|
|
177
|
+
error,
|
|
178
|
+
status,
|
|
179
|
+
item: this.state.item
|
|
180
|
+
}));
|
|
181
|
+
}
|
|
182
|
+
});
|
|
183
|
+
});
|
|
184
|
+
|
|
185
|
+
if (status === 400 && _.isEmpty(validationErrors)) {
|
|
186
|
+
_.extend(validationErrors, { error: i18n.t('EditContainer.errors.general') });
|
|
187
|
+
} else if (status === 500 && _.isEmpty(validationErrors)) {
|
|
188
|
+
_.extend(validationErrors, { error: i18n.t('EditContainer.errors.system') });
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
this.setState({ saving: false, validationErrors });
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
/**
|
|
195
|
+
* Marks the passed child for delete form the passed association. This function is called if the child record
|
|
196
|
+
* has an ID value.
|
|
197
|
+
*
|
|
198
|
+
* @param association
|
|
199
|
+
* @param child
|
|
200
|
+
*/
|
|
201
|
+
onMarkChildAssociationForDelete(association: string, child: any) {
|
|
202
|
+
this.setState((state) => ({
|
|
203
|
+
item: {
|
|
204
|
+
...state.item,
|
|
205
|
+
[association]: _.map(state.item[association] || [],
|
|
206
|
+
(c) => (c.id === child.id ? { ...c, _destroy: true } : c))
|
|
207
|
+
}
|
|
208
|
+
}));
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
/**
|
|
212
|
+
* Modifies the passed association based on the passed collection of children.
|
|
213
|
+
*
|
|
214
|
+
* @param association
|
|
215
|
+
* @param children
|
|
216
|
+
*/
|
|
217
|
+
onMultiAddChildAssociations(association: string, children: any) {
|
|
218
|
+
const items = this.state.item[association];
|
|
219
|
+
|
|
220
|
+
// Add new children or update existing children
|
|
221
|
+
_.each(children, this.onSaveChildAssociation.bind(this, association));
|
|
222
|
+
|
|
223
|
+
// Remove any children that no longer exist
|
|
224
|
+
const childrenToRemove = _.filter(items, (item) => !_.find(children, this.isChild.bind(this, item)));
|
|
225
|
+
_.each(childrenToRemove, this.onDeleteChildAssociation.bind(this, association));
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
/**
|
|
229
|
+
* Removes the passed child record from the passed association. This function is called if the child record does
|
|
230
|
+
* not have an ID value.
|
|
231
|
+
*
|
|
232
|
+
* @param association
|
|
233
|
+
* @param child
|
|
234
|
+
*/
|
|
235
|
+
onRemoveChildAssociation(association: string, child: any) {
|
|
236
|
+
this.setState((state) => ({
|
|
237
|
+
item: {
|
|
238
|
+
...state.item,
|
|
239
|
+
[association]: _.filter(state.item[association] || [],
|
|
240
|
+
(c) => c !== child)
|
|
241
|
+
}
|
|
242
|
+
}));
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
/**
|
|
246
|
+
* Resets the item on the state to the default item and calls the onReset prop.
|
|
247
|
+
*/
|
|
248
|
+
onReset() {
|
|
249
|
+
const item = this.props.defaults || {};
|
|
250
|
+
|
|
251
|
+
this.setState({
|
|
252
|
+
item,
|
|
253
|
+
originalItem: item
|
|
254
|
+
});
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
/**
|
|
258
|
+
* Saves the current item.
|
|
259
|
+
*/
|
|
260
|
+
onSave() {
|
|
261
|
+
if (!this.validateForm()) {
|
|
262
|
+
return;
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
this.setState({ saving: true }, () => {
|
|
266
|
+
this.props
|
|
267
|
+
.onSave(this.state.item)
|
|
268
|
+
.catch(this.onError.bind(this))
|
|
269
|
+
.finally(() => this.setState({ saving: false }));
|
|
270
|
+
});
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
/**
|
|
274
|
+
* Adds the passed child record to the passed association.
|
|
275
|
+
*
|
|
276
|
+
* @param association
|
|
277
|
+
* @param child
|
|
278
|
+
*/
|
|
279
|
+
onSaveChildAssociation(association: string, child: any) {
|
|
280
|
+
const children = this.state.item[association] || [];
|
|
281
|
+
|
|
282
|
+
return _.find(children, this.isChild.bind(this, child))
|
|
283
|
+
? this.onUpdateChildAssociation(association, child)
|
|
284
|
+
: this.onCreateChildAssociation(association, child);
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
/**
|
|
288
|
+
* Updates the child record in the passed association.
|
|
289
|
+
*
|
|
290
|
+
* @param association
|
|
291
|
+
* @param child
|
|
292
|
+
*/
|
|
293
|
+
onUpdateChildAssociation(association: string, child: any) {
|
|
294
|
+
this.setState((state) => ({
|
|
295
|
+
item: {
|
|
296
|
+
...state.item,
|
|
297
|
+
[association]: _.map(state.item[association] || [], (c) => (this.isChild(child, c) ? child : c))
|
|
298
|
+
}
|
|
299
|
+
}));
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
/**
|
|
303
|
+
* Sets the associated ID and object.
|
|
304
|
+
*
|
|
305
|
+
* @param idKey
|
|
306
|
+
* @param valueKey
|
|
307
|
+
* @param record
|
|
308
|
+
*/
|
|
309
|
+
onAssociationInputChange(idKey: string, valueKey: string, record: any = {}) {
|
|
310
|
+
this.setState((state) => ({
|
|
311
|
+
item: {
|
|
312
|
+
...state.item,
|
|
313
|
+
[idKey]: record.id || '',
|
|
314
|
+
[valueKey]: record || {}
|
|
315
|
+
},
|
|
316
|
+
validationErrors: _.omit(state.validationErrors, idKey)
|
|
317
|
+
}));
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
/**
|
|
321
|
+
* Toggles the checkbox value for the passed key.
|
|
322
|
+
*
|
|
323
|
+
* @param key
|
|
324
|
+
*/
|
|
325
|
+
onCheckboxInputChange(key: string) {
|
|
326
|
+
this.setState((state) => ({
|
|
327
|
+
item: {
|
|
328
|
+
...state.item,
|
|
329
|
+
[key]: !state.item[key]
|
|
330
|
+
}
|
|
331
|
+
}));
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
/**
|
|
335
|
+
* Sets the passed properties on the item on the state.
|
|
336
|
+
*
|
|
337
|
+
* @param props
|
|
338
|
+
*/
|
|
339
|
+
onSetState(props: any) {
|
|
340
|
+
this.setState((state) => ({
|
|
341
|
+
item: {
|
|
342
|
+
...state.item,
|
|
343
|
+
...props
|
|
344
|
+
}
|
|
345
|
+
}));
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
/**
|
|
349
|
+
* Sets the text input property for the passed key/value.
|
|
350
|
+
*
|
|
351
|
+
* @param key
|
|
352
|
+
* @param e
|
|
353
|
+
* @param value
|
|
354
|
+
*/
|
|
355
|
+
onTextInputChange(key: string, e: Event, { value }: any) {
|
|
356
|
+
this.setState((state) => ({
|
|
357
|
+
item: {
|
|
358
|
+
...state.item,
|
|
359
|
+
[key]: value
|
|
360
|
+
},
|
|
361
|
+
validationErrors: _.omit(state.validationErrors, key)
|
|
362
|
+
}));
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
render() {
|
|
366
|
+
return (
|
|
367
|
+
<WrappedComponent
|
|
368
|
+
{...this.props}
|
|
369
|
+
dirty={!!(this.state.item.id && !isEqual(this.state.item, this.state.originalItem))}
|
|
370
|
+
errors={_.values(this.state.validationErrors)}
|
|
371
|
+
isError={this.isError.bind(this)}
|
|
372
|
+
isRequired={this.isRequired.bind(this)}
|
|
373
|
+
item={this.state.item}
|
|
374
|
+
loading={this.state.loading}
|
|
375
|
+
onAssociationInputChange={this.onAssociationInputChange.bind(this)}
|
|
376
|
+
onCheckboxInputChange={this.onCheckboxInputChange.bind(this)}
|
|
377
|
+
onDeleteChildAssociation={this.onDeleteChildAssociation.bind(this)}
|
|
378
|
+
onMultiAddChildAssociations={this.onMultiAddChildAssociations.bind(this)}
|
|
379
|
+
onReset={this.onReset.bind(this)}
|
|
380
|
+
onSave={this.onSave.bind(this)}
|
|
381
|
+
onSaveChildAssociation={this.onSaveChildAssociation.bind(this)}
|
|
382
|
+
onTextInputChange={this.onTextInputChange.bind(this)}
|
|
383
|
+
onSetState={this.onSetState.bind(this)}
|
|
384
|
+
saving={this.state.saving}
|
|
385
|
+
/>
|
|
386
|
+
);
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
/**
|
|
390
|
+
* Validates the form using the component props required attributes.
|
|
391
|
+
*
|
|
392
|
+
* @returns {boolean}
|
|
393
|
+
*/
|
|
394
|
+
validateForm() {
|
|
395
|
+
const validationErrors = [];
|
|
396
|
+
|
|
397
|
+
// Custom validations
|
|
398
|
+
if (this.props.validate) {
|
|
399
|
+
_.extend(validationErrors, this.props.validate(this.state.item));
|
|
400
|
+
}
|
|
401
|
+
|
|
402
|
+
// Validate required properties
|
|
403
|
+
const required = this.props.required || [];
|
|
404
|
+
|
|
405
|
+
_.each(required, (key) => {
|
|
406
|
+
const value = this.state.item[key];
|
|
407
|
+
|
|
408
|
+
let invalid;
|
|
409
|
+
|
|
410
|
+
if (_.isNumber(value)) {
|
|
411
|
+
invalid = _.isEmpty(value.toString());
|
|
412
|
+
} else {
|
|
413
|
+
invalid = _.isEmpty(value);
|
|
414
|
+
}
|
|
415
|
+
|
|
416
|
+
if (invalid) {
|
|
417
|
+
_.extend(validationErrors, { [key]: i18n.t('EditContainer.errors.required', { key }) });
|
|
418
|
+
}
|
|
419
|
+
});
|
|
420
|
+
|
|
421
|
+
this.setState({ validationErrors });
|
|
422
|
+
|
|
423
|
+
return _.keys(validationErrors).length === 0;
|
|
424
|
+
}
|
|
425
|
+
}
|
|
426
|
+
);
|
|
427
|
+
|
|
428
|
+
export default useEditContainer;
|
|
429
|
+
|
|
430
|
+
export type EditContainerProps = {
|
|
431
|
+
children: Element<any>,
|
|
432
|
+
dirty: boolean,
|
|
433
|
+
errors: Array<string>,
|
|
434
|
+
isError: (property: string) => boolean,
|
|
435
|
+
isRequired: (property: string) => boolean,
|
|
436
|
+
item: any,
|
|
437
|
+
loading: boolean,
|
|
438
|
+
onAssociationInputChange: (idKey: string, valueKey: string, item: any) => void,
|
|
439
|
+
onCheckboxInputChange: (key: string, value: any) => void,
|
|
440
|
+
onDeleteChildAssociation: (association: string, child: any) => void,
|
|
441
|
+
onMultiAddChildAssociations: (association: string, Array<any>) => void,
|
|
442
|
+
onReset: () => void,
|
|
443
|
+
onSave: () => void,
|
|
444
|
+
onSaveChildAssociation: (association: string, child: any) => void,
|
|
445
|
+
onSetState: (any) => void,
|
|
446
|
+
onTextInputChange: (key: string, e: ?Event, value: any) => void,
|
|
447
|
+
saving: boolean
|
|
448
|
+
};
|
|
File without changes
|
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
// @flow
|
|
2
|
+
|
|
3
|
+
import React, {
|
|
4
|
+
useCallback,
|
|
5
|
+
useEffect,
|
|
6
|
+
useState,
|
|
7
|
+
type ComponentType
|
|
8
|
+
} from 'react';
|
|
9
|
+
import GoogleAnalytics from 'react-ga4';
|
|
10
|
+
|
|
11
|
+
type Props = {
|
|
12
|
+
id: string,
|
|
13
|
+
location?: any,
|
|
14
|
+
storageKey: string,
|
|
15
|
+
};
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Status constants.
|
|
19
|
+
*
|
|
20
|
+
* @type {{rejected: string, notSet: string, accepted: string}}
|
|
21
|
+
*/
|
|
22
|
+
const Status = {
|
|
23
|
+
accepted: 'accepted',
|
|
24
|
+
notSet: 'not_set',
|
|
25
|
+
rejected: 'rejected'
|
|
26
|
+
};
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Higher order component function used to wrap a "cookie consent" banner with Google Analytics.
|
|
30
|
+
*
|
|
31
|
+
* @param BannerComponent
|
|
32
|
+
*
|
|
33
|
+
* @returns {function(Props)}
|
|
34
|
+
*/
|
|
35
|
+
const withGoogleAnalytics = (BannerComponent: ComponentType<any>) => (props: Props) => {
|
|
36
|
+
const [status, setStatus] = useState();
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Returns the Google Analytics consent value from local storage.
|
|
40
|
+
*
|
|
41
|
+
* @param storageKey
|
|
42
|
+
*
|
|
43
|
+
* @returns {string|string}
|
|
44
|
+
*/
|
|
45
|
+
const getGoogleAnalyticsConsent = (storageKey) => (
|
|
46
|
+
localStorage.getItem(storageKey) || Status.notSet
|
|
47
|
+
);
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* Sets the Google Analytics consent value in local storage.
|
|
51
|
+
*
|
|
52
|
+
* @param storageKey
|
|
53
|
+
* @param newStatus
|
|
54
|
+
*/
|
|
55
|
+
const setGoogleAnalyticsConsent = (storageKey: string, newStatus: string) => (
|
|
56
|
+
localStorage.setItem(storageKey, newStatus)
|
|
57
|
+
);
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* Sets the "accepted" status and initializes analytics.
|
|
61
|
+
*
|
|
62
|
+
* @type {(function(): void)|*}
|
|
63
|
+
*/
|
|
64
|
+
const onAccept = useCallback(() => setStatus(Status.accepted), []);
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* Sets the "rejected" status.
|
|
68
|
+
*
|
|
69
|
+
* @type {(function(): void)|*}
|
|
70
|
+
*/
|
|
71
|
+
const onDecline = useCallback(() => setStatus(Status.rejected), []);
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* Sets the initial status on the state from local storage.
|
|
75
|
+
*/
|
|
76
|
+
useEffect(() => {
|
|
77
|
+
setStatus(getGoogleAnalyticsConsent(props.storageKey));
|
|
78
|
+
}, [props.storageKey]);
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* Sets the status on local storage when the state changes.
|
|
82
|
+
*/
|
|
83
|
+
useEffect(() => {
|
|
84
|
+
setGoogleAnalyticsConsent(props.storageKey, status || '');
|
|
85
|
+
}, [status, props.storageKey]);
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* Sends the page view event if the location changes.
|
|
89
|
+
*/
|
|
90
|
+
useEffect(() => {
|
|
91
|
+
if (props.location && status === Status.accepted) {
|
|
92
|
+
GoogleAnalytics.send('pageview');
|
|
93
|
+
}
|
|
94
|
+
}, [status, props.location]);
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* Initializes GoogleAnalytics when the status is set to accepted.
|
|
98
|
+
*/
|
|
99
|
+
useEffect(() => {
|
|
100
|
+
if (status === Status.accepted) {
|
|
101
|
+
GoogleAnalytics.initialize(props.id);
|
|
102
|
+
}
|
|
103
|
+
}, [status]);
|
|
104
|
+
|
|
105
|
+
if (!props.id || !props.storageKey || status !== Status.notSet) {
|
|
106
|
+
return null;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
return (
|
|
110
|
+
<BannerComponent
|
|
111
|
+
{...props}
|
|
112
|
+
onAccept={onAccept}
|
|
113
|
+
onDecline={onDecline}
|
|
114
|
+
/>
|
|
115
|
+
);
|
|
116
|
+
};
|
|
117
|
+
|
|
118
|
+
export default withGoogleAnalytics;
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
|
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
// @flow
|
|
2
|
+
|
|
3
|
+
import React, {
|
|
4
|
+
useEffect,
|
|
5
|
+
useRef,
|
|
6
|
+
useState,
|
|
7
|
+
type Element
|
|
8
|
+
} from 'react';
|
|
9
|
+
import { isBrowser } from '../utils/Browser';
|
|
10
|
+
|
|
11
|
+
type Props = {
|
|
12
|
+
children: Element<any>,
|
|
13
|
+
context?: { current: HTMLElement },
|
|
14
|
+
offset: number,
|
|
15
|
+
onBottomReached: () => void
|
|
16
|
+
};
|
|
17
|
+
|
|
18
|
+
const InfiniteScroll = (props: Props) => {
|
|
19
|
+
const [height, setHeight] = useState(0);
|
|
20
|
+
const containerRef = useRef();
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Returns the scrolling element.
|
|
24
|
+
*
|
|
25
|
+
* @returns {*}
|
|
26
|
+
*/
|
|
27
|
+
const getScrollElement = () => {
|
|
28
|
+
let scrollElement;
|
|
29
|
+
|
|
30
|
+
if (props.context) {
|
|
31
|
+
scrollElement = props.context.current;
|
|
32
|
+
} else if (isBrowser()) {
|
|
33
|
+
scrollElement = document.documentElement;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
return scrollElement;
|
|
37
|
+
};
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Calls the onBottomReached prop if the scroll has reached the end.
|
|
41
|
+
*/
|
|
42
|
+
const onScroll = () => {
|
|
43
|
+
const element = getScrollElement();
|
|
44
|
+
|
|
45
|
+
if (element) {
|
|
46
|
+
const { scrollTop, clientHeight, scrollHeight } = element;
|
|
47
|
+
|
|
48
|
+
if ((scrollTop + clientHeight) >= (scrollHeight - props.offset)) {
|
|
49
|
+
props.onBottomReached();
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
};
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* Sets up the container scroll event listeners.
|
|
56
|
+
*/
|
|
57
|
+
useEffect(() => {
|
|
58
|
+
let scrollContainer;
|
|
59
|
+
|
|
60
|
+
if (props.context) {
|
|
61
|
+
scrollContainer = props.context.current;
|
|
62
|
+
} else if (isBrowser()) {
|
|
63
|
+
scrollContainer = window;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
if (!scrollContainer) {
|
|
67
|
+
return undefined;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
scrollContainer.addEventListener('scroll', onScroll);
|
|
71
|
+
return () => scrollContainer && scrollContainer.removeEventListener('scroll', onScroll);
|
|
72
|
+
}, [props.context]);
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* Returns true if the context element is scrollable.
|
|
76
|
+
*
|
|
77
|
+
* @returns {boolean}
|
|
78
|
+
*/
|
|
79
|
+
const isScrollable = () => {
|
|
80
|
+
let scrollable = false;
|
|
81
|
+
|
|
82
|
+
const element = getScrollElement();
|
|
83
|
+
if (element) {
|
|
84
|
+
const { clientHeight, scrollHeight } = element;
|
|
85
|
+
scrollable = scrollHeight > clientHeight;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
return scrollable;
|
|
89
|
+
};
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* Upon initial render, the DOM may not be tall enough to scroll and trigger the onScroll event. In this case,
|
|
93
|
+
* we'll call the onBottomReached prop when the component is mounted until the container's scrollHeight is greater
|
|
94
|
+
* than the height of the container.
|
|
95
|
+
*/
|
|
96
|
+
useEffect(() => {
|
|
97
|
+
if (!isScrollable() && containerRef && containerRef.current) {
|
|
98
|
+
const { clientHeight } = containerRef.current;
|
|
99
|
+
|
|
100
|
+
if (clientHeight > height) {
|
|
101
|
+
setHeight(clientHeight);
|
|
102
|
+
props.onBottomReached();
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
return (
|
|
108
|
+
<div
|
|
109
|
+
ref={containerRef}
|
|
110
|
+
>
|
|
111
|
+
{ props.children }
|
|
112
|
+
</div>
|
|
113
|
+
);
|
|
114
|
+
};
|
|
115
|
+
|
|
116
|
+
InfiniteScroll.defaultProps = {
|
|
117
|
+
offset: 0
|
|
118
|
+
};
|
|
119
|
+
|
|
120
|
+
export default InfiniteScroll;
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
/*!
|
|
2
|
+
*
|
|
3
|
+
* simple-keyboard v3.0.22
|
|
4
|
+
* https://github.com/hodgef/simple-keyboard
|
|
5
|
+
*
|
|
6
|
+
* Copyright (c) Francisco Hodge (https://github.com/hodgef) and project contributors.
|
|
7
|
+
*
|
|
8
|
+
* This source code is licensed under the MIT license found in the
|
|
9
|
+
* LICENSE file in the root directory of this source tree.
|
|
10
|
+
*
|
|
11
|
+
*/.hg-theme-default{width:100%;-webkit-user-select:none;-moz-user-select:none;-ms-user-select:none;user-select:none;box-sizing:border-box;overflow:hidden;touch-action:manipulation;font-family:HelveticaNeue-Light,Helvetica Neue Light,Helvetica Neue,Helvetica,Arial,Lucida Grande,sans-serif;background-color:#ececec;padding:5px;border-radius:5px}.hg-theme-default .hg-button span{pointer-events:none}.hg-theme-default button.hg-button{border-width:0;outline:0;font-size:inherit}.hg-theme-default .hg-button{display:inline-block;flex-grow:1}.hg-theme-default .hg-row{display:flex}.hg-theme-default .hg-row:not(:last-child){margin-bottom:5px}.hg-theme-default .hg-row .hg-button-container,.hg-theme-default .hg-row .hg-button:not(:last-child){margin-right:5px}.hg-theme-default .hg-row>div:last-child{margin-right:0}.hg-theme-default .hg-row .hg-button-container{display:flex}.hg-theme-default .hg-button{box-shadow:0 0 3px -1px rgba(0,0,0,.3);height:40px;border-radius:5px;box-sizing:border-box;padding:5px;background:#fff;border-bottom:1px solid #b5b5b5;cursor:pointer;display:flex;align-items:center;justify-content:center;-webkit-tap-highlight-color:rgba(0,0,0,0)}.hg-theme-default .hg-button.hg-standardBtn{width:20px}.hg-theme-default .hg-button.hg-activeButton{background:#efefef}.hg-theme-default.hg-layout-numeric .hg-button{width:33.3%;height:60px;align-items:center;display:flex;justify-content:center}.hg-theme-default .hg-button.hg-button-numpadadd,.hg-theme-default .hg-button.hg-button-numpadenter{height:85px}.hg-theme-default .hg-button.hg-button-numpad0{width:105px}.hg-theme-default .hg-button.hg-button-com{max-width:85px}.hg-theme-default .hg-button.hg-standardBtn.hg-button-at{max-width:45px}.hg-theme-default .hg-button.hg-selectedButton{background:rgba(5,25,70,.53);color:#fff}.hg-theme-default .hg-button.hg-standardBtn[data-skbtn=".com"]{max-width:82px}.hg-theme-default .hg-button.hg-standardBtn[data-skbtn="@"]{max-width:60px}.hg-candidate-box{display:inline-flex;border-radius:5px;position:absolute;background:#ececec;border-bottom:2px solid #b5b5b5;-webkit-user-select:none;-moz-user-select:none;-ms-user-select:none;user-select:none;max-width:272px;transform:translateY(-100%);margin-top:-10px}ul.hg-candidate-box-list{display:flex;list-style:none;padding:0;margin:0;flex:1}li.hg-candidate-box-list-item{height:40px;width:40px;display:flex;align-items:center;justify-content:center}li.hg-candidate-box-list-item:hover{background:rgba(0,0,0,.03);cursor:pointer}li.hg-candidate-box-list-item:active{background:rgba(0,0,0,.1)}.hg-candidate-box-prev:before{content:"◄"}.hg-candidate-box-next:before{content:"►"}.hg-candidate-box-next,.hg-candidate-box-prev{display:flex;align-items:center;padding:0 10px;background:#d0d0d0;color:#969696;cursor:pointer}.hg-candidate-box-next{border-top-right-radius:5px;border-bottom-right-radius:5px}.hg-candidate-box-prev{border-top-left-radius:5px;border-bottom-left-radius:5px}.hg-candidate-box-btn-active{color:#444}
|