TreeView.tsx 12 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318
  1. /*
  2. * Copyright 2021-2024 Avaiga Private Limited
  3. *
  4. * Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with
  5. * the License. You may obtain a copy of the License at
  6. *
  7. * http://www.apache.org/licenses/LICENSE-2.0
  8. *
  9. * Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on
  10. * an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the
  11. * specific language governing permissions and limitations under the License.
  12. */
  13. import React, {
  14. useState,
  15. useCallback,
  16. useEffect,
  17. useMemo,
  18. SyntheticEvent,
  19. HTMLAttributes,
  20. forwardRef,
  21. Ref,
  22. CSSProperties,
  23. } from "react";
  24. import Box from "@mui/material/Box";
  25. import { SimpleTreeView as MuiTreeView } from "@mui/x-tree-view/SimpleTreeView";
  26. import ChevronRightIcon from "@mui/icons-material/ChevronRight";
  27. import { TreeItem, TreeItemContentProps, useTreeItemState, TreeItemProps } from "@mui/x-tree-view/TreeItem";
  28. import Paper from "@mui/material/Paper";
  29. import TextField from "@mui/material/TextField";
  30. import Tooltip from "@mui/material/Tooltip";
  31. import Typography from "@mui/material/Typography";
  32. import { createSendUpdateAction } from "../../context/taipyReducers";
  33. import { isLovParent, LovImage, paperBaseSx, SelTreeProps, showItem, useLovListMemo } from "./lovUtils";
  34. import {
  35. useClassNames,
  36. useDispatch,
  37. useDispatchRequestUpdateOnFirstRender,
  38. useDynamicProperty,
  39. useModule,
  40. } from "../../utils/hooks";
  41. import { LovItem } from "../../utils/lov";
  42. import { getUpdateVar } from "./utils";
  43. import { Icon } from "../../utils/icon";
  44. const treeSlots = { expandIcon: ChevronRightIcon };
  45. const CustomContent = forwardRef(function CustomContent(props: TreeItemContentProps, ref) {
  46. // need a display name
  47. const { classes, className, label, itemId, icon: iconProp, expansionIcon, displayIcon } = props;
  48. const { allowSelection, lovIcon, height } = props as unknown as CustomTreeProps;
  49. const { disabled, expanded, selected, focused, handleExpansion, handleSelection, preventSelection } =
  50. useTreeItemState(itemId);
  51. const icon = iconProp || expansionIcon || displayIcon;
  52. const classNames = [className, classes.root];
  53. if (expanded) {
  54. classNames.push(classes.expanded);
  55. }
  56. if (selected) {
  57. classNames.push(classes.selected);
  58. }
  59. if (allowSelection && focused) {
  60. classNames.push(classes.focused);
  61. }
  62. if (disabled) {
  63. classNames.push(classes.disabled);
  64. }
  65. const divStyle = useMemo(() => (height ? { height: height } : undefined), [height]);
  66. return (
  67. <div
  68. className={classNames.join(" ")}
  69. onMouseDown={preventSelection}
  70. ref={ref as Ref<HTMLDivElement>}
  71. style={divStyle}
  72. >
  73. <div onClick={handleExpansion} className={classes.iconContainer}>
  74. {icon}
  75. </div>
  76. <Typography
  77. onClick={allowSelection ? handleSelection : handleExpansion}
  78. component="div"
  79. className={classes.label}
  80. >
  81. {lovIcon ? <LovImage item={lovIcon} disableTypo={true} height={height} /> : label}
  82. </Typography>
  83. </div>
  84. );
  85. });
  86. interface CustomTreeProps extends HTMLAttributes<HTMLElement> {
  87. allowSelection: boolean;
  88. lovIcon?: Icon;
  89. height?: string;
  90. }
  91. const CustomTreeItem = (props: TreeItemProps & CustomTreeProps) => {
  92. const { allowSelection, lovIcon, height, ...tiProps } = props;
  93. const ctProps = { allowSelection, lovIcon, height } as CustomTreeProps;
  94. return <TreeItem ContentComponent={CustomContent} ContentProps={ctProps} {...tiProps} />;
  95. };
  96. const renderTree = (
  97. lov: LovItem[],
  98. active: boolean,
  99. searchValue: string,
  100. selectLeafsOnly: boolean,
  101. rowHeight?: string
  102. ) => {
  103. return lov.map((li) => {
  104. const children = li.children ? renderTree(li.children, active, searchValue, selectLeafsOnly, rowHeight) : [];
  105. if (!children.filter((c) => c).length && !showItem(li, searchValue)) {
  106. return null;
  107. }
  108. return (
  109. <CustomTreeItem
  110. key={li.id}
  111. itemId={li.id}
  112. label={typeof li.item === "string" ? li.item : "undefined item"}
  113. disabled={!active}
  114. allowSelection={selectLeafsOnly ? !children || children.length == 0 : true}
  115. lovIcon={typeof li.item !== "string" ? (li.item as Icon) : undefined}
  116. height={rowHeight}
  117. >
  118. {children}
  119. </CustomTreeItem>
  120. );
  121. });
  122. };
  123. const boxSx = { width: "100%" } as CSSProperties;
  124. const textFieldSx = { mb: 1, px: 1, display: "flex" };
  125. interface TreeViewProps extends SelTreeProps {
  126. defaultExpanded?: string | boolean;
  127. expanded?: string[] | boolean;
  128. selectLeafsOnly?: boolean;
  129. rowHeight?: string;
  130. }
  131. const TreeView = (props: TreeViewProps) => {
  132. const {
  133. id,
  134. defaultValue = "",
  135. value,
  136. updateVarName = "",
  137. defaultLov = "",
  138. filter = false,
  139. multiple = false,
  140. propagate = true,
  141. lov,
  142. updateVars = "",
  143. width = "100%",
  144. height,
  145. valueById,
  146. selectLeafsOnly = false,
  147. rowHeight,
  148. } = props;
  149. const [searchValue, setSearchValue] = useState("");
  150. const [selectedValue, setSelectedValue] = useState<string[]>([]);
  151. const [oneExpanded, setOneExpanded] = useState(false);
  152. const [refreshExpanded, setRefreshExpanded] = useState(false);
  153. const [expandedNodes, setExpandedNodes] = useState<string[]>([]);
  154. const dispatch = useDispatch();
  155. const module = useModule();
  156. const className = useClassNames(props.libClassName, props.dynamicClassName, props.className);
  157. const active = useDynamicProperty(props.active, props.defaultActive, true);
  158. const hover = useDynamicProperty(props.hoverText, props.defaultHoverText, undefined);
  159. useDispatchRequestUpdateOnFirstRender(dispatch, id, module, updateVars, updateVarName);
  160. const lovList = useLovListMemo(lov, defaultLov, true);
  161. const treeSx = useMemo(
  162. () => ({ bgcolor: "transparent", overflowY: "auto", width: "100%", maxWidth: width }),
  163. [width]
  164. );
  165. const paperSx = useMemo(() => {
  166. const sx = height === undefined ? paperBaseSx : { ...paperBaseSx, maxHeight: height };
  167. return { ...sx, overflow: "hidden", py: 1 };
  168. }, [height]);
  169. useEffect(() => {
  170. let refExp = false;
  171. let oneExp = false;
  172. if (props.expanded === undefined) {
  173. if (typeof props.defaultExpanded === "boolean") {
  174. oneExp = !props.defaultExpanded;
  175. } else if (typeof props.defaultExpanded === "string") {
  176. try {
  177. const val = JSON.parse(props.defaultExpanded);
  178. if (Array.isArray(val)) {
  179. setExpandedNodes(val.map((v) => "" + v));
  180. } else {
  181. setExpandedNodes(["" + val]);
  182. }
  183. refExp = true;
  184. } catch (e) {
  185. console.info(`Tree.expanded cannot parse property\n${(e as Error).message || e}`);
  186. }
  187. }
  188. } else if (typeof props.expanded === "boolean") {
  189. oneExp = !props.expanded;
  190. } else {
  191. try {
  192. if (Array.isArray(props.expanded)) {
  193. setExpandedNodes(props.expanded.map((v) => "" + v));
  194. } else {
  195. setExpandedNodes(["" + props.expanded]);
  196. }
  197. refExp = true;
  198. } catch (e) {
  199. console.info(`Tree.expanded wrongly formatted property\n${(e as Error).message || e}`);
  200. }
  201. }
  202. setOneExpanded(oneExp);
  203. setRefreshExpanded(refExp);
  204. }, [props.defaultExpanded, props.expanded]);
  205. useEffect(() => {
  206. if (value !== undefined) {
  207. setSelectedValue(Array.isArray(value) ? value : [value]);
  208. } else if (defaultValue) {
  209. let parsedValue;
  210. try {
  211. parsedValue = JSON.parse(defaultValue);
  212. } catch (e) {
  213. parsedValue = defaultValue;
  214. }
  215. setSelectedValue(Array.isArray(parsedValue) ? parsedValue : [parsedValue]);
  216. }
  217. }, [defaultValue, value, multiple]);
  218. const clickHandler = useCallback(
  219. (event: SyntheticEvent, nodeIds: string[] | string | null) => {
  220. const ids = nodeIds === null ? [] : Array.isArray(nodeIds) ? nodeIds : [nodeIds];
  221. setSelectedValue(ids);
  222. updateVarName &&
  223. dispatch(
  224. createSendUpdateAction(
  225. updateVarName,
  226. ids,
  227. module,
  228. props.onChange,
  229. propagate,
  230. valueById ? undefined : getUpdateVar(updateVars, "lov")
  231. )
  232. );
  233. },
  234. [updateVarName, dispatch, propagate, updateVars, valueById, props.onChange, module]
  235. );
  236. const handleInput = useCallback((e: React.ChangeEvent<HTMLInputElement>) => setSearchValue(e.target.value), []);
  237. const handleNodeToggle = useCallback(
  238. (event: React.SyntheticEvent, nodeIds: string[]) => {
  239. const expVar = getUpdateVar(updateVars, "expanded");
  240. if (oneExpanded) {
  241. setExpandedNodes((en) => {
  242. if (en.length < nodeIds.length) {
  243. // node opened: keep only parent nodes
  244. nodeIds = nodeIds.filter((n, i) => i == 0 || isLovParent(lovList, n, nodeIds[0]));
  245. }
  246. if (refreshExpanded) {
  247. dispatch(createSendUpdateAction(expVar, nodeIds, module, props.onChange, propagate));
  248. }
  249. return nodeIds;
  250. });
  251. } else {
  252. setExpandedNodes(nodeIds);
  253. if (refreshExpanded) {
  254. dispatch(createSendUpdateAction(expVar, nodeIds, module, props.onChange, propagate));
  255. }
  256. }
  257. },
  258. [oneExpanded, refreshExpanded, lovList, propagate, updateVars, dispatch, props.onChange, module]
  259. );
  260. const treeProps = useMemo(() => ({ multiSelect: multiple, selectedItems: selectedValue }), [multiple, selectedValue]);
  261. return (
  262. <Box id={id} sx={boxSx} className={className}>
  263. <Tooltip title={hover || ""}>
  264. <Paper sx={paperSx}>
  265. <Box>
  266. {filter && (
  267. <TextField
  268. margin="dense"
  269. placeholder="Search field"
  270. value={searchValue}
  271. onChange={handleInput}
  272. disabled={!active}
  273. sx={textFieldSx}
  274. />
  275. )}
  276. </Box>
  277. <MuiTreeView
  278. aria-label="tree"
  279. slots={treeSlots}
  280. sx={treeSx}
  281. onSelectedItemsChange={clickHandler}
  282. expandedItems={expandedNodes}
  283. onExpandedItemsChange={handleNodeToggle}
  284. {...treeProps}
  285. >
  286. {renderTree(lovList, !!active, searchValue, selectLeafsOnly, rowHeight)}
  287. </MuiTreeView>
  288. </Paper>
  289. </Tooltip>
  290. </Box>
  291. );
  292. };
  293. export default TreeView;