mirror of
https://github.com/strapi/strapi.git
synced 2025-11-02 02:44:55 +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