From ec22e4d401a46f5088a14db333c1be81ec5a5daa Mon Sep 17 00:00:00 2001 From: Xiaofan Wu Date: Mon, 4 Sep 2023 10:06:04 +1000 Subject: [PATCH] feat: add new TreePicker --- package-lock.json | 24 ++ package.json | 1 + scripts/generate-types/generateTypes.js | 7 +- src/components/ListPickerPure/index.jsx | 2 +- src/components/RadioGroup/index.jsx | 2 +- src/components/TreePicker/TreePicker.css | 32 ++ src/components/TreePicker/TreePicker.jsx | 38 +++ .../TreePicker/TreePicker.stories.mdx | 5 + .../TreePicker/TreePicker.stories.tsx | 166 +++++------ .../TreePicker/TreePickerContext.jsx | 215 ++++++++++++++ .../TreePicker/TreePickerHeader.css | 18 ++ .../TreePicker/TreePickerHeader.jsx | 59 ++++ src/components/TreePicker/TreePickerNav.css | 3 + src/components/TreePicker/TreePickerNav.jsx | 36 +++ src/components/TreePicker/TreePickerNode.css | 32 ++ src/components/TreePicker/TreePickerNode.jsx | 280 ++++++++++++++++++ .../TreePicker/TreePickerSearch.css | 7 + .../TreePicker/TreePickerSearch.jsx | 77 +++++ src/components/TreePicker/TreePickerTree.css | 27 ++ src/components/TreePicker/TreePickerTree.jsx | 136 +++++++++ src/components/TreePicker/index.d.ts | 236 ++++++--------- src/components/TreePicker/index.js | 11 + .../Grid/index.d.ts | 0 .../Grid/index.jsx | 0 .../Grid/index.spec.jsx | 0 .../Grid/styles.css | 0 .../Nav/index.d.ts | 0 .../Nav/index.jsx | 0 .../Nav/index.spec.jsx | 0 .../Nav/styles.css | 0 .../Node/Expander/index.d.ts | 0 .../Node/Expander/index.jsx | 0 .../Node/Expander/index.spec.jsx | 0 .../Node/index.d.ts | 0 .../Node/index.jsx | 0 .../Node/index.spec.jsx | 0 .../Node/styles.css | 0 .../TreePicker.stories.tsx | 114 +++++++ .../TreePickerSimplePure/index.d.ts | 167 +++++++++++ .../index.jsx | 0 .../index.spec.jsx | 0 .../mocks.js | 0 .../styles.css | 0 src/hooks/index.js | 1 - src/hooks/useCallbackRef.js | 13 + src/hooks/useIsUnmounted.js | 16 + src/index.d.ts | 9 +- src/index.js | 28 +- src/styles/icons/folder-open.svg | 1 + src/styles/icons/folder.svg | 1 + src/styles/icons/plus.svg | 1 + svgo-config.js | 3 + 52 files changed, 1505 insertions(+), 263 deletions(-) create mode 100644 src/components/TreePicker/TreePicker.css create mode 100644 src/components/TreePicker/TreePicker.jsx create mode 100644 src/components/TreePicker/TreePicker.stories.mdx create mode 100644 src/components/TreePicker/TreePickerContext.jsx create mode 100644 src/components/TreePicker/TreePickerHeader.css create mode 100644 src/components/TreePicker/TreePickerHeader.jsx create mode 100644 src/components/TreePicker/TreePickerNav.css create mode 100644 src/components/TreePicker/TreePickerNav.jsx create mode 100644 src/components/TreePicker/TreePickerNode.css create mode 100644 src/components/TreePicker/TreePickerNode.jsx create mode 100644 src/components/TreePicker/TreePickerSearch.css create mode 100644 src/components/TreePicker/TreePickerSearch.jsx create mode 100644 src/components/TreePicker/TreePickerTree.css create mode 100644 src/components/TreePicker/TreePickerTree.jsx create mode 100644 src/components/TreePicker/index.js rename src/components/{TreePicker => TreePickerSimplePure}/Grid/index.d.ts (100%) rename src/components/{TreePicker => TreePickerSimplePure}/Grid/index.jsx (100%) rename src/components/{TreePicker => TreePickerSimplePure}/Grid/index.spec.jsx (100%) rename src/components/{TreePicker => TreePickerSimplePure}/Grid/styles.css (100%) rename src/components/{TreePicker => TreePickerSimplePure}/Nav/index.d.ts (100%) rename src/components/{TreePicker => TreePickerSimplePure}/Nav/index.jsx (100%) rename src/components/{TreePicker => TreePickerSimplePure}/Nav/index.spec.jsx (100%) rename src/components/{TreePicker => TreePickerSimplePure}/Nav/styles.css (100%) rename src/components/{TreePicker => TreePickerSimplePure}/Node/Expander/index.d.ts (100%) rename src/components/{TreePicker => TreePickerSimplePure}/Node/Expander/index.jsx (100%) rename src/components/{TreePicker => TreePickerSimplePure}/Node/Expander/index.spec.jsx (100%) rename src/components/{TreePicker => TreePickerSimplePure}/Node/index.d.ts (100%) rename src/components/{TreePicker => TreePickerSimplePure}/Node/index.jsx (100%) rename src/components/{TreePicker => TreePickerSimplePure}/Node/index.spec.jsx (100%) rename src/components/{TreePicker => TreePickerSimplePure}/Node/styles.css (100%) create mode 100644 src/components/TreePickerSimplePure/TreePicker.stories.tsx create mode 100644 src/components/TreePickerSimplePure/index.d.ts rename src/components/{TreePicker => TreePickerSimplePure}/index.jsx (100%) rename src/components/{TreePicker => TreePickerSimplePure}/index.spec.jsx (100%) rename src/components/{TreePicker => TreePickerSimplePure}/mocks.js (100%) rename src/components/{TreePicker => TreePickerSimplePure}/styles.css (100%) delete mode 100644 src/hooks/index.js create mode 100644 src/hooks/useCallbackRef.js create mode 100644 src/hooks/useIsUnmounted.js create mode 100644 src/styles/icons/folder-open.svg create mode 100644 src/styles/icons/folder.svg create mode 100644 src/styles/icons/plus.svg diff --git a/package-lock.json b/package-lock.json index 8d6d7a90a..8b20f6d28 100644 --- a/package-lock.json +++ b/package-lock.json @@ -24,6 +24,7 @@ "react-select": "^5.7.3", "react-slick": "^0.29.0", "react-toastify": "^9.1.3", + "ryze": "^0.1.1", "slick-carousel": "^1.8.1" }, "devDependencies": { @@ -26907,6 +26908,21 @@ "tslib": "^2.1.0" } }, + "node_modules/ryze": { + "version": "0.1.1", + "resolved": "https://registry.npmjs.org/ryze/-/ryze-0.1.1.tgz", + "integrity": "sha512-e6wgwf1U+Ho5YqPSRtSH3jwChWhBGFxgcjevLfVsSnjoYtETcU7QRXfYPtcbJ4+J6qbEP5TXZO7NQBu/97z/Rg==", + "dependencies": { + "memoize-one": "^6.0.0" + }, + "engines": { + "node": "^v18.17.0" + }, + "peerDependencies": { + "react": "^18.2.0", + "react-dom": "^18.2.0" + } + }, "node_modules/safe-buffer": { "version": "5.1.2", "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz", @@ -50513,6 +50529,14 @@ "tslib": "^2.1.0" } }, + "ryze": { + "version": "0.1.1", + "resolved": "https://registry.npmjs.org/ryze/-/ryze-0.1.1.tgz", + "integrity": "sha512-e6wgwf1U+Ho5YqPSRtSH3jwChWhBGFxgcjevLfVsSnjoYtETcU7QRXfYPtcbJ4+J6qbEP5TXZO7NQBu/97z/Rg==", + "requires": { + "memoize-one": "^6.0.0" + } + }, "safe-buffer": { "version": "5.1.2", "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz", diff --git a/package.json b/package.json index 9f8d31f3a..590f64716 100644 --- a/package.json +++ b/package.json @@ -180,6 +180,7 @@ "react-select": "^5.7.3", "react-slick": "^0.29.0", "react-toastify": "^9.1.3", + "ryze": "^0.1.1", "slick-carousel": "^1.8.1" }, "peerDependencies": { diff --git a/scripts/generate-types/generateTypes.js b/scripts/generate-types/generateTypes.js index 5840000ec..dea699af0 100644 --- a/scripts/generate-types/generateTypes.js +++ b/scripts/generate-types/generateTypes.js @@ -73,11 +73,11 @@ async function generateTypeDefs() { .join(path.posix.sep); // get all the jsx components using glob. Ignore spec & test files. - const allComponentFiles = glob.sync(globString); + const allComponentFiles = glob.sync(globString).filter((entry) => !entry.includes('/TreePicker/')); if (!allComponentFiles || allComponentFiles.length === 0) { console.log( - chalk.red(`No component files were found for ${options.only} + chalk.red(`No component files were found for ${options.only} ${globString}`) ); return; @@ -138,7 +138,7 @@ async function generateTypeDefs() { await Promise.all( parsed.map(async (code, i) => { const result = await generateFromSource(null, code, { - babylonPlugins: ['exportDefaultFrom', 'transformImports'], + babylonPlugins: ['exportDefaultFrom', 'transformImports', 'nullishCoalescingOperator'], }); const component = allComponents[i]; @@ -164,7 +164,6 @@ async function generateTypeDefs() { console.log( chalk.cyan( `Generated type defs for ${allComponents.map(({ componentName }) => chalk.bold(componentName)).join(', ')} - ` ) ); diff --git a/src/components/ListPickerPure/index.jsx b/src/components/ListPickerPure/index.jsx index 6275cdb12..6beb2a550 100644 --- a/src/components/ListPickerPure/index.jsx +++ b/src/components/ListPickerPure/index.jsx @@ -7,7 +7,7 @@ import Empty from '../Empty'; import Grid from '../Grid'; import GridRow from '../Grid/Row'; import GridCell from '../Grid/Cell'; -import { useArrowFocus } from '../../hooks'; +import useArrowFocus from '../../hooks/useArrowFocus'; import './styles.css'; const ListPickerPure = ({ diff --git a/src/components/RadioGroup/index.jsx b/src/components/RadioGroup/index.jsx index c47479da5..379fe41c8 100644 --- a/src/components/RadioGroup/index.jsx +++ b/src/components/RadioGroup/index.jsx @@ -1,7 +1,7 @@ import React from 'react'; import PropTypes from 'prop-types'; import classnames from 'classnames'; -import { useArrowFocus } from '../../hooks'; +import useArrowFocus from '../../hooks/useArrowFocus'; import { expandDts } from '../../utils'; import invariant from '../../invariant'; import '../RadioGroup/style.css'; diff --git a/src/components/TreePicker/TreePicker.css b/src/components/TreePicker/TreePicker.css new file mode 100644 index 000000000..0a48f491c --- /dev/null +++ b/src/components/TreePicker/TreePicker.css @@ -0,0 +1,32 @@ +@import url('../../styles/variable.css'); + +.aui--tree-picker { + & .aui--tree-picker-row + .aui--tree-picker-tree { + border-top: 1px solid $color-border-base; + } +} + +.aui--tree-picker-section { + margin-top: 16px; + margin-bottom: 16px; +} + +.aui--tree-picker-row { + padding: 0 2px; + min-height: 36px; + display: flex; + align-items: center; + position: relative; + gap: 12px; + border-top: 1px solid $color-border-base; + + & .aui--button.aui-icon { + width: 24px; + height: 24px; + min-height: 24px; + } +} + +.aui--tree-picker-row-content { + flex: 1; +} diff --git a/src/components/TreePicker/TreePicker.jsx b/src/components/TreePicker/TreePicker.jsx new file mode 100644 index 000000000..7399e967f --- /dev/null +++ b/src/components/TreePicker/TreePicker.jsx @@ -0,0 +1,38 @@ +import React from 'react'; +import PropTypes from 'prop-types'; +import cc from 'classnames'; +import { TreePickerProvider } from './TreePickerContext'; +import TreePickerTree from './TreePickerTree'; +import TreePickerHeader from './TreePickerHeader'; +import TreePickerNode from './TreePickerNode'; +import TreePickerNav from './TreePickerNav'; +import TreePickerSearch from './TreePickerSearch'; + +import './TreePicker.css'; + +const TreePicker = ({ children, renderNode, className }) => { + const renderNodeWithKey = React.useCallback( + (node, index) => {renderNode(node, index)}, + [renderNode] + ); + + return ( + +
{children}
+
+ ); +}; + +TreePicker.propTypes = { + children: PropTypes.node.isRequired, + renderNode: PropTypes.func.isRequired, + className: PropTypes.string, +}; + +TreePicker.Tree = TreePickerTree; +TreePicker.Header = TreePickerHeader; +TreePicker.Node = TreePickerNode; +TreePicker.Nav = TreePickerNav; +TreePicker.Search = TreePickerSearch; + +export default TreePicker; diff --git a/src/components/TreePicker/TreePicker.stories.mdx b/src/components/TreePicker/TreePicker.stories.mdx new file mode 100644 index 000000000..b11dc82f7 --- /dev/null +++ b/src/components/TreePicker/TreePicker.stories.mdx @@ -0,0 +1,5 @@ +import { Meta } from '@storybook/blocks'; + + + +ok diff --git a/src/components/TreePicker/TreePicker.stories.tsx b/src/components/TreePicker/TreePicker.stories.tsx index da949dcec..a59b5ee96 100644 --- a/src/components/TreePicker/TreePicker.stories.tsx +++ b/src/components/TreePicker/TreePicker.stories.tsx @@ -1,114 +1,88 @@ import React from 'react'; +import axios from 'axios'; import type { Meta, StoryObj } from '@storybook/react'; -import _ from 'lodash'; - import TreePicker from './index'; -import SvgSymbol from '../SvgSymbol'; -import Search from '../Search'; const meta = { - title: 'Pending Review/TreePicker', + title: 'Components/TreePicker', component: TreePicker, tags: ['autodocs'], } satisfies Meta; export default meta; - type Story = StoryObj; -const DefaultComponent = () => { - const [selectedSearchValue, setSelectedSearchValue] = React.useState(''); - const [selectedNodes, setSelectedNodes] = React.useState([]); - const [pickerSearchValue, setPickerSearchValue] = React.useState(''); - const subTree = [ - { - id: '0', - label: 'Northern Territory', - path: [{ id: '10', label: 'Australia' }], - type: 'Territory', - }, - { - id: '1', - label: 'Australian Capital Territory', - path: [{ id: '10', label: 'Australia' }], - type: 'Territory', - }, - { - id: '2', - label: 'Victoria', - path: [{ id: '10', label: 'Australia' }], - type: 'State', - }, - ]; - - const getSelectedNodes = () => { - if (_.isEmpty(selectedSearchValue)) return selectedNodes; - - return _.filter(selectedNodes, ({ label }) => _.includes(label.toLowerCase(), selectedSearchValue.toLowerCase())); - }; - - const getSubtree = () => { - let treePickerPureSubtree = []; - - // filter out nodes that do not contain search string - if (!_.isEmpty(pickerSearchValue)) { - treePickerPureSubtree = _.filter(subTree, ({ label }) => - _.includes(label.toLowerCase(), pickerSearchValue.toLowerCase()) - ); - } +const NodeRender = ({ node }) => ( + + {node.label} + {node.type === 'comment' ? null : ( + { + if (node.type === 'user') { + const res = await axios.get(`https://dummyjson.com/users/${node.id}/posts?limit=5`); + return res.data.posts.map((entry) => ({ + ...entry, + label: entry.title, + type: 'post', + })); + } + const res = await axios.get(`https://dummyjson.com/posts/${node.id}/comments?limit=5`); - // filter out nodes that do not contain the selected search string - // however keep the nodes that are not selected but do not contain the selected search string - if (!_.isEmpty(selectedSearchValue)) { - treePickerPureSubtree = _.filter(treePickerPureSubtree, ({ id, label }) => { - if (_.find(selectedNodes, { id })) { - return _.includes(label.toLowerCase(), selectedSearchValue.toLowerCase()); - } + return res.data.comments.map((entry) => ({ + ...entry, + label: entry.body, + type: 'comment', + })); + }} + /> + )} + { + // eslint-disable-next-line no-console + console.log(`Added ${node.id}: ${node.label}`); + }} + /> + +); - return true; - }); - } +export const Default: Story = { + args: { + renderNode: (node) => , - return treePickerPureSubtree; - }; + children: ( + <> + + Name + { + const res = await axios.get(`https://dummyjson.com/posts/search?q=${searchText}`); - return ( -
- - } - emptySelectedListText={ -
- Choose items of interest -
- } - initialStateNode={ -
- Start by searching for items -
- } - searchValue={pickerSearchValue} - onChange={setPickerSearchValue} - searchOnClear={() => setPickerSearchValue('')} - includeNode={(node) => setSelectedNodes(_.concat([], selectedNodes, node))} - removeNode={(node) => setSelectedNodes(_.reject(selectedNodes, { id: node.id }))} - additionalClassNames={pickerSearchValue ? undefined : ['background-highlighted', 'test-class']} - selectedTopSearch={ -
- -
- } - /> + return res.data.posts.map((post) => ({ + ...post, + label: post.title, + type: 'post', + })); + }} + /> + { + const res = await axios.get('https://dummyjson.com/users?limit=5'); + return res.data.users.map((entry) => ({ + ...entry, + label: entry.firstName + ' ' + entry.lastName, + type: 'user', + })); + }} + /> + + ), + }, + render: (args) => ( +
+
- ); -}; - -export const Default: Story = { - args: {}, - render: () => DefaultComponent(), + ), }; diff --git a/src/components/TreePicker/TreePickerContext.jsx b/src/components/TreePicker/TreePickerContext.jsx new file mode 100644 index 000000000..00968de0d --- /dev/null +++ b/src/components/TreePicker/TreePickerContext.jsx @@ -0,0 +1,215 @@ +import React from 'react'; +import PropTypes from 'prop-types'; +import { createStoreContext } from 'ryze'; +import _ from 'lodash'; + +const { StoreProvider, useSlice, useStore } = createStoreContext(); +const RenderNodeCtx = React.createContext(); +export const HiddenByIdCtx = React.createContext({}); + +export const useRenderNode = () => React.useContext(RenderNodeCtx); +export const useHiddenById = () => React.useContext(HiddenByIdCtx); + +export const TreePickerProvider = ({ children, renderNode }) => { + return ( + + {children} + + ); +}; + +TreePickerProvider.propTypes = { + children: PropTypes.node.isRequired, + renderNode: PropTypes.func.isRequired, +}; + +export const useTreePickerSlice = useSlice; + +const getCurrentNode = (state) => state.paths[state.paths.length - 1]; +export const useTreePickerCurrentNode = () => useTreePickerSlice(getCurrentNode); +export const useTreePickerPaths = () => useTreePickerSlice('paths'); + +const getNodesForRendering = (state) => (!!state.search ? state.searchNodes : state.nodes); +export const useTreePickerNodes = () => useTreePickerSlice(getNodesForRendering); + +export const useTreePickerSearch = () => useTreePickerSlice('search'); + +export const useTreePickerGetState = () => { + const store = useStore(); + return store.getState; +}; + +export const useInternalActions = () => { + const { setState } = useStore(); + // breadcrumb navigate back to a node + const backTo = React.useCallback( + (nodeId) => { + setState((prev) => { + // nodeId: null -> go back to root + // nodeId: undefined -> go back 1 level + // nodeId: string -> go back to the node + const newPaths = + nodeId === null + ? [] + : // if nodeId === undefined, index = -1, will end up slice without last item, if nodeId has a valid item, then index is positive + prev.paths.slice( + 0, + prev.paths.findIndex((entry) => entry.id === nodeId) + (nodeId === undefined ? 0 : 1) + ); + + const nodes = prev.treeMapRef.current.get(nodeId || newPaths[newPaths.length - 1]?.id || ''); + + if (!nodes) { + console.error('[TreePicker] Unexpected node id: ', nodeId); + return prev; + } + + return { + ...prev, + paths: newPaths, + nodes, + search: '', + }; + }); + }, + [setState] + ); + + // expand new node & init root + const goTo = React.useCallback( + (nodes, node) => { + setState((prev) => { + if (!node) { + // init root + prev.treeMapRef.current.set('', nodes); + + return { + ...prev, + nodes: nodes, + search: '', + }; + } + + const currentNode = prev.paths[prev.paths.length - 1]; + // when node is the same as currentNode, do no change paths + const newPaths = currentNode && currentNode.id === node.id ? prev.paths : [...prev.paths, node]; + + prev.treeMapRef.current.set(node.id, nodes); + + return { + ...prev, + paths: newPaths, + nodes: nodes, + search: '', + }; + }); + }, + [setState] + ); + + const searchTo = React.useCallback( + (nodes, search) => { + setState((prev) => { + if (search) { + return { + ...prev, + search: search, + searchNodes: nodes, + }; + } + + // clear search + // const node = prev.paths[prev.paths.length - 1]; + // if node is falsy, assume it is searching in root + // const newNodes = prev.treeMapRef.current.get(node ? node.id : ''); + return { + ...prev, + search: '', + searchNodes: [], + }; + }); + }, + [setState] + ); + + const addAddable = React.useCallback( + (nodeId, addNode) => { + setState((prev) => { + if (!nodeId || !addNode) return prev; + + return prev.addable[nodeId] + ? prev + : { + ...prev, + addable: { ...prev.addable, [nodeId]: addNode }, + }; + }); + }, + [setState] + ); + + const removeAddable = React.useCallback( + (nodeId) => { + setState((prev) => { + if (!nodeId) return prev; + + return !prev.addable[nodeId] + ? prev + : { + ...prev, + addable: _.pickBy(prev.addable, (_value, id) => id !== nodeId), + }; + }); + }, + [setState] + ); + + const setIsResolvingRoot = React.useCallback( + (newValue) => { + setState((prev) => ({ + ...prev, + isResolvingRoot: newValue, + })); + }, + [setState] + ); + + const setExpandingNodeId = React.useCallback( + (nodeId, expanding) => { + setState((prev) => ({ + ...prev, + expandingNodeId: expanding ? nodeId : prev.expandingNodeId === nodeId ? '' : prev.expandingNodeId, + })); + }, + [setState] + ); + + return { + backTo, + goTo, + searchTo, + addAddable, + removeAddable, + setIsResolvingRoot, + setExpandingNodeId, + }; +}; + +export const useTreePickerActions = () => { + const { backTo, goTo, searchTo } = useInternalActions(); + return { backTo, goTo, searchTo }; +}; diff --git a/src/components/TreePicker/TreePickerHeader.css b/src/components/TreePicker/TreePickerHeader.css new file mode 100644 index 000000000..44aefdd3a --- /dev/null +++ b/src/components/TreePicker/TreePickerHeader.css @@ -0,0 +1,18 @@ +@import url('../../styles/variable.css'); + +.aui--tree-picker-header { + color: $color-text-description; +} + +.aui--tree-picker-header-action-holder { + /* 24 + 12 + 24 */ + width: 60px; +} + +.aui--tree-picker-header-add-all.aui--button.aui-link { + width: 60px; + + & .aui-children-container { + font-weight: $font-weight-bold; + } +} diff --git a/src/components/TreePicker/TreePickerHeader.jsx b/src/components/TreePicker/TreePickerHeader.jsx new file mode 100644 index 000000000..96586376d --- /dev/null +++ b/src/components/TreePicker/TreePickerHeader.jsx @@ -0,0 +1,59 @@ +import React from 'react'; +import PropTypes from 'prop-types'; +import cc from 'classnames'; +import _ from 'lodash'; +import Button from '../Button'; +import { useTreePickerGetState, useTreePickerSlice } from './TreePickerContext'; + +import './TreePickerHeader.css'; + +const getNoAddable = (state) => _.isEmpty(state.addable); + +const HeaderAddAll = () => { + const noAddable = useTreePickerSlice(getNoAddable); + const getTreeState = useTreePickerGetState(); + + return noAddable ? ( +
+ ) : ( + + ); +}; + +const getCurrentNodeHeader = (state) => { + const currentNode = state.paths[state.paths.length - 1]; + return currentNode?.header; +}; + +const TreePickerHeader = ({ children, className, label }) => { + const header = useTreePickerSlice(getCurrentNodeHeader); + + const finalLabel = !_.isUndefined(header) ? header : label; + + return !finalLabel ? null : ( +
+
{finalLabel}
+ {children ?? } +
+ ); +}; + +TreePickerHeader.propTypes = { + label: PropTypes.node, + children: PropTypes.node, + className: PropTypes.string, +}; + +export default TreePickerHeader; diff --git a/src/components/TreePicker/TreePickerNav.css b/src/components/TreePicker/TreePickerNav.css new file mode 100644 index 000000000..d9a283012 --- /dev/null +++ b/src/components/TreePicker/TreePickerNav.css @@ -0,0 +1,3 @@ +.aui--tree-picker-nav { + margin-left: 2px; +} diff --git a/src/components/TreePicker/TreePickerNav.jsx b/src/components/TreePicker/TreePickerNav.jsx new file mode 100644 index 000000000..9cb182f97 --- /dev/null +++ b/src/components/TreePicker/TreePickerNav.jsx @@ -0,0 +1,36 @@ +import React from 'react'; +import PropTypes from 'prop-types'; +import cx from 'classnames'; +import Breadcrumb from '../Breadcrumb'; +import { useInternalActions, useTreePickerPaths } from './TreePickerContext'; + +import './TreePickerNav.css'; + +const TreePickerNav = ({ rootLabel = 'All', className, onNavTo }) => { + const paths = useTreePickerPaths(); + const { backTo } = useInternalActions(); + + const rootNode = { id: '__all__', label: rootLabel }; + + return paths.length === 0 ? null : ( + { + const newPath = pathId === rootNode.id ? null : pathId; + backTo(newPath); + onNavTo?.(newPath); + }} + /> + ); +}; + +TreePickerNav.propTypes = { + rootLabel: PropTypes.string, + className: PropTypes.string, + onNavTo: PropTypes.func, +}; + +export default TreePickerNav; diff --git a/src/components/TreePicker/TreePickerNode.css b/src/components/TreePicker/TreePickerNode.css new file mode 100644 index 000000000..32d89038a --- /dev/null +++ b/src/components/TreePicker/TreePickerNode.css @@ -0,0 +1,32 @@ +@import url('../../styles/variable.css'); + +.aui--tree-picker-node-expand { + margin: 0; + + &.aui--button.aui-borderless:hover { + border-color: transparent; + } +} + +.aui--tree-picker-node-add { + margin: 0; +} + +.aui--tree-picker-node-svg { + width: 16px; + height: 16px; +} + +.aui--tree-picker-node-branch { + margin-left: 24px; + border-top: 1px solid $color-border-base; + border-left: 1px solid $color-border-base; + + & .aui--tree-picker-row { + padding-left: 12px; + + &:last-child { + border-bottom: 0; + } + } +} diff --git a/src/components/TreePicker/TreePickerNode.jsx b/src/components/TreePicker/TreePickerNode.jsx new file mode 100644 index 000000000..c0337e660 --- /dev/null +++ b/src/components/TreePicker/TreePickerNode.jsx @@ -0,0 +1,280 @@ +import React from 'react'; +import PropTypes from 'prop-types'; +import cx from 'classnames'; +import _ from 'lodash'; +import useIsUnmounted from '../../hooks/useIsUnmounted'; +import useCallbackRef from '../../hooks/useCallbackRef'; +import PlusIcon from '../../styles/icons/plus.svg'; +import FolderOpenIcon from '../../styles/icons/folder-open.svg'; +import FolderIcon from '../../styles/icons/folder.svg'; +import Button from '../Button'; +import Skeleton from '../Skeleton'; +import { + useTreePickerGetState, + useRenderNode, + useInternalActions, + useHiddenById, + useTreePickerSlice, +} from './TreePickerContext'; + +import './TreePickerNode.css'; + +const TreePickerNodeContext = React.createContext({ level: 0 }); + +export const useTreePickerNode = () => React.useContext(TreePickerNodeContext); + +const TreePickerNode = ({ className, children, node }) => { + const { goTo } = useInternalActions(); + const [state, setState] = React.useState(() => ({ expanded: false, subNodes: [] })); + + const { level } = useTreePickerNode(); + + const inlineExpand = React.useCallback( + (nodes) => { + setState((prev) => { + if (!prev.expanded && nodes) { + return { expanded: true, subNodes: nodes }; + } + return { expanded: false, subNodes: [] }; + }); + }, + [setState] + ); + + const fullExpand = React.useCallback( + (nodes) => { + goTo(nodes, node); + }, + [goTo, node] + ); + + const value = React.useMemo( + () => ({ + level: level + 1, + node, + expanded: state.expanded, + inlineExpand, + fullExpand, + }), + [level, node, state.expanded, inlineExpand, fullExpand] + ); + + return ( + +
{children}
+ {state.subNodes.length === 0 ? null : } +
+ ); +}; +TreePickerNode.propTypes = { + className: PropTypes.string, + children: PropTypes.node.isRequired, + node: PropTypes.shape({ + id: PropTypes.oneOfType([PropTypes.string, PropTypes.number]).isRequired, + label: PropTypes.string.isRequired, + }).isRequired, +}; + +const TreePickerNodeBranch = ({ nodes }) => { + const renderNode = useRenderNode(); + const { level } = useTreePickerNode(); + const byId = useHiddenById(); + + const visibleNodes = nodes.filter(({ id }) => !byId[id]); + + return visibleNodes.length === 0 ? null : ( +
+ {visibleNodes.map(renderNode)} +
+ ); +}; + +const TreePickerNodeContent = ({ children, className, ...rest }) => { + return ( +
+ {children} +
+ ); +}; +TreePickerNodeContent.propTypes = { + children: PropTypes.node.isRequired, + className: PropTypes.string, +}; + +const getReverseIcon = (current) => + ({ + 'folder-open': 'folder', + folder: 'folder-open', + }[current]); + +const IconComponents = { + 'folder-open': FolderOpenIcon, + folder: FolderIcon, +}; + +const getHasSearch = (state) => !!state.search; + +const TreePickerNodeExpand = ({ + className, + inline, + onClick, + disabled, + onMouseEnter, + onMouseLeave, + resolveNodes, + ...rest +}) => { + const [isHover, setIsHover] = React.useState(false); + const [isLoading, setIsLoading] = React.useState(false); + const { setExpandingNodeId } = useInternalActions(); + const expandableRef = useTreePickerSlice('expandableRef'); + const { node, expanded, inlineExpand, fullExpand } = useTreePickerNode(); + const isUnmounted = useIsUnmounted(); + const getTreeState = useTreePickerGetState(); + const hasSearch = useTreePickerSlice(getHasSearch); + + const iconName = expanded ? 'folder-open' : 'folder'; + const ExpandIcon = IconComponents[isHover ? getReverseIcon(iconName) : iconName]; + + const propExpand = inline ? inlineExpand : fullExpand; + const defaultExpand = hasSearch ? inlineExpand : fullExpand; + const shouldDefaultExpand = _.isNil(inline); + + const handleExpand = async () => { + setExpandingNodeId(node.id, true); + const expandFunction = shouldDefaultExpand ? defaultExpand : propExpand; + + setIsLoading(true); + try { + const nodes = await resolveNodes(); + + // ignore falsy nodes + // or when user click 2 different expands, ignore the first click + // or if node is unmounted + if (!nodes || getTreeState().expandingNodeId !== node.id || isUnmounted()) return; + + expandFunction(nodes); + } catch (error) { + console.error('[TreePickerNodeExpand]', error); + } finally { + setIsLoading(false); + setExpandingNodeId(node.id, false); + } + }; + + // record the current expand node to ctx ref + React.useLayoutEffect(() => { + if (!disabled) { + expandableRef.current[node.id] = handleExpand; + return; + } + delete expandableRef.current[node.id]; + }); + + // when using default expand, on search reset it should go back to collapsed + React.useEffect(() => { + if (!hasSearch && shouldDefaultExpand) { + inlineExpand(); + } + }, [hasSearch, shouldDefaultExpand, inlineExpand]); + + return ( + + ); + return ( +
+
{title}
+ {search ? ( + + ) : ( + backAction + )} +
+ ); +}; +TreePickerTreeEmptyState.propTypes = { + title: PropTypes.node, +}; + +const defaultPlaceholder = ; +const defaultEmptyState = ; + +const TreePickerTree = ({ + className, + resolveRootNodes, + hiddenNodeIds, + placeholder = defaultPlaceholder, + emptyState = defaultEmptyState, +}) => { + const isInRoot = useTreePickerSlice(getIsInRoot); + const nodes = useTreePickerNodes(); + const renderNode = useRenderNode(); + const { goTo, setIsResolvingRoot } = useInternalActions(); + const isResolvingRoot = useTreePickerSlice('isResolvingRoot'); + const isUnmounted = useIsUnmounted(); + + React.useEffect(() => { + let cancelled = false; + const resolve = async () => { + if (!isInRoot) return; + + setIsResolvingRoot(true); + try { + const rootNodes = await resolveRootNodes(); + + // ignore falsy nodes + // or if the tree itself is unmounted + // or if the resolveRootNodes func has changed + if (!rootNodes || isUnmounted() || cancelled) return; + + goTo(rootNodes); + } catch (error) { + console.error('[TreePickerTree]', error); + } finally { + if (cancelled) return; + setIsResolvingRoot(false); + } + }; + resolve(); + + return () => { + cancelled = true; + }; + }, [goTo, setIsResolvingRoot, isUnmounted, resolveRootNodes, isInRoot]); + + const byId = React.useMemo(() => _.keyBy(hiddenNodeIds), [hiddenNodeIds]); + const visibleNodes = nodes.filter(({ id }) => !byId[id]); + + return ( + +
+ {isResolvingRoot ? placeholder : visibleNodes.length === 0 ? emptyState : visibleNodes.map(renderNode)} +
+
+ ); +}; + +TreePickerTree.propTypes = { + className: PropTypes.string, + hiddenNodeIds: PropTypes.array, + resolveRootNodes: PropTypes.func.isRequired, + placeholder: PropTypes.node, + emptyState: PropTypes.node, +}; + +TreePickerTree.Placeholder = TreePickerTreePlaceholder; +TreePickerTree.EmptyState = TreePickerTreeEmptyState; + +export default TreePickerTree; diff --git a/src/components/TreePicker/index.d.ts b/src/components/TreePicker/index.d.ts index bd2f4b623..2a703dd64 100644 --- a/src/components/TreePicker/index.d.ts +++ b/src/components/TreePicker/index.d.ts @@ -1,167 +1,99 @@ import * as React from 'react'; -export interface TreePickerSimplePureBreadcrumbNodes { - id?: any; - label: string; +export interface TreePickerNodeContentProps { + className?: string; + children: React.ReactNode; + onIncludeAll?: (...args: any[]) => any; } -export interface TreePickerSimplePureSelectedNodes { - id?: any; - label: string; - isExpandable?: boolean; - path?: { - id?: any; - label: string; - }[]; - ancestors?: { - id?: any; - label: string; - }[]; - type: string; - value?: number; - accent?: 'warning' | 'success' | 'info' | 'error'; +declare const TreePickerNodeContent: React.FC; + +export interface TreePickerNodeExpandProps { + className?: string; + inline?: boolean; + resolveNodes?: (...args: any[]) => any; + onClick?: (...args: any[]) => any; + disabled?: boolean; + onMouseEnter?: (...args: any[]) => any; + onMouseLeave?: (...args: any[]) => any; } -export interface TreePickerSimplePureSubtree { - id?: any; - label: string; - isExpandable?: boolean; - path?: { - id?: any; - label: string; - }[]; - ancestors?: { - id?: any; - label: string; - }[]; - type: string; - value?: number; - accent?: 'warning' | 'success' | 'info' | 'error'; +declare const TreePickerNodeExpand: React.FC; + +export interface TreePickerNodeAddProps { + onAdd: (...args: any[]) => any; + onClick?: (...args: any[]) => any; + disabled?: boolean; + className?: string; } -export interface TreePickerSimplePureBreadcrumbRootNode { - id?: any; +declare const TreePickerNodeAdd: React.FC; + +declare const TreePickerNodePlaceholder: React.FC; + +export interface TreePickerNodeNode { + id: string | number; label: string; } -export interface TreePickerSimplePureProps { - /** - * Class Names for SplitPane component - */ - additionalClassNames?: string[]; - /** - * Returns node id. This prop is not required, but an empty array is not allowed. At least one element is required in the array. - */ - breadcrumbNodes?: TreePickerSimplePureBreadcrumbNodes[]; - /** - * This propType creates a list of breadcrumb node - */ - breadcrumbOnClick?: (...args: any[]) => any; - /** - * Interval time on search - */ - debounceInterval?: number; - /** - * Disables treepicker including search bar - */ +export interface TreePickerNodeProps { + node: TreePickerNodeNode; + children: React.ReactNode; + className?: string; +} + +declare const TreePickerNode: React.FC & { + Content: typeof TreePickerNodeContent; + Expand: typeof TreePickerNodeExpand; + Add: typeof TreePickerNodeAdd; + Placeholder: typeof TreePickerNodePlaceholder; +}; + +export interface TreePickerNavProps { + rootLabel?: string; + className?: string; + onNavTo?: (...args: any[]) => any; +} + +declare const TreePickerNav: React.FC; + +export interface TreePickerHeaderProps { + children?: React.ReactNode; + className?: string; +} + +declare const TreePickerHeader: React.FC; + +export interface TreePickerSearchProps { + resolveNodes: (...args: any[]) => any; + className?: string; disabled?: boolean; - /** - * Disables treepicker's grid item - */ - disableInclude?: boolean; - /** - * The svg symbol used when there will be no item on both left or right Grid - */ - emptySvgSymbol?: React.ReactNode; - /** - * The svg symbol used when there will be no item on right Grid (Selected list) - */ - emptySelectedListSvgSymbol?: React.ReactNode; - /** - * Displays this text when there will be no item on left Grid. Prefer type 'string', but rich text can be used here - */ - emptyText?: React.ReactNode; - /** - * Displays this text when there will be no item on right Grid(Selected list). Prefer type 'string', but rich text can be used here. - */ - emptySelectedListText?: React.ReactNode; - /** - * Triggers when clicking any item in the left Grid - */ - expandNode?: (...args: any[]) => any; - /** - * This function use to transform keys of the list item in the left Grid - */ - groupFormatter?: (...args: any[]) => any; - /** - * Hides the empty icon on right Grid (Selected list). Given emptySvgSymbol and hideIcon together, the empty symbol will be only displayed on the left grid. - */ - hideIcon?: boolean; - /** - * Click event on '+' button of each list Item - */ - includeNode?: (...args: any[]) => any; - /** - * Same as emptyText - */ - initialStateNode?: React.ReactNode; - /** - * Same as emptySymbol - */ - initialStateSymbol?: React.ReactNode; - /** - * Uses for specific className - */ - itemType?: string; - isLoading?: boolean; - /** - * Uses for rendering custom node - */ - nodeRenderer?: (...args: any[]) => any; - removeNode?: (...args: any[]) => any; - /** - * Triggers when search input changes - */ - onChange?: (...args: any[]) => any; - /** - * Triggers when the user clicks the clear button on search input - */ - onClear?: (...args: any[]) => any; - /** - * Please see Search - */ - onSearch?: (...args: any[]) => any; - /** - * Please see Search - */ - searchOnEnter?: boolean; - searchPlaceholder?: string; - searchValue?: string; - selectedNodes: TreePickerSimplePureSelectedNodes[]; - /** - * Show or hide the search field on the selection pane - */ - showSearch?: boolean; - /** - * A list of available unselected nodes. This prop is not required, but an empty array is not allowed. At least one element is required in the array. - */ - subtree?: TreePickerSimplePureSubtree[]; - svgSymbolCancel?: React.ReactNode; - svgSymbolSearch?: React.ReactNode; - /** - * e.g: Default Group - */ - displayGroupHeader?: boolean; - hideSearchOnRoot?: boolean; - /** - * A react node to be rendered at the top of the right hand side pane. Generally we are expecting a search component. - */ - selectedTopSearch?: React.ReactNode; - addNodePopoverInfoProps?: Object; - removeNodePopoverInfoProps?: Object; - breadcrumbRootNode?: TreePickerSimplePureBreadcrumbRootNode; } -declare const TreePickerSimplePure: React.FC; +declare const TreePickerSearch: React.FC; + +export interface TreePickerTreeProps { + resolveRootNodes: (...args: any[]) => any; + className?: string; + hiddenNodeIds?: string[]; + placeholder?: React.ReactNode; + emptyState?: React.ReactNode; +} + +declare const TreePickerTree: React.FC; + +export interface TreePickerProps { + children: React.ReactNode; + renderNode: (...args: any[]) => any; + className?: string; +} + +declare const TreePicker: React.FC & { + Node: typeof TreePickerNode; + Nav: typeof TreePickerNav; + Header: typeof TreePickerHeader; + Search: typeof TreePickerSearch; + Tree: typeof TreePickerTree; +}; -export default TreePickerSimplePure; +export default TreePicker; diff --git a/src/components/TreePicker/index.js b/src/components/TreePicker/index.js new file mode 100644 index 000000000..1110f33b1 --- /dev/null +++ b/src/components/TreePicker/index.js @@ -0,0 +1,11 @@ +export { + useTreePickerActions, + useTreePickerPaths, + useTreePickerNodes, + useTreePickerSearch, + useTreePickerCurrentNode, + useTreePickerGetState, + useTreePickerSlice, +} from './TreePickerContext'; +export { useTreePickerNode } from './TreePickerNode'; +export { default } from './TreePicker'; diff --git a/src/components/TreePicker/Grid/index.d.ts b/src/components/TreePickerSimplePure/Grid/index.d.ts similarity index 100% rename from src/components/TreePicker/Grid/index.d.ts rename to src/components/TreePickerSimplePure/Grid/index.d.ts diff --git a/src/components/TreePicker/Grid/index.jsx b/src/components/TreePickerSimplePure/Grid/index.jsx similarity index 100% rename from src/components/TreePicker/Grid/index.jsx rename to src/components/TreePickerSimplePure/Grid/index.jsx diff --git a/src/components/TreePicker/Grid/index.spec.jsx b/src/components/TreePickerSimplePure/Grid/index.spec.jsx similarity index 100% rename from src/components/TreePicker/Grid/index.spec.jsx rename to src/components/TreePickerSimplePure/Grid/index.spec.jsx diff --git a/src/components/TreePicker/Grid/styles.css b/src/components/TreePickerSimplePure/Grid/styles.css similarity index 100% rename from src/components/TreePicker/Grid/styles.css rename to src/components/TreePickerSimplePure/Grid/styles.css diff --git a/src/components/TreePicker/Nav/index.d.ts b/src/components/TreePickerSimplePure/Nav/index.d.ts similarity index 100% rename from src/components/TreePicker/Nav/index.d.ts rename to src/components/TreePickerSimplePure/Nav/index.d.ts diff --git a/src/components/TreePicker/Nav/index.jsx b/src/components/TreePickerSimplePure/Nav/index.jsx similarity index 100% rename from src/components/TreePicker/Nav/index.jsx rename to src/components/TreePickerSimplePure/Nav/index.jsx diff --git a/src/components/TreePicker/Nav/index.spec.jsx b/src/components/TreePickerSimplePure/Nav/index.spec.jsx similarity index 100% rename from src/components/TreePicker/Nav/index.spec.jsx rename to src/components/TreePickerSimplePure/Nav/index.spec.jsx diff --git a/src/components/TreePicker/Nav/styles.css b/src/components/TreePickerSimplePure/Nav/styles.css similarity index 100% rename from src/components/TreePicker/Nav/styles.css rename to src/components/TreePickerSimplePure/Nav/styles.css diff --git a/src/components/TreePicker/Node/Expander/index.d.ts b/src/components/TreePickerSimplePure/Node/Expander/index.d.ts similarity index 100% rename from src/components/TreePicker/Node/Expander/index.d.ts rename to src/components/TreePickerSimplePure/Node/Expander/index.d.ts diff --git a/src/components/TreePicker/Node/Expander/index.jsx b/src/components/TreePickerSimplePure/Node/Expander/index.jsx similarity index 100% rename from src/components/TreePicker/Node/Expander/index.jsx rename to src/components/TreePickerSimplePure/Node/Expander/index.jsx diff --git a/src/components/TreePicker/Node/Expander/index.spec.jsx b/src/components/TreePickerSimplePure/Node/Expander/index.spec.jsx similarity index 100% rename from src/components/TreePicker/Node/Expander/index.spec.jsx rename to src/components/TreePickerSimplePure/Node/Expander/index.spec.jsx diff --git a/src/components/TreePicker/Node/index.d.ts b/src/components/TreePickerSimplePure/Node/index.d.ts similarity index 100% rename from src/components/TreePicker/Node/index.d.ts rename to src/components/TreePickerSimplePure/Node/index.d.ts diff --git a/src/components/TreePicker/Node/index.jsx b/src/components/TreePickerSimplePure/Node/index.jsx similarity index 100% rename from src/components/TreePicker/Node/index.jsx rename to src/components/TreePickerSimplePure/Node/index.jsx diff --git a/src/components/TreePicker/Node/index.spec.jsx b/src/components/TreePickerSimplePure/Node/index.spec.jsx similarity index 100% rename from src/components/TreePicker/Node/index.spec.jsx rename to src/components/TreePickerSimplePure/Node/index.spec.jsx diff --git a/src/components/TreePicker/Node/styles.css b/src/components/TreePickerSimplePure/Node/styles.css similarity index 100% rename from src/components/TreePicker/Node/styles.css rename to src/components/TreePickerSimplePure/Node/styles.css diff --git a/src/components/TreePickerSimplePure/TreePicker.stories.tsx b/src/components/TreePickerSimplePure/TreePicker.stories.tsx new file mode 100644 index 000000000..da949dcec --- /dev/null +++ b/src/components/TreePickerSimplePure/TreePicker.stories.tsx @@ -0,0 +1,114 @@ +import React from 'react'; +import type { Meta, StoryObj } from '@storybook/react'; +import _ from 'lodash'; + +import TreePicker from './index'; +import SvgSymbol from '../SvgSymbol'; +import Search from '../Search'; + +const meta = { + title: 'Pending Review/TreePicker', + component: TreePicker, + tags: ['autodocs'], +} satisfies Meta; + +export default meta; + +type Story = StoryObj; + +const DefaultComponent = () => { + const [selectedSearchValue, setSelectedSearchValue] = React.useState(''); + const [selectedNodes, setSelectedNodes] = React.useState([]); + const [pickerSearchValue, setPickerSearchValue] = React.useState(''); + const subTree = [ + { + id: '0', + label: 'Northern Territory', + path: [{ id: '10', label: 'Australia' }], + type: 'Territory', + }, + { + id: '1', + label: 'Australian Capital Territory', + path: [{ id: '10', label: 'Australia' }], + type: 'Territory', + }, + { + id: '2', + label: 'Victoria', + path: [{ id: '10', label: 'Australia' }], + type: 'State', + }, + ]; + + const getSelectedNodes = () => { + if (_.isEmpty(selectedSearchValue)) return selectedNodes; + + return _.filter(selectedNodes, ({ label }) => _.includes(label.toLowerCase(), selectedSearchValue.toLowerCase())); + }; + + const getSubtree = () => { + let treePickerPureSubtree = []; + + // filter out nodes that do not contain search string + if (!_.isEmpty(pickerSearchValue)) { + treePickerPureSubtree = _.filter(subTree, ({ label }) => + _.includes(label.toLowerCase(), pickerSearchValue.toLowerCase()) + ); + } + + // filter out nodes that do not contain the selected search string + // however keep the nodes that are not selected but do not contain the selected search string + if (!_.isEmpty(selectedSearchValue)) { + treePickerPureSubtree = _.filter(treePickerPureSubtree, ({ id, label }) => { + if (_.find(selectedNodes, { id })) { + return _.includes(label.toLowerCase(), selectedSearchValue.toLowerCase()); + } + + return true; + }); + } + + return treePickerPureSubtree; + }; + + return ( +
+ + } + emptySelectedListText={ +
+ Choose items of interest +
+ } + initialStateNode={ +
+ Start by searching for items +
+ } + searchValue={pickerSearchValue} + onChange={setPickerSearchValue} + searchOnClear={() => setPickerSearchValue('')} + includeNode={(node) => setSelectedNodes(_.concat([], selectedNodes, node))} + removeNode={(node) => setSelectedNodes(_.reject(selectedNodes, { id: node.id }))} + additionalClassNames={pickerSearchValue ? undefined : ['background-highlighted', 'test-class']} + selectedTopSearch={ +
+ +
+ } + /> +
+ ); +}; + +export const Default: Story = { + args: {}, + render: () => DefaultComponent(), +}; diff --git a/src/components/TreePickerSimplePure/index.d.ts b/src/components/TreePickerSimplePure/index.d.ts new file mode 100644 index 000000000..bd2f4b623 --- /dev/null +++ b/src/components/TreePickerSimplePure/index.d.ts @@ -0,0 +1,167 @@ +import * as React from 'react'; + +export interface TreePickerSimplePureBreadcrumbNodes { + id?: any; + label: string; +} + +export interface TreePickerSimplePureSelectedNodes { + id?: any; + label: string; + isExpandable?: boolean; + path?: { + id?: any; + label: string; + }[]; + ancestors?: { + id?: any; + label: string; + }[]; + type: string; + value?: number; + accent?: 'warning' | 'success' | 'info' | 'error'; +} + +export interface TreePickerSimplePureSubtree { + id?: any; + label: string; + isExpandable?: boolean; + path?: { + id?: any; + label: string; + }[]; + ancestors?: { + id?: any; + label: string; + }[]; + type: string; + value?: number; + accent?: 'warning' | 'success' | 'info' | 'error'; +} + +export interface TreePickerSimplePureBreadcrumbRootNode { + id?: any; + label: string; +} + +export interface TreePickerSimplePureProps { + /** + * Class Names for SplitPane component + */ + additionalClassNames?: string[]; + /** + * Returns node id. This prop is not required, but an empty array is not allowed. At least one element is required in the array. + */ + breadcrumbNodes?: TreePickerSimplePureBreadcrumbNodes[]; + /** + * This propType creates a list of breadcrumb node + */ + breadcrumbOnClick?: (...args: any[]) => any; + /** + * Interval time on search + */ + debounceInterval?: number; + /** + * Disables treepicker including search bar + */ + disabled?: boolean; + /** + * Disables treepicker's grid item + */ + disableInclude?: boolean; + /** + * The svg symbol used when there will be no item on both left or right Grid + */ + emptySvgSymbol?: React.ReactNode; + /** + * The svg symbol used when there will be no item on right Grid (Selected list) + */ + emptySelectedListSvgSymbol?: React.ReactNode; + /** + * Displays this text when there will be no item on left Grid. Prefer type 'string', but rich text can be used here + */ + emptyText?: React.ReactNode; + /** + * Displays this text when there will be no item on right Grid(Selected list). Prefer type 'string', but rich text can be used here. + */ + emptySelectedListText?: React.ReactNode; + /** + * Triggers when clicking any item in the left Grid + */ + expandNode?: (...args: any[]) => any; + /** + * This function use to transform keys of the list item in the left Grid + */ + groupFormatter?: (...args: any[]) => any; + /** + * Hides the empty icon on right Grid (Selected list). Given emptySvgSymbol and hideIcon together, the empty symbol will be only displayed on the left grid. + */ + hideIcon?: boolean; + /** + * Click event on '+' button of each list Item + */ + includeNode?: (...args: any[]) => any; + /** + * Same as emptyText + */ + initialStateNode?: React.ReactNode; + /** + * Same as emptySymbol + */ + initialStateSymbol?: React.ReactNode; + /** + * Uses for specific className + */ + itemType?: string; + isLoading?: boolean; + /** + * Uses for rendering custom node + */ + nodeRenderer?: (...args: any[]) => any; + removeNode?: (...args: any[]) => any; + /** + * Triggers when search input changes + */ + onChange?: (...args: any[]) => any; + /** + * Triggers when the user clicks the clear button on search input + */ + onClear?: (...args: any[]) => any; + /** + * Please see Search + */ + onSearch?: (...args: any[]) => any; + /** + * Please see Search + */ + searchOnEnter?: boolean; + searchPlaceholder?: string; + searchValue?: string; + selectedNodes: TreePickerSimplePureSelectedNodes[]; + /** + * Show or hide the search field on the selection pane + */ + showSearch?: boolean; + /** + * A list of available unselected nodes. This prop is not required, but an empty array is not allowed. At least one element is required in the array. + */ + subtree?: TreePickerSimplePureSubtree[]; + svgSymbolCancel?: React.ReactNode; + svgSymbolSearch?: React.ReactNode; + /** + * e.g: Default Group + */ + displayGroupHeader?: boolean; + hideSearchOnRoot?: boolean; + /** + * A react node to be rendered at the top of the right hand side pane. Generally we are expecting a search component. + */ + selectedTopSearch?: React.ReactNode; + addNodePopoverInfoProps?: Object; + removeNodePopoverInfoProps?: Object; + breadcrumbRootNode?: TreePickerSimplePureBreadcrumbRootNode; +} + +declare const TreePickerSimplePure: React.FC; + +export default TreePickerSimplePure; diff --git a/src/components/TreePicker/index.jsx b/src/components/TreePickerSimplePure/index.jsx similarity index 100% rename from src/components/TreePicker/index.jsx rename to src/components/TreePickerSimplePure/index.jsx diff --git a/src/components/TreePicker/index.spec.jsx b/src/components/TreePickerSimplePure/index.spec.jsx similarity index 100% rename from src/components/TreePicker/index.spec.jsx rename to src/components/TreePickerSimplePure/index.spec.jsx diff --git a/src/components/TreePicker/mocks.js b/src/components/TreePickerSimplePure/mocks.js similarity index 100% rename from src/components/TreePicker/mocks.js rename to src/components/TreePickerSimplePure/mocks.js diff --git a/src/components/TreePicker/styles.css b/src/components/TreePickerSimplePure/styles.css similarity index 100% rename from src/components/TreePicker/styles.css rename to src/components/TreePickerSimplePure/styles.css diff --git a/src/hooks/index.js b/src/hooks/index.js deleted file mode 100644 index 03e1ec0b4..000000000 --- a/src/hooks/index.js +++ /dev/null @@ -1 +0,0 @@ -export { default as useArrowFocus } from './useArrowFocus'; diff --git a/src/hooks/useCallbackRef.js b/src/hooks/useCallbackRef.js new file mode 100644 index 000000000..7852648a5 --- /dev/null +++ b/src/hooks/useCallbackRef.js @@ -0,0 +1,13 @@ +import React from 'react'; + +const useCallbackRef = (callback) => { + const ref = React.useRef(callback); + + React.useLayoutEffect(() => { + ref.current = callback; + }); + + return ref; +}; + +export default useCallbackRef; diff --git a/src/hooks/useIsUnmounted.js b/src/hooks/useIsUnmounted.js new file mode 100644 index 000000000..2b8b21868 --- /dev/null +++ b/src/hooks/useIsUnmounted.js @@ -0,0 +1,16 @@ +import React from 'react'; + +const useIsUnmounted = () => { + const isUnmounted = React.useRef(false); + + React.useEffect(() => { + isUnmounted.current = false; + return () => { + isUnmounted.current = true; + }; + }, []); + + return React.useCallback(() => isUnmounted.current, []); +}; + +export default useIsUnmounted; diff --git a/src/index.d.ts b/src/index.d.ts index 38e0347fa..c55eb6006 100644 --- a/src/index.d.ts +++ b/src/index.d.ts @@ -60,9 +60,10 @@ export { default as Tile } from './components/Tile'; export { default as TileGrid } from './components/TileGrid'; export { default as Toast } from './components/Toast'; export { default as Totals } from './components/Totals'; -export { default as TreePickerSimplePure } from './components/TreePicker'; -export { default as TreePickerGrid } from './components/TreePicker/Grid'; -export { default as TreePickerNav } from './components/TreePicker/Nav'; -export { default as TreePickerNode } from './components/TreePicker/Node'; +export { default as TreePicker } from './components/TreePicker'; +export { default as TreePickerSimplePure } from './components/TreePickerSimplePure'; +export { default as TreePickerGrid } from './components/TreePickerSimplePure/Grid'; +export { default as TreePickerNav } from './components/TreePickerSimplePure/Nav'; +export { default as TreePickerNode } from './components/TreePickerSimplePure/Node'; export { default as UserListPicker } from './components/UserListPicker'; export { default as VerticalNav } from './components/VerticalNav'; diff --git a/src/index.js b/src/index.js index bea4c3f34..f9c49479d 100644 --- a/src/index.js +++ b/src/index.js @@ -64,10 +64,21 @@ import Tile from './components/Tile'; import TileGrid from './components/TileGrid'; import Toast from './components/Toast'; import Totals from './components/Totals'; -import TreePickerSimplePure from './components/TreePicker'; -import TreePickerGrid from './components/TreePicker/Grid'; -import TreePickerNav from './components/TreePicker/Nav'; -import TreePickerNode from './components/TreePicker/Node'; +import TreePicker, { + useTreePickerActions, + useTreePickerPaths, + useTreePickerNodes, + useTreePickerSearch, + useTreePickerCurrentNode, + useTreePickerGetState, + useTreePickerSlice, + useTreePickerNode, +} from './components/TreePicker'; +// legacy TreePickerSimplePure will be deprecated once all usage replaced with TreePicker +import TreePickerSimplePure from './components/TreePickerSimplePure'; +import TreePickerGrid from './components/TreePickerSimplePure/Grid'; +import TreePickerNav from './components/TreePickerSimplePure/Nav'; +import TreePickerNode from './components/TreePickerSimplePure/Node'; import UserListPicker from './components/UserListPicker'; import VerticalNav from './components/VerticalNav'; @@ -128,6 +139,15 @@ export { TextEllipsis, TileGrid, Totals, + TreePicker, + useTreePickerActions, + useTreePickerPaths, + useTreePickerNodes, + useTreePickerSearch, + useTreePickerCurrentNode, + useTreePickerGetState, + useTreePickerSlice, + useTreePickerNode, TreePickerGrid, TreePickerNav, TreePickerNode, diff --git a/src/styles/icons/folder-open.svg b/src/styles/icons/folder-open.svg new file mode 100644 index 000000000..100166e02 --- /dev/null +++ b/src/styles/icons/folder-open.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/src/styles/icons/folder.svg b/src/styles/icons/folder.svg new file mode 100644 index 000000000..5f749c05d --- /dev/null +++ b/src/styles/icons/folder.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/src/styles/icons/plus.svg b/src/styles/icons/plus.svg new file mode 100644 index 000000000..0f4db63c0 --- /dev/null +++ b/src/styles/icons/plus.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/svgo-config.js b/svgo-config.js index 84b37ecb0..734dff2c1 100644 --- a/svgo-config.js +++ b/svgo-config.js @@ -10,6 +10,8 @@ module.exports = { }, removeDoctype: false, + + removeViewBox: false, }, }, }, @@ -22,5 +24,6 @@ module.exports = { attrs: ['id'], }, }, + { name: 'removeDimensions' }, ], };