diff --git a/package-lock.json b/package-lock.json index 4aad8440554ebbab3ac260423e29db2eb4097bbe..fda23e3ba417c3a63f180521727bc071a4ac8ff4 100644 --- a/package-lock.json +++ b/package-lock.json @@ -11,6 +11,7 @@ "dependencies": { "@next/font": "^13.5.2", "@reduxjs/toolkit": "^1.9.6", + "@tanstack/react-table": "^8.11.7", "@types/node": "20.6.2", "@types/openlayers": "^4.6.20", "@types/react": "18.2.21", @@ -2018,6 +2019,37 @@ "tslib": "^2.4.0" } }, + "node_modules/@tanstack/react-table": { + "version": "8.11.7", + "resolved": "https://registry.npmjs.org/@tanstack/react-table/-/react-table-8.11.7.tgz", + "integrity": "sha512-ZbzfMkLjxUTzNPBXJYH38pv2VpC9WUA+Qe5USSHEBz0dysDTv4z/ARI3csOed/5gmlmrPzVUN3UXGuUMbod3Jg==", + "dependencies": { + "@tanstack/table-core": "8.11.7" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + }, + "peerDependencies": { + "react": ">=16", + "react-dom": ">=16" + } + }, + "node_modules/@tanstack/table-core": { + "version": "8.11.7", + "resolved": "https://registry.npmjs.org/@tanstack/table-core/-/table-core-8.11.7.tgz", + "integrity": "sha512-N3ksnkbPbsF3PjubuZCB/etTqvctpXWRHIXTmYfJFnhynQKjeZu8BCuHvdlLPpumKbA+bjY4Ay9AELYLOXPWBg==", + "engines": { + "node": ">=12" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + } + }, "node_modules/@testing-library/dom": { "version": "9.3.3", "resolved": "https://registry.npmjs.org/@testing-library/dom/-/dom-9.3.3.tgz", @@ -15341,6 +15373,19 @@ "tslib": "^2.4.0" } }, + "@tanstack/react-table": { + "version": "8.11.7", + "resolved": "https://registry.npmjs.org/@tanstack/react-table/-/react-table-8.11.7.tgz", + "integrity": "sha512-ZbzfMkLjxUTzNPBXJYH38pv2VpC9WUA+Qe5USSHEBz0dysDTv4z/ARI3csOed/5gmlmrPzVUN3UXGuUMbod3Jg==", + "requires": { + "@tanstack/table-core": "8.11.7" + } + }, + "@tanstack/table-core": { + "version": "8.11.7", + "resolved": "https://registry.npmjs.org/@tanstack/table-core/-/table-core-8.11.7.tgz", + "integrity": "sha512-N3ksnkbPbsF3PjubuZCB/etTqvctpXWRHIXTmYfJFnhynQKjeZu8BCuHvdlLPpumKbA+bjY4Ay9AELYLOXPWBg==" + }, "@testing-library/dom": { "version": "9.3.3", "resolved": "https://registry.npmjs.org/@testing-library/dom/-/dom-9.3.3.tgz", diff --git a/package.json b/package.json index 4cf34d33a72916303aa6cf834ab8b7e4f1e2a1ba..e45e31b66caee289777e05e7e30301a7a8592d93 100644 --- a/package.json +++ b/package.json @@ -25,6 +25,7 @@ "dependencies": { "@next/font": "^13.5.2", "@reduxjs/toolkit": "^1.9.6", + "@tanstack/react-table": "^8.11.7", "@types/node": "20.6.2", "@types/openlayers": "^4.6.20", "@types/react": "18.2.21", diff --git a/src/components/FunctionalArea/Modal/Modal.component.tsx b/src/components/FunctionalArea/Modal/Modal.component.tsx index 0244ce42dd44e7f82fddf2d2454cb10e989e5679..f8f68474fe88d68226316e47307c5855bb6bab56 100644 --- a/src/components/FunctionalArea/Modal/Modal.component.tsx +++ b/src/components/FunctionalArea/Modal/Modal.component.tsx @@ -8,6 +8,7 @@ import { twMerge } from 'tailwind-merge'; import { LoginModal } from './LoginModal'; import { MODAL_ROLE } from './Modal.constants'; import { OverviewImagesModal } from './OverviewImagesModal'; +import { PublicationsModal } from './PublicationsModal'; const MolArtModal = dynamic( () => import('./MolArtModal/MolArtModal.component').then(mod => mod.MolArtModal), @@ -46,6 +47,7 @@ export const Modal = (): React.ReactNode => { {isOpen && modalName === 'overview-images' && <OverviewImagesModal />} {isOpen && modalName === 'mol-art' && <MolArtModal />} {isOpen && modalName === 'login' && <LoginModal />} + {isOpen && modalName === 'publications' && <PublicationsModal />} </div> </div> </div> diff --git a/src/components/FunctionalArea/Modal/PublicationsModal/PublicationsModal.test.tsx b/src/components/FunctionalArea/Modal/PublicationsModal/PublicationsModal.test.tsx new file mode 100644 index 0000000000000000000000000000000000000000..4da37a1f33fa0b150571dc5c2a7ebd4bf0420357 --- /dev/null +++ b/src/components/FunctionalArea/Modal/PublicationsModal/PublicationsModal.test.tsx @@ -0,0 +1,20 @@ +describe('Publications Modal - component', () => { + it('should render number of publications', () => {}); + it('should render download csv button', () => {}); + it('should trigger download on csv button click', () => {}); + it('should render search input', () => {}); + it('should be able to search publications by using search input', () => {}); + it('should be able to sort publications by clicking on Pubmed ID column header', () => {}); + it('should be able to sort publications by clicking on Title column header', () => {}); + it('should be able to sort publications by clicking on Authors column header', () => {}); + it('should be able to sort publications by clicking on Journal column header', () => {}); + it('should be able to sort publications by clicking on Year column header', () => {}); + it('should be able to sort publications by clicking on Elements on map column header', () => {}); + it('should be able to sort publications by clicking on SUBMAPS on map column header', () => {}); + + it('should render publications list', () => {}); + + it('should render pagination', () => {}); + it('should be able to navigate to next page', () => {}); + it('should be able to navigate to previous page', () => {}); +}); diff --git a/src/components/FunctionalArea/Modal/PublicationsModal/PublicationsModal.tsx b/src/components/FunctionalArea/Modal/PublicationsModal/PublicationsModal.tsx new file mode 100644 index 0000000000000000000000000000000000000000..9c3c6a9d92aa136b37960627141a946f28adb7e5 --- /dev/null +++ b/src/components/FunctionalArea/Modal/PublicationsModal/PublicationsModal.tsx @@ -0,0 +1,13 @@ +import { useAppDispatch } from '@/redux/hooks/useAppDispatch'; +import { getPublications } from '@/redux/publications/publications.thunks'; +import { useEffect } from 'react'; + +export const PublicationsModal = (): JSX.Element => { + const dispatch = useAppDispatch(); + + useEffect(() => { + dispatch(getPublications({})); + }, [dispatch]); + + return <div className="flex h-full w-full items-center justify-center bg-white">lol</div>; +}; diff --git a/src/components/FunctionalArea/Modal/PublicationsModal/index.ts b/src/components/FunctionalArea/Modal/PublicationsModal/index.ts new file mode 100644 index 0000000000000000000000000000000000000000..ea5f74a5c47ed3034b8edb452ef072c7271bcff7 --- /dev/null +++ b/src/components/FunctionalArea/Modal/PublicationsModal/index.ts @@ -0,0 +1 @@ +export { PublicationsModal } from './PublicationsModal'; diff --git a/src/components/Map/Drawer/ProjectInfoDrawer/ProjectInfoDrawer.component.tsx b/src/components/Map/Drawer/ProjectInfoDrawer/ProjectInfoDrawer.component.tsx index 318239811d4e068808bcf99f604719c7d577e9a7..a006e829e5515fb7eb53d8bac0b397187ed4f2e7 100644 --- a/src/components/Map/Drawer/ProjectInfoDrawer/ProjectInfoDrawer.component.tsx +++ b/src/components/Map/Drawer/ProjectInfoDrawer/ProjectInfoDrawer.component.tsx @@ -12,8 +12,12 @@ import { apiPath } from '@/redux/apiPath'; import { LinkButton } from '@/shared/LinkButton'; import { mainMapModelDescriptionSelector } from '@/redux/models/models.selectors'; import './ProjectInfoDrawer.styles.css'; +import { useAppDispatch } from '@/redux/hooks/useAppDispatch'; +import { useEffect } from 'react'; +import { openPublicationsModal } from '@/redux/modal/modal.slice'; export const ProjectInfoDrawer = (): JSX.Element => { + const dispatch = useAppDispatch(); const diseaseName = useAppSelector(diseaseNameSelector); const diseaseLink = useAppSelector(diseaseLinkSelector); const organismLink = useAppSelector(organismLinkSelector); @@ -24,6 +28,14 @@ export const ProjectInfoDrawer = (): JSX.Element => { const sourceDownloadLink = window.location.hostname + apiPath.getSourceFile(); + useEffect(() => { + // dispatch(getPublications()); + }, [dispatch]); + + const onPublicationsClick = (): void => { + dispatch(openPublicationsModal()); + }; + return ( <div data-testid="export-drawer" className="h-full max-h-full"> <DrawerHeading title="Project info" /> @@ -36,13 +48,17 @@ export const ProjectInfoDrawer = (): JSX.Element => { </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"> + <button type="button" onClick={onPublicationsClick} className="text-sm font-semibold"> + (21) publications + </button> + </li> <li className="mt-2 text-hyperlink-blue"> <a href="https://minerva.pages.uni.lu/doc/" target="_blank" rel="noopener noreferrer" - className="hover:underline" + className="font-semibold hover:underline" > Manual </a> @@ -53,7 +69,7 @@ export const ProjectInfoDrawer = (): JSX.Element => { href={diseaseLink} target="_blank" rel="noopener noreferrer" - className="hover:underline" + className="font-semibold hover:underline" > {diseaseName} </a> @@ -64,7 +80,7 @@ export const ProjectInfoDrawer = (): JSX.Element => { href={organismLink} target="_blank" rel="noopener noreferrer" - className="hover:underline" + className="font-semibold hover:underline" > {organismName} </a> diff --git a/src/models/publicationsResponseSchema.ts b/src/models/publicationsResponseSchema.ts new file mode 100644 index 0000000000000000000000000000000000000000..5bdcca5b4d1a1bd5540cb23832957567acdfc559 --- /dev/null +++ b/src/models/publicationsResponseSchema.ts @@ -0,0 +1,10 @@ +import { z } from 'zod'; +import { publicationSchema } from './publicationsSchema'; + +export const publicationsResponseSchema = z.object({ + data: z.array(publicationSchema), + totalSize: z.number(), + filteredSize: z.number(), + length: z.number(), + page: z.number(), +}); diff --git a/src/models/publicationsSchema.ts b/src/models/publicationsSchema.ts new file mode 100644 index 0000000000000000000000000000000000000000..b1574088a29007686de243593f69566a074b2ede --- /dev/null +++ b/src/models/publicationsSchema.ts @@ -0,0 +1,10 @@ +import { z } from 'zod'; +import { targetElementSchema } from './targetElementSchema'; +import { articleSchema } from './articleSchema'; + +export const publicationSchema = z.object({ + elements: z.array(targetElementSchema), + publication: z.object({ + article: articleSchema, + }), +}); diff --git a/src/redux/apiPath.ts b/src/redux/apiPath.ts index a3e416a482b8a52d81244d03a9a5f3d81fb9a3de..95aed0951c702fac177157c88ce24c470776e69d 100644 --- a/src/redux/apiPath.ts +++ b/src/redux/apiPath.ts @@ -2,6 +2,33 @@ import { PROJECT_ID } from '@/constants'; import { PerfectSearchParams } from '@/types/search'; import { Point } from '@/types/map'; +export type GetPublicationsParams = { + start?: number; + sortColumn?: string; + sortOrder?: string; + level?: number; + length?: number; + search?: string; +}; + +const getPublicationsURLSearchParams = ({ + start, + sortColumn, + sortOrder, + level, + length, + search, +}: GetPublicationsParams): URLSearchParams => { + const params = new URLSearchParams(); + if (start) params.append('start', start.toString()); + if (sortColumn) params.append('sortColumn', sortColumn); + if (sortOrder) params.append('sortOrder', sortOrder); + if (level) params.append('level', level.toString()); + if (length) params.append('length', length.toString()); + if (search) params.append('search', search); + return params; +}; + export const apiPath = { getBioEntityContentsStringWithQuery: ({ searchQuery, @@ -47,4 +74,8 @@ export const apiPath = { getSourceFile: (): string => `/projects/${PROJECT_ID}:downloadSource`, getMesh: (meshId: string): string => `mesh/${meshId}`, getTaxonomy: (taxonomyId: string): string => `taxonomy/${taxonomyId}`, + getPublications: (params: GetPublicationsParams, modelId = '*'): string => + `/projects/${PROJECT_ID}/models/${modelId}/publications/${getPublicationsURLSearchParams( + params, + )}`, }; diff --git a/src/redux/modal/modal.reducers.ts b/src/redux/modal/modal.reducers.ts index 78d5adeaf1cdfce8da14de32fcb17db2828b51a3..7b1581806cf4062d447c6c3a3d9e32286f5655df 100644 --- a/src/redux/modal/modal.reducers.ts +++ b/src/redux/modal/modal.reducers.ts @@ -50,3 +50,9 @@ export const setOverviewImageIdReducer = ( imageId: action.payload, }; }; + +export const openPublicationsModalReducer = (state: ModalState): void => { + state.isOpen = true; + state.modalName = 'publications'; + state.modalTitle = 'Publications'; +}; diff --git a/src/redux/modal/modal.slice.ts b/src/redux/modal/modal.slice.ts index 84f1023141d74c975d7b2d879d9b51641439b344..a324db237e715053968b125448f9048234da523e 100644 --- a/src/redux/modal/modal.slice.ts +++ b/src/redux/modal/modal.slice.ts @@ -7,6 +7,7 @@ import { openOverviewImagesModalByIdReducer, openMolArtModalByIdReducer, setOverviewImageIdReducer, + openPublicationsModalReducer, } from './modal.reducers'; const modalSlice = createSlice({ @@ -19,6 +20,7 @@ const modalSlice = createSlice({ openMolArtModalById: openMolArtModalByIdReducer, setOverviewImageId: setOverviewImageIdReducer, openLoginModal: openLoginModalReducer, + openPublicationsModal: openPublicationsModalReducer, }, }); @@ -29,6 +31,7 @@ export const { setOverviewImageId, openMolArtModalById, openLoginModal, + openPublicationsModal, } = modalSlice.actions; export default modalSlice.reducer; diff --git a/src/redux/publications/publications.mock.ts b/src/redux/publications/publications.mock.ts new file mode 100644 index 0000000000000000000000000000000000000000..ea202e1400ad7ae20e6a7d1709f13cfd8dceb21e --- /dev/null +++ b/src/redux/publications/publications.mock.ts @@ -0,0 +1,7 @@ +import { PublicationsState } from './publications.types'; + +export const PUBLICATIONS_INITIAL_STATE_MOCK: PublicationsState = { + loading: 'idle', + data: undefined, + error: { name: '', message: '' }, +}; diff --git a/src/redux/publications/publications.reducers.ts b/src/redux/publications/publications.reducers.ts new file mode 100644 index 0000000000000000000000000000000000000000..ed3fc0576a068791a29e2c0e966f706ad03089e5 --- /dev/null +++ b/src/redux/publications/publications.reducers.ts @@ -0,0 +1,19 @@ +import { ActionReducerMapBuilder } from '@reduxjs/toolkit'; +import { PublicationsState } from './publications.types'; +import { getPublications } from './publications.thunks'; + +export const getPublicationsReducer = ( + builder: ActionReducerMapBuilder<PublicationsState>, +): void => { + builder.addCase(getPublications.pending, state => { + state.loading = 'pending'; + }); + builder.addCase(getPublications.fulfilled, (state, action) => { + state.data = action.payload || undefined; + state.loading = 'succeeded'; + }); + builder.addCase(getPublications.rejected, state => { + state.loading = 'failed'; + // TODO to discuss manage state of failure + }); +}; diff --git a/src/redux/publications/publications.selectors.ts b/src/redux/publications/publications.selectors.ts new file mode 100644 index 0000000000000000000000000000000000000000..a1fcbb1c3c6f9c90fcb37155eaa75bfbcc957687 --- /dev/null +++ b/src/redux/publications/publications.selectors.ts @@ -0,0 +1,16 @@ +import { createSelector } from '@reduxjs/toolkit'; +import { rootSelector } from '../root/root.selectors'; + +export const publicationsSelector = createSelector(rootSelector, state => state.publications); + +export const publicationsDataSelector = createSelector( + publicationsSelector, + publications => publications?.data, +); + +export const totalSizeSelector = createSelector(publicationsDataSelector, data => data?.totalSize); + +export const filteredSizeSelector = createSelector( + publicationsDataSelector, + data => data?.filteredSize, +); diff --git a/src/redux/publications/publications.slice.ts b/src/redux/publications/publications.slice.ts new file mode 100644 index 0000000000000000000000000000000000000000..291893453440898dee479b8d5aaadd20b135792e --- /dev/null +++ b/src/redux/publications/publications.slice.ts @@ -0,0 +1,20 @@ +import { createSlice } from '@reduxjs/toolkit'; +import { PublicationsState } from './publications.types'; +import { getPublicationsReducer } from './publications.reducers'; + +const initialState: PublicationsState = { + data: undefined, + loading: 'idle', + error: { name: '', message: '' }, +}; + +const publicationsSlice = createSlice({ + name: 'publications', + initialState, + reducers: {}, + extraReducers: builder => { + getPublicationsReducer(builder); + }, +}); + +export default publicationsSlice.reducer; diff --git a/src/redux/publications/publications.thunks.ts b/src/redux/publications/publications.thunks.ts new file mode 100644 index 0000000000000000000000000000000000000000..cb39b53f5ad2e39a6505d98beee897cfb6545440 --- /dev/null +++ b/src/redux/publications/publications.thunks.ts @@ -0,0 +1,17 @@ +import { createAsyncThunk } from '@reduxjs/toolkit'; +import { validateDataUsingZodSchema } from '@/utils/validateDataUsingZodSchema'; +import { axiosInstance } from '@/services/api/utils/axiosInstance'; +import { PublicationsResponse } from '@/types/models'; +import { publicationsResponseSchema } from '@/models/publicationsResponseSchema'; +import { GetPublicationsParams, apiPath } from '../apiPath'; + +export const getPublications = createAsyncThunk( + 'publications/getPublications', + async (params: GetPublicationsParams): Promise<PublicationsResponse | undefined> => { + const response = await axiosInstance.get<PublicationsResponse>(apiPath.getPublications(params)); + + const isDataValid = validateDataUsingZodSchema(response.data, publicationsResponseSchema); + + return isDataValid ? response.data : undefined; + }, +); diff --git a/src/redux/publications/publications.types.ts b/src/redux/publications/publications.types.ts new file mode 100644 index 0000000000000000000000000000000000000000..9a6875aa91a77377bf57c1644058743a5d6b5619 --- /dev/null +++ b/src/redux/publications/publications.types.ts @@ -0,0 +1,13 @@ +import { FetchDataState } from '@/types/fetchDataState'; +import { PublicationsResponse } from '@/types/models'; + +export type PublicationsState = FetchDataState<PublicationsResponse>; + +export type GetPublicationsParams = { + start?: number; + sortColumn?: string; + sortOrder?: string; + level?: number; + length?: number; + search?: string; +}; diff --git a/src/redux/root/root.fixtures.ts b/src/redux/root/root.fixtures.ts index a8494c467cedda636c3db0f9951d99325a9bc048..77d70737a030314a15c3b1322df0b1782ea365c9 100644 --- a/src/redux/root/root.fixtures.ts +++ b/src/redux/root/root.fixtures.ts @@ -19,6 +19,7 @@ import { RootState } from '../store'; import { USER_INITIAL_STATE_MOCK } from '../user/user.mock'; import { STATISTICS_STATE_INITIAL_MOCK } from '../statistics/statistics.mock'; import { COMPARTMENT_PATHWAYS_INITIAL_STATE_MOCK } from '../compartmentPathways/compartmentPathways.mock'; +import { PUBLICATIONS_INITIAL_STATE_MOCK } from '../publications/publications.mock'; export const INITIAL_STORE_STATE_MOCK: RootState = { search: SEARCH_STATE_INITIAL_MOCK, @@ -41,4 +42,5 @@ export const INITIAL_STORE_STATE_MOCK: RootState = { legend: LEGEND_INITIAL_STATE_MOCK, statistics: STATISTICS_STATE_INITIAL_MOCK, compartmentPathways: COMPARTMENT_PATHWAYS_INITIAL_STATE_MOCK, + publications: PUBLICATIONS_INITIAL_STATE_MOCK, }; diff --git a/src/redux/store.ts b/src/redux/store.ts index 944288e8e5a21d046914ed7ad655a40bcda014f3..32c4c738048dc5fd63bda60d4391003dc701f1fb 100644 --- a/src/redux/store.ts +++ b/src/redux/store.ts @@ -26,6 +26,7 @@ import legendReducer from './legend/legend.slice'; import { mapListenerMiddleware } from './map/middleware/map.middleware'; import statisticsReducer from './statistics/statistics.slice'; import compartmentPathwaysReducer from './compartmentPathways/compartmentPathways.slice'; +import publicationsReducer from './publications/publications.slice'; export const reducers = { search: searchReducer, @@ -48,6 +49,7 @@ export const reducers = { legend: legendReducer, statistics: statisticsReducer, compartmentPathways: compartmentPathwaysReducer, + publications: publicationsReducer, }; export const middlewares = [mapListenerMiddleware.middleware]; diff --git a/src/types/modal.ts b/src/types/modal.ts index 763926937f9de43ed4ae16a957d75fc2a6069684..474c0f7aef86b9c6f5d0e83e6a924a7af323afbd 100644 --- a/src/types/modal.ts +++ b/src/types/modal.ts @@ -1 +1 @@ -export type ModalName = 'none' | 'overview-images' | 'mol-art' | 'login'; +export type ModalName = 'none' | 'overview-images' | 'mol-art' | 'login' | 'publications'; diff --git a/src/types/models.ts b/src/types/models.ts index 8ad656d412117837bce73441bdc0692900124fc4..22862e3aa5f3270cd99643d33cc250ef837a5047 100644 --- a/src/types/models.ts +++ b/src/types/models.ts @@ -1,3 +1,4 @@ +import { z } from 'zod'; import { bioEntityContentSchema } from '@/models/bioEntityContentSchema'; import { bioEntityResponseSchema } from '@/models/bioEntityResponseSchema'; import { bioEntitySchema } from '@/models/bioEntitySchema'; @@ -30,13 +31,14 @@ import { } from '@/models/overviewImageLink'; import { overviewImageView } from '@/models/overviewImageView'; import { projectSchema } from '@/models/projectSchema'; +import { publicationSchema } from '@/models/publicationsSchema'; import { reactionSchema } from '@/models/reaction'; import { reactionLineSchema } from '@/models/reactionLineSchema'; import { referenceSchema } from '@/models/referenceSchema'; import { sessionSchemaValid } from '@/models/sessionValidSchema'; import { statisticsSchema } from '@/models/statisticsSchema'; import { targetSchema } from '@/models/targetSchema'; -import { z } from 'zod'; +import { publicationsResponseSchema } from '@/models/publicationsResponseSchema'; export type Project = z.infer<typeof projectSchema>; export type OverviewImageView = z.infer<typeof overviewImageView>; @@ -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 PublicationsResponse = z.infer<typeof publicationsResponseSchema>; +export type Publication = z.infer<typeof publicationSchema>;