From 35b99a4a27377c1c96971830a475814a69edfc88 Mon Sep 17 00:00:00 2001 From: Timmy Huang Date: Thu, 7 Dec 2023 10:55:34 -0800 Subject: [PATCH] feat: 5673 cell guide tissue specific cell type route (#6232) --- ...c-cellType-seoMetaTags-chromium-darwin.txt | 1 + ...ic-cellType-seoMetaTags-chromium-linux.txt | 1 + frontend/src/common/constants/routes.ts | 3 + frontend/src/common/queries/cellGuide.ts | 23 +- frontend/src/pages/cellguide/[cellTypeId].tsx | 8 + .../[tissueId]/cell-types/[cellTypeId].tsx | 9 + frontend/src/views/CellGuide/common/utils.ts | 29 +++ .../MarkerGeneTables/hooks/common.ts | 21 +- .../components/CellGuideCard/connect.ts | 166 ++++++++++++ .../components/CellGuideCard/index.tsx | 181 +++++-------- .../components/CellGuideCard/types.ts | 4 + .../components/CellGuideInfoSideBar/index.tsx | 27 +- .../components/AnimatedNodes/index.tsx | 12 +- .../common/OntologyDagView/index.tsx | 11 +- .../features/cellGuide/cellGuide.test.ts | 237 ++++++++++++++---- frontend/tests/utils/helpers.ts | 6 + frontend/tsconfig.json | 30 +-- 17 files changed, 564 insertions(+), 205 deletions(-) create mode 100644 frontend/__snapshots__/tests/features/cellGuide/cellGuide.test.ts-snapshots/tissue-specific-cellType-seoMetaTags-chromium-darwin.txt create mode 100644 frontend/__snapshots__/tests/features/cellGuide/cellGuide.test.ts-snapshots/tissue-specific-cellType-seoMetaTags-chromium-linux.txt create mode 100644 frontend/src/pages/cellguide/tissues/[tissueId]/cell-types/[cellTypeId].tsx create mode 100644 frontend/src/views/CellGuide/components/CellGuideCard/connect.ts create mode 100644 frontend/src/views/CellGuide/components/CellGuideCard/types.ts diff --git a/frontend/__snapshots__/tests/features/cellGuide/cellGuide.test.ts-snapshots/tissue-specific-cellType-seoMetaTags-chromium-darwin.txt b/frontend/__snapshots__/tests/features/cellGuide/cellGuide.test.ts-snapshots/tissue-specific-cellType-seoMetaTags-chromium-darwin.txt new file mode 100644 index 0000000000000..30036f3f5a5d5 --- /dev/null +++ b/frontend/__snapshots__/tests/features/cellGuide/cellGuide.test.ts-snapshots/tissue-specific-cellType-seoMetaTags-chromium-darwin.txt @@ -0,0 +1 @@ +["","","","","","","","","","","","","","","","","",""] \ No newline at end of file diff --git a/frontend/__snapshots__/tests/features/cellGuide/cellGuide.test.ts-snapshots/tissue-specific-cellType-seoMetaTags-chromium-linux.txt b/frontend/__snapshots__/tests/features/cellGuide/cellGuide.test.ts-snapshots/tissue-specific-cellType-seoMetaTags-chromium-linux.txt new file mode 100644 index 0000000000000..30036f3f5a5d5 --- /dev/null +++ b/frontend/__snapshots__/tests/features/cellGuide/cellGuide.test.ts-snapshots/tissue-specific-cellType-seoMetaTags-chromium-linux.txt @@ -0,0 +1 @@ +["","","","","","","","","","","","","","","","","",""] \ No newline at end of file diff --git a/frontend/src/common/constants/routes.ts b/frontend/src/common/constants/routes.ts index 15c371bd103ce..6c97ab3b12ad1 100644 --- a/frontend/src/common/constants/routes.ts +++ b/frontend/src/common/constants/routes.ts @@ -16,5 +16,8 @@ export enum ROUTES { WMG_DOCS_DATA_PROCESSING = "/docs/04__Analyze%20Public%20Data/4_2__Gene%20Expression%20Documentation/4_2_3__Gene%20Expression%20Data%20Processing", SITEMAP = "/sitemap", CELL_GUIDE = "/cellguide", + CELL_GUIDE_CELL_TYPE = "/cellguide/:cellTypeId", + CELL_GUIDE_TISSUE = "/cellguide/tissues/:tissueId", + CELL_GUIDE_TISSUE_SPECIFIC_CELL_TYPE = "/cellguide/tissues/:tissueId/cell-types/:cellTypeId", DEPLOYED_VERSION = "/api/deployed_version", } diff --git a/frontend/src/common/queries/cellGuide.ts b/frontend/src/common/queries/cellGuide.ts index e16290559cb4f..87f62ffaa9964 100644 --- a/frontend/src/common/queries/cellGuide.ts +++ b/frontend/src/common/queries/cellGuide.ts @@ -472,11 +472,20 @@ export const fetchTissueMetadata = }; /* ========== Lookup tables for organs ========== */ -export function useAllOrgansLookupTables(): Map { - const { data: allOrgansData } = useTissueMetadata(); +export function useAllOrgansLookupTables(): { + data: Map; + isSuccess: boolean; +} { + /** + * (thuang): Expose `isSuccess`, so `CellGuide/components/CellGuideCard/connect.ts` + * can use it to determine if the data is ready and determine if the user should + * be redirected to the tissue agnostic cell type page. + */ + const { data: allOrgansData, isSuccess } = useTissueMetadata(); + return useMemo(() => { if (!allOrgansData) { - return new Map(); + return { data: new Map(), isSuccess }; } const allOrgansLabelToIdMap = new Map(); @@ -484,8 +493,12 @@ export function useAllOrgansLookupTables(): Map { const organData = allOrgansData[organId]; allOrgansLabelToIdMap.set(organData.name, organData.id); } - return allOrgansLabelToIdMap; - }, [allOrgansData]); + + return { + data: allOrgansLabelToIdMap, + isSuccess, + }; + }, [allOrgansData, isSuccess]); } /* ========== Lookup tables for tissues ========== */ diff --git a/frontend/src/pages/cellguide/[cellTypeId].tsx b/frontend/src/pages/cellguide/[cellTypeId].tsx index d88a9709e810e..e953ba6cefa32 100644 --- a/frontend/src/pages/cellguide/[cellTypeId].tsx +++ b/frontend/src/pages/cellguide/[cellTypeId].tsx @@ -1,3 +1,11 @@ +/** + * IMPORTANT(thuang): `frontend/src/pages/cellguide/tissues/[tissueId]/cell-types/[cellTypeId].tsx` + * imports the exports from this file, since both routes use the same components. + * If we export more things from this file, we need to export them from the other file as well, + * to make sure that /cellguide/tissues/[tissueId]/cell-types/[cellTypeId] route continue + * to work. + */ + import { GetServerSideProps, InferGetServerSidePropsType } from "next"; import CellGuideCard from "src/views/CellGuide/components/CellGuideCard"; import { diff --git a/frontend/src/pages/cellguide/tissues/[tissueId]/cell-types/[cellTypeId].tsx b/frontend/src/pages/cellguide/tissues/[tissueId]/cell-types/[cellTypeId].tsx new file mode 100644 index 0000000000000..aa78a0e5bb985 --- /dev/null +++ b/frontend/src/pages/cellguide/tissues/[tissueId]/cell-types/[cellTypeId].tsx @@ -0,0 +1,9 @@ +/** + * IMPORTANT(thuang): Make sure the exports from this file is in sync with + * frontend/src/pages/cellguide/[cellTypeId].tsx + */ + +import Page, { getServerSideProps } from "src/pages/cellguide/[cellTypeId]"; + +export default Page; +export { getServerSideProps }; diff --git a/frontend/src/views/CellGuide/common/utils.ts b/frontend/src/views/CellGuide/common/utils.ts index 7705153cba8fe..f763725141842 100644 --- a/frontend/src/views/CellGuide/common/utils.ts +++ b/frontend/src/views/CellGuide/common/utils.ts @@ -28,3 +28,32 @@ export function filterDescendantsOfAncestorTissueId( return tissueIdList.filter((value) => descendantSet.has(value)); } + +import { ROUTES } from "src/common/constants/routes"; +import { NO_ORGAN_ID } from "src/views/CellGuide/components/CellGuideCard/components/MarkerGeneTables/constants"; + +export function getCellTypeLink({ + /** + * (thuang): `queryTissueId` can be undefined when a user is on `/cellguide/:cellTypeId` route + * instead of `/cellguide/tissues/:tissueId/cell-types/:cellTypeId` route. + * + * NOTE: `tissue` and `organ` in variable names are interchangeable here. + */ + tissueId = NO_ORGAN_ID, + cellTypeId, +}: { + tissueId: string; + cellTypeId: string; +}) { + const urlCellTypeId = cellTypeId.replace(":", "_") ?? ""; + const urlTissueId = tissueId.replace(":", "_") || NO_ORGAN_ID; + + if (tissueId === NO_ORGAN_ID) { + return ROUTES.CELL_GUIDE_CELL_TYPE.replace(":cellTypeId", urlCellTypeId); + } else { + return ROUTES.CELL_GUIDE_TISSUE_SPECIFIC_CELL_TYPE.replace( + ":tissueId", + urlTissueId + ).replace(":cellTypeId", urlCellTypeId); + } +} diff --git a/frontend/src/views/CellGuide/components/CellGuideCard/components/MarkerGeneTables/hooks/common.ts b/frontend/src/views/CellGuide/components/CellGuideCard/components/MarkerGeneTables/hooks/common.ts index 4321d60950e60..e021b93215253 100644 --- a/frontend/src/views/CellGuide/components/CellGuideCard/components/MarkerGeneTables/hooks/common.ts +++ b/frontend/src/views/CellGuide/components/CellGuideCard/components/MarkerGeneTables/hooks/common.ts @@ -18,10 +18,14 @@ import { ALL_TISSUES, HOMO_SAPIENS, NO_ORGAN_ID } from "../constants"; export function useOrganAndOrganismFilterListForCellType(cellTypeId: string): { organsMap: Map; organismsList: string[]; + isSuccess: boolean; } { - const { data: computationalMarkers } = useComputationalMarkers(cellTypeId); + const { + data: computationalMarkers, + isSuccess: isComputationalMarkersSuccess, + } = useComputationalMarkers(cellTypeId); - const organLabelToIdMap = useAllOrgansLookupTables(); + const { data: organLabelToIdMap, isSuccess } = useAllOrgansLookupTables(); // eslint-disable-next-line sonarjs/cognitive-complexity return useMemo(() => { @@ -66,6 +70,17 @@ export function useOrganAndOrganismFilterListForCellType(cellTypeId: string): { return { organsMap: sortedFilteredOrganMap, organismsList: sortedOrganismList, + /** + * (thuang): Expose `isSuccess`, so `CellGuide/components/CellGuideCard/connect.ts` + * can use it to determine if the data is ready and determine if the user should + * be redirected to the tissue agnostic cell type page. + */ + isSuccess: isComputationalMarkersSuccess && isSuccess, }; - }, [computationalMarkers, organLabelToIdMap]); + }, [ + computationalMarkers, + organLabelToIdMap, + isSuccess, + isComputationalMarkersSuccess, + ]); } diff --git a/frontend/src/views/CellGuide/components/CellGuideCard/connect.ts b/frontend/src/views/CellGuide/components/CellGuideCard/connect.ts new file mode 100644 index 0000000000000..f45cc62a4196a --- /dev/null +++ b/frontend/src/views/CellGuide/components/CellGuideCard/connect.ts @@ -0,0 +1,166 @@ +import { DefaultDropdownMenuOption } from "@czi-sds/components"; +import { throttle } from "lodash"; +import { useRouter } from "next/router"; +import { useState, useRef, useCallback, useMemo, useEffect } from "react"; +import { ROUTES } from "src/common/constants/routes"; +import { + ALL_TISSUES, + NO_ORGAN_ID, + TISSUE_AGNOSTIC, +} from "src/views/CellGuide/components/CellGuideCard/components/MarkerGeneTables/constants"; +import { useOrganAndOrganismFilterListForCellType } from "src/views/CellGuide/components/CellGuideCard/components/MarkerGeneTables/hooks/common"; +import { SKINNY_MODE_BREAKPOINT_WIDTH } from "src/views/CellGuide/components/CellGuideCard/constants"; +import { LEFT_RIGHT_PADDING_PX_XXL } from "src/views/CellGuide/components/CellGuideCard/style"; +import { SDSOrgan } from "src/views/CellGuide/components/CellGuideCard/types"; +import { CellType } from "src/views/CellGuide/components/common/OntologyDagView/common/types"; +import { Gene } from "src/views/WheresMyGeneV2/common/types"; + +export function useConnect() { + const router = useRouter(); + + const [pageNavIsOpen, setPageNavIsOpen] = useState(false); + const [selectedGene, setSelectedGene] = useState( + undefined + ); + + const [skinnyMode, setSkinnyMode] = useState(false); + + // Navigation + const sectionRef0 = useRef(null); + const sectionRef1 = useRef(null); + const sectionRef2 = useRef(null); + const sectionRef3 = useRef(null); + + const selectGene = useCallback( + (gene: string) => { + if (gene === selectedGene) { + setSelectedGene(undefined); + } else { + setSelectedGene(gene); + if (sectionRef1.current) { + window.scrollTo({ + top: + sectionRef1.current.getBoundingClientRect().top + + window.scrollY - + 50, + behavior: "smooth", + }); + } + } + }, + [selectedGene] + ); + + // Set the mobile tooltip view content + const [tooltipContent, setTooltipContent] = useState<{ + title: string; + element: JSX.Element; + } | null>(null); + + const { cellTypeId: queryCellTypeId, tissueId: queryTissueId } = router.query; + const cellTypeId = (queryCellTypeId as string)?.replace("_", ":") ?? ""; + + /** + * (thuang): `queryTissueId` can be undefined when a user is on `/cellguide/:cellTypeId` route + * instead of `/cellguide/tissues/:tissueId/cell-types/:cellTypeId` route. + * + * NOTE: `tissue` and `organ` in variable names are interchangeable here. + */ + const tissueId = (queryTissueId as string)?.replace("_", ":") || NO_ORGAN_ID; + + const handleResize = useCallback(() => { + setSkinnyMode( + window.innerWidth < + SKINNY_MODE_BREAKPOINT_WIDTH + 2 * LEFT_RIGHT_PADDING_PX_XXL + ); + }, [setSkinnyMode]); + + const throttledHandleResize = useMemo(() => { + return throttle(handleResize, 100); + }, [handleResize]); + + useEffect(() => { + throttledHandleResize(); + window.addEventListener("resize", throttledHandleResize); + + return () => window.removeEventListener("resize", throttledHandleResize); + }, [throttledHandleResize]); + + const [geneInfoGene, setGeneInfoGene] = useState(null); + const [cellInfoCellType, setCellInfoCellType] = useState( + null + ); + + const { organismsList, organsMap, isSuccess } = + useOrganAndOrganismFilterListForCellType(cellTypeId); + + const sdsOrganismsList = useMemo( + () => + organismsList.map((organism) => ({ + name: organism, + })), + [organismsList] + ); + + const sdsOrgansList = useMemo( + () => + Array.from(organsMap).map(([name, id]) => ({ + name: name === ALL_TISSUES ? TISSUE_AGNOSTIC : name, + id, + })), + [organsMap] + ); + + const selectedOrgan = sdsOrgansList.find((organ) => organ.id === tissueId); + + /** + * (thuang): Push the user to tissue agnostic cell type page if the user is on + * a tissue-specific cell type page and the tissue is not in the filter list. + */ + useEffect(() => { + if (isSuccess && tissueId !== NO_ORGAN_ID && !selectedOrgan) { + router.replace( + ROUTES.CELL_GUIDE_CELL_TYPE.replace( + ":cellTypeId", + queryCellTypeId as string + ) + ); + } + }, [queryCellTypeId, router, selectedOrgan, tissueId, isSuccess]); + + const [selectedOrganism, setSelectedOrganism] = + useState(sdsOrganismsList[0]); + + useEffect(() => { + setSelectedGene(undefined); + }, [selectedOrgan, selectedOrganism, setSelectedGene]); + + return { + router, + pageNavIsOpen, + setPageNavIsOpen, + selectedGene, + sectionRef0, + sectionRef1, + sectionRef2, + sectionRef3, + skinnyMode, + selectGene, + tooltipContent, + setTooltipContent, + queryCellTypeId, + cellTypeId, + geneInfoGene, + setGeneInfoGene, + cellInfoCellType, + setCellInfoCellType, + organismsList, + organsMap, + sdsOrganismsList, + sdsOrgansList, + selectedOrgan, + selectedOrganId: tissueId, + selectedOrganism, + setSelectedOrganism, + }; +} diff --git a/frontend/src/views/CellGuide/components/CellGuideCard/index.tsx b/frontend/src/views/CellGuide/components/CellGuideCard/index.tsx index 3a88787456ce1..e230b0a04193f 100644 --- a/frontend/src/views/CellGuide/components/CellGuideCard/index.tsx +++ b/frontend/src/views/CellGuide/components/CellGuideCard/index.tsx @@ -1,5 +1,4 @@ -import React, { useCallback, useEffect, useMemo, useState } from "react"; -import { useRouter } from "next/router"; +import React, { useMemo } from "react"; import { Global } from "@emotion/react"; import { Wrapper, @@ -8,7 +7,6 @@ import { StyledTag, CellGuideView, CellGuideCardHeaderInnerWrapper, - LEFT_RIGHT_PADDING_PX_XXL, StyledRightSideBar, MobileTooltipTitle, MobileTooltipWrapper, @@ -25,25 +23,20 @@ import FullScreenProvider from "../common/FullScreenProvider"; import SourceDataTable from "./components/SourceDataTable"; import CellGuideCardSidebar from "./components/CellGuideCardSidebar"; import CellGuideMobileHeader from "../CellGuideMobileHeader"; -import { Gene } from "src/views/WheresMyGeneV2/common/types"; -import { throttle } from "lodash"; import GeneInfoSideBar from "src/components/GeneInfoSideBar"; import { titleize } from "src/common/utils/string"; import Head from "next/head"; import CellGuideBottomBanner from "../CellGuideBottomBanner"; import { StickySidebarStyle } from "./components/CellGuideCardSidebar/style"; import { - SKINNY_MODE_BREAKPOINT_WIDTH, CELL_GUIDE_CARD_GLOBAL_ORGANISM_FILTER_DROPDOWN, CELL_GUIDE_CARD_GLOBAL_TISSUE_FILTER_DROPDOWN, CELL_GUIDE_CARD_HEADER_NAME, CELL_GUIDE_CARD_HEADER_TAG, RIGHT_SIDEBAR_WIDTH_PX, } from "src/views/CellGuide/components/CellGuideCard/constants"; -import { useOrganAndOrganismFilterListForCellType } from "./components/MarkerGeneTables/hooks/common"; import { ALL_TISSUES, - NO_ORGAN_ID, TISSUE_AGNOSTIC, } from "./components/MarkerGeneTables/constants"; import { @@ -57,7 +50,9 @@ import { DEFAULT_ONTOLOGY_HEIGHT } from "../common/OntologyDagView/common/consta import { track } from "src/common/analytics"; import { EVENTS } from "src/common/analytics/events"; import CellGuideInfoSideBar from "../CellGuideInfoSideBar"; -import { CellType } from "../common/OntologyDagView/common/types"; +import { useConnect } from "./connect"; +import { SDSOrgan } from "src/views/CellGuide/components/CellGuideCard/types"; +import { getCellTypeLink } from "src/views/CellGuide/common/utils"; const SDS_INPUT_DROPDOWN_PROPS: InputDropdownProps = { sdsStyle: "square", @@ -77,36 +72,34 @@ export default function CellGuideCard({ // From getServerSideProps synonyms, }: Props): JSX.Element { - const router = useRouter(); - - const [pageNavIsOpen, setPageNavIsOpen] = useState(false); - const [selectedGene, setSelectedGene] = useState( - undefined - ); - - // Navigation - const sectionRef0 = React.useRef(null); - const sectionRef1 = React.useRef(null); - const sectionRef2 = React.useRef(null); - const sectionRef3 = React.useRef(null); - - const selectGene = (gene: string) => { - if (gene === selectedGene) { - setSelectedGene(undefined); - } else { - setSelectedGene(gene); - if (sectionRef1.current) { - window.scrollTo({ - top: - sectionRef1.current.getBoundingClientRect().top + - window.scrollY - - 50, - behavior: "smooth", - }); - } - } - }; - const [skinnyMode, setSkinnyMode] = useState(false); + const { + router, + pageNavIsOpen, + setPageNavIsOpen, + selectedGene, + sectionRef0, + sectionRef1, + sectionRef2, + sectionRef3, + selectGene, + skinnyMode, + tooltipContent, + setTooltipContent, + queryCellTypeId, + cellTypeId, + geneInfoGene, + setGeneInfoGene, + cellInfoCellType, + setCellInfoCellType, + sdsOrganismsList, + sdsOrgansList, + selectedOrgan, + selectedOrganId, + selectedOrganism, + setSelectedOrganism, + } = useConnect(); + + const tissueName = selectedOrgan?.name || ""; const cellGuideSideBar = useMemo(() => { return ( @@ -121,82 +114,37 @@ export default function CellGuideCard({ ]} /> ); - }, [skinnyMode]); - - // Set the mobile tooltip view content - const [tooltipContent, setTooltipContent] = useState<{ - title: string; - element: JSX.Element; - } | null>(null); + }, [ + skinnyMode, + sectionRef0, + sectionRef1, + sectionRef2, + sectionRef3, + setPageNavIsOpen, + ]); - // cell type id - const { cellTypeId: cellTypeIdRaw } = router.query; - const cellTypeId = (cellTypeIdRaw as string)?.replace("_", ":") ?? ""; const cellTypeName = name || ""; const titleizedCellTypeName = titleize(cellTypeName); - const handleResize = useCallback(() => { - setSkinnyMode( - window.innerWidth < - SKINNY_MODE_BREAKPOINT_WIDTH + 2 * LEFT_RIGHT_PADDING_PX_XXL - ); - }, []); - - const throttledHandleResize = useMemo(() => { - return throttle(handleResize, 100); - }, [handleResize]); - - useEffect(() => { - throttledHandleResize(); - window.addEventListener("resize", throttledHandleResize); + const handleChangeOrgan = ( + option: DefaultDropdownMenuOption | null = null + ) => { + const { name, id } = (option || {}) as SDSOrgan; - return () => window.removeEventListener("resize", throttledHandleResize); - }, [throttledHandleResize]); + if (!option || !name || name === tissueName) return; - const [geneInfoGene, setGeneInfoGene] = useState(null); - const [cellInfoCellType, setCellInfoCellType] = useState( - null - ); - - const { organismsList, organsMap } = - useOrganAndOrganismFilterListForCellType(cellTypeId); + const optionName = name === TISSUE_AGNOSTIC ? ALL_TISSUES : name; - const sdsOrganismsList = useMemo( - () => - organismsList.map((organism) => ({ - name: organism, - })), - [organismsList] - ); - - const sdsOrgansList = useMemo( - () => - Array.from(organsMap.keys()).map((organ) => ({ - name: organ === ALL_TISSUES ? TISSUE_AGNOSTIC : organ, - })), - [organsMap] - ); - - const [selectedOrgan, setSelectedOrgan] = useState( - sdsOrgansList.find( - (organ) => organ.name === TISSUE_AGNOSTIC - ) as DefaultDropdownMenuOption - ); - - const [selectedOrganId, setSelectedOrganId] = useState(NO_ORGAN_ID); - - const handleChangeOrgan = (option: DefaultDropdownMenuOption | null) => { - if (!option || option.name === selectedOrgan.name) return; - setSelectedOrgan(option); - const optionName = - option.name === TISSUE_AGNOSTIC ? ALL_TISSUES : option.name; - setSelectedOrganId(organsMap.get(optionName) ?? ""); // Continue tracking the analytics event as All Tissues track(EVENTS.CG_SELECT_TISSUE, { tissue: optionName }); - }; - const [selectedOrganism, setSelectedOrganism] = - useState(sdsOrganismsList[0]); + const url = getCellTypeLink({ tissueId: id, cellTypeId }); + + /** + * (thuang): Product requirement to keep the scroll position + */ + router.push(url, url, { scroll: false }); + }; const handleChangeOrganism = (option: DefaultDropdownMenuOption | null) => { if (!option || option.name === selectedOrganism.name) return; @@ -212,12 +160,14 @@ export default function CellGuideCard({ setCellInfoCellType(null); } - useEffect(() => { - setSelectedGene(undefined); - }, [selectedOrgan, selectedOrganism, setSelectedGene]); + const cellTypePrefix = + tissueName === TISSUE_AGNOSTIC ? "" : `${tissueName} specific `; - const title = `${titleizedCellTypeName} Cell Types - CZ CELLxGENE CellGuide`; - const seoDescription = `Find comprehensive information about "${cellTypeName}" cell types (synonyms: ${ + const title = `${titleize( + cellTypePrefix + )}${titleizedCellTypeName} Cell Types - CZ CELLxGENE CellGuide`; + + const seoDescription = `Find comprehensive information about ${cellTypePrefix}"${cellTypeName}" cell types (synonyms: ${ synonyms?.join(", ") || "N/A" }). ${rawSeoDescription}`; @@ -235,7 +185,7 @@ export default function CellGuideCard({ ); + return ( <> {/* This is a fix that overrides a global overflow css prop to get sticky elements to work */} @@ -330,7 +281,7 @@ export default function CellGuideCard({ {titleizedCellTypeName} @@ -373,7 +324,7 @@ export default function CellGuideCard({ } setGeneInfoGene={setGeneInfoGene} - selectedOrganName={selectedOrgan.name} + selectedOrganName={tissueName} selectedOrganId={selectedOrganId} organismName={selectedOrganism.name} selectedGene={selectedGene} diff --git a/frontend/src/views/CellGuide/components/CellGuideCard/types.ts b/frontend/src/views/CellGuide/components/CellGuideCard/types.ts new file mode 100644 index 0000000000000..d49abde466517 --- /dev/null +++ b/frontend/src/views/CellGuide/components/CellGuideCard/types.ts @@ -0,0 +1,4 @@ +export interface SDSOrgan { + name: string; + id: string; +} diff --git a/frontend/src/views/CellGuide/components/CellGuideInfoSideBar/index.tsx b/frontend/src/views/CellGuide/components/CellGuideInfoSideBar/index.tsx index 463013ac95c02..9433ce69749b2 100644 --- a/frontend/src/views/CellGuide/components/CellGuideInfoSideBar/index.tsx +++ b/frontend/src/views/CellGuide/components/CellGuideInfoSideBar/index.tsx @@ -3,7 +3,6 @@ import { RightSidebarProperties } from "src/components/common/RightSideBar"; import { CellType } from "../common/OntologyDagView/common/types"; import Description from "../CellGuideCard/components/Description"; import MarkerGeneTables from "../CellGuideCard/components/MarkerGeneTables"; -import { ROUTES } from "src/common/constants/routes"; import { MarkerGeneTableWrapper, StyledLink } from "./style"; import { CELLGUIDE_VIEW_PAGE_SIDEBAR_BUTTON_TEST_ID, @@ -12,6 +11,7 @@ import { import { track } from "src/common/analytics"; import { EVENTS } from "src/common/analytics/events"; import { useRouter } from "next/router"; +import { getCellTypeLink } from "src/views/CellGuide/common/utils"; export interface CellGuideInfoBarProps extends RightSidebarProperties { cellInfoCellType: CellType; @@ -44,14 +44,19 @@ function CellGuideInfoBar({ setCellInfoCellType, }: CellGuideInfoBarProps): JSX.Element | null { const router = useRouter(); + + const { cellTypeId, cellTypeName } = cellInfoCellType; + + const cellTypeUrl = getCellTypeLink({ + tissueId: selectedOrganId, + cellTypeId, + }); + return (
{ if (!e.metaKey && !e.ctrlKey) { e.preventDefault(); @@ -60,15 +65,15 @@ function CellGuideInfoBar({ router.push(href); } track(EVENTS.CG_VIEW_CELLGUIDE_PAGE_CLICKED, { - cell_type: cellInfoCellType.cellTypeName, + cell_type: cellTypeName, }); }} > View CellGuide Page ; + tissueId?: string; cellTypeId?: string; duration: number; setDuration: (duration: number) => void; @@ -54,6 +56,7 @@ interface AnimationNode { export default function AnimatedNodes({ tree, cellTypeId, + tissueId = NO_ORGAN_ID, duration, setDuration, toggleTriggerRender, @@ -74,7 +77,12 @@ export default function AnimatedNodes({ if (setCellInfoCellType) { setCellInfoCellType({ cellTypeId, cellTypeName }); } else { - router.push(`${ROUTES.CELL_GUIDE}/${cellTypeId.replace(":", "_")}`); + const url = getCellTypeLink({ + tissueId, + cellTypeId, + }); + + router.push(url); } }; diff --git a/frontend/src/views/CellGuide/components/common/OntologyDagView/index.tsx b/frontend/src/views/CellGuide/components/common/OntologyDagView/index.tsx index f3069de2928f0..7e154af902892 100644 --- a/frontend/src/views/CellGuide/components/common/OntologyDagView/index.tsx +++ b/frontend/src/views/CellGuide/components/common/OntologyDagView/index.tsx @@ -1,4 +1,10 @@ -import React, { useMemo, useEffect, useState } from "react"; +import React, { + useMemo, + useEffect, + useState, + Dispatch, + SetStateAction, +} from "react"; import { Group } from "@visx/group"; import { Global } from "@emotion/react"; import { useTooltip, useTooltipInPortal } from "@visx/tooltip"; @@ -69,7 +75,7 @@ interface TreeProps { tissueId: string; tissueName: string; selectGene?: (gene: string) => void; - setCellInfoCellType?: (props: CellType | null) => void; + setCellInfoCellType?: Dispatch>; } // This determines the initial Zoom position and scale @@ -531,6 +537,7 @@ export default function OntologyDagView({ { describe("Landing Page", () => { @@ -205,20 +209,24 @@ describe("Cell Guide", () => { ); await takeSnapshotOfMetaTags("cellType", page); + await assertAllCellCardComponentsArePresent(page); + }); - await isElementVisible(page, CELL_GUIDE_CARD_HEADER_NAME); - await isElementVisible(page, CELL_GUIDE_CARD_HEADER_TAG); - await isElementVisible(page, CELL_GUIDE_CARD_CL_DESCRIPTION); - await isElementVisible(page, CELL_GUIDE_CARD_GPT_DESCRIPTION); - await isElementVisible(page, CELL_GUIDE_CARD_SYNONYMS); - await isElementVisible(page, CELL_GUIDE_CARD_GPT_TOOLTIP_LINK); - await isElementVisible(page, CELL_GUIDE_CARD_SEARCH_BAR); - await isElementVisible(page, CELL_GUIDE_CARD_ENRICHED_GENES_TABLE); - await isElementVisible(page, CELL_GUIDE_CARD_ONTOLOGY_DAG_VIEW); - const headerName = page.getByTestId(CELL_GUIDE_CARD_HEADER_NAME); - const headerNameText = await headerName.textContent(); - expect(headerNameText).toBe("Neuron"); + test("All tissue specific CellGuide card components are present", async ({ + page, + }) => { + await goToPage( + `${TEST_URL}${ROUTES.CELL_GUIDE_TISSUE_SPECIFIC_CELL_TYPE.replace( + ":tissueId", + BRAIN_TISSUE_ID + ).replace(":cellTypeId", NEURON_CELL_TYPE_ID)}`, + page + ); + + await takeSnapshotOfMetaTags("tissue-specific-cellType", page); + await assertAllCellCardComponentsArePresent(page); }); + test("Glioblast CellGuide card is validated", async ({ page }) => { await goToPage( `${TEST_URL}${ROUTES.CELL_GUIDE}/${GLIOBLAST_CELL_TYPE_ID}`, @@ -342,13 +350,19 @@ describe("Cell Guide", () => { async () => { await waitForElementAndClick(dropdown); await page.getByRole("option").getByText("brain").click(); - const rowElementsAfter = await page - .locator(`${tableSelector} tbody tr`) - .all(); - const rowCountAfter = rowElementsAfter.length; - expect(rowCountAfter).toBeGreaterThan(1); - expect(rowCountAfter).not.toBe(rowCountBefore); + await tryUntil( + async () => { + const rowElementsAfter = await page + .locator(`${tableSelector} tbody tr`) + .all(); + + const rowCountAfter = rowElementsAfter.length; + expect(rowCountAfter).toBeGreaterThan(1); + expect(rowCountAfter).not.toBe(rowCountBefore); + }, + { page } + ); }, { page } ); @@ -473,16 +487,16 @@ describe("Cell Guide", () => { test("Enriched marker gene table is updated by the organ dropdown", async ({ page, }) => { - await goToPage( - `${TEST_URL}${ROUTES.CELL_GUIDE}/${T_CELL_CELL_TYPE_ID}`, - page - ); - - await selectComputationalMarkerGeneTable(page); - const tableSelector = `[data-testid='${CELL_GUIDE_CARD_ENRICHED_GENES_TABLE}']`; - await tryUntil( async () => { + await goToPage( + `${TEST_URL}${ROUTES.CELL_GUIDE}/${T_CELL_CELL_TYPE_ID}`, + page + ); + + await selectComputationalMarkerGeneTable(page); + const tableSelector = `[data-testid='${CELL_GUIDE_CARD_ENRICHED_GENES_TABLE}']`; + const rowElementsBefore = await page .locator(`${tableSelector} tbody tr`) .all(); @@ -494,19 +508,29 @@ describe("Cell Guide", () => { const dropdown = page.getByTestId( CELL_GUIDE_CARD_GLOBAL_TISSUE_FILTER_DROPDOWN ); + await waitForElementAndClick(dropdown); await page.keyboard.type("colon"); await page.keyboard.press("ArrowDown"); await page.keyboard.press("Enter"); - const rowElementsAfter = await page - .locator(`${tableSelector} tbody tr`) - .all(); - const rowCountAfter = rowElementsAfter.length; - expect(rowCountAfter).toBeGreaterThan(1); - const firstRowContentAfter = - await rowElementsAfter[0].textContent(); - expect(firstRowContentBefore).not.toBe(firstRowContentAfter); + await tryUntil( + async () => { + const rowElementsAfter = await page + .locator(`${tableSelector} tbody tr`) + .all(); + + const rowCountAfter = rowElementsAfter.length; + + expect(rowCountAfter).toBeGreaterThan(1); + + const firstRowContentAfter = + await rowElementsAfter[0].textContent(); + + expect(firstRowContentBefore).not.toBe(firstRowContentAfter); + }, + { page } + ); }, { page } ); @@ -566,6 +590,99 @@ describe("Cell Guide", () => { }); describe("Ontology Viewer", () => { + describe("Tissue specific cell type page", () => { + test("Clicks on a cell type node that has the same tissue in its dropdown in the ontology viewer navigates to the correct cell type page", async ({ + page, + }) => { + await goToPage( + `${TEST_URL}${ROUTES.CELL_GUIDE_TISSUE.replace( + ":tissueId", + BRAIN_TISSUE_ID + )}`, + page + ); + + await page + .getByTestId(CELL_GUIDE_CARD_ONTOLOGY_DAG_VIEW) + .waitFor({ timeout: WAIT_FOR_TIMEOUT_MS }); + + await Promise.all([ + page.waitForURL( + `${TEST_URL}${ROUTES.CELL_GUIDE_TISSUE_SPECIFIC_CELL_TYPE.replace( + ":tissueId", + BRAIN_TISSUE_ID + ).replace(":cellTypeId", PROGENITOR_CELL_CELL_TYPE_ID)}` + ), + page + .getByText( + "progenitor cell", + /** + * (thuang): There is "neural progenitor cell" that we don't want to match + */ + { exact: true } + ) + .click(), + ]); + + await tryUntil( + async () => { + const dropdownText = await page + .getByTestId(CELL_GUIDE_CARD_GLOBAL_TISSUE_FILTER_DROPDOWN) + .textContent(); + + expect(dropdownText).toBe("brain"); + }, + { page } + ); + }); + + test("Clicks on a cell type node that does NOT have the same tissue in its dropdown in the ontology viewer navigates to the generic cell type page", async ({ + page, + }) => { + await goToPage( + `${TEST_URL}${ROUTES.CELL_GUIDE_TISSUE.replace( + ":tissueId", + BRAIN_TISSUE_ID + )}`, + page + ); + + await page + .getByTestId(CELL_GUIDE_CARD_ONTOLOGY_DAG_VIEW) + .waitFor({ timeout: WAIT_FOR_TIMEOUT_MS }); + + await Promise.all([ + page.waitForURL( + `${TEST_URL}${ROUTES.CELL_GUIDE_CELL_TYPE.replace( + ":cellTypeId", + CELL_CELL_TYPE_ID + )}` + ), + page + .getByTestId(CELL_GUIDE_CARD_ONTOLOGY_DAG_VIEW) + .locator("svg") + /** + * (thuang): The "cell" cell type page seems to only have "Tissue Agnostic" tissue + * in the dropdown, which is what we want + * Since many nodes have "cell" in it, we intend to pick just the "cell" node + */ + .getByText("cell", { exact: true }) + .click(), + ]); + + await tryUntil( + async () => { + const dropdownText = await page + .getByTestId(CELL_GUIDE_CARD_GLOBAL_TISSUE_FILTER_DROPDOWN) + .textContent(); + + expect(dropdownText).toBe("Tissue Agnostic"); + }, + { page } + ); + }); + }); + test("Clicking on a parent node expands and collapses its children", async ({ page, }) => { @@ -760,17 +877,25 @@ describe("Cell Guide", () => { await selectComputationalMarkerGeneTable(page); - const rowElements = await page - .locator(`${tableSelector} tbody tr`) - .all(); - const rowText = await rowElements[0].textContent(); + /** + * (thuang): Since the marker gene table is not stably sorted, we need + * to target a specific marker gene to prevent flakiness. + */ + const markerGeneNRXN1 = page.locator(`${tableSelector} tbody tr`, { + hasText: "NRXN1", + }); + + const rowText = await markerGeneNRXN1.textContent(); + const geneSymbol = rowText?.split(" ").at(0); - expect(geneSymbol).toBeDefined(); + + expect(geneSymbol).toBe("NRXN1"); + const treeIcon = page.getByTestId( MARKER_GENES_TREE_ICON_BUTTON_TEST_ID(geneSymbol as string) ); - await rowElements[0].locator("td").nth(0).hover(); + await markerGeneNRXN1.locator("td").nth(0).hover(); await treeIcon.click(); // check that the eyeClosed button is visible @@ -779,9 +904,14 @@ describe("Cell Guide", () => { CELL_GUIDE_CARD_ONTOLOGY_DAG_VIEW_DEACTIVATE_MARKER_GENE_MODE ); - // hover over the node + /** + * Hover over the `neural cell` node, since it will still be in the tree + * view window when on a small viewport size. + * Otherwise, choosing a different node will risk it being hidden and + * not be hoverable + */ const node = page.getByTestId( - `${CELL_GUIDE_CARD_ONTOLOGY_DAG_VIEW_RECT_OR_CIRCLE_PREFIX_ID}-CL:0000099__0-has-children-isTargetNode=false` + `${CELL_GUIDE_CARD_ONTOLOGY_DAG_VIEW_RECT_OR_CIRCLE_PREFIX_ID}-CL:0002319__0-has-children-isTargetNode=false` ); await node.hover(); await isElementVisible(page, CELL_GUIDE_CARD_ONTOLOGY_DAG_VIEW_TOOLTIP); @@ -819,6 +949,7 @@ describe("Cell Guide", () => { expect(newTooltipText).not.toContain(`${geneSymbol} stats`); }); }); + describe("Tissue Card", () => { test("All tissue card components are present", async ({ page }) => { await goToPage( @@ -855,7 +986,12 @@ describe("Cell Guide", () => { ); await Promise.all([ - page.waitForURL(`${TEST_URL}${ROUTES.CELL_GUIDE}/CL_1000271`), + page.waitForURL( + `${TEST_URL}${ROUTES.CELL_GUIDE_TISSUE_SPECIFIC_CELL_TYPE.replace( + ":tissueId", + LUNG_TISSUE_ID + ).replace(":cellTypeId", LUNG_CILIATED_CELL_CELL_TYPE_ID)}` + ), waitForElementAndClick(label), ]); @@ -1140,3 +1276,18 @@ function getSearchBarLocator(page: Page) { .locator("input") ); } + +async function assertAllCellCardComponentsArePresent(page: Page) { + await isElementVisible(page, CELL_GUIDE_CARD_HEADER_NAME); + await isElementVisible(page, CELL_GUIDE_CARD_HEADER_TAG); + await isElementVisible(page, CELL_GUIDE_CARD_CL_DESCRIPTION); + await isElementVisible(page, CELL_GUIDE_CARD_GPT_DESCRIPTION); + await isElementVisible(page, CELL_GUIDE_CARD_SYNONYMS); + await isElementVisible(page, CELL_GUIDE_CARD_GPT_TOOLTIP_LINK); + await isElementVisible(page, CELL_GUIDE_CARD_SEARCH_BAR); + await isElementVisible(page, CELL_GUIDE_CARD_ENRICHED_GENES_TABLE); + await isElementVisible(page, CELL_GUIDE_CARD_ONTOLOGY_DAG_VIEW); + const headerName = page.getByTestId(CELL_GUIDE_CARD_HEADER_NAME); + const headerNameText = await headerName.textContent(); + expect(headerNameText).toBe("Neuron"); +} diff --git a/frontend/tests/utils/helpers.ts b/frontend/tests/utils/helpers.ts index 28a06b2dc30df..2579eb19505cc 100644 --- a/frontend/tests/utils/helpers.ts +++ b/frontend/tests/utils/helpers.ts @@ -496,6 +496,12 @@ export async function takeSnapshotOfMetaTags(name: string, page: Page) { ) ).sort(); + /** + * (thuang): Whenever snapshot updates, make sure to update the snapshot + * for linux files as well. + * NOTE: I had to use `cp FILE_NAME-darwin.txt FILE_NAME-linux.txt` to copy + * to avoid failed snapshot test in GHA for some reason + */ expect(JSON.stringify(allMetaTagsHTML)).toMatchSnapshot({ name: name + "-seoMetaTags.txt", }); diff --git a/frontend/tsconfig.json b/frontend/tsconfig.json index 2e7813686b591..8b7f8c79665fe 100644 --- a/frontend/tsconfig.json +++ b/frontend/tsconfig.json @@ -5,42 +5,24 @@ "esModuleInterop": true, "isolatedModules": true, "jsx": "preserve", - "lib": [ - "dom", - "es2020" - ], + "lib": ["dom", "es2020"], "module": "esnext", "moduleResolution": "node", "noEmit": true, "noUnusedLocals": true, "noUnusedParameters": true, "paths": { - "src/*": [ - "./src/*" - ], - "tests/*": [ - "./tests/*" - ] + "src/*": ["./src/*"], + "tests/*": ["./tests/*"] }, "skipLibCheck": true, "strict": true, "target": "esnext", - "types": [ - "expect-playwright" - ], + "types": ["expect-playwright"], "forceConsistentCasingInFileNames": true, "resolveJsonModule": true, "incremental": true }, - "exclude": [ - "node_modules", - "public", - ".cache" - ], - "include": [ - "next-env.d.ts", - "**/*.ts", - "**/*.tsx", - "**/*.js", - ] + "exclude": ["node_modules", "public", ".cache"], + "include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", "**/*.js"] }