diff --git a/src/components/FunctionalArea/Modal/PublicationsModal/PublicationsModal.tsx b/src/components/FunctionalArea/Modal/PublicationsModal/PublicationsModal.tsx index 77b4f737a1ca5f40a55da59c4fc52a120e76140e..627fc36f6ef46d0eb525089c3bde854ee485027e 100644 --- a/src/components/FunctionalArea/Modal/PublicationsModal/PublicationsModal.tsx +++ b/src/components/FunctionalArea/Modal/PublicationsModal/PublicationsModal.tsx @@ -1,24 +1,44 @@ import Image from 'next/image'; import { useAppDispatch } from '@/redux/hooks/useAppDispatch'; import { getPublications } from '@/redux/publications/publications.thunks'; -import { useEffect } from 'react'; +import { useEffect, useMemo } from 'react'; import { publicationsListDataSelector } from '@/redux/publications/publications.selectors'; import { useAppSelector } from '@/redux/hooks/useAppSelector'; import spinnerIcon from '@/assets/vectors/icons/spinner.svg'; -import { PublicationsTable } from './PublicationsTable/PublicationsTable.component'; +import { modelsNameMapSelector } from '@/redux/models/models.selectors'; +import { FIRST_ARRAY_ELEMENT } from '@/constants/common'; +import { + PublicationsTable, + PublicationsTableData, +} from './PublicationsTable/PublicationsTable.component'; export const PublicationsModal = (): JSX.Element => { const dispatch = useAppDispatch(); const data = useAppSelector(publicationsListDataSelector); + const mapsNames = useAppSelector(modelsNameMapSelector); + + const parsedData: PublicationsTableData[] | undefined = useMemo(() => { + const dd = data?.map(item => ({ + pubmedId: item.publication.article.pubmedId, + title: item.publication.article.title, + authors: item.publication.article.authors, + journal: item.publication.article.journal, + year: item.publication.article.year, + elementsOnMap: '{link to element on map}', + submaps: mapsNames[item.elements[FIRST_ARRAY_ELEMENT].modelId], + })); + return dd || []; + }, [data, mapsNames]); useEffect(() => { dispatch(getPublications({})); }, [dispatch]); return ( - <div className="flex w-full flex-1 items-center justify-center overflow-hidden bg-white"> + <div className="flex w-full flex-1 flex-col items-center justify-center overflow-hidden bg-white"> + {/* <PublicationsSearch /> */} {data ? ( - <PublicationsTable data={data} /> + <PublicationsTable data={parsedData} /> ) : ( <Image src={spinnerIcon} diff --git a/src/components/FunctionalArea/Modal/PublicationsModal/PublicationsSearch/PublicationsSearch.component.tsx b/src/components/FunctionalArea/Modal/PublicationsModal/PublicationsSearch/PublicationsSearch.component.tsx new file mode 100644 index 0000000000000000000000000000000000000000..94e6c40449cdd8dbee522fc6463dc1de64f359ba --- /dev/null +++ b/src/components/FunctionalArea/Modal/PublicationsModal/PublicationsSearch/PublicationsSearch.component.tsx @@ -0,0 +1,46 @@ +import { ChangeEvent, useEffect, useState } from 'react'; +import lensIcon from '@/assets/vectors/icons/lens.svg'; +import { useDebounce } from '@/hooks/useDebounce'; +import { useAppDispatch } from '@/redux/hooks/useAppDispatch'; +import { getPublications } from '@/redux/publications/publications.thunks'; +import { useAppSelector } from '@/redux/hooks/useAppSelector'; +import { isLoadingSelector } from '@/redux/publications/publications.selectors'; +import Image from 'next/image'; + +export const PublicationsSearch = (): JSX.Element => { + const dispatch = useAppDispatch(); + const isLoading = useAppSelector(isLoadingSelector); + const [value, setValue] = useState(''); + const debouncedValue = useDebounce<string>(value); + + const handleChange = (event: ChangeEvent<HTMLInputElement>): void => { + setValue(event.target.value); + }; + + useEffect(() => { + dispatch(getPublications({ search: debouncedValue })); + }, [dispatch, debouncedValue]); + + return ( + <div className="mt-5"> + <input + value={value} + name="search-input" + aria-label="search-input" + data-testid="search-input" + onChange={handleChange} + disabled={isLoading} + className="h-9 w-72 rounded-[64px] border border-transparent bg-cultured px-4 py-2.5 text-xs font-medium text-font-400 outline-none hover:border-greyscale-600 focus:border-greyscale-600" + /> + <button disabled={isLoading} type="button" className="bg-transparent"> + <Image + src={lensIcon} + alt="lens icon" + height={16} + width={16} + className="absolute right-4 top-2.5" + /> + </button> + </div> + ); +}; diff --git a/src/components/FunctionalArea/Modal/PublicationsModal/PublicationsSearch/index.ts b/src/components/FunctionalArea/Modal/PublicationsModal/PublicationsSearch/index.ts new file mode 100644 index 0000000000000000000000000000000000000000..b7e9612330ef61a9b109084f1198c4e038cf4af6 --- /dev/null +++ b/src/components/FunctionalArea/Modal/PublicationsModal/PublicationsSearch/index.ts @@ -0,0 +1 @@ +export { PublicationsSearch } from './PublicationsSearch.component'; diff --git a/src/components/FunctionalArea/Modal/PublicationsModal/PublicationsTable/PublicationsTable.component.tsx b/src/components/FunctionalArea/Modal/PublicationsModal/PublicationsTable/PublicationsTable.component.tsx index 69049b5c37be91bdb4e8e118d62fb3dddebaf91a..1845bf32fdec2ac85bbf2d66facc375fd143a39f 100644 --- a/src/components/FunctionalArea/Modal/PublicationsModal/PublicationsTable/PublicationsTable.component.tsx +++ b/src/components/FunctionalArea/Modal/PublicationsModal/PublicationsTable/PublicationsTable.component.tsx @@ -5,46 +5,102 @@ import { totalSizeSelector, paginationSelector, isLoadingSelector, + sortColumnSelector, + sortOrderSelector, } from '@/redux/publications/publications.selectors'; import { getPublications } from '@/redux/publications/publications.thunks'; import { Button } from '@/shared/Button'; -import { Publication } from '@/types/models'; import { + PaginationState, createColumnHelper, flexRender, getCoreRowModel, useReactTable, + OnChangeFn, } from '@tanstack/react-table'; -import { useEffect, useState } from 'react'; +import { useState } from 'react'; +import { SortByHeader } from './SortByHeader'; +import { DEFAULT_PAGE_SIZE } from './PublicationsTable.constants'; -const columnHelper = createColumnHelper<Publication>(); +export type PublicationsTableData = { + pubmedId: string; + title: string; + authors: string[]; + journal: string; + year: number; + elementsOnMap: string; + submaps: string; +}; + +const columnHelper = createColumnHelper<PublicationsTableData>(); const columns = [ - columnHelper.accessor(row => row.publication.article.pubmedId, { header: 'Pubmed ID' }), - columnHelper.accessor(row => row.publication.article.title, { header: 'Title' }), - columnHelper.accessor(row => row.publication.article.authors, { header: 'Authors' }), - columnHelper.accessor(row => row.publication.article.journal, { header: 'Journal' }), - columnHelper.accessor(row => row.publication.article.year, { header: 'Year' }), + columnHelper.accessor(row => row.pubmedId, { + id: 'pubmedId', + header: () => <SortByHeader columnName="pubmedId">Pubmed ID</SortByHeader>, + size: 128, + }), + columnHelper.accessor(row => row.title, { + id: 'title', + header: () => <SortByHeader columnName="title">Title</SortByHeader>, + size: 288, + }), + columnHelper.accessor(row => row.authors, { + id: 'authors', + header: () => <SortByHeader columnName="authors">Authors</SortByHeader>, + size: 200, + }), + columnHelper.accessor(row => row.journal, { + id: 'journal', + header: () => <SortByHeader columnName="journal">Journal</SortByHeader>, + size: 168, + }), + columnHelper.accessor(row => row.year, { + id: 'year', + header: () => <SortByHeader columnName="year">Year</SortByHeader>, + size: 80, + }), // eslint-disable-next-line @typescript-eslint/no-unused-vars - columnHelper.accessor(row => '{link to element on map}', { header: 'Elements on map' }), + columnHelper.accessor(row => row.elementsOnMap, { header: 'Elements on map', size: 176 }), // eslint-disable-next-line @typescript-eslint/no-unused-vars - columnHelper.accessor(row => '{link to submap}', { header: 'Submaps' }), + columnHelper.accessor(row => row.submaps, { header: 'Submaps', size: 144 }), ]; type PublicationsTableProps = { - data: Publication[]; + data: PublicationsTableData[]; }; export const PublicationsTable = ({ data }: PublicationsTableProps): JSX.Element => { const dispatch = useAppDispatch(); const pagesCount = useAppSelector(totalSizeSelector); const isPublicationsLoading = useAppSelector(isLoadingSelector); + const sortColumn = useAppSelector(sortColumnSelector); + const sortOrder = useAppSelector(sortOrderSelector); const reduxPagination = useAppSelector(paginationSelector); const [pagination, setPagination] = useState(reduxPagination); - useEffect(() => { - dispatch(getPublications({ page: pagination.pageIndex, length: pagination.pageSize })); - }, [pagination, dispatch]); + + // useEffect(() => { + // dispatch(getPublications({ page: pagination.pageIndex, length: DEFAULT_PAGE_SIZE })); + // }, [pagination, dispatch]); + + const onPaginationChange: OnChangeFn<PaginationState> = updater => { + /** updating state this way is forced by table library */ + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + // @ts-ignore + const nextState = updater(pagination); + dispatch( + getPublications({ + page: nextState.pageIndex, + length: DEFAULT_PAGE_SIZE, + sortColumn, + sortOrder, + // TODO + // search: get search from redux + }), + ); + setPagination(nextState); + }; const table = useReactTable({ state: { @@ -55,21 +111,24 @@ export const PublicationsTable = ({ data }: PublicationsTableProps): JSX.Element getCoreRowModel: getCoreRowModel(), manualPagination: true, pageCount: pagesCount, - onPaginationChange: setPagination, + // onPaginationChange: setPagination, + onPaginationChange, }); return ( <div className="flex max-h-full w-full flex-col items-center justify-center bg-white p-6"> - <div className="overflow-y-auto"> - <table className="table-auto overflow-auto text-sm"> + <div className="w-full overflow-auto"> + <table className="w-full min-w-[1184px] table-auto overflow-auto text-sm"> <thead className="sticky top-0 bg-white-pearl"> {table.getHeaderGroups().map(headerGroup => ( <tr key={headerGroup.id} className="border-y "> {headerGroup.headers.map(header => ( - <th key={header.id} className="whitespace-nowrap py-2.5"> - {header.isPlaceholder - ? null - : flexRender(header.column.columnDef.header, header.getContext())} + <th + key={header.id} + className="whitespace-nowrap py-2.5" + style={{ width: header.getSize() }} + > + {flexRender(header.column.columnDef.header, header.getContext())} </th> ))} </tr> @@ -80,7 +139,13 @@ export const PublicationsTable = ({ data }: PublicationsTableProps): JSX.Element table.getRowModel().rows.map(row => ( <tr key={row.id} className="even:bg-lotion"> {row.getVisibleCells().map(cell => ( - <td key={cell.id} className="p-3"> + <td + key={cell.id} + className="p-3" + style={{ + width: cell.column.getSize(), + }} + > {flexRender(cell.column.columnDef.cell, cell.getContext())} </td> ))} diff --git a/src/components/FunctionalArea/Modal/PublicationsModal/PublicationsTable/PublicationsTable.constants.ts b/src/components/FunctionalArea/Modal/PublicationsModal/PublicationsTable/PublicationsTable.constants.ts new file mode 100644 index 0000000000000000000000000000000000000000..459723e867c1e713d2867cdf21e7784751a627dc --- /dev/null +++ b/src/components/FunctionalArea/Modal/PublicationsModal/PublicationsTable/PublicationsTable.constants.ts @@ -0,0 +1 @@ +export const DEFAULT_PAGE_SIZE = 10; diff --git a/src/components/FunctionalArea/Modal/PublicationsModal/PublicationsTable/SortByHeader/SortByHeader.component.tsx b/src/components/FunctionalArea/Modal/PublicationsModal/PublicationsTable/SortByHeader/SortByHeader.component.tsx new file mode 100644 index 0000000000000000000000000000000000000000..9b797b9970aeb0cd4c1ecb1ffb9a092a2135eb3d --- /dev/null +++ b/src/components/FunctionalArea/Modal/PublicationsModal/PublicationsTable/SortByHeader/SortByHeader.component.tsx @@ -0,0 +1,67 @@ +import { useEffect, useState } from 'react'; +import { useAppDispatch } from '@/redux/hooks/useAppDispatch'; +import { setSortOrderAndColumn } from '@/redux/publications/publications.slice'; +import { useAppSelector } from '@/redux/hooks/useAppSelector'; +import { Icon } from '@/shared/Icon'; +import { sortColumnSelector } from '@/redux/publications/publications.selectors'; +import { SortColumn, SortOrder } from '@/redux/publications/publications.types'; +import { getPublications } from '@/redux/publications/publications.thunks'; +import { DEFAULT_PAGE_SIZE } from '../PublicationsTable.constants'; + +type SortByHeaderProps = { + columnName: SortColumn; + children: React.ReactNode; +}; + +export const SortByHeader = ({ columnName, children }: SortByHeaderProps): JSX.Element => { + const activeColumn = useAppSelector(sortColumnSelector); + const [sortDirection, setSortDirection] = useState<SortOrder | undefined>(); + const dispatch = useAppDispatch(); + // if columnName is the same as the current sortColumn, then sort in the opposite direction + + const handleSortBy = (): void => { + const newSortDirection = sortDirection === 'asc' ? 'desc' : 'asc'; + setSortDirection(newSortDirection); + dispatch( + setSortOrderAndColumn({ + sortColumn: columnName, + sortOrder: newSortDirection, + }), + ); + + dispatch( + getPublications({ + page: 0, + length: DEFAULT_PAGE_SIZE, + sortColumn: columnName, + sortOrder: newSortDirection, + // TODO + // search: get search from redux + }), + ); + }; + + useEffect(() => { + if (activeColumn === columnName) { + setSortDirection('asc'); + } else { + setSortDirection(undefined); + } + }, [activeColumn, columnName]); + + return ( + <div className="flex flex-row items-center px-3"> + <button type="button" onClick={handleSortBy}> + {children} + </button> + <div className="relative ml-2 flex h-6 w-4 flex-col"> + {sortDirection !== 'desc' && ( + <Icon name="arrow" className="absolute top-0 h-4 w-4 rotate-[270deg] fill-font-500" /> + )} + {sortDirection !== 'asc' && ( + <Icon name="arrow" className="absolute bottom-0 h-4 w-4 rotate-90 fill-font-500" /> + )} + </div> + </div> + ); +}; diff --git a/src/components/FunctionalArea/Modal/PublicationsModal/PublicationsTable/SortByHeader/index.ts b/src/components/FunctionalArea/Modal/PublicationsModal/PublicationsTable/SortByHeader/index.ts new file mode 100644 index 0000000000000000000000000000000000000000..fc286d4cab14498e77a0c792d0efd88568886571 --- /dev/null +++ b/src/components/FunctionalArea/Modal/PublicationsModal/PublicationsTable/SortByHeader/index.ts @@ -0,0 +1 @@ +export { SortByHeader } from './SortByHeader.component'; diff --git a/src/hooks/useDebounce.ts b/src/hooks/useDebounce.ts new file mode 100644 index 0000000000000000000000000000000000000000..9742ab926a92a1944252b5b44e6426542bcb74aa --- /dev/null +++ b/src/hooks/useDebounce.ts @@ -0,0 +1,17 @@ +import { useEffect, useState } from 'react'; + +const DEFAULT_DELAY = 500; + +export const useDebounce = <T>(value: T, delay?: number): T => { + const [debouncedValue, setDebouncedValue] = useState<T>(value); + + useEffect(() => { + const timer = setTimeout(() => setDebouncedValue(value), delay || DEFAULT_DELAY); + + return () => { + clearTimeout(timer); + }; + }, [value, delay]); + + return debouncedValue; +}; diff --git a/src/redux/models/models.selectors.ts b/src/redux/models/models.selectors.ts index c113be0700fcc4be0e946f1e0de3241d04348fe0..7097c72427a689b0ed74bdbf83752db57cef9ab2 100644 --- a/src/redux/models/models.selectors.ts +++ b/src/redux/models/models.selectors.ts @@ -17,6 +17,13 @@ export const modelsIdsSelector = createSelector(modelsDataSelector, models => models.map(model => model.idObject), ); +export const modelsNameMapSelector = createSelector(modelsDataSelector, models => + models.reduce( + (acc, model) => ({ ...acc, [model.idObject]: model.name }), + {} as Record<number, string>, + ), +); + export const currentModelIdSelector = createSelector( currentModelSelector, model => model?.idObject || MODEL_ID_DEFAULT, diff --git a/src/redux/publications/publications.mock.ts b/src/redux/publications/publications.mock.ts index ea202e1400ad7ae20e6a7d1709f13cfd8dceb21e..11afdfb0e9fa298a939f6ed9ffd2faf2bf90c34b 100644 --- a/src/redux/publications/publications.mock.ts +++ b/src/redux/publications/publications.mock.ts @@ -4,4 +4,6 @@ export const PUBLICATIONS_INITIAL_STATE_MOCK: PublicationsState = { loading: 'idle', data: undefined, error: { name: '', message: '' }, + sortColumn: '', + sortOrder: 'asc', }; diff --git a/src/redux/publications/publications.reducers.ts b/src/redux/publications/publications.reducers.ts index ed3fc0576a068791a29e2c0e966f706ad03089e5..dc4e2e5755297e197373dab3d7d5a10b321d678a 100644 --- a/src/redux/publications/publications.reducers.ts +++ b/src/redux/publications/publications.reducers.ts @@ -1,5 +1,5 @@ -import { ActionReducerMapBuilder } from '@reduxjs/toolkit'; -import { PublicationsState } from './publications.types'; +import { ActionReducerMapBuilder, PayloadAction } from '@reduxjs/toolkit'; +import { PublicationsState, SortColumn, SortOrder } from './publications.types'; import { getPublications } from './publications.thunks'; export const getPublicationsReducer = ( @@ -17,3 +17,11 @@ export const getPublicationsReducer = ( // TODO to discuss manage state of failure }); }; + +export const setSortOrderAndColumnReducer = ( + state: PublicationsState, + action: PayloadAction<{ sortOrder: SortOrder; sortColumn: SortColumn }>, +): void => { + state.sortColumn = action.payload.sortColumn; + state.sortOrder = action.payload.sortOrder; +}; diff --git a/src/redux/publications/publications.selectors.ts b/src/redux/publications/publications.selectors.ts index 81a1b9f34cf8cc93a9b4dd6a8f8d30a09f0e8785..bb734a4392b532f4d316b78c62581922adc3a19e 100644 --- a/src/redux/publications/publications.selectors.ts +++ b/src/redux/publications/publications.selectors.ts @@ -32,3 +32,12 @@ export const isLoadingSelector = createSelector( publicationsSelector, publications => publications.loading === 'pending', ); + +export const sortColumnSelector = createSelector( + publicationsSelector, + publications => publications.sortColumn, +); +export const sortOrderSelector = createSelector( + publicationsSelector, + publications => publications.sortOrder, +); diff --git a/src/redux/publications/publications.slice.ts b/src/redux/publications/publications.slice.ts index 291893453440898dee479b8d5aaadd20b135792e..4e46818c357b4e397da9ba0fb9f7e90373f7fbd2 100644 --- a/src/redux/publications/publications.slice.ts +++ b/src/redux/publications/publications.slice.ts @@ -1,20 +1,26 @@ import { createSlice } from '@reduxjs/toolkit'; import { PublicationsState } from './publications.types'; -import { getPublicationsReducer } from './publications.reducers'; +import { getPublicationsReducer, setSortOrderAndColumnReducer } from './publications.reducers'; const initialState: PublicationsState = { data: undefined, loading: 'idle', error: { name: '', message: '' }, + sortColumn: '', + sortOrder: 'asc', }; const publicationsSlice = createSlice({ name: 'publications', initialState, - reducers: {}, + reducers: { + setSortOrderAndColumn: setSortOrderAndColumnReducer, + }, extraReducers: builder => { getPublicationsReducer(builder); }, }); +export const { setSortOrderAndColumn } = publicationsSlice.actions; + export default publicationsSlice.reducer; diff --git a/src/redux/publications/publications.types.ts b/src/redux/publications/publications.types.ts index 9a6875aa91a77377bf57c1644058743a5d6b5619..39d862cc07efdeb1eb27f6893113812d7212d468 100644 --- a/src/redux/publications/publications.types.ts +++ b/src/redux/publications/publications.types.ts @@ -1,7 +1,13 @@ import { FetchDataState } from '@/types/fetchDataState'; import { PublicationsResponse } from '@/types/models'; -export type PublicationsState = FetchDataState<PublicationsResponse>; +export type SortColumn = '' | 'pubmedId' | 'title' | 'authors' | 'journal' | 'year' | 'level'; +export type SortOrder = 'asc' | 'desc'; + +export type PublicationsState = FetchDataState<PublicationsResponse> & { + sortColumn: SortColumn; + sortOrder: SortOrder; +}; export type GetPublicationsParams = { start?: number;