From e11b64df92c90805a70e8102555df3a7790ebf96 Mon Sep 17 00:00:00 2001 From: Michael Shilman Date: Wed, 3 Jun 2020 08:53:57 +0800 Subject: [PATCH 1/4] Controls: Remove react-select and fix initialization logic --- .../stories/addon-docs/props.stories.mdx | 62 ++++++++++++------- lib/components/package.json | 1 - .../src/controls/options/Checkbox.tsx | 22 +++---- .../src/controls/options/Options.stories.tsx | 32 +++++----- .../src/controls/options/Options.tsx | 10 ++- lib/components/src/controls/options/Radio.tsx | 9 +-- .../src/controls/options/Select.tsx | 61 ++++++++++-------- .../src/controls/options/helpers.ts | 14 +++++ 8 files changed, 130 insertions(+), 81 deletions(-) create mode 100644 lib/components/src/controls/options/helpers.ts diff --git a/examples/official-storybook/stories/addon-docs/props.stories.mdx b/examples/official-storybook/stories/addon-docs/props.stories.mdx index 879ab6984e52..0f3edca85533 100644 --- a/examples/official-storybook/stories/addon-docs/props.stories.mdx +++ b/examples/official-storybook/stories/addon-docs/props.stories.mdx @@ -11,20 +11,14 @@ import { MemoButton } from '../../components/MemoButton'; parameters={{ controls: { expanded: false } }} /> -export const FooBar = ({ foo, bar, baz } = {}) => ( +export const ArgsDisplay = (args = {}) => ( - - - - - - - - - - - - + {Object.entries(args).map(([key, val]) => ( + + + + + ))}
Foo{foo && foo.toString()}
Bar{bar}
Baz{baz && baz.toString()}
{key}{val && val.toString()}
); @@ -34,21 +28,45 @@ export const FooBar = ({ foo, bar, baz } = {}) => ( - {(args) => } + {(args) => } @@ -68,7 +86,7 @@ export const FooBar = ({ foo, bar, baz } = {}) => ( bar: '', }} > - {(args) => } + {(args) => } diff --git a/lib/components/package.json b/lib/components/package.json index 6dd87cc5bc76..89ca5e1ab962 100644 --- a/lib/components/package.json +++ b/lib/components/package.json @@ -50,7 +50,6 @@ "react-dom": "^16.8.3", "react-helmet-async": "^1.0.2", "react-popper-tooltip": "^2.11.0", - "react-select": "^3.0.8", "react-syntax-highlighter": "^12.2.1", "react-textarea-autosize": "^7.1.0", "ts-dedent": "^1.1.1" diff --git a/lib/components/src/controls/options/Checkbox.tsx b/lib/components/src/controls/options/Checkbox.tsx index 44e24b78518c..d03c2a82905a 100644 --- a/lib/components/src/controls/options/Checkbox.tsx +++ b/lib/components/src/controls/options/Checkbox.tsx @@ -1,6 +1,7 @@ import React, { FC, ChangeEvent, useState } from 'react'; import { styled } from '@storybook/theming'; import { ControlProps, OptionsMultiSelection, NormalizedOptionsConfig } from '../types'; +import { selectedKeys, selectedValues } from './helpers'; const CheckboxesWrapper = styled.div<{ isInline: boolean }>(({ isInline }) => isInline @@ -36,18 +37,19 @@ export const CheckboxControl: FC = ({ onChange, isInline, }) => { - const [selected, setSelected] = useState(value || []); + const initial = selectedKeys(value, options); + const [selected, setSelected] = useState(initial || []); const handleChange = (e: ChangeEvent) => { const option = (e.target as HTMLInputElement).value; - const newVal = [...selected]; - if (newVal.includes(option)) { - newVal.splice(newVal.indexOf(option), 1); + const updated = [...selected]; + if (updated.includes(option)) { + updated.splice(updated.indexOf(option), 1); } else { - newVal.push(option); + updated.push(option); } - onChange(name, newVal); - setSelected(newVal); + onChange(name, selectedValues(updated, options)); + setSelected(updated); }; return ( @@ -55,17 +57,15 @@ export const CheckboxControl: FC = ({ {Object.keys(options).map((key: string) => { const id = `${name}-${key}`; - const optionValue = options[key]; - return (
{key}
diff --git a/lib/components/src/controls/options/Options.stories.tsx b/lib/components/src/controls/options/Options.stories.tsx index d8ce20c14bb8..5c70774b04db 100644 --- a/lib/components/src/controls/options/Options.stories.tsx +++ b/lib/components/src/controls/options/Options.stories.tsx @@ -12,10 +12,10 @@ const objectOptions = { B: { id: 'Bat' }, C: { id: 'Cat' }, }; -const emptyOptions = null; -const optionsHelper = (options, type) => { - const [value, setValue] = useState([]); +const optionsHelper = (options, type, isMulti) => { + const initial = Array.isArray(options) ? options[1] : options.B; + const [value, setValue] = useState(isMulti ? [initial] : initial); return ( <> { options={options} value={value} type={type} - onChange={(name, newVal) => setValue(newVal)} + onChange={(_name, newVal) => setValue(newVal)} /> {value && Array.isArray(value) ? ( // eslint-disable-next-line react/no-array-index-key @@ -36,19 +36,19 @@ const optionsHelper = (options, type) => { }; // Check -export const CheckArray = () => optionsHelper(arrayOptions, 'check'); -export const InlineCheckArray = () => optionsHelper(arrayOptions, 'inline-check'); -export const CheckObject = () => optionsHelper(objectOptions, 'check'); -export const InlineCheckObject = () => optionsHelper(objectOptions, 'inline-check'); +export const CheckArray = () => optionsHelper(arrayOptions, 'check', true); +export const InlineCheckArray = () => optionsHelper(arrayOptions, 'inline-check', true); +export const CheckObject = () => optionsHelper(objectOptions, 'check', true); +export const InlineCheckObject = () => optionsHelper(objectOptions, 'inline-check', true); // Radio -export const ArrayRadio = () => optionsHelper(arrayOptions, 'radio'); -export const ArrayInlineRadio = () => optionsHelper(arrayOptions, 'inline-radio'); -export const ObjectRadio = () => optionsHelper(objectOptions, 'radio'); -export const ObjectInlineRadio = () => optionsHelper(objectOptions, 'inline-radio'); +export const ArrayRadio = () => optionsHelper(arrayOptions, 'radio', false); +export const ArrayInlineRadio = () => optionsHelper(arrayOptions, 'inline-radio', false); +export const ObjectRadio = () => optionsHelper(objectOptions, 'radio', false); +export const ObjectInlineRadio = () => optionsHelper(objectOptions, 'inline-radio', false); // Select -export const ArraySelect = () => optionsHelper(arrayOptions, 'select'); -export const ArrayMultiSelect = () => optionsHelper(arrayOptions, 'multi-select'); -export const ObjectSelect = () => optionsHelper(objectOptions, 'select'); -export const ObjectMultiSelect = () => optionsHelper(objectOptions, 'multi-select'); +export const ArraySelect = () => optionsHelper(arrayOptions, 'select', false); +export const ArrayMultiSelect = () => optionsHelper(arrayOptions, 'multi-select', true); +export const ObjectSelect = () => optionsHelper(objectOptions, 'select', false); +export const ObjectMultiSelect = () => optionsHelper(objectOptions, 'multi-select', true); diff --git a/lib/components/src/controls/options/Options.tsx b/lib/components/src/controls/options/Options.tsx index 373cf58a5e11..d838b30e964d 100644 --- a/lib/components/src/controls/options/Options.tsx +++ b/lib/components/src/controls/options/Options.tsx @@ -5,10 +5,18 @@ import { RadioControl } from './Radio'; import { SelectControl } from './Select'; import { ControlProps, OptionsSelection, OptionsConfig, Options } from '../types'; +/** + * Options can accept `options` in two formats: + * - array: ['a', 'b', 'c'] OR + * - object: { a: 1, b: 2, c: 3 } + * + * We always normalize to the more generalized object format and ONLY handle + * the object format in the underlying control implementations. + */ const normalizeOptions = (options: Options) => { if (Array.isArray(options)) { return options.reduce((acc, item) => { - acc[item] = item; + acc[item.toString()] = item; return acc; }, {}); } diff --git a/lib/components/src/controls/options/Radio.tsx b/lib/components/src/controls/options/Radio.tsx index ff39af32f8c8..dbff8a1c1387 100644 --- a/lib/components/src/controls/options/Radio.tsx +++ b/lib/components/src/controls/options/Radio.tsx @@ -1,6 +1,7 @@ import React, { FC, Validator } from 'react'; import { styled } from '@storybook/theming'; import { ControlProps, OptionsSingleSelection, NormalizedOptionsConfig } from '../types'; +import { selectedKey, selectedKeys } from './helpers'; const RadiosWrapper = styled.div<{ isInline: boolean }>(({ isInline }) => isInline @@ -24,20 +25,20 @@ const RadioLabel = styled.label({ type RadioConfig = NormalizedOptionsConfig & { isInline: boolean }; type RadioProps = ControlProps & RadioConfig; export const RadioControl: FC = ({ name, options, value, onChange, isInline }) => { + const selection = selectedKey(value, options); return ( {Object.keys(options).map((key) => { const id = `${name}-${key}`; - const optionValue = options[key]; return (
onChange(name, e.target.value)} - checked={optionValue === value} + value={key} + onChange={(e) => onChange(name, options[e.currentTarget.value])} + checked={key === selection} /> {key}
diff --git a/lib/components/src/controls/options/Select.tsx b/lib/components/src/controls/options/Select.tsx index 8709a0c6ad05..bc00311daaab 100644 --- a/lib/components/src/controls/options/Select.tsx +++ b/lib/components/src/controls/options/Select.tsx @@ -1,41 +1,50 @@ -import React, { FC } from 'react'; -import ReactSelect from 'react-select'; +import React, { FC, ChangeEvent } from 'react'; import { styled } from '@storybook/theming'; import { ControlProps, OptionsSelection, NormalizedOptionsConfig } from '../types'; +import { selectedKey, selectedKeys, selectedValues } from './helpers'; -// TODO: Apply the Storybook theme to react-select -const OptionsSelect = styled(ReactSelect)({ +const OptionsSelect = styled.select({ width: '100%', maxWidth: '300px', color: 'black', }); -interface OptionsItem { - value: any; - label: string; -} -type ReactSelectOnChangeFn = { (v: OptionsItem): void } | { (v: OptionsItem[]): void }; - type SelectConfig = NormalizedOptionsConfig & { isMulti: boolean }; type SelectProps = ControlProps & SelectConfig; -export const SelectControl: FC = ({ name, value, options, onChange, isMulti }) => { - // const optionsIndex = options.findIndex(i => i.value === value); - // let defaultValue: typeof options | typeof options[0] = options[optionsIndex]; - const selectOptions = Object.entries(options).reduce((acc, [key, val]) => { - acc.push({ label: key, value: val }); - return acc; - }, []); - const handleChange: ReactSelectOnChangeFn = isMulti - ? (values: OptionsItem[]) => onChange(name, values && values.map((item) => item.value)) - : (e: OptionsItem) => onChange(name, e.value); +const SingleSelect: FC = ({ name, value, options, onChange }) => { + const handleChange = (e: ChangeEvent) => { + onChange(name, options[e.currentTarget.value]); + }; + const selection = selectedKey(value, options); return ( - + + {Object.keys(options).map((key) => ( + + ))} + ); }; + +const MultiSelect: FC = ({ name, value, options, onChange }) => { + const handleChange = (e: ChangeEvent) => { + const selection = Array.from(e.currentTarget.options) + .filter((option) => option.selected) + .map((option) => option.value); + onChange(name, selectedValues(selection, options)); + }; + const selection = selectedKeys(value, options); + + return ( + + {Object.keys(options).map((key) => ( + + ))} + + ); +}; + +export const SelectControl: FC = (props) => + // eslint-disable-next-line react/destructuring-assignment + props.isMulti ? : ; diff --git a/lib/components/src/controls/options/helpers.ts b/lib/components/src/controls/options/helpers.ts new file mode 100644 index 000000000000..3a7667ade8f3 --- /dev/null +++ b/lib/components/src/controls/options/helpers.ts @@ -0,0 +1,14 @@ +import { OptionsObject } from '../types'; + +export const selectedKey = (value: any, options: OptionsObject) => { + const entry = Object.entries(options).find(([_key, val]) => val === value); + return entry ? entry[0] : undefined; +}; + +export const selectedKeys = (value: any[], options: OptionsObject) => + Object.entries(options) + .filter((entry) => value.includes(entry[1])) + .map((entry) => entry[0]); + +export const selectedValues = (keys: string[], options: OptionsObject) => + keys.map((key) => options[key]); From 9cecd21413911a00f451757d57d9982aa3c2ff07 Mon Sep 17 00:00:00 2001 From: Michael Shilman Date: Wed, 3 Jun 2020 10:56:15 +0800 Subject: [PATCH 2/4] Controls: Fix "no selection" behavior on Select --- lib/components/src/controls/options/Select.tsx | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/lib/components/src/controls/options/Select.tsx b/lib/components/src/controls/options/Select.tsx index bc00311daaab..6e50665d3a48 100644 --- a/lib/components/src/controls/options/Select.tsx +++ b/lib/components/src/controls/options/Select.tsx @@ -12,16 +12,21 @@ const OptionsSelect = styled.select({ type SelectConfig = NormalizedOptionsConfig & { isMulti: boolean }; type SelectProps = ControlProps & SelectConfig; +const NO_SELECTION = 'Select...'; + const SingleSelect: FC = ({ name, value, options, onChange }) => { const handleChange = (e: ChangeEvent) => { onChange(name, options[e.currentTarget.value]); }; - const selection = selectedKey(value, options); + const selection = selectedKey(value, options) || NO_SELECTION; return ( + {Object.keys(options).map((key) => ( - + ))} ); From 44f8ac526b4b7154874fa067341bf62637395ee6 Mon Sep 17 00:00:00 2001 From: Michael Shilman Date: Wed, 3 Jun 2020 22:12:31 +0800 Subject: [PATCH 3/4] Fix deepscan --- lib/components/src/controls/options/Checkbox.tsx | 2 +- lib/components/src/controls/options/Radio.tsx | 4 ++-- lib/components/src/controls/options/Select.tsx | 2 +- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/lib/components/src/controls/options/Checkbox.tsx b/lib/components/src/controls/options/Checkbox.tsx index d03c2a82905a..e922ae949b85 100644 --- a/lib/components/src/controls/options/Checkbox.tsx +++ b/lib/components/src/controls/options/Checkbox.tsx @@ -38,7 +38,7 @@ export const CheckboxControl: FC = ({ isInline, }) => { const initial = selectedKeys(value, options); - const [selected, setSelected] = useState(initial || []); + const [selected, setSelected] = useState(initial); const handleChange = (e: ChangeEvent) => { const option = (e.target as HTMLInputElement).value; diff --git a/lib/components/src/controls/options/Radio.tsx b/lib/components/src/controls/options/Radio.tsx index dbff8a1c1387..1f0febb4f058 100644 --- a/lib/components/src/controls/options/Radio.tsx +++ b/lib/components/src/controls/options/Radio.tsx @@ -1,7 +1,7 @@ -import React, { FC, Validator } from 'react'; +import React, { FC } from 'react'; import { styled } from '@storybook/theming'; import { ControlProps, OptionsSingleSelection, NormalizedOptionsConfig } from '../types'; -import { selectedKey, selectedKeys } from './helpers'; +import { selectedKey } from './helpers'; const RadiosWrapper = styled.div<{ isInline: boolean }>(({ isInline }) => isInline diff --git a/lib/components/src/controls/options/Select.tsx b/lib/components/src/controls/options/Select.tsx index 6e50665d3a48..00000cd5ce61 100644 --- a/lib/components/src/controls/options/Select.tsx +++ b/lib/components/src/controls/options/Select.tsx @@ -44,7 +44,7 @@ const MultiSelect: FC = ({ name, value, options, onChange }) => { return ( {Object.keys(options).map((key) => ( - + ))} ); From 0443717863a68a6387c0a06ce061739f8c064f9c Mon Sep 17 00:00:00 2001 From: Michael Shilman Date: Wed, 3 Jun 2020 22:13:40 +0800 Subject: [PATCH 4/4] React argTypes: Use defaultValue as initial arg in test --- addons/docs/src/frameworks/react/react-argtypes.stories.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/addons/docs/src/frameworks/react/react-argtypes.stories.tsx b/addons/docs/src/frameworks/react/react-argtypes.stories.tsx index 25c7c482529a..cb1d3d6a10fa 100644 --- a/addons/docs/src/frameworks/react/react-argtypes.stories.tsx +++ b/addons/docs/src/frameworks/react/react-argtypes.stories.tsx @@ -18,7 +18,7 @@ const argsTableProps = (component: Component) => { const ArgsStory = ({ component }: any) => { const { rows } = argsTableProps(component); - const initialArgs = mapValues(rows, () => null) as Args; + const initialArgs = mapValues(rows, (argType) => argType.defaultValue) as Args; const [args, setArgs] = useState(initialArgs); return (