ML: Create SelectTree component on top of ReactSelect

This commit is contained in:
Gustav Hansen 2022-04-06 18:57:41 +02:00
parent 8983a69025
commit bf0e321820
7 changed files with 540 additions and 0 deletions

View File

@ -0,0 +1,93 @@
/* eslint-disable react/prop-types */
import React, { useEffect, useState, useMemo } from 'react';
import styled from 'styled-components';
import PropTypes from 'prop-types';
import { components } from 'react-select';
import { Flex } from '@strapi/design-system/Flex';
import { Icon } from '@strapi/design-system/Icon';
import { Typography } from '@strapi/design-system/Typography';
import ChevronUp from '@strapi/icons/ChevronUp';
import ChevronDown from '@strapi/icons/ChevronDown';
import { ReactSelect as Select, pxToRem } from '@strapi/helper-plugin';
import flattenTree from './utils/flattenTree';
const ToggleButton = styled.button`
align-self: flex-end;
margin-left: auto;
`;
const hasParent = option => !option.parent;
const hasParentOrMatchesValue = (option, value) =>
option.value === value || option.parent === value;
const SelectTree = ({ options: defaultOptions, ...props }) => {
const flatDefaultOptions = useMemo(() => flattenTree(defaultOptions), [defaultOptions]);
const toplevelDefaultOptions = useMemo(() => flatDefaultOptions.filter(hasParent), [
flatDefaultOptions,
]);
const [options, setOptions] = useState(toplevelDefaultOptions);
const [openValues, setOpenValues] = useState([]);
useEffect(() => {
if (openValues.length === 0) {
setOptions(toplevelDefaultOptions);
}
openValues.forEach(value => {
const filtered = flatDefaultOptions.filter(
option => hasParentOrMatchesValue(option, value) || hasParent(option)
);
setOptions(filtered);
});
}, [openValues, flatDefaultOptions, toplevelDefaultOptions]);
function handleToggle(e, data) {
e.preventDefault();
e.stopPropagation();
if (openValues.includes(data.value)) {
setOpenValues(prev => prev.filter(prevData => prevData !== data.value));
} else {
setOpenValues(prev => [...prev, data.value]);
}
}
const CustomOption = ({ children, data, ...props }) => {
const hasChildren = data?.children?.length > 0;
return (
<>
<components.Option {...props}>
<Flex alignItems="start">
<Typography textColor="neutral800">
<span style={{ paddingLeft: `${data.depth * 10}px` }}>{children}</span>
</Typography>
{hasChildren && (
<ToggleButton type="button" onClick={event => handleToggle(event, data)}>
<Icon
width={pxToRem(14)}
color="neutral500"
as={openValues.includes(data.value) ? ChevronUp : ChevronDown}
/>
</ToggleButton>
)}
</Flex>
</components.Option>
</>
);
};
return <Select components={{ Option: CustomOption }} options={options} {...props} />;
};
SelectTree.propTypes = {
options: PropTypes.array.isRequired,
};
export default SelectTree;

View File

@ -0,0 +1,3 @@
import SelectTree from './SelectTree';
export default SelectTree;

View File

@ -0,0 +1,50 @@
import React from 'react';
import { render } from '@testing-library/react';
import { IntlProvider } from 'react-intl';
import { ThemeProvider, lightTheme } from '@strapi/design-system';
import SelectTree from '../SelectTree';
const FIXTURE_OPTIONS = [
{
value: 'f-1',
label: 'Folder 1',
},
{
value: 'f-2',
label: 'Folder 2',
children: [
{
value: 'f-2-1',
label: 'Folder 2-1',
},
{
value: 'f-2-2',
label: 'Folder 2-2',
children: [
{
value: 'f-2-2-1',
label: 'Folder 2-2-1',
},
],
},
],
},
];
const ComponentFixture = props => (
<IntlProvider locale="en" messages={{}}>
<ThemeProvider theme={lightTheme}>
<SelectTree {...props} />
</ThemeProvider>
</IntlProvider>
);
describe('SelectTree', () => {
test('renders', () => {
expect(render(<ComponentFixture options={FIXTURE_OPTIONS} />)).toMatchSnapshot();
});
});

View File

@ -0,0 +1,293 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`SelectTree renders 1`] = `
Object {
"asFragment": [Function],
"baseElement": .c3 {
border: 0;
-webkit-clip: rect(0 0 0 0);
clip: rect(0 0 0 0);
height: 1px;
margin: -1px;
overflow: hidden;
padding: 0;
position: absolute;
width: 1px;
}
.c0 {
padding-right: 12px;
}
.c1 {
background: transparent;
border: none;
position: relative;
z-index: 1;
}
.c1 svg {
height: 0.6875rem;
width: 0.6875rem;
}
.c1 svg path {
fill: #666687;
}
.c2 {
display: -webkit-box;
display: -webkit-flex;
display: -ms-flexbox;
display: flex;
background: none;
border: none;
}
.c2 svg {
width: 0.375rem;
}
<body>
<div>
<div
class=" css-npt6n7-container"
>
<span
aria-atomic="false"
aria-live="polite"
aria-relevant="additions text"
class="css-1f43avz-a11yText-A11yText"
/>
<div
class=" css-1sb7bue-control"
>
<div
class=" css-1kj7tu4-ValueContainer"
>
<div
class=" css-930xd4-placeholder"
>
Select...
</div>
<div
class="css-reu10z-Input"
>
<div
class=""
style="display: inline-block;"
>
<input
aria-autocomplete="list"
autocapitalize="none"
autocomplete="off"
autocorrect="off"
id="react-select-2-input"
spellcheck="false"
style="box-sizing: content-box; width: 2px; border: 0px; opacity: 1; outline: 0; padding: 0px;"
tabindex="0"
type="text"
value=""
/>
<div
style="position: absolute; top: 0px; left: 0px; visibility: hidden; height: 0px; overflow: scroll; white-space: pre; font-family: -webkit-small-control; letter-spacing: normal; text-transform: none;"
/>
</div>
</div>
</div>
<div
class=" css-1hb7zxy-IndicatorsContainer"
>
<button
class="c0 c1 c2"
type="button"
>
<svg
fill="none"
height="1em"
viewBox="0 0 14 8"
width="1em"
xmlns="http://www.w3.org/2000/svg"
>
<path
clip-rule="evenodd"
d="M14 .889a.86.86 0 01-.26.625L7.615 7.736A.834.834 0 017 8a.834.834 0 01-.615-.264L.26 1.514A.861.861 0 010 .889c0-.24.087-.45.26-.625A.834.834 0 01.875 0h12.25c.237 0 .442.088.615.264a.86.86 0 01.26.625z"
fill="#32324D"
fill-rule="evenodd"
/>
</svg>
</button>
</div>
</div>
</div>
<div
class="c3"
>
<p
aria-live="polite"
aria-relevant="all"
id="live-region-log"
role="log"
/>
<p
aria-live="polite"
aria-relevant="all"
id="live-region-status"
role="status"
/>
<p
aria-live="assertive"
aria-relevant="all"
id="live-region-alert"
role="alert"
/>
</div>
</div>
</body>,
"container": <div>
<div
class=" css-npt6n7-container"
>
<span
aria-atomic="false"
aria-live="polite"
aria-relevant="additions text"
class="css-1f43avz-a11yText-A11yText"
/>
<div
class=" css-1sb7bue-control"
>
<div
class=" css-1kj7tu4-ValueContainer"
>
<div
class=" css-930xd4-placeholder"
>
Select...
</div>
<div
class="css-reu10z-Input"
>
<div
class=""
style="display: inline-block;"
>
<input
aria-autocomplete="list"
autocapitalize="none"
autocomplete="off"
autocorrect="off"
id="react-select-2-input"
spellcheck="false"
style="box-sizing: content-box; width: 2px; border: 0px; opacity: 1; outline: 0; padding: 0px;"
tabindex="0"
type="text"
value=""
/>
<div
style="position: absolute; top: 0px; left: 0px; visibility: hidden; height: 0px; overflow: scroll; white-space: pre; font-family: -webkit-small-control; letter-spacing: normal; text-transform: none;"
/>
</div>
</div>
</div>
<div
class=" css-1hb7zxy-IndicatorsContainer"
>
<button
class="sc-gVkuDy sc-hJhJFJ sc-lhMiDA hghsOQ iVfetF bAWeRh"
type="button"
>
<svg
fill="none"
height="1em"
viewBox="0 0 14 8"
width="1em"
xmlns="http://www.w3.org/2000/svg"
>
<path
clip-rule="evenodd"
d="M14 .889a.86.86 0 01-.26.625L7.615 7.736A.834.834 0 017 8a.834.834 0 01-.615-.264L.26 1.514A.861.861 0 010 .889c0-.24.087-.45.26-.625A.834.834 0 01.875 0h12.25c.237 0 .442.088.615.264a.86.86 0 01.26.625z"
fill="#32324D"
fill-rule="evenodd"
/>
</svg>
</button>
</div>
</div>
</div>
<div
class="sc-cxpSdN hEuJJy"
>
<p
aria-live="polite"
aria-relevant="all"
id="live-region-log"
role="log"
/>
<p
aria-live="polite"
aria-relevant="all"
id="live-region-status"
role="status"
/>
<p
aria-live="assertive"
aria-relevant="all"
id="live-region-alert"
role="alert"
/>
</div>
</div>,
"debug": [Function],
"findAllByAltText": [Function],
"findAllByDisplayValue": [Function],
"findAllByLabelText": [Function],
"findAllByPlaceholderText": [Function],
"findAllByRole": [Function],
"findAllByTestId": [Function],
"findAllByText": [Function],
"findAllByTitle": [Function],
"findByAltText": [Function],
"findByDisplayValue": [Function],
"findByLabelText": [Function],
"findByPlaceholderText": [Function],
"findByRole": [Function],
"findByTestId": [Function],
"findByText": [Function],
"findByTitle": [Function],
"getAllByAltText": [Function],
"getAllByDisplayValue": [Function],
"getAllByLabelText": [Function],
"getAllByPlaceholderText": [Function],
"getAllByRole": [Function],
"getAllByTestId": [Function],
"getAllByText": [Function],
"getAllByTitle": [Function],
"getByAltText": [Function],
"getByDisplayValue": [Function],
"getByLabelText": [Function],
"getByPlaceholderText": [Function],
"getByRole": [Function],
"getByTestId": [Function],
"getByText": [Function],
"getByTitle": [Function],
"queryAllByAltText": [Function],
"queryAllByDisplayValue": [Function],
"queryAllByLabelText": [Function],
"queryAllByPlaceholderText": [Function],
"queryAllByRole": [Function],
"queryAllByTestId": [Function],
"queryAllByText": [Function],
"queryAllByTitle": [Function],
"queryByAltText": [Function],
"queryByDisplayValue": [Function],
"queryByLabelText": [Function],
"queryByPlaceholderText": [Function],
"queryByRole": [Function],
"queryByTestId": [Function],
"queryByText": [Function],
"queryByTitle": [Function],
"rerender": [Function],
"unmount": [Function],
}
`;

View File

@ -0,0 +1,7 @@
export default function flattenTree(tree, parent, depth = 1) {
return tree.flatMap(item =>
item.children
? [{ ...item, parent: parent?.value, depth }, ...flattenTree(item.children, item, depth + 1)]
: { ...item, depth, parent: parent?.value }
);
}

View File

@ -0,0 +1,58 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`flattenTree flattens the passed tree structure properly 1`] = `
Array [
Object {
"depth": 1,
"label": "Folder 1",
"parent": undefined,
"value": "f-1",
},
Object {
"children": Array [
Object {
"label": "Folder 2-1",
"value": "f-2-1",
},
Object {
"children": Array [
Object {
"label": "Folder 2-2-1",
"value": "f-2-2-1",
},
],
"label": "Folder 2-2",
"value": "f-2-2",
},
],
"depth": 1,
"label": "Folder 2",
"parent": undefined,
"value": "f-2",
},
Object {
"depth": 2,
"label": "Folder 2-1",
"parent": "f-2",
"value": "f-2-1",
},
Object {
"children": Array [
Object {
"label": "Folder 2-2-1",
"value": "f-2-2-1",
},
],
"depth": 2,
"label": "Folder 2-2",
"parent": "f-2",
"value": "f-2-2",
},
Object {
"depth": 3,
"label": "Folder 2-2-1",
"parent": "f-2-2",
"value": "f-2-2-1",
},
]
`;

View File

@ -0,0 +1,36 @@
import flattenTree from '../flattenTree';
const FIXTURE = [
{
value: 'f-1',
label: 'Folder 1',
},
{
value: 'f-2',
label: 'Folder 2',
children: [
{
value: 'f-2-1',
label: 'Folder 2-1',
},
{
value: 'f-2-2',
label: 'Folder 2-2',
children: [
{
value: 'f-2-2-1',
label: 'Folder 2-2-1',
},
],
},
],
},
];
describe('flattenTree', () => {
test('flattens the passed tree structure properly', () => {
expect(flattenTree(FIXTURE)).toMatchSnapshot();
});
});