@jasperoosthoek/react-toolbox 0.8.0 → 0.9.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/change-log.md +330 -309
- package/dist/components/buttons/ConfirmButton.d.ts +2 -2
- package/dist/components/buttons/DeleteConfirmButton.d.ts +2 -2
- package/dist/components/buttons/IconButtons.d.ts +40 -41
- package/dist/components/errors/Errors.d.ts +1 -2
- package/dist/components/forms/FormField.d.ts +22 -0
- package/dist/components/forms/FormFields.d.ts +1 -56
- package/dist/components/forms/FormModal.d.ts +7 -34
- package/dist/components/forms/FormModalProvider.d.ts +19 -26
- package/dist/components/forms/FormProvider.d.ts +66 -0
- package/dist/components/forms/fields/FormBadgesSelection.d.ts +26 -0
- package/dist/components/forms/fields/FormCheckbox.d.ts +7 -0
- package/dist/components/forms/fields/FormDropdown.d.ts +19 -0
- package/dist/components/forms/fields/FormInput.d.ts +17 -0
- package/dist/components/forms/fields/FormSelect.d.ts +12 -0
- package/dist/components/forms/fields/index.d.ts +5 -0
- package/dist/components/indicators/CheckIndicator.d.ts +1 -2
- package/dist/components/indicators/LoadingIndicator.d.ts +4 -4
- package/dist/components/login/LoginPage.d.ts +1 -1
- package/dist/components/tables/DataTable.d.ts +2 -2
- package/dist/components/tables/DragAndDropList.d.ts +2 -2
- package/dist/components/tables/SearchBox.d.ts +2 -2
- package/dist/index.d.ts +4 -1
- package/dist/index.js +2 -2
- package/dist/index.js.LICENSE.txt +0 -4
- package/dist/localization/LocalizationContext.d.ts +1 -1
- package/dist/utils/hooks.d.ts +1 -1
- package/dist/utils/timeAndDate.d.ts +5 -2
- package/dist/utils/utils.d.ts +3 -3
- package/package.json +10 -11
- package/src/__tests__/buttons.test.tsx +545 -0
- package/src/__tests__/errors.test.tsx +339 -0
- package/src/__tests__/forms.test.tsx +3021 -0
- package/src/__tests__/hooks.test.tsx +413 -0
- package/src/__tests__/indicators.test.tsx +284 -0
- package/src/__tests__/localization.test.tsx +462 -0
- package/src/__tests__/login.test.tsx +417 -0
- package/src/__tests__/setupTests.ts +328 -0
- package/src/__tests__/tables.test.tsx +609 -0
- package/src/__tests__/timeAndDate.test.tsx +308 -0
- package/src/__tests__/utils.test.tsx +422 -0
- package/src/components/forms/FormField.tsx +92 -0
- package/src/components/forms/FormFields.tsx +3 -423
- package/src/components/forms/FormModal.tsx +168 -243
- package/src/components/forms/FormModalProvider.tsx +141 -95
- package/src/components/forms/FormProvider.tsx +218 -0
- package/src/components/forms/fields/FormBadgesSelection.tsx +108 -0
- package/src/components/forms/fields/FormCheckbox.tsx +76 -0
- package/src/components/forms/fields/FormDropdown.tsx +123 -0
- package/src/components/forms/fields/FormInput.tsx +114 -0
- package/src/components/forms/fields/FormSelect.tsx +47 -0
- package/src/components/forms/fields/index.ts +6 -0
- package/src/index.ts +32 -28
- package/src/localization/LocalizationContext.tsx +156 -131
- package/src/localization/localization.ts +131 -131
- package/src/utils/hooks.ts +108 -94
- package/src/utils/timeAndDate.ts +33 -4
- package/src/utils/utils.ts +74 -66
- package/dist/components/forms/CreateEditModal.d.ts +0 -41
- package/dist/components/forms/CreateEditModalProvider.d.ts +0 -41
- package/dist/components/forms/FormFields.test.d.ts +0 -4
- package/dist/login/Login.d.ts +0 -70
- package/src/components/forms/FormFields.test.tsx +0 -107
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
import React, { useMemo, KeyboardEvent } from 'react';
|
|
2
|
+
import { Form } from 'react-bootstrap';
|
|
3
|
+
import { useFormField } from '../FormField';
|
|
4
|
+
import { useLocalization } from '../../../localization/LocalizationContext';
|
|
5
|
+
|
|
6
|
+
type DisabledProps = {
|
|
7
|
+
list: any[];
|
|
8
|
+
value: string | number;
|
|
9
|
+
state: any;
|
|
10
|
+
initialState: any;
|
|
11
|
+
initialValue: any;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export interface FormDropdownProps<T> extends Omit<
|
|
15
|
+
React.InputHTMLAttributes<HTMLInputElement>,
|
|
16
|
+
'name' | 'value' | 'onChange' | 'disabled' | 'list'
|
|
17
|
+
> {
|
|
18
|
+
name: string;
|
|
19
|
+
label?: React.ReactElement | string;
|
|
20
|
+
list?: T[];
|
|
21
|
+
options?: T[]; // Alias for list
|
|
22
|
+
idKey?: keyof T;
|
|
23
|
+
nameKey?: keyof T;
|
|
24
|
+
disabled?: boolean | ((props: DisabledProps) => boolean);
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export const FormDropdown = <T,>(props: FormDropdownProps<T>) => {
|
|
28
|
+
const {
|
|
29
|
+
list: listProp,
|
|
30
|
+
options,
|
|
31
|
+
idKey = 'value' as keyof T, // Change default to match test
|
|
32
|
+
nameKey = 'label' as keyof T, // Change default to match test
|
|
33
|
+
disabled,
|
|
34
|
+
...componentProps
|
|
35
|
+
} = props;
|
|
36
|
+
|
|
37
|
+
const { value, onChange, isInvalid, error, label, required, mergedProps, submit } = useFormField(componentProps);
|
|
38
|
+
const { strings } = useLocalization();
|
|
39
|
+
|
|
40
|
+
// Use options or list, with options taking precedence
|
|
41
|
+
const listBase = options || listProp;
|
|
42
|
+
|
|
43
|
+
const [list, mismatch] = useMemo(() => {
|
|
44
|
+
if (!listBase || !Array.isArray(listBase)) {
|
|
45
|
+
return [null, null] as [T[] | null, any];
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
// Handle string arrays by converting them to { value, label } objects
|
|
49
|
+
if (listBase.length > 0 && typeof listBase[0] === 'string') {
|
|
50
|
+
const convertedList = listBase.map((item: any) => ({
|
|
51
|
+
value: item,
|
|
52
|
+
label: item
|
|
53
|
+
})) as unknown as T[];
|
|
54
|
+
return [convertedList, null];
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
// Only validate object arrays
|
|
58
|
+
if (listBase.length > 0 && typeof listBase[0] === 'object') {
|
|
59
|
+
const mismatch = listBase?.find((item: any) => (
|
|
60
|
+
!['number', 'string'].includes(typeof item[idKey])
|
|
61
|
+
|| !['number', 'string'].includes(typeof item[nameKey])
|
|
62
|
+
))
|
|
63
|
+
if (mismatch) return [null, mismatch];
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
return [listBase, null];
|
|
67
|
+
}, [listBase, idKey, nameKey]);
|
|
68
|
+
|
|
69
|
+
if (!list) {
|
|
70
|
+
console.error(
|
|
71
|
+
`FormDropdown Error:
|
|
72
|
+
- Each item in 'list' must include a valid 'idKey' (${String(idKey)}) and 'nameKey' (${String(nameKey)}).
|
|
73
|
+
- Both keys must exist on every item and be of type 'string' or 'number'.
|
|
74
|
+
- One or more items failed this check.
|
|
75
|
+
|
|
76
|
+
Received list:`,
|
|
77
|
+
listBase,
|
|
78
|
+
...mismatch ? ['First mismatch:', mismatch] : []
|
|
79
|
+
);
|
|
80
|
+
return null;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
const selectedItem = list.find(item => item[idKey] === value);
|
|
84
|
+
|
|
85
|
+
return (
|
|
86
|
+
<Form.Group controlId={props.name}>
|
|
87
|
+
{label && <Form.Label htmlFor={props.name}>{label}{required && ' *'}</Form.Label>}
|
|
88
|
+
{isInvalid && error && (
|
|
89
|
+
<Form.Text className="text-danger">
|
|
90
|
+
{error}
|
|
91
|
+
</Form.Text>
|
|
92
|
+
)}
|
|
93
|
+
|
|
94
|
+
<Form.Select
|
|
95
|
+
id={props.name}
|
|
96
|
+
value={value || ''}
|
|
97
|
+
isInvalid={isInvalid}
|
|
98
|
+
onChange={(e) => onChange(e.target.value)}
|
|
99
|
+
onKeyDown={(e: KeyboardEvent<HTMLSelectElement>) => {
|
|
100
|
+
if (e.key === 'Enter') {
|
|
101
|
+
e.preventDefault();
|
|
102
|
+
submit();
|
|
103
|
+
}
|
|
104
|
+
}}
|
|
105
|
+
{...mergedProps}
|
|
106
|
+
>
|
|
107
|
+
{list.map((item, key) =>
|
|
108
|
+
<option
|
|
109
|
+
key={key}
|
|
110
|
+
value={item[idKey] as string | number}
|
|
111
|
+
disabled={
|
|
112
|
+
typeof disabled === 'function'
|
|
113
|
+
? disabled({ list, value: item[idKey] as number | string, state: {}, initialState: {}, initialValue: '' })
|
|
114
|
+
: disabled
|
|
115
|
+
}
|
|
116
|
+
>
|
|
117
|
+
{item[nameKey] as string}
|
|
118
|
+
</option>
|
|
119
|
+
)}
|
|
120
|
+
</Form.Select>
|
|
121
|
+
</Form.Group>
|
|
122
|
+
);
|
|
123
|
+
};
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
import React, { ChangeEvent, KeyboardEvent } from 'react';
|
|
2
|
+
import { Form } from 'react-bootstrap';
|
|
3
|
+
import { useFormField } from '../FormField';
|
|
4
|
+
|
|
5
|
+
export interface FormInputProps extends Omit<React.InputHTMLAttributes<HTMLInputElement>, 'name' | 'onChange'> {
|
|
6
|
+
name: string;
|
|
7
|
+
label?: React.ReactElement | string;
|
|
8
|
+
as?: string; // For textarea, select, etc.
|
|
9
|
+
rows?: number; // For textarea
|
|
10
|
+
onChange: (value: string) => void;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export const FormInput = (props: FormInputProps) => {
|
|
14
|
+
const { value, onChange, isInvalid, error, label, required, mergedProps, submit } = useFormField(props);
|
|
15
|
+
|
|
16
|
+
const errorId = isInvalid && error ? `${props.name}-error` : undefined;
|
|
17
|
+
|
|
18
|
+
return (
|
|
19
|
+
<Form.Group controlId={props.name}>
|
|
20
|
+
{label && <Form.Label htmlFor={props.name}>{label}{required && ' *'}</Form.Label>}
|
|
21
|
+
{isInvalid && error && (
|
|
22
|
+
<Form.Text id={errorId} className="text-danger">
|
|
23
|
+
{error}
|
|
24
|
+
</Form.Text>
|
|
25
|
+
)}
|
|
26
|
+
<Form.Control
|
|
27
|
+
id={props.name}
|
|
28
|
+
autoComplete="off"
|
|
29
|
+
{...mergedProps}
|
|
30
|
+
value={value || ''}
|
|
31
|
+
isInvalid={isInvalid}
|
|
32
|
+
aria-describedby={errorId}
|
|
33
|
+
onChange={(e: ChangeEvent<HTMLInputElement>) => {
|
|
34
|
+
onChange(e.target.value);
|
|
35
|
+
}}
|
|
36
|
+
onKeyDown={(e: KeyboardEvent<HTMLInputElement>) => {
|
|
37
|
+
if (e.key === 'Enter' && mergedProps.as !== 'textarea') {
|
|
38
|
+
// Pressing the enter key will save data unless it is a multi line text area
|
|
39
|
+
e.preventDefault();
|
|
40
|
+
submit();
|
|
41
|
+
}
|
|
42
|
+
}}
|
|
43
|
+
/>
|
|
44
|
+
</Form.Group>
|
|
45
|
+
);
|
|
46
|
+
};
|
|
47
|
+
|
|
48
|
+
export const FormTextarea = ({ rows = 3, ...props }: FormInputProps) => (
|
|
49
|
+
<FormInput as="textarea" rows={rows} {...props} />
|
|
50
|
+
);
|
|
51
|
+
|
|
52
|
+
export const FormDate = (props: FormInputProps) => (
|
|
53
|
+
<FormInput type="date" {...props} />
|
|
54
|
+
);
|
|
55
|
+
|
|
56
|
+
export interface FormDateTimeProps extends Omit<FormInputProps, 'value' | 'onChange'> {
|
|
57
|
+
value?: string | Date;
|
|
58
|
+
onChange?: (value: string) => void;
|
|
59
|
+
timezone?: string;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export const FormDateTime = ({ value, onChange, timezone, ...props }: FormDateTimeProps) => {
|
|
63
|
+
// Convert value to datetime-local format (YYYY-MM-DDTHH:mm)
|
|
64
|
+
const formatForInput = (val: string | Date | undefined) => {
|
|
65
|
+
if (!val) return '';
|
|
66
|
+
|
|
67
|
+
try {
|
|
68
|
+
const date = typeof val === 'string' ? new Date(val) : val;
|
|
69
|
+
if (isNaN(date.getTime())) return '';
|
|
70
|
+
|
|
71
|
+
// Format as YYYY-MM-DDTHH:mm for datetime-local input
|
|
72
|
+
const year = date.getFullYear();
|
|
73
|
+
const month = String(date.getMonth() + 1).padStart(2, '0');
|
|
74
|
+
const day = String(date.getDate()).padStart(2, '0');
|
|
75
|
+
const hours = String(date.getHours()).padStart(2, '0');
|
|
76
|
+
const minutes = String(date.getMinutes()).padStart(2, '0');
|
|
77
|
+
|
|
78
|
+
return `${year}-${month}-${day}T${hours}:${minutes}`;
|
|
79
|
+
} catch {
|
|
80
|
+
return '';
|
|
81
|
+
}
|
|
82
|
+
};
|
|
83
|
+
|
|
84
|
+
const handleChange = (inputValue: string) => {
|
|
85
|
+
if (!onChange) return;
|
|
86
|
+
|
|
87
|
+
if (!inputValue) {
|
|
88
|
+
onChange('');
|
|
89
|
+
return;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
try {
|
|
93
|
+
// Convert datetime-local value to ISO string
|
|
94
|
+
const date = new Date(inputValue);
|
|
95
|
+
if (isNaN(date.getTime())) {
|
|
96
|
+
onChange('');
|
|
97
|
+
return;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
onChange(date.toISOString());
|
|
101
|
+
} catch {
|
|
102
|
+
onChange('');
|
|
103
|
+
}
|
|
104
|
+
};
|
|
105
|
+
|
|
106
|
+
return (
|
|
107
|
+
<FormInput
|
|
108
|
+
type="datetime-local"
|
|
109
|
+
value={formatForInput(value)}
|
|
110
|
+
onChange={handleChange}
|
|
111
|
+
{...props}
|
|
112
|
+
/>
|
|
113
|
+
);
|
|
114
|
+
};
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
import React from 'react';
|
|
2
|
+
import { Form } from 'react-bootstrap';
|
|
3
|
+
import { useFormField } from '../FormField';
|
|
4
|
+
|
|
5
|
+
export interface FormSelectProps extends Omit<React.SelectHTMLAttributes<HTMLSelectElement>, 'name' | 'value' | 'onChange'> {
|
|
6
|
+
name: string;
|
|
7
|
+
label?: React.ReactElement | string;
|
|
8
|
+
options: Array<{ value: string | number; label: string; disabled?: boolean }>;
|
|
9
|
+
placeholder?: string;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export const FormSelect = (props: FormSelectProps) => {
|
|
13
|
+
const { value, onChange, isInvalid, error, label, required, mergedProps } = useFormField(props);
|
|
14
|
+
const { options, placeholder = "Choose..." } = props;
|
|
15
|
+
|
|
16
|
+
return (
|
|
17
|
+
<Form.Group controlId={props.name}>
|
|
18
|
+
{label && <Form.Label>{label}{required && ' *'}</Form.Label>}
|
|
19
|
+
{isInvalid && error && (
|
|
20
|
+
<Form.Text className="text-danger">
|
|
21
|
+
{error}
|
|
22
|
+
</Form.Text>
|
|
23
|
+
)}
|
|
24
|
+
<Form.Select
|
|
25
|
+
{...mergedProps}
|
|
26
|
+
value={value || ''}
|
|
27
|
+
isInvalid={isInvalid}
|
|
28
|
+
onChange={(e: React.ChangeEvent<HTMLSelectElement>) => {
|
|
29
|
+
onChange(e.target.value);
|
|
30
|
+
}}
|
|
31
|
+
>
|
|
32
|
+
<option value="" disabled>
|
|
33
|
+
{placeholder}
|
|
34
|
+
</option>
|
|
35
|
+
{options.map((option, index) => (
|
|
36
|
+
<option
|
|
37
|
+
key={index}
|
|
38
|
+
value={option.value}
|
|
39
|
+
disabled={option.disabled}
|
|
40
|
+
>
|
|
41
|
+
{option.label}
|
|
42
|
+
</option>
|
|
43
|
+
))}
|
|
44
|
+
</Form.Select>
|
|
45
|
+
</Form.Group>
|
|
46
|
+
);
|
|
47
|
+
};
|
package/src/index.ts
CHANGED
|
@@ -1,28 +1,32 @@
|
|
|
1
|
-
export * from './components/buttons/IconButtons';
|
|
2
|
-
export * from './components/buttons/ConfirmButton';
|
|
3
|
-
export { default as ConfirmButton } from './components/buttons/ConfirmButton';
|
|
4
|
-
export { default as DeleteConfirmButton } from './components/buttons/DeleteConfirmButton';
|
|
5
|
-
export * from './components/buttons/DeleteConfirmButton';
|
|
6
|
-
|
|
7
|
-
export * from './components/forms/
|
|
8
|
-
export * from './components/forms/
|
|
9
|
-
export * from './components/forms/
|
|
10
|
-
|
|
11
|
-
export * from './components/
|
|
12
|
-
export * from './components/
|
|
13
|
-
|
|
14
|
-
export * from './components/
|
|
15
|
-
export * from './components/
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
export * from './components/
|
|
19
|
-
export * from './components/
|
|
20
|
-
|
|
21
|
-
export * from './
|
|
22
|
-
export * from './
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
export * from './
|
|
28
|
-
export * from './
|
|
1
|
+
export * from './components/buttons/IconButtons';
|
|
2
|
+
export * from './components/buttons/ConfirmButton';
|
|
3
|
+
export { default as ConfirmButton } from './components/buttons/ConfirmButton';
|
|
4
|
+
export { default as DeleteConfirmButton } from './components/buttons/DeleteConfirmButton';
|
|
5
|
+
export * from './components/buttons/DeleteConfirmButton';
|
|
6
|
+
|
|
7
|
+
export * from './components/forms/FormProvider';
|
|
8
|
+
export * from './components/forms/FormModal';
|
|
9
|
+
export * from './components/forms/FormModalProvider';
|
|
10
|
+
export * from './components/forms/FormFields';
|
|
11
|
+
export * from './components/forms/FormField';
|
|
12
|
+
export * from './components/forms/fields';
|
|
13
|
+
|
|
14
|
+
export * from './components/indicators/LoadingIndicator';
|
|
15
|
+
export * from './components/indicators/CheckIndicator';
|
|
16
|
+
|
|
17
|
+
export * from './components/tables/DataTable';
|
|
18
|
+
export * from './components/tables/DragAndDropList';
|
|
19
|
+
export * from './components/tables/SearchBox';
|
|
20
|
+
|
|
21
|
+
export * from './components/errors/Errors';
|
|
22
|
+
export * from './components/errors/ErrorBoundary';
|
|
23
|
+
|
|
24
|
+
export * from './components/login/LoginPage';
|
|
25
|
+
|
|
26
|
+
export * from './utils/hooks';
|
|
27
|
+
export * from './utils/timeAndDate';
|
|
28
|
+
export * from './utils/utils';
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
export * from './localization/localization';
|
|
32
|
+
export * from './localization/LocalizationContext';
|
|
@@ -1,131 +1,156 @@
|
|
|
1
|
-
import React, { useState, useContext } from 'react';
|
|
2
|
-
import LocalizedStrings from 'react-localization';
|
|
3
|
-
import {
|
|
4
|
-
defaultLocalization,
|
|
5
|
-
AdditionalLocalization,
|
|
6
|
-
LocalizationElement,
|
|
7
|
-
LocalizationFunction,
|
|
8
|
-
defaultLanguages,
|
|
9
|
-
} from './localization';
|
|
10
|
-
|
|
11
|
-
const out_of_context_error = 'This function should only be used in a child of LocalizationProvider.';
|
|
12
|
-
|
|
13
|
-
export const combineLocalization = (...locals: AdditionalLocalization[]) => {
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
);
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
}
|
|
56
|
-
|
|
57
|
-
export
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
1
|
+
import React, { useState, useContext, useEffect, useMemo } from 'react';
|
|
2
|
+
import LocalizedStrings from 'react-localization';
|
|
3
|
+
import {
|
|
4
|
+
defaultLocalization,
|
|
5
|
+
AdditionalLocalization,
|
|
6
|
+
LocalizationElement,
|
|
7
|
+
LocalizationFunction,
|
|
8
|
+
defaultLanguages,
|
|
9
|
+
} from './localization';
|
|
10
|
+
|
|
11
|
+
const out_of_context_error = 'This function should only be used in a child of LocalizationProvider.';
|
|
12
|
+
|
|
13
|
+
export const combineLocalization = (...locals: AdditionalLocalization[]) => {
|
|
14
|
+
if (locals.length === 0) {
|
|
15
|
+
return {} as AdditionalLocalization;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
// Filter out empty objects
|
|
19
|
+
const validLocals = locals.filter(local => local && Object.keys(local).length > 0);
|
|
20
|
+
|
|
21
|
+
if (validLocals.length === 0) {
|
|
22
|
+
return {} as AdditionalLocalization;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
// Extract all unique language keys
|
|
26
|
+
const languages = [...new Set(validLocals.flatMap(Object.keys))];
|
|
27
|
+
|
|
28
|
+
return languages.reduce(
|
|
29
|
+
(o, lang) => ({
|
|
30
|
+
...o,
|
|
31
|
+
[lang]: validLocals.reduce<LocalizationElement>(
|
|
32
|
+
(acc, l) => ({ ...acc, ...(l[lang] || {}) }),
|
|
33
|
+
{} as LocalizationElement
|
|
34
|
+
),
|
|
35
|
+
}),
|
|
36
|
+
{} as AdditionalLocalization
|
|
37
|
+
);
|
|
38
|
+
};
|
|
39
|
+
|
|
40
|
+
export const LocalizationContext = React.createContext({
|
|
41
|
+
lang: 'en',
|
|
42
|
+
languages: Object.keys(defaultLanguages),
|
|
43
|
+
setLanguage: (lang: string) => console.error(out_of_context_error),
|
|
44
|
+
text: (str: TemplateStringsArray, ...values: (string | number)[]) => {
|
|
45
|
+
console.error(out_of_context_error);
|
|
46
|
+
return str[0];
|
|
47
|
+
},
|
|
48
|
+
textByLang: (lang: string) => (str: TemplateStringsArray, ...values: (string | number)[]) => {
|
|
49
|
+
console.error(out_of_context_error);
|
|
50
|
+
return str[0];
|
|
51
|
+
},
|
|
52
|
+
strings: new LocalizedStrings({ en: {} }),
|
|
53
|
+
localizationStrings: {} as AdditionalLocalization,
|
|
54
|
+
setLocalization: (localization: AdditionalLocalization) => console.error(out_of_context_error),
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
export type RestProps = {
|
|
58
|
+
[prop: string]: any;
|
|
59
|
+
}
|
|
60
|
+
export interface LocalizationProviderProps extends RestProps {
|
|
61
|
+
lang?: string;
|
|
62
|
+
localization?: AdditionalLocalization;
|
|
63
|
+
languages?: string[];
|
|
64
|
+
children?: any;
|
|
65
|
+
[prop: string]: any;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
export const LocalizationProvider = ({
|
|
69
|
+
lang: initialLanguage = 'en',
|
|
70
|
+
localization: additionalLocalizationInitial = {},
|
|
71
|
+
languages: languagesOverride,
|
|
72
|
+
children,
|
|
73
|
+
...restProps
|
|
74
|
+
}: LocalizationProviderProps) => {
|
|
75
|
+
const [additionalLocalization, setAdditionalLocalization] = useState(additionalLocalizationInitial);
|
|
76
|
+
const [lang, setLanguage] = useState(initialLanguage);
|
|
77
|
+
|
|
78
|
+
const languages = Array.from(new Set([
|
|
79
|
+
...languagesOverride || Object.keys(defaultLocalization),
|
|
80
|
+
...Object.keys(additionalLocalization),
|
|
81
|
+
]));
|
|
82
|
+
|
|
83
|
+
const localizationStrings = useMemo(() => {
|
|
84
|
+
return languages.reduce(
|
|
85
|
+
(o, lang) => ({
|
|
86
|
+
...o,
|
|
87
|
+
[lang]: {
|
|
88
|
+
...defaultLocalization[lang] || {},
|
|
89
|
+
...additionalLocalization[lang] || {},
|
|
90
|
+
},
|
|
91
|
+
}),
|
|
92
|
+
{}
|
|
93
|
+
);
|
|
94
|
+
}, [languages, additionalLocalization]);
|
|
95
|
+
|
|
96
|
+
const strings = useMemo(() => {
|
|
97
|
+
const localizedStrings = new LocalizedStrings(localizationStrings);
|
|
98
|
+
localizedStrings.setLanguage(lang);
|
|
99
|
+
return localizedStrings;
|
|
100
|
+
}, [localizationStrings, lang]);
|
|
101
|
+
|
|
102
|
+
const setLocalization = (newLocalization: AdditionalLocalization) => {
|
|
103
|
+
setAdditionalLocalization(prev => {
|
|
104
|
+
const merged = combineLocalization(prev, newLocalization);
|
|
105
|
+
return merged;
|
|
106
|
+
});
|
|
107
|
+
};
|
|
108
|
+
|
|
109
|
+
const textByLang = (lang: string) => (str: TemplateStringsArray, ...values: (string | number)[]) => {
|
|
110
|
+
const text_or_func = strings.getString(str[0], lang) as string | LocalizationFunction;
|
|
111
|
+
if (!text_or_func) {
|
|
112
|
+
console.error(`Language string not found: "${str[0]}"`);
|
|
113
|
+
return str[0];
|
|
114
|
+
} else if (typeof text_or_func === 'function') {
|
|
115
|
+
if (text_or_func.length !== values.length) {
|
|
116
|
+
console.error(
|
|
117
|
+
`Language function "${str[0]}" expects exactly ${text_or_func.length} argument${text_or_func.length === 1 ? '' : 's'}. `
|
|
118
|
+
+ (
|
|
119
|
+
text_or_func.length > 0
|
|
120
|
+
? ` Use text${'`'}${str[0]}${'${arg1}${arg2}`'}`
|
|
121
|
+
: ` Use text${'`'}${str[0]}${'`'}`
|
|
122
|
+
)
|
|
123
|
+
);
|
|
124
|
+
return text_or_func.length > 0 ? `${str[0]}${'${arg1}${arg2}'}` : str[0];
|
|
125
|
+
}
|
|
126
|
+
return text_or_func(...values);
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
return text_or_func;
|
|
130
|
+
}
|
|
131
|
+
return (
|
|
132
|
+
<LocalizationContext.Provider
|
|
133
|
+
value={{
|
|
134
|
+
lang,
|
|
135
|
+
languages,
|
|
136
|
+
setLanguage: (newLang: string) => {
|
|
137
|
+
if (!languages.includes(newLang)) {
|
|
138
|
+
console.error(`Language ${newLang} not available`);
|
|
139
|
+
return;
|
|
140
|
+
}
|
|
141
|
+
setLanguage(newLang);
|
|
142
|
+
},
|
|
143
|
+
strings,
|
|
144
|
+
text: textByLang(lang),
|
|
145
|
+
textByLang,
|
|
146
|
+
localizationStrings,
|
|
147
|
+
setLocalization,
|
|
148
|
+
...restProps,
|
|
149
|
+
}}
|
|
150
|
+
>
|
|
151
|
+
{children}
|
|
152
|
+
</LocalizationContext.Provider>
|
|
153
|
+
);
|
|
154
|
+
};
|
|
155
|
+
|
|
156
|
+
export const useLocalization = () => useContext(LocalizationContext);
|