diff --git a/src/components/Map/Drawer/ExportDrawer/CheckboxFilter/CheckboxFilter.component.test.tsx b/src/components/Map/Drawer/ExportDrawer/CheckboxFilter/CheckboxFilter.component.test.tsx index 50ad407922abb07782cf7f6b89e5f2a380efb0b5..1fb3437a0f779358067827ff463df4d3f8ca76c2 100644 --- a/src/components/Map/Drawer/ExportDrawer/CheckboxFilter/CheckboxFilter.component.test.tsx +++ b/src/components/Map/Drawer/ExportDrawer/CheckboxFilter/CheckboxFilter.component.test.tsx @@ -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(); + }); + }); }); diff --git a/src/components/Map/Drawer/ExportDrawer/CheckboxFilter/CheckboxFilter.component.tsx b/src/components/Map/Drawer/ExportDrawer/CheckboxFilter/CheckboxFilter.component.tsx index a44328e27640b813ed302cf6d752b43fb8d884a1..68dbe9c6ecf1ea715852925c85e2f2d18b3d4ad2 100644 --- a/src/components/Map/Drawer/ExportDrawer/CheckboxFilter/CheckboxFilter.component.tsx +++ b/src/components/Map/Drawer/ExportDrawer/CheckboxFilter/CheckboxFilter.component.tsx @@ -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 diff --git a/src/components/Map/Drawer/ExportDrawer/CollapsibleSection/CollapsibleSection.component.test.tsx b/src/components/Map/Drawer/ExportDrawer/CollapsibleSection/CollapsibleSection.component.test.tsx new file mode 100644 index 0000000000000000000000000000000000000000..99569fa0dc72b5da8578a79faa5187e81cc55483 --- /dev/null +++ b/src/components/Map/Drawer/ExportDrawer/CollapsibleSection/CollapsibleSection.component.test.tsx @@ -0,0 +1,37 @@ +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(); + }); +}); diff --git a/src/components/Map/Drawer/ExportDrawer/CollapsibleSection/CollapsibleSection.component.tsx b/src/components/Map/Drawer/ExportDrawer/CollapsibleSection/CollapsibleSection.component.tsx new file mode 100644 index 0000000000000000000000000000000000000000..b0d478bba09f05a0b119a0b3ce84163684055a70 --- /dev/null +++ b/src/components/Map/Drawer/ExportDrawer/CollapsibleSection/CollapsibleSection.component.tsx @@ -0,0 +1,26 @@ +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> +); diff --git a/src/components/Map/Drawer/ExportDrawer/CollapsibleSection/index.ts b/src/components/Map/Drawer/ExportDrawer/CollapsibleSection/index.ts new file mode 100644 index 0000000000000000000000000000000000000000..7d4a61e43bafadb39c60bce53019003b0ab58675 --- /dev/null +++ b/src/components/Map/Drawer/ExportDrawer/CollapsibleSection/index.ts @@ -0,0 +1 @@ +export { CollapsibleSection } from './CollapsibleSection.component'; diff --git a/src/components/Map/Drawer/ExportDrawer/Elements/Annotations/Annotations.component.test.tsx b/src/components/Map/Drawer/ExportDrawer/Elements/Annotations/Annotations.component.test.tsx new file mode 100644 index 0000000000000000000000000000000000000000..df19cb66cbdd04c7511105db937cf4f5f41be111 --- /dev/null +++ b/src/components/Map/Drawer/ExportDrawer/Elements/Annotations/Annotations.component.test.tsx @@ -0,0 +1,122 @@ +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(); + }); +}); diff --git a/src/components/Map/Drawer/ExportDrawer/Elements/Annotations/Annotations.component.tsx b/src/components/Map/Drawer/ExportDrawer/Elements/Annotations/Annotations.component.tsx new file mode 100644 index 0000000000000000000000000000000000000000..f3795e9b9f5c9828957e6691c5427fd42deacd2d --- /dev/null +++ b/src/components/Map/Drawer/ExportDrawer/Elements/Annotations/Annotations.component.tsx @@ -0,0 +1,27 @@ +/* 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> + ); +}; diff --git a/src/components/Map/Drawer/ExportDrawer/Elements/Annotations/index.ts b/src/components/Map/Drawer/ExportDrawer/Elements/Annotations/index.ts new file mode 100644 index 0000000000000000000000000000000000000000..3b82aaf76f1b363c9cc2429bbe05d17938bdac29 --- /dev/null +++ b/src/components/Map/Drawer/ExportDrawer/Elements/Annotations/index.ts @@ -0,0 +1 @@ +export { Annotations } from './Annotations.component'; diff --git a/src/components/Map/Drawer/ExportDrawer/Elements/Elements.component.tsx b/src/components/Map/Drawer/ExportDrawer/Elements/Elements.component.tsx index 066ba3b562c3338f5e31d7541eb3fff0498ccb78..17dcf86ae8c79babe79b0269628a4bdbcf2d1ba3 100644 --- a/src/components/Map/Drawer/ExportDrawer/Elements/Elements.component.tsx +++ b/src/components/Map/Drawer/ExportDrawer/Elements/Elements.component.tsx @@ -1,8 +1,10 @@ +import { Types } from './Types'; import { Annotations } from '../Annotations'; export const Elements = (): React.ReactNode => { return ( <div data-testid="elements-tab"> + <Types /> <Annotations /> </div> ); diff --git a/src/components/Map/Drawer/ExportDrawer/Elements/Types/Types.component.test.tsx b/src/components/Map/Drawer/ExportDrawer/Elements/Types/Types.component.test.tsx new file mode 100644 index 0000000000000000000000000000000000000000..4d228509706c27621afef1d5620a23dc03d647ab --- /dev/null +++ b/src/components/Map/Drawer/ExportDrawer/Elements/Types/Types.component.test.tsx @@ -0,0 +1,29 @@ +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(); + }); +}); diff --git a/src/components/Map/Drawer/ExportDrawer/Elements/Types/Types.component.tsx b/src/components/Map/Drawer/ExportDrawer/Elements/Types/Types.component.tsx new file mode 100644 index 0000000000000000000000000000000000000000..0c37bdf6907d676eeaed5182c5002710327ff13c --- /dev/null +++ b/src/components/Map/Drawer/ExportDrawer/Elements/Types/Types.component.tsx @@ -0,0 +1,16 @@ +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> + ); +}; diff --git a/src/components/Map/Drawer/ExportDrawer/Elements/Types/Types.utils.test.ts b/src/components/Map/Drawer/ExportDrawer/Elements/Types/Types.utils.test.ts new file mode 100644 index 0000000000000000000000000000000000000000..34e10ae6cf11eba8a045e3929738f636f2a03620 --- /dev/null +++ b/src/components/Map/Drawer/ExportDrawer/Elements/Types/Types.utils.test.ts @@ -0,0 +1,36 @@ +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([]); + }); +}); diff --git a/src/components/Map/Drawer/ExportDrawer/Elements/Types/Types.utils.ts b/src/components/Map/Drawer/ExportDrawer/Elements/Types/Types.utils.ts new file mode 100644 index 0000000000000000000000000000000000000000..a8a7cc990d683cad01bd17ca5f8007f4bce4e86b --- /dev/null +++ b/src/components/Map/Drawer/ExportDrawer/Elements/Types/Types.utils.ts @@ -0,0 +1,35 @@ +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; +}; diff --git a/src/components/Map/Drawer/ExportDrawer/Elements/Types/index.ts b/src/components/Map/Drawer/ExportDrawer/Elements/Types/index.ts new file mode 100644 index 0000000000000000000000000000000000000000..ce8a0cc157c89e6d8b723d3b67d9479b8a1df515 --- /dev/null +++ b/src/components/Map/Drawer/ExportDrawer/Elements/Types/index.ts @@ -0,0 +1 @@ +export { Types } from './Types.component'; diff --git a/src/models/configurationSchema.ts b/src/models/configurationSchema.ts new file mode 100644 index 0000000000000000000000000000000000000000..db44bb5651921d33a73a75029205a2b30f5291eb --- /dev/null +++ b/src/models/configurationSchema.ts @@ -0,0 +1,105 @@ +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, +}); diff --git a/src/redux/apiPath.ts b/src/redux/apiPath.ts index 22592f28bd5f73f21507929915098d241000ea7e..283a420a57d9f49509d4f43785b9ac9dda4b6ad3 100644 --- a/src/redux/apiPath.ts +++ b/src/redux/apiPath.ts @@ -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/`, diff --git a/src/redux/configuration/configuration.adapter.ts b/src/redux/configuration/configuration.adapter.ts index cb3c59beabe94d7b0fd1ef707ccc90c03342cecd..d99fb14c680d6532f321aff23f532eb0a9a77c88 100644 --- a/src/redux/configuration/configuration.adapter.ts +++ b/src/redux/configuration/configuration.adapter.ts @@ -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; diff --git a/src/redux/configuration/configuration.mock.ts b/src/redux/configuration/configuration.mock.ts index ce8f052d426a153b14230093988426ea8d3c25f0..d371990411b2f4fc2e88b66f44c411da248d346e 100644 --- a/src/redux/configuration/configuration.mock.ts +++ b/src/redux/configuration/configuration.mock.ts @@ -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, }; diff --git a/src/redux/configuration/configuration.reducers.ts b/src/redux/configuration/configuration.reducers.ts index 01cd1fe5a6a869b6707bb12c6d2f4917a2bc25a1..57e60a05a28eda8721332f80adbf4abeda5c56bd 100644 --- a/src/redux/configuration/configuration.reducers.ts +++ b/src/redux/configuration/configuration.reducers.ts @@ -1,21 +1,37 @@ 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 }); }; diff --git a/src/redux/configuration/configuration.selectors.ts b/src/redux/configuration/configuration.selectors.ts index 7a694a44779048afc22d05ab9b406f8f970fd153..06b4c6239f49e4362cbe8a978eb46aaeb3d7d095 100644 --- a/src/redux/configuration/configuration.selectors.ts +++ b/src/redux/configuration/configuration.selectors.ts @@ -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, +); diff --git a/src/redux/configuration/configuration.slice.ts b/src/redux/configuration/configuration.slice.ts index 4bf43488b0dbf265504a24370e7607a90c1f82b5..7758564a93148b091a89035997757284c363e5ca 100644 --- a/src/redux/configuration/configuration.slice.ts +++ b/src/redux/configuration/configuration.slice.ts @@ -1,5 +1,5 @@ import { createSlice } from '@reduxjs/toolkit'; -import { getConfigurationOptionsReducer } from './configuration.reducers'; +import { getConfigurationOptionsReducer, getConfigurationReducer } from './configuration.reducers'; import { CONFIGURATION_INITIAL_STATE } from './configuration.adapter'; export const configurationSlice = createSlice({ @@ -8,6 +8,7 @@ export const configurationSlice = createSlice({ reducers: {}, extraReducers: builder => { getConfigurationOptionsReducer(builder); + getConfigurationReducer(builder); }, }); diff --git a/src/redux/configuration/configuration.thunks.ts b/src/redux/configuration/configuration.thunks.ts index ad3812bbf4d28e9093e64ca80bc92dc43b1be770..012e8b1c5184c9ed39c948d76b4124d29dac57d4 100644 --- a/src/redux/configuration/configuration.thunks.ts +++ b/src/redux/configuration/configuration.thunks.ts @@ -1,8 +1,9 @@ -import { ConfigurationOption } from '@/types/models'; +import { Configuration, ConfigurationOption } from '@/types/models'; import { createAsyncThunk } from '@reduxjs/toolkit'; import { z } from 'zod'; import { axiosInstance } from '@/services/api/utils/axiosInstance'; import { validateDataUsingZodSchema } from '@/utils/validateDataUsingZodSchema'; +import { configurationSchema } from '@/models/configurationSchema'; import { configurationOptionSchema } from '@/models/configurationOptionSchema'; import { apiPath } from '../apiPath'; @@ -21,3 +22,14 @@ export const getConfigurationOptions = createAsyncThunk( return isDataValid ? response.data : undefined; }, ); + +export const getConfiguration = createAsyncThunk( + 'configuration/getConfiguration', + async (): Promise<Configuration | undefined> => { + const response = await axiosInstance.get<Configuration>(apiPath.getConfiguration()); + + const isDataValid = validateDataUsingZodSchema(response.data, configurationSchema); + + return isDataValid ? response.data : undefined; + }, +); diff --git a/src/redux/configuration/configuration.types.ts b/src/redux/configuration/configuration.types.ts new file mode 100644 index 0000000000000000000000000000000000000000..086619ae0b02e56f9a8d4a9a21f26beaebfa4545 --- /dev/null +++ b/src/redux/configuration/configuration.types.ts @@ -0,0 +1,4 @@ +import { FetchDataState } from '@/types/fetchDataState'; +import { Configuration } from '@/types/models'; + +export type ConfigurationMainState = FetchDataState<Configuration>; diff --git a/src/redux/root/init.thunks.ts b/src/redux/root/init.thunks.ts index 51b6564eaab4d415ef9fa95221111825e604e063..0d45822836c1a3aef96f84c756c3321feccd56a5 100644 --- a/src/redux/root/init.thunks.ts +++ b/src/redux/root/init.thunks.ts @@ -17,7 +17,7 @@ import { 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 { getConfiguration, getConfigurationOptions } from '../configuration/configuration.thunks'; import { getStatisticsById } from '../statistics/statistics.thunks'; interface InitializeAppParams { @@ -51,6 +51,7 @@ export const fetchInitialAppData = createAsyncThunk< // Fetch data needed for export dispatch(getStatisticsById(PROJECT_ID)); + dispatch(getConfiguration()); /** Trigger search */ if (queryData.searchValue) { diff --git a/src/types/models.ts b/src/types/models.ts index 63d9c3a33a5bbe56ed24959efc5de0afbd7b2028..bdf8fd63d5d5b94b5051c84a34f3bbf358291153 100644 --- a/src/types/models.ts +++ b/src/types/models.ts @@ -4,6 +4,7 @@ import { bioEntitySchema } from '@/models/bioEntitySchema'; import { chemicalSchema } from '@/models/chemicalSchema'; import { colorSchema } from '@/models/colorSchema'; import { configurationOptionSchema } from '@/models/configurationOptionSchema'; +import { configurationSchema } from '@/models/configurationSchema'; import { disease } from '@/models/disease'; import { drugSchema } from '@/models/drugSchema'; import { elementSearchResult, elementSearchResultType } from '@/models/elementSearchResult'; @@ -52,6 +53,7 @@ export type ElementSearchResultType = z.infer<typeof elementSearchResultType>; export type SessionValid = z.infer<typeof sessionSchemaValid>; export type Login = z.infer<typeof loginSchema>; export type ConfigurationOption = z.infer<typeof configurationOptionSchema>; +export type Configuration = z.infer<typeof configurationSchema>; export type OverlayBioEntity = z.infer<typeof overlayBioEntitySchema>; export type Color = z.infer<typeof colorSchema>; export type Statistics = z.infer<typeof statisticsSchema>;