From fd76ab815e19aa23ab38995caf709ca7db313f17 Mon Sep 17 00:00:00 2001 From: mateusz-winiarczyk <mateusz.winiarczyk@appunite.com> Date: Thu, 4 Jan 2024 18:09:05 +0100 Subject: [PATCH] feat(export): MIN-162 select annotation --- .../Map/Drawer/Drawer.component.tsx | 2 + .../Annotations.component.test.tsx | 119 ++++++++++++++++++ .../Annotations/Annotations.component.tsx | 40 ++++++ .../Drawer/ExportDrawer/Annotations/index.ts | 1 + .../CheckboxFilter.component.test.tsx | 88 +++++++++++++ .../CheckboxFilter.component.tsx | 94 ++++++++++++++ .../ExportDrawer/CheckboxFilter/index.ts | 1 + .../Elements/Elements.component.tsx | 9 ++ .../Map/Drawer/ExportDrawer/Elements/index.ts | 1 + .../ExportDrawer.component.test.tsx | 70 +++++++++++ .../ExportDrawer/ExportDrawer.component.tsx | 24 ++++ .../TabButton/TabButton.component.test.tsx | 37 ++++++ .../TabButton/TabButton.component.tsx | 22 ++++ .../Drawer/ExportDrawer/TabButton/index.ts | 1 + .../TabNavigator.component.test.tsx | 36 ++++++ .../TabNavigator/TabNavigator.component.tsx | 21 ++++ .../TabNavigator/TabNavigator.constants.ts | 5 + .../TabNavigator/TabNavigator.types.ts | 3 + .../Drawer/ExportDrawer/TabNavigator/index.ts | 1 + .../Map/Drawer/ExportDrawer/index.ts | 1 + src/models/fixtures/statisticsFixture.ts | 8 ++ src/models/statisticsSchema.ts | 7 ++ src/redux/apiPath.ts | 1 + src/redux/drawer/drawerFixture.ts | 14 +++ src/redux/root/init.thunks.ts | 4 + src/redux/root/root.fixtures.ts | 2 + src/redux/statistics/statistics.mock.ts | 8 ++ .../statistics/statistics.reducers.test.ts | 70 +++++++++++ src/redux/statistics/statistics.reducers.ts | 18 +++ src/redux/statistics/statistics.selectors.ts | 16 +++ src/redux/statistics/statistics.slice.ts | 20 +++ src/redux/statistics/statistics.thunks.ts | 17 +++ src/redux/statistics/statistics.types.ts | 4 + src/redux/store.ts | 2 + src/types/models.ts | 2 + 35 files changed, 769 insertions(+) create mode 100644 src/components/Map/Drawer/ExportDrawer/Annotations/Annotations.component.test.tsx create mode 100644 src/components/Map/Drawer/ExportDrawer/Annotations/Annotations.component.tsx create mode 100644 src/components/Map/Drawer/ExportDrawer/Annotations/index.ts create mode 100644 src/components/Map/Drawer/ExportDrawer/CheckboxFilter/CheckboxFilter.component.test.tsx create mode 100644 src/components/Map/Drawer/ExportDrawer/CheckboxFilter/CheckboxFilter.component.tsx create mode 100644 src/components/Map/Drawer/ExportDrawer/CheckboxFilter/index.ts create mode 100644 src/components/Map/Drawer/ExportDrawer/Elements/Elements.component.tsx create mode 100644 src/components/Map/Drawer/ExportDrawer/Elements/index.ts create mode 100644 src/components/Map/Drawer/ExportDrawer/ExportDrawer.component.test.tsx create mode 100644 src/components/Map/Drawer/ExportDrawer/ExportDrawer.component.tsx create mode 100644 src/components/Map/Drawer/ExportDrawer/TabButton/TabButton.component.test.tsx create mode 100644 src/components/Map/Drawer/ExportDrawer/TabButton/TabButton.component.tsx create mode 100644 src/components/Map/Drawer/ExportDrawer/TabButton/index.ts create mode 100644 src/components/Map/Drawer/ExportDrawer/TabNavigator/TabNavigator.component.test.tsx create mode 100644 src/components/Map/Drawer/ExportDrawer/TabNavigator/TabNavigator.component.tsx create mode 100644 src/components/Map/Drawer/ExportDrawer/TabNavigator/TabNavigator.constants.ts create mode 100644 src/components/Map/Drawer/ExportDrawer/TabNavigator/TabNavigator.types.ts create mode 100644 src/components/Map/Drawer/ExportDrawer/TabNavigator/index.ts create mode 100644 src/components/Map/Drawer/ExportDrawer/index.ts create mode 100644 src/models/fixtures/statisticsFixture.ts create mode 100644 src/models/statisticsSchema.ts create mode 100644 src/redux/statistics/statistics.mock.ts create mode 100644 src/redux/statistics/statistics.reducers.test.ts create mode 100644 src/redux/statistics/statistics.reducers.ts create mode 100644 src/redux/statistics/statistics.selectors.ts create mode 100644 src/redux/statistics/statistics.slice.ts create mode 100644 src/redux/statistics/statistics.thunks.ts create mode 100644 src/redux/statistics/statistics.types.ts diff --git a/src/components/Map/Drawer/Drawer.component.tsx b/src/components/Map/Drawer/Drawer.component.tsx index ace83985..b55022ff 100644 --- a/src/components/Map/Drawer/Drawer.component.tsx +++ b/src/components/Map/Drawer/Drawer.component.tsx @@ -7,6 +7,7 @@ import { SearchDrawerWrapper as SearchDrawerContent } from './SearchDrawerWrappe import { SubmapsDrawer } from './SubmapsDrawer'; import { OverlaysDrawer } from './OverlaysDrawer'; import { BioEntityDrawer } from './BioEntityDrawer/BioEntityDrawer.component'; +import { ExportDrawer } from './ExportDrawer'; export const Drawer = (): JSX.Element => { const { isOpen, drawerName } = useAppSelector(drawerSelector); @@ -24,6 +25,7 @@ export const Drawer = (): JSX.Element => { {isOpen && drawerName === 'reaction' && <ReactionDrawer />} {isOpen && drawerName === 'overlays' && <OverlaysDrawer />} {isOpen && drawerName === 'bio-entity' && <BioEntityDrawer />} + {isOpen && drawerName === 'export' && <ExportDrawer />} </div> ); }; diff --git a/src/components/Map/Drawer/ExportDrawer/Annotations/Annotations.component.test.tsx b/src/components/Map/Drawer/ExportDrawer/Annotations/Annotations.component.test.tsx new file mode 100644 index 00000000..df05c8e0 --- /dev/null +++ b/src/components/Map/Drawer/ExportDrawer/Annotations/Annotations.component.test.tsx @@ -0,0 +1,119 @@ +import { render, screen, waitFor } from '@testing-library/react'; +import { + InitialStoreState, + getReduxWrapperWithStore, +} from '@/utils/testing/getReduxWrapperWithStore'; +import { StoreType } from '@/redux/store'; +import { statisticsFixture } from '@/models/fixtures/statisticsFixture'; +import { act } from 'react-dom/test-utils'; +import { Annotations } from './Annotations.component'; + +const renderComponent = (initialStoreState: InitialStoreState = {}): { store: StoreType } => { + const { Wrapper, store } = getReduxWrapperWithStore(initialStoreState); + + return ( + render( + <Wrapper> + <Annotations /> + </Wrapper>, + ), + { + store, + } + ); +}; + +describe('Annotations - component', () => { + it('should display annotations checkboxes when fetching data is successful', async () => { + renderComponent({ + statistics: { + data: { + ...statisticsFixture, + elementAnnotations: { + compartment: 1, + pathway: 0, + }, + }, + loading: 'succeeded', + error: { + message: '', + name: '', + }, + }, + }); + const navigationButton = screen.getByTestId('accordion-item-button'); + + act(() => { + navigationButton.click(); + }); + + expect(screen.getByText('Select annotations')).toBeInTheDocument(); + + await waitFor(() => { + expect(screen.getByTestId('checkbox-filter')).toBeInTheDocument(); + expect(screen.getByLabelText('compartment')).toBeInTheDocument(); + expect(screen.getByLabelText('search-input')).toBeInTheDocument(); + }); + }); + it('should not display annotations checkboxes when fetching data fails', async () => { + renderComponent({ + statistics: { + data: undefined, + loading: 'failed', + error: { + message: '', + name: '', + }, + }, + }); + expect(screen.getByText('Select annotations')).toBeInTheDocument(); + const navigationButton = screen.getByTestId('accordion-item-button'); + act(() => { + navigationButton.click(); + }); + + expect(screen.queryByTestId('checkbox-filter')).not.toBeInTheDocument(); + }); + it('should not display annotations checkboxes when fetched data is empty object', async () => { + renderComponent({ + statistics: { + data: { + ...statisticsFixture, + elementAnnotations: {}, + }, + loading: 'failed', + error: { + message: '', + name: '', + }, + }, + }); + expect(screen.getByText('Select annotations')).toBeInTheDocument(); + const navigationButton = screen.getByTestId('accordion-item-button'); + act(() => { + navigationButton.click(); + }); + + expect(screen.queryByTestId('checkbox-filter')).not.toBeInTheDocument(); + }); + + it('should display loading message when fetching data is pending', async () => { + renderComponent({ + statistics: { + data: undefined, + loading: 'pending', + error: { + message: '', + name: '', + }, + }, + }); + expect(screen.getByText('Select annotations')).toBeInTheDocument(); + const navigationButton = screen.getByTestId('accordion-item-button'); + act(() => { + navigationButton.click(); + }); + + expect(screen.getByText('Loading...')).toBeInTheDocument(); + }); +}); diff --git a/src/components/Map/Drawer/ExportDrawer/Annotations/Annotations.component.tsx b/src/components/Map/Drawer/ExportDrawer/Annotations/Annotations.component.tsx new file mode 100644 index 00000000..a68fd389 --- /dev/null +++ b/src/components/Map/Drawer/ExportDrawer/Annotations/Annotations.component.tsx @@ -0,0 +1,40 @@ +/* eslint-disable no-magic-numbers */ +import { + Accordion, + AccordionItem, + AccordionItemButton, + AccordionItemHeading, + AccordionItemPanel, +} from '@/shared/Accordion'; +import { useAppSelector } from '@/redux/hooks/useAppSelector'; +import { + elementAnnotationsSelector, + loadingStatisticsSelector, +} from '@/redux/statistics/statistics.selectors'; +import { CheckboxFilter } from '../CheckboxFilter'; + +export const Annotations = (): React.ReactNode => { + const loadingStatistics = useAppSelector(loadingStatisticsSelector); + const elementAnnotations = useAppSelector(elementAnnotationsSelector); + const isPending = loadingStatistics === 'pending'; + + const mappedElementAnnotations = elementAnnotations + ? Object.keys(elementAnnotations)?.map(el => ({ id: el, label: el })) + : []; + + return ( + <Accordion allowZeroExpanded> + <AccordionItem> + <AccordionItemHeading> + <AccordionItemButton>Select annotations</AccordionItemButton> + </AccordionItemHeading> + <AccordionItemPanel> + {isPending && <p>Loading...</p>} + {!isPending && mappedElementAnnotations && mappedElementAnnotations.length > 0 && ( + <CheckboxFilter options={mappedElementAnnotations} /> + )} + </AccordionItemPanel> + </AccordionItem> + </Accordion> + ); +}; diff --git a/src/components/Map/Drawer/ExportDrawer/Annotations/index.ts b/src/components/Map/Drawer/ExportDrawer/Annotations/index.ts new file mode 100644 index 00000000..3b82aaf7 --- /dev/null +++ b/src/components/Map/Drawer/ExportDrawer/Annotations/index.ts @@ -0,0 +1 @@ +export { Annotations } from './Annotations.component'; diff --git a/src/components/Map/Drawer/ExportDrawer/CheckboxFilter/CheckboxFilter.component.test.tsx b/src/components/Map/Drawer/ExportDrawer/CheckboxFilter/CheckboxFilter.component.test.tsx new file mode 100644 index 00000000..50ad4079 --- /dev/null +++ b/src/components/Map/Drawer/ExportDrawer/CheckboxFilter/CheckboxFilter.component.test.tsx @@ -0,0 +1,88 @@ +import React from 'react'; +import { render, screen, fireEvent } from '@testing-library/react'; + +import { CheckboxFilter } from './CheckboxFilter.component'; + +const options = [ + { id: '1', label: 'Option 1' }, + { id: '2', label: 'Option 2' }, + { id: '3', label: 'Option 3' }, +]; + +describe('CheckboxFilter - component', () => { + it('should render CheckboxFilter properly', () => { + render(<CheckboxFilter options={options} />); + expect(screen.getByTestId('search')).toBeInTheDocument(); + }); + + it('should filter options based on search term', async () => { + render(<CheckboxFilter options={options} />); + const searchInput = screen.getByLabelText('search-input'); + + fireEvent.change(searchInput, { target: { value: `Option 1` } }); + + expect(screen.getByLabelText('Option 1')).toBeInTheDocument(); + expect(screen.queryByText('Option 2')).not.toBeInTheDocument(); + expect(screen.queryByText('Option 3')).not.toBeInTheDocument(); + }); + + it('should handle checkbox value change', async () => { + const onCheckedChange = jest.fn(); + render(<CheckboxFilter options={options} onCheckedChange={onCheckedChange} />); + const checkbox = screen.getByLabelText('Option 1'); + + fireEvent.click(checkbox); + + expect(onCheckedChange).toHaveBeenCalledWith([{ id: '1', label: 'Option 1' }]); + }); + + it('should call onFilterChange when searching new term', async () => { + const onFilterChange = jest.fn(); + render(<CheckboxFilter options={options} onFilterChange={onFilterChange} />); + const searchInput = screen.getByLabelText('search-input'); + + fireEvent.change(searchInput, { target: { value: 'Option 1' } }); + + expect(onFilterChange).toHaveBeenCalledWith([{ id: '1', label: 'Option 1' }]); + }); + it('should display message when no elements are found', async () => { + render(<CheckboxFilter options={options} />); + const searchInput = screen.getByLabelText('search-input'); + + fireEvent.change(searchInput, { target: { value: 'Nonexistent Option' } }); + + expect(screen.getByText('No matching elements found.')).toBeInTheDocument(); + }); + it('should display message when options are empty', () => { + const onFilterChange = jest.fn(); + render(<CheckboxFilter options={[]} onFilterChange={onFilterChange} />); + + expect(screen.getByText('No matching elements found.')).toBeInTheDocument(); + }); + it('should handle multiple checkbox selection', () => { + const onCheckedChange = jest.fn(); + render(<CheckboxFilter options={options} onCheckedChange={onCheckedChange} />); + + const checkbox1 = screen.getByLabelText('Option 1'); + const checkbox2 = screen.getByLabelText('Option 2'); + + fireEvent.click(checkbox1); + fireEvent.click(checkbox2); + + expect(onCheckedChange).toHaveBeenCalledWith([ + { id: '1', label: 'Option 1' }, + { id: '2', label: 'Option 2' }, + ]); + }); + it('should handle unchecking a checkbox', () => { + const onCheckedChange = jest.fn(); + render(<CheckboxFilter options={options} onCheckedChange={onCheckedChange} />); + + const checkbox = screen.getByLabelText('Option 1'); + + fireEvent.click(checkbox); // Check + fireEvent.click(checkbox); // Uncheck + + expect(onCheckedChange).toHaveBeenCalledWith([]); + }); +}); diff --git a/src/components/Map/Drawer/ExportDrawer/CheckboxFilter/CheckboxFilter.component.tsx b/src/components/Map/Drawer/ExportDrawer/CheckboxFilter/CheckboxFilter.component.tsx new file mode 100644 index 00000000..a44328e2 --- /dev/null +++ b/src/components/Map/Drawer/ExportDrawer/CheckboxFilter/CheckboxFilter.component.tsx @@ -0,0 +1,94 @@ +/* eslint-disable no-magic-numbers */ +import Image from 'next/image'; +import React, { useEffect, useState } from 'react'; +import lensIcon from '@/assets/vectors/icons/lens.svg'; + +type CheckboxItem = { id: string; label: string }; + +type CheckboxFilterProps = { + options: CheckboxItem[]; + onFilterChange?: (filteredItems: CheckboxItem[]) => void; + onCheckedChange?: (filteredItems: CheckboxItem[]) => void; +}; + +export const CheckboxFilter = ({ + options, + onFilterChange, + onCheckedChange, +}: CheckboxFilterProps): React.ReactNode => { + const [searchTerm, setSearchTerm] = useState(''); + const [filteredOptions, setFilteredOptions] = useState<CheckboxItem[]>(options); + const [checkedCheckboxes, setCheckedCheckboxes] = useState<CheckboxItem[]>([]); + + const filterOptions = (term: string): void => { + const filteredItems = options.filter(item => + item.label.toLowerCase().includes(term.toLowerCase()), + ); + + setFilteredOptions(filteredItems); + onFilterChange?.(filteredItems); + }; + + const handleSearchTermChange = (e: React.ChangeEvent<HTMLInputElement>): void => { + const newSearchTerm = e.target.value; + setSearchTerm(newSearchTerm); + filterOptions(newSearchTerm); + }; + + const handleCheckboxChange = (option: CheckboxItem): void => { + const newCheckedCheckboxes = checkedCheckboxes.includes(option) + ? checkedCheckboxes.filter(item => item !== option) + : [...checkedCheckboxes, option]; + + setCheckedCheckboxes(newCheckedCheckboxes); + onCheckedChange?.(newCheckedCheckboxes); + }; + + useEffect(() => { + setFilteredOptions(options); + }, [options]); + + return ( + <div className="relative" data-testid="checkbox-filter"> + <div className="relative" data-testid="search"> + <input + name="search-input" + aria-label="search-input" + value={searchTerm} + onChange={handleSearchTermChange} + placeholder="Search..." + className="h-9 w-full 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" + /> + + <Image + src={lensIcon} + alt="lens icon" + height={16} + width={16} + className="absolute right-4 top-2.5" + /> + </div> + <div className="my-6 max-h-[250px] overflow-y-auto py-2.5 pr-2.5 "> + {filteredOptions.length === 0 ? ( + <p className="w-full text-sm text-font-400">No matching elements found.</p> + ) : ( + <ul className="columns-2 gap-8 "> + {filteredOptions.map(option => ( + <li key={option.id} className="mb-5 flex items-center gap-x-2"> + <input + type="checkbox" + id={option.id} + className=" h-4 w-4 shrink-0 accent-primary-500" + onChange={(): void => handleCheckboxChange(option)} + /> + <label htmlFor={option.id} className="break-all text-sm"> + {option.label} + </label> + </li> + ))} + </ul> + )} + </div> + </div> + ); +}; diff --git a/src/components/Map/Drawer/ExportDrawer/CheckboxFilter/index.ts b/src/components/Map/Drawer/ExportDrawer/CheckboxFilter/index.ts new file mode 100644 index 00000000..45a47c9f --- /dev/null +++ b/src/components/Map/Drawer/ExportDrawer/CheckboxFilter/index.ts @@ -0,0 +1 @@ +export { CheckboxFilter } from './CheckboxFilter.component'; diff --git a/src/components/Map/Drawer/ExportDrawer/Elements/Elements.component.tsx b/src/components/Map/Drawer/ExportDrawer/Elements/Elements.component.tsx new file mode 100644 index 00000000..066ba3b5 --- /dev/null +++ b/src/components/Map/Drawer/ExportDrawer/Elements/Elements.component.tsx @@ -0,0 +1,9 @@ +import { Annotations } from '../Annotations'; + +export const Elements = (): React.ReactNode => { + return ( + <div data-testid="elements-tab"> + <Annotations /> + </div> + ); +}; diff --git a/src/components/Map/Drawer/ExportDrawer/Elements/index.ts b/src/components/Map/Drawer/ExportDrawer/Elements/index.ts new file mode 100644 index 00000000..4a0d339a --- /dev/null +++ b/src/components/Map/Drawer/ExportDrawer/Elements/index.ts @@ -0,0 +1 @@ +export { Elements } from './Elements.component'; diff --git a/src/components/Map/Drawer/ExportDrawer/ExportDrawer.component.test.tsx b/src/components/Map/Drawer/ExportDrawer/ExportDrawer.component.test.tsx new file mode 100644 index 00000000..cec6029b --- /dev/null +++ b/src/components/Map/Drawer/ExportDrawer/ExportDrawer.component.test.tsx @@ -0,0 +1,70 @@ +import { + InitialStoreState, + getReduxWrapperWithStore, +} from '@/utils/testing/getReduxWrapperWithStore'; +import { StoreType } from '@/redux/store'; +import { render, screen } from '@testing-library/react'; +import { openedExportDrawerFixture } from '@/redux/drawer/drawerFixture'; +import { act } from 'react-dom/test-utils'; +import { ExportDrawer } from './ExportDrawer.component'; +import { TAB_NAMES } from './TabNavigator/TabNavigator.constants'; + +const renderComponent = (initialStoreState: InitialStoreState = {}): { store: StoreType } => { + const { Wrapper, store } = getReduxWrapperWithStore(initialStoreState); + + return ( + render( + <Wrapper> + <ExportDrawer /> + </Wrapper>, + ), + { + store, + } + ); +}; + +describe('ExportDrawer - component', () => { + it('should display drawer heading and tab names', () => { + renderComponent(); + + expect(screen.getByText('Export')).toBeInTheDocument(); + + Object.keys(TAB_NAMES).forEach(label => { + expect(screen.getByText(label)).toBeInTheDocument(); + }); + }); + it('should close drawer after clicking close button', () => { + const { store } = renderComponent({ + drawer: openedExportDrawerFixture, + }); + const closeButton = screen.getByRole('close-drawer-button'); + + closeButton.click(); + + const { + drawer: { isOpen }, + } = store.getState(); + + expect(isOpen).toBe(false); + }); + it('should set elements as initial tab', () => { + renderComponent(); + + expect(screen.getByTestId('elements-tab')).toBeInTheDocument(); + }); + it('should set correct tab on tab change', () => { + renderComponent(); + const currentTab = screen.getByRole('button', { current: true }); + const networkTab = screen.getByText(/network/i); + const elementsTab = screen.getByTestId('elements-tab'); + expect(currentTab).not.toBe(networkTab); + expect(screen.getByTestId('elements-tab')).toBeInTheDocument(); + + act(() => { + networkTab.click(); + }); + expect(screen.getByRole('button', { current: true })).toBe(networkTab); + expect(elementsTab).not.toBeInTheDocument(); + }); +}); diff --git a/src/components/Map/Drawer/ExportDrawer/ExportDrawer.component.tsx b/src/components/Map/Drawer/ExportDrawer/ExportDrawer.component.tsx new file mode 100644 index 00000000..068348d1 --- /dev/null +++ b/src/components/Map/Drawer/ExportDrawer/ExportDrawer.component.tsx @@ -0,0 +1,24 @@ +import { DrawerHeading } from '@/shared/DrawerHeading'; +import { useState } from 'react'; +import { TabNavigator } from './TabNavigator'; +import { Elements } from './Elements'; +import { TAB_NAMES } from './TabNavigator/TabNavigator.constants'; +import { TabNames } from './TabNavigator/TabNavigator.types'; + +export const ExportDrawer = (): React.ReactNode => { + const [activeTab, setActiveTab] = useState<TabNames>(TAB_NAMES.ELEMENTS); + + const handleTabChange = (tabName: TabNames): void => { + setActiveTab(tabName); + }; + + return ( + <div data-testid="export-drawer" className="h-full max-h-full"> + <DrawerHeading title="Export" /> + <div className="h-[calc(100%-93px)] max-h-[calc(100%-93px)] overflow-y-auto px-6"> + <TabNavigator activeTab={activeTab} onTabChange={handleTabChange} /> + {activeTab === TAB_NAMES.ELEMENTS && <Elements />} + </div> + </div> + ); +}; diff --git a/src/components/Map/Drawer/ExportDrawer/TabButton/TabButton.component.test.tsx b/src/components/Map/Drawer/ExportDrawer/TabButton/TabButton.component.test.tsx new file mode 100644 index 00000000..e4c872bd --- /dev/null +++ b/src/components/Map/Drawer/ExportDrawer/TabButton/TabButton.component.test.tsx @@ -0,0 +1,37 @@ +import { RenderResult, fireEvent, render, screen } from '@testing-library/react'; +import { TabButton } from './TabButton.component'; + +const mockHandleChangeTab = jest.fn(); + +const renderTabButton = (label: string, active = false): RenderResult => + render(<TabButton label={label} handleChangeTab={mockHandleChangeTab} active={active} />); + +describe('TabButton - component', () => { + it('should render TabButton with custom label', () => { + renderTabButton('Map'); + + expect(screen.getByText('Map')).toBeInTheDocument(); + }); + + it('should handle click event', () => { + renderTabButton('Network'); + + fireEvent.click(screen.getByText('Network')); + expect(mockHandleChangeTab).toHaveBeenCalled(); + }); + + it('should indicate active tab correctly', () => { + renderTabButton('Graphics', true); + + const currentTab = screen.getByRole('button', { current: true }); + expect(currentTab).toHaveTextContent('Graphics'); + }); + it('should indicate not active tab correctly', () => { + renderTabButton('Graphics'); + + const activeTab = screen.queryByRole('button', { current: true }); + const graphicsTab = screen.getByRole('button', { current: false }); + expect(activeTab).not.toBeInTheDocument(); + expect(graphicsTab).toHaveTextContent('Graphics'); + }); +}); diff --git a/src/components/Map/Drawer/ExportDrawer/TabButton/TabButton.component.tsx b/src/components/Map/Drawer/ExportDrawer/TabButton/TabButton.component.tsx new file mode 100644 index 00000000..c30a1082 --- /dev/null +++ b/src/components/Map/Drawer/ExportDrawer/TabButton/TabButton.component.tsx @@ -0,0 +1,22 @@ +import { twMerge } from 'tailwind-merge'; + +type TabButtonProps = { + handleChangeTab: () => void; + active: boolean; + label: string; +}; + +export const TabButton = ({ handleChangeTab, active, label }: TabButtonProps): React.ReactNode => ( + <button + type="button" + className={twMerge( + 'text-sm font-normal text-[#979797]', + active && + 'relative py-2.5 font-semibold leading-6 text-cetacean-blue before:absolute before:inset-x-0 before:top-0 before:block before:h-1 before:rounded-b before:bg-primary-500 before:content-[""]', + )} + aria-current={active} + onClick={handleChangeTab} + > + {label} + </button> +); diff --git a/src/components/Map/Drawer/ExportDrawer/TabButton/index.ts b/src/components/Map/Drawer/ExportDrawer/TabButton/index.ts new file mode 100644 index 00000000..f22cacf6 --- /dev/null +++ b/src/components/Map/Drawer/ExportDrawer/TabButton/index.ts @@ -0,0 +1 @@ +export { TabButton } from './TabButton.component'; diff --git a/src/components/Map/Drawer/ExportDrawer/TabNavigator/TabNavigator.component.test.tsx b/src/components/Map/Drawer/ExportDrawer/TabNavigator/TabNavigator.component.test.tsx new file mode 100644 index 00000000..c604f80e --- /dev/null +++ b/src/components/Map/Drawer/ExportDrawer/TabNavigator/TabNavigator.component.test.tsx @@ -0,0 +1,36 @@ +import { fireEvent, render, screen } from '@testing-library/react'; +import { TAB_NAMES } from './TabNavigator.constants'; +import { TabNavigator } from './TabNavigator.component'; + +const mockOnTabChange = jest.fn(); + +describe('TabNavigator - component', () => { + beforeEach(() => { + mockOnTabChange.mockReset(); + }); + it('should render TabNavigator with correct tabs', () => { + render(<TabNavigator activeTab="elements" onTabChange={mockOnTabChange} />); + + Object.keys(TAB_NAMES).forEach(label => { + expect(screen.getByText(label)).toBeInTheDocument(); + }); + }); + + it('should change tabs correctly', () => { + render(<TabNavigator activeTab="elements" onTabChange={mockOnTabChange} />); + + fireEvent.click(screen.getByText(/network/i)); + expect(mockOnTabChange).toHaveBeenCalledWith('network'); + + fireEvent.click(screen.getByText(/graphics/i)); + expect(mockOnTabChange).toHaveBeenCalledWith('graphics'); + }); + + it('should set initial active tab', () => { + render(<TabNavigator activeTab="network" onTabChange={mockOnTabChange} />); + const currentTab = screen.getByRole('button', { current: true }); + const networkTab = screen.getByText(/network/i); + + expect(currentTab).toBe(networkTab); + }); +}); diff --git a/src/components/Map/Drawer/ExportDrawer/TabNavigator/TabNavigator.component.tsx b/src/components/Map/Drawer/ExportDrawer/TabNavigator/TabNavigator.component.tsx new file mode 100644 index 00000000..e8714166 --- /dev/null +++ b/src/components/Map/Drawer/ExportDrawer/TabNavigator/TabNavigator.component.tsx @@ -0,0 +1,21 @@ +import { TabButton } from '../TabButton'; +import { TAB_NAMES } from './TabNavigator.constants'; +import { TabNames } from './TabNavigator.types'; + +type TabNavigatorProps = { + activeTab: TabNames; + onTabChange: (tabName: TabNames) => void; +}; + +export const TabNavigator = ({ activeTab, onTabChange }: TabNavigatorProps): React.ReactNode => ( + <div className="flex gap-5"> + {Object.entries(TAB_NAMES).map(([label, tabName]) => ( + <TabButton + key={tabName} + handleChangeTab={(): void => onTabChange(tabName)} + label={label} + active={activeTab === tabName} + /> + ))} + </div> +); diff --git a/src/components/Map/Drawer/ExportDrawer/TabNavigator/TabNavigator.constants.ts b/src/components/Map/Drawer/ExportDrawer/TabNavigator/TabNavigator.constants.ts new file mode 100644 index 00000000..3eda3a54 --- /dev/null +++ b/src/components/Map/Drawer/ExportDrawer/TabNavigator/TabNavigator.constants.ts @@ -0,0 +1,5 @@ +export const TAB_NAMES = { + ELEMENTS: 'elements', + NETWORK: 'network', + GRAPHICS: 'graphics', +} as const; diff --git a/src/components/Map/Drawer/ExportDrawer/TabNavigator/TabNavigator.types.ts b/src/components/Map/Drawer/ExportDrawer/TabNavigator/TabNavigator.types.ts new file mode 100644 index 00000000..cd0ee383 --- /dev/null +++ b/src/components/Map/Drawer/ExportDrawer/TabNavigator/TabNavigator.types.ts @@ -0,0 +1,3 @@ +import { TAB_NAMES } from './TabNavigator.constants'; + +export type TabNames = (typeof TAB_NAMES)[keyof typeof TAB_NAMES]; diff --git a/src/components/Map/Drawer/ExportDrawer/TabNavigator/index.ts b/src/components/Map/Drawer/ExportDrawer/TabNavigator/index.ts new file mode 100644 index 00000000..b471dcc5 --- /dev/null +++ b/src/components/Map/Drawer/ExportDrawer/TabNavigator/index.ts @@ -0,0 +1 @@ +export { TabNavigator } from './TabNavigator.component'; diff --git a/src/components/Map/Drawer/ExportDrawer/index.ts b/src/components/Map/Drawer/ExportDrawer/index.ts new file mode 100644 index 00000000..313d407d --- /dev/null +++ b/src/components/Map/Drawer/ExportDrawer/index.ts @@ -0,0 +1 @@ +export { ExportDrawer } from './ExportDrawer.component'; diff --git a/src/models/fixtures/statisticsFixture.ts b/src/models/fixtures/statisticsFixture.ts new file mode 100644 index 00000000..92500578 --- /dev/null +++ b/src/models/fixtures/statisticsFixture.ts @@ -0,0 +1,8 @@ +import { ZOD_SEED } from '@/constants'; +// eslint-disable-next-line import/no-extraneous-dependencies +import { createFixture } from 'zod-fixture'; +import { statisticsSchema } from '../statisticsSchema'; + +export const statisticsFixture = createFixture(statisticsSchema, { + seed: ZOD_SEED, +}); diff --git a/src/models/statisticsSchema.ts b/src/models/statisticsSchema.ts new file mode 100644 index 00000000..8cb37fac --- /dev/null +++ b/src/models/statisticsSchema.ts @@ -0,0 +1,7 @@ +import { z } from 'zod'; + +export const statisticsSchema = z.object({ + elementAnnotations: z.record(z.string(), z.number()), + publications: z.number(), + reactionAnnotations: z.record(z.string(), z.number()), +}); diff --git a/src/redux/apiPath.ts b/src/redux/apiPath.ts index cc657a71..22592f28 100644 --- a/src/redux/apiPath.ts +++ b/src/redux/apiPath.ts @@ -34,4 +34,5 @@ export const apiPath = { getConfigurationOptions: (): string => 'configuration/options/', getOverlayBioEntity: ({ overlayId, modelId }: { overlayId: number; modelId: number }): string => `projects/${PROJECT_ID}/overlays/${overlayId}/models/${modelId}/bioEntities/`, + getStatisticsById: (projectId: string): string => `projects/${projectId}/statistics/`, }; diff --git a/src/redux/drawer/drawerFixture.ts b/src/redux/drawer/drawerFixture.ts index 818a545c..f9623293 100644 --- a/src/redux/drawer/drawerFixture.ts +++ b/src/redux/drawer/drawerFixture.ts @@ -69,3 +69,17 @@ export const drawerSearchChemicalsStepTwoFixture: DrawerState = { reactionDrawerState: {}, bioEntityDrawerState: {}, }; + +export const openedExportDrawerFixture: DrawerState = { + isOpen: true, + drawerName: 'export', + searchDrawerState: { + currentStep: 0, + stepType: 'none', + selectedValue: undefined, + listOfBioEnitites: [], + selectedSearchElement: '', + }, + reactionDrawerState: {}, + bioEntityDrawerState: {}, +}; diff --git a/src/redux/root/init.thunks.ts b/src/redux/root/init.thunks.ts index 4591391b..51b6564e 100644 --- a/src/redux/root/init.thunks.ts +++ b/src/redux/root/init.thunks.ts @@ -18,6 +18,7 @@ import { getSearchData } from '../search/search.thunks'; import { setPerfectMatch } from '../search/search.slice'; import { getSessionValid } from '../user/user.thunks'; import { getConfigurationOptions } from '../configuration/configuration.thunks'; +import { getStatisticsById } from '../statistics/statistics.thunks'; interface InitializeAppParams { queryData: QueryData; @@ -48,6 +49,9 @@ export const fetchInitialAppData = createAsyncThunk< // Check if auth token is valid dispatch(getSessionValid()); + // Fetch data needed for export + dispatch(getStatisticsById(PROJECT_ID)); + /** Trigger search */ if (queryData.searchValue) { dispatch(setPerfectMatch(queryData.perfectMatch)); diff --git a/src/redux/root/root.fixtures.ts b/src/redux/root/root.fixtures.ts index e2c17d64..debb1d9c 100644 --- a/src/redux/root/root.fixtures.ts +++ b/src/redux/root/root.fixtures.ts @@ -16,6 +16,7 @@ import { REACTIONS_STATE_INITIAL_MOCK } from '../reactions/reactions.mock'; import { SEARCH_STATE_INITIAL_MOCK } from '../search/search.mock'; import { RootState } from '../store'; import { USER_INITIAL_STATE_MOCK } from '../user/user.mock'; +import { STATISTICS_STATE_INITIAL_MOCK } from '../statistics/statistics.mock'; export const INITIAL_STORE_STATE_MOCK: RootState = { search: SEARCH_STATE_INITIAL_MOCK, @@ -35,4 +36,5 @@ export const INITIAL_STORE_STATE_MOCK: RootState = { contextMenu: CONTEXT_MENU_INITIAL_STATE, cookieBanner: COOKIE_BANNER_INITIAL_STATE_MOCK, user: USER_INITIAL_STATE_MOCK, + statistics: STATISTICS_STATE_INITIAL_MOCK, }; diff --git a/src/redux/statistics/statistics.mock.ts b/src/redux/statistics/statistics.mock.ts new file mode 100644 index 00000000..9c753dcd --- /dev/null +++ b/src/redux/statistics/statistics.mock.ts @@ -0,0 +1,8 @@ +import { DEFAULT_ERROR } from '@/constants/errors'; +import { StatisticsState } from './statistics.types'; + +export const STATISTICS_STATE_INITIAL_MOCK: StatisticsState = { + data: undefined, + loading: 'idle', + error: DEFAULT_ERROR, +}; diff --git a/src/redux/statistics/statistics.reducers.test.ts b/src/redux/statistics/statistics.reducers.test.ts new file mode 100644 index 00000000..af16b53b --- /dev/null +++ b/src/redux/statistics/statistics.reducers.test.ts @@ -0,0 +1,70 @@ +import { PROJECT_ID } from '@/constants'; +import { + ToolkitStoreWithSingleSlice, + createStoreInstanceUsingSliceReducer, +} from '@/utils/createStoreInstanceUsingSliceReducer'; +import { mockNetworkResponse } from '@/utils/mockNetworkResponse'; +import { HttpStatusCode } from 'axios'; +import { waitFor } from '@testing-library/react'; +import { statisticsFixture } from '@/models/fixtures/statisticsFixture'; +import { StatisticsState } from './statistics.types'; +import statisticsReducer from './statistics.slice'; +import { apiPath } from '../apiPath'; +import { getStatisticsById } from './statistics.thunks'; + +const mockedAxiosClient = mockNetworkResponse(); + +const INITIAL_STATE: StatisticsState = { + data: undefined, + loading: 'idle', + error: { name: '', message: '' }, +}; + +describe('statistics reducer', () => { + let store = {} as ToolkitStoreWithSingleSlice<StatisticsState>; + beforeEach(() => { + store = createStoreInstanceUsingSliceReducer('statistics', statisticsReducer); + }); + + it('should match initial state', () => { + const action = { type: 'unknown' }; + + expect(statisticsReducer(undefined, action)).toEqual(INITIAL_STATE); + }); + + it('should update store after successful getStatisticById query', async () => { + mockedAxiosClient + .onGet(apiPath.getStatisticsById(PROJECT_ID)) + .reply(HttpStatusCode.Ok, statisticsFixture); + + const { type } = await store.dispatch(getStatisticsById(PROJECT_ID)); + const { data, loading, error } = store.getState().statistics; + + expect(type).toBe('statistics/getStatisticsById/fulfilled'); + + waitFor(() => { + expect(loading).toEqual('pending'); + }); + + expect(loading).toEqual('succeeded'); + expect(error).toEqual({ message: '', name: '' }); + expect(data).toEqual(statisticsFixture); + }); + + it('should update store after failed getStatisticById query', async () => { + mockedAxiosClient + .onGet(apiPath.getStatisticsById(PROJECT_ID)) + .reply(HttpStatusCode.NotFound, undefined); + + const { type } = await store.dispatch(getStatisticsById(PROJECT_ID)); + const { loading } = store.getState().statistics; + + expect(type).toBe('statistics/getStatisticsById/rejected'); + + waitFor(() => { + expect(loading).toEqual('pending'); + }); + + expect(loading).toEqual('failed'); + }); +}); diff --git a/src/redux/statistics/statistics.reducers.ts b/src/redux/statistics/statistics.reducers.ts new file mode 100644 index 00000000..0829625f --- /dev/null +++ b/src/redux/statistics/statistics.reducers.ts @@ -0,0 +1,18 @@ +import { ActionReducerMapBuilder } from '@reduxjs/toolkit'; +import { StatisticsState } from './statistics.types'; +import { getStatisticsById } from './statistics.thunks'; + +export const getStatisticsByIdReducer = ( + builder: ActionReducerMapBuilder<StatisticsState>, +): void => { + builder.addCase(getStatisticsById.pending, state => { + state.loading = 'pending'; + }); + builder.addCase(getStatisticsById.fulfilled, (state, action) => { + state.data = action.payload; + state.loading = 'succeeded'; + }); + builder.addCase(getStatisticsById.rejected, state => { + state.loading = 'failed'; + }); +}; diff --git a/src/redux/statistics/statistics.selectors.ts b/src/redux/statistics/statistics.selectors.ts new file mode 100644 index 00000000..e0bb3259 --- /dev/null +++ b/src/redux/statistics/statistics.selectors.ts @@ -0,0 +1,16 @@ +import { createSelector } from '@reduxjs/toolkit'; +import { rootSelector } from '../root/root.selectors'; + +export const statisticsSelector = createSelector(rootSelector, state => state.statistics); + +export const loadingStatisticsSelector = createSelector(statisticsSelector, state => state.loading); + +export const statisticsDataSelector = createSelector( + statisticsSelector, + statistics => statistics?.data, +); + +export const elementAnnotationsSelector = createSelector( + statisticsDataSelector, + statistics => statistics?.elementAnnotations, +); diff --git a/src/redux/statistics/statistics.slice.ts b/src/redux/statistics/statistics.slice.ts new file mode 100644 index 00000000..f2cf9f80 --- /dev/null +++ b/src/redux/statistics/statistics.slice.ts @@ -0,0 +1,20 @@ +import { createSlice } from '@reduxjs/toolkit'; +import { StatisticsState } from './statistics.types'; +import { getStatisticsByIdReducer } from './statistics.reducers'; + +const initialState: StatisticsState = { + data: undefined, + loading: 'idle', + error: { name: '', message: '' }, +}; + +export const statisticsSlice = createSlice({ + name: 'statistics', + initialState, + reducers: {}, + extraReducers: builder => { + getStatisticsByIdReducer(builder); + }, +}); + +export default statisticsSlice.reducer; diff --git a/src/redux/statistics/statistics.thunks.ts b/src/redux/statistics/statistics.thunks.ts new file mode 100644 index 00000000..df5b6589 --- /dev/null +++ b/src/redux/statistics/statistics.thunks.ts @@ -0,0 +1,17 @@ +import { axiosInstance } from '@/services/api/utils/axiosInstance'; +import { Statistics } from '@/types/models'; +import { validateDataUsingZodSchema } from '@/utils/validateDataUsingZodSchema'; +import { createAsyncThunk } from '@reduxjs/toolkit'; +import { statisticsSchema } from '@/models/statisticsSchema'; +import { apiPath } from '../apiPath'; + +export const getStatisticsById = createAsyncThunk( + 'statistics/getStatisticsById', + async (id: string): Promise<Statistics | undefined> => { + const response = await axiosInstance.get<Statistics>(apiPath.getStatisticsById(id)); + + const isDataValid = validateDataUsingZodSchema(response.data, statisticsSchema); + + return isDataValid ? response.data : undefined; + }, +); diff --git a/src/redux/statistics/statistics.types.ts b/src/redux/statistics/statistics.types.ts new file mode 100644 index 00000000..077d4df1 --- /dev/null +++ b/src/redux/statistics/statistics.types.ts @@ -0,0 +1,4 @@ +import { FetchDataState } from '@/types/fetchDataState'; +import { Statistics } from '@/types/models'; + +export type StatisticsState = FetchDataState<Statistics>; diff --git a/src/redux/store.ts b/src/redux/store.ts index c0016526..45e09d27 100644 --- a/src/redux/store.ts +++ b/src/redux/store.ts @@ -23,6 +23,7 @@ import { configureStore, } from '@reduxjs/toolkit'; import { mapListenerMiddleware } from './map/middleware/map.middleware'; +import statisticsReducer from './statistics/statistics.slice'; export const reducers = { search: searchReducer, @@ -42,6 +43,7 @@ export const reducers = { user: userReducer, configuration: configurationReducer, overlayBioEntity: overlayBioEntityReducer, + statistics: statisticsReducer, }; export const middlewares = [mapListenerMiddleware.middleware]; diff --git a/src/types/models.ts b/src/types/models.ts index a1e01c3d..63d9c3a3 100644 --- a/src/types/models.ts +++ b/src/types/models.ts @@ -24,6 +24,7 @@ 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'; @@ -53,3 +54,4 @@ export type Login = z.infer<typeof loginSchema>; export type ConfigurationOption = z.infer<typeof configurationOptionSchema>; export type OverlayBioEntity = z.infer<typeof overlayBioEntitySchema>; export type Color = z.infer<typeof colorSchema>; +export type Statistics = z.infer<typeof statisticsSchema>; -- GitLab