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