diff --git a/ui/src/textbox.test.tsx b/ui/src/textbox.test.tsx index 03c5f07f06..26a3469c75 100644 --- a/ui/src/textbox.test.tsx +++ b/ui/src/textbox.test.tsx @@ -13,18 +13,23 @@ // limitations under the License. import { fireEvent, render } from '@testing-library/react' +import userEvent from '@testing-library/user-event' import React from 'react' import { Textbox, XTextbox } from './textbox' import { wave } from './ui' -const name = 'textbox' -const textboxProps: Textbox = { name } +const + name = 'textbox', + pushMock = jest.fn() +let textboxProps: Textbox = { name } +wave.push = pushMock describe('Textbox.tsx', () => { beforeEach(() => { jest.clearAllMocks() jest.useFakeTimers() - wave.args[name] = null + // Because we mutate props to programatically change "value" from Wave app we need to reset it before every test + textboxProps = { name } }) it('Renders data-test attr', () => { @@ -70,27 +75,86 @@ describe('Textbox.tsx', () => { expect(wave.args[name]).toBe('default') }) + it('Sets args when "value" prop changes', () => { + const { rerender } = render() + expect(wave.args[name]).toBe('a') + + rerender() + expect(wave.args[name]).toBe('b') + }) + + it('Sets args when typing new text and then "value" prop changes', () => { + const { getByTestId, rerender } = render() + expect(wave.args[name]).toBe('a') + + userEvent.type(getByTestId(name), '{backspace}b') + expect(wave.args[name]).toBe('b') + + rerender() + expect(wave.args[name]).toBe('c') + }) + it('Calls sync on change - trigger specified', () => { const { getByTestId } = render() - const pushMock = jest.fn() - wave.push = pushMock - fireEvent.change(getByTestId(name), { target: { value: 'aaa' } }) expect(pushMock).not.toBeCalled() // Not called immediately, but after specified timeout. jest.runOnlyPendingTimers() - expect(pushMock).toBeCalled() + expect(pushMock).toBeCalledTimes(1) + }) + + it('Debounces wave push', () => { + const { getByTestId } = render() + + userEvent.type(getByTestId(name), 'a') + userEvent.type(getByTestId(name), 'a') + userEvent.type(getByTestId(name), 'a') + userEvent.type(getByTestId(name), 'a') + jest.runOnlyPendingTimers() + + expect(pushMock).toBeCalledTimes(1) }) it('Does not call sync on change - trigger not specified', () => { const { getByTestId } = render() - const pushMock = jest.fn() - wave.push = pushMock - fireEvent.change(getByTestId(name), { target: { value: 'aaa' } }) expect(pushMock).not.toBeCalled() }) + + it('Display new value when "value" prop changes', () => { + const { getByTestId, rerender } = render() + expect(getByTestId(name)).toHaveValue('A') + + rerender() + expect(getByTestId(name)).toHaveValue('B') + }) + + it('Types value and then display new value when "value" prop changes', () => { + const { getByTestId, rerender } = render() + userEvent.type(getByTestId(name), '{backspace}A{Enter}') + expect(getByTestId(name)).toHaveValue('A') + + rerender() + expect(getByTestId(name)).toHaveValue('B') + }) + + it('Display new value when "value" prop changes (masked)', () => { + const { getByTestId, rerender } = render() + expect(getByTestId(name)).toHaveValue('(123)') + + rerender() + expect(getByTestId(name)).toHaveValue('(456)') + }) + + it('Types value and then display new value when "value" prop changes (masked)', () => { + const { getByTestId, rerender } = render() + userEvent.type(getByTestId(name), '{backspace}123{Enter}') + expect(getByTestId(name)).toHaveValue('(123)') + + rerender() + expect(getByTestId(name)).toHaveValue('(456)') + }) }) \ No newline at end of file diff --git a/ui/src/textbox.tsx b/ui/src/textbox.tsx index 1dcbc509a8..b6c159f662 100644 --- a/ui/src/textbox.tsx +++ b/ui/src/textbox.tsx @@ -71,20 +71,25 @@ const DEBOUNCE_TIMEOUT = 500 export const XTextbox = ({ model: m }: { model: Textbox }) => { const + [value, setValue] = React.useState(m.value ?? ''), + debounceRef = React.useRef(debounce(DEBOUNCE_TIMEOUT, wave.push)), onChange = ({ target }: React.FormEvent, v?: S) => { v = v || (target as HTMLInputElement).value wave.args[m.name] = v ?? (m.value || '') - if (m.trigger) wave.push() + if (m.trigger) debounceRef.current() + setValue(v) + m.value = v }, textFieldProps: Fluent.ITextFieldProps & { 'data-test': S } = { 'data-test': m.name, label: m.label, + value, errorMessage: m.error, required: m.required, disabled: m.disabled, readOnly: m.readonly, - onChange: m.trigger ? debounce(DEBOUNCE_TIMEOUT, onChange) : onChange, + onChange, iconProps: m.icon ? { iconName: m.icon } : undefined, placeholder: m.placeholder, prefix: m.prefix, @@ -94,11 +99,13 @@ export const type: m.password ? 'password' : undefined, } - // eslint-disable-next-line react-hooks/exhaustive-deps - React.useEffect(() => { wave.args[m.name] = m.value || '' }, []) + React.useEffect(() => { + wave.args[m.name] = m.value ?? '' + setValue(m.value ?? '') + }, [m.value, m.name]) return m.mask - ? + ? : ( ) } \ No newline at end of file