From a4442f0bc7167632751aeb13be4e8f417883aae5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tadeusz=20Miesi=C4=85c?= <tadeusz.miesiac@gmail.com> Date: Fri, 19 Jan 2024 20:03:24 +0100 Subject: [PATCH] feat(project info): initialised project info drawer --- .../Map/Drawer/Drawer.component.tsx | 2 + .../ProjectInfoDrawer.component.test.tsx | 117 ++++++++++++++++++ .../ProjectInfoDrawer.component.tsx | 79 ++++++++++++ .../ProjectInfoDrawer.styles.css | 4 + .../ProjectInfoDrawer/hooks/useDisease.ts | 37 ++++++ .../ProjectInfoDrawer/hooks/useOrganism.ts | 37 ++++++ .../Map/Drawer/ProjectInfoDrawer/index.ts | 1 + src/models/fixtures/meshFixture.ts | 8 ++ src/models/fixtures/taxonomyFixture.ts | 8 ++ src/models/meshSchema.ts | 8 ++ src/models/mocks/modelsMock.ts | 19 +++ src/models/taxonomySchema.ts | 6 + src/redux/apiPath.ts | 3 + src/redux/models/models.selectors.ts | 4 + src/redux/project/project.selectors.ts | 27 ++++ .../LinkButton/LinkButton.component.test.tsx | 30 +++++ .../LinkButton/LinkButton.component.tsx | 34 +++++ src/shared/LinkButton/index.ts | 1 + src/types/models.ts | 4 + tailwind.config.ts | 1 + 20 files changed, 430 insertions(+) create mode 100644 src/components/Map/Drawer/ProjectInfoDrawer/ProjectInfoDrawer.component.test.tsx create mode 100644 src/components/Map/Drawer/ProjectInfoDrawer/ProjectInfoDrawer.component.tsx create mode 100644 src/components/Map/Drawer/ProjectInfoDrawer/ProjectInfoDrawer.styles.css create mode 100644 src/components/Map/Drawer/ProjectInfoDrawer/hooks/useDisease.ts create mode 100644 src/components/Map/Drawer/ProjectInfoDrawer/hooks/useOrganism.ts create mode 100644 src/components/Map/Drawer/ProjectInfoDrawer/index.ts create mode 100644 src/models/fixtures/meshFixture.ts create mode 100644 src/models/fixtures/taxonomyFixture.ts create mode 100644 src/models/meshSchema.ts create mode 100644 src/models/taxonomySchema.ts create mode 100644 src/shared/LinkButton/LinkButton.component.test.tsx create mode 100644 src/shared/LinkButton/LinkButton.component.tsx create mode 100644 src/shared/LinkButton/index.ts diff --git a/src/components/Map/Drawer/Drawer.component.tsx b/src/components/Map/Drawer/Drawer.component.tsx index b55022ff..098c4f98 100644 --- a/src/components/Map/Drawer/Drawer.component.tsx +++ b/src/components/Map/Drawer/Drawer.component.tsx @@ -8,6 +8,7 @@ import { SubmapsDrawer } from './SubmapsDrawer'; import { OverlaysDrawer } from './OverlaysDrawer'; import { BioEntityDrawer } from './BioEntityDrawer/BioEntityDrawer.component'; import { ExportDrawer } from './ExportDrawer'; +import { ProjectInfoDrawer } from './ProjectInfoDrawer'; export const Drawer = (): JSX.Element => { const { isOpen, drawerName } = useAppSelector(drawerSelector); @@ -25,6 +26,7 @@ export const Drawer = (): JSX.Element => { {isOpen && drawerName === 'reaction' && <ReactionDrawer />} {isOpen && drawerName === 'overlays' && <OverlaysDrawer />} {isOpen && drawerName === 'bio-entity' && <BioEntityDrawer />} + {isOpen && drawerName === 'project-info' && <ProjectInfoDrawer />} {isOpen && drawerName === 'export' && <ExportDrawer />} </div> ); diff --git a/src/components/Map/Drawer/ProjectInfoDrawer/ProjectInfoDrawer.component.test.tsx b/src/components/Map/Drawer/ProjectInfoDrawer/ProjectInfoDrawer.component.test.tsx new file mode 100644 index 00000000..560ab58a --- /dev/null +++ b/src/components/Map/Drawer/ProjectInfoDrawer/ProjectInfoDrawer.component.test.tsx @@ -0,0 +1,117 @@ +import { HttpStatusCode } from 'axios'; +import { act } from 'react-dom/test-utils'; +import { render, screen } from '@testing-library/react'; +import { + InitialStoreState, + getReduxWrapperWithStore, +} from '@/utils/testing/getReduxWrapperWithStore'; +import { projectFixture } from '@/models/fixtures/projectFixture'; +import { StoreType } from '@/redux/store'; +import { mockNetworkResponse } from '@/utils/mockNetworkResponse'; +import { MODEL_WITH_DESCRIPTION } from '@/models/mocks/modelsMock'; +import { meshFixture } from '@/models/fixtures/meshFixture'; +import { apiPath } from '@/redux/apiPath'; +import { taxonomyFixture } from '@/models/fixtures/taxonomyFixture'; +import { ProjectInfoDrawer } from './ProjectInfoDrawer.component'; + +const mockedAxiosClient = mockNetworkResponse(); + +const MOCKED_STORE: InitialStoreState = { + project: { + data: { ...projectFixture }, + loading: 'idle', + error: new Error(), + }, + models: { + data: [MODEL_WITH_DESCRIPTION], + loading: 'idle', + error: new Error(), + }, +}; + +const renderComponent = (initialStore?: InitialStoreState): { store: StoreType } => { + const { Wrapper, store } = getReduxWrapperWithStore(initialStore); + return ( + render( + <Wrapper> + <ProjectInfoDrawer /> + </Wrapper>, + ), + { + store, + } + ); +}; + +describe('ProjectInfoDrawer', () => { + beforeEach(() => { + mockedAxiosClient.reset(); + }); + + it('should render the project name', () => { + renderComponent(MOCKED_STORE); + + expect(screen.getByText(projectFixture.name)).toBeInTheDocument(); + }); + + it('should render the version', () => { + renderComponent(MOCKED_STORE); + + expect(screen.getByText(projectFixture.version)).toBeInTheDocument(); + }); + + it.skip('should render number of publications', () => {}); + it.skip('should open publications modal when publications link is clicked', () => {}); + + it('should render the manual link', () => { + renderComponent(MOCKED_STORE); + + const manualLink = screen.getByText(/Manual/i); + expect(manualLink).toBeInTheDocument(); + expect(manualLink).toHaveAttribute('href', 'https://minerva.pages.uni.lu/doc/'); + }); + + it('should render the disease link with name and href', async () => { + mockedAxiosClient + .onGet(apiPath.getMesh(projectFixture.disease.resource)) + .reply(HttpStatusCode.Ok, meshFixture); + await act(() => { + renderComponent(MOCKED_STORE); + }); + + const diseaseLink = screen.getByText(/Disease:/i); + expect(diseaseLink).toBeInTheDocument(); + + const linkelement = screen.getByRole('link', { name: meshFixture.name }); + expect(linkelement).toBeInTheDocument(); + expect(linkelement).toHaveAttribute('href', projectFixture.disease.link); + }); + + it('should fetch diesease name when diseaseId is provided', async () => { + mockedAxiosClient + .onGet(apiPath.getTaxonomy(projectFixture.organism.resource)) + .reply(HttpStatusCode.Ok, taxonomyFixture); + await act(() => { + renderComponent(MOCKED_STORE); + }); + + const organismLink = screen.getByText(/Organism:/i); + expect(organismLink).toBeInTheDocument(); + + const linkelement = screen.getByRole('link', { name: taxonomyFixture.name }); + expect(linkelement).toBeInTheDocument(); + expect(linkelement).toHaveAttribute('href', projectFixture.organism.link); + }); + + it('should render the source file download button', () => { + renderComponent(MOCKED_STORE); + + const downloadButton = screen.getByRole('link', { name: /Download source file/i }); + expect(downloadButton).toBeInTheDocument(); + expect(downloadButton).toHaveAttribute( + 'href', + 'localhost/projects/pdmap_appu_test:downloadSource', + ); + expect(downloadButton).toHaveAttribute('download', 'sourceFile.txt'); + }); +}); diff --git a/src/components/Map/Drawer/ProjectInfoDrawer/ProjectInfoDrawer.component.tsx b/src/components/Map/Drawer/ProjectInfoDrawer/ProjectInfoDrawer.component.tsx new file mode 100644 index 00000000..439a017a --- /dev/null +++ b/src/components/Map/Drawer/ProjectInfoDrawer/ProjectInfoDrawer.component.tsx @@ -0,0 +1,79 @@ +import { useAppSelector } from '@/redux/hooks/useAppSelector'; +import { projectNameSelector, versionSelector } from '@/redux/project/project.selectors'; +import { DrawerHeading } from '@/shared/DrawerHeading'; +import { apiPath } from '@/redux/apiPath'; +import { LinkButton } from '@/shared/LinkButton'; +import { mainMapModelDescriptionSelector } from '@/redux/models/models.selectors'; +import './ProjectInfoDrawer.styles.css'; +import { useDisease } from './hooks/useDisease'; +import { useOrganism } from './hooks/useOrganism'; + +export const ProjectInfoDrawer = (): JSX.Element => { + const { diseaseName, diseaseLink } = useDisease(); + const { organismName, organismLink } = useOrganism(); + const projectName = useAppSelector(projectNameSelector); + const version = useAppSelector(versionSelector); + const description = useAppSelector(mainMapModelDescriptionSelector); + + const sourceDownloadLink = window.location.hostname + apiPath.getSourceFile(); + + return ( + <div data-testid="export-drawer" className="h-full max-h-full"> + <DrawerHeading title="Project info" /> + <div className="h-[calc(100%-93px)] max-h-[calc(100%-93px)] overflow-y-auto px-6"> + <p className="mt-6"> + Name: <span className="font-semibold">{projectName}</span> + </p> + <p className="mt-4"> + version: <span className="font-semibold">{version}</span> + </p> + <div className="mt-4">Data:</div> + <ul className="list-disc pl-6 "> + <li className="mt-2 text-hyperlink-blue">(21) publications</li> + <li className="mt-2 text-hyperlink-blue"> + <a + href="https://minerva.pages.uni.lu/doc/" + target="_blank" + rel="noopener noreferrer" + className="hover:underline" + > + Manual + </a> + </li> + <li className="mt-2 text-hyperlink-blue"> + <span className="text-black">Disease: </span> + <a + href={diseaseLink} + target="_blank" + rel="noopener noreferrer" + className="hover:underline" + > + {diseaseName} + </a> + </li> + <li className="mt-2 text-hyperlink-blue"> + <span className="text-black">Organism: </span> + <a + href={organismLink} + target="_blank" + rel="noopener noreferrer" + className="hover:underline" + > + {organismName} + </a> + </li> + </ul> + <LinkButton className="mt-6" href={sourceDownloadLink} download="sourceFile.txt"> + Download source file + </LinkButton> + {description && ( + <div + className="anchor-tag mt-7 rounded-lg bg-cultured px-4 py-2" + // eslint-disable-next-line react/no-danger + dangerouslySetInnerHTML={{ __html: description }} + /> + )} + </div> + </div> + ); +}; diff --git a/src/components/Map/Drawer/ProjectInfoDrawer/ProjectInfoDrawer.styles.css b/src/components/Map/Drawer/ProjectInfoDrawer/ProjectInfoDrawer.styles.css new file mode 100644 index 00000000..6713b0fe --- /dev/null +++ b/src/components/Map/Drawer/ProjectInfoDrawer/ProjectInfoDrawer.styles.css @@ -0,0 +1,4 @@ +.anchor-tag a { + @apply text-hyperlink-blue; + @apply hover:underline; +} diff --git a/src/components/Map/Drawer/ProjectInfoDrawer/hooks/useDisease.ts b/src/components/Map/Drawer/ProjectInfoDrawer/hooks/useDisease.ts new file mode 100644 index 00000000..9c99905c --- /dev/null +++ b/src/components/Map/Drawer/ProjectInfoDrawer/hooks/useDisease.ts @@ -0,0 +1,37 @@ +import { useEffect, useState } from 'react'; +import { useAppSelector } from '@/redux/hooks/useAppSelector'; +import { diseaseIdSelector, diseaseLinkSelector } from '@/redux/project/project.selectors'; +import { Mesh } from '@/types/models'; +import { apiPath } from '@/redux/apiPath'; +import { axiosInstance } from '@/services/api/utils/axiosInstance'; + +type UseDiseaseReturnType = { + diseaseName: string; + diseaseLink?: string; +}; + +export const useDisease = (): UseDiseaseReturnType => { + const [diseaseName, setDiseaseName] = useState<string>(''); + const diseaseId = useAppSelector(diseaseIdSelector); + const diseaseLink = useAppSelector(diseaseLinkSelector); + + useEffect(() => { + const getDiseaseName = async (id: string): Promise<void> => { + try { + const mesh = await axiosInstance.get<Mesh>(apiPath.getMesh(id)); + setDiseaseName(mesh.data.name); + } catch (error) { + /* empty */ + } + }; + + if (diseaseId) { + getDiseaseName(diseaseId); + } + }, [diseaseId]); + + return { + diseaseName, + diseaseLink, + }; +}; diff --git a/src/components/Map/Drawer/ProjectInfoDrawer/hooks/useOrganism.ts b/src/components/Map/Drawer/ProjectInfoDrawer/hooks/useOrganism.ts new file mode 100644 index 00000000..bff0276e --- /dev/null +++ b/src/components/Map/Drawer/ProjectInfoDrawer/hooks/useOrganism.ts @@ -0,0 +1,37 @@ +import { apiPath } from '@/redux/apiPath'; +import { useAppSelector } from '@/redux/hooks/useAppSelector'; +import { organismIdSelector, organismLinkSelector } from '@/redux/project/project.selectors'; +import { axiosInstance } from '@/services/api/utils/axiosInstance'; +import { Taxonomy } from '@/types/models'; +import { useState, useEffect } from 'react'; + +type UseOrganismReturnType = { + organismName: string; + organismLink?: string; +}; + +export const useOrganism = (): UseOrganismReturnType => { + const [organismName, setOrganismName] = useState<string>(''); + const organismId = useAppSelector(organismIdSelector); + const organismLink = useAppSelector(organismLinkSelector); + + useEffect(() => { + const getOrganismName = async (id: string): Promise<void> => { + try { + const taxonomy = await axiosInstance.get<Taxonomy>(apiPath.getTaxonomy(id)); + setOrganismName(taxonomy.data.name); + } catch (error) { + /* empty */ + } + }; + + if (organismId) { + getOrganismName(organismId); + } + }, [organismId]); + + return { + organismName, + organismLink, + }; +}; diff --git a/src/components/Map/Drawer/ProjectInfoDrawer/index.ts b/src/components/Map/Drawer/ProjectInfoDrawer/index.ts new file mode 100644 index 00000000..9b1f2f89 --- /dev/null +++ b/src/components/Map/Drawer/ProjectInfoDrawer/index.ts @@ -0,0 +1 @@ +export { ProjectInfoDrawer } from './ProjectInfoDrawer.component'; diff --git a/src/models/fixtures/meshFixture.ts b/src/models/fixtures/meshFixture.ts new file mode 100644 index 00000000..753cc1bb --- /dev/null +++ b/src/models/fixtures/meshFixture.ts @@ -0,0 +1,8 @@ +import { ZOD_SEED } from '@/constants'; +// eslint-disable-next-line import/no-extraneous-dependencies +import { createFixture } from 'zod-fixture'; +import { meshSchema } from '../meshSchema'; + +export const meshFixture = createFixture(meshSchema, { + seed: ZOD_SEED, +}); diff --git a/src/models/fixtures/taxonomyFixture.ts b/src/models/fixtures/taxonomyFixture.ts new file mode 100644 index 00000000..99b5db53 --- /dev/null +++ b/src/models/fixtures/taxonomyFixture.ts @@ -0,0 +1,8 @@ +import { ZOD_SEED } from '@/constants'; +// eslint-disable-next-line import/no-extraneous-dependencies +import { createFixture } from 'zod-fixture'; +import { taxonomySchema } from '../taxonomySchema'; + +export const taxonomyFixture = createFixture(taxonomySchema, { + seed: ZOD_SEED, +}); diff --git a/src/models/meshSchema.ts b/src/models/meshSchema.ts new file mode 100644 index 00000000..c78ff933 --- /dev/null +++ b/src/models/meshSchema.ts @@ -0,0 +1,8 @@ +import { z } from 'zod'; + +export const meshSchema = z.object({ + id: z.string(), + name: z.string(), + decription: z.string(), + synonyms: z.array(z.string()), +}); diff --git a/src/models/mocks/modelsMock.ts b/src/models/mocks/modelsMock.ts index 5254ae65..1684bf93 100644 --- a/src/models/mocks/modelsMock.ts +++ b/src/models/mocks/modelsMock.ts @@ -475,3 +475,22 @@ export const CORE_PD_MODEL_MOCK: MapModel = { minZoom: 2, maxZoom: 9, }; + +export const MODEL_WITH_DESCRIPTION: MapModel = { + idObject: 5056, + width: 1975.0, + height: 1950.0, + defaultCenterX: null, + defaultCenterY: null, + description: + 'For information on content, functionalities and referencing the Parkinson\'s disease map, click <a href="http://pdmap.uni.lu" target="_blank">here</a>\n\n.', + name: 'MTOR AMPK signaling', + defaultZoomLevel: null, + tileSize: 256, + references: [], + authors: [], + creationDate: null, + modificationDates: [], + minZoom: 2, + maxZoom: 5, +}; diff --git a/src/models/taxonomySchema.ts b/src/models/taxonomySchema.ts new file mode 100644 index 00000000..cfc3d9e2 --- /dev/null +++ b/src/models/taxonomySchema.ts @@ -0,0 +1,6 @@ +import { z } from 'zod'; + +export const taxonomySchema = z.object({ + id: z.string(), + name: z.string(), +}); diff --git a/src/redux/apiPath.ts b/src/redux/apiPath.ts index c417dbca..a3e416a4 100644 --- a/src/redux/apiPath.ts +++ b/src/redux/apiPath.ts @@ -44,4 +44,7 @@ export const apiPath = { getCompartmentPathwayDetails: (ids: number[]): string => `projects/${PROJECT_ID}/models/*/bioEntities/elements/?id=${ids.join(',')}`, sendCompartmentPathwaysIds: (): string => `projects/${PROJECT_ID}/models/*/bioEntities/elements/`, + getSourceFile: (): string => `/projects/${PROJECT_ID}:downloadSource`, + getMesh: (meshId: string): string => `mesh/${meshId}`, + getTaxonomy: (taxonomyId: string): string => `taxonomy/${taxonomyId}`, }; diff --git a/src/redux/models/models.selectors.ts b/src/redux/models/models.selectors.ts index ab2cdc16..c113be07 100644 --- a/src/redux/models/models.selectors.ts +++ b/src/redux/models/models.selectors.ts @@ -34,3 +34,7 @@ export const modelByIdSelector = createSelector( const MAIN_MAP = 0; export const mainMapModelSelector = createSelector(modelsDataSelector, models => models[MAIN_MAP]); +export const mainMapModelDescriptionSelector = createSelector( + modelsDataSelector, + models => models[MAIN_MAP].description, +); diff --git a/src/redux/project/project.selectors.ts b/src/redux/project/project.selectors.ts index c5ac3403..198295bf 100644 --- a/src/redux/project/project.selectors.ts +++ b/src/redux/project/project.selectors.ts @@ -36,3 +36,30 @@ export const projectIdSelector = createSelector( projectDataSelector, projectData => projectData?.projectId, ); + +export const projectNameSelector = createSelector( + projectDataSelector, + projectData => projectData?.name, +); + +export const diseaseIdSelector = createSelector( + projectDataSelector, + projectData => projectData?.disease.resource, +); + +export const diseaseLinkSelector = createSelector( + projectDataSelector, + projectData => projectData?.disease.link, +); + +export const organismIdSelector = createSelector( + projectDataSelector, + projectData => projectData?.organism.resource, +); + +export const organismLinkSelector = createSelector( + projectDataSelector, + projectData => projectData?.organism.link, +); + +export const versionSelector = createSelector(projectDataSelector, state => state?.version); diff --git a/src/shared/LinkButton/LinkButton.component.test.tsx b/src/shared/LinkButton/LinkButton.component.test.tsx new file mode 100644 index 00000000..90ecf333 --- /dev/null +++ b/src/shared/LinkButton/LinkButton.component.test.tsx @@ -0,0 +1,30 @@ +import { render, screen } from '@testing-library/react'; +import { LinkButton } from './LinkButton.component'; + +describe('LinkButton', () => { + it('renders without crashing', () => { + render(<LinkButton>Test</LinkButton>); + expect(screen.getByText('Test')).toBeInTheDocument(); + }); + + it('applies the primary variant by default', () => { + render(<LinkButton>Test</LinkButton>); + const button = screen.getByText('Test'); + expect(button).toHaveClass( + 'bg-primary-500 text-white-pearl hover:bg-primary-600 active:bg-primary-700 disabled:bg-greyscale-700', + ); + }); + + it('applies additional classes passed in', () => { + // eslint-disable-next-line tailwindcss/no-custom-classname + render(<LinkButton className="extra-class">Test</LinkButton>); + const button = screen.getByText('Test'); + expect(button).toHaveClass('extra-class'); + }); + + it('passes through additional props to the anchor element', () => { + render(<LinkButton data-testid="my-button">Test</LinkButton>); + const button = screen.getByTestId('my-button'); + expect(button).toBeInTheDocument(); + }); +}); diff --git a/src/shared/LinkButton/LinkButton.component.tsx b/src/shared/LinkButton/LinkButton.component.tsx new file mode 100644 index 00000000..9f54a79e --- /dev/null +++ b/src/shared/LinkButton/LinkButton.component.tsx @@ -0,0 +1,34 @@ +import { twMerge } from 'tailwind-merge'; + +const variants = { + primary: { + link: 'bg-primary-500 text-white-pearl hover:bg-primary-600 active:bg-primary-700 disabled:bg-greyscale-700', + }, +} as const; + +type VariantStyle = keyof typeof variants; + +type LinkButtonProps = { + variant?: VariantStyle; + children: React.ReactNode; +} & React.AnchorHTMLAttributes<HTMLAnchorElement>; + +export const LinkButton = ({ + variant = 'primary', + className = '', + children, + ...props +}: LinkButtonProps): JSX.Element => { + return ( + <a + className={twMerge( + 'group flex w-fit items-center rounded-e rounded-s px-3 py-2 text-xs font-bold', + variants[variant].link, + className, + )} + {...props} + > + {children} + </a> + ); +}; diff --git a/src/shared/LinkButton/index.ts b/src/shared/LinkButton/index.ts new file mode 100644 index 00000000..b57ca5ce --- /dev/null +++ b/src/shared/LinkButton/index.ts @@ -0,0 +1 @@ +export { LinkButton } from './LinkButton.component'; diff --git a/src/types/models.ts b/src/types/models.ts index dce27bad..db056dc4 100644 --- a/src/types/models.ts +++ b/src/types/models.ts @@ -20,6 +20,7 @@ import { mapOverlay, uploadedOverlayFileContentSchema, } from '@/models/mapOverlay'; +import { meshSchema } from '@/models/meshSchema'; import { mapModelSchema } from '@/models/modelSchema'; import { organism } from '@/models/organism'; import { overlayBioEntitySchema } from '@/models/overlayBioEntitySchema'; @@ -36,6 +37,7 @@ import { referenceSchema } from '@/models/referenceSchema'; import { sessionSchemaValid } from '@/models/sessionValidSchema'; import { statisticsSchema } from '@/models/statisticsSchema'; import { targetSchema } from '@/models/targetSchema'; +import { taxonomySchema } from '@/models/taxonomySchema'; import { z } from 'zod'; export type Project = z.infer<typeof projectSchema>; @@ -72,3 +74,5 @@ export type Color = z.infer<typeof colorSchema>; export type Statistics = z.infer<typeof statisticsSchema>; export type CompartmentPathway = z.infer<typeof compartmentPathwaySchema>; export type CompartmentPathwayDetails = z.infer<typeof compartmentPathwayDetailsSchema>; +export type Mesh = z.infer<typeof meshSchema>; +export type Taxonomy = z.infer<typeof taxonomySchema>; diff --git a/tailwind.config.ts b/tailwind.config.ts index 80376a72..a4154960 100644 --- a/tailwind.config.ts +++ b/tailwind.config.ts @@ -29,6 +29,7 @@ const config: Config = { purple: '#6400e3', pink: '#f1009f', 'cetacean-blue': '#070130', + 'hyperlink-blue': '#0048ff', }, height: { 'calc-drawer': 'calc(100% - 104px)', -- GitLab