mirror of
https://github.com/chanzuckerberg/cellxgene.git
synced 2026-10-03 07:38:12 +08:00
feat: add quick gene lookup functionality (#2250)
* add/remove gene functionality back with geneset style gene * styling and expansion * memo gene list to prevent re render
This commit is contained in:
@@ -64,6 +64,8 @@ class Gene extends React.Component {
|
|||||||
isColorAccessor,
|
isColorAccessor,
|
||||||
isScatterplotXXaccessor,
|
isScatterplotXXaccessor,
|
||||||
isScatterplotYYaccessor,
|
isScatterplotYYaccessor,
|
||||||
|
quickGene,
|
||||||
|
removeGene,
|
||||||
} = this.props;
|
} = this.props;
|
||||||
const { geneIsExpanded } = this.state;
|
const { geneIsExpanded } = this.state;
|
||||||
const geneSymbolWidth = 60 + (geneIsExpanded ? MINI_HISTOGRAM_WIDTH : 0);
|
const geneSymbolWidth = 60 + (geneIsExpanded ? MINI_HISTOGRAM_WIDTH : 0);
|
||||||
@@ -94,16 +96,18 @@ class Gene extends React.Component {
|
|||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<div>
|
<div>
|
||||||
<Icon
|
{!quickGene && (
|
||||||
icon="drag-handle-horizontal"
|
<Icon
|
||||||
iconSize={12}
|
icon="drag-handle-horizontal"
|
||||||
style={{
|
iconSize={12}
|
||||||
marginRight: 7,
|
style={{
|
||||||
cursor: "grab",
|
marginRight: 7,
|
||||||
position: "relative",
|
cursor: "grab",
|
||||||
top: -1,
|
position: "relative",
|
||||||
}}
|
top: -1,
|
||||||
/>
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
<Truncate
|
<Truncate
|
||||||
tooltipAddendum={geneDescription && `: ${geneDescription}`}
|
tooltipAddendum={geneDescription && `: ${geneDescription}`}
|
||||||
>
|
>
|
||||||
@@ -132,7 +136,9 @@ class Gene extends React.Component {
|
|||||||
minimal
|
minimal
|
||||||
small
|
small
|
||||||
data-testid={`delete-from-geneset-${gene}`}
|
data-testid={`delete-from-geneset-${gene}`}
|
||||||
onClick={this.handleDeleteGeneFromSet}
|
onClick={
|
||||||
|
quickGene ? removeGene(gene) : this.handleDeleteGeneFromSet
|
||||||
|
}
|
||||||
intent="none"
|
intent="none"
|
||||||
style={{ fontWeight: 700, marginRight: 2 }}
|
style={{ fontWeight: 700, marginRight: 2 }}
|
||||||
icon={<Icon icon="trash" iconSize={10} />}
|
icon={<Icon icon="trash" iconSize={10} />}
|
||||||
|
|||||||
@@ -1,8 +1,10 @@
|
|||||||
import React from "react";
|
import React from "react";
|
||||||
import { connect } from "react-redux";
|
import { connect } from "react-redux";
|
||||||
import { Button } from "@blueprintjs/core";
|
import { Button, H4, Icon } from "@blueprintjs/core";
|
||||||
import GeneSet from "./geneSet";
|
import { IconNames } from "@blueprintjs/icons";
|
||||||
|
|
||||||
|
import GeneSet from "./geneSet";
|
||||||
|
import QuickGene from "./quickGene";
|
||||||
import CreateGenesetDialogue from "./menus/createGenesetDialogue";
|
import CreateGenesetDialogue from "./menus/createGenesetDialogue";
|
||||||
|
|
||||||
@connect((state) => {
|
@connect((state) => {
|
||||||
@@ -11,6 +13,11 @@ import CreateGenesetDialogue from "./menus/createGenesetDialogue";
|
|||||||
};
|
};
|
||||||
})
|
})
|
||||||
class GeneExpression extends React.Component {
|
class GeneExpression extends React.Component {
|
||||||
|
constructor(props) {
|
||||||
|
super(props);
|
||||||
|
this.state = { geneSetsExpanded: true };
|
||||||
|
}
|
||||||
|
|
||||||
renderGeneSets = () => {
|
renderGeneSets = () => {
|
||||||
const sets = [];
|
const sets = [];
|
||||||
const { genesets } = this.props;
|
const { genesets } = this.props;
|
||||||
@@ -31,25 +38,66 @@ class GeneExpression extends React.Component {
|
|||||||
|
|
||||||
handleActivateCreateGenesetMode = () => {
|
handleActivateCreateGenesetMode = () => {
|
||||||
const { dispatch } = this.props;
|
const { dispatch } = this.props;
|
||||||
|
const { geneSetsExpanded } = this.state;
|
||||||
dispatch({ type: "geneset: activate add new geneset mode" });
|
dispatch({ type: "geneset: activate add new geneset mode" });
|
||||||
|
if (!geneSetsExpanded) {
|
||||||
|
this.setState((state) => {
|
||||||
|
return { ...state, geneSetsExpanded: true };
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
handleExpandGeneSets = () => {
|
||||||
|
this.setState((state) => {
|
||||||
|
return { ...state, geneSetsExpanded: !state.geneSetsExpanded };
|
||||||
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
render() {
|
render() {
|
||||||
|
const { geneSetsExpanded } = this.state;
|
||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
|
<QuickGene />
|
||||||
<div>
|
<div>
|
||||||
<div style={{ marginBottom: 10, position: "relative", top: -2 }}>
|
<div
|
||||||
<Button
|
style={{
|
||||||
data-testid="open-create-geneset-dialog"
|
display: "flex",
|
||||||
onClick={this.handleActivateCreateGenesetMode}
|
flexDirection: "row",
|
||||||
intent="primary"
|
justifyContent: "space-between",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<H4
|
||||||
|
role="menuitem"
|
||||||
|
tabIndex="0"
|
||||||
|
data-testclass="category-expand"
|
||||||
|
onKeyPress={this.handleExpandGeneSets}
|
||||||
|
style={{
|
||||||
|
cursor: "pointer",
|
||||||
|
}}
|
||||||
|
onClick={this.handleExpandGeneSets}
|
||||||
>
|
>
|
||||||
Create new <strong>gene set</strong>
|
Gene Sets{" "}
|
||||||
</Button>
|
{geneSetsExpanded ? (
|
||||||
|
<Icon icon={IconNames.CHEVRON_DOWN} />
|
||||||
|
) : (
|
||||||
|
<Icon icon={IconNames.CHEVRON_RIGHT} />
|
||||||
|
)}
|
||||||
|
</H4>
|
||||||
|
|
||||||
|
<div style={{ marginBottom: 10, position: "relative", top: -2 }}>
|
||||||
|
<Button
|
||||||
|
data-testid="open-create-geneset-dialog"
|
||||||
|
onClick={this.handleActivateCreateGenesetMode}
|
||||||
|
intent="primary"
|
||||||
|
>
|
||||||
|
Create new
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<CreateGenesetDialogue />
|
<CreateGenesetDialogue />
|
||||||
</div>
|
</div>
|
||||||
<div>{this.renderGeneSets()}</div>
|
|
||||||
|
{geneSetsExpanded && <div>{this.renderGeneSets()}</div>}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,176 @@
|
|||||||
|
import { H4, Icon, MenuItem } from "@blueprintjs/core";
|
||||||
|
import { IconNames } from "@blueprintjs/icons";
|
||||||
|
import React, { useState, useEffect, useRef, useMemo } from "react";
|
||||||
|
import fuzzysort from "fuzzysort";
|
||||||
|
import { Suggest } from "@blueprintjs/select";
|
||||||
|
import { useSelector, useDispatch } from "react-redux";
|
||||||
|
|
||||||
|
import Gene from "./gene";
|
||||||
|
|
||||||
|
import { postUserErrorToast } from "../framework/toasters";
|
||||||
|
import actions from "../../actions";
|
||||||
|
|
||||||
|
const usePrevious = (value) => {
|
||||||
|
const ref = useRef();
|
||||||
|
useEffect(() => {
|
||||||
|
ref.current = value;
|
||||||
|
});
|
||||||
|
return ref.current;
|
||||||
|
};
|
||||||
|
|
||||||
|
function QuickGene() {
|
||||||
|
const dispatch = useDispatch();
|
||||||
|
|
||||||
|
const [isExpanded, setIsExpanded] = useState(true);
|
||||||
|
const [geneNames, setGeneNames] = useState([]);
|
||||||
|
const [, setStatus] = useState("pending");
|
||||||
|
|
||||||
|
const { annoMatrix, userDefinedGenes, userDefinedGenesLoading } = useSelector(
|
||||||
|
(state) => {
|
||||||
|
return {
|
||||||
|
annoMatrix: state.annoMatrix,
|
||||||
|
userDefinedGenes: state.controls.userDefinedGenes,
|
||||||
|
userDefinedGenesLoading: state.controls.userDefinedGenesLoading,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
const prevProps = usePrevious({ annoMatrix });
|
||||||
|
|
||||||
|
useEffect(async () => {
|
||||||
|
if (!annoMatrix) return;
|
||||||
|
if (annoMatrix !== prevProps?.annoMatrix) {
|
||||||
|
const { schema } = annoMatrix;
|
||||||
|
const varIndex = schema.annotations.var.index;
|
||||||
|
|
||||||
|
setStatus("pending");
|
||||||
|
try {
|
||||||
|
const df = await annoMatrix.fetch("var", varIndex);
|
||||||
|
setStatus("success");
|
||||||
|
setGeneNames(df.col(varIndex).asArray());
|
||||||
|
} catch (error) {
|
||||||
|
setStatus("error");
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, [annoMatrix, prevProps]);
|
||||||
|
|
||||||
|
const handleExpand = () => setIsExpanded(!isExpanded);
|
||||||
|
|
||||||
|
const renderGene = (fuzzySortResult, { handleClick, modifiers }) => {
|
||||||
|
if (!modifiers.matchesPredicate) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
/* the fuzzysort wraps the object with other properties, like a score */
|
||||||
|
const geneName = fuzzySortResult.target;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<MenuItem
|
||||||
|
active={modifiers.active}
|
||||||
|
disabled={modifiers.disabled}
|
||||||
|
data-testid={`suggest-menu-item-${geneName}`}
|
||||||
|
key={geneName}
|
||||||
|
onClick={(g) =>
|
||||||
|
/* this fires when user clicks a menu item */
|
||||||
|
handleClick(g)
|
||||||
|
}
|
||||||
|
text={geneName}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleClick = (g) => {
|
||||||
|
if (!g) return;
|
||||||
|
const gene = g.target;
|
||||||
|
if (userDefinedGenes.indexOf(gene) !== -1) {
|
||||||
|
postUserErrorToast("That gene already exists");
|
||||||
|
} else if (geneNames.indexOf(gene) === undefined) {
|
||||||
|
postUserErrorToast("That doesn't appear to be a valid gene name.");
|
||||||
|
} else {
|
||||||
|
dispatch({ type: "single user defined gene start" });
|
||||||
|
dispatch(actions.requestUserDefinedGene(gene));
|
||||||
|
dispatch({ type: "single user defined gene complete" });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const filterGenes = (query, genes) =>
|
||||||
|
/* fires on load, once, and then for each character typed into the input */
|
||||||
|
fuzzysort.go(query, genes, {
|
||||||
|
limit: 5,
|
||||||
|
threshold: -10000, // don't return bad results
|
||||||
|
});
|
||||||
|
|
||||||
|
const removeGene = (gene) => () => {
|
||||||
|
dispatch({ type: "clear user defined gene", data: gene });
|
||||||
|
};
|
||||||
|
|
||||||
|
const QuickGenes = useMemo(() => {
|
||||||
|
return userDefinedGenes.map((gene) => {
|
||||||
|
return (
|
||||||
|
<Gene
|
||||||
|
key={`quick=${gene}`}
|
||||||
|
gene={gene}
|
||||||
|
removeGene={removeGene}
|
||||||
|
quickGene
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}, [userDefinedGenes]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div style={{ width: "100%", marginBottom: "16px" }}>
|
||||||
|
<H4
|
||||||
|
role="menuitem"
|
||||||
|
tabIndex="0"
|
||||||
|
data-testclass="category-expand"
|
||||||
|
onKeyPress={handleExpand}
|
||||||
|
style={{
|
||||||
|
cursor: "pointer",
|
||||||
|
}}
|
||||||
|
onClick={handleExpand}
|
||||||
|
>
|
||||||
|
Genes{" "}
|
||||||
|
{isExpanded ? (
|
||||||
|
<Icon icon={IconNames.CHEVRON_DOWN} />
|
||||||
|
) : (
|
||||||
|
<Icon icon={IconNames.CHEVRON_RIGHT} />
|
||||||
|
)}
|
||||||
|
</H4>
|
||||||
|
{isExpanded && (
|
||||||
|
<>
|
||||||
|
<div style={{ marginBottom: "8px" }}>
|
||||||
|
<Suggest
|
||||||
|
resetOnSelect
|
||||||
|
closeOnSelect
|
||||||
|
resetOnClose
|
||||||
|
itemDisabled={userDefinedGenesLoading ? () => true : () => false}
|
||||||
|
noResults={<MenuItem disabled text="No matching genes." />}
|
||||||
|
onItemSelect={(g) => {
|
||||||
|
/* this happens on 'enter' */
|
||||||
|
handleClick(g);
|
||||||
|
}}
|
||||||
|
initialContent={<MenuItem disabled text="Enter a gene…" />}
|
||||||
|
inputProps={{
|
||||||
|
"data-testid": "gene-search",
|
||||||
|
placeholder: "Quick Gene Search",
|
||||||
|
leftIcon: IconNames.SEARCH,
|
||||||
|
fill: true,
|
||||||
|
}}
|
||||||
|
inputValueRenderer={() => {
|
||||||
|
return "";
|
||||||
|
}}
|
||||||
|
itemListPredicate={filterGenes}
|
||||||
|
itemRenderer={renderGene}
|
||||||
|
items={geneNames || ["No genes"]}
|
||||||
|
popoverProps={{ minimal: true }}
|
||||||
|
fill
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
{QuickGenes}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default QuickGene;
|
||||||
Reference in New Issue
Block a user