diff --git a/src/__test__/pages/experiments/[experimentId]/plots-and-tables/marker-heatmap/index.test.jsx b/src/__test__/pages/experiments/[experimentId]/plots-and-tables/marker-heatmap/index.test.jsx index cfd0833e78..6d80c887b2 100644 --- a/src/__test__/pages/experiments/[experimentId]/plots-and-tables/marker-heatmap/index.test.jsx +++ b/src/__test__/pages/experiments/[experimentId]/plots-and-tables/marker-heatmap/index.test.jsx @@ -1,4 +1,6 @@ import { render, screen } from '@testing-library/react'; +import { mount } from 'enzyme'; +import { within } from '@testing-library/dom'; import userEvent from '@testing-library/user-event'; import fetchMock, { enableFetchMocks } from 'jest-fetch-mock'; import _ from 'lodash'; @@ -14,6 +16,8 @@ import expressionDataFAKEGENE from '__test__/data/gene_expression_FAKEGENE.json' import markerGenesData2 from '__test__/data/marker_genes_2.json'; import markerGenesData5 from '__test__/data/marker_genes_5.json'; +import { Tree } from 'antd'; + import preloadAll from 'jest-next-dynamic'; import fake from '__test__/test-utils/constants'; @@ -23,6 +27,8 @@ import mockAPI, { statusResponse, } from '__test__/test-utils/mockAPI'; import createTestComponentFactory from '__test__/test-utils/testComponentFactory'; +import waitForComponentToPaint from '__test__/test-utils/waitForComponentToPaint'; +import { arrayMoveImmutable } from 'utils/array-move'; jest.mock('components/header/UserButton', () => () => <>>); jest.mock('react-resize-detector', () => (props) => { @@ -81,6 +87,23 @@ const getDisplayedGenes = (container) => { return Array.from(genesNodeList).map((gene) => gene.textContent); }; +// Helper function to get genes held within the tree +const getTreeGenes = (container) => { + const treeNodeList = container.querySelectorAll('span[class*=ant-tree-title]'); + return Array.from(treeNodeList).map((node) => node.firstChild.firstChild.textContent); +}; + +// Helper function to get current order of displayed genes in enzyme tests +const getCurrentGeneOrder = (component) => { + const treeNodes = component.find('div.ant-tree-treenode'); + const newOrder = [] + treeNodes.forEach((node) => { + newOrder.push(node.text()); + }); + newOrder.splice(0, 1); + return newOrder; +} + const renderHeatmapPage = async (store) => { await act(async () => ( render( @@ -91,6 +114,14 @@ const renderHeatmapPage = async (store) => { )); }; +const renderHeatmapPageForEnzyme = async (store) => ( + mount( + + {heatmapPageFactory()} + , + ) +); + describe('Marker heatmap plot', () => { beforeAll(async () => { await preloadAll(); @@ -275,4 +306,143 @@ describe('Marker heatmap plot', () => { // The list of displayed genes should be in the same order as the displayed genes expect(_.isEqual(genesListAfterRemoval, genesListBeforeRemoval)).toEqual(true); }); + + it('loads the tabs under gene selection', async () => { + await renderHeatmapPage(storeState); + + expect(screen.getByText(/Add\/Remove genes/i)).toBeInTheDocument(); + expect(screen.getByText(/Re-order genes/i)).toBeInTheDocument(); + }); + + it('switches tabs and removes genes within the tree', async () => { + await renderHeatmapPage(storeState); + + await act(async () => { + userEvent.click(screen.getByText('Re-order genes')); + }); + // note: clicking another tab doesn't remove previous tab from screen + // screen.getByText will find multiples of the same gene -> use within(geneTree) + + const geneTree = screen.getByRole('tree'); + + // The genes in Data 5 should be in the tree + markerGenesData5.order.forEach((geneName) => { + expect(within(geneTree).getByText(geneName)).toBeInTheDocument(); + }); + + // Remove a gene using the X button + const genesListBeforeRemoval = getTreeGenes(geneTree); + + const geneToRemove = within(geneTree).getByText(genesListBeforeRemoval[1]); + + const geneRemoveButton = geneToRemove.nextSibling.firstChild; + + userEvent.click(geneRemoveButton); + + const genesListAfterRemoval = getTreeGenes(geneTree); + + // remove element from list manually to compare + genesListBeforeRemoval.splice(1, 1); + + // The gene should be deleted from the list + expect(_.isEqual(genesListAfterRemoval, genesListBeforeRemoval)).toEqual(true); + }); + + describe('Drag and drop enzyme tests', () => { + let component; + let tree; + + beforeEach(async () => { + component = await renderHeatmapPageForEnzyme(storeState); + + await waitForComponentToPaint(component); + + await act(async () => { + // needs to find specifically the tab button to click + const reorderTab = component.find('div.ant-tabs-tab-btn'); + reorderTab.at(1).simulate('click'); + }); + + component.update(); + + // this finds 5 elements, use the first one + tree = component.find({ 'data-testid': 'HierachicalTreeGenes' }); + }); + + it('changes nothing on drop in place', async () => { + // default genes are in the tree + markerGenesData5.order.forEach((geneName) => { + expect(tree.at(0).containsMatchingElement(geneName)); + }); + + // dropping in place does nothing + const info = { + dragNode: { key: 1, pos: '0-1' }, + node: { key: 1, pos: '0-1' }, + dropPosition: 1, + dropToGap: true, + }; + + tree.at(0).getElement().props.onDrop(info); + + await act(async () => { + component.update(); + }); + + const newOrder = getCurrentGeneOrder(component); + + expect(_.isEqual(newOrder, markerGenesData5.order)).toEqual(true); + }); + + it('changes nothing when not dropped in gap', async () => { + // default genes are in the tree + markerGenesData5.order.forEach((geneName) => { + expect(tree.at(0).containsMatchingElement(geneName)); + }); + + // not dropping to gap does nothing + const info = { + dragNode: { key: 1, pos: '0-1' }, + node: { key: 3, pos: '0-3' }, + dropPosition: 4, + dropToGap: false, + }; + + tree.at(0).getElement().props.onDrop(info); + + await act(async () => { + component.update(); + }); + + const newOrder = getCurrentGeneOrder(component); + + expect(_.isEqual(newOrder, markerGenesData5.order)).toEqual(true); + }); + + it('re-orders genes correctly', async () => { + // default genes are in the tree + markerGenesData5.order.forEach((geneName) => { + expect(tree.at(0).containsMatchingElement(geneName)); + }); + // dropping to gap re-orders genes + const info = { + dragNode: { key: 1, pos: '0-1' }, + node: { key: 3, pos: '0-3' }, + dropPosition: 4, + dropToGap: true, + }; + + tree.at(0).getElement().props.onDrop(info); + + await act(async () => { + component.update(); + }); + + const newOrder = getCurrentGeneOrder(component); + + const expectedOrder = arrayMoveImmutable(markerGenesData5.order, 1, 3); + + expect(_.isEqual(newOrder, expectedOrder)).toEqual(true); + }); + }); }); diff --git a/src/components/plots/GeneReorderTool.jsx b/src/components/plots/GeneReorderTool.jsx new file mode 100644 index 0000000000..c95023b65d --- /dev/null +++ b/src/components/plots/GeneReorderTool.jsx @@ -0,0 +1,106 @@ +import React, { useEffect, useState } from 'react'; +import { useDispatch, useSelector } from 'react-redux'; +import PropTypes from 'prop-types'; + +import { arrayMoveImmutable } from 'utils/array-move'; +import { updatePlotConfig } from 'redux/actions/componentConfig'; +import { loadGeneExpression } from 'redux/actions/genes'; +import HierarchicalTreeGenes from 'components/plots/hierarchical-tree-genes/HierarchicalTreeGenes'; + +import { Space, Button } from 'antd'; +import { CloseOutlined } from '@ant-design/icons'; + +const GeneReorderTool = (props) => { + const { plotUuid } = (props); + + const dispatch = useDispatch(); + + const config = useSelector((state) => state.componentConfig[plotUuid]?.config); + + const experimentId = useSelector((state) => state.componentConfig[plotUuid]?.experimentId); + + const loadedMarkerGenes = useSelector( + (state) => state.genes.expression.views[plotUuid]?.data, + ); + + // Tree from antd requires format [{key: , title: }], made from gene names from loadedMarkerGenes and config + const composeGeneTree = (treeGenes) => { + if (!treeGenes) { + return []; + } + + const data = []; + Object.entries(treeGenes).forEach(([key, value]) => { + data.push({ key: `${key}`, title: `${value}` }); + }); + return data; + }; + + const [geneTreeData, setGeneTreeData] = useState(composeGeneTree(loadedMarkerGenes)); + + useEffect(() => { + setGeneTreeData(composeGeneTree(config?.selectedGenes)); + }, [config?.selectedGenes]); + + // geneKey is equivalent to it's index, moves a gene from pos geneKey to newPosition + // dispatches an action to update selectedGenes in config + const onGeneReorder = (geneKey, newPosition) => { + const oldOrder = geneTreeData.map((treeNode) => treeNode.title); + + const newOrder = arrayMoveImmutable(Object.values(oldOrder), geneKey, newPosition); + + dispatch(updatePlotConfig(plotUuid, { selectedGenes: newOrder })); + }; + + const onNodeDelete = (geneKey) => { + const genes = geneTreeData.map((treeNode) => treeNode.title); + genes.splice(geneKey, 1); + + dispatch(loadGeneExpression(experimentId, genes, plotUuid)); + }; + + const renderTitles = (data) => { + // replace every title (gene name) in tree data with a modified title (name + button) + const toRender = data.map((treeNode) => { + // modified needs to be a copy of a given node + const modified = { ...treeNode }; + modified.title = ( + + {treeNode.title} + { + onNodeDelete(treeNode.key); + }} + > + + + + ); + return modified; + }); + return toRender; + }; + + const [renderedTreeData, setRenderedTreeData] = useState([]); + + useEffect(() => { + setRenderedTreeData(renderTitles(geneTreeData)); + }, [geneTreeData]); + + return ( + + ); +}; + +GeneReorderTool.defaultProps = {}; + +GeneReorderTool.propTypes = { + plotUuid: PropTypes.string.isRequired, +}; + +export default GeneReorderTool; diff --git a/src/components/plots/hierarchical-tree-genes/HierarchicalTreeGenes.css b/src/components/plots/hierarchical-tree-genes/HierarchicalTreeGenes.css new file mode 100644 index 0000000000..c2a17c31c3 --- /dev/null +++ b/src/components/plots/hierarchical-tree-genes/HierarchicalTreeGenes.css @@ -0,0 +1,3 @@ +.ant-tree-list-holder-inner { + background-color: #f5f8fa; + } \ No newline at end of file diff --git a/src/components/plots/hierarchical-tree-genes/HierarchicalTreeGenes.jsx b/src/components/plots/hierarchical-tree-genes/HierarchicalTreeGenes.jsx new file mode 100644 index 0000000000..687876c0e4 --- /dev/null +++ b/src/components/plots/hierarchical-tree-genes/HierarchicalTreeGenes.jsx @@ -0,0 +1,51 @@ +import React from 'react'; +import PropTypes from 'prop-types'; +import { Tree, Skeleton } from 'antd'; + +import 'components/plots/hierarchical-tree-genes/HierarchicalTreeGenes.css'; +import { ConsoleLogger } from '@aws-amplify/core'; + +const HierarchicalTreeGenes = (props) => { + const { + treeData, + onGeneReorder, + } = props; + + const onDrop = (info) => { + const { + dragNode, node, dropPosition, dropToGap, + } = info; + + // if dropped in place, ignore + // dragNode.key is str, dropPosition is int + if (dragNode.key == dropPosition) return; + + // If not dropped in gap, ignore + if (!dropToGap) return; + + let newPosition = dropPosition - (dragNode.key < dropPosition ? 1 : 0); + newPosition = Math.max(0, newPosition); + + onGeneReorder(dragNode.key, newPosition); + }; + + if (!treeData) return ; + + return ( + + ); +}; + +HierarchicalTreeGenes.defaultProps = {}; + +HierarchicalTreeGenes.propTypes = { + treeData: PropTypes.array.isRequired, + onGeneReorder: PropTypes.func.isRequired, +}; + +export default HierarchicalTreeGenes; diff --git a/src/pages/experiments/[experimentId]/plots-and-tables/marker-heatmap/index.jsx b/src/pages/experiments/[experimentId]/plots-and-tables/marker-heatmap/index.jsx index 963d6a802f..6d072ad958 100644 --- a/src/pages/experiments/[experimentId]/plots-and-tables/marker-heatmap/index.jsx +++ b/src/pages/experiments/[experimentId]/plots-and-tables/marker-heatmap/index.jsx @@ -6,6 +6,7 @@ import { Empty, Select, Radio, + Tabs, } from 'antd'; import _ from 'lodash'; import { useSelector, useDispatch } from 'react-redux'; @@ -33,9 +34,12 @@ import Loader from 'components/Loader'; import populateHeatmapData from 'components/plots/helpers/heatmap/populateHeatmapData'; import { plotNames } from 'utils/constants'; +import GeneReorderTool from 'components/plots/GeneReorderTool'; + const { Panel } = Collapse; const plotUuid = 'markerHeatmapPlotMain'; const plotType = 'markerHeatmap'; +const { TabPane } = Tabs; const MarkerHeatmap = ({ experimentId }) => { const dispatch = useDispatch(); @@ -300,26 +304,34 @@ const MarkerHeatmap = ({ experimentId }) => { const renderExtraPanels = () => ( <> - - - - Gene labels: - updatePlotWithChanges({ showGeneLabels: e.target.value }) - } - value={config.showGeneLabels} - > - Show - Hide - - - + + + + + Gene labels: + updatePlotWithChanges({ showGeneLabels: e.target.value }) + } + value={config.showGeneLabels} + > + Show + Hide + + + + + Drag and drop genes to re-order them in the heatmap. + + +
Gene labels:
Drag and drop genes to re-order them in the heatmap.