mirror of
https://github.com/strapi/strapi.git
synced 2025-11-13 16:52:18 +00:00
ML: Create SelectTree component on top of ReactSelect
This commit is contained in:
parent
8983a69025
commit
bf0e321820
@ -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;
|
||||||
@ -0,0 +1,3 @@
|
|||||||
|
import SelectTree from './SelectTree';
|
||||||
|
|
||||||
|
export default SelectTree;
|
||||||
@ -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();
|
||||||
|
});
|
||||||
|
});
|
||||||
@ -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],
|
||||||
|
}
|
||||||
|
`;
|
||||||
@ -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 }
|
||||||
|
);
|
||||||
|
}
|
||||||
@ -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",
|
||||||
|
},
|
||||||
|
]
|
||||||
|
`;
|
||||||
@ -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();
|
||||||
|
});
|
||||||
|
});
|
||||||
Loading…
x
Reference in New Issue
Block a user