Skip to content
Snippets Groups Projects
Commit dd0d629f authored by Tadeusz Miesiąc's avatar Tadeusz Miesiąc
Browse files

Merge branch 'feature/project-info-publications-list-submap-select' into 'development'

Feature/project info publications list submap select

See merge request !117
parents 0573eb9b 6997074a
No related branches found
No related tags found
3 merge requests!223reset the pin numbers before search results are fetch (so the results will be...,!118Feature/publications search and layout,!117Feature/project info publications list submap select
Pipeline #85339 passed
Showing
with 852 additions and 123 deletions
......@@ -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",
......@@ -2038,6 +2039,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",
......@@ -15447,6 +15479,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",
......
......@@ -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",
......
import { MODAL_INITIAL_STATE } from '@/redux/modal/modal.constants';
import { modalSelector } from '@/redux/modal/modal.selector';
import { StoreType } from '@/redux/store';
import {
InitialStoreState,
getReduxWrapperWithStore,
} from '@/utils/testing/getReduxWrapperWithStore';
import { render, screen } from '@testing-library/react';
import { Modal } from './Modal.component';
const renderComponent = (initialStore?: InitialStoreState): { store: StoreType } => {
const { Wrapper, store } = getReduxWrapperWithStore(initialStore);
return (
render(
<Wrapper>
<Modal />
</Wrapper>,
),
{
store,
}
);
};
describe('Modal - Component', () => {
describe('when modal is hidden', () => {
beforeEach(() => {
renderComponent({
modal: {
...MODAL_INITIAL_STATE,
isOpen: false,
modalTitle: 'Modal Hidden Title',
},
});
});
it('should modal have hidden class', () => {
const modalElement = screen.getByRole('modal');
expect(modalElement).toBeInTheDocument();
expect(modalElement).toHaveClass('hidden');
});
});
describe('when modal is shown', () => {
let store: StoreType;
beforeEach(() => {
const { store: newStore } = renderComponent({
modal: {
...MODAL_INITIAL_STATE,
isOpen: true,
modalTitle: 'Modal Opened Title',
},
});
store = newStore;
});
it('should modal NOT have hidden class', () => {
const modalElement = screen.getByRole('modal');
expect(modalElement).toBeInTheDocument();
expect(modalElement).not.toHaveClass('hidden');
});
it('shows modal title', () => {
expect(screen.getByText('Modal Opened Title', { exact: false })).toBeInTheDocument();
});
it('shows modal close button', () => {
expect(screen.getByLabelText('close button')).toBeInTheDocument();
});
it('closes modal on close button click', () => {
const closeButton = screen.getByLabelText('close button');
closeButton.click();
const { isOpen } = modalSelector(store.getState());
expect(isOpen).toBeFalsy();
});
});
});
import { useAppDispatch } from '@/redux/hooks/useAppDispatch';
import { useAppSelector } from '@/redux/hooks/useAppSelector';
import { modalSelector } from '@/redux/modal/modal.selector';
import { closeModal } from '@/redux/modal/modal.slice';
import { Icon } from '@/shared/Icon';
import dynamic from 'next/dynamic';
import { twMerge } from 'tailwind-merge';
import { LoginModal } from './LoginModal';
import { MODAL_ROLE } from './Modal.constants';
import { OverviewImagesModal } from './OverviewImagesModal';
import { PublicationsModal } from './PublicationsModal';
import { ModalLayout } from './ModalLayout';
import { EditOverlayModal } from './EditOverlayModal';
const MolArtModal = dynamic(
......@@ -16,41 +13,31 @@ const MolArtModal = dynamic(
);
export const Modal = (): React.ReactNode => {
const dispatch = useAppDispatch();
const { isOpen, modalName, modalTitle } = useAppSelector(modalSelector);
const handleCloseModal = (): void => {
dispatch(closeModal());
};
const { isOpen, modalName } = useAppSelector(modalSelector);
return (
<div
className={twMerge(
'absolute left-0 top-0 z-10 h-full w-full bg-cetacean-blue/[.48]',
isOpen ? '' : 'hidden',
<>
{isOpen && modalName === 'overview-images' && (
<ModalLayout>
<OverviewImagesModal />
</ModalLayout>
)}
{isOpen && modalName === 'mol-art' && (
<ModalLayout>
<MolArtModal />
</ModalLayout>
)}
{isOpen && modalName === 'login' && (
<ModalLayout>
<LoginModal />
</ModalLayout>
)}
{isOpen && modalName === 'publications' && <PublicationsModal />}
{isOpen && modalName === 'edit-overlay' && (
<ModalLayout>
<EditOverlayModal />
</ModalLayout>
)}
role={MODAL_ROLE}
>
<div className="flex h-full w-full items-center justify-center">
<div
className={twMerge(
'flex h-5/6 w-10/12 flex-col overflow-hidden rounded-lg',
modalName === 'login' && 'h-auto w-[400px]',
modalName === 'edit-overlay' && 'h-auto w-[450px]',
)}
>
<div className="flex items-center justify-between bg-white p-[24px] text-xl">
<div>{modalTitle}</div>
<button type="button" onClick={handleCloseModal} aria-label="close button">
<Icon name="close" className="fill-font-500" />
</button>
</div>
{isOpen && modalName === 'overview-images' && <OverviewImagesModal />}
{isOpen && modalName === 'mol-art' && <MolArtModal />}
{isOpen && modalName === 'login' && <LoginModal />}
{isOpen && modalName === 'edit-overlay' && <EditOverlayModal />}
</div>
</div>
</div>
</>
);
};
import { twMerge } from 'tailwind-merge';
import { useAppDispatch } from '@/redux/hooks/useAppDispatch';
import { useAppSelector } from '@/redux/hooks/useAppSelector';
import { modalSelector } from '@/redux/modal/modal.selector';
import { closeModal } from '@/redux/modal/modal.slice';
import { Icon } from '@/shared/Icon';
import { MODAL_ROLE } from './ModalLayout.constants';
type ModalLayoutProps = {
children: React.ReactNode;
};
export const ModalLayout = ({ children }: ModalLayoutProps): JSX.Element => {
const dispatch = useAppDispatch();
const { modalName, modalTitle } = useAppSelector(modalSelector);
const handleCloseModal = (): void => {
dispatch(closeModal());
};
return (
<div
className={twMerge('absolute left-0 top-0 z-10 h-full w-full bg-cetacean-blue/[.48]')}
role={MODAL_ROLE}
>
<div className="flex h-full w-full items-center justify-center">
<div
className={twMerge(
'flex h-5/6 w-10/12 flex-col overflow-hidden rounded-lg',
modalName === 'login' && 'h-auto w-[400px]',
)}
>
<div className="flex items-center justify-between bg-white p-[24px] text-xl">
<div>{modalTitle}</div>
<button type="button" onClick={handleCloseModal} aria-label="close button">
<Icon name="close" className="fill-font-500" />
</button>
</div>
{children}
</div>
</div>
</div>
);
};
export { ModalLayout } from './ModalLayout.component';
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', () => {});
describe('submaps filter', () => {
it('should render submaps filter', () => {});
it('should have no default submap selected on init on submaps filter', () => {});
it('should display publications for selected submap', () => {});
it('should display publications related to selected submap when submap is selected', () => {});
it('should display publications related to selected submap when user searches for publications using search input', () => {});
it('should display publications related to selected submap when user sorts publications by clicking on column header', () => {});
});
});
import { useAppDispatch } from '@/redux/hooks/useAppDispatch';
import { getPublications } from '@/redux/publications/publications.thunks';
import { useEffect, useMemo } from 'react';
import { publicationsListDataSelector } from '@/redux/publications/publications.selectors';
import { useAppSelector } from '@/redux/hooks/useAppSelector';
import { modelsNameMapSelector } from '@/redux/models/models.selectors';
import { FIRST_ARRAY_ELEMENT } from '@/constants/common';
import { LoadingIndicator } from '@/shared/LoadingIndicator';
import {
PublicationsTable,
PublicationsTableData,
} from './PublicationsTable/PublicationsTable.component';
import { PublicationsModalLayout } from './PublicationsModalLayout';
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(() => {
if (!data) {
dispatch(getPublications({ params: {} }));
}
}, [data, dispatch]);
return (
<PublicationsModalLayout>
<div className="flex w-full flex-1 flex-col items-center justify-center overflow-hidden bg-white">
{data ? <PublicationsTable data={parsedData} /> : <LoadingIndicator />}
</div>
</PublicationsModalLayout>
);
};
import { twMerge } from 'tailwind-merge';
import { useAppDispatch } from '@/redux/hooks/useAppDispatch';
import { useAppSelector } from '@/redux/hooks/useAppSelector';
import { closeModal } from '@/redux/modal/modal.slice';
import { Icon } from '@/shared/Icon';
import { filteredSizeSelector } from '@/redux/publications/publications.selectors';
import { MODAL_ROLE } from './PublicationsModalLayout.constants';
import { PublicationsSearch } from '../PublicationsSearch';
type ModalLayoutProps = {
children: React.ReactNode;
};
export const PublicationsModalLayout = ({ children }: ModalLayoutProps): JSX.Element => {
const dispatch = useAppDispatch();
const numberOfPublications = useAppSelector(filteredSizeSelector);
const handleCloseModal = (): void => {
dispatch(closeModal());
};
return (
<div
className={twMerge('absolute left-0 top-0 z-10 h-full w-full bg-cetacean-blue/[.48]')}
role={MODAL_ROLE}
>
<div className="flex h-full w-full items-center justify-center">
<div className={twMerge('flex h-5/6 w-10/12 flex-col overflow-hidden rounded-lg')}>
<div className="flex items-center justify-between bg-white p-[24px] text-xl">
<div className="font-semibold">
<div>Publications ({numberOfPublications} results)</div>
</div>
<div className="flex flex-row flex-nowrap items-center">
<PublicationsSearch />
<button type="button" onClick={handleCloseModal} aria-label="close button">
<Icon name="close" className="fill-font-500" />
</button>
</div>
</div>
{children}
</div>
</div>
</div>
);
};
export const MODAL_ROLE = 'modal';
export { PublicationsModalLayout } from './PublicationsModalLayout.component';
import { ChangeEvent, useCallback, useEffect, useState } from 'react';
import lensIcon from '@/assets/vectors/icons/lens.svg';
import { useDebounce } from 'use-debounce';
import { useAppDispatch } from '@/redux/hooks/useAppDispatch';
import { getPublications } from '@/redux/publications/publications.thunks';
import { useAppSelector } from '@/redux/hooks/useAppSelector';
import {
isLoadingSelector,
selectedModelIdSelector,
sortColumnSelector,
sortOrderSelector,
} from '@/redux/publications/publications.selectors';
import Image from 'next/image';
import { setPublicationSearchValue } from '@/redux/publications/publications.slice';
import { DEFAULT_PAGE_SIZE } from '../PublicationsTable/PublicationsTable.constants';
const DEFAULT_DELAY = 500;
export const PublicationsSearch = (): JSX.Element => {
const dispatch = useAppDispatch();
const isLoading = useAppSelector(isLoadingSelector);
const [value, setValue] = useState('');
const [debouncedValue] = useDebounce<string>(value, DEFAULT_DELAY);
const sortColumn = useAppSelector(sortColumnSelector);
const sortOrder = useAppSelector(sortOrderSelector);
const selectedId = useAppSelector(selectedModelIdSelector);
const handleChange = (event: ChangeEvent<HTMLInputElement>): void => {
setValue(event.target.value);
};
const handleSearch = useCallback((): void => {
dispatch(
getPublications({
params: {
page: 0,
length: DEFAULT_PAGE_SIZE,
sortColumn,
sortOrder,
search: debouncedValue,
},
modelId: selectedId,
}),
);
}, [debouncedValue, dispatch, selectedId, sortColumn, sortOrder]);
useEffect(() => {
dispatch(setPublicationSearchValue(debouncedValue));
handleSearch();
}, [debouncedValue, dispatch, handleSearch]);
return (
<div className="relative mr-4">
<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>
);
};
export { PublicationsSearch } from './PublicationsSearch.component';
import { useSelect } from 'downshift';
import { twMerge } from 'tailwind-merge';
import { useAppDispatch } from '@/redux/hooks/useAppDispatch';
import { useAppSelector } from '@/redux/hooks/useAppSelector';
import { modelsIdsAndNamesSelector } from '@/redux/models/models.selectors';
import {
searchValueSelector,
sortColumnSelector,
sortOrderSelector,
} from '@/redux/publications/publications.selectors';
import { getPublications } from '@/redux/publications/publications.thunks';
import { setSelectedModelId } from '@/redux/publications/publications.slice';
import { Icon } from '@/shared/Icon';
import { DEFAULT_PAGE_SIZE } from '../PublicationsTable.constants';
export const FilterBySubmapHeader = (): JSX.Element => {
const dispatch = useAppDispatch();
const models = useAppSelector(modelsIdsAndNamesSelector);
const sortColumn = useAppSelector(sortColumnSelector);
const sortOrder = useAppSelector(sortOrderSelector);
const searchValue = useAppSelector(searchValueSelector);
const handleChange = (modelId: number | undefined): void => {
const newModelId = modelId ? String(modelId) : undefined;
dispatch(setSelectedModelId(newModelId));
dispatch(
getPublications({
params: {
page: 0,
length: DEFAULT_PAGE_SIZE,
sortColumn,
sortOrder,
search: searchValue,
},
modelId: newModelId,
}),
);
};
const {
isOpen,
selectedItem,
getToggleButtonProps,
getMenuProps,
highlightedIndex,
getItemProps,
} = useSelect({
items: models,
initialSelectedItem: null,
onSelectedItemChange: ({ selectedItem: newSelectedItem }) => handleChange(newSelectedItem?.id),
});
return (
<div className="relative">
<div
className="flex cursor-pointer flex-row items-center justify-between bg-white px-3"
{...getToggleButtonProps()}
data-testid="background-dropdown-button"
>
<span data-testid="background-dropdown-button-name" className="truncate font-semibold">
{selectedItem?.name || 'Submaps'}
</span>
<Icon
name="chevron-down"
className={twMerge('arrow-button h-6 w-6 fill-primary-500', isOpen && 'rotate-180')}
/>
</div>
<ul
{...getMenuProps()}
className={twMerge(
'absolute top-full mt-2 h-60 w-full overflow-y-scroll bg-white shadow-lg',
!isOpen && 'hidden',
)}
data-testid="background-dropdown-list"
>
{isOpen &&
models.map((item, index) => (
<li
key={item.id}
{...getItemProps({ item, index })}
className={twMerge(
'w-full truncate border-t text-left font-normal',
highlightedIndex === index && 'text-primary-500',
selectedItem?.id === item.id && 'font-bold',
'flex flex-col px-4 py-2 shadow-sm',
)}
>
{item.name}
</li>
))}
</ul>
</div>
);
};
/* eslint-disable no-magic-numbers */
import { Provider } from 'react-redux';
import configureMockStore from 'redux-mock-store';
import thunk from 'redux-thunk';
import { act } from 'react-dom/test-utils';
import { mockNetworkResponse } from '@/utils/mockNetworkResponse';
import {
InitialStoreState,
getReduxWrapperWithStore,
} from '@/utils/testing/getReduxWrapperWithStore';
import { StoreType } from '@/redux/store';
import { render, screen } from '@testing-library/react';
import { PUBLICATIONS_INITIAL_STATE_MOCK } from '@/redux/publications/publications.mock';
import { DEFAULT_ERROR } from '@/constants/errors';
import { MODELS_MOCK } from '@/models/mocks/modelsMock';
import { FIRST_ARRAY_ELEMENT, ZERO } from '@/constants/common';
import { FilterBySubmapHeader } from './FilterBySubmapHeader.component';
mockNetworkResponse();
const renderComponent = (initialStoreState: InitialStoreState = {}): { store: StoreType } => {
const { Wrapper, store } = getReduxWrapperWithStore(initialStoreState);
return (
render(
<Wrapper>
<FilterBySubmapHeader />
</Wrapper>,
),
{
store,
}
);
};
describe('FilterBySubmapHeader - component', () => {
describe('render', () => {
it('should render closed dropdown', async () => {
renderComponent({
publications: PUBLICATIONS_INITIAL_STATE_MOCK,
models: { data: MODELS_MOCK, loading: 'idle', error: DEFAULT_ERROR },
});
const ulTag = await screen.findByTestId('background-dropdown-list');
const listItems = screen.queryAllByRole('listitem');
expect(listItems).toHaveLength(ZERO);
expect(ulTag).toBeInTheDocument();
expect(ulTag).toHaveClass('hidden');
});
it('should render available submaps on dropdown open', async () => {
renderComponent({
publications: PUBLICATIONS_INITIAL_STATE_MOCK,
models: { data: MODELS_MOCK, loading: 'idle', error: DEFAULT_ERROR },
});
const button = await screen.findByTestId('background-dropdown-button');
await act(() => {
button.click();
});
const ulTag = await screen.findByTestId('background-dropdown-list');
expect(ulTag).not.toHaveClass('hidden');
const listItems = await screen.findAllByRole('option');
expect(listItems).toHaveLength(MODELS_MOCK.length);
expect(listItems[FIRST_ARRAY_ELEMENT]).toHaveTextContent(
MODELS_MOCK[FIRST_ARRAY_ELEMENT].name,
);
});
it('should display no value selected initially', () => {
renderComponent({
publications: PUBLICATIONS_INITIAL_STATE_MOCK,
models: { data: MODELS_MOCK, loading: 'idle', error: DEFAULT_ERROR },
});
const button = screen.getByTestId('background-dropdown-button-name');
expect(button).toHaveTextContent('Submaps');
});
it('should display selected submap name in toggle button', async () => {
renderComponent({
publications: PUBLICATIONS_INITIAL_STATE_MOCK,
models: { data: MODELS_MOCK, loading: 'idle', error: DEFAULT_ERROR },
});
const button = await screen.findByTestId('background-dropdown-button');
await act(() => {
button.click();
});
const listItems = screen.getAllByRole('option');
const selectedItem = listItems[FIRST_ARRAY_ELEMENT];
await act(() => {
selectedItem.click();
});
const buttonName = screen.getByTestId('background-dropdown-button-name');
expect(buttonName).toHaveTextContent(MODELS_MOCK[FIRST_ARRAY_ELEMENT].name);
});
});
describe('on submap selection', () => {
it('should dispatch setSelectedModelId action', async () => {
const mockStore = configureMockStore([thunk]);
const store = mockStore({
publications: PUBLICATIONS_INITIAL_STATE_MOCK,
models: { data: MODELS_MOCK, loading: 'idle', error: DEFAULT_ERROR },
});
render(
<Provider store={store}>
<FilterBySubmapHeader />
</Provider>,
);
const button = await screen.findByTestId('background-dropdown-button');
await act(() => {
button.click();
});
const listItems = screen.getAllByRole('option');
const selectedItem = listItems[FIRST_ARRAY_ELEMENT];
await act(() => {
selectedItem.click();
});
const actions = store.getActions();
expect(actions).toHaveLength(3); // 2 - getPublications (pending, fulfilled), 1 - setSelectedModelId
expect(actions[FIRST_ARRAY_ELEMENT].type).toBe('publications/setSelectedModelId');
const selectedModelId = actions[FIRST_ARRAY_ELEMENT].payload;
expect(selectedModelId).toBe(String(MODELS_MOCK[FIRST_ARRAY_ELEMENT].idObject));
});
it('should dispatch getPublications action', async () => {
const mockStore = configureMockStore([thunk]);
const store = mockStore({
publications: PUBLICATIONS_INITIAL_STATE_MOCK,
models: { data: MODELS_MOCK, loading: 'idle', error: DEFAULT_ERROR },
});
render(
<Provider store={store}>
<FilterBySubmapHeader />
</Provider>,
);
const button = await screen.findByTestId('background-dropdown-button');
await act(() => {
button.click();
});
const listItems = screen.getAllByRole('option');
const selectedItem = listItems[FIRST_ARRAY_ELEMENT];
await act(() => {
selectedItem.click();
});
const actions = store.getActions();
expect(actions).toHaveLength(3); // 2 - getPublications (pending, fulfilled), 1 - setSelectedModelId
expect(actions[1].type).toBe('publications/getPublications/pending');
});
});
});
import { ONE, ZERO } from '@/constants/common';
import { useAppDispatch } from '@/redux/hooks/useAppDispatch';
import { useAppSelector } from '@/redux/hooks/useAppSelector';
import {
totalSizeSelector,
paginationSelector,
isLoadingSelector,
sortColumnSelector,
sortOrderSelector,
selectedModelIdSelector,
searchValueSelector,
} from '@/redux/publications/publications.selectors';
import { getPublications } from '@/redux/publications/publications.thunks';
import { Button } from '@/shared/Button';
import {
PaginationState,
createColumnHelper,
flexRender,
getCoreRowModel,
useReactTable,
OnChangeFn,
} from '@tanstack/react-table';
import { useState } from 'react';
import { SortByHeader } from './SortByHeader';
import { DEFAULT_PAGE_SIZE } from './PublicationsTable.constants';
import { FilterBySubmapHeader } from './FilterBySubmapHeader/FilterBySubmapHeader.component';
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.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 => row.elementsOnMap, { header: 'Elements on map', size: 176 }),
// eslint-disable-next-line @typescript-eslint/no-unused-vars
columnHelper.accessor(row => row.submaps, {
id: 'submaps',
header: () => <FilterBySubmapHeader />,
size: 144,
}),
];
type PublicationsTableProps = {
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 selectedId = useAppSelector(selectedModelIdSelector);
const searchValue = useAppSelector(searchValueSelector);
const reduxPagination = useAppSelector(paginationSelector);
const [pagination, setPagination] = useState(reduxPagination);
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({
params: {
page: nextState.pageIndex,
length: DEFAULT_PAGE_SIZE,
sortColumn,
sortOrder,
search: searchValue,
},
modelId: selectedId,
}),
);
setPagination(nextState);
};
const table = useReactTable({
state: {
pagination,
},
columns,
data,
getCoreRowModel: getCoreRowModel(),
manualPagination: true,
pageCount: pagesCount,
// onPaginationChange: setPagination,
onPaginationChange,
});
return (
<div className="flex max-h-full w-full flex-col items-center justify-center bg-white p-6">
<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"
style={{ width: header.getSize() }}
>
{flexRender(header.column.columnDef.header, header.getContext())}
</th>
))}
</tr>
))}
</thead>
<tbody>
{data &&
table.getRowModel().rows.map(row => (
<tr key={row.id} className="even:bg-lotion">
{row.getVisibleCells().map(cell => (
<td
key={cell.id}
className="p-3"
style={{
width: cell.column.getSize(),
}}
>
{flexRender(cell.column.columnDef.cell, cell.getContext())}
</td>
))}
</tr>
))}
</tbody>
</table>
</div>
<div className="flex w-full flex-row justify-end border-t">
<div className="mt-6 flex flex-row items-center">
<Button
variantStyles="quiet"
className="text-primary-500"
onClick={() => table.setPageIndex(ZERO)}
disabled={isPublicationsLoading}
>
First page
</Button>
<Button
variantStyles="secondary"
onClick={() => table.previousPage()}
disabled={isPublicationsLoading}
>
Previous page
</Button>
<div className="mx-4 text-sm font-semibold">
Page {table.getState().pagination.pageIndex + ONE} out of {table.getPageCount()}
</div>
<Button
variantStyles="secondary"
onClick={() => table.nextPage()}
disabled={isPublicationsLoading}
>
Next page
</Button>
<Button
variantStyles="quiet"
className="text-primary-500"
onClick={() => table.setPageIndex(table.getPageCount() - ONE)}
disabled={isPublicationsLoading}
>
Last page
</Button>
</div>
</div>
</div>
);
};
export const DEFAULT_PAGE_SIZE = 10;
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 {
searchValueSelector,
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 searchValue = useAppSelector(searchValueSelector);
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({
params: {
page: 0,
length: DEFAULT_PAGE_SIZE,
sortColumn: columnName,
sortOrder: newSortDirection,
search: searchValue,
},
}),
);
};
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>
);
};
0% Loading or .
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment