diff --git a/.infra/rdev/values.yaml b/.infra/rdev/values.yaml index fae7faeaf..23398d44a 100644 --- a/.infra/rdev/values.yaml +++ b/.infra/rdev/values.yaml @@ -2,7 +2,7 @@ stack: services: explorer: image: - tag: sha-ec7bd16c + tag: sha-bf4bef92 replicaCount: 1 env: # env vars common to all deployment stages diff --git a/client/__tests__/e2e/cellxgeneActions.ts b/client/__tests__/e2e/cellxgeneActions.ts index 61ebb0817..cc96feb61 100644 --- a/client/__tests__/e2e/cellxgeneActions.ts +++ b/client/__tests__/e2e/cellxgeneActions.ts @@ -602,22 +602,29 @@ export async function expandGene( export async function requestGeneInfo(gene: string, page: Page): Promise { await page.getByTestId(`get-info-${gene}`).click(); - await expect(page.getByTestId(`${gene}:gene-info`)).toBeTruthy(); +} + +export async function requestCellTypeInfo(cell: string, page: Page) { + await page.getByTestId(`cell_type:category-expand`).click(); + await page.getByTestId(`get-info-cell_type-${cell}`).click(); } export async function assertInfoPanelExists( - gene: string, + id: string, + infoType: string, page: Page ): Promise { - await expect(page.getByTestId(`${gene}:gene-info`)).toBeTruthy(); - await expect(page.getByTestId(`info-panel-header`)).toBeTruthy(); - await expect(page.getByTestId(`min-info-panel`)).toBeTruthy(); + await expect( + page.getByTestId(`${id}:${infoType}-info-wrapper`) + ).toBeVisible(); + await expect(page.getByTestId(`info-panel-header`)).toBeVisible(); + await expect(page.getByTestId(`min-info-panel`)).toBeVisible(); - await expect(page.getByTestId(`gene-info-synonyms`).innerText).not.toEqual( - "" - ); + await expect( + page.getByTestId(`${infoType}-info-synonyms`).innerText + ).not.toEqual(""); - await expect(page.getByTestId(`gene-info-link`)).toBeTruthy(); + await expect(page.getByTestId(`${infoType}-info-link`)).toBeTruthy(); } export async function minimizeInfoPanel(page: Page): Promise { @@ -625,11 +632,12 @@ export async function minimizeInfoPanel(page: Page): Promise { } export async function assertInfoPanelIsMinimized( - gene: string, + id: string, + infoType: string, page: Page ): Promise { const testIds = [ - `${gene}:gene-info`, + `${id}:${infoType}-info-wrapper`, "info-panel-header", "max-info-panel", "close-info-panel", @@ -637,8 +645,8 @@ export async function assertInfoPanelIsMinimized( await tryUntil( async () => { - for (const id of testIds) { - const result = await page.getByTestId(id).isVisible(); + for (const testId of testIds) { + const result = await page.getByTestId(testId).isVisible(); await expect(result).toBe(true); } }, @@ -651,19 +659,20 @@ export async function closeInfoPanel(page: Page): Promise { } export async function assertInfoPanelClosed( - gene: string, + id: string, + infoType: string, page: Page ): Promise { const testIds = [ - `${gene}:gene-info`, + `${id}:${infoType}-info-wrapper`, "info-panel-header", "min-info-panel", "close-info-panel", ]; await tryUntil( async () => { - for (const id of testIds) { - const result = await page.getByTestId(id).isVisible(); + for (const testId of testIds) { + const result = await page.getByTestId(testId).isVisible(); await expect(result).toBe(false); } }, diff --git a/client/__tests__/e2e/e2e.test.ts b/client/__tests__/e2e/e2e.test.ts index 969a26bba..bd7543a1f 100644 --- a/client/__tests__/e2e/e2e.test.ts +++ b/client/__tests__/e2e/e2e.test.ts @@ -52,6 +52,7 @@ import { snapshotTestGraph, getAllCategories, selectLayout, + requestCellTypeInfo, } from "./cellxgeneActions"; import { datasets } from "./data"; @@ -66,6 +67,7 @@ import { conditionallyToggleSidePanel, goToPage, shouldSkipTests, + skipIfPbmcDataset, skipIfSidePanel, toggleSidePanel, } from "../util/helpers"; @@ -99,6 +101,7 @@ const brushThisGeneGeneset = "brush_this_gene"; // open gene info card const geneToRequestInfo = "SIK1"; +const cellToRequestInfo = "monocyte"; const genesetDescriptionString = "fourth_gene_set: fourth description"; const genesetToCheckForDescription = "fourth_gene_set"; @@ -1382,6 +1385,12 @@ for (const testDataset of testDatasets) { test("open info panel and hide/remove", async ({ page, }, testInfo) => { + skipIfSidePanel(graphTestId, MAIN_PANEL); + /* + * Skip since there's no cell_type data in pbmc3k.cxg + */ + skipIfPbmcDataset(testDataset, DATASET); + await setup({ option, page, url, testInfo }); await addGeneToSearch(geneToRequestInfo, page); @@ -1390,7 +1399,11 @@ for (const testDataset of testDatasets) { await tryUntil( async () => { await requestGeneInfo(geneToRequestInfo, page); - await assertInfoPanelExists(geneToRequestInfo, page); + await assertInfoPanelExists( + geneToRequestInfo, + "gene", + page + ); }, { page } ); @@ -1400,7 +1413,11 @@ for (const testDataset of testDatasets) { await tryUntil( async () => { await minimizeInfoPanel(page); - await assertInfoPanelIsMinimized(geneToRequestInfo, page); + await assertInfoPanelIsMinimized( + geneToRequestInfo, + "gene", + page + ); }, { page } ); @@ -1410,7 +1427,39 @@ for (const testDataset of testDatasets) { await tryUntil( async () => { await closeInfoPanel(page); - await assertInfoPanelClosed(geneToRequestInfo, page); + await assertInfoPanelClosed( + geneToRequestInfo, + "gene", + page + ); + }, + { page } + ); + + await snapshotTestGraph(page, testInfo); + + await tryUntil( + async () => { + await requestCellTypeInfo(cellToRequestInfo, page); + await assertInfoPanelExists( + cellToRequestInfo, + "cell-type", + page + ); + }, + { page } + ); + + await snapshotTestGraph(page, testInfo); + + await tryUntil( + async () => { + await minimizeInfoPanel(page); + await assertInfoPanelIsMinimized( + cellToRequestInfo, + "cell-type", + page + ); }, { page } ); diff --git a/client/__tests__/util/helpers.ts b/client/__tests__/util/helpers.ts index e6e7cb101..1ce673cdd 100644 --- a/client/__tests__/util/helpers.ts +++ b/client/__tests__/util/helpers.ts @@ -291,6 +291,13 @@ export function skipIfSidePanel(graphTestId: string, MAIN_PANEL: string) { skip(graphTestId !== MAIN_PANEL, "This test is only for the main panel"); } +export function skipIfPbmcDataset(graphTestId: string, PBMC_DATASET: string) { + skip( + graphTestId === PBMC_DATASET, + "This test is only for the spatial dataset, since there's not cell_type data in pbmc3k.cxg dataset" + ); +} + export function shouldSkipTests( graphTestId: string, SIDE_PANEL: string diff --git a/client/src/actions/index.ts b/client/src/actions/index.ts index f1cf25a70..9f3c34572 100644 --- a/client/src/actions/index.ts +++ b/client/src/actions/index.ts @@ -179,6 +179,30 @@ async function fetchGeneInfo( return response; } +interface CellTypeInfoAPI { + name: string; + summary: string; +} +/** + * Fetch cell type summary information + * @param cell human-readable name of cell type + */ +async function fetchCellTypeInfo( + cell: string, + dispatch: AppDispatch +): Promise { + try { + return await fetchJson(`cellinfo?cell=${cell}`); + } catch (error) { + dispatchNetworkErrorMessageToUser("Failed to fetch cell type information."); + dispatch({ + type: "request cell info error", + error, + }); + return undefined; + } +} + function prefetchEmbeddings(annoMatrix: AnnoMatrix) { /* prefetch requests for all embeddings @@ -539,6 +563,7 @@ export default { checkExplainNewTab, navigateCheckUserState, selectDataset, + fetchCellTypeInfo, fetchGeneInfo, selectContinuousMetadataAction: selnActions.selectContinuousMetadataAction, selectCategoricalMetadataAction: selnActions.selectCategoricalMetadataAction, diff --git a/client/src/analytics/events.ts b/client/src/analytics/events.ts index aaf4b8641..f7967064e 100644 --- a/client/src/analytics/events.ts +++ b/client/src/analytics/events.ts @@ -48,6 +48,8 @@ export enum EVENTS { EXPLORER_GENE_DISPLAY_PLOT = "EXPLORER_GENE_DISPLAY_PLOT", EXPLORER_GENE_HISTOGRAM_HIGHLIGHT = "EXPLORER_GENE_HISTOGRAM_HIGHLIGHT", EXPLORER_SELECT_HISTOGRAM = "EXPLORER_SELECT_HISTOGRAM", + EXPLORER_GENE_INFO_BUTTON_CLICKED = "EXPLORER_GENE_INFO_BUTTON_CLICKED", + EXPLORER_CELLTYPE_INFO_BUTTON_CLICKED = "EXPLORER_CELLTYPE_INFO_BUTTON_CLICKED", EXPLORER_VIEW_GENE_INFO = "EXPLORER_VIEW_GENE_INFO", EXPLORER_IMAGE_SELECT = "EXPLORER_IMAGE_SELECT", EXPLORER_IMAGE_DESELECT = "EXPLORER_IMAGE_DESELECT", diff --git a/client/src/common/types/entities.ts b/client/src/common/types/entities.ts index 379af6932..8984e54c7 100644 --- a/client/src/common/types/entities.ts +++ b/client/src/common/types/entities.ts @@ -143,3 +143,30 @@ export interface PublisherMetadata { published_month: number; published_year: number; } + +export interface CellInfo { + cellId: string; + cellName: string; + cellDescription: string; + synonyms: string[]; + references: string[]; + error: string | null; + loading: boolean; +} + +export interface GeneInfo { + gene: string | null; + geneName: string; + geneSummary: string; + geneSynonyms: string[]; + geneUrl: string; + showWarningBanner: boolean; + infoError: string | null; + loading: boolean; +} + +export enum ActiveTab { + Gene = "Gene", + CellType = "CellType", + Dataset = "Dataset", +} diff --git a/client/src/components/categorical/value/index.tsx b/client/src/components/categorical/value/index.tsx index 0683fcd35..cc88af45c 100644 --- a/client/src/components/categorical/value/index.tsx +++ b/client/src/components/categorical/value/index.tsx @@ -1,8 +1,9 @@ import { connect } from "react-redux"; import React from "react"; import * as d3 from "d3"; +import { Icon as InfoCircle, IconButton } from "czifui"; -import { Classes } from "@blueprintjs/core"; +import { AnchorButton, Classes } from "@blueprintjs/core"; import * as globals from "../../../globals"; // @ts-expect-error ts-migrate(2307) FIXME: Cannot find module '../categorical.css' or its cor... Remove this comment to see the full error message import styles from "../categorical.css"; @@ -21,6 +22,7 @@ import { Schema, Category } from "../../../common/types/schema"; import { isDataframeDictEncodedColumn } from "../../../util/dataframe/types"; import { CategorySummary } from "../../../util/stateManager/controlsHelpers"; import { ColorTable } from "../../../util/stateManager/colorHelpers"; +import { ActiveTab } from "../../../common/types/entities"; const STACKED_BAR_HEIGHT = 11; const STACKED_BAR_WIDTH = 100; @@ -356,6 +358,30 @@ class CategoryValue extends React.Component { return null; }; + handleDisplayCellTypeInfo = async (cellName: string): Promise => { + const { dispatch } = this.props; + + track(EVENTS.EXPLORER_CELLTYPE_INFO_BUTTON_CLICKED, { cellName }); + + dispatch({ type: "request cell info start", cellName }); + + dispatch({ + type: "toggle active info panel", + activeTab: ActiveTab.CellType, + }); + + const info = await actions.fetchCellTypeInfo(cellName, dispatch); + + if (!info) { + return; + } + + dispatch({ + type: "open cell info", + cellInfo: info, + }); + }; + // If coloring by and this isn't the colorAccessor and it isn't being edited shouldRenderStackedBarOrHistogram() { const { colorAccessor, isColorBy } = this.props; @@ -545,6 +571,8 @@ class CategoryValue extends React.Component { CHART_MARGIN : globals.leftSidebarWidth - otherElementsWidth; + const isCellInfo = metadataField === "cell_type"; + return (
{ } data-testid="categorical-row" style={{ - padding: "4px 0px 4px 7px", + padding: "4px 10px 4px 7px", display: "flex", alignItems: "baseline", justifyContent: "space-between", @@ -568,7 +596,7 @@ class CategoryValue extends React.Component { margin: 0, padding: 0, userSelect: "none", - width: globals.leftSidebarWidth - 145, + width: globals.leftSidebarWidth - 120, display: "flex", justifyContent: "space-between", }} @@ -597,7 +625,7 @@ class CategoryValue extends React.Component { data-testid="categorical-value" tabIndex={-1} style={{ - width: labelWidth, + width: labelWidth - 25, color: displayString === globals.unassignedCategoryLabel ? "#ababab" @@ -617,6 +645,28 @@ class CategoryValue extends React.Component { {displayString} + {isCellInfo && ( +
+ this.handleDisplayCellTypeInfo(displayString)} + style={{ minHeight: "18px", minWidth: "18px", padding: 0 }} + > + +
+ +
+
+
+
+ )}
{this.renderMiniStackedBar()} @@ -628,7 +678,7 @@ class CategoryValue extends React.Component { whiteSpace: "nowrap", }} > - + { displayString === globals.unassignedCategoryLabel ? "italic" : "auto", + top: "10px", }} > {count} - - + + + diff --git a/client/src/components/diffexNotice/index.tsx b/client/src/components/diffexNotice/index.tsx index 2fc590628..e2cac2924 100644 --- a/client/src/components/diffexNotice/index.tsx +++ b/client/src/components/diffexNotice/index.tsx @@ -1,7 +1,7 @@ /* Core dependencies */ import React, { useState, useEffect } from "react"; import { noop } from "lodash"; -import { Link } from "../geneExpression/infoPanel/geneInfo/style"; +import { Link } from "../geneExpression/infoPanel/common/style"; import { StyledSnackbar, StyledAlert } from "./style"; interface Props { diff --git a/client/src/components/geneExpression/gene.tsx b/client/src/components/geneExpression/gene.tsx index 7d53194c7..753b5761a 100644 --- a/client/src/components/geneExpression/gene.tsx +++ b/client/src/components/geneExpression/gene.tsx @@ -15,7 +15,7 @@ import { thunkTrackColorByHistogramHighlightHistogramFromColorByHistogram, } from "../../analytics"; import { EVENTS } from "../../analytics/events"; -import { ActiveTab } from "../../reducers/controls"; +import { ActiveTab } from "../../common/types/entities"; import { DataframeValue } from "../../util/dataframe"; const MINI_HISTOGRAM_WIDTH = 110; @@ -115,17 +115,15 @@ class Gene extends React.Component { gene, }); - dispatch({ - type: "load gene info", - gene, - }); - + dispatch({ type: "request gene info start", gene }); dispatch({ type: "toggle active info panel", activeTab: ActiveTab.Gene }); const info = await actions.fetchGeneInfo(geneId, gene); + if (!info) { return; } + dispatch({ type: "open gene info", gene, @@ -314,6 +312,6 @@ function mapStateToProps(state: RootState, ownProps: OwnProps): StateProps { state.colors.colorMode !== "color by categorical metadata", isScatterplotXXaccessor: state.controls.scatterplotXXaccessor === gene, isScatterplotYYaccessor: state.controls.scatterplotYYaccessor === gene, - isGeneInfo: state.controls.gene === gene, + isGeneInfo: state.controls.geneInfo.gene === gene, }; } diff --git a/client/src/components/geneExpression/infoPanel/common/constants.ts b/client/src/components/geneExpression/infoPanel/common/constants.ts new file mode 100644 index 000000000..a4bd253c4 --- /dev/null +++ b/client/src/components/geneExpression/infoPanel/common/constants.ts @@ -0,0 +1,16 @@ +export const NO_ENTITY_SELECTED = (infoType: string) => + `No ${infoType} Selected`; +export const ENTITY_NOT_FOUND = (infoType: string) => + `Sorry, this ${infoType} could not be found.`; +export const OPEN_IN = (infoType: string) => `Open in ${infoType}`; +export const SELECT_GENE_OR_CELL_TYPE = + "Select a gene or cell type or search the Cell Guide database"; +export const NCBI_WARNING = "NCBI didn't return an exact match for this gene."; +export const SEARCH_ON_GOOGLE = "Search on Google"; +export const LABELS = { + ontologyID: "Ontology ID: ", + Synonyms: "Synonyms: ", + References: "References: ", +}; + +export const CELLGUIDE_URL = "https://cellxgene.cziscience.com/cellguide/"; diff --git a/client/src/components/geneExpression/infoPanel/common/infoPanelContainer/index.tsx b/client/src/components/geneExpression/infoPanel/common/infoPanelContainer/index.tsx new file mode 100644 index 000000000..3ad05292d --- /dev/null +++ b/client/src/components/geneExpression/infoPanel/common/infoPanelContainer/index.tsx @@ -0,0 +1,68 @@ +import React from "react"; +import { kebabCase } from "lodash"; +import { InfoContainer, InfoWrapper } from "../style"; +import { + ErrorInfo, + LoadingInfo, + NoneSelected, + ShowInfo, +} from "../infoPanelParts"; +import { ExtendedInfoProps } from "../types"; + +function ContainerInfo(props: ExtendedInfoProps) { + const { + id, + name, + symbol, + description, + synonyms, + references, + error, + loading, + infoType, + url, + showWarningBanner = false, + } = props; + + const infoTypeTag = kebabCase(infoType); + + const wrapperTestId = `${ + infoType === "Gene" ? symbol : name + }:${infoTypeTag}-info-wrapper`; + + return ( + + + {/* Loading */} + {(name || symbol) && !error && loading && ( + + )} + + {/* None Selected */} + {name === "" && error === null && !loading && ( + + )} + + {/* Error */} + {error && } + + {/* Show Info Card for Gen or Cell Type*/} + {name && !error && !loading && ( + + )} + + + ); +} + +export default ContainerInfo; diff --git a/client/src/components/geneExpression/infoPanel/common/infoPanelParts/index.tsx b/client/src/components/geneExpression/infoPanel/common/infoPanelParts/index.tsx new file mode 100644 index 000000000..d28e22695 --- /dev/null +++ b/client/src/components/geneExpression/infoPanel/common/infoPanelParts/index.tsx @@ -0,0 +1,183 @@ +import React from "react"; +import { kebabCase } from "lodash"; +import { Icon, LoadingIndicator } from "czifui"; + +import { + Content, + ContentRow, + CustomIcon, + InfoDiv, + InfoLabel, + InfoOpenIn, + InfoSymbol, + InfoTitle, + Link, + MessageDiv, + NoGeneSelectedDiv, + Items, + WarningBanner, +} from "../style"; +import { BaseInfoProps, ExtendedInfoProps } from "../types"; +import { + ENTITY_NOT_FOUND, + NCBI_WARNING, + NO_ENTITY_SELECTED, + OPEN_IN, + SEARCH_ON_GOOGLE, + SELECT_GENE_OR_CELL_TYPE, + LABELS, +} from "../constants"; + +export function LoadingInfo(props: BaseInfoProps) { + const { name } = props; + return ( + + + {name} + +
+
+ + + +
+
+
+ ); +} + +export function NoneSelected({ + infoType, +}: { + infoType: BaseInfoProps["infoType"]; +}) { + return ( + + + + + {NO_ENTITY_SELECTED(infoType)} + + {SELECT_GENE_OR_CELL_TYPE} + + + ); +} + +export function ShowWarningBanner() { + return ( + + + {NCBI_WARNING} + + ); +} + +export function ErrorInfo(props: BaseInfoProps) { + const { name, infoType } = props; + return ( + + + {name} + + + {ENTITY_NOT_FOUND(infoType)} + + + {SEARCH_ON_GOOGLE} + + + ); +} + +export function ShowInfo(props: ExtendedInfoProps) { + const { + name, + infoType, + description, + id, + synonyms, + references, + url, + symbol, + showWarningBanner, + } = props; + const externalUrl = id ? url + id : url; + const infoTypeTag = kebabCase(infoType); + + return ( + + {showWarningBanner && } + + {symbol ?? name} + + + {OPEN_IN(infoType === "Cell Type" ? "Cell Guide" : "NCBI")} + + + + {infoType === "Gene" && ( + {name} + )} + + {description} + {infoType === "Cell Type" && ( + + {LABELS.ontologyID} + + {id} + + + )} + {synonyms.length > 0 && ( + + {LABELS.Synonyms} + + {synonyms.map((syn, index) => ( + + {syn} + {index < synonyms.length - 1 && ", "} + + ))} + + + )} + {infoType === "Cell Type" && references && references?.length > 0 && ( + + {LABELS.References} + {references.map((ref, index) => ( + + [{index + 1}] + + ))} + + )} + + + ); +} diff --git a/client/src/components/geneExpression/infoPanel/geneInfo/style.ts b/client/src/components/geneExpression/infoPanel/common/style.ts similarity index 82% rename from client/src/components/geneExpression/infoPanel/geneInfo/style.ts rename to client/src/components/geneExpression/infoPanel/common/style.ts index f72b580dc..5d5a7d127 100644 --- a/client/src/components/geneExpression/infoPanel/geneInfo/style.ts +++ b/client/src/components/geneExpression/infoPanel/common/style.ts @@ -13,22 +13,28 @@ import * as globals from "../../../../globals"; import * as styles from "../../util"; import { gray100, gray500, spacesM } from "../../../theme"; -export const GeneInfoWrapper = styled.div` +export const InfoWrapper = styled.div` display: flex; bottom: ${globals.bottomToolbarGutter}px; left: ${globals.leftSidebarWidth + globals.scatterplotMarginLeft}px; `; -export const GeneInfoContainer = styled.div` +export const InfoContainer = styled.div` width: ${styles.width + styles.margin.left + styles.margin.right}px; height: "50%"; `; -export const GeneInfoEmpty = styled.div` +export const InfoDiv = styled.div` margin: ${spacesM}px; `; -export const GeneSymbol = styled.h1` +export const InfoOpenIn = styled.div` + padding-top: 15px; +`; + +export const InfoSymbol = styled.h1` + width: 60%; + text-overflow: ellipsis; color: black; ${fontHeaderL} ${(props) => { @@ -40,7 +46,12 @@ export const GeneSymbol = styled.h1` }} `; -export const Content = styled.p` +export const InfoTitle = styled.div` + display: flex; + justify-content: space-between; +`; + +export const Content = styled.div` font-weight: 500; color: black; ${fontBodyXs} @@ -50,7 +61,11 @@ export const Content = styled.p` overflow: hidden; `; -export const SynHeader = styled.span` +export const ContentRow = styled.p` + padding-bottom: 4px; +`; + +export const InfoLabel = styled.span` ${fontBodyXs} ${(props) => { const colors = getColors(props); @@ -63,7 +78,7 @@ export const SynHeader = styled.span` }} `; -export const Synonyms = styled.span` +export const Items = styled.span` padding: 4px; color: black; diff --git a/client/src/components/geneExpression/infoPanel/common/types.ts b/client/src/components/geneExpression/infoPanel/common/types.ts new file mode 100644 index 000000000..74531342a --- /dev/null +++ b/client/src/components/geneExpression/infoPanel/common/types.ts @@ -0,0 +1,16 @@ +export interface BaseInfoProps { + name: string; + infoType: "Gene" | "Cell Type"; +} + +export interface ExtendedInfoProps extends BaseInfoProps { + description: string; + id: string | null; + synonyms: string[]; + references: string[]; + url: string; + symbol?: string; + showWarningBanner?: boolean; + error?: string | null; + loading?: boolean; +} diff --git a/client/src/components/geneExpression/infoPanel/connect.ts b/client/src/components/geneExpression/infoPanel/connect.ts new file mode 100644 index 000000000..ec9529ede --- /dev/null +++ b/client/src/components/geneExpression/infoPanel/connect.ts @@ -0,0 +1,35 @@ +import { ChangeEvent, useEffect, useState } from "react"; +import { AppDispatch } from "../../../reducers"; +import { ActiveTab } from "../../../common/types/entities"; + +function useConnect({ + dispatch, + activeTab, +}: { + dispatch: AppDispatch; + activeTab: ActiveTab; +}) { + const [tabValue, setTabValue] = useState(ActiveTab.CellType); + + /** + * TODO: update to once we upgrade MUI + */ + const handleTabsChange = ( + _: ChangeEvent>, + activeTabValue: ActiveTab + ) => { + setTabValue(activeTabValue); + dispatch({ + type: "toggle active info panel", + activeTab: activeTabValue, + }); + }; + + useEffect(() => { + setTabValue(activeTab); + }, [activeTab]); + + return { tabValue, handleTabsChange }; +} + +export default useConnect; diff --git a/client/src/components/geneExpression/infoPanel/geneInfo/connect.ts b/client/src/components/geneExpression/infoPanel/geneInfo/connect.ts deleted file mode 100644 index 2554dcd92..000000000 --- a/client/src/components/geneExpression/infoPanel/geneInfo/connect.ts +++ /dev/null @@ -1,11 +0,0 @@ -export function useConnect({ geneSynonyms }: { geneSynonyms: string[] }) { - let synonymList; - if (geneSynonyms.length > 1) { - synonymList = geneSynonyms.join(", "); - } else if (geneSynonyms.length === 1) { - synonymList = geneSynonyms[0]; - } else { - synonymList = null; - } - return { synonymList }; -} diff --git a/client/src/components/geneExpression/infoPanel/geneInfo/index.tsx b/client/src/components/geneExpression/infoPanel/geneInfo/index.tsx deleted file mode 100644 index 33051ab07..000000000 --- a/client/src/components/geneExpression/infoPanel/geneInfo/index.tsx +++ /dev/null @@ -1,116 +0,0 @@ -import React from "react"; -import { connect } from "react-redux"; -import { Icon } from "czifui"; -import { - SynHeader, - Synonyms, - Link, - Content, - GeneSymbol, - GeneInfoContainer, - GeneInfoEmpty, - GeneInfoWrapper, - WarningBanner, - NoGeneSelectedDiv, - MessageDiv, - CustomIcon, -} from "./style"; -import { Props, mapStateToProps } from "./types"; -import { useConnect } from "./connect"; - -function GeneInfo(props: Props) { - const { - geneSummary, - geneName, - gene, - geneUrl, - geneSynonyms, - infoError, - showWarningBanner, - } = props; - - const { synonymList } = useConnect({ geneSynonyms }); - - return ( - - - {geneName === "" && infoError === null && gene !== null ? ( - - {gene} - loading... - - ) : null} - {gene === null && infoError === null ? ( - - - - - No Gene Selected - - Choose a gene above or search the NCBI database. - - - - ) : null} - {infoError !== null ? ( - - {gene} - Sorry, this gene could not be found on NCBI. - - Search on Google - - - ) : null} - {geneName !== "" && infoError === null ? ( - - {showWarningBanner ? ( - - - - NCBI didn't return an exact match for this gene. - - - ) : null} - {gene} - {geneName} - {geneSummary === "" ? ( - - This gene does not currently have a summary in NCBI. - - ) : ( - {geneSummary} - )} - {synonymList ? ( -

- Synonyms - - {synonymList} - -

- ) : null} - {geneUrl !== "" ? ( - - View on NCBI - - ) : null} -
- ) : null} -
-
- ); -} - -export default connect(mapStateToProps)(GeneInfo); diff --git a/client/src/components/geneExpression/infoPanel/geneInfo/types.ts b/client/src/components/geneExpression/infoPanel/geneInfo/types.ts deleted file mode 100644 index b23705c1b..000000000 --- a/client/src/components/geneExpression/infoPanel/geneInfo/types.ts +++ /dev/null @@ -1,21 +0,0 @@ -import { RootState } from "../../../../reducers"; - -export interface Props { - geneSummary: RootState["controls"]["geneSummary"]; - geneName: RootState["controls"]["geneName"]; - gene: RootState["controls"]["gene"]; - geneUrl: RootState["controls"]["geneUrl"]; - geneSynonyms: RootState["controls"]["geneSynonyms"]; - showWarningBanner: RootState["controls"]["showWarningBanner"]; - infoError: RootState["controls"]["infoError"]; -} - -export const mapStateToProps = (state: RootState): Props => ({ - geneSummary: state.controls.geneSummary, - geneName: state.controls.geneName, - gene: state.controls.gene, - geneUrl: state.controls.geneUrl, - geneSynonyms: state.controls.geneSynonyms, - showWarningBanner: state.controls.showWarningBanner, - infoError: state.controls.infoError, -}); diff --git a/client/src/components/geneExpression/infoPanel/index.tsx b/client/src/components/geneExpression/infoPanel/index.tsx index 0cbe6cc6c..40bc4be9f 100644 --- a/client/src/components/geneExpression/infoPanel/index.tsx +++ b/client/src/components/geneExpression/infoPanel/index.tsx @@ -1,4 +1,4 @@ -import React, { useState, ChangeEvent, useEffect } from "react"; +import React from "react"; import { connect } from "react-redux"; import { AnchorButton, ButtonGroup } from "@blueprintjs/core"; import { Tabs, Tab } from "czifui"; @@ -9,28 +9,16 @@ import { InfoPanelHeader, InfoPanelWrapper, } from "./style"; -import GeneInfo from "./geneInfo"; -import DatasetInfo from "./datasetInfo"; +import CellTypeInfo from "./infoPanelCellType"; +import GeneInfo from "./infoPanelGene"; +import DatasetInfo from "./infoPanelDataset"; import { Props, mapStateToProps } from "./types"; +import useConnect from "./connect"; +import { ActiveTab } from "../../../common/types/entities"; function InfoPanel(props: Props) { const { activeTab, dispatch, infoPanelMinimized, infoPanelHidden } = props; - const [value, setValue] = useState(1); - - const handleTabsChange = ( - _: ChangeEvent>, - tabsValue: unknown - ) => { - setValue(tabsValue as number); - dispatch({ - type: "toggle active info panel", - activeTab: tabsValue === 0 ? "Gene" : "Dataset", - }); - }; - - useEffect(() => { - setValue(activeTab === "Gene" ? 0 : 1); - }, [activeTab]); + const { tabValue, handleTabsChange } = useConnect({ dispatch, activeTab }); return ( - - - + + + + - {activeTab === "Gene" && } - {activeTab === "Dataset" && } + {activeTab === ActiveTab.Gene && } + {activeTab === ActiveTab.CellType && } + {activeTab === ActiveTab.Dataset && } ); diff --git a/client/src/components/geneExpression/infoPanel/infoPanelCellType/index.tsx b/client/src/components/geneExpression/infoPanel/infoPanelCellType/index.tsx new file mode 100644 index 000000000..43eb6f891 --- /dev/null +++ b/client/src/components/geneExpression/infoPanel/infoPanelCellType/index.tsx @@ -0,0 +1,35 @@ +import React from "react"; +import { connect } from "react-redux"; +import { Props, mapStateToProps } from "./types"; +import ContainerInfo from "../common/infoPanelContainer"; +import { CELLGUIDE_URL } from "../common/constants"; + +function CellTypeInfo(props: Props) { + const { cellInfo } = props; + + const { + cellId, + cellName, + cellDescription, + synonyms, + references, + error, + loading, + } = cellInfo; + + return ( + + ); +} + +export default connect(mapStateToProps)(CellTypeInfo); diff --git a/client/src/components/geneExpression/infoPanel/infoPanelCellType/types.ts b/client/src/components/geneExpression/infoPanel/infoPanelCellType/types.ts new file mode 100644 index 000000000..f6c5cbeb9 --- /dev/null +++ b/client/src/components/geneExpression/infoPanel/infoPanelCellType/types.ts @@ -0,0 +1,9 @@ +import { RootState } from "../../../../reducers"; + +export interface Props { + cellInfo: RootState["controls"]["cellInfo"]; +} + +export const mapStateToProps = (state: RootState): Props => ({ + cellInfo: state.controls.cellInfo, +}); diff --git a/client/src/components/geneExpression/infoPanel/datasetInfo/connect.ts b/client/src/components/geneExpression/infoPanel/infoPanelDataset/connect.ts similarity index 100% rename from client/src/components/geneExpression/infoPanel/datasetInfo/connect.ts rename to client/src/components/geneExpression/infoPanel/infoPanelDataset/connect.ts diff --git a/client/src/components/geneExpression/infoPanel/datasetInfo/datasetInfoFormat.tsx b/client/src/components/geneExpression/infoPanel/infoPanelDataset/datasetInfoFormat.tsx similarity index 100% rename from client/src/components/geneExpression/infoPanel/datasetInfo/datasetInfoFormat.tsx rename to client/src/components/geneExpression/infoPanel/infoPanelDataset/datasetInfoFormat.tsx diff --git a/client/src/components/geneExpression/infoPanel/datasetInfo/index.tsx b/client/src/components/geneExpression/infoPanel/infoPanelDataset/index.tsx similarity index 100% rename from client/src/components/geneExpression/infoPanel/datasetInfo/index.tsx rename to client/src/components/geneExpression/infoPanel/infoPanelDataset/index.tsx diff --git a/client/src/components/geneExpression/infoPanel/datasetInfo/types.ts b/client/src/components/geneExpression/infoPanel/infoPanelDataset/types.ts similarity index 100% rename from client/src/components/geneExpression/infoPanel/datasetInfo/types.ts rename to client/src/components/geneExpression/infoPanel/infoPanelDataset/types.ts diff --git a/client/src/components/geneExpression/infoPanel/infoPanelGene/index.tsx b/client/src/components/geneExpression/infoPanel/infoPanelGene/index.tsx new file mode 100644 index 000000000..01af4020a --- /dev/null +++ b/client/src/components/geneExpression/infoPanel/infoPanelGene/index.tsx @@ -0,0 +1,37 @@ +import React from "react"; +import { connect } from "react-redux"; +import { Props, mapStateToProps } from "./types"; +import ContainerInfo from "../common/infoPanelContainer"; + +function GeneInfo(props: Props) { + const { geneInfo } = props; + + const { + geneName, + geneSummary, + gene, + geneUrl, + geneSynonyms, + showWarningBanner, + infoError, + loading, + } = geneInfo; + + return ( + + ); +} + +export default connect(mapStateToProps)(GeneInfo); diff --git a/client/src/components/geneExpression/infoPanel/infoPanelGene/types.ts b/client/src/components/geneExpression/infoPanel/infoPanelGene/types.ts new file mode 100644 index 000000000..b14e6de39 --- /dev/null +++ b/client/src/components/geneExpression/infoPanel/infoPanelGene/types.ts @@ -0,0 +1,9 @@ +import { RootState } from "../../../../reducers"; + +export interface Props { + geneInfo: RootState["controls"]["geneInfo"]; +} + +export const mapStateToProps = (state: RootState): Props => ({ + geneInfo: state.controls.geneInfo, +}); diff --git a/client/src/components/geneExpression/infoPanel/types.ts b/client/src/components/geneExpression/infoPanel/types.ts index ab20cff47..5d556abf8 100644 --- a/client/src/components/geneExpression/infoPanel/types.ts +++ b/client/src/components/geneExpression/infoPanel/types.ts @@ -1,7 +1,8 @@ +import { ActiveTab } from "../../../common/types/entities"; import { AppDispatch, RootState } from "../../../reducers"; interface StateProps { - activeTab: string; + activeTab: ActiveTab; infoPanelMinimized: boolean; infoPanelHidden: boolean; } diff --git a/client/src/components/theme.ts b/client/src/components/theme.ts index 6300c6453..0ef421db4 100644 --- a/client/src/components/theme.ts +++ b/client/src/components/theme.ts @@ -58,7 +58,7 @@ export const cornersS = (props: CommonThemeProps) => getCorners(props)?.s; export const cornersNone = (props: CommonThemeProps) => getCorners(props)?.none; const typography = { - fontFamily: "Inter", + fontFamily: "Roboto Condensed", styles: { body: { button: { diff --git a/client/src/reducers/controls.ts b/client/src/reducers/controls.ts index c8388382c..503144e7c 100644 --- a/client/src/reducers/controls.ts +++ b/client/src/reducers/controls.ts @@ -1,10 +1,11 @@ import { AnyAction } from "redux"; -import { DatasetUnsMetadata } from "../common/types/entities"; +import { + ActiveTab, + CellInfo, + DatasetUnsMetadata, + GeneInfo, +} from "../common/types/entities"; -export enum ActiveTab { - Gene = "Gene", - Dataset = "Dataset", -} interface ControlsState { loading: boolean; error: Error | string | null; @@ -14,26 +15,21 @@ interface ControlsState { scatterplotYYaccessor: string | false; scatterplotIsMinimized: boolean; scatterplotIsOpen: boolean; - gene: string | null; - infoError: string | null; graphRenderCounter: number; colorLoading: boolean; datasetDrawer: boolean; - geneUrl: string; - geneSummary: string; - geneName: string; - geneSynonyms: string[]; isCellGuideCxg: boolean; screenCap: boolean; mountCapture: boolean; - showWarningBanner: boolean; imageUnderlay: boolean; activeTab: ActiveTab; infoPanelHidden: boolean; infoPanelMinimized: boolean; - unsMetadata: DatasetUnsMetadata; imageOpacity: number; dotOpacity: number; + geneInfo: GeneInfo; + unsMetadata: DatasetUnsMetadata; + cellInfo: CellInfo; expandedCategories: string[]; } const Controls = ( @@ -47,20 +43,13 @@ const Controls = ( scatterplotXXaccessor: false, // just easier to read scatterplotYYaccessor: false, scatterplotIsMinimized: false, - geneUrl: "", - geneSummary: "", - geneSynonyms: [""], - geneName: "", scatterplotIsOpen: false, - gene: null, - infoError: null, graphRenderCounter: 0 /* integer as str: + response = requests.get(url=f"{CELLGUIDE_BASE_URL}/latest_snapshot_identifier") + response.raise_for_status() + return response.text.strip() + + +def get_celltype_metadata(snapshot_identifier: str) -> Dict[str, CellTypeMetadata]: + response = requests.get(url=f"{CELLGUIDE_BASE_URL}/{snapshot_identifier}/celltype_metadata.json") + response.raise_for_status() + return cast(Dict[str, CellTypeMetadata], response.json()) + + +def get_cell_description(cell_id: str) -> CellDescription: + response = requests.get(url=f"{CELLGUIDE_BASE_URL}/validated_descriptions/{cell_id}.json") + response.raise_for_status() + return cast(CellDescription, response.json()) diff --git a/server/tests/unit/common/apis/test_api_v3.py b/server/tests/unit/common/apis/test_api_v3.py index 3ae30eaa2..0521d6a70 100644 --- a/server/tests/unit/common/apis/test_api_v3.py +++ b/server/tests/unit/common/apis/test_api_v3.py @@ -20,6 +20,27 @@ BAD_FILTER = {"filter": {"obs": {"annotation_value": [{"name": "xyz"}]}}} +# Test data for cell type info endpoint +TEST_SNAPSHOT_IDENTIFIER = "snapshot123" +TEST_CELL_TYPE_METADATA = { + "1": { + "name": "monocyte", + "id": "CL:0000576", + "clDescription": "Monocytes are a type of white blood cell...", + "synonyms": [], + } +} +TEST_CELL_DESCRIPTION = { + "description": "Monocytes are a type of white blood cell...", + "references": ["ref1", "ref2"], +} +TEST_CELL_DESCRIPTION_RESPONSE = ( + '{"description": "Monocytes are a type of white blood cell", "references": ["ref1", "ref2"]}' +) +TEST_CELL_METADATA_RESPONSE = ( + '{"1": {"name": "monocyte", "id": "CL:0000540", "clDescription": "A neuron", "synonyms": []}}' +) + class BaseTest(_BaseTest): @classmethod @@ -554,6 +575,67 @@ def test_gene_info_failure(self, mock_request): self.assertEqual(result.status_code, HTTPStatus.BAD_REQUEST) self.assertEqual(result.headers["Content-Type"], "application/json") + @patch("server.common.rest.requests.get") + @patch("server.common.utils.cell_type_info.get_latest_snapshot_identifier") + @patch("server.common.utils.cell_type_info.get_celltype_metadata") + @patch("server.common.utils.cell_type_info.get_cell_description") + def test_cell_type_info_success( + self, mock_get_cell_description, mock_get_celltype_metadata, mock_get_latest_snapshot_identifier, mock_request + ): + endpoint = "cellinfo?cell=monocyte" + mock_get_latest_snapshot_identifier.return_value = TEST_SNAPSHOT_IDENTIFIER + mock_get_celltype_metadata.return_value = TEST_CELL_TYPE_METADATA + mock_get_cell_description.return_value = TEST_CELL_DESCRIPTION + + def mock_requests_get(url, *args, **kwargs): + if "latest_snapshot_identifier" in url: + return MockResponse(TEST_SNAPSHOT_IDENTIFIER, 200) + elif "celltype_metadata.json" in url: + return MockResponse(TEST_CELL_METADATA_RESPONSE, 200) + else: + return MockResponse(TEST_CELL_DESCRIPTION_RESPONSE, 200) + + mock_request.side_effect = mock_requests_get + + for url_base in [self.TEST_UNS_URL_BASE]: + with self.subTest(url_base=url_base): + url = f"{url_base}{endpoint}" + result = self.client.get(url) + self.assertEqual(result.status_code, HTTPStatus.OK) + self.assertIn("description", result.json) + self.assertIn("cell_id", result.json) + self.assertIn("cell_name", result.json) + self.assertIn("synonyms", result.json) + + @patch("server.common.rest.requests.get") + @patch("server.common.utils.cell_type_info.get_latest_snapshot_identifier") + @patch("server.common.utils.cell_type_info.get_celltype_metadata") + @patch("server.common.utils.cell_type_info.get_cell_description") + def test_cell_type_info_not_found( + self, _, mock_get_celltype_metadata, mock_get_latest_snapshot_identifier, mock_request + ): + endpoint = "cellinfo?cell=unknowncell" + mock_get_latest_snapshot_identifier.return_value = TEST_SNAPSHOT_IDENTIFIER + mock_get_celltype_metadata.return_value = TEST_CELL_TYPE_METADATA + + def mock_requests_get(url): + if "latest_snapshot_identifier" in url: + return MockResponse(TEST_SNAPSHOT_IDENTIFIER, 200) + elif "celltype_metadata.json" in url: + return MockResponse(TEST_CELL_METADATA_RESPONSE, 200) + else: + return MockResponse(TEST_CELL_DESCRIPTION_RESPONSE, 200) + + mock_request.side_effect = mock_requests_get + + for url_base in [self.TEST_UNS_URL_BASE]: + with self.subTest(url_base=url_base): + url = f"{url_base}{endpoint}" + result = self.client.get(url) + self.assertEqual(result.status_code, HTTPStatus.OK) + self.assertIn("error", result.json) + self.assertEqual(result.json["error"], "Cell type not found") + @unittest.skipIf(lambda x: os.getenv("SKIP_STATIC"), "Skip static test when running locally") def test_static(self): endpoint = "static" @@ -1074,10 +1156,15 @@ def test_get_deployed_version(self): class MockResponse: - def __init__(self, body, status_code, ok=True): + def __init__(self, body, status_code, text="monocyte", ok=True): + self.text = text self.content = body self.status_code = status_code self.ok = ok def json(self): return json.loads(self.content) + + def raise_for_status(self): + if self.status_code >= 400: + raise