diff --git a/src/components/Map/Drawer/Drawer.component.test.tsx b/src/components/Map/Drawer/Drawer.component.test.tsx index fb4be23d8760e48608e43a944bc35515b5d6d739..2499e7f3b459484389a46d4453426b5123cc12ac 100644 --- a/src/components/Map/Drawer/Drawer.component.test.tsx +++ b/src/components/Map/Drawer/Drawer.component.test.tsx @@ -104,10 +104,10 @@ describe('Drawer - component', () => { }); expect(screen.queryByTestId('reaction-drawer')).not.toBeInTheDocument(); - - store.dispatch(getReactionsByIds([id])); - store.dispatch(openReactionDrawerById(id)); - + await act(() => { + store.dispatch(getReactionsByIds([id])); + store.dispatch(openReactionDrawerById(id)); + }); await waitFor(() => expect(screen.getByTestId('reaction-drawer')).toBeInTheDocument()); }); }); diff --git a/src/components/Map/Drawer/Drawer.component.tsx b/src/components/Map/Drawer/Drawer.component.tsx index b55022ff3b3690835076976d2f7f443cf374689e..098c4f989ec233798af6de0addfbb28fca84b02e 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 0000000000000000000000000000000000000000..bfad37058b0e1ef0cb5c84863aaba191676735c1 --- /dev/null +++ b/src/components/Map/Drawer/ProjectInfoDrawer/ProjectInfoDrawer.component.test.tsx @@ -0,0 +1,117 @@ +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 { MODEL_WITH_DESCRIPTION } from '@/models/mocks/modelsMock'; +import { ProjectInfoDrawer } from './ProjectInfoDrawer.component'; + +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', () => { + 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 () => { + await act(() => { + renderComponent(MOCKED_STORE); + }); + + const diseaseLink = screen.getByText(/Disease:/i); + expect(diseaseLink).toBeInTheDocument(); + + const linkelement = screen.getByRole('link', { name: projectFixture.diseaseName }); + expect(linkelement).toBeInTheDocument(); + expect(linkelement).toHaveAttribute('href', projectFixture.disease.link); + }); + + it('should fetch diesease name when diseaseId is provided', async () => { + await act(() => { + renderComponent(MOCKED_STORE); + }); + + const organismLink = screen.getByText(/Organism:/i); + expect(organismLink).toBeInTheDocument(); + + const linkelement = screen.getByRole('link', { name: projectFixture.organismName }); + 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'); + }); + + it('should render the description when it exists', () => { + renderComponent(MOCKED_STORE); + + const desc = screen.getByTestId('project-description'); + + expect(desc.innerHTML).toContain( + 'For information on content, functionalities and referencing the Parkinson\'s disease map, click <a href="http://pdmap.uni.lu" target="_blank">here</a>', + ); + }); + + it.skip('should not render the description when it does not exist', () => { + renderComponent(); + + const descriptionElement = screen.queryByText('This is the project description.'); + expect(descriptionElement).not.toBeInTheDocument(); + }); +}); 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 0000000000000000000000000000000000000000..318239811d4e068808bcf99f604719c7d577e9a7 --- /dev/null +++ b/src/components/Map/Drawer/ProjectInfoDrawer/ProjectInfoDrawer.component.tsx @@ -0,0 +1,87 @@ +import { useAppSelector } from '@/redux/hooks/useAppSelector'; +import { + diseaseNameSelector, + projectNameSelector, + versionSelector, + organismNameSelector, + diseaseLinkSelector, + organismLinkSelector, +} 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'; + +export const ProjectInfoDrawer = (): JSX.Element => { + const diseaseName = useAppSelector(diseaseNameSelector); + const diseaseLink = useAppSelector(diseaseLinkSelector); + const organismLink = useAppSelector(organismLinkSelector); + const organismName = useAppSelector(organismNameSelector); + 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 + data-testid="project-description" + 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 0000000000000000000000000000000000000000..6713b0feb4859c99256590e371a0c2d36ec61dbc --- /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/index.ts b/src/components/Map/Drawer/ProjectInfoDrawer/index.ts new file mode 100644 index 0000000000000000000000000000000000000000..9b1f2f893c4b3b2e9db56e5b99a455ce1dd3d084 --- /dev/null +++ b/src/components/Map/Drawer/ProjectInfoDrawer/index.ts @@ -0,0 +1 @@ +export { ProjectInfoDrawer } from './ProjectInfoDrawer.component'; diff --git a/src/models/fixtures/projectFixture.ts b/src/models/fixtures/projectFixture.ts index 99e01bb36cb06a4dac15e20fbf116238a398a1f5..06868af6d5692a22d0e0e5d733508cf56357d781 100644 --- a/src/models/fixtures/projectFixture.ts +++ b/src/models/fixtures/projectFixture.ts @@ -1,7 +1,7 @@ import { ZOD_SEED } from '@/constants'; // eslint-disable-next-line import/no-extraneous-dependencies import { createFixture } from 'zod-fixture'; -import { projectSchema } from '../project'; +import { projectSchema } from '../projectSchema'; export const projectFixture = createFixture(projectSchema, { seed: ZOD_SEED, diff --git a/src/models/mocks/modelsMock.ts b/src/models/mocks/modelsMock.ts index 5254ae65fb2158e7e999e4cebce4229742aa139c..1684bf934c71c5ecf6fc05912aa57e41b7fbc1a2 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/project.ts b/src/models/projectSchema.ts similarity index 85% rename from src/models/project.ts rename to src/models/projectSchema.ts index 844fe68047b7407e3a4e17152290f9778905ceb0..517d7b0c52d0684e7f3ef1c29f05955add86ab61 100644 --- a/src/models/project.ts +++ b/src/models/projectSchema.ts @@ -6,8 +6,9 @@ import { overviewImageView } from './overviewImageView'; export const projectSchema = z.object({ version: z.string(), disease, + diseaseName: z.string(), organism, - idObject: z.number(), + organismName: z.string(), status: z.string(), directory: z.string(), progress: z.number(), @@ -15,7 +16,9 @@ export const projectSchema = z.object({ logEntries: z.boolean(), name: z.string(), sharedInMinervaNet: z.boolean(), - owner: z.string(), + owner: z.object({ + login: z.string(), + }), projectId: z.string(), creationDate: z.string(), mapCanvasType: z.string(), diff --git a/src/redux/apiPath.ts b/src/redux/apiPath.ts index f22dff259a6127c4f0a86ca6c2fd2ecdc8390799..0e0865418dfa8c9294787e5dd9fba9ed368ae1ea 100644 --- a/src/redux/apiPath.ts +++ b/src/redux/apiPath.ts @@ -46,4 +46,7 @@ export const apiPath = { sendCompartmentPathwaysIds: (): string => `projects/${PROJECT_ID}/models/*/bioEntities/elements/`, downloadOverlay: (overlayId: number): string => `projects/${PROJECT_ID}/overlays/${overlayId}:downloadSource`, + 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 ab2cdc1631bc94bc2d67754860bbc2cab3a044ac..c113be0700fcc4be0e946f1e0de3241d04348fe0 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.reducers.test.ts b/src/redux/project/project.reducers.test.ts index 28b9ef70063d829473534c60f51abe85143da8f9..744f725261ca8605d915b0da3474c3300526d24d 100644 --- a/src/redux/project/project.reducers.test.ts +++ b/src/redux/project/project.reducers.test.ts @@ -4,14 +4,14 @@ import { ToolkitStoreWithSingleSlice, createStoreInstanceUsingSliceReducer, } from '@/utils/createStoreInstanceUsingSliceReducer'; -import { mockNetworkResponse } from '@/utils/mockNetworkResponse'; +import { mockNetworkNewAPIResponse } from '@/utils/mockNetworkResponse'; import { HttpStatusCode } from 'axios'; import { apiPath } from '../apiPath'; import projectReducer from './project.slice'; import { getProjectById } from './project.thunks'; import { ProjectState } from './project.types'; -const mockedAxiosClient = mockNetworkResponse(); +const mockedAxiosClient = mockNetworkNewAPIResponse(); const INITIAL_STATE: ProjectState = { data: undefined, diff --git a/src/redux/project/project.selectors.ts b/src/redux/project/project.selectors.ts index c5ac340314f157036c59a0cc3cd648ae701582bd..6ae2769e7c06400bd86c6df411b73f800bba3d36 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 diseaseNameSelector = createSelector( + projectDataSelector, + projectData => projectData?.diseaseName, +); + +export const diseaseLinkSelector = createSelector( + projectDataSelector, + projectData => projectData?.disease.link, +); + +export const organismLinkSelector = createSelector( + projectDataSelector, + projectData => projectData?.organism.link, +); + +export const organismNameSelector = createSelector( + projectDataSelector, + projectData => projectData?.organismName, +); + +export const versionSelector = createSelector(projectDataSelector, state => state?.version); diff --git a/src/redux/project/project.thunks.ts b/src/redux/project/project.thunks.ts index f3d9fbe26e2fc0d226a86be7b8762b39a938b777..649f867d87cf8581543559dc5f2c2a2911ea3388 100644 --- a/src/redux/project/project.thunks.ts +++ b/src/redux/project/project.thunks.ts @@ -1,5 +1,5 @@ -import { projectSchema } from '@/models/project'; -import { axiosInstance } from '@/services/api/utils/axiosInstance'; +import { projectSchema } from '@/models/projectSchema'; +import { axiosInstanceNewAPI } from '@/services/api/utils/axiosInstance'; import { Project } from '@/types/models'; import { validateDataUsingZodSchema } from '@/utils/validateDataUsingZodSchema'; import { createAsyncThunk } from '@reduxjs/toolkit'; @@ -8,7 +8,7 @@ import { apiPath } from '../apiPath'; export const getProjectById = createAsyncThunk( 'project/getProjectById', async (id: string): Promise<Project | undefined> => { - const response = await axiosInstance.get<Project>(apiPath.getProjectById(id)); + const response = await axiosInstanceNewAPI.get<Project>(apiPath.getProjectById(id)); const isDataValid = validateDataUsingZodSchema(response.data, projectSchema); diff --git a/src/shared/LinkButton/LinkButton.component.test.tsx b/src/shared/LinkButton/LinkButton.component.test.tsx new file mode 100644 index 0000000000000000000000000000000000000000..90ecf3330e16275c91c06c1cd52b1ad4ce26ac76 --- /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 0000000000000000000000000000000000000000..9f54a79ed83fe300e389143dedc87d59b27e8d90 --- /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 0000000000000000000000000000000000000000..b57ca5cea9a7871e972b0907c426de44cc208476 --- /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 dce27badd9f02dc755462a39205f5e52abfc7545..8ad656d412117837bce73441bdc0692900124fc4 100644 --- a/src/types/models.ts +++ b/src/types/models.ts @@ -29,7 +29,7 @@ import { overviewImageLinkModel, } from '@/models/overviewImageLink'; import { overviewImageView } from '@/models/overviewImageView'; -import { projectSchema } from '@/models/project'; +import { projectSchema } from '@/models/projectSchema'; import { reactionSchema } from '@/models/reaction'; import { reactionLineSchema } from '@/models/reactionLineSchema'; import { referenceSchema } from '@/models/referenceSchema'; diff --git a/src/utils/initialize/useInitializeStore.test.ts b/src/utils/initialize/useInitializeStore.test.ts index d1b7da08d0e98f26cc0fc4403efc1c333dfa877b..1a99f715fa58967978dc5b381962a660731e6ebf 100644 --- a/src/utils/initialize/useInitializeStore.test.ts +++ b/src/utils/initialize/useInitializeStore.test.ts @@ -9,13 +9,14 @@ import { modelsDataSelector } from '@/redux/models/models.selectors'; import { overlaysDataSelector } from '@/redux/overlays/overlays.selectors'; import { projectDataSelector } from '@/redux/project/project.selectors'; import { initDataLoadingInitialized } from '@/redux/root/init.selectors'; -import { mockNetworkResponse } from '@/utils/mockNetworkResponse'; +import { mockNetworkNewAPIResponse, mockNetworkResponse } from '@/utils/mockNetworkResponse'; import { getReduxWrapperWithStore } from '@/utils/testing/getReduxWrapperWithStore'; import { renderHook, waitFor } from '@testing-library/react'; import { HttpStatusCode } from 'axios'; import * as hook from './useInitializeStore'; const mockedAxiosClient = mockNetworkResponse(); +const mockedAxiosNEWApiClient = mockNetworkNewAPIResponse(); describe('useInitializeStore - hook', () => { describe('when fired', () => { @@ -24,7 +25,7 @@ describe('useInitializeStore - hook', () => { mockedAxiosClient .onGet(apiPath.getAllOverlaysByProjectIdQuery(PROJECT_ID, { publicOverlay: true })) .reply(HttpStatusCode.Ok, overlaysFixture); - mockedAxiosClient + mockedAxiosNEWApiClient .onGet(apiPath.getProjectById(PROJECT_ID)) .reply(HttpStatusCode.Ok, projectFixture); mockedAxiosClient diff --git a/tailwind.config.ts b/tailwind.config.ts index 80376a72e62b6e23b6a7a7d560dee0650e4d5d92..a41549602c07c850668d40ae4b784fafdfb237bc 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)',