diff --git a/playbook/app/pb_kits/playbook/pb_dropdown/_dropdown.tsx b/playbook/app/pb_kits/playbook/pb_dropdown/_dropdown.tsx index cb5d53c031..7e3f13b1a0 100644 --- a/playbook/app/pb_kits/playbook/pb_dropdown/_dropdown.tsx +++ b/playbook/app/pb_kits/playbook/pb_dropdown/_dropdown.tsx @@ -112,6 +112,8 @@ type DropdownProps = { isClosed?: boolean; label?: string; multiSelect?: boolean; + name?: string; + onChange?: (event: { target: { name?: string; value: any } }) => void; onSelect?: (arg: GenericObject) => null; options?: GenericObject; placeholder?: string; @@ -156,6 +158,8 @@ let Dropdown = (props: DropdownProps, ref: any): React.ReactElement | null => { label, multiSelect = false, formPillProps, + name, + onChange, onSelect, options, placeholder, @@ -414,6 +418,10 @@ let Dropdown = (props: DropdownProps, ref: any): React.ReactElement | null => { setIsDropDownClosed(false); }; + const handleSelectionChange = (value: any) => { + onSelect && onSelect(value); + onChange && onChange({ target: { name, value } }); + }; const handleOptionClick = (clickedItem: GenericObject) => { if (disabled) return; @@ -426,7 +434,7 @@ let Dropdown = (props: DropdownProps, ref: any): React.ReactElement | null => { const next = exists ? list.filter((option) => option.value !== clickedItem.value) : [...list, clickedItem]; - onSelect && onSelect(next); + handleSelectionChange(next); return next; }); setFilterItem(""); @@ -439,7 +447,7 @@ let Dropdown = (props: DropdownProps, ref: any): React.ReactElement | null => { if (shouldCloseOnClick) { setIsDropDownClosed(true); } - onSelect && onSelect(clickedItem); + handleSelectionChange(clickedItem); // Sync with DatePickers if this is a quickpick variant if (variant === "quickpick" && Array.isArray(clickedItem.value)) { @@ -468,10 +476,10 @@ let Dropdown = (props: DropdownProps, ref: any): React.ReactElement | null => { if (disabled) return; if (multiSelect) { setSelected([]); - onSelect && onSelect([]); + handleSelectionChange([]); } else { setSelected({}); - onSelect && onSelect(null); + handleSelectionChange(null); setFocusedOptionIndex(-1); setFilterItem(""); @@ -505,10 +513,10 @@ let Dropdown = (props: DropdownProps, ref: any): React.ReactElement | null => { clearSelected: () => { if (multiSelect) { setSelected([]); - onSelect && onSelect([]); + handleSelectionChange([]); } else { setSelected({}); - onSelect && onSelect(null); + handleSelectionChange(null); } setFilterItem(""); setIsDropDownClosed(true); @@ -522,16 +530,16 @@ let Dropdown = (props: DropdownProps, ref: any): React.ReactElement | null => { clearSelected: () => { if (multiSelect) { setSelected([]); - onSelect && onSelect([]); + handleSelectionChange([]); } else { setSelected({}); - onSelect && onSelect(null); + handleSelectionChange(null); } setFilterItem(""); setIsDropDownClosed(true); }, }; - }, [multiSelect, onSelect, setSelected, setFilterItem, setIsDropDownClosed]); + }, [multiSelect, handleSelectionChange, setSelected, setFilterItem, setIsDropDownClosed]); useImperativeHandle(ref, () => imperativeRef.current); @@ -594,6 +602,7 @@ let Dropdown = (props: DropdownProps, ref: any): React.ReactElement | null => { handleBackspace, handleChange, handleOptionClick, + handleSelectionChange, handleWrapperClick, inputRef, inputWrapperRef, diff --git a/playbook/app/pb_kits/playbook/pb_dropdown/docs/_dropdown_multi_select_react_hook.jsx b/playbook/app/pb_kits/playbook/pb_dropdown/docs/_dropdown_multi_select_react_hook.jsx new file mode 100644 index 0000000000..55a2485f99 --- /dev/null +++ b/playbook/app/pb_kits/playbook/pb_dropdown/docs/_dropdown_multi_select_react_hook.jsx @@ -0,0 +1,45 @@ +import React from 'react' + +import Dropdown from '../_dropdown' +import Title from '../../pb_title/_title' +import { useForm } from 'react-hook-form' + +const options = [ + { label: 'United States', value: 'unitedStates', id: 'us' }, + { label: 'United Kingdom', value: 'unitedKingdom', id: 'gb' }, + { label: 'Canada', value: 'canada', id: 'ca' }, + { label: 'Pakistan', value: 'pakistan', id: 'pk' }, + { label: 'India', value: 'india', id: 'in' }, + { label: 'Australia', value: 'australia', id: 'au' }, + { label: 'New Zealand', value: 'new Zealand', id: 'nz' }, + { label: 'Italy', value: 'italy', id: 'it' }, + { label: 'Spain', value: 'spain', id: 'es' }, +] + +const DropdownMultiSelectReactHook = (props) => { + const { register, watch } = useForm() + + const selectedCountries = watch('countries') + + return ( + <> + + + {selectedCountries && selectedCountries.map(country => ( + <p key={country.id}>{`${country.label} - ${country.value}`}</p> + ))} + </> + ) +} + +export default DropdownMultiSelectReactHook diff --git a/playbook/app/pb_kits/playbook/pb_dropdown/docs/_dropdown_multi_select_react_hook.md b/playbook/app/pb_kits/playbook/pb_dropdown/docs/_dropdown_multi_select_react_hook.md new file mode 100644 index 0000000000..41f34c1df5 --- /dev/null +++ b/playbook/app/pb_kits/playbook/pb_dropdown/docs/_dropdown_multi_select_react_hook.md @@ -0,0 +1 @@ +You can pass `react-hook-form` props to a multi-select Dropdown. Spread `register` onto Dropdown with `multiSelect` to keep the selected options array in form state. diff --git a/playbook/app/pb_kits/playbook/pb_dropdown/docs/_dropdown_react_hook.jsx b/playbook/app/pb_kits/playbook/pb_dropdown/docs/_dropdown_react_hook.jsx new file mode 100644 index 0000000000..b3bd5f8e6a --- /dev/null +++ b/playbook/app/pb_kits/playbook/pb_dropdown/docs/_dropdown_react_hook.jsx @@ -0,0 +1,36 @@ +import React from 'react' + +import Dropdown from '../_dropdown' +import Title from '../../pb_title/_title' +import { useForm } from 'react-hook-form' + +const options = [ + { label: 'United States', value: 'unitedStates', id: 'us' }, + { label: 'Canada', value: 'canada', id: 'ca' }, + { label: 'Pakistan', value: 'pakistan', id: 'pk' }, +] + +const DropdownReactHook = (props) => { + const { register, watch } = useForm() + + const selectedCountry = watch('country') + + return ( + <> + <Dropdown + label="Countries" + options={options} + {...props} + {...register('country')} + /> + <Title + marginTop="sm" + size={4} + text="Selected Country" + /> + <p>{selectedCountry && `${selectedCountry.label} - ${selectedCountry.value}`}</p> + </> + ) +} + +export default DropdownReactHook diff --git a/playbook/app/pb_kits/playbook/pb_dropdown/docs/_dropdown_react_hook.md b/playbook/app/pb_kits/playbook/pb_dropdown/docs/_dropdown_react_hook.md new file mode 100644 index 0000000000..4d34d51c78 --- /dev/null +++ b/playbook/app/pb_kits/playbook/pb_dropdown/docs/_dropdown_react_hook.md @@ -0,0 +1 @@ +You can pass `react-hook-form` props to the Dropdown kit. Spread `register` onto a single-select Dropdown to keep the selected option in form state. diff --git a/playbook/app/pb_kits/playbook/pb_dropdown/docs/_playground.json b/playbook/app/pb_kits/playbook/pb_dropdown/docs/_playground.json index fdd2745a46..42a9569feb 100644 --- a/playbook/app/pb_kits/playbook/pb_dropdown/docs/_playground.json +++ b/playbook/app/pb_kits/playbook/pb_dropdown/docs/_playground.json @@ -88,6 +88,7 @@ "name": "Content", "props": [ "label", + "name", "placeholder", "options", "defaultValue", @@ -130,6 +131,7 @@ { "name": "Events", "props": [ + "onChange", "onSelect" ] } diff --git a/playbook/app/pb_kits/playbook/pb_dropdown/docs/_playground.overrides.json b/playbook/app/pb_kits/playbook/pb_dropdown/docs/_playground.overrides.json index cc84af3363..b02dcb5c4d 100644 --- a/playbook/app/pb_kits/playbook/pb_dropdown/docs/_playground.overrides.json +++ b/playbook/app/pb_kits/playbook/pb_dropdown/docs/_playground.overrides.json @@ -70,6 +70,7 @@ "name": "Content", "props": [ "label", + "name", "placeholder", "options", "defaultValue", @@ -111,7 +112,7 @@ }, { "name": "Events", - "props": ["onSelect"] + "props": ["onChange", "onSelect"] } ], "presets": [ diff --git a/playbook/app/pb_kits/playbook/pb_dropdown/docs/example.yml b/playbook/app/pb_kits/playbook/pb_dropdown/docs/example.yml index 42b288f5d4..f575657d02 100644 --- a/playbook/app/pb_kits/playbook/pb_dropdown/docs/example.yml +++ b/playbook/app/pb_kits/playbook/pb_dropdown/docs/example.yml @@ -81,3 +81,6 @@ examples: - dropdown_required_indicator: Required Indicator - dropdown_disabled: Disabled Input - dropdown_grouped_options: Grouped Options + - dropdown_react_hook: React Hook + - dropdown_multi_select_react_hook: React Hook Multi Select + diff --git a/playbook/app/pb_kits/playbook/pb_dropdown/docs/index.js b/playbook/app/pb_kits/playbook/pb_dropdown/docs/index.js index c1e4b9e75a..57eea9f555 100644 --- a/playbook/app/pb_kits/playbook/pb_dropdown/docs/index.js +++ b/playbook/app/pb_kits/playbook/pb_dropdown/docs/index.js @@ -1,4 +1,6 @@ export { default as DropdownDefault } from './_dropdown_default.jsx' +export { default as DropdownReactHook } from './_dropdown_react_hook.jsx' +export { default as DropdownMultiSelectReactHook } from './_dropdown_multi_select_react_hook.jsx' export { default as DropdownWithCustomDisplay } from './_dropdown_with_custom_display.jsx' export { default as DropdownWithCustomOptions } from './_dropdown_with_custom_options.jsx' export { default as DropdownWithCustomTrigger } from './_dropdown_with_custom_trigger.jsx' diff --git a/playbook/app/pb_kits/playbook/pb_dropdown/dropdown.test.jsx b/playbook/app/pb_kits/playbook/pb_dropdown/dropdown.test.jsx index 214e601c3f..e76ac511d7 100644 --- a/playbook/app/pb_kits/playbook/pb_dropdown/dropdown.test.jsx +++ b/playbook/app/pb_kits/playbook/pb_dropdown/dropdown.test.jsx @@ -1,5 +1,5 @@ import React, { useState } from "react" -import { render, screen, fireEvent, waitFor } from "../utilities/test-utils" +import { render, screen, fireEvent, waitFor, act } from "../utilities/test-utils" import { Dropdown, Icon, IconCircle } from 'playbook-ui' import DateTime from "../pb_kit/dateTime.ts" @@ -995,4 +995,207 @@ test('disabled prop disables autocomplete input', () => { const input = kit.querySelector('.dropdown_input') expect(input).toBeDisabled() -}) \ No newline at end of file +}) + +test('onChange uses react-hook-form event shape', () => { + const onChange = jest.fn() + + render( + <Dropdown + data={{ testid: testId }} + name="color" + onChange={onChange} + options={options} + /> + ) + + const kit = screen.getByTestId(testId) + fireEvent.click(kit.querySelectorAll('.pb_dropdown_option_list')[0]) + + expect(onChange).toHaveBeenCalledWith({ + target: { name: 'color', value: options[0] }, + }) +}) + +test('react-hook-form onChange receives selected options for multiSelect', () => { + const onChange = jest.fn() + + render( + <Dropdown + data={{ testid: testId }} + multiSelect + name="languages" + onChange={onChange} + options={options} + /> + ) + + const kit = screen.getByTestId(testId) + fireEvent.click(kit.querySelectorAll('.pb_dropdown_option_list')[0]) + fireEvent.click(kit.querySelectorAll('.pb_dropdown_option_list')[0]) + + expect(onChange.mock.calls[0][0]).toEqual({ + target: { name: 'languages', value: [options[0]] }, + }) + expect(onChange.mock.calls[1][0]).toEqual({ + target: { name: 'languages', value: [options[0], options[1]] }, + }) +}) + +test('onSelect still fires when onChange is provided', () => { + const onSelect = jest.fn() + const onChange = jest.fn() + + render( + <Dropdown + data={{ testid: testId }} + name="country" + onChange={onChange} + onSelect={onSelect} + options={options} + /> + ) + + const kit = screen.getByTestId(testId) + fireEvent.click(kit.querySelectorAll('.pb_dropdown_option_list')[0]) + + expect(onSelect).toHaveBeenCalledWith(options[0]) + expect(onChange).toHaveBeenCalledWith({ + target: { name: 'country', value: options[0] }, + }) +}) + +test('onSelect-only single select still receives the option object', () => { + const onSelect = jest.fn() + + render( + <Dropdown + data={{ testid: testId }} + onSelect={onSelect} + options={options} + /> + ) + + const kit = screen.getByTestId(testId) + fireEvent.click(kit.querySelectorAll('.pb_dropdown_option_list')[0]) + + expect(onSelect).toHaveBeenCalledTimes(1) + expect(onSelect).toHaveBeenCalledWith(options[0]) +}) + +test('onSelect-only multiSelect still receives the selected options array', () => { + const onSelect = jest.fn() + + render( + <Dropdown + data={{ testid: testId }} + multiSelect + onSelect={onSelect} + options={options} + /> + ) + + const kit = screen.getByTestId(testId) + fireEvent.click(kit.querySelectorAll('.pb_dropdown_option_list')[0]) + fireEvent.click(kit.querySelectorAll('.pb_dropdown_option_list')[0]) + + expect(onSelect.mock.calls[0][0]).toEqual([options[0]]) + expect(onSelect.mock.calls[1][0]).toEqual([options[0], options[1]]) +}) + +test('onSelect-only clearSelected still receives null', () => { + const onSelect = jest.fn() + const dropdownRef = React.createRef() + + render( + <Dropdown + data={{ testid: testId }} + defaultValue={options[0]} + onSelect={onSelect} + options={options} + ref={dropdownRef} + /> + ) + + act(() => { + dropdownRef.current.clearSelected() + }) + + expect(onSelect).toHaveBeenCalledWith(null) +}) + +test('onSelect-only clear icon still receives null', () => { + const onSelect = jest.fn() + + render( + <Dropdown + data={{ testid: testId }} + defaultValue={options[0]} + onSelect={onSelect} + options={options} + /> + ) + + const kit = screen.getByTestId(testId) + fireEvent.click(kit.querySelector('[aria-label="times icon"]').closest('div')) + + expect(onSelect).toHaveBeenCalledWith(null) +}) + +test('onSelect-only removing a multiSelect pill still receives remaining options', () => { + const onSelect = jest.fn() + + render( + <Dropdown + data={{ testid: testId }} + defaultValue={[options[0], options[1]]} + multiSelect + onSelect={onSelect} + options={options} + /> + ) + + const kit = screen.getByTestId(testId) + fireEvent.click(kit.querySelector('.pb_form_pill_close')) + + expect(onSelect).toHaveBeenCalledWith([options[1]]) +}) + +test('autocomplete typing does not fire onSelect or onChange', () => { + const onSelect = jest.fn() + const onChange = jest.fn() + + render( + <Dropdown + autocomplete + data={{ testid: testId }} + onChange={onChange} + onSelect={onSelect} + options={options} + /> + ) + + const kit = screen.getByTestId(testId) + fireEvent.change(kit.querySelector('.dropdown_input'), { target: { value: 'Can' } }) + + expect(onSelect).not.toHaveBeenCalled() + expect(onChange).not.toHaveBeenCalled() +}) + +test('autocomplete selection still fires onSelect', () => { + const onSelect = jest.fn() + + render( + <Dropdown + autocomplete + data={{ testid: testId }} + onSelect={onSelect} + options={options} + /> + ) + + const kit = screen.getByTestId(testId) + fireEvent.click(kit.querySelectorAll('.pb_dropdown_option_list')[1]) + + expect(onSelect).toHaveBeenCalledWith(options[1]) +}) diff --git a/playbook/app/pb_kits/playbook/pb_dropdown/kit.schema.json b/playbook/app/pb_kits/playbook/pb_dropdown/kit.schema.json index 04f25ad78f..9e66d849c9 100644 --- a/playbook/app/pb_kits/playbook/pb_dropdown/kit.schema.json +++ b/playbook/app/pb_kits/playbook/pb_dropdown/kit.schema.json @@ -115,6 +115,19 @@ ], "default": false }, + "name": { + "type": "string", + "platforms": [ + "react", + "rails" + ] + }, + "onChange": { + "type": "function", + "platforms": [ + "react" + ] + }, "onSelect": { "type": "function", "platforms": [ @@ -194,12 +207,6 @@ ], "default": false }, - "name": { - "platforms": [ - "rails" - ], - "type": "string" - }, "required": { "platforms": [ "rails" diff --git a/playbook/app/pb_kits/playbook/pb_dropdown/subcomponents/MultiSelectTriggerDisplay.tsx b/playbook/app/pb_kits/playbook/pb_dropdown/subcomponents/MultiSelectTriggerDisplay.tsx index 12e58eb560..e3fd33a322 100644 --- a/playbook/app/pb_kits/playbook/pb_dropdown/subcomponents/MultiSelectTriggerDisplay.tsx +++ b/playbook/app/pb_kits/playbook/pb_dropdown/subcomponents/MultiSelectTriggerDisplay.tsx @@ -19,7 +19,7 @@ const MultiSelectTriggerDisplay = ({ dark = false, }: MultiSelectTriggerDisplayProps) => { - const { setSelected, onSelect, formPillProps } = useContext(DropdownContext); + const { setSelected, handleSelectionChange, formPillProps } = useContext(DropdownContext); if (selected.length === 0) { if (autocomplete) return null; @@ -35,7 +35,7 @@ const MultiSelectTriggerDisplay = ({ const handleRemoveIconClick = (option: GenericObject) => { setSelected((prev: GenericObject[]) => { const next = prev.filter((item) => item.label !== option.label); - onSelect && onSelect(next); + handleSelectionChange && handleSelectionChange(next); return next; }); }