Skip to content
Snippets Groups Projects
Commit a37d39ed authored by Adrian Orłów's avatar Adrian Orłów
Browse files

Merge branch 'development' into feat/molart-modal

parents 3d15989e 5f0d2e38
No related branches found
No related tags found
2 merge requests!223reset the pin numbers before search results are fetch (so the results will be...,!84feat: Molart modal
Pipeline #84090 failed
Showing
with 677 additions and 18 deletions
...@@ -89,7 +89,14 @@ ...@@ -89,7 +89,14 @@
"config": "./tailwind.config.ts" "config": "./tailwind.config.ts"
} }
], ],
"prettier/prettier": "error" "prettier/prettier": "error",
"jsx-a11y/label-has-associated-control": [
2,
{
"controlComponents": ["Input"],
"depth": 3
}
]
}, },
"overrides": [ "overrides": [
{ {
......
...@@ -28,6 +28,7 @@ ...@@ -28,6 +28,7 @@
"react": "18.2.0", "react": "18.2.0",
"react-accessible-accordion": "^5.0.0", "react-accessible-accordion": "^5.0.0",
"react-dom": "18.2.0", "react-dom": "18.2.0",
"react-dropzone": "^14.2.3",
"react-redux": "^8.1.2", "react-redux": "^8.1.2",
"tailwind-merge": "^1.14.0", "tailwind-merge": "^1.14.0",
"tailwindcss": "3.3.3", "tailwindcss": "3.3.3",
...@@ -3056,6 +3057,14 @@ ...@@ -3056,6 +3057,14 @@
"node": ">= 4.0.0" "node": ">= 4.0.0"
} }
}, },
"node_modules/attr-accept": {
"version": "2.2.2",
"resolved": "https://registry.npmjs.org/attr-accept/-/attr-accept-2.2.2.tgz",
"integrity": "sha512-7prDjvt9HmqiZ0cl5CRjtS84sEyhsHP2coDkaZKRKVfCDo9s7iw7ChVmar78Gu9pC4SoR/28wFu/G5JJhTnqEg==",
"engines": {
"node": ">=4"
}
},
"node_modules/autoprefixer": { "node_modules/autoprefixer": {
"version": "10.4.15", "version": "10.4.15",
"resolved": "https://registry.npmjs.org/autoprefixer/-/autoprefixer-10.4.15.tgz", "resolved": "https://registry.npmjs.org/autoprefixer/-/autoprefixer-10.4.15.tgz",
...@@ -6377,10 +6386,16 @@ ...@@ -6377,10 +6386,16 @@
"node": "^10.12.0 || >=12.0.0" "node": "^10.12.0 || >=12.0.0"
} }
}, },
"node_modules/file-saver": { "node_modules/file-selector": {
"version": "1.3.8", "version": "0.6.0",
"resolved": "https://registry.npmjs.org/file-saver/-/file-saver-1.3.8.tgz", "resolved": "https://registry.npmjs.org/file-selector/-/file-selector-0.6.0.tgz",
"integrity": "sha512-spKHSBQIxxS81N/O21WmuXA2F6wppUCsutpzenOeZzOCCJ5gEfcbqJP983IrpLXzYmXnMUa6J03SubcNPdKrlg==" "integrity": "sha512-QlZ5yJC0VxHxQQsQhXvBaC7VRJ2uaxTf+Tfpu4Z/OcVQJVpZO+DGU0rkoVW5ce2SccxugvpBJoMvUs59iILYdw==",
"dependencies": {
"tslib": "^2.4.0"
},
"engines": {
"node": ">= 12"
}
}, },
"node_modules/fill-range": { "node_modules/fill-range": {
"version": "7.0.1", "version": "7.0.1",
...@@ -11519,6 +11534,22 @@ ...@@ -11519,6 +11534,22 @@
"react": "^18.2.0" "react": "^18.2.0"
} }
}, },
"node_modules/react-dropzone": {
"version": "14.2.3",
"resolved": "https://registry.npmjs.org/react-dropzone/-/react-dropzone-14.2.3.tgz",
"integrity": "sha512-O3om8I+PkFKbxCukfIR3QAGftYXDZfOE2N1mr/7qebQJHs7U+/RSL/9xomJNpRg9kM5h9soQSdf0Gc7OHF5Fug==",
"dependencies": {
"attr-accept": "^2.2.2",
"file-selector": "^0.6.0",
"prop-types": "^15.8.1"
},
"engines": {
"node": ">= 10.13"
},
"peerDependencies": {
"react": ">= 16.8 || 18.0.0"
}
},
"node_modules/react-is": { "node_modules/react-is": {
"version": "18.2.0", "version": "18.2.0",
"resolved": "https://registry.npmjs.org/react-is/-/react-is-18.2.0.tgz", "resolved": "https://registry.npmjs.org/react-is/-/react-is-18.2.0.tgz",
...@@ -16089,6 +16120,11 @@ ...@@ -16089,6 +16120,11 @@
"integrity": "sha512-+q/t7Ekv1EDY2l6Gda6LLiX14rU9TV20Wa3ofeQmwPFZbOMo9DXrLbOjFaaclkXKWidIaopwAObQDqwWtGUjqg==", "integrity": "sha512-+q/t7Ekv1EDY2l6Gda6LLiX14rU9TV20Wa3ofeQmwPFZbOMo9DXrLbOjFaaclkXKWidIaopwAObQDqwWtGUjqg==",
"dev": true "dev": true
}, },
"attr-accept": {
"version": "2.2.2",
"resolved": "https://registry.npmjs.org/attr-accept/-/attr-accept-2.2.2.tgz",
"integrity": "sha512-7prDjvt9HmqiZ0cl5CRjtS84sEyhsHP2coDkaZKRKVfCDo9s7iw7ChVmar78Gu9pC4SoR/28wFu/G5JJhTnqEg=="
},
"autoprefixer": { "autoprefixer": {
"version": "10.4.15", "version": "10.4.15",
"resolved": "https://registry.npmjs.org/autoprefixer/-/autoprefixer-10.4.15.tgz", "resolved": "https://registry.npmjs.org/autoprefixer/-/autoprefixer-10.4.15.tgz",
...@@ -18506,6 +18542,14 @@ ...@@ -18506,6 +18542,14 @@
"resolved": "https://registry.npmjs.org/file-saver/-/file-saver-1.3.8.tgz", "resolved": "https://registry.npmjs.org/file-saver/-/file-saver-1.3.8.tgz",
"integrity": "sha512-spKHSBQIxxS81N/O21WmuXA2F6wppUCsutpzenOeZzOCCJ5gEfcbqJP983IrpLXzYmXnMUa6J03SubcNPdKrlg==" "integrity": "sha512-spKHSBQIxxS81N/O21WmuXA2F6wppUCsutpzenOeZzOCCJ5gEfcbqJP983IrpLXzYmXnMUa6J03SubcNPdKrlg=="
}, },
"file-selector": {
"version": "0.6.0",
"resolved": "https://registry.npmjs.org/file-selector/-/file-selector-0.6.0.tgz",
"integrity": "sha512-QlZ5yJC0VxHxQQsQhXvBaC7VRJ2uaxTf+Tfpu4Z/OcVQJVpZO+DGU0rkoVW5ce2SccxugvpBJoMvUs59iILYdw==",
"requires": {
"tslib": "^2.4.0"
}
},
"fill-range": { "fill-range": {
"version": "7.0.1", "version": "7.0.1",
"resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.0.1.tgz", "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.0.1.tgz",
...@@ -20987,7 +21031,8 @@ ...@@ -20987,7 +21031,8 @@
"lodash": { "lodash": {
"version": "4.17.21", "version": "4.17.21",
"resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.21.tgz", "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.21.tgz",
"integrity": "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==" "integrity": "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==",
"dev": true
}, },
"lodash.camelcase": { "lodash.camelcase": {
"version": "4.3.0", "version": "4.3.0",
...@@ -22120,6 +22165,16 @@ ...@@ -22120,6 +22165,16 @@
"scheduler": "^0.23.0" "scheduler": "^0.23.0"
} }
}, },
"react-dropzone": {
"version": "14.2.3",
"resolved": "https://registry.npmjs.org/react-dropzone/-/react-dropzone-14.2.3.tgz",
"integrity": "sha512-O3om8I+PkFKbxCukfIR3QAGftYXDZfOE2N1mr/7qebQJHs7U+/RSL/9xomJNpRg9kM5h9soQSdf0Gc7OHF5Fug==",
"requires": {
"attr-accept": "^2.2.2",
"file-selector": "^0.6.0",
"prop-types": "^15.8.1"
}
},
"react-is": { "react-is": {
"version": "18.2.0", "version": "18.2.0",
"resolved": "https://registry.npmjs.org/react-is/-/react-is-18.2.0.tgz", "resolved": "https://registry.npmjs.org/react-is/-/react-is-18.2.0.tgz",
......
...@@ -10,6 +10,10 @@ global.ResizeObserver = jest.fn().mockImplementation(() => ({ ...@@ -10,6 +10,10 @@ global.ResizeObserver = jest.fn().mockImplementation(() => ({
jest.mock('next/router', () => require('next-router-mock')); jest.mock('next/router', () => require('next-router-mock'));
global.TextEncoder = jest.fn().mockImplementation(() => ({
encode: jest.fn(),
}));
const localStorageMock = (() => { const localStorageMock = (() => {
let store: { let store: {
[key: PropertyKey]: string; [key: PropertyKey]: string;
......
import { searchedBioEntityElementUniProtIdSelector } from '@/redux/bioEntity/bioEntity.selectors';
import { contextMenuSelector } from '@/redux/contextMenu/contextMenu.selector'; import { contextMenuSelector } from '@/redux/contextMenu/contextMenu.selector';
import { closeContextMenu } from '@/redux/contextMenu/contextMenu.slice';
import { useAppDispatch } from '@/redux/hooks/useAppDispatch'; import { useAppDispatch } from '@/redux/hooks/useAppDispatch';
import { useAppSelector } from '@/redux/hooks/useAppSelector'; import { useAppSelector } from '@/redux/hooks/useAppSelector';
import { openMolArtModalById } from '@/redux/modal/modal.slice';
import React from 'react'; import React from 'react';
import { twMerge } from 'tailwind-merge'; import { twMerge } from 'tailwind-merge';
import { searchedBioEntityElementUniProtIdSelector } from '@/redux/bioEntity/bioEntity.selectors';
import { openMolArtModalById } from '@/redux/modal/modal.slice';
import { closeContextMenu } from '@/redux/contextMenu/contextMenu.slice';
import { FIRST_ARRAY_ELEMENT, SECOND_ARRAY_ELEMENT } from '@/constants/common'; import { FIRST_ARRAY_ELEMENT, SECOND_ARRAY_ELEMENT } from '@/constants/common';
......
...@@ -3,6 +3,7 @@ import { useAppSelector } from '@/redux/hooks/useAppSelector'; ...@@ -3,6 +3,7 @@ import { useAppSelector } from '@/redux/hooks/useAppSelector';
import { loadingUserSelector } from '@/redux/user/user.selectors'; import { loadingUserSelector } from '@/redux/user/user.selectors';
import { login } from '@/redux/user/user.thunks'; import { login } from '@/redux/user/user.thunks';
import { Button } from '@/shared/Button'; import { Button } from '@/shared/Button';
import { Input } from '@/shared/Input';
import Link from 'next/link'; import Link from 'next/link';
import React from 'react'; import React from 'react';
...@@ -27,26 +28,26 @@ export const LoginModal: React.FC = () => { ...@@ -27,26 +28,26 @@ export const LoginModal: React.FC = () => {
<form onSubmit={handleSubmit}> <form onSubmit={handleSubmit}>
<label className="mb-5 block text-sm font-semibold" htmlFor="login"> <label className="mb-5 block text-sm font-semibold" htmlFor="login">
Login: Login:
<input <Input
type="text" type="text"
name="login" name="login"
id="login" id="login"
placeholder="Your login here.." placeholder="Your login here.."
value={credentials.login} value={credentials.login}
onChange={handleChange} onChange={handleChange}
className="mt-2.5 h-10 w-full rounded-s border border-transparent bg-cultured px-2 py-2.5 text-sm font-medium text-font-400 outline-none hover:border-greyscale-600 focus:border-greyscale-600" className="mt-2.5 text-sm font-medium text-font-400"
/> />
</label> </label>
<label className="text-sm font-semibold" htmlFor="password"> <label className="text-sm font-semibold" htmlFor="password">
Password: Password:
<input <Input
type="password" type="password"
name="password" name="password"
id="password" id="password"
placeholder="Your password here.." placeholder="Your password here.."
value={credentials.password} value={credentials.password}
onChange={handleChange} onChange={handleChange}
className="mt-2.5 h-10 w-full rounded-s border border-transparent bg-cultured px-2 py-2.5 text-sm font-medium text-font-400 outline-none hover:border-greyscale-600 focus:border-greyscale-600" className="mt-2.5 text-sm font-medium text-font-400"
/> />
</label> </label>
<div className="mb-10 text-right"> <div className="mb-10 text-right">
......
...@@ -2,6 +2,7 @@ import logoImg from '@/assets/images/logo.png'; ...@@ -2,6 +2,7 @@ import logoImg from '@/assets/images/logo.png';
import luxembourgLogoImg from '@/assets/images/luxembourg-logo.png'; import luxembourgLogoImg from '@/assets/images/luxembourg-logo.png';
import { openDrawer } from '@/redux/drawer/drawer.slice'; import { openDrawer } from '@/redux/drawer/drawer.slice';
import { useAppDispatch } from '@/redux/hooks/useAppDispatch'; import { useAppDispatch } from '@/redux/hooks/useAppDispatch';
import { openLegend } from '@/redux/legend/legend.slice';
import { IconButton } from '@/shared/IconButton'; import { IconButton } from '@/shared/IconButton';
import Image from 'next/image'; import Image from 'next/image';
...@@ -21,7 +22,7 @@ export const NavBar = (): JSX.Element => { ...@@ -21,7 +22,7 @@ export const NavBar = (): JSX.Element => {
}; };
const openDrawerLegend = (): void => { const openDrawerLegend = (): void => {
dispatch(openDrawer('legend')); dispatch(openLegend());
}; };
return ( return (
......
import { DrawerHeading } from '@/shared/DrawerHeading';
import { useAppSelector } from '@/redux/hooks/useAppSelector';
import { searchedFromMapBioEntityElement } from '@/redux/bioEntity/bioEntity.selectors';
import { ZERO } from '@/constants/common'; import { ZERO } from '@/constants/common';
import { searchedFromMapBioEntityElement } from '@/redux/bioEntity/bioEntity.selectors';
import { useAppSelector } from '@/redux/hooks/useAppSelector';
import { DrawerHeading } from '@/shared/DrawerHeading';
import { AnnotationItem } from './AnnotationItem'; import { AnnotationItem } from './AnnotationItem';
import { AssociatedSubmap } from './AssociatedSubmap'; import { AssociatedSubmap } from './AssociatedSubmap';
...@@ -26,7 +26,7 @@ export const BioEntityDrawer = (): React.ReactNode => { ...@@ -26,7 +26,7 @@ export const BioEntityDrawer = (): React.ReactNode => {
/> />
<div className="flex flex-col gap-6 p-6"> <div className="flex flex-col gap-6 p-6">
<div className="text-sm font-normal"> <div className="text-sm font-normal">
Compartment: <b className="font-semibold">{bioEntityData.compartment}</b> Compartment: <b className="font-semibold">{bioEntityData.compartmentName}</b>
</div> </div>
{bioEntityData.fullName && ( {bioEntityData.fullName && (
<div className="text-sm font-normal"> <div className="text-sm font-normal">
......
...@@ -7,6 +7,7 @@ import { SearchDrawerWrapper as SearchDrawerContent } from './SearchDrawerWrappe ...@@ -7,6 +7,7 @@ import { SearchDrawerWrapper as SearchDrawerContent } from './SearchDrawerWrappe
import { SubmapsDrawer } from './SubmapsDrawer'; import { SubmapsDrawer } from './SubmapsDrawer';
import { OverlaysDrawer } from './OverlaysDrawer'; import { OverlaysDrawer } from './OverlaysDrawer';
import { BioEntityDrawer } from './BioEntityDrawer/BioEntityDrawer.component'; import { BioEntityDrawer } from './BioEntityDrawer/BioEntityDrawer.component';
import { ExportDrawer } from './ExportDrawer';
export const Drawer = (): JSX.Element => { export const Drawer = (): JSX.Element => {
const { isOpen, drawerName } = useAppSelector(drawerSelector); const { isOpen, drawerName } = useAppSelector(drawerSelector);
...@@ -24,6 +25,7 @@ export const Drawer = (): JSX.Element => { ...@@ -24,6 +25,7 @@ export const Drawer = (): JSX.Element => {
{isOpen && drawerName === 'reaction' && <ReactionDrawer />} {isOpen && drawerName === 'reaction' && <ReactionDrawer />}
{isOpen && drawerName === 'overlays' && <OverlaysDrawer />} {isOpen && drawerName === 'overlays' && <OverlaysDrawer />}
{isOpen && drawerName === 'bio-entity' && <BioEntityDrawer />} {isOpen && drawerName === 'bio-entity' && <BioEntityDrawer />}
{isOpen && drawerName === 'export' && <ExportDrawer />}
</div> </div>
); );
}; };
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();
});
});
/* 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>
);
};
export { Annotations } from './Annotations.component';
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([]);
});
it('should render search input when isSearchEnabled is true', () => {
render(<CheckboxFilter options={options} />);
const searchInput = screen.getByLabelText('search-input');
expect(searchInput).toBeInTheDocument();
});
it('should not render search input when isSearchEnabled is false', () => {
render(<CheckboxFilter options={options} isSearchEnabled={false} />);
const searchInput = screen.queryByLabelText('search-input');
expect(searchInput).not.toBeInTheDocument();
});
it('should not filter options based on search input when isSearchEnabled is false', () => {
render(<CheckboxFilter options={options} isSearchEnabled={false} />);
const searchInput = screen.queryByLabelText('search-input');
expect(searchInput).not.toBeInTheDocument();
options.forEach(option => {
const checkboxLabel = screen.getByText(option.label);
expect(checkboxLabel).toBeInTheDocument();
});
});
});
/* eslint-disable no-magic-numbers */
import Image from 'next/image';
import React, { useEffect, useState } from 'react';
import lensIcon from '@/assets/vectors/icons/lens.svg';
import { twMerge } from 'tailwind-merge';
type CheckboxItem = { id: string; label: string };
type CheckboxFilterProps = {
options: CheckboxItem[];
onFilterChange?: (filteredItems: CheckboxItem[]) => void;
onCheckedChange?: (filteredItems: CheckboxItem[]) => void;
isSearchEnabled?: boolean;
};
export const CheckboxFilter = ({
options,
onFilterChange,
onCheckedChange,
isSearchEnabled = true,
}: 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">
{isSearchEnabled && (
<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={twMerge(
'mb-6 max-h-[300px] overflow-y-auto py-2.5 pr-2.5',
isSearchEnabled && 'mt-6',
)}
>
{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>
);
};
export { CheckboxFilter } from './CheckboxFilter.component';
import React from 'react';
import { render, screen, fireEvent } from '@testing-library/react';
import { CollapsibleSection } from './CollapsibleSection.component';
describe('CollapsibleSection - component', () => {
it('should render with title and content', () => {
render(
<CollapsibleSection title="Section">
<div>Content</div>
</CollapsibleSection>,
);
expect(screen.getByText('Section')).toBeInTheDocument();
expect(screen.getByText('Content')).toBeInTheDocument();
});
it('should collapse and expands on button click', () => {
render(
<CollapsibleSection title="Test Section">
<div>Test Content</div>
</CollapsibleSection>,
);
const button = screen.getByText('Test Section');
const content = screen.getByText('Test Content');
expect(content).not.toBeVisible();
// Expand
fireEvent.click(button);
expect(content).toBeVisible();
// Collapse
fireEvent.click(button);
expect(content).not.toBeVisible();
});
});
import {
Accordion,
AccordionItem,
AccordionItemButton,
AccordionItemHeading,
AccordionItemPanel,
} from '@/shared/Accordion';
type CollapsibleSectionProps = {
title: string;
children: React.ReactNode;
};
export const CollapsibleSection = ({
title,
children,
}: CollapsibleSectionProps): React.ReactNode => (
<Accordion allowZeroExpanded>
<AccordionItem>
<AccordionItemHeading>
<AccordionItemButton>{title}</AccordionItemButton>
</AccordionItemHeading>
<AccordionItemPanel>{children}</AccordionItemPanel>
</AccordionItem>
</Accordion>
);
export { CollapsibleSection } from './CollapsibleSection.component';
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: '',
},
},
});
expect(screen.queryByTestId('checkbox-filter')).not.toBeVisible();
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();
});
});
/* eslint-disable no-magic-numbers */
import { useAppSelector } from '@/redux/hooks/useAppSelector';
import {
elementAnnotationsSelector,
loadingStatisticsSelector,
} from '@/redux/statistics/statistics.selectors';
import { CheckboxFilter } from '../../CheckboxFilter';
import { CollapsibleSection } from '../../CollapsibleSection';
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 (
<CollapsibleSection title="Select annotations">
{isPending && <p>Loading...</p>}
{!isPending && mappedElementAnnotations && mappedElementAnnotations.length > 0 && (
<CheckboxFilter options={mappedElementAnnotations} />
)}
</CollapsibleSection>
);
};
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