Skip to content
Snippets Groups Projects
Commit 5b324319 authored by mateusz-winiarczyk's avatar mateusz-winiarczyk
Browse files

feat(export): MIN-153 select type

parent 5a7c99ce
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...,!89feat(export): MIN-153 select type
Showing
with 563 additions and 43 deletions
......@@ -85,4 +85,25 @@ describe('CheckboxFilter - component', () => {
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();
});
});
});
......@@ -2,6 +2,7 @@
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 };
......@@ -9,12 +10,14 @@ 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);
......@@ -50,29 +53,37 @@ export const CheckboxFilter = ({
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"
/>
{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="my-6 max-h-[250px] overflow-y-auto py-2.5 pr-2.5 ">
<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 ">
<ul className="columns-2 gap-8">
{filteredOptions.map(option => (
<li key={option.id} className="mb-5 flex items-center gap-x-2">
<input
......
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>
);
};
export { Annotations } from './Annotations.component';
import { Types } from './Types';
import { Annotations } from '../Annotations';
export const Elements = (): React.ReactNode => {
return (
<div data-testid="elements-tab">
<Types />
<Annotations />
</div>
);
......
import { render, screen } from '@testing-library/react';
import {
InitialStoreState,
getReduxWrapperWithStore,
} from '@/utils/testing/getReduxWrapperWithStore';
import { StoreType } from '@/redux/store';
import { Types } from './Types.component';
const renderComponent = (initialStoreState: InitialStoreState = {}): { store: StoreType } => {
const { Wrapper, store } = getReduxWrapperWithStore(initialStoreState);
return (
render(
<Wrapper>
<Types />
</Wrapper>,
),
{
store,
}
);
};
describe('Types Component', () => {
test('renders without crashing', () => {
renderComponent();
expect(screen.getByText('Select types')).toBeInTheDocument();
});
});
import { elementTypesSelector } from '@/redux/configuration/configuration.selectors';
import { useAppSelector } from '@/redux/hooks/useAppSelector';
import { getCheckboxElements } from './Types.utils';
import { CheckboxFilter } from '../../CheckboxFilter';
import { CollapsibleSection } from '../../CollapsibleSection';
export const Types = (): React.ReactNode => {
const elementTypes = useAppSelector(elementTypesSelector);
const checkboxElements = getCheckboxElements(elementTypes);
return (
<CollapsibleSection title="Select types">
{checkboxElements && <CheckboxFilter options={checkboxElements} isSearchEnabled={false} />}
</CollapsibleSection>
);
};
import { getCheckboxElements } from './Types.utils';
describe('getCheckboxElements', () => {
it('should return an empty array when elementTypes is undefined', () => {
const result = getCheckboxElements(undefined);
expect(result).toEqual([]);
});
it('should map elementTypes to MappedElementTypes and exclude duplicates based on name and parentClass', () => {
const elementTypes = [
{ className: 'class1', name: 'type1', parentClass: 'parent1' },
{ className: 'class2', name: 'type2', parentClass: 'parent2' },
{ className: 'class1', name: 'type1', parentClass: 'parent1' },
{ className: 'class3', name: 'type3', parentClass: 'parent3' },
{ className: 'class2', name: 'type2', parentClass: 'parent2' },
];
const result = getCheckboxElements(elementTypes);
expect(result).toEqual([
{ id: 'type1', label: 'type1' },
{ id: 'type2', label: 'type2' },
{ id: 'type3', label: 'type3' },
]);
});
it('should handle an empty array of elementTypes', () => {
const result = getCheckboxElements([]);
expect(result).toEqual([]);
});
it('should return an empty array when elementTypes is undefined', () => {
const result = getCheckboxElements(undefined);
expect(result).toEqual([]);
});
});
type ElementTypes =
| {
className: string;
name: string;
parentClass: string;
}[]
| undefined;
type MappedElementTypes = { id: string; label: string }[];
type PresenceMap = { [key: string]: boolean };
export const getCheckboxElements = (elementTypes: ElementTypes): MappedElementTypes => {
if (!elementTypes) return [];
const excludedTypes: PresenceMap = {};
elementTypes?.forEach(type => {
excludedTypes[type.parentClass] = true;
});
const mappedElementTypes: MappedElementTypes = [];
const processedNames: PresenceMap = {};
elementTypes.forEach(elementType => {
if (excludedTypes[elementType.className] || processedNames[elementType.name]) return;
processedNames[elementType.name] = true;
mappedElementTypes.push({
id: elementType.name,
label: elementType.name,
});
});
return mappedElementTypes;
};
export { Types } from './Types.component';
import { z } from 'zod';
export const elementTypeSchema = z.object({
className: z.string(),
name: z.string(),
parentClass: z.string(),
});
export const optionSchema = z.object({
idObject: z.number(),
type: z.string(),
valueType: z.string(),
commonName: z.string(),
isServerSide: z.boolean(),
group: z.string(),
value: z.string().optional(),
});
export const imageFormatSchema = z.object({
name: z.string(),
handler: z.string(),
extension: z.string(),
});
export const modelFormatSchema = z.object({
name: z.string(),
handler: z.string(),
extension: z.string(),
});
export const overlayTypeSchema = z.object({ name: z.string() });
export const reactionTypeSchema = z.object({
className: z.string(),
name: z.string(),
parentClass: z.string(),
});
export const miriamTypesSchema = z.record(
z.string(),
z.object({
commonName: z.string(),
homepage: z.string().nullable(),
registryIdentifier: z.string().nullable(),
uris: z.array(z.string()),
}),
);
export const bioEntityFieldSchema = z.object({ commonName: z.string(), name: z.string() });
export const annotatorSchema = z.object({
className: z.string(),
name: z.string(),
description: z.string(),
url: z.string(),
elementClassNames: z.array(z.string()),
parameters: z.array(
z.object({
field: z.string().nullable().optional(),
annotation_type: z.string().nullable().optional(),
order: z.number(),
type: z.string(),
}),
),
});
export const privilegeTypeSchema = z.record(
z.string(),
z.object({
commonName: z.string(),
objectType: z.string().nullable(),
valueType: z.string(),
}),
);
export const mapTypeSchema = z.object({ name: z.string(), id: z.string() });
export const mapCanvasTypeSchema = z.object({ name: z.string(), id: z.string() });
export const unitTypeSchema = z.object({ name: z.string(), id: z.string() });
export const modificationStateTypeSchema = z.record(
z.string(),
z.object({ commonName: z.string(), abbreviation: z.string() }),
);
export const configurationSchema = z.object({
elementTypes: z.array(elementTypeSchema),
options: z.array(optionSchema),
imageFormats: z.array(imageFormatSchema),
modelFormats: z.array(modelFormatSchema),
overlayTypes: z.array(overlayTypeSchema),
reactionTypes: z.array(reactionTypeSchema),
miriamTypes: miriamTypesSchema,
bioEntityFields: z.array(bioEntityFieldSchema),
version: z.string(),
buildDate: z.string(),
gitHash: z.string(),
annotators: z.array(annotatorSchema),
privilegeTypes: privilegeTypeSchema,
mapTypes: z.array(mapTypeSchema),
mapCanvasTypes: z.array(mapCanvasTypeSchema),
unitTypes: z.array(unitTypeSchema),
modificationStateTypes: modificationStateTypeSchema,
});
......@@ -32,6 +32,7 @@ export const apiPath = {
getSessionValid: (): string => `users/isSessionValid`,
postLogin: (): string => `doLogin`,
getConfigurationOptions: (): string => 'configuration/options/',
getConfiguration: (): string => 'configuration/',
getOverlayBioEntity: ({ overlayId, modelId }: { overlayId: number; modelId: number }): string =>
`projects/${PROJECT_ID}/overlays/${overlayId}/models/${modelId}/bioEntities/`,
getStatisticsById: (projectId: string): string => `projects/${projectId}/statistics/`,
......
......@@ -2,6 +2,7 @@ import { DEFAULT_ERROR } from '@/constants/errors';
import { Loading } from '@/types/loadingState';
import { ConfigurationOption } from '@/types/models';
import { createEntityAdapter } from '@reduxjs/toolkit';
import { ConfigurationMainState } from './configuration.types';
export const configurationAdapter = createEntityAdapter<ConfigurationOption>({
selectId: option => option.type,
......@@ -12,7 +13,14 @@ const REQUEST_INITIAL_STATUS: { loading: Loading; error: Error } = {
error: DEFAULT_ERROR,
};
export const CONFIGURATION_INITIAL_STATE =
configurationAdapter.getInitialState(REQUEST_INITIAL_STATUS);
const MAIN_CONFIGURATION_INITIAL_STATE: ConfigurationMainState = {
data: undefined,
...REQUEST_INITIAL_STATUS,
};
export const CONFIGURATION_INITIAL_STATE = {
options: configurationAdapter.getInitialState(REQUEST_INITIAL_STATUS),
main: MAIN_CONFIGURATION_INITIAL_STATE,
};
export type ConfigurationState = typeof CONFIGURATION_INITIAL_STATE;
......@@ -7,21 +7,35 @@ import {
import { ConfigurationState } from './configuration.adapter';
export const CONFIGURATION_INITIAL_STORE_MOCK: ConfigurationState = {
ids: [],
entities: {},
loading: 'idle',
error: DEFAULT_ERROR,
options: {
ids: [],
entities: {},
loading: 'idle',
error: DEFAULT_ERROR,
},
main: {
data: undefined,
loading: 'idle',
error: DEFAULT_ERROR,
},
};
/** IMPORTANT MOCK IDS MUST MATCH KEYS IN ENTITIES */
export const CONFIGURATION_INITIAL_STORE_MOCKS: ConfigurationState = {
ids: CONFIGURATION_OPTIONS_TYPES_MOCK,
entities: {
[CONFIGURATION_OPTIONS_TYPES_MOCK[0]]: CONFIGURATION_OPTIONS_COLOURS_MOCK[0],
[CONFIGURATION_OPTIONS_TYPES_MOCK[1]]: CONFIGURATION_OPTIONS_COLOURS_MOCK[1],
[CONFIGURATION_OPTIONS_TYPES_MOCK[2]]: CONFIGURATION_OPTIONS_COLOURS_MOCK[2],
[CONFIGURATION_OPTIONS_TYPES_MOCK[3]]: CONFIGURATION_OPTIONS_COLOURS_MOCK[3],
options: {
ids: CONFIGURATION_OPTIONS_TYPES_MOCK,
entities: {
[CONFIGURATION_OPTIONS_TYPES_MOCK[0]]: CONFIGURATION_OPTIONS_COLOURS_MOCK[0],
[CONFIGURATION_OPTIONS_TYPES_MOCK[1]]: CONFIGURATION_OPTIONS_COLOURS_MOCK[1],
[CONFIGURATION_OPTIONS_TYPES_MOCK[2]]: CONFIGURATION_OPTIONS_COLOURS_MOCK[2],
[CONFIGURATION_OPTIONS_TYPES_MOCK[3]]: CONFIGURATION_OPTIONS_COLOURS_MOCK[3],
},
loading: 'idle',
error: DEFAULT_ERROR,
},
main: {
data: undefined,
loading: 'idle',
error: DEFAULT_ERROR,
},
loading: 'idle',
error: DEFAULT_ERROR,
};
import { ActionReducerMapBuilder } from '@reduxjs/toolkit';
import { getConfigurationOptions } from './configuration.thunks';
import { getConfiguration, getConfigurationOptions } from './configuration.thunks';
import { ConfigurationState, configurationAdapter } from './configuration.adapter';
export const getConfigurationOptionsReducer = (
builder: ActionReducerMapBuilder<ConfigurationState>,
): void => {
builder.addCase(getConfigurationOptions.pending, state => {
state.loading = 'pending';
state.options.loading = 'pending';
});
builder.addCase(getConfigurationOptions.fulfilled, (state, action) => {
if (action.payload) {
state.loading = 'succeeded';
configurationAdapter.addMany(state, action.payload);
state.options.loading = 'succeeded';
configurationAdapter.addMany(state.options, action.payload);
}
});
builder.addCase(getConfigurationOptions.rejected, state => {
state.loading = 'failed';
state.options.loading = 'failed';
// TODO to discuss manage state of failure
});
};
export const getConfigurationReducer = (
builder: ActionReducerMapBuilder<ConfigurationState>,
): void => {
builder.addCase(getConfiguration.pending, state => {
state.main.loading = 'pending';
});
builder.addCase(getConfiguration.fulfilled, (state, action) => {
state.main.loading = 'succeeded';
state.main.data = action.payload;
});
builder.addCase(getConfiguration.rejected, state => {
state.main.loading = 'failed';
// TODO to discuss manage state of failure
});
};
......@@ -10,30 +10,41 @@ import {
} from './configuration.constants';
const configurationSelector = createSelector(rootSelector, state => state.configuration);
const configurationOptionsSelector = createSelector(configurationSelector, state => state.options);
const configurationAdapterSelectors = configurationAdapter.getSelectors();
export const minColorValSelector = createSelector(
configurationSelector,
configurationOptionsSelector,
state => configurationAdapterSelectors.selectById(state, MIN_COLOR_VAL_NAME_ID)?.value,
);
export const maxColorValSelector = createSelector(
configurationSelector,
configurationOptionsSelector,
state => configurationAdapterSelectors.selectById(state, MAX_COLOR_VAL_NAME_ID)?.value,
);
export const neutralColorValSelector = createSelector(
configurationSelector,
configurationOptionsSelector,
state => configurationAdapterSelectors.selectById(state, NEUTRAL_COLOR_VAL_NAME_ID)?.value,
);
export const overlayOpacitySelector = createSelector(
configurationSelector,
configurationOptionsSelector,
state => configurationAdapterSelectors.selectById(state, OVERLAY_OPACITY_NAME_ID)?.value,
);
export const simpleColorValSelector = createSelector(
configurationSelector,
configurationOptionsSelector,
state => configurationAdapterSelectors.selectById(state, SIMPLE_COLOR_VAL_NAME_ID)?.value,
);
export const configurationMainSelector = createSelector(
configurationSelector,
state => state.main.data,
);
export const elementTypesSelector = createSelector(
configurationMainSelector,
state => state?.elementTypes,
);
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