Skip to content

Commit fb72430

Browse files
committed
Merge branch 'frontend-fiat-comma'
2 parents ae9b954 + 7723a31 commit fb72430

File tree

8 files changed

+316
-26
lines changed

8 files changed

+316
-26
lines changed

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55
- Add specific titles to guides replacing the generic "Guide" previously used on all pages
66
- Android: enable transactions export feature
77
- Format amounts using localized decimal and group separator
8+
- Support pasting different localized number formats, i.e. dot and comma separated amounts
89
- Fix BitBoxApp crash on GrapheneOS and other phones without Google Play Services when scanning QR codes.
910

1011
## 4.42.0

frontends/web/src/components/forms/index.tsx

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
/**
22
* Copyright 2018 Shift Devices AG
3-
* Copyright 2021 Shift Crypto AG
3+
* Copyright 2021-2024 Shift Crypto AG
44
*
55
* Licensed under the Apache License, Version 2.0 (the "License");
66
* you may not use this file except in compliance with the License.
@@ -19,6 +19,7 @@ export { Button, ButtonLink } from './button';
1919
export { default as Checkbox } from './checkbox';
2020
export { Radio } from './radio';
2121
export { Field } from './field';
22-
export { default as Input } from './input';
22+
export { Input } from './input';
23+
export { NumberInput } from './input-number';
2324
export { Label } from './label';
2425
export { Select } from './select';
Lines changed: 190 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,190 @@
1+
/**
2+
* Copyright 2024 Shift Crypto AG
3+
*
4+
* Licensed under the Apache License, Version 2.0 (the "License");
5+
* you may not use this file except in compliance with the License.
6+
* You may obtain a copy of the License at
7+
*
8+
* http://www.apache.org/licenses/LICENSE-2.0
9+
*
10+
* Unless required by applicable law or agreed to in writing, software
11+
* distributed under the License is distributed on an "AS IS" BASIS,
12+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
* See the License for the specific language governing permissions and
14+
* limitations under the License.
15+
*/
16+
17+
import { describe, expect, it, vi } from 'vitest';
18+
import { fireEvent, render, screen } from '@testing-library/react';
19+
import { NumberInput } from './input-number';
20+
21+
describe('components/forms/input-number', () => {
22+
it('should preserve type attribute', () => {
23+
const { container } = render(<NumberInput defaultValue="" />);
24+
expect(container.querySelector('[type="number"')).toBeTruthy();
25+
});
26+
27+
it('should have children', () => {
28+
render(<NumberInput defaultValue=""><span>label</span></NumberInput>);
29+
expect(screen.getByText('label')).toBeTruthy();
30+
});
31+
32+
it('should have a label', () => {
33+
render(<NumberInput id="myInput" label="Label" defaultValue="" />);
34+
expect(screen.getByLabelText('Label')).toBeTruthy();
35+
});
36+
37+
it('should preserve text', () => {
38+
render(<NumberInput label="Label" error="text too short" defaultValue="" />);
39+
expect(screen.getByText('text too short')).toBeTruthy();
40+
});
41+
42+
it('should paste supported number formats', async () => {
43+
const mockCallback = vi.fn();
44+
render(
45+
<NumberInput placeholder="Number input" onChange={mockCallback} />
46+
);
47+
const input = screen.queryByPlaceholderText('Number input');
48+
if (!input) {
49+
throw new Error('Input not found');
50+
}
51+
fireEvent.paste(input, {
52+
clipboardData: {
53+
getData: () => '1,000,000.50'
54+
}
55+
});
56+
expect(mockCallback).toHaveBeenCalledTimes(1);
57+
expect(mockCallback).toHaveBeenCalledWith(expect.objectContaining({
58+
target: expect.objectContaining({ value: '1000000.50' })
59+
}));
60+
61+
fireEvent.paste(input, { clipboardData: { getData: () => '100.50' } });
62+
expect(mockCallback).toHaveBeenCalledTimes(2);
63+
expect(mockCallback.mock.calls[1][0].target.value).toBe('100.50');
64+
65+
mockCallback.mockClear();
66+
fireEvent.paste(input, { clipboardData: { getData: () => '1' } });
67+
expect(mockCallback.mock.calls[0][0].target.value).toBe('1');
68+
69+
mockCallback.mockClear();
70+
fireEvent.paste(input, { clipboardData: { getData: () => '1,0' } });
71+
expect(mockCallback.mock.calls[0][0].target.value).toBe('1.0');
72+
73+
mockCallback.mockClear();
74+
fireEvent.paste(input, { clipboardData: { getData: () => '1,0' } });
75+
expect(mockCallback.mock.calls[0][0].target.value).toBe('1.0');
76+
77+
mockCallback.mockClear();
78+
fireEvent.paste(input, { clipboardData: { getData: () => '1,00' } });
79+
expect(mockCallback.mock.calls[0][0].target.value).toBe('1.00');
80+
81+
mockCallback.mockClear();
82+
fireEvent.paste(input, { clipboardData: { getData: () => '1.00' } });
83+
expect(mockCallback.mock.calls[0][0].target.value).toBe('1.00');
84+
85+
mockCallback.mockClear();
86+
fireEvent.paste(input, { clipboardData: { getData: () => '1,000' } });
87+
expect(mockCallback.mock.calls[0][0].target.value).toBe('1.000');
88+
89+
mockCallback.mockClear();
90+
fireEvent.paste(input, { clipboardData: { getData: () => '1,000.00' } });
91+
expect(mockCallback.mock.calls[0][0].target.value).toBe('1000.00');
92+
93+
mockCallback.mockClear();
94+
fireEvent.paste(input, { clipboardData: { getData: () => '1.000,00' } });
95+
expect(mockCallback.mock.calls[0][0].target.value).toBe('1000.00');
96+
97+
mockCallback.mockClear();
98+
fireEvent.paste(input, { clipboardData: { getData: () => '100,50' } });
99+
expect(mockCallback.mock.calls[0][0].target.value).toBe('100.50');
100+
101+
mockCallback.mockClear();
102+
fireEvent.paste(input, { clipboardData: { getData: () => '100.00' } });
103+
expect(mockCallback.mock.calls[0][0].target.value).toBe('100.00');
104+
105+
mockCallback.mockClear();
106+
fireEvent.paste(input, { clipboardData: { getData: () => '100,000' } });
107+
expect(mockCallback.mock.calls[0][0].target.value).toBe('100.000');
108+
109+
mockCallback.mockClear();
110+
fireEvent.paste(input, { clipboardData: { getData: () => '100.000' } });
111+
expect(mockCallback.mock.calls[0][0].target.value).toBe('100.000');
112+
113+
mockCallback.mockClear();
114+
fireEvent.paste(input, { clipboardData: { getData: () => '.99' } });
115+
expect(mockCallback.mock.calls[0][0].target.value).toBe('.99');
116+
117+
mockCallback.mockClear();
118+
fireEvent.paste(input, { clipboardData: { getData: () => ',99' } });
119+
expect(mockCallback.mock.calls[0][0].target.value).toBe('.99');
120+
121+
mockCallback.mockClear();
122+
fireEvent.paste(input, { clipboardData: { getData: () => '0.0000000000000000001' } });
123+
expect(mockCallback.mock.calls[0][0].target.value).toBe('0.0000000000000000001');
124+
125+
mockCallback.mockClear();
126+
fireEvent.paste(input, { clipboardData: { getData: () => '0,0000000000000000001' } });
127+
expect(mockCallback.mock.calls[0][0].target.value).toBe('0.0000000000000000001');
128+
129+
mockCallback.mockClear();
130+
fireEvent.paste(input, { clipboardData: { getData: () => '1\'000\'000.50' } });
131+
expect(mockCallback.mock.calls[0][0].target.value).toBe('1000000.50');
132+
133+
mockCallback.mockClear();
134+
fireEvent.paste(input, { clipboardData: { getData: () => '1\'000\'000,50' } });
135+
expect(mockCallback.mock.calls[0][0].target.value).toBe('1000000.50');
136+
137+
mockCallback.mockClear();
138+
fireEvent.paste(input, { clipboardData: { getData: () => '1 000 000.50' } });
139+
expect(mockCallback.mock.calls[0][0].target.value).toBe('1000000.50');
140+
141+
mockCallback.mockClear();
142+
fireEvent.paste(input, { clipboardData: { getData: () => '1.000.000,50' } });
143+
expect(mockCallback.mock.calls[0][0].target.value).toBe('1000000.50');
144+
145+
mockCallback.mockClear();
146+
fireEvent.paste(input, { clipboardData: { getData: () => '1 000.50' } });
147+
expect(mockCallback.mock.calls[0][0].target.value).toBe('1000.50');
148+
149+
mockCallback.mockClear();
150+
fireEvent.paste(input, { clipboardData: { getData: () => '1.000,50' } });
151+
expect(mockCallback.mock.calls[0][0].target.value).toBe('1000.50');
152+
153+
mockCallback.mockClear();
154+
fireEvent.paste(input, { clipboardData: { getData: () => '1,000.50' } });
155+
expect(mockCallback.mock.calls[0][0].target.value).toBe('1000.50');
156+
157+
mockCallback.mockClear();
158+
fireEvent.paste(input, { clipboardData: { getData: () => '1,000,000.50' } });
159+
expect(mockCallback.mock.calls[0][0].target.value).toBe('1000000.50');
160+
});
161+
162+
it('has some unsupported number formats', async () => {
163+
const mockCallback = vi.fn();
164+
render(
165+
<NumberInput placeholder="Weird formats" onChange={mockCallback} />
166+
);
167+
const input = screen.queryByPlaceholderText('Weird formats');
168+
if (!input) {
169+
throw new Error('Input not found');
170+
}
171+
172+
fireEvent.paste(input, { clipboardData: { getData: () => '100,' } });
173+
expect(mockCallback).toHaveBeenCalledTimes(1);
174+
expect(mockCallback.mock.calls[0][0].target.value).toBe('');
175+
176+
mockCallback.mockClear();
177+
fireEvent.paste(input, { clipboardData: { getData: () => '1,000.000,50' } });
178+
expect(mockCallback).toHaveBeenCalledTimes(0);
179+
180+
mockCallback.mockClear();
181+
fireEvent.paste(input, { clipboardData: { getData: () => '1,,000' } });
182+
expect(mockCallback).toHaveBeenCalledTimes(0);
183+
184+
mockCallback.mockClear();
185+
fireEvent.paste(input, { clipboardData: { getData: () => '1,000' } });
186+
expect(mockCallback.mock.calls[0][0].target.value).toBe('1.000');
187+
188+
});
189+
190+
});
Lines changed: 96 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,96 @@
1+
/**
2+
* Copyright 2024 Shift Crypto AG
3+
*
4+
* Licensed under the Apache License, Version 2.0 (the "License");
5+
* you may not use this file except in compliance with the License.
6+
* You may obtain a copy of the License at
7+
*
8+
* http://www.apache.org/licenses/LICENSE-2.0
9+
*
10+
* Unless required by applicable law or agreed to in writing, software
11+
* distributed under the License is distributed on an "AS IS" BASIS,
12+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
* See the License for the specific language governing permissions and
14+
* limitations under the License.
15+
*/
16+
17+
import { useCallback } from 'react';
18+
import { Input, TInputProps } from './input';
19+
20+
type Props = Omit<TInputProps, 'ref' | 'onInput'>;
21+
22+
export const NumberInput = (({
23+
onChange,
24+
...props
25+
}: Props) => {
26+
27+
// support pasting of various different formats
28+
// the function tries to do some light string replacement and see if it becomes a number
29+
// if NaN it tries to detect and change a few commonly localized formats
30+
// 100,50 should become 100.50, and not 10050
31+
// more examples in the condition
32+
const handlePaste = useCallback((event: React.ClipboardEvent<HTMLInputElement>) => {
33+
if (!event.currentTarget || !event.clipboardData) {
34+
return;
35+
}
36+
const text = (
37+
event.clipboardData
38+
.getData('text')
39+
.trim()
40+
// remove thousand separator characters space (fr, ru, sw) and apostrophe (ch, li)
41+
.replace(/[ ']/g, '')
42+
);
43+
44+
const target = event.currentTarget;
45+
const dividedByComma = text.split(',');
46+
const dividedByDot = text.split('.');
47+
// see if this would turn to a valid number
48+
const value = Number(text);
49+
if (value && !Number.isNaN(value)) {
50+
// valid number, stop here and let paste event continue
51+
target.value = text;
52+
} else if (
53+
// comma decimal separator 100,50 or ,99
54+
dividedByComma.length === 2
55+
&& dividedByDot.length === 1
56+
) {
57+
target.value = dividedByComma.join('.');
58+
} else if (
59+
// comma decimal with dot thousand separator i.e. 1.000.000,50 (de, es, it)
60+
dividedByComma.length === 2
61+
&& dividedByDot.length > 1
62+
&& dividedByDot[dividedByDot.length - 1]?.includes(',')
63+
) {
64+
target.value = [
65+
dividedByComma[0].replace(/[.]/g, ''), // replace dot in whole coins 1.000.000
66+
dividedByComma[1], // rest i.e. 50
67+
].join('.');
68+
} else if (
69+
// dot decimal with comma thousand separator i.e. 1,000,000.50 (cn, jp, us)
70+
dividedByDot.length === 2
71+
&& dividedByComma.length > 1
72+
&& dividedByComma[dividedByComma.length - 1]?.includes('.')
73+
) {
74+
target.value = [
75+
dividedByDot[0].replace(/[,]/g, ''), // replace comma in 1,000,000
76+
dividedByDot[1], // rest i.e. 50
77+
].join('.');
78+
} else {
79+
console.warn(`unexpected format ${text.replace(/[\d]/g, '9')}`);
80+
return;
81+
}
82+
if (onChange) {
83+
onChange({ ...event, target });
84+
}
85+
event.preventDefault();
86+
}, [onChange]);
87+
88+
return (
89+
<Input
90+
{...props}
91+
type="number"
92+
onInput={onChange}
93+
onPaste={handlePaste}
94+
/>
95+
);
96+
});

frontends/web/src/components/forms/input.test.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,7 @@
1717

1818
import { describe, expect, it } from 'vitest';
1919
import { render, screen } from '@testing-library/react';
20-
import Input from './input';
20+
import { Input } from './input';
2121

2222
describe('components/forms/input', () => {
2323
it('should preserve type attribute', () => {

frontends/web/src/components/forms/input.tsx

Lines changed: 4 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
/**
22
* Copyright 2018 Shift Devices AG
3-
* Copyright 2021 Shift Crypto AG
3+
* Copyright 2021-2024 Shift Crypto AG
44
*
55
* Licensed under the Apache License, Version 2.0 (the "License");
66
* you may not use this file except in compliance with the License.
@@ -18,9 +18,7 @@
1818
import { ChangeEvent, HTMLProps, forwardRef } from 'react';
1919
import styles from './input.module.css';
2020

21-
22-
23-
export type Props = {
21+
export type TInputProps = {
2422
align?: 'left' | 'right';
2523
children?: React.ReactNode;
2624
className?: string;
@@ -31,7 +29,7 @@ export type Props = {
3129
label?: string;
3230
} & Omit<HTMLProps<HTMLInputElement>, 'onInput'>
3331

34-
export default forwardRef<HTMLInputElement, Props>(function Input({
32+
export const Input = forwardRef<HTMLInputElement, TInputProps>(({
3533
id,
3634
label = '',
3735
error,
@@ -42,7 +40,7 @@ export default forwardRef<HTMLInputElement, Props>(function Input({
4240
type = 'text',
4341
labelSection,
4442
...props
45-
}, ref) {
43+
}: TInputProps, ref) => {
4644
return (
4745
<div className={[
4846
styles.input,

0 commit comments

Comments
 (0)