mirror of
https://github.com/chanzuckerberg/cellxgene.git
synced 2026-09-15 12:47:56 +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:
committed by
GitHub
parent
023ae10822
commit
82de4178d9
@@ -64,6 +64,8 @@ class Gene extends React.Component {
|
||||
isColorAccessor,
|
||||
isScatterplotXXaccessor,
|
||||
isScatterplotYYaccessor,
|
||||
quickGene,
|
||||
removeGene,
|
||||
} = this.props;
|
||||
const { geneIsExpanded } = this.state;
|
||||
const geneSymbolWidth = 60 + (geneIsExpanded ? MINI_HISTOGRAM_WIDTH : 0);
|
||||
@@ -94,16 +96,18 @@ class Gene extends React.Component {
|
||||
}}
|
||||
>
|
||||
<div>
|
||||
<Icon
|
||||
icon="drag-handle-horizontal"
|
||||
iconSize={12}
|
||||
style={{
|
||||
marginRight: 7,
|
||||
cursor: "grab",
|
||||
position: "relative",
|
||||
top: -1,
|
||||
}}
|
||||
/>
|
||||
{!quickGene && (
|
||||
<Icon
|
||||
icon="drag-handle-horizontal"
|
||||
iconSize={12}
|
||||
style={{
|
||||
marginRight: 7,
|
||||
cursor: "grab",
|
||||
position: "relative",
|
||||
top: -1,
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
<Truncate
|
||||
tooltipAddendum={geneDescription && `: ${geneDescription}`}
|
||||
>
|
||||
@@ -132,7 +136,9 @@ class Gene extends React.Component {
|
||||
minimal
|
||||
small
|
||||
data-testid={`delete-from-geneset-${gene}`}
|
||||
onClick={this.handleDeleteGeneFromSet}
|
||||
onClick={
|
||||
quickGene ? removeGene(gene) : this.handleDeleteGeneFromSet
|
||||
}
|
||||
intent="none"
|
||||
style={{ fontWeight: 700, marginRight: 2 }}
|
||||
icon={<Icon icon="trash" iconSize={10} />}
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
import React from "react";
|
||||
import { connect } from "react-redux";
|
||||
import { Button } from "@blueprintjs/core";
|
||||
import GeneSet from "./geneSet";
|
||||
import { Button, H4, Icon } from "@blueprintjs/core";
|
||||
import { IconNames } from "@blueprintjs/icons";
|
||||
|
||||
import GeneSet from "./geneSet";
|
||||
import QuickGene from "./quickGene";
|
||||
import CreateGenesetDialogue from "./menus/createGenesetDialogue";
|
||||
|
||||
@connect((state) => {
|
||||
@@ -11,6 +13,11 @@ import CreateGenesetDialogue from "./menus/createGenesetDialogue";
|
||||
};
|
||||
})
|
||||
class GeneExpression extends React.Component {
|
||||
constructor(props) {
|
||||
super(props);
|
||||
this.state = { geneSetsExpanded: true };
|
||||
}
|
||||
|
||||
renderGeneSets = () => {
|
||||
const sets = [];
|
||||
const { genesets } = this.props;
|
||||
@@ -31,25 +38,66 @@ class GeneExpression extends React.Component {
|
||||
|
||||
handleActivateCreateGenesetMode = () => {
|
||||
const { dispatch } = this.props;
|
||||
const { geneSetsExpanded } = this.state;
|
||||
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() {
|
||||
const { geneSetsExpanded } = this.state;
|
||||
return (
|
||||
<div>
|
||||
<QuickGene />
|
||||
<div>
|
||||
<div style={{ marginBottom: 10, position: "relative", top: -2 }}>
|
||||
<Button
|
||||
data-testid="open-create-geneset-dialog"
|
||||
onClick={this.handleActivateCreateGenesetMode}
|
||||
intent="primary"
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
flexDirection: "row",
|
||||
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>
|
||||
</Button>
|
||||
Gene Sets{" "}
|
||||
{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>
|
||||
<CreateGenesetDialogue />
|
||||
</div>
|
||||
<div>{this.renderGeneSets()}</div>
|
||||
|
||||
{geneSetsExpanded && <div>{this.renderGeneSets()}</div>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
176
client/src/components/geneExpression/quickGene.js
Normal file
176
client/src/components/geneExpression/quickGene.js
Normal file
@@ -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