diff --git a/src/__test__/components/plots/styling/MarkerGeneSelection.test.jsx b/src/__test__/components/plots/styling/MarkerGeneSelection.test.jsx index 8e993b19fa..d8804afa70 100644 --- a/src/__test__/components/plots/styling/MarkerGeneSelection.test.jsx +++ b/src/__test__/components/plots/styling/MarkerGeneSelection.test.jsx @@ -12,16 +12,17 @@ import { plotTypes } from 'utils/constants'; const mockOnUpdate = jest.fn(); const mockOnReset = jest.fn(); -const mockOnGeneEnter = jest.fn(); + +const plotType = plotTypes.DOT_PLOT; const defaultProps = { onUpdate: mockOnUpdate, onReset: mockOnReset, - onGeneEnter: mockOnGeneEnter, + plotUuid: 'dotPlotMain', + experimentId: 'experimentId', + searchBarUuid: 'searchBarUuid', }; -const plotType = plotTypes.DOT_PLOT; - const markerGeneSelectionFactory = createTestComponentFactory(MarkerGeneSelection, defaultProps); describe('MarkerGeneSelection', () => { @@ -29,32 +30,6 @@ describe('MarkerGeneSelection', () => { jest.clearAllMocks(); }); - it('Should show the custom gene input by default', async () => { - const mockConfig = initialPlotConfigStates[plotType]; - - await act(async () => { - render( - markerGeneSelectionFactory({ config: mockConfig }), - ); - }); - - // Expect screen to show the custom gene selection input by default - expect(screen.getByText(/Type in a gene name/i)).toBeInTheDocument(); - - // Typing genes and then pressing enter - const geneInput = screen.getByRole('combobox'); - - // This is not wrapped in act() because changes to the value causes a re-render - // which causes mockOnGeneEnter to lose its memory of having been called - userEvent.type(geneInput, 'ABC{enter}'); - - // Expect geneEnter to be called - expect(mockOnGeneEnter).toHaveBeenCalledTimes(1); - const inputValue = mockOnGeneEnter.mock.calls[0][0]; - - expect(inputValue).toEqual(['ABC']); - }); - it('Should show the number of marker genes input', async () => { const mockConfig = { ...initialPlotConfigStates[plotType], useMarkerGenes: true }; diff --git a/src/__test__/pages/experiments/[experimentId]/plots-and-tables/dot-plot/__snapshots__/index.test.jsx.snap b/src/__test__/pages/experiments/[experimentId]/plots-and-tables/dot-plot/__snapshots__/index.test.jsx.snap index 0ebd603d6c..9aec9580b6 100644 --- a/src/__test__/pages/experiments/[experimentId]/plots-and-tables/dot-plot/__snapshots__/index.test.jsx.snap +++ b/src/__test__/pages/experiments/[experimentId]/plots-and-tables/dot-plot/__snapshots__/index.test.jsx.snap @@ -37,13 +37,6 @@ Array [ }, Object {}, ], - Array [ - Object { - "data": Array [], - "filename": "Test_Experiment-dot_plot-louvain-All", - }, - Object {}, - ], Array [ Object { "data": Array [ diff --git a/src/__test__/pages/experiments/[experimentId]/plots-and-tables/dot-plot/index.test.jsx b/src/__test__/pages/experiments/[experimentId]/plots-and-tables/dot-plot/index.test.jsx index 7cdf17b36b..e910997918 100644 --- a/src/__test__/pages/experiments/[experimentId]/plots-and-tables/dot-plot/index.test.jsx +++ b/src/__test__/pages/experiments/[experimentId]/plots-and-tables/dot-plot/index.test.jsx @@ -3,6 +3,8 @@ import _ from 'lodash'; import { act } from 'react-dom/test-utils'; import { render, screen, fireEvent } from '@testing-library/react'; +import { mount } from 'enzyme'; +import { within } from '@testing-library/dom'; import '@testing-library/jest-dom'; import { Provider } from 'react-redux'; @@ -33,6 +35,9 @@ import userEvent from '@testing-library/user-event'; import { plotNames } from 'utils/constants'; import ExportAsCSV from 'components/plots/ExportAsCSV'; +import waitForComponentToPaint from '__test__/test-utils/waitForComponentToPaint'; +import { arrayMoveImmutable } from 'utils/array-move'; + jest.mock('components/plots/ExportAsCSV', () => jest.fn(() => (<>))); jest.mock('components/header/UserButton', () => () => <>); jest.mock('react-resize-detector', () => (props) => { @@ -86,6 +91,23 @@ const mockAPIResponse = _.merge( const defaultProps = { experimentId }; const dotPlotPageFactory = createTestComponentFactory(DotPlotPage, defaultProps); +// 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.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 renderDotPlot = async (store) => { await act(async () => { render( @@ -96,6 +118,14 @@ const renderDotPlot = async (store) => { }); }; +const renderDotPlotForEnzyme = async (store) => ( + mount( + + {dotPlotPageFactory()} + , + ) +); + enableFetchMocks(); let storeState = null; @@ -226,7 +256,7 @@ describe('Dot plot page', () => { // 2nd call to load dot plot .mockImplementationOnce(() => null) .mockImplementationOnce((Etag) => mockWorkerResponses[Etag]()) - // 3nd call to load dot plot + // 3rd call to load dot plot .mockImplementationOnce(() => null) .mockImplementationOnce((Etag) => mockWorkerResponses[Etag]()); @@ -271,4 +301,249 @@ describe('Dot plot page', () => { expect(screen.getByText(/A comparison can not be run to determine the top marker genes/i)).toBeInTheDocument(); expect(screen.getByText(/Select another option from the 'Select data' menu/i)).toBeInTheDocument(); }); + + it('removing a gene keeps the order', async () => { + await renderDotPlot(storeState); + + const geneTree = screen.getByRole('tree'); + + // first three genes of the data should be loaded by default + const loadedGenes = paginatedGeneExpressionData.rows.map((row) => (row.gene_names)).slice(0, 3); + + // The genes in Data 5 should be in the tree + loadedGenes.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); + }); + + it('searches for genes and adds a valid gene', async () => { + await renderDotPlot(storeState); + + const geneTree = screen.getByRole('tree'); + const initialOrder = getTreeGenes(geneTree); + + // check placeholder text is loaded + expect(screen.getByText('Search for genes...')).toBeInTheDocument(); + + const searchBox = screen.getByRole('combobox'); + + // search for genes using lowercase + userEvent.type(searchBox, 'ap'); + + // antd creates multiple elements for options + // find option element by title, clicking on element with role='option' does nothing + const option = screen.getByTitle('Apoe'); + + await act(async () => { + // the element has pointer-events set to 'none', skip check + // based on https://stackoverflow.com/questions/61080116 + userEvent.click(option, undefined, { skipPointerEventsCheck: true }); + }); + + // check the search text is cleared after selecting a valid option + expect(searchBox.value).toBe(''); + + // check the selected gene was added + expect(within(geneTree).getByText('Apoe')).toBeInTheDocument(); + + // check the genes were not re-ordered when adding + initialOrder.push('Apoe'); + expect(_.isEqual(initialOrder, getTreeGenes(geneTree))).toEqual(true); + }); + + it('adds an already loaded gene and clears the input', async () => { + await renderDotPlot(storeState); + + const searchBox = screen.getByRole('combobox'); + + userEvent.type(searchBox, 'ly'); + + const option = screen.getByTitle('Lyz2'); + + // expecting option to be disabled throws error, click the option instead and check reaction + await act(async () => { + userEvent.click(option, undefined, { skipPointerEventsCheck: true }); + }); + + // search box shouldn't clear when selecting an already loaded gene + expect(searchBox.value).toBe('ly'); + + // clear button is automatically generated by antd and cannot be easily accessed + const clearButton = searchBox.closest('div[class*=ant-select-auto-complete]').lastChild; + + userEvent.click(clearButton); + + expect(searchBox.value).toBe(''); + }); + + it('resets the data', async () => { + await renderDotPlot(storeState); + + seekFromS3 + .mockReset() + // 1st call to list genes + .mockImplementationOnce(() => null) + .mockImplementationOnce((Etag) => mockWorkerResponses[Etag]()) + // 2nd call to load dot plot + .mockImplementationOnce(() => null) + .mockImplementationOnce((Etag) => mockWorkerResponses[Etag]()) + // 3rd call to load dot plot + .mockImplementationOnce(() => null) + .mockImplementationOnce((Etag) => mockWorkerResponses[Etag]()); + + // add a gene to prepare for reset + const searchBox = screen.getByRole('combobox'); + + userEvent.type(searchBox, 'ap'); + + const option = screen.getByTitle('Apoe'); + + await act(async () => { + userEvent.click(option, undefined, { skipPointerEventsCheck: true }); + }); + + const resetButton = screen.getAllByText('Reset')[1]; + + await act(async () => { + userEvent.click(resetButton); + }); + + // expect the gene only within the options of the search box, antd creates 2 elements + expect(screen.getAllByText('Apoe').length).toBe(2); + }); +}); + +// drag and drop is impossible in RTL, use enzyme +describe('Drag and drop enzyme tests', () => { + let component; + let tree; + let loadedGenes; + + beforeEach(async () => { + jest.clearAllMocks(); + + seekFromS3 + .mockReset() + // 1st call to list genes + .mockImplementationOnce(() => null) + .mockImplementationOnce((Etag) => mockWorkerResponses[Etag]()) + // 2nd call to paginated gene expression + .mockImplementationOnce(() => null) + .mockImplementationOnce((Etag) => mockWorkerResponses[Etag]()); + + fetchMock.resetMocks(); + fetchMock.mockIf(/.*/, mockAPI(mockAPIResponse)); + + storeState = makeStore(); + + await storeState.dispatch(loadBackendStatus(experimentId)); + + storeState.dispatch({ + type: EXPERIMENT_SETTINGS_INFO_UPDATE, + payload: { + experimentId: fake.EXPERIMENT_ID, + experimentName: fake.EXPERIMENT_NAME, + }, + }); + + component = await renderDotPlotForEnzyme(storeState); + + await waitForComponentToPaint(component); + + component.update(); + + // antd renders 5 elements, use the first one + tree = component.find({ 'data-testid': 'HierachicalTreeGenes' }).at(0); + loadedGenes = paginatedGeneExpressionData.rows.map((row) => (row.gene_names)).slice(0, 3).reverse(); + }); + + it('changes nothing on drop in place', async () => { + // default genes are in the tree + loadedGenes.forEach((geneName) => { + expect(tree.containsMatchingElement(geneName)); + }); + + // dropping in place does nothing + const info = { + dragNode: { key: 1, pos: '0-1' }, + dropPosition: 1, + dropToGap: true, + }; + + tree.getElement().props.onDrop(info); + + await act(async () => { + component.update(); + }); + + const newOrder = getCurrentGeneOrder(component); + + expect(_.isEqual(newOrder, loadedGenes)).toEqual(true); + }); + + it('changes nothing when not dropped in gap', async () => { + // default genes are in the tree + loadedGenes.forEach((geneName) => { + expect(tree.containsMatchingElement(geneName)); + }); + + // not dropping to gap does nothing + const info = { + dragNode: { key: 0, pos: '0-0' }, + dropPosition: 2, + dropToGap: false, + }; + + tree.getElement().props.onDrop(info); + + await act(async () => { + component.update(); + }); + + const newOrder = getCurrentGeneOrder(component); + + expect(_.isEqual(newOrder, loadedGenes)).toEqual(true); + }); + + it('re-orders genes correctly', async () => { + // default genes are in the tree + loadedGenes.forEach((geneName) => { + expect(tree.containsMatchingElement(geneName)); + }); + // dropping to gap re-orders genes + const info = { + dragNode: { key: 0, pos: '0-0' }, + dropPosition: 2, + dropToGap: true, + }; + + tree.getElement().props.onDrop(info); + + await act(async () => { + component.update(); + }); + + const newOrder = getCurrentGeneOrder(component); + + const expectedOrder = arrayMoveImmutable(loadedGenes, 0, 1); + + expect(_.isEqual(newOrder, expectedOrder)).toEqual(true); + }); }); 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 9a3db3fdb7..6bb350442b 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 @@ -85,12 +85,6 @@ const defaultProps = { experimentId }; const heatmapPageFactory = createTestComponentFactory(MarkerHeatmap, defaultProps); -// Helper function to get displayed genes from the gene input -const getDisplayedGenes = (container) => { - const genesNodeList = container.querySelectorAll('span[class*=selection-item-content]'); - 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]'); @@ -242,19 +236,19 @@ describe('Marker heatmap plot', () => { await renderHeatmapPage(storeState); // Add in a new gene - // This is done because we can not insert text into the genes list input const genesToLoad = [...markerGenesData5.order, 'FAKEGENE']; await act(async () => { await storeState.dispatch(loadGeneExpression(experimentId, genesToLoad, plotUuid)); }); - expect(screen.getByText('FAKEGENE')).toBeInTheDocument(); + // Get genes displayed in the tree + const geneTree = screen.getByRole('tree'); - // The returned value is a HTML NodeList - const genesContainer = screen.getByText('FAKEGENE').closest('div[class*=selector]'); + const displayedGenesList = getTreeGenes(geneTree); - const displayedGenesList = getDisplayedGenes(genesContainer); + // check the added gene is in the tree + expect(within(geneTree).getByText('FAKEGENE')).toBeInTheDocument(); // Check that the genes is ordered correctly. // This means that FAKEGENE should not be the last in the genes list @@ -305,64 +299,8 @@ describe('Marker heatmap plot', () => { }); it('removing a gene keeps the sorted order without re-sorting', async () => { - seekFromS3 - .mockReset() - // load genes list - .mockImplementationOnce(() => null) - .mockImplementationOnce((Etag) => mockWorkerResponses[Etag]) - // 1st load - .mockImplementationOnce(() => null) - .mockImplementationOnce((ETag) => mockWorkerResponses[ETag]) - // 2nd load - .mockImplementationOnce(() => null) - .mockImplementationOnce((ETag) => mockWorkerResponses[ETag]); - await renderHeatmapPage(storeState); - // Setting up so that there is an inserted gene in the list - const genesToLoad = [...markerGenesData5.order, 'FAKEGENE']; - - await act(async () => { - // This is done because we can not insert text into the genes list input - await storeState.dispatch(loadGeneExpression(experimentId, genesToLoad, plotUuid)); - }); - - expect(screen.getByText('FAKEGENE')).toBeInTheDocument(); - - // The returned value is a HTML NodeList - const genesContainer = screen.getByText('FAKEGENE').closest('div[class*=selector]'); - const genesListBeforeRemoval = getDisplayedGenes(genesContainer); - - // Removing the 5th gene from the list - // genesListBeforeRemoval is modified - splice removes the item from the list - const geneToRemove = genesListBeforeRemoval.splice(5, 1); - const geneRemoveButton = screen.getByText(geneToRemove).nextSibling; - - userEvent.click(geneRemoveButton); - - // Get newly displayed genes after the removal - const genesListAfterRemoval = getDisplayedGenes(genesContainer); - - // 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(/Search for and 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('Search for and 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 @@ -391,10 +329,6 @@ describe('Marker heatmap plot', () => { it('searches for genes and adds a valid gene', async () => { await renderHeatmapPage(storeState); - await act(async () => { - userEvent.click(screen.getByText('Search for and re-order genes')); - }); - // check placeholder text is loaded expect(screen.getByText('Search for genes...')).toBeInTheDocument(); @@ -431,17 +365,13 @@ describe('Marker heatmap plot', () => { it('adds an already loaded gene and clears the input', async () => { await renderHeatmapPage(storeState); - await act(async () => { - userEvent.click(screen.getByText('Search for and re-order genes')); - }); - const searchBox = screen.getByRole('combobox'); userEvent.type(searchBox, 'tmem'); - // this finds option for selection box in 1st tab and search box, use second element - const option = screen.getAllByTitle('Tmem176a')[1]; + const option = screen.getByTitle('Tmem176a'); + // expecting option to be disabled throws error, click the option instead and check reaction await act(async () => { userEvent.click(option, undefined, { skipPointerEventsCheck: true }); }); @@ -458,7 +388,7 @@ describe('Marker heatmap plot', () => { }); }); -// drag and drop is impossible in jest, use enzyme +// drag and drop is impossible in RTL, use enzyme describe('Drag and drop enzyme tests', () => { let component; let tree; @@ -489,12 +419,6 @@ describe('Drag and drop enzyme tests', () => { await waitForComponentToPaint(component); - // activate re-order genes tab - await act(async () => { - const reorderTab = component.find('div.ant-tabs-tab-btn'); - reorderTab.at(1).simulate('click'); - }); - component.update(); // antd renders 5 elements, use the first one @@ -510,7 +434,6 @@ describe('Drag and drop enzyme tests', () => { // dropping in place does nothing const info = { dragNode: { key: 1, pos: '0-1' }, - node: { key: 1, pos: '0-1' }, dropPosition: 1, dropToGap: true, }; @@ -535,7 +458,6 @@ describe('Drag and drop enzyme tests', () => { // not dropping to gap does nothing const info = { dragNode: { key: 1, pos: '0-1' }, - node: { key: 3, pos: '0-3' }, dropPosition: 4, dropToGap: false, }; @@ -559,7 +481,6 @@ describe('Drag and drop enzyme tests', () => { // dropping to gap re-orders genes const info = { dragNode: { key: 1, pos: '0-1' }, - node: { key: 3, pos: '0-3' }, dropPosition: 4, dropToGap: true, }; diff --git a/src/components/data-exploration/hierarchical-tree/hierarchicalTree.css b/src/components/data-exploration/hierarchical-tree/hierarchicalTree.css index 1f71759f77..9996c2c70d 100644 --- a/src/components/data-exploration/hierarchical-tree/hierarchicalTree.css +++ b/src/components/data-exploration/hierarchical-tree/hierarchicalTree.css @@ -1,3 +1,7 @@ +.ant-tree { + background-color: #f5f8fa; +} + .ant-tree-list-holder-inner { background-color: #f5f8fa; } \ No newline at end of file diff --git a/src/components/plots/GeneReorderTool.jsx b/src/components/plots/GeneReorderTool.jsx index c95023b65d..873b539da2 100644 --- a/src/components/plots/GeneReorderTool.jsx +++ b/src/components/plots/GeneReorderTool.jsx @@ -2,30 +2,24 @@ 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 { arrayMoveImmutable } from 'utils/array-move'; 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 { plotUuid, onDelete } = 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) { + if (!treeGenes.length) { return []; } @@ -36,7 +30,7 @@ const GeneReorderTool = (props) => { return data; }; - const [geneTreeData, setGeneTreeData] = useState(composeGeneTree(loadedMarkerGenes)); + const [geneTreeData, setGeneTreeData] = useState([]); useEffect(() => { setGeneTreeData(composeGeneTree(config?.selectedGenes)); @@ -56,7 +50,7 @@ const GeneReorderTool = (props) => { const genes = geneTreeData.map((treeNode) => treeNode.title); genes.splice(geneKey, 1); - dispatch(loadGeneExpression(experimentId, genes, plotUuid)); + onDelete(genes); }; const renderTitles = (data) => { @@ -101,6 +95,7 @@ GeneReorderTool.defaultProps = {}; GeneReorderTool.propTypes = { plotUuid: PropTypes.string.isRequired, + onDelete: PropTypes.func.isRequired, }; export default GeneReorderTool; diff --git a/src/components/plots/GeneSearchBar.jsx b/src/components/plots/GeneSearchBar.jsx index 337788d388..69ba3b265a 100644 --- a/src/components/plots/GeneSearchBar.jsx +++ b/src/components/plots/GeneSearchBar.jsx @@ -1,8 +1,6 @@ import React, { useState } from 'react'; -import _ from 'lodash'; import { AutoComplete } from 'antd'; -import { useSelector, useDispatch } from 'react-redux'; -import { loadGeneExpression } from 'redux/actions/genes'; +import { useSelector } from 'react-redux'; import PropTypes from 'prop-types'; const filterGenes = (searchText, geneList, loadedGenes) => { @@ -17,9 +15,9 @@ const filterGenes = (searchText, geneList, loadedGenes) => { }; const GeneSearchBar = (props) => { - const { plotUuid, experimentId, searchBarUuid } = props; - - const dispatch = useDispatch(); + const { + plotUuid, searchBarUuid, onSelect, + } = props; const geneList = useSelector((state) => state.genes.properties.views[searchBarUuid]?.data); @@ -30,14 +28,10 @@ const GeneSearchBar = (props) => { // pass reactive component as value (search text) to allow auto clear on select const [value, setValue] = useState(''); - const onSelect = (newGene) => { - if (!geneList.includes(newGene) || config?.selectedGenes.includes(newGene)) { - return; - } + const onOptionSelect = (newGene) => { + const genes = [...config?.selectedGenes, newGene]; - const genes = _.clone(config?.selectedGenes); - genes.push(newGene); - dispatch(loadGeneExpression(experimentId, genes, plotUuid)); + onSelect(genes); setValue(''); }; @@ -52,7 +46,7 @@ const GeneSearchBar = (props) => { value={value} options={options} style={{ width: '100%' }} - onSelect={onSelect} + onSelect={onOptionSelect} onSearch={onSearch} placeholder='Search for genes...' /> @@ -61,8 +55,8 @@ const GeneSearchBar = (props) => { GeneSearchBar.propTypes = { plotUuid: PropTypes.string.isRequired, - experimentId: PropTypes.string.isRequired, searchBarUuid: PropTypes.string.isRequired, + onSelect: PropTypes.func.isRequired, }; export default GeneSearchBar; diff --git a/src/components/plots/ScrollOnDrag.jsx b/src/components/plots/ScrollOnDrag.jsx new file mode 100644 index 0000000000..d328afa45c --- /dev/null +++ b/src/components/plots/ScrollOnDrag.jsx @@ -0,0 +1,43 @@ +// find the Y position of an object in the document +// based on https://www.quirksmode.org/js/findpos.html +const findTop = (elem) => { + let posTop = 0; + + if (!elem.offsetParent) { + return; + } + // recursion to add .offsetTop of an object and all parents of the object + // until obj.offsetParent is undefined, needed to compare with event.clientY for drag event + do { + posTop += elem.offsetTop; + } while (elem = elem.offsetParent); + + return posTop; +}; + +const ScrollOnDrag = (treeScrollable) => { + const treeTop = findTop(treeScrollable); + // scrollable collapsable tablist is wrapped in a div with overflow + const tablist = document.getElementsByClassName('ant-collapse')[0].parentNode; + let interval; + + const handleScrollOnDrag = (event) => { + const treeHeight = treeScrollable.clientHeight; + const relY = event.clientY - treeTop + tablist.scrollTop; + + clearInterval(interval); + + // drag event ends with relY = -treeTop, currently hardcoded to ignore + if (relY < 0 && relY !== -treeTop + tablist.scrollTop) { + interval = setInterval(() => { treeScrollable.scrollTop -= 5; }, 20); + } + + if (relY > treeHeight) { + interval = setInterval(() => { treeScrollable.scrollTop += 5; }, 20); + } + }; + + document.addEventListener('drag', handleScrollOnDrag); +}; + +export default ScrollOnDrag; diff --git a/src/components/plots/hierarchical-tree-genes/HierarchicalTreeGenes.css b/src/components/plots/hierarchical-tree-genes/HierarchicalTreeGenes.css index c2a17c31c3..3e598ed07b 100644 --- a/src/components/plots/hierarchical-tree-genes/HierarchicalTreeGenes.css +++ b/src/components/plots/hierarchical-tree-genes/HierarchicalTreeGenes.css @@ -1,3 +1,3 @@ -.ant-tree-list-holder-inner { +.ant-tree { background-color: #f5f8fa; - } \ No newline at end of file +} \ 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 index 13d9d31434..cff6d6543a 100644 --- a/src/components/plots/hierarchical-tree-genes/HierarchicalTreeGenes.jsx +++ b/src/components/plots/hierarchical-tree-genes/HierarchicalTreeGenes.jsx @@ -12,7 +12,7 @@ const HierarchicalTreeGenes = (props) => { const onDrop = (info) => { const { - dragNode, node, dropPosition, dropToGap, + dragNode, dropPosition, dropToGap, } = info; // if dropped in place, ignore @@ -31,12 +31,17 @@ const HierarchicalTreeGenes = (props) => { if (!treeData) return ; return ( - + // wrapping in div needed to not unload dragged element when scrolling + // add padding to the tree to make first drop position visible +
+ +
); }; diff --git a/src/components/plots/styling/MarkerGeneSelection.jsx b/src/components/plots/styling/MarkerGeneSelection.jsx index 7622997125..6cd0e9f634 100644 --- a/src/components/plots/styling/MarkerGeneSelection.jsx +++ b/src/components/plots/styling/MarkerGeneSelection.jsx @@ -4,13 +4,15 @@ import { Space, Radio, InputNumber, - Select, Button, } from 'antd'; +import GeneReorderTool from 'components/plots/GeneReorderTool'; +import GeneSearchBar from 'components/plots/GeneSearchBar'; + const MarkerGeneSelection = (props) => { const { - config, onUpdate, onReset, onGeneEnter, + config, plotUuid, searchBarUuid, onUpdate, onReset, onGenesChange, } = props; const [numGenes, setNumGenes] = useState(config.nMarkerGenes); @@ -41,15 +43,16 @@ const MarkerGeneSelection = (props) => { return ( -

Type in a gene name and hit space or enter to add it to the heatmap.

-