From daaea536892b6837ff03bef4bde6463b5ef4451e Mon Sep 17 00:00:00 2001 From: Andrey Azov Date: Mon, 15 Jan 2024 11:42:09 +0000 Subject: [PATCH] Add an initial, skeleton page for Variant Entity Viewer, with appropriate routing (#1073) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Added a new, so far mostly empty, view for the Entity Viewer that will evolve into a view for variants - Updated server-side fetch code for EntityViewer pages — split it into gene data fetching vs variant data fetching - Picking an allele (first alternative allele) and adding it as a url parameter --- .../app/entity-viewer/EntityViewer.tsx | 101 ++------- .../app/entity-viewer/EntityViewerForGene.tsx | 109 ++++++++++ .../entity-viewer/EntityViewerForVariant.tsx | 68 ++++++ .../app/entity-viewer/EntityViewerPage.tsx | 150 ++++--------- .../shared/page-meta/buildPageMeta.ts | 37 ++++ .../server-fetch/entityViewerServerFetch.ts | 199 ++++++++++++++++++ .../state/api/entityViewerThoasSlice.ts | 48 ++++- .../state/api/queries/variantDefaultQuery.ts | 139 ++++++++++++ .../state/api/queries/variantPageMetaQuery.ts | 40 ++++ .../variant-view/VariantView.module.css | 7 + .../variant-view/VariantView.tsx | 100 +++++++++ .../state/api/genomeBrowserApiSlice.ts | 2 +- 12 files changed, 804 insertions(+), 196 deletions(-) create mode 100644 src/content/app/entity-viewer/EntityViewerForGene.tsx create mode 100644 src/content/app/entity-viewer/EntityViewerForVariant.tsx create mode 100644 src/content/app/entity-viewer/shared/page-meta/buildPageMeta.ts create mode 100644 src/content/app/entity-viewer/shared/server-fetch/entityViewerServerFetch.ts create mode 100644 src/content/app/entity-viewer/state/api/queries/variantDefaultQuery.ts create mode 100644 src/content/app/entity-viewer/state/api/queries/variantPageMetaQuery.ts create mode 100644 src/content/app/entity-viewer/variant-view/VariantView.module.css create mode 100644 src/content/app/entity-viewer/variant-view/VariantView.tsx diff --git a/src/content/app/entity-viewer/EntityViewer.tsx b/src/content/app/entity-viewer/EntityViewer.tsx index 1c7e83d705..c2ddf1ffff 100644 --- a/src/content/app/entity-viewer/EntityViewer.tsx +++ b/src/content/app/entity-viewer/EntityViewer.tsx @@ -21,35 +21,21 @@ import * as urlFor from 'src/shared/helpers/urlHelper'; import { useAppSelector, useAppDispatch } from 'src/store'; import useEntityViewerIds from 'src/content/app/entity-viewer/hooks/useEntityViewerIds'; -import useEntityViewerUrlCheck from 'src/content/app/entity-viewer/hooks/useEntityViewerUrlChecks'; -import { getBreakpointWidth } from 'src/global/globalSelectors'; import { getGenomeById } from 'src/shared/state/genome/genomeSelectors'; -import { - isEntityViewerSidebarOpen, - getEntityViewerSidebarModalView -} from 'src/content/app/entity-viewer/state/sidebar/entityViewerSidebarSelectors'; import { setActiveIds, deleteActiveEntityIdAndSave } from 'src/content/app/entity-viewer/state/general/entityViewerGeneralSlice'; -import { - toggleSidebar, - initializeSidebar -} from 'src/content/app/entity-viewer/state/sidebar/entityViewerSidebarSlice'; +import { initializeSidebar } from 'src/content/app/entity-viewer/state/sidebar/entityViewerSidebarSlice'; -import { StandardAppLayout } from 'src/shared/components/layout'; import EntityViewerAppBar from './shared/components/entity-viewer-app-bar/EntityViewerAppBar'; -import EntityViewerSidebarToolstrip from './shared/components/entity-viewer-sidebar/entity-viewer-sidebar-toolstrip/EntityViewerSidebarToolstrip'; -import EntityViewerSidebarModal from 'src/content/app/entity-viewer/shared/components/entity-viewer-sidebar/entity-viewer-sidebar-modal/EntityViewerSidebarModal'; -import EntityViewerTopbar from './shared/components/entity-viewer-topbar/EntityViewerTopbar'; import EntityViewerInterstitial from './interstitial/EntityViewerInterstitial'; -import GeneView from './gene-view/GeneView'; -import GeneViewSidebar from './gene-view/components/gene-view-sidebar/GeneViewSideBar'; -import GeneViewSidebarTabs from './gene-view/components/gene-view-sidebar-tabs/GeneViewSidebarTabs'; import MissingGenomeError from 'src/shared/components/error-screen/url-errors/MissingGenomeError'; import MissingFeatureError from 'src/shared/components/error-screen/url-errors/MissingFeatureError'; +import EntityViewerForGene from './EntityViewerForGene'; +import EntityViewerForVariant from './EntityViewerForVariant'; import styles from './EntityViewer.module.css'; @@ -58,6 +44,7 @@ const EntityViewer = () => { activeGenomeId, genomeIdInUrl, entityIdInUrl, + parsedEntityId, isMissingGenomeId, isMalformedEntityId } = useEntityViewerIds(); @@ -80,13 +67,7 @@ const EntityViewer = () => { navigate(urlFor.entityViewer({ genomeId: genomeIdInUrl })); }; - const renderEntityViewerRoutes = () => ( - - } /> - } /> - } /> - - ); + const entityType = parsedEntityId?.type ?? 'unknown'; return (
@@ -101,70 +82,30 @@ const EntityViewer = () => { onContinue={openEntityViewerInterstitial} /> ) : ( - renderEntityViewerRoutes() + + } /> + } /> + } + /> + )}
); }; -const EntityViewerForGene = () => { - const { activeGenomeId, activeEntityId } = useEntityViewerIds(); - const { - genomeIdInUrl, - entityIdInUrl, - isFetching: isVerifyingUrl, - isMissingEntity - } = useEntityViewerUrlCheck(); - const genome = useAppSelector((state) => - getGenomeById(state, activeGenomeId ?? '') - ); - const isSidebarOpen = useAppSelector(isEntityViewerSidebarOpen); - const isSidebarModalOpen = Boolean( - useAppSelector(getEntityViewerSidebarModalView) - ); - const viewportWidth = useAppSelector(getBreakpointWidth); - const dispatch = useAppDispatch(); - const navigate = useNavigate(); +const EntityViewerController = (props: { entityType: string }) => { + const { entityType } = props; - const openEntityViewerInterstitial = () => { - dispatch(deleteActiveEntityIdAndSave()); - navigate(urlFor.entityViewer({ genomeId: genomeIdInUrl })); - }; - - if (!activeGenomeId || !activeEntityId || isVerifyingUrl) { + if (entityType === 'gene') { + return ; + } else if (entityType === 'variant') { + return ; + } else { + // this shouldn't happen return null; } - - if (isMissingEntity) { - return ( - - ); - } - - const SideBarContent = isSidebarModalOpen ? ( - - ) : ( - - ); - - return ( - } - topbarContent={} - sidebarContent={SideBarContent} - sidebarNavigation={} - sidebarToolstripContent={} - isSidebarOpen={isSidebarOpen} - onSidebarToggle={() => dispatch(toggleSidebar())} - isDrawerOpen={false} - viewportWidth={viewportWidth} - /> - ); }; const useEntityViewerRouting = () => { diff --git a/src/content/app/entity-viewer/EntityViewerForGene.tsx b/src/content/app/entity-viewer/EntityViewerForGene.tsx new file mode 100644 index 0000000000..9434ec5dde --- /dev/null +++ b/src/content/app/entity-viewer/EntityViewerForGene.tsx @@ -0,0 +1,109 @@ +/** + * See the NOTICE file distributed with this work for additional information + * regarding copyright ownership. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import React from 'react'; +import { useNavigate } from 'react-router-dom'; + +import * as urlFor from 'src/shared/helpers/urlHelper'; + +import { useAppSelector, useAppDispatch } from 'src/store'; +import useEntityViewerIds from 'src/content/app/entity-viewer/hooks/useEntityViewerIds'; +import useEntityViewerUrlCheck from 'src/content/app/entity-viewer/hooks/useEntityViewerUrlChecks'; + +import { getBreakpointWidth } from 'src/global/globalSelectors'; +import { getGenomeById } from 'src/shared/state/genome/genomeSelectors'; +import { + isEntityViewerSidebarOpen, + getEntityViewerSidebarModalView +} from 'src/content/app/entity-viewer/state/sidebar/entityViewerSidebarSelectors'; + +import { deleteActiveEntityIdAndSave } from 'src/content/app/entity-viewer/state/general/entityViewerGeneralSlice'; +import { toggleSidebar } from 'src/content/app/entity-viewer/state/sidebar/entityViewerSidebarSlice'; + +import { StandardAppLayout } from 'src/shared/components/layout'; +import EntityViewerSidebarToolstrip from './shared/components/entity-viewer-sidebar/entity-viewer-sidebar-toolstrip/EntityViewerSidebarToolstrip'; +import EntityViewerSidebarModal from 'src/content/app/entity-viewer/shared/components/entity-viewer-sidebar/entity-viewer-sidebar-modal/EntityViewerSidebarModal'; +import EntityViewerTopbar from './shared/components/entity-viewer-topbar/EntityViewerTopbar'; +import GeneView from './gene-view/GeneView'; +import GeneViewSidebar from './gene-view/components/gene-view-sidebar/GeneViewSideBar'; +import GeneViewSidebarTabs from './gene-view/components/gene-view-sidebar-tabs/GeneViewSidebarTabs'; +import MissingFeatureError from 'src/shared/components/error-screen/url-errors/MissingFeatureError'; + +const EntityViewerForGene = () => { + const { activeGenomeId, activeEntityId } = useEntityViewerIds(); + const { + genomeIdInUrl, + entityIdInUrl, + isFetching: isVerifyingUrl, + isMissingEntity + } = useEntityViewerUrlCheck(); + const genome = useAppSelector((state) => + getGenomeById(state, activeGenomeId ?? '') + ); + const isSidebarOpen = useAppSelector(isEntityViewerSidebarOpen); + const isSidebarModalOpen = Boolean( + useAppSelector(getEntityViewerSidebarModalView) + ); + const viewportWidth = useAppSelector(getBreakpointWidth); + const dispatch = useAppDispatch(); + const navigate = useNavigate(); + + const openEntityViewerInterstitial = () => { + dispatch(deleteActiveEntityIdAndSave()); + navigate(urlFor.entityViewer({ genomeId: genomeIdInUrl })); + }; + + if (!activeGenomeId || !activeEntityId || isVerifyingUrl) { + return null; + } + + if (isMissingEntity) { + return ( + + ); + } + + return ( + } + topbarContent={} + sidebarContent={ + + } + sidebarNavigation={} + sidebarToolstripContent={} + isSidebarOpen={isSidebarOpen} + onSidebarToggle={() => dispatch(toggleSidebar())} + isDrawerOpen={false} + viewportWidth={viewportWidth} + /> + ); +}; + +const SidebarContent = (props: { isSidebarModalOpen: boolean }) => { + return props.isSidebarModalOpen ? ( + + ) : ( + + ); +}; + +export default EntityViewerForGene; diff --git a/src/content/app/entity-viewer/EntityViewerForVariant.tsx b/src/content/app/entity-viewer/EntityViewerForVariant.tsx new file mode 100644 index 0000000000..bac5bf1ecc --- /dev/null +++ b/src/content/app/entity-viewer/EntityViewerForVariant.tsx @@ -0,0 +1,68 @@ +/** + * See the NOTICE file distributed with this work for additional information + * regarding copyright ownership. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import React from 'react'; + +import { useAppSelector, useAppDispatch } from 'src/store'; + +import { getBreakpointWidth } from 'src/global/globalSelectors'; +import { + isEntityViewerSidebarOpen, + getEntityViewerSidebarModalView +} from 'src/content/app/entity-viewer/state/sidebar/entityViewerSidebarSelectors'; + +import { toggleSidebar } from 'src/content/app/entity-viewer/state/sidebar/entityViewerSidebarSlice'; + +import { StandardAppLayout } from 'src/shared/components/layout'; +import EntityViewerSidebarToolstrip from './shared/components/entity-viewer-sidebar/entity-viewer-sidebar-toolstrip/EntityViewerSidebarToolstrip'; +import EntityViewerSidebarModal from 'src/content/app/entity-viewer/shared/components/entity-viewer-sidebar/entity-viewer-sidebar-modal/EntityViewerSidebarModal'; +import VariantView from './variant-view/VariantView'; + +const EntityViewerForVariant = () => { + const isSidebarOpen = useAppSelector(isEntityViewerSidebarOpen); + const isSidebarModalOpen = Boolean( + useAppSelector(getEntityViewerSidebarModalView) + ); + + const viewportWidth = useAppSelector(getBreakpointWidth); + const dispatch = useAppDispatch(); + + return ( + } + topbarContent={
Variant summary for top bar goes here
} + sidebarContent={ + + } + sidebarNavigation={null} + sidebarToolstripContent={} + isSidebarOpen={isSidebarOpen} + onSidebarToggle={() => dispatch(toggleSidebar())} + isDrawerOpen={false} + viewportWidth={viewportWidth} + /> + ); +}; + +const SidebarContent = (props: { isSidebarModalOpen: boolean }) => { + return props.isSidebarModalOpen ? ( + + ) : ( +
Variant information for sidebar goes here
+ ); +}; + +export default EntityViewerForVariant; diff --git a/src/content/app/entity-viewer/EntityViewerPage.tsx b/src/content/app/entity-viewer/EntityViewerPage.tsx index bda7f85610..f390827026 100644 --- a/src/content/app/entity-viewer/EntityViewerPage.tsx +++ b/src/content/app/entity-viewer/EntityViewerPage.tsx @@ -18,48 +18,60 @@ import React, { useEffect } from 'react'; import { useAppDispatch } from 'src/store'; -import { parseFocusIdFromUrl } from 'src/shared/helpers/focusObjectHelpers'; +import { buildPageMeta } from './shared/page-meta/buildPageMeta'; -import useGeneViewIds from 'src/content/app/entity-viewer/gene-view/hooks/useGeneViewIds'; -import { getPathParameters } from 'src/shared/hooks/useUrlParams'; +import useEntityViewerIds from 'src/content/app/entity-viewer/hooks/useEntityViewerIds'; import useHasMounted from 'src/shared/hooks/useHasMounted'; -import { - fetchGenomeSummary, - isGenomeNotFoundError -} from 'src/shared/state/genome/genomeApiSlice'; import { updatePageMeta } from 'src/shared/state/page-meta/pageMetaSlice'; import { useGenePageMetaQuery, - fetchGenePageMeta + useVariantPageMetaQuery } from 'src/content/app/entity-viewer/state/api/entityViewerThoasSlice'; import EntityViewerIdsContextProvider from 'src/content/app/entity-viewer/contexts/entity-viewer-ids-context/EntityViewerIdsContextProvider'; -import type { ServerFetch } from 'src/routes/routesConfig'; -import type { AppDispatch } from 'src/store'; +export { serverFetch } from 'src/content/app/entity-viewer/shared/server-fetch/entityViewerServerFetch'; const LazilyLoadedEntityViewer = React.lazy(() => import('./EntityViewer')); -const defaultPageTitle = 'Entity viewer — Ensembl'; - const EntityViewerPage = () => { const hasMounted = useHasMounted(); + + useEntityViewerPageMeta(); + + return hasMounted ? : null; +}; + +const useEntityViewerPageMeta = () => { const dispatch = useAppDispatch(); + const { activeGenomeId, parsedEntityId } = useEntityViewerIds(); + const { type: entityType, objectId: entityId } = parsedEntityId ?? {}; + + const { currentData: genePageMeta, isLoading: isGenePageMetaLoading } = + useGenePageMetaQuery( + { + genomeId: activeGenomeId ?? '', + geneId: entityId ?? '' + }, + { + skip: entityType !== 'gene' || !activeGenomeId || !entityId + } + ); - // TODO: eventually, EntityViewerPage should not use a hook that is explicitly about gene, - // because we will have entities other than gene - const { genomeId, geneId, entityId } = useGeneViewIds(); - - const { data: pageMeta, isLoading } = useGenePageMetaQuery( - { - genomeId: genomeId ?? '', - geneId: geneId ?? '' - }, - { - skip: !genomeId || !geneId - } - ); + const { currentData: variantPageMeta, isLoading: isVariantPageMetaLoading } = + useVariantPageMetaQuery( + { + genomeId: activeGenomeId ?? '', + variantId: entityId ?? '' + }, + { + skip: entityType !== 'variant' || !activeGenomeId || !entityId + } + ); + + const pageMeta = genePageMeta || variantPageMeta; + const isLoading = isGenePageMetaLoading || isVariantPageMetaLoading; useEffect(() => { if (isLoading) { @@ -68,98 +80,12 @@ const EntityViewerPage = () => { const preparedPageMeta = entityId ? buildPageMeta({ - title: pageMeta?.title ?? defaultPageTitle + title: pageMeta?.title }) : buildPageMeta(); dispatch(updatePageMeta(preparedPageMeta)); }, [isLoading]); - - return hasMounted ? : null; -}; - -export const serverFetch: ServerFetch = async (params) => { - const { path, store } = params; - const dispatch: AppDispatch = store.dispatch; - const { genomeId: genomeIdFromUrl, entityId } = getPathParameters< - 'genomeId' | 'entityId' - >(['/entity-viewer/:genomeId', '/entity-viewer/:genomeId/:entityId'], path); - - // If the url is just /entity-viewer, update page meta and exit - if (!genomeIdFromUrl) { - dispatch(updatePageMeta(buildPageMeta())); - return; - } - - const genomeInfoResponsePromise = dispatch( - fetchGenomeSummary.initiate(genomeIdFromUrl) - ); - const { data: genomeInfoData, error: genomeInfoError } = - await genomeInfoResponsePromise; - - if (isGenomeNotFoundError(genomeInfoError)) { - return { - status: 404 - }; - } - - const genomeId = genomeInfoData?.genome_id as string; // by this point, genomeId clearly exists - - // If the url is just /entity-viewer/:genomeId, update page meta and exit - if (!entityId) { - dispatch(updatePageMeta(buildPageMeta())); - return; - } - - // NOTE: we will have to be smarter here when entities are no longer just genes - let geneStableId; - - try { - geneStableId = parseFocusIdFromUrl(entityId).objectId; - } catch { - // something wrong with the entity id - return { - status: 404 - }; - } - - const pageMetaPromise = dispatch( - fetchGenePageMeta.initiate({ - genomeId, - geneId: geneStableId - }) - ); - const pageMetaQueryResult = await pageMetaPromise; - - if ((pageMetaQueryResult?.error as any)?.meta?.data?.gene === null) { - // this is graphql's way of telling us that there is no such gene - return { - status: 404 - }; - } else { - const title = pageMetaQueryResult.data?.title ?? ''; - dispatch( - updatePageMeta( - buildPageMeta({ - title - }) - ) - ); - } -}; - -const buildPageMeta = ( - params: { - title?: string; - description?: string; - } = {} -) => { - // TODO: eventually, decide on page description - const { title = defaultPageTitle, description = '' } = params; - return { - title, - description - }; }; const WrappedEntityViewerPage = () => { diff --git a/src/content/app/entity-viewer/shared/page-meta/buildPageMeta.ts b/src/content/app/entity-viewer/shared/page-meta/buildPageMeta.ts new file mode 100644 index 0000000000..661efdf2a3 --- /dev/null +++ b/src/content/app/entity-viewer/shared/page-meta/buildPageMeta.ts @@ -0,0 +1,37 @@ +/** + * See the NOTICE file distributed with this work for additional information + * regarding copyright ownership. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +export const defaultEntityViewerPageTitle = 'Entity viewer — Ensembl'; + +/** + * NOTE: for the time being, this function for building page metadata + * is very simplistic; its only purpose is to set fallback values if none are provided. + * Later on, I expect we will want to add something like JSON schemas to the pages. + */ +export const buildPageMeta = ( + params: { + title?: string; + description?: string; + } = {} +) => { + // TODO: eventually, decide on what we want to put in the page description + const { title = defaultEntityViewerPageTitle, description = '' } = params; + + return { + title, + description + }; +}; diff --git a/src/content/app/entity-viewer/shared/server-fetch/entityViewerServerFetch.ts b/src/content/app/entity-viewer/shared/server-fetch/entityViewerServerFetch.ts new file mode 100644 index 0000000000..cbead134f4 --- /dev/null +++ b/src/content/app/entity-viewer/shared/server-fetch/entityViewerServerFetch.ts @@ -0,0 +1,199 @@ +/** + * See the NOTICE file distributed with this work for additional information + * regarding copyright ownership. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { getPathParameters } from 'src/shared/hooks/useUrlParams'; + +import { parseFocusIdFromUrl } from 'src/shared/helpers/focusObjectHelpers'; + +import { + fetchGenomeSummary, + isGenomeNotFoundError +} from 'src/shared/state/genome/genomeApiSlice'; +import { updatePageMeta } from 'src/shared/state/page-meta/pageMetaSlice'; +import { + fetchGenePageMeta, + fetchVariantPageMeta +} from 'src/content/app/entity-viewer/state/api/entityViewerThoasSlice'; + +import { buildPageMeta } from '../page-meta/buildPageMeta'; + +import type { ServerFetch } from 'src/routes/routesConfig'; +import type { AppDispatch } from 'src/store'; + +class NotFoundError extends Error {} + +/** + * The purpose of this function is to: + * - Check that url parts resolve to existing genome id and entity; make the server respond with a 404 error if not. + * - Fetch summary data about the feature that can then be exposed as page metadata + */ +export const serverFetch: ServerFetch = async (params) => { + const { path, store } = params; + const dispatch: AppDispatch = store.dispatch; + const { genomeId: genomeIdFromUrl, entityId } = getPathParameters< + 'genomeId' | 'entityId' + >(['/entity-viewer/:genomeId', '/entity-viewer/:genomeId/:entityId'], path); + + // If the url is just /entity-viewer, update page meta and exit + if (!genomeIdFromUrl) { + dispatch(updatePageMeta(buildPageMeta())); + return; + } + + try { + const genomeId = await fetchGenomeData({ + dispatch, + genomeId: genomeIdFromUrl + }); + + if (!entityId) { + dispatch(updatePageMeta(buildPageMeta())); + return; + } + + await fetchEntityData({ genomeId, entityId, dispatch }); + } catch (error) { + if (error instanceof NotFoundError) { + return { + status: 404 + }; + } else { + throw new Error(); // viewRouter will pick this up and return a response with a 500 http status code + } + } +}; + +// the first and, sadly, unavoidable check: need to make sure that genome with given id exists +const fetchGenomeData = async ({ + genomeId, + dispatch +}: { + genomeId: string; + dispatch: AppDispatch; +}) => { + const genomeInfoResponsePromise = dispatch( + fetchGenomeSummary.initiate(genomeId) + ); + const { data: genomeInfoData, error: genomeInfoError } = + await genomeInfoResponsePromise; + + if (isGenomeNotFoundError(genomeInfoError)) { + throw new NotFoundError(); + } + + return genomeInfoData?.genome_id as string; // by this point, genomeId clearly exists +}; + +const fetchEntityData = async (params: { + genomeId: string; + entityId: string; + dispatch: AppDispatch; +}) => { + const { entityId } = params; + + let parsedEntityId; + + try { + parsedEntityId = parseFocusIdFromUrl(entityId); + } catch { + throw new NotFoundError(); + } + + if (parsedEntityId.type === 'gene') { + return await fetchGeneData({ ...params, geneId: parsedEntityId.objectId }); + } else if (parsedEntityId.type === 'variant') { + return await fetchVariantData({ + ...params, + variantId: parsedEntityId.objectId + }); + } else { + throw new NotFoundError(); + } +}; + +const fetchGeneData = async ({ + genomeId, + geneId, + dispatch +}: { + genomeId: string; + geneId: string; + dispatch: AppDispatch; +}) => { + const pageMetaPromise = dispatch( + fetchGenePageMeta.initiate({ + genomeId, + geneId + }) + ); + const pageMetaQueryResult = await pageMetaPromise; + pageMetaPromise.unsubscribe(); + + if (pageMetaQueryResult?.error) { + if ((pageMetaQueryResult.error as any)?.meta?.data?.gene === null) { + // this is graphql's way of telling us that there is no such gene + throw new NotFoundError(); + } else { + throw new Error(); // must be some other error; we will respond with a status code 500 + } + } else { + const title = pageMetaQueryResult.data?.title; + dispatch( + updatePageMeta( + buildPageMeta({ + title + }) + ) + ); + } +}; + +const fetchVariantData = async ({ + genomeId, + variantId, + dispatch +}: { + genomeId: string; + variantId: string; + dispatch: AppDispatch; +}) => { + const pageMetaPromise = dispatch( + fetchVariantPageMeta.initiate({ + genomeId, + variantId + }) + ); + const pageMetaQueryResult = await pageMetaPromise; + pageMetaPromise.unsubscribe(); + + if (pageMetaQueryResult?.error) { + if ((pageMetaQueryResult.error as any)?.meta?.data?.variant === null) { + // this is graphql's way of telling us that there is no such variant + throw new NotFoundError(); + } else { + throw new Error(); // must be some other error; we will respond with a status code 500 + } + } else { + const title = pageMetaQueryResult.data?.title; + dispatch( + updatePageMeta( + buildPageMeta({ + title + }) + ) + ); + } +}; diff --git a/src/content/app/entity-viewer/state/api/entityViewerThoasSlice.ts b/src/content/app/entity-viewer/state/api/entityViewerThoasSlice.ts index 2d57cf165a..0f15c637ba 100644 --- a/src/content/app/entity-viewer/state/api/entityViewerThoasSlice.ts +++ b/src/content/app/entity-viewer/state/api/entityViewerThoasSlice.ts @@ -51,9 +51,19 @@ import { geneHomologiesQuery, type EntityViewerGeneHomologiesQueryResult } from './queries/geneHomologiesQuery'; +import { + variantPageMetaQuery, + type VariantPageMetaQueryResult, + type VariantPageMeta +} from './queries/variantPageMetaQuery'; +import { + variantDefaultQuery, + type EntityViewerVariantDefaultQueryResult +} from './queries/variantDefaultQuery'; type GeneQueryParams = { genomeId: string; geneId: string }; type ProductQueryParams = { productId: string; genomeId: string }; +type VariantQueryParams = { genomeId: string; variantId: string }; const entityViewerThoasSlice = graphqlApiSlice.injectEndpoints({ endpoints: (builder) => ({ @@ -138,6 +148,34 @@ const entityViewerThoasSlice = graphqlApiSlice.injectEndpoints({ body: geneHomologiesQuery, variables: params }) + }), + variantPageMeta: builder.query({ + query: (params) => ({ + url: config.variationApiUrl, + body: variantPageMetaQuery, + variables: params + }), + transformResponse(response: VariantPageMetaQueryResult) { + const { + variant: { name: variantName } + } = response; + + const title = `Variant: ${variantName} — Ensembl`; + + return { + title + }; + } + }), + defaultEntityViewerVariant: builder.query< + EntityViewerVariantDefaultQueryResult, + VariantQueryParams + >({ + query: (params) => ({ + url: config.variationApiUrl, + body: variantDefaultQuery, + variables: params + }) }) }) }); @@ -150,8 +188,12 @@ export const { useGeneExternalReferencesQuery, useGeneForSequenceDownloadQuery, useProteinDomainsQuery, - useEvGeneHomologyQuery + useEvGeneHomologyQuery, + useVariantPageMetaQuery, + useDefaultEntityViewerVariantQuery } = entityViewerThoasSlice; -export const { genePageMeta: fetchGenePageMeta } = - entityViewerThoasSlice.endpoints; +export const { + genePageMeta: fetchGenePageMeta, + variantPageMeta: fetchVariantPageMeta +} = entityViewerThoasSlice.endpoints; diff --git a/src/content/app/entity-viewer/state/api/queries/variantDefaultQuery.ts b/src/content/app/entity-viewer/state/api/queries/variantDefaultQuery.ts new file mode 100644 index 0000000000..a24287f696 --- /dev/null +++ b/src/content/app/entity-viewer/state/api/queries/variantDefaultQuery.ts @@ -0,0 +1,139 @@ +/** + * See the NOTICE file distributed with this work for additional information + * regarding copyright ownership. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { gql } from 'graphql-request'; +import { Pick2, Pick3 } from 'ts-multipick'; + +import type { Variant } from 'src/shared/types/variation-api/variant'; +import type { VariantAllele } from 'src/shared/types/variation-api/variantAllele'; + +export const variantDefaultQuery = gql` + query VariantDetails($genomeId: String!, $variantId: String!) { + variant(by_id: { genome_id: $genomeId, variant_id: $variantId }) { + name + alternative_names { + name + url + } + slice { + location { + start + end + } + region { + name + } + } + allele_type { + value + } + primary_source { + url + source { + name + release + } + } + prediction_results { + score + result + analysis_method { + tool + } + } + alleles { + name + allele_type { + value + } + slice { + location { + start + end + } + } + allele_sequence + reference_sequence + phenotype_assertions { + feature + } + prediction_results { + score + result + analysis_method { + tool + } + } + population_frequencies { + is_minor_allele + is_hpmaf + allele_frequency + } + } + } + } +`; + +type VariantDetailsAllele = Pick< + VariantAllele, + 'name' | 'allele_sequence' | 'reference_sequence' +> & + Pick2 & + Pick3 & { + population_frequencies: VariantDetailsAllelePopulationFrequency[]; + prediction_results: VariantDetailsAllelePredictionResult[]; + phenotype_assertions: VariantDetailsAllelePhenotypeAssertion[]; + }; + +type VariantDetailsAllelePopulationFrequency = Pick< + VariantAllele['population_frequencies'][number], + 'is_minor_allele' | 'is_hpmaf' | 'allele_frequency' +>; + +type VariantDetailsAllelePhenotypeAssertion = Pick< + VariantAllele['phenotype_assertions'][number], + 'feature' +>; // it doesn't really matter what we request; the point is to check whether the array of assertions will be empty + +type VariantDetailsPredictionResult = Pick< + Variant['prediction_results'][number], + 'result' | 'score' +> & + Pick2; + +type VariantDetailsAllelePredictionResult = Pick< + VariantAllele['prediction_results'][number], + 'result' | 'score' +> & + Pick2; + +export type VariantDetails = Pick & + Pick3 & + Pick3 & + Pick2 & + Pick2 & + Pick3 & { + alternative_names: Pick< + Variant['alternative_names'][number], + 'name' | 'url' + >[]; + prediction_results: VariantDetailsPredictionResult[]; + alleles: VariantDetailsAllele[]; + }; + +export type EntityViewerVariantDefaultQueryResult = { + variant: VariantDetails; +}; diff --git a/src/content/app/entity-viewer/state/api/queries/variantPageMetaQuery.ts b/src/content/app/entity-viewer/state/api/queries/variantPageMetaQuery.ts new file mode 100644 index 0000000000..b03aebd1ba --- /dev/null +++ b/src/content/app/entity-viewer/state/api/queries/variantPageMetaQuery.ts @@ -0,0 +1,40 @@ +/** + * See the NOTICE file distributed with this work for additional information + * regarding copyright ownership. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { gql } from 'graphql-request'; + +export const variantPageMetaQuery = gql` + query VariantDetails($genomeId: String!, $variantId: String!) { + variant(by_id: { genome_id: $genomeId, variant_id: $variantId }) { + name + } + } +`; + +export type VariantPageMetaQueryResult = { + variant: { + name: string; + }; +}; + +/** + * TODO in the future: + * - Add information for page description meta tag + */ + +export type VariantPageMeta = { + title: string; +}; diff --git a/src/content/app/entity-viewer/variant-view/VariantView.module.css b/src/content/app/entity-viewer/variant-view/VariantView.module.css new file mode 100644 index 0000000000..d855c0efde --- /dev/null +++ b/src/content/app/entity-viewer/variant-view/VariantView.module.css @@ -0,0 +1,7 @@ +.container { + color: var(--color-white); + background-color: var(--color-black); + display: grid; + grid-template-rows: [navigation-panel] auto [view-specific-content] 1fr; + height: 100%; +} diff --git a/src/content/app/entity-viewer/variant-view/VariantView.tsx b/src/content/app/entity-viewer/variant-view/VariantView.tsx new file mode 100644 index 0000000000..51accbe877 --- /dev/null +++ b/src/content/app/entity-viewer/variant-view/VariantView.tsx @@ -0,0 +1,100 @@ +/** + * See the NOTICE file distributed with this work for additional information + * regarding copyright ownership. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import React, { useEffect } from 'react'; +import { useLocation, useNavigate } from 'react-router-dom'; + +import useEntityViewerIds from 'src/content/app/entity-viewer/hooks/useEntityViewerIds'; + +import { useDefaultEntityViewerVariantQuery } from 'src/content/app/entity-viewer/state/api/entityViewerThoasSlice'; + +import type { VariantAllele } from 'src/shared/types/variation-api/variantAllele'; + +import styles from './VariantView.module.css'; + +const VariantView = () => { + const { activeGenomeId, genomeIdForUrl, entityIdForUrl, parsedEntityId } = + useEntityViewerIds(); + + const { objectId: variantId } = parsedEntityId ?? {}; + + const { currentData } = useDefaultEntityViewerVariantQuery( + { + genomeId: activeGenomeId ?? '', + variantId: variantId ?? '' + }, + { + skip: !activeGenomeId || !variantId + } + ); + + const variantData = currentData?.variant; + + useDefaultAlternativeAllele({ + genomeId: genomeIdForUrl, + variantId: entityIdForUrl, + variant: variantData + }); + + return ( +
+ {variantData && ( + <> +
+ Placeholder for navigation panel for variant {variantData.name} +
+
+ Placeholder for the image for variant {variantData.name} +
+ + )} +
+ ); +}; + +const useDefaultAlternativeAllele = (params: { + genomeId?: string; + variantId?: string; + variant?: { + alleles: Pick[]; + }; +}) => { + const { genomeId, variantId, variant } = params; + const { search: urlQuery } = useLocation(); + const navigate = useNavigate(); + const alleleIndexInUrl = new URLSearchParams(urlQuery).get('allele'); + + useEffect(() => { + if (!genomeId || !variantId || !variant) { + return; + } + + // temporary solution for identifying alleles + const parsedAlleleIndex = (alleleIndexInUrl && + parseInt(alleleIndexInUrl, 10)) as number; + + if (!alleleIndexInUrl || !variant?.alleles[parsedAlleleIndex]) { + const firstAlternativeAlleleIndex = variant.alleles.findIndex( + (allele) => allele.reference_sequence !== allele.allele_sequence + ); + + const url = `/entity-viewer/${genomeId}/${variantId}?allele=${firstAlternativeAlleleIndex}`; + navigate(url, { replace: true }); + } + }, [variant]); +}; + +export default VariantView; diff --git a/src/content/app/genome-browser/state/api/genomeBrowserApiSlice.ts b/src/content/app/genome-browser/state/api/genomeBrowserApiSlice.ts index 56e64a2dfe..3bc48b4098 100644 --- a/src/content/app/genome-browser/state/api/genomeBrowserApiSlice.ts +++ b/src/content/app/genome-browser/state/api/genomeBrowserApiSlice.ts @@ -43,7 +43,7 @@ import type { TrackPanelGene } from '../types/track-panel-gene'; type GeneQueryParams = { genomeId: string; geneId: string }; type TranscriptQueryParams = { genomeId: string; transcriptId: string }; type RegionQueryParams = { genomeId: string; regionName: string }; -type VariantQueryParams = { genomeId: string; variantId: string }; // it isn't quite clear yet what exactly the variant id should be; just an rsID is insufficient +type VariantQueryParams = { genomeId: string; variantId: string }; const genomeBrowserApiSlice = graphqlApiSlice.injectEndpoints({ endpoints: (builder) => ({