From e4366cb2b60117acf75c8daaa45a761af24a5a80 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Tadeusz=20Miesi=C4=85c?= <tadeusz.miesiac@gmail.com>
Date: Mon, 29 Jan 2024 16:19:33 +0100
Subject: [PATCH] feat(publications): sorting by, sort order, search bar

---
 .../PublicationsModal/PublicationsModal.tsx   |  28 ++++-
 .../PublicationsSearch.component.tsx          |  46 ++++++++
 .../PublicationsSearch/index.ts               |   1 +
 .../PublicationsTable.component.tsx           | 109 ++++++++++++++----
 .../PublicationsTable.constants.ts            |   1 +
 .../SortByHeader/SortByHeader.component.tsx   |  67 +++++++++++
 .../PublicationsTable/SortByHeader/index.ts   |   1 +
 src/hooks/useDebounce.ts                      |  17 +++
 src/redux/models/models.selectors.ts          |   7 ++
 src/redux/publications/publications.mock.ts   |   2 +
 .../publications/publications.reducers.ts     |  12 +-
 .../publications/publications.selectors.ts    |   9 ++
 src/redux/publications/publications.slice.ts  |  10 +-
 src/redux/publications/publications.types.ts  |   8 +-
 14 files changed, 287 insertions(+), 31 deletions(-)
 create mode 100644 src/components/FunctionalArea/Modal/PublicationsModal/PublicationsSearch/PublicationsSearch.component.tsx
 create mode 100644 src/components/FunctionalArea/Modal/PublicationsModal/PublicationsSearch/index.ts
 create mode 100644 src/components/FunctionalArea/Modal/PublicationsModal/PublicationsTable/PublicationsTable.constants.ts
 create mode 100644 src/components/FunctionalArea/Modal/PublicationsModal/PublicationsTable/SortByHeader/SortByHeader.component.tsx
 create mode 100644 src/components/FunctionalArea/Modal/PublicationsModal/PublicationsTable/SortByHeader/index.ts
 create mode 100644 src/hooks/useDebounce.ts

diff --git a/src/components/FunctionalArea/Modal/PublicationsModal/PublicationsModal.tsx b/src/components/FunctionalArea/Modal/PublicationsModal/PublicationsModal.tsx
index 77b4f737..627fc36f 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 00000000..94e6c404
--- /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 00000000..b7e96123
--- /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 69049b5c..1845bf32 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 00000000..459723e8
--- /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 00000000..9b797b99
--- /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 00000000..fc286d4c
--- /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 00000000..9742ab92
--- /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 c113be07..7097c724 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 ea202e14..11afdfb0 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 ed3fc057..dc4e2e57 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 81a1b9f3..bb734a43 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 29189345..4e46818c 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 9a6875aa..39d862cc 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;
-- 
GitLab