diff --git a/README.md b/README.md index b3217cc..affd9db 100644 --- a/README.md +++ b/README.md @@ -42,26 +42,29 @@ export default () => { ### NodeDataType -| 名称 | 类型 | 默认值 | 说明 | -| --------- | ------------------- | ------ | ---------- | -| key | string \| number | - | key | -| label | number | - | label | -| children | NodeDataType[] | - | 子节点集合 | -| className | string | - | 类名 | -| style | React.CSSProperties | - | 样式 | +| 名称 | 类型 | 默认值 | 说明 | +| ------------ | ------------------- | ------ | ------------------ | +| key | string \| number | - | key | +| label | number | - | label | +| expand | boolean | - | 控制展开/收缩 | +| loadChildren | boolean | - | 异步加载子节点数据 | +| children | NodeDataType[] | - | 子节点集合 | +| className | string | - | 类名 | +| style | React.CSSProperties | - | 样式 | ### OrgChartProps -| 名称 | 类型 | 默认值 | 说明 | -| ---------- | --------------------------------------------------------------------- | ------ | --------------------- | -| data | NodeDataType | - | 数据 | -| className | string | - | 类名 | -| style | React.CSSProperties | - | 样式 | -| expandAll | boolean | true | 是否展开所有子节点 | -| expandable | boolean | false | 是否允许子节点展开 | -| renderNode | (node: NodeDataType, originNode: React.ReactNode) => React.ReactNode; | - | 自定义渲染节点 | -| onExpand | (expanded: boolean, node: NodeDataType) => void | - | 展开/收起节点时的回调 | -| onClick | (node: NodeDataType) => void | - | 点击节点时的回调 | +| 名称 | 类型 | 默认值 | 说明 | +| ------------ | --------------------------------------------------------------------- | ------ | --------------------- | +| data | NodeDataType | - | 数据 | +| className | string | - | 类名 | +| style | React.CSSProperties | - | 样式 | +| expandAll | boolean | true | 是否展开所有子节点 | +| expandable | boolean | false | 是否允许子节点展开 | +| renderNode | (node: NodeDataType, originNode: React.ReactNode) => React.ReactNode; | - | 自定义渲染节点 | +| loadChildren | (data: NodeDataType) => Promise; | - | 异步加载子节点数据 | +| onExpand | (expanded: boolean, node: NodeDataType) => void | - | 展开/收起节点时的回调 | +| onClick | (node: NodeDataType) => void | - | 点击节点时的回调 | ## 支持 diff --git a/docs/demo/expandAsync.md b/docs/demo/expandAsync.md new file mode 100644 index 0000000..34e6eea --- /dev/null +++ b/docs/demo/expandAsync.md @@ -0,0 +1,7 @@ +--- +order: 4 +--- + +## expandAsync + + diff --git a/docs/examples/expandAsync.tsx b/docs/examples/expandAsync.tsx new file mode 100644 index 0000000..800cb41 --- /dev/null +++ b/docs/examples/expandAsync.tsx @@ -0,0 +1,106 @@ +import React from 'react'; +import OrgChart, { NodeDataType } from '@twp0217/react-org-chart'; + +export default () => { + const data: NodeDataType = { + key: 0, + label: '科技有限公司', + expand: true, + children: [ + { + key: 1, + label: '研发部', + children: [ + { key: 11, label: '开发-前端' }, + { key: 12, label: '开发-后端' }, + { key: 13, label: 'UI设计' }, + { key: 14, label: '产品经理' }, + ], + }, + { + key: 2, + label: '销售部', + children: [ + { key: 21, label: '销售一部' }, + { key: 22, label: '销售二部' }, + ], + }, + { key: 3, label: '财务部' }, + { key: 4, label: '人事部', loadChildren: true }, + ], + }; + + const [expandAll, setExpandAll] = React.useState(false); + + return ( +
+
+ +
+
+
+ { + return new Promise((resolve, _reject) => { + setTimeout(() => { + resolve([ + { + key: 41, + label: '人事一部', + }, + { + key: 42, + label: '人事二部', + }, + { + key: 43, + label: '人事三部', + }, + { + key: 44, + label: '人事四部', + }, + { + key: 45, + label: '人事五部', + }, + { + key: 46, + label: '人事六部', + }, + { + key: 47, + label: '人事七部', + }, + { + key: 48, + label: '人事八部', + }, + { + key: 49, + label: '人事九部', + }, + ]); + }, 3000); + }); + }} + /> +
+
+ ); +}; diff --git a/src/OrgChart.module.less b/src/OrgChart.module.less index 7186c39..f2ee18e 100644 --- a/src/OrgChart.module.less +++ b/src/OrgChart.module.less @@ -1,7 +1,8 @@ -@node-color: #1890ff; -@line-width: 1px; +@node-color: #3870e1; +@line-width: 2px; @line-color: @node-color; -@expand-icon-size: 16px; +@expand-icon-size: 18px; +@expand-icon-background-color: #e8eefb; .orgChartContainer { display: inline-block; @@ -21,46 +22,64 @@ position: relative; .expand-icon { + box-sizing: content-box; display: inline-block; width: @expand-icon-size; height: @expand-icon-size; border-radius: 50%; - background-color: @line-color; + background-color: @expand-icon-background-color; + border: 1px solid @node-color; position: absolute; left: 50%; bottom: 0; margin-left: -@expand-icon-size / 2; margin-bottom: -@expand-icon-size / 2; - z-index: 99; + z-index: 9; cursor: pointer; &-expanded, &-collapsed { &::before { + box-sizing: border-box; content: ''; - width: 8px; + width: 10px; height: 2px; - background-color: #fff; + background-color: @node-color; position: absolute; - top: 7px; - left: 4px; + top: (@expand-icon-size - 2px) / 2; + left: (@expand-icon-size - 10px) / 2; } } &-collapsed { &::after { + box-sizing: border-box; content: ''; width: 2px; - height: 8px; - background-color: #fff; + height: 10px; + background-color: @node-color; position: absolute; - top: 4px; - left: 7px; + top: (@expand-icon-size - 10px) / 2; + left: (@expand-icon-size - 2px) / 2; + } + } + + &-loading-children { + &::before { + box-sizing: border-box; + content: ''; + width: 10px; + height: 10px; + border: 2px solid @node-color; + border-radius: 50%; + position: absolute; + top: (@expand-icon-size - 10px) / 2; + left: (@expand-icon-size - 10px) / 2; } } &:hover { - background-color: darken(@line-color, 5%); + background-color: darken(@expand-icon-background-color, 5%); } } @@ -72,18 +91,42 @@ height: 100%; background-color: @line-color; display: inline-block; + user-select: none; } - &.left { + .mixLeft( @a ) when (mod(@a,2)>0) { + border-right: @line-width solid @line-color; + } + .mixLeft( @a ) when (od(@a,2)<0) { border-right: @line-width solid @line-color; } + .mixLeft( @a ) when (mod(@a,2)=0) { + border-right: @line-width / 2 solid @line-color; + } - &.right { + &.left { + .mixLeft(@line-width); + user-select: none; + } + + .mixRight( @a ) when (mod(@a,2)<0) { + border-left: @line-width solid transparent; + } + .mixRight( @a ) when (mod(@a,2)>0) { border-left: @line-width solid transparent; } + .mixRight( @a ) when (mod(@a,2)=0) { + border-left: @line-width / 2 solid @line-color; + } + + &.right { + .mixRight(@line-width); + user-select: none; + } &.top { border-top: @line-width solid @line-color; + user-select: none; } } } @@ -96,10 +139,18 @@ .node { display: inline-block; - border: 1px solid @node-color; - padding: 0.5rem; margin: 0 5px; - cursor: pointer; + + .node-content { + color: black; + text-align: center; + padding: 0.2rem 0.4rem; + border: 1px solid @node-color; + border-radius: 3px; + box-shadow: 0 1px 5px rgba(0, 0, 0, 0.15); + cursor: pointer; + white-space: nowrap; + } } } } diff --git a/src/OrgChart.tsx b/src/OrgChart.tsx index 7d11d20..f51e2d3 100644 --- a/src/OrgChart.tsx +++ b/src/OrgChart.tsx @@ -2,6 +2,7 @@ import classNames from 'classnames'; import React from 'react'; import DefaultOrgChart from './components/DefaultOrgChart'; import { OrgChartProps } from './interface'; +// @ts-ignore import styles from './OrgChart.module.less'; const OrgChart = (props: OrgChartProps) => { diff --git a/src/components/DefaultOrgChart.tsx b/src/components/DefaultOrgChart.tsx index fc3d022..038e6de 100644 --- a/src/components/DefaultOrgChart.tsx +++ b/src/components/DefaultOrgChart.tsx @@ -1,39 +1,74 @@ import { NodeDataType, OrgChartComponentProps } from '../interface'; import classNames from 'classnames'; -import React from 'react'; +import React, { useReducer } from 'react'; const DefaultOrgChart = (props: OrgChartComponentProps) => { const { - data, expandAll = true, expandable = false, renderNode: customRenderNode, + loadChildren: customLoadChildren, onExpand, onClick, } = props; - const [expanded, setExpanded] = React.useState(false); + //region data with key map + const [data, setData] = React.useState(props.data); + const getValue = (key: string, fromData: NodeDataType = data) => + fromData[props.keyMap?.[key] ?? key]; + const setValue = (key: string, value: any, toData: NodeDataType = data) => { + toData[props.keyMap?.[key] ?? key] = value; + return toData; + }; + const label = getValue('label'); + const style = getValue('style'); + const className = getValue('className'); + const loadChildren = getValue('loadChildren'); + const expand = getValue('expand'); + const children = () => + getValue('children')?.filter((item: NodeDataType) => { + return props.filter?.(item) ?? true; + }); + const childrenLength = () => children()?.length || 0; + const colSpan = () => childrenLength() * 2; + //endregion + + if (props.debug === true) { + console.log('DefaultOrgChart props=', props); + console.log('DefaultOrgChart label=', label); + console.log('DefaultOrgChart style=', style); + console.log('DefaultOrgChart className=', className); + console.log('DefaultOrgChart loadChildren=', loadChildren); + console.log('DefaultOrgChart expand=', expand); + console.log('DefaultOrgChart children=', children()); + console.log('DefaultOrgChart childrenLength=', childrenLength()); + } - const childrenLength = data.children?.length || 0; - const colSpan: number = childrenLength * 2; + // eslint-disable-next-line + const [ignored, forceUpdate] = useReducer((x) => x + 1, 0); + const [expanded, setExpanded] = React.useState( + expand ?? expandAll ?? false, + ); + const [loadingChildren, setLoadingChildren] = React.useState(false); + const expandableOnlyOneOnSameTime = + props.expandableOnlyOneOnSameTime ?? false; /** * 渲染节点 * @param data - * @returns */ const renderNode = (data: NodeDataType): React.ReactNode => { const contentNode: React.ReactNode = ( -
- {data.label} +
+ {label}
); return ( - +
onClick && onClick(data)} > {!!customRenderNode @@ -50,8 +85,84 @@ const DefaultOrgChart = (props: OrgChartComponentProps) => { */ const handleExpandChange = () => { const newExpanded = !expanded; - setExpanded(newExpanded); - onExpand && onExpand(newExpanded, data); + if ( + childrenLength() <= 0 && + loadChildren === true && + !!customLoadChildren && + newExpanded + ) { + setLoadingChildren(true); + Promise.resolve(customLoadChildren(data)) + .then((newChildren) => { + setLoadingChildren(false); + if (!expandableOnlyOneOnSameTime) { + const newData = data; + setValue('children', newChildren, newData); + setData(newData); + } else { + setValue('children', newChildren); + } + processExpanded(newExpanded); + }) + .catch((ignore) => { + setLoadingChildren(false); + }); + } else { + processExpanded(newExpanded); + } + }; + + const getExpandedPath = (newExpanded = expanded) => + (props.expandedPath ?? []).concat(newExpanded ? [label] : []); + + const processExpanded = (newExpanded: boolean) => { + if ( + !props.setBrothersExpand || + !newExpanded || + !expandableOnlyOneOnSameTime || + childrenLength() <= 1 + ) { + setExpanded(newExpanded); + onExpand && onExpand(newExpanded, data, getExpandedPath(newExpanded)); + } else { + props.setBrothersExpand?.( + (item) => (getValue('label', item) === label ? newExpanded : false), + (processBySelf) => { + if (processBySelf) { + setExpanded(newExpanded); + } + }, + ); + onExpand && onExpand(newExpanded, data, getExpandedPath(newExpanded)); + } + }; + + const setChildrenExpand = ( + handleChildExpanded: (item: NodeDataType) => boolean, + handleExpandedBySelf: (processBySelf: boolean) => void, + ) => { + let changeCount = 0; + children()?.map((item: NodeDataType) => { + let newExpand = handleChildExpanded(item); + let oldExpand = getValue('expand', item); + if ( + (getValue('children', item)?.length ?? 0 > 0) && + oldExpand !== newExpand + ) { + if ( + (!oldExpand && newExpand === true) || + (oldExpand === true && !newExpand) + ) { + changeCount++; + } + setValue('expand', newExpand, item); + } + }); + if (changeCount > 1) { + forceUpdate(); + } else { + handleExpandedBySelf(true); + } }; /** @@ -60,17 +171,21 @@ const DefaultOrgChart = (props: OrgChartComponentProps) => { */ const renderVerticalLine = (): React.ReactNode => { return ( - -
+ +
{expandable ? (
handleExpandChange()} - >
+ /> ) : null} ); @@ -82,14 +197,14 @@ const DefaultOrgChart = (props: OrgChartComponentProps) => { */ const renderConnectLines = (): React.ReactNode[] => { const lines: React.ReactNode[] = []; - for (let index = 0; index < colSpan; index++) { + for (let index = 0; index < colSpan(); index++) { lines.push(   @@ -101,11 +216,12 @@ const DefaultOrgChart = (props: OrgChartComponentProps) => { /** * 渲染子节点 - * @param datas + * @param children * @returns */ - const renderChildren = (datas: NodeDataType[] = []): React.ReactNode => { - if (datas.length > 0) { + const renderChildren = (children: NodeDataType[]): React.ReactNode => { + if ((children?.length ?? 0) > 0) { + let uniqueTime = new Date().getTime(); return ( <> {renderVerticalLine()} @@ -113,29 +229,51 @@ const DefaultOrgChart = (props: OrgChartComponentProps) => { {renderConnectLines()} - {datas.map((data) => { + {children.map((child, index) => { return ( - - + + boolean, + handleExpandedBySelf: (processBySelf: boolean) => void, + ) => { + setChildrenExpand( + handleChildExpanded, + handleExpandedBySelf, + ); + }} + /> ); })} ); + } else if (loadChildren === true && !!customLoadChildren) { + return ( + <> + {renderVerticalLine()} + + ); } return; }; React.useEffect(() => { - setExpanded(expandAll); - }, [expandAll]); + setExpanded(expand ?? expandAll ?? false); + }, [data, expand, expandAll]); return ( {renderNode(data)} - {renderChildren(data.children)} + {renderChildren(children())}
); diff --git a/src/interface.ts b/src/interface.ts index fb6736d..d832a14 100644 --- a/src/interface.ts +++ b/src/interface.ts @@ -1,8 +1,17 @@ +/* eslint-disable */ +// noinspection TypeScriptUMDGlobal + +// noinspection TypeScriptUMDGlobal + export interface NodeDataType { + [key: string]: any; + key: string | number; label: string; children?: NodeDataType[]; className?: string; + expand?: boolean; + loadChildren?: boolean; style?: React.CSSProperties; } @@ -14,10 +23,24 @@ export type RenderNode = ( export interface OrgChartComponentProps { data: NodeDataType; expandAll?: boolean; + debug?: boolean; expandable?: boolean; + expandableOnlyOneOnSameTime?: boolean; + expandedPath?: string[]; renderNode?: RenderNode; - onExpand?: (expanded: boolean, node: NodeDataType) => void; + keyMap?: { [key: string]: string }; + setBrothersExpand?: ( + handleChildExpanded: (item: NodeDataType) => boolean, + handleExpandedBySelf: (processBySelf: boolean) => void, + ) => void; + loadChildren?: (data: NodeDataType) => Promise; + onExpand?: ( + expanded: boolean, + node: NodeDataType, + expandedPath: string[], + ) => void; onClick?: (node: NodeDataType) => void; + filter?: (node: NodeDataType) => boolean; } export interface OrgChartProps extends Partial {