diff --git a/CHANGELOG.adoc b/CHANGELOG.adoc index 7cb567544f..80d047129b 100644 --- a/CHANGELOG.adoc +++ b/CHANGELOG.adoc @@ -117,6 +117,7 @@ This will allow specifier to create images that fir perfectly in the project tem - https://github.com/eclipse-sirius/sirius-web/issues/2163[#2163] [form] Make EMF default form support non changeable features - https://github.com/eclipse-sirius/sirius-web/issues/4086[#4086] [form] Wrap widget returned by property section in a div with a specific classname - https://github.com/eclipse-sirius/sirius-web/issues/4088[#4088] Change tree item context menu entries internal management +- https://github.com/eclipse-sirius/sirius-web/issues/3895[#3895] [trees] Memoize the explorer == v2024.9.0 diff --git a/package-lock.json b/package-lock.json index 4303b3765d..6cf81118d8 100644 --- a/package-lock.json +++ b/package-lock.json @@ -8407,6 +8407,9 @@ "name": "@eclipse-sirius/sirius-components-trees", "version": "2024.9.8", "license": "EPL-2.0", + "dependencies": { + "zustand": "^5.0.0" + }, "devDependencies": { "@apollo/client": "3.10.4", "@eclipse-sirius/sirius-components-core": "*", @@ -8442,6 +8445,34 @@ "xstate": "4.32.1" } }, + "packages/trees/frontend/sirius-components-trees/node_modules/zustand": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/zustand/-/zustand-5.0.0.tgz", + "integrity": "sha512-LE+VcmbartOPM+auOjCCLQOsQ05zUTp8RkgwRzefUk+2jISdMMFnxvyTjA4YNWr5ZGXYbVsEMZosttuxUBkojQ==", + "engines": { + "node": ">=12.20.0" + }, + "peerDependencies": { + "@types/react": ">=18.0.0", + "immer": ">=9.0.6", + "react": ">=18.0.0", + "use-sync-external-store": ">=1.2.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "immer": { + "optional": true + }, + "react": { + "optional": true + }, + "use-sync-external-store": { + "optional": true + } + } + }, "packages/validation/frontend/sirius-components-validation": { "name": "@eclipse-sirius/sirius-components-validation", "version": "2024.9.8", @@ -9062,7 +9093,16 @@ "typescript": "5.4.5", "vite": "5.2.11", "vitest": "1.6.0", - "xstate": "4.32.1" + "xstate": "4.32.1", + "zustand": "^5.0.0" + }, + "dependencies": { + "zustand": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/zustand/-/zustand-5.0.0.tgz", + "integrity": "sha512-LE+VcmbartOPM+auOjCCLQOsQ05zUTp8RkgwRzefUk+2jISdMMFnxvyTjA4YNWr5ZGXYbVsEMZosttuxUBkojQ==", + "requires": {} + } } }, "@eclipse-sirius/sirius-components-tsconfig": { diff --git a/packages/core/frontend/sirius-components-core/src/label/StyledLabel.tsx b/packages/core/frontend/sirius-components-core/src/label/StyledLabel.tsx index 039c52a209..75b6c8ea08 100644 --- a/packages/core/frontend/sirius-components-core/src/label/StyledLabel.tsx +++ b/packages/core/frontend/sirius-components-core/src/label/StyledLabel.tsx @@ -12,6 +12,7 @@ *******************************************************************************/ import Typography from '@mui/material/Typography'; import { Theme } from '@mui/material/styles'; +import { memo } from 'react'; import { makeStyles } from 'tss-react/mui'; import { GQLStyledString, GQLStyledStringFragmentStyle, StyledLabelInputProps } from './StyledLabel.type'; @@ -131,7 +132,7 @@ const getStyledString = (styledString: GQLStyledString) => { }); }; -export const StyledLabel = ({ styledString, selected, textToHighlight, marked }: StyledLabelInputProps) => { +export const StyledLabel = memo(({ styledString, selected, textToHighlight, marked }: StyledLabelInputProps) => { const { classes } = useTreeItemStyle(); const textLabel = getTextFromStyledString(styledString); let itemLabel: JSX.Element; @@ -172,4 +173,4 @@ export const StyledLabel = ({ styledString, selected, textToHighlight, marked }: ); -}; +}); diff --git a/packages/core/frontend/sirius-components-core/src/workbench/Workbench.types.ts b/packages/core/frontend/sirius-components-core/src/workbench/Workbench.types.ts index 500b35b649..20815601e8 100644 --- a/packages/core/frontend/sirius-components-core/src/workbench/Workbench.types.ts +++ b/packages/core/frontend/sirius-components-core/src/workbench/Workbench.types.ts @@ -38,7 +38,7 @@ export interface WorkbenchViewContribution { side: WorkbenchViewSide; title: string; icon: React.ReactElement; - component: (props: WorkbenchViewComponentProps) => JSX.Element | null; + component: React.ComponentType; } export interface WorkbenchViewComponentProps { diff --git a/packages/forms/frontend/sirius-components-widget-reference/src/components/ModelBrowserTreeView.tsx b/packages/forms/frontend/sirius-components-widget-reference/src/components/ModelBrowserTreeView.tsx index 669c493378..03e1f7a28c 100644 --- a/packages/forms/frontend/sirius-components-widget-reference/src/components/ModelBrowserTreeView.tsx +++ b/packages/forms/frontend/sirius-components-widget-reference/src/components/ModelBrowserTreeView.tsx @@ -14,7 +14,7 @@ import { TreeItemActionProps, TreeView } from '@eclipse-sirius/sirius-components-trees'; import UnfoldMoreIcon from '@mui/icons-material/UnfoldMore'; import IconButton from '@mui/material/IconButton'; -import { useState } from 'react'; +import { memo, useCallback, useState } from 'react'; import { makeStyles } from 'tss-react/mui'; import { ModelBrowserFilterBar } from './ModelBrowserFilterBar'; import { ModelBrowserTreeViewProps, ModelBrowserTreeViewState } from './ModelBrowserTreeView.types'; @@ -57,9 +57,9 @@ export const ModelBrowserTreeView = ({ }&descriptionId=${encodeURIComponent(widget.descriptionId)}&isContainment=${widget.reference.containment}`; const { tree } = useModelBrowserSubscription(editingContextId, treeId, state.expanded, state.maxDepth); - const onExpandedElementChange = (expanded: string[], maxDepth: number) => { + const onExpandedElementChange = useCallback((expanded: string[], maxDepth: number) => { setState((prevState) => ({ ...prevState, expanded, maxDepth })); - }; + }, []); return ( <> @@ -92,7 +92,7 @@ export const ModelBrowserTreeView = ({ ); }; -const WidgetReferenceTreeItemAction = ({ onExpandAll, item, isHovered }: TreeItemActionProps) => { +const WidgetReferenceTreeItemAction = memo(({ onExpandAll, item, isHovered }: TreeItemActionProps) => { if (!onExpandAll || !item || !item.hasChildren || !isHovered) { return null; } @@ -107,4 +107,4 @@ const WidgetReferenceTreeItemAction = ({ onExpandAll, item, isHovered }: TreeIte ); -}; +}); diff --git a/packages/forms/frontend/sirius-components-widget-reference/src/modals/BrowseModal.tsx b/packages/forms/frontend/sirius-components-widget-reference/src/modals/BrowseModal.tsx index f5af9da4fc..225b7e26a3 100644 --- a/packages/forms/frontend/sirius-components-widget-reference/src/modals/BrowseModal.tsx +++ b/packages/forms/frontend/sirius-components-widget-reference/src/modals/BrowseModal.tsx @@ -17,8 +17,8 @@ import Dialog from '@mui/material/Dialog'; import DialogActions from '@mui/material/DialogActions'; import DialogContent from '@mui/material/DialogContent'; import DialogTitle from '@mui/material/DialogTitle'; +import { useMemo, useState } from 'react'; import { makeStyles } from 'tss-react/mui'; -import { useState } from 'react'; import { ModelBrowserTreeView } from '../components/ModelBrowserTreeView'; import { BrowseModalProps } from './BrowseModal.types'; @@ -32,6 +32,8 @@ export const BrowseModal = ({ editingContextId, widget, onClose }: BrowseModalPr const { classes: styles } = useBrowserModalStyles(); const [browserSelection, setBrowserSelection] = useState({ entries: widget.referenceValues }); + const markedItemIds = useMemo(() => [], []); + return ( [], []); + return ( { + const onExpandedElementChange = useCallback((expanded: string[], maxDepth: number) => { setState((prevState) => ({ ...prevState, expanded, maxDepth })); - }; + }, []); return (
diff --git a/packages/sirius-web/frontend/sirius-web-application/src/views/explorer/ExplorerView.tsx b/packages/sirius-web/frontend/sirius-web-application/src/views/explorer/ExplorerView.tsx index 0268d6811c..f5515c75b1 100644 --- a/packages/sirius-web/frontend/sirius-web-application/src/views/explorer/ExplorerView.tsx +++ b/packages/sirius-web/frontend/sirius-web-application/src/views/explorer/ExplorerView.tsx @@ -21,7 +21,7 @@ import { useTreeFilters, } from '@eclipse-sirius/sirius-components-trees'; import { Theme } from '@mui/material/styles'; -import { useContext, useEffect, useRef, useState } from 'react'; +import { memo, useCallback, useContext, useEffect, useRef, useState } from 'react'; import { makeStyles } from 'tss-react/mui'; import { ExplorerViewState } from './ExplorerView.types'; import { TreeDescriptionsMenu } from './TreeDescriptionsMenu'; @@ -40,11 +40,10 @@ const useStyles = makeStyles()((theme: Theme) => ({ overflow: 'auto', }, })); - const isTreeRefreshedEventPayload = (payload: GQLTreeEventPayload): payload is GQLTreeRefreshedEventPayload => payload && payload.__typename === 'TreeRefreshedEventPayload'; -export const ExplorerView = ({ editingContextId, readOnly }: WorkbenchViewComponentProps) => { +export const ExplorerView = memo(({ editingContextId, readOnly }: WorkbenchViewComponentProps) => { const { classes: styles } = useStyles(); const initialState: ExplorerViewState = { @@ -58,6 +57,7 @@ export const ExplorerView = ({ editingContextId, readOnly }: WorkbenchViewCompon maxDepth: {}, tree: null, }; + const [state, setState] = useState(initialState); const treeToolBarContributionComponents = useContext(TreeToolBarContext).map( (contribution) => contribution.props.component @@ -146,7 +146,8 @@ export const ExplorerView = ({ editingContextId, readOnly }: WorkbenchViewCompon /> ); } - const onExpandedElementChange = (expanded: string[], maxDepth: number) => { + + const onExpandedElementChange = useCallback((expanded: string[], maxDepth: number) => { setState((prevState) => ({ ...prevState, expanded: { @@ -158,7 +159,7 @@ export const ExplorerView = ({ editingContextId, readOnly }: WorkbenchViewCompon [prevState.activeTreeDescriptionId]: maxDepth, }, })); - }; + }, []); const treeDescriptionSelector: JSX.Element = explorerDescriptions.length > 1 && (
); -}; +}); diff --git a/packages/trees/frontend/sirius-components-trees/package.json b/packages/trees/frontend/sirius-components-trees/package.json index c1d4502ece..bcdba07d98 100644 --- a/packages/trees/frontend/sirius-components-trees/package.json +++ b/packages/trees/frontend/sirius-components-trees/package.json @@ -34,8 +34,8 @@ "peerDependencies": { "@apollo/client": "3.10.4", "@eclipse-sirius/sirius-components-core": "*", - "@mui/material": "5.15.19", "@mui/icons-material": "5.15.19", + "@mui/material": "5.15.19", "@xstate/react": "3.0.0", "graphql": "16.8.1", "react": "18.3.1", @@ -47,22 +47,25 @@ "@apollo/client": "3.10.4", "@eclipse-sirius/sirius-components-core": "*", "@eclipse-sirius/sirius-components-tsconfig": "*", - "@mui/material": "5.15.19", "@mui/icons-material": "5.15.19", + "@mui/material": "5.15.19", "@types/react": "18.3.3", "@vitejs/plugin-react": "4.3.0", - "@xstate/react": "3.0.0", "@vitest/coverage-v8": "1.6.0", - "jsdom": "16.7.0", + "@xstate/react": "3.0.0", "graphql": "16.8.1", + "jsdom": "16.7.0", + "prettier": "2.7.1", "react": "18.3.1", "react-dom": "18.3.1", - "prettier": "2.7.1", "rollup-plugin-peer-deps-external": "2.2.4", - "xstate": "4.32.1", "tss-react": "4.9.7", "typescript": "5.4.5", "vite": "5.2.11", - "vitest": "1.6.0" + "vitest": "1.6.0", + "xstate": "4.32.1" + }, + "dependencies": { + "zustand": "^5.0.0" } } diff --git a/packages/trees/frontend/sirius-components-trees/src/store/treeStore.ts b/packages/trees/frontend/sirius-components-trees/src/store/treeStore.ts new file mode 100644 index 0000000000..61a69d2bd4 --- /dev/null +++ b/packages/trees/frontend/sirius-components-trees/src/store/treeStore.ts @@ -0,0 +1,28 @@ +import { create } from 'zustand'; + +export type StoreType = { + expanded: string[]; + maxDepth: number; + updateMaxDepth: (maxDepth) => void; + updateExpended: (newExpended) => void; + onExpand: (id, depth) => void; +}; + +export const useTreeStore = create((set, get) => ({ + expanded: [], + maxDepth: 0, + updateMaxDepth: (newMaxDepth) => set(() => ({ maxDepth: newMaxDepth })), + updateExpended: (newExpended) => set(() => ({ expanded: newExpended })), + onExpand: (id, depth) => { + console.log('onExpand ' + id + ' - ' + depth); + const currentExpanded = get().expanded; + const currentMaxDepth = get().maxDepth; + if (currentExpanded.includes(id)) { + const newExpanded = [...currentExpanded]; + newExpanded.splice(newExpanded.indexOf(id), 1); + set((state) => ({ ...state, expanded: newExpanded, maxDepth: Math.max(currentMaxDepth, depth) })); + } else { + set((state) => ({ ...state, expanded: [...currentExpanded, id], maxDepth: Math.max(currentMaxDepth, depth) })); + } + }, +})); diff --git a/packages/trees/frontend/sirius-components-trees/src/treeitems/TreeItem.tsx b/packages/trees/frontend/sirius-components-trees/src/treeitems/TreeItem.tsx index e58300ce02..2922f22fcd 100644 --- a/packages/trees/frontend/sirius-components-trees/src/treeitems/TreeItem.tsx +++ b/packages/trees/frontend/sirius-components-trees/src/treeitems/TreeItem.tsx @@ -20,8 +20,9 @@ import { useSelection, } from '@eclipse-sirius/sirius-components-core'; import CropDinIcon from '@mui/icons-material/CropDin'; -import React, { useEffect, useRef, useState } from 'react'; +import React, { memo, useCallback, useEffect, useRef, useState } from 'react'; import { makeStyles } from 'tss-react/mui'; +import { useTreeStore } from '../store/treeStore'; import { TreeItemProps, TreeItemState } from './TreeItem.types'; import { TreeItemAction } from './TreeItemAction'; import { TreeItemArrow } from './TreeItemArrow'; @@ -102,288 +103,286 @@ export const getString = (styledString: GQLStyledString): string => { // The list of characters that will enable the direct edit mechanism. const directEditActivationValidCharacters = /[\w&é§èàùçÔØÁÛÊË"«»’”„´$¥€£\\¿?!=+-,;:%/{}[\]–#@*.]/; -export const TreeItem = ({ - editingContextId, - treeId, - item, - depth, - onExpand, - onExpandAll, - readOnly, - textToHighlight, - textToFilter, - enableMultiSelection, - markedItemIds, - treeItemActionRender, -}: TreeItemProps) => { - const { classes } = useTreeItemStyle(); +export const TreeItem = memo( + ({ + editingContextId, + treeId, + item, + depth, + onExpandAll, + readOnly, + textToHighlight, + textToFilter, + enableMultiSelection, + markedItemIds, + treeItemActionRender, + }: TreeItemProps) => { + const { classes } = useTreeItemStyle(); + const onExpand = useTreeStore((state) => state.onExpand); - const initialState: TreeItemState = { - editingMode: false, - editingKey: null, - isHovered: false, - }; + const initialState: TreeItemState = { + editingMode: false, + editingKey: null, + isHovered: false, + }; - const [state, setState] = useState(initialState); - const { editingMode } = state; + const [state, setState] = useState(initialState); + const { editingMode } = state; - const refDom = useRef() as any; + const refDom = useRef(); - const { selection, setSelection } = useSelection(); + const { selection, setSelection } = useSelection(); - const handleMouseEnter = () => { - setState((prevState) => { - return { ...prevState, isHovered: true }; - }); - }; + const handleMouseEnter = () => { + setState((prevState) => { + return { ...prevState, isHovered: true }; + }); + }; - const handleMouseLeave = () => { - setState((prevState) => { - return { ...prevState, isHovered: false }; - }); - }; + const handleMouseLeave = () => { + setState((prevState) => { + return { ...prevState, isHovered: false }; + }); + }; - const onTreeItemAction = () => { - setState((prevState) => { - return { ...prevState, isHovered: false }; - }); - }; + const onTreeItemAction = () => { + setState((prevState) => { + return { ...prevState, isHovered: false }; + }); + }; - const enterEditingMode = () => { - setState((prevState) => ({ - ...prevState, - editingMode: true, - editingKey: null, - })); - }; + const enterEditingMode = useCallback(() => { + setState((prevState) => ({ + ...prevState, + editingMode: true, + editingKey: null, + })); + }, []); - let content = null; - if (item.expanded && item.children) { - content = ( -
    - {item.children.map((childItem) => { - return ( -
  • - -
  • - ); - })} -
- ); - } + let content = null; + if (item.expanded && item.children) { + content = ( +
    + {item.children.map((childItem) => { + return ( +
  • + +
  • + ); + })} +
+ ); + } - let className = classes.treeItem; - let dataTestid = undefined; + let className = classes.treeItem; + let dataTestid = undefined; - const selected = selection.entries.find((entry) => entry.id === item.id); - if (selected) { - className = `${className} ${classes.selected}`; - dataTestid = 'selected'; - } - if (state.isHovered && item.selectable) { - className = `${className} ${classes.treeItemHover}`; - } - useEffect(() => { + const selected = selection.entries.find((entry) => entry.id === item.id); if (selected) { - if (refDom.current?.scrollIntoViewIfNeeded) { - refDom.current.scrollIntoViewIfNeeded(true); - } else { + className = `${className} ${classes.selected}`; + dataTestid = 'selected'; + } + if (state.isHovered && item.selectable) { + className = `${className} ${classes.treeItemHover}`; + } + useEffect(() => { + if (selected && refDom.current) { // Fallback for browsers not supporting the non-standard `scrollIntoViewIfNeeded` - refDom.current?.scrollIntoView({ behavior: 'smooth' }); + refDom.current.scrollIntoView({ behavior: 'smooth', block: 'nearest', inline: 'nearest' }); } - } - }, [selected]); - - let image = ; - if (item.iconURL?.length > 0) { - image = ; - } - let text: JSX.Element | null = null; - const onCloseEditingMode = () => { - setState((prevState) => { - return { ...prevState, editingMode: false }; - }); - refDom.current.focus(); - }; + }, [selected]); - const marked: boolean = markedItemIds.some((id) => id === item.id); - if (editingMode) { - text = ( - - ); - } else { - const styledLabelProps = { - styledString: item.label, - selected: false, - textToHighlight: textToHighlight, - marked: marked, + let image = ; + if (item.iconURL?.length > 0) { + image = ; + } + let text: JSX.Element | null = null; + const onCloseEditingMode = () => { + setState((prevState) => { + return { ...prevState, editingMode: false }; + }); + refDom.current.focus(); }; - text = ; - } - const onClick: React.MouseEventHandler = (event) => { - if (!state.editingMode && event.currentTarget.contains(event.target as HTMLElement)) { - refDom.current.focus(); - if (!item.selectable) { - return; - } + const marked: boolean = markedItemIds && markedItemIds.some((id) => id === item.id); + if (editingMode) { + text = ( + + ); + } else { + const styledLabelProps = { + styledString: item.label, + selected: false, + textToHighlight: textToHighlight, + marked: marked, + }; + text = ; + } + + const onClick: React.MouseEventHandler = (event) => { + if (refDom.current && !state.editingMode && event.currentTarget.contains(event.target as HTMLElement)) { + refDom.current.focus(); + if (!item.selectable) { + return; + } - if ((event.ctrlKey || event.metaKey) && enableMultiSelection) { - event.stopPropagation(); - const isItemInSelection = selection.entries.find((entry) => entry.id === item.id); - if (isItemInSelection) { - const newSelection: Selection = { entries: selection.entries.filter((entry) => entry.id !== item.id) }; - setSelection(newSelection); + if ((event.ctrlKey || event.metaKey) && enableMultiSelection) { + event.stopPropagation(); + const isItemInSelection = selection.entries.find((entry) => entry.id === item.id); + if (isItemInSelection) { + const newSelection: Selection = { entries: selection.entries.filter((entry) => entry.id !== item.id) }; + setSelection(newSelection); + } else { + const { id, label, kind } = item; + const newEntry = { id, label: getString(label), kind }; + const newSelection: Selection = { entries: [...selection.entries, newEntry] }; + setSelection(newSelection); + } } else { - const { id, label, kind } = item; - const newEntry = { id, label: getString(label), kind }; - const newSelection: Selection = { entries: [...selection.entries, newEntry] }; - setSelection(newSelection); + const { id, kind } = item; + setSelection({ entries: [{ id, kind }] }); } - } else { - const { id, kind } = item; - setSelection({ entries: [{ id, kind }] }); } - } - }; + }; - const onBeginEditing = (event) => { - if (!item.editable || editingMode || readOnly || !event.currentTarget.contains(event.target as HTMLElement)) { - return; - } - const { key } = event; - /*If a modifier key is hit alone, do nothing*/ - if ((event.altKey || event.shiftKey) && event.getModifierState(key)) { - return; - } - const validFirstInputChar = - !event.metaKey && !event.ctrlKey && key.length === 1 && directEditActivationValidCharacters.test(key); - if (validFirstInputChar) { - setState((prevState) => { - return { ...prevState, editingMode: true, editingKey: key }; - }); - } - }; + const onBeginEditing = (event) => { + if (!item.editable || editingMode || readOnly || !event.currentTarget.contains(event.target as HTMLElement)) { + return; + } + const { key } = event; + /*If a modifier key is hit alone, do nothing*/ + if ((event.altKey || event.shiftKey) && event.getModifierState(key)) { + return; + } + const validFirstInputChar = + !event.metaKey && !event.ctrlKey && key.length === 1 && directEditActivationValidCharacters.test(key); + if (validFirstInputChar) { + setState((prevState) => { + return { ...prevState, editingMode: true, editingKey: key }; + }); + } + }; - const dragStart: React.DragEventHandler = (event) => { - const isDraggedItemSelected = selection.entries.map((entry) => entry.id).includes(item.id); - if (!isDraggedItemSelected) { - // If we're dragging a non-selected item, drag it alone - const itemEntry: SelectionEntry = { id: item.id, kind: item.kind }; - event.dataTransfer.setData(DRAG_SOURCES_TYPE, JSON.stringify([itemEntry])); - } else if (selection.entries.length > 0) { - // Otherwise drag the whole selection - event.dataTransfer.setData(DRAG_SOURCES_TYPE, JSON.stringify(selection.entries)); - } - }; + const dragStart: React.DragEventHandler = (event) => { + const isDraggedItemSelected = selection.entries.map((entry) => entry.id).includes(item.id); + if (!isDraggedItemSelected) { + // If we're dragging a non-selected item, drag it alone + const itemEntry: SelectionEntry = { id: item.id, kind: item.kind }; + event.dataTransfer.setData(DRAG_SOURCES_TYPE, JSON.stringify([itemEntry])); + } else if (selection.entries.length > 0) { + // Otherwise drag the whole selection + event.dataTransfer.setData(DRAG_SOURCES_TYPE, JSON.stringify(selection.entries)); + } + }; - const dragOver: React.DragEventHandler = (event) => { - event.stopPropagation(); - }; + const dragOver: React.DragEventHandler = (event) => { + event.stopPropagation(); + }; - let tooltipText = ''; - if (item.kind.startsWith('siriusComponents://semantic')) { - const query = item.kind.substring(item.kind.indexOf('?') + 1, item.kind.length); - const params = new URLSearchParams(query); - if (params.has('domain') && params.has('entity')) { - tooltipText = params.get('domain') + '::' + params.get('entity'); - } - } else if (item.kind.startsWith('siriusComponents://representation')) { - const query = item.kind.substring(item.kind.indexOf('?') + 1, item.kind.length); - const params = new URLSearchParams(query); - if (params.has('type')) { - tooltipText = params.get('type'); + let tooltipText = ''; + if (item.kind.startsWith('siriusComponents://semantic')) { + const query = item.kind.substring(item.kind.indexOf('?') + 1, item.kind.length); + const params = new URLSearchParams(query); + if (params.has('domain') && params.has('entity')) { + tooltipText = params.get('domain') + '::' + params.get('entity'); + } + } else if (item.kind.startsWith('siriusComponents://representation')) { + const query = item.kind.substring(item.kind.indexOf('?') + 1, item.kind.length); + const params = new URLSearchParams(query); + if (params.has('type')) { + tooltipText = params.get('type'); + } } - } - let currentTreeItem: JSX.Element | null; - if (textToFilter && isFilterCandidate(item, textToFilter)) { - currentTreeItem = null; - } else { - const label = getString(item.label); - /* ref, tabindex and onFocus are used to set the React component focusabled and to set the focus to the corresponding DOM part */ - currentTreeItem = ( - <> -
- + let currentTreeItem: JSX.Element | null; + if (textToFilter && isFilterCandidate(item, textToFilter)) { + currentTreeItem = null; + } else { + const label = getString(item.label); + /* ref, tabindex and onFocus are used to set the React component focusabled and to set the focus to the corresponding DOM part */ + currentTreeItem = ( + <>
-
-
item.hasChildren && onExpand(item.id, depth)} - title={tooltipText} - data-testid={label}> - {image} - {text} -
-
- {treeItemActionRender ? ( - treeItemActionRender({ - editingContextId: editingContextId, - treeId: treeId, - item: item, - depth: depth, - onExpand: onExpand, - onExpandAll: onExpandAll, - readOnly: readOnly, - onEnterEditingMode: enterEditingMode, - isHovered: state.isHovered, - }) - ) : ( - - )} + key={item.id} + className={className} + draggable={true} + onClick={onClick} + onDragStart={dragStart} + onDragOver={dragOver} + onMouseEnter={handleMouseEnter} + onMouseLeave={handleMouseLeave}> + +
+
+
item.hasChildren && onExpand(item.id, depth)} + title={tooltipText} + data-testid={label}> + {image} + {text} +
+
+ {treeItemActionRender ? ( + treeItemActionRender({ + editingContextId: editingContextId, + treeId: treeId, + item: item, + depth: depth, + onExpand: onExpand, + onExpandAll: onExpandAll, + readOnly: readOnly, + onEnterEditingMode: enterEditingMode, + isHovered: state.isHovered, + }) + ) : ( + + )} +
-
- {content} - - ); + {content} + + ); + } + return <>{currentTreeItem}; } - return <>{currentTreeItem}; -}; +); diff --git a/packages/trees/frontend/sirius-components-trees/src/treeitems/TreeItem.types.ts b/packages/trees/frontend/sirius-components-trees/src/treeitems/TreeItem.types.ts index 40346ec187..8327538a1a 100644 --- a/packages/trees/frontend/sirius-components-trees/src/treeitems/TreeItem.types.ts +++ b/packages/trees/frontend/sirius-components-trees/src/treeitems/TreeItem.types.ts @@ -18,7 +18,6 @@ export interface TreeItemProps { treeId: string; item: GQLTreeItem; depth: number; - onExpand: (id: string, depth: number) => void; onExpandAll: (treeItem: GQLTreeItem) => void; readOnly: boolean; textToHighlight: string | null; diff --git a/packages/trees/frontend/sirius-components-trees/src/treeitems/TreeItemAction.tsx b/packages/trees/frontend/sirius-components-trees/src/treeitems/TreeItemAction.tsx index db9248a761..b2888d9ba2 100644 --- a/packages/trees/frontend/sirius-components-trees/src/treeitems/TreeItemAction.tsx +++ b/packages/trees/frontend/sirius-components-trees/src/treeitems/TreeItemAction.tsx @@ -10,10 +10,10 @@ * Contributors: * Obeo - initial API and implementation *******************************************************************************/ +import MoreVertIcon from '@mui/icons-material/MoreVert'; import IconButton from '@mui/material/IconButton'; +import { memo, useCallback, useState } from 'react'; import { makeStyles } from 'tss-react/mui'; -import MoreVertIcon from '@mui/icons-material/MoreVert'; -import { useState } from 'react'; import { getString } from './TreeItem'; import { TreeItemActionProps, TreeItemActionState } from './TreeItemAction.types'; import { TreeItemContextMenu } from './TreeItemContextMenu'; @@ -29,77 +29,79 @@ const useTreeItemActionStyle = makeStyles()((theme) => ({ }, })); -export const TreeItemAction = ({ - editingContextId, - treeId, - item, - readOnly, - depth, - onExpand, - onExpandAll, - onEnterEditingMode, -}: TreeItemActionProps) => { - const { classes } = useTreeItemActionStyle(); - const [state, setState] = useState({ - showContextMenu: false, - menuAnchor: null, - }); +export const TreeItemAction = memo( + ({ + editingContextId, + treeId, + item, + readOnly, + depth, + onExpand, + onExpandAll, + onEnterEditingMode, + }: TreeItemActionProps) => { + const { classes } = useTreeItemActionStyle(); + const [state, setState] = useState({ + showContextMenu: false, + menuAnchor: null, + }); - const openContextMenu = (event) => { - if (!state.showContextMenu) { - const { currentTarget } = event; - setState((prevState) => ({ - ...prevState, - showContextMenu: true, - menuAnchor: currentTarget, - })); - } - }; - - let contextMenu = null; - if (state.showContextMenu) { - const closeContextMenu = () => { - setState((prevState) => ({ - ...prevState, - showContextMenu: false, - menuAnchor: null, - })); - }; - const enterEditingMode = () => { - setState((prevState) => ({ - ...prevState, - showContextMenu: false, - menuAnchor: null, - })); - onEnterEditingMode(); + const openContextMenu = (event) => { + if (!state.showContextMenu) { + const { currentTarget } = event; + setState((prevState) => ({ + ...prevState, + showContextMenu: true, + menuAnchor: currentTarget, + })); + } }; - contextMenu = ( - + let contextMenu = null; + if (state.showContextMenu) { + const closeContextMenu = () => { + setState((prevState) => ({ + ...prevState, + showContextMenu: false, + menuAnchor: null, + })); + }; + const enterEditingMode = useCallback(() => { + setState((prevState) => ({ + ...prevState, + showContextMenu: false, + menuAnchor: null, + })); + onEnterEditingMode(); + }, []); + + contextMenu = ( + + ); + } + + return ( + <> + + + + {contextMenu} + ); } - - return ( - <> - - - - {contextMenu} - - ); -}; +); diff --git a/packages/trees/frontend/sirius-components-trees/src/treeitems/TreeItemArrow.tsx b/packages/trees/frontend/sirius-components-trees/src/treeitems/TreeItemArrow.tsx index ff439e9b1c..7af5a818ea 100644 --- a/packages/trees/frontend/sirius-components-trees/src/treeitems/TreeItemArrow.tsx +++ b/packages/trees/frontend/sirius-components-trees/src/treeitems/TreeItemArrow.tsx @@ -13,6 +13,7 @@ import ChevronRightIcon from '@mui/icons-material/ChevronRight'; import ExpandMoreIcon from '@mui/icons-material/ExpandMore'; +import { memo } from 'react'; import { makeStyles } from 'tss-react/mui'; import { TreeItemArrowProps } from './TreeItemArrow.types'; @@ -25,7 +26,7 @@ const useTreeItemArrowStyle = makeStyles()(() => ({ }, })); -export const TreeItemArrow = ({ item, depth, onExpand, 'data-testid': dataTestid }: TreeItemArrowProps) => { +export const TreeItemArrow = memo(({ item, depth, onExpand, 'data-testid': dataTestid }: TreeItemArrowProps) => { const { classes } = useTreeItemArrowStyle(); if (item.hasChildren) { const onClick = () => onExpand(item.id, depth); @@ -45,4 +46,4 @@ export const TreeItemArrow = ({ item, depth, onExpand, 'data-testid': dataTestid } } return
; -}; +}); diff --git a/packages/trees/frontend/sirius-components-trees/src/trees/Tree.tsx b/packages/trees/frontend/sirius-components-trees/src/trees/Tree.tsx index 08e79e2091..48414ece1c 100644 --- a/packages/trees/frontend/sirius-components-trees/src/trees/Tree.tsx +++ b/packages/trees/frontend/sirius-components-trees/src/trees/Tree.tsx @@ -13,6 +13,7 @@ import { makeStyles } from 'tss-react/mui'; import { useEffect, useRef } from 'react'; +import { useTreeStore } from '../store/treeStore'; import { TreeItem } from '../treeitems/TreeItem'; import { TreeProps } from './Tree.types'; @@ -26,7 +27,6 @@ const useTreeStyle = makeStyles()((_) => ({ export const Tree = ({ editingContextId, tree, - onExpand, onExpandAll, readOnly, enableMultiSelection = true, @@ -37,7 +37,7 @@ export const Tree = ({ }: TreeProps) => { const { classes } = useTreeStyle(); const treeElement = useRef(null); - + const onExpand = useTreeStore((state) => state.onExpand); useEffect(() => { const downHandler = (event) => { if ( @@ -116,7 +116,6 @@ export const Tree = ({ treeId={tree.id} item={item} depth={1} - onExpand={onExpand} onExpandAll={onExpandAll} enableMultiSelection={enableMultiSelection} readOnly={readOnly} diff --git a/packages/trees/frontend/sirius-components-trees/src/trees/Tree.types.ts b/packages/trees/frontend/sirius-components-trees/src/trees/Tree.types.ts index fee5521ca0..ea245b171a 100644 --- a/packages/trees/frontend/sirius-components-trees/src/trees/Tree.types.ts +++ b/packages/trees/frontend/sirius-components-trees/src/trees/Tree.types.ts @@ -10,13 +10,12 @@ * Contributors: * Obeo - initial API and implementation *******************************************************************************/ -import { GQLTree, GQLTreeItem } from '../views/TreeView.types'; import { TreeItemActionProps } from '../treeitems/TreeItemAction.types'; +import { GQLTree, GQLTreeItem } from '../views/TreeView.types'; export interface TreeProps { editingContextId: string; tree: GQLTree; - onExpand: (id: string, depth: number) => void; onExpandAll: (treeItem: GQLTreeItem) => void; readOnly: boolean; enableMultiSelection: boolean; diff --git a/packages/trees/frontend/sirius-components-trees/src/views/TreeView.tsx b/packages/trees/frontend/sirius-components-trees/src/views/TreeView.tsx index f9c4c684cf..6b90fd9a20 100644 --- a/packages/trees/frontend/sirius-components-trees/src/views/TreeView.tsx +++ b/packages/trees/frontend/sirius-components-trees/src/views/TreeView.tsx @@ -12,7 +12,8 @@ *******************************************************************************/ import { gql, useLazyQuery } from '@apollo/client'; import { DataExtension, useData, useMultiToast, useSelection } from '@eclipse-sirius/sirius-components-core'; -import { useEffect, useState } from 'react'; +import { useCallback, useEffect, useMemo } from 'react'; +import { useTreeStore } from '../store/treeStore'; import { Tree } from '../trees/Tree'; import { GQLGetExpandAllTreePathData, @@ -23,7 +24,6 @@ import { GQLTreeItem, TreeConverter, TreeViewProps, - TreeViewState, } from './TreeView.types'; import { treeViewTreeConverterExtensionPoint } from './TreeViewExtensionPoints'; @@ -62,16 +62,26 @@ export const TreeView = ({ synchronizedWithSelection, textToHighlight, textToFilter, - markedItemIds = [], + markedItemIds, treeItemActionRender, onExpandedElementChange, - expanded, - maxDepth, + expanded: expandedProp, + maxDepth: maxDepthProp, }: TreeViewProps) => { - const [state, setState] = useState({ - expanded: expanded, - maxDepth: maxDepth, - }); + const updateExpended = useTreeStore((state) => state.updateExpended); + const updateMaxDepth = useTreeStore((state) => state.updateMaxDepth); + + const expanded = useTreeStore((state) => state.expanded); + const maxDepth = useTreeStore((state) => state.maxDepth); + + useEffect(() => { + updateExpended(expandedProp); + updateMaxDepth(maxDepthProp); + }, [expandedProp, maxDepthProp]); + + useEffect(() => { + onExpandedElementChange(expanded, maxDepth); + }, [expanded, maxDepth]); const { selection } = useSelection(); const [getTreePath, { loading: treePathLoading, data: treePathData, error: treePathError }] = useLazyQuery< @@ -103,7 +113,6 @@ export const TreeView = ({ useEffect(() => { if (!treePathLoading) { if (treePathData) { - const { expanded, maxDepth } = state; if (treePathData.viewer?.editingContext?.treePath) { const { treeItemIdsToExpand, maxDepth: expandedMaxDepth } = treePathData.viewer.editingContext.treePath; const newExpanded: string[] = [...expanded]; @@ -113,11 +122,8 @@ export const TreeView = ({ newExpanded.push(itemToExpand); } }); - setState((prevState) => ({ - ...prevState, - expanded: newExpanded, - maxDepth: Math.max(expandedMaxDepth, maxDepth), - })); + updateExpended(newExpanded); + updateMaxDepth(Math.max(expandedMaxDepth, maxDepth)); } } } @@ -126,7 +132,6 @@ export const TreeView = ({ useEffect(() => { if (!expandAllTreePathLoading) { if (expandAllTreePathData) { - const { expanded, maxDepth } = state; if (expandAllTreePathData.viewer?.editingContext?.expandAllTreePath) { const { treeItemIdsToExpand, maxDepth: expandedMaxDepth } = expandAllTreePathData.viewer.editingContext.expandAllTreePath; @@ -137,11 +142,8 @@ export const TreeView = ({ newExpanded.push(itemToExpand); } }); - setState((prevState) => ({ - ...prevState, - expanded: newExpanded, - maxDepth: Math.max(expandedMaxDepth, maxDepth), - })); + updateExpended(newExpanded); + updateMaxDepth(Math.max(expandedMaxDepth, maxDepth)); } } } @@ -159,49 +161,30 @@ export const TreeView = ({ } }, [treePathError]); - const onExpand = (id: string, depth: number) => { - const { expanded, maxDepth } = state; - - if (expanded.includes(id)) { - const newExpanded = [...expanded]; - newExpanded.splice(newExpanded.indexOf(id), 1); - - setState((prevState) => ({ - ...prevState, - expanded: newExpanded, - maxDepth: Math.max(maxDepth, depth), - })); - } else { - setState((prevState) => ({ ...prevState, expanded: [...expanded, id], maxDepth: Math.max(maxDepth, depth) })); - } - }; - - useEffect(() => { - onExpandedElementChange(state.expanded, state.maxDepth); - }, [state.expanded, state.maxDepth]); - - const onExpandAll = (treeItem: GQLTreeItem) => { + const onExpandAll = useCallback((treeItem: GQLTreeItem) => { const variables: GQLGetExpandAllTreePathVariables = { editingContextId, treeId: tree.id, treeItemId: treeItem.id, }; getExpandAllTreePath({ variables }); - }; + }, []); const { data: treeConverters }: DataExtension = useData(treeViewTreeConverterExtensionPoint); - let convertedTree: GQLTree = tree; - treeConverters.forEach((treeConverter) => { - convertedTree = treeConverter.convert(editingContextId, convertedTree); - }); + let renderedTree: GQLTree = useMemo(() => { + let convertedTree: GQLTree = tree; + treeConverters.forEach((treeConverter) => { + convertedTree = treeConverter.convert(editingContextId, convertedTree); + }); + return convertedTree; + }, [tree]); return (