diff --git a/portal/src/app/components/home/Home.module.scss b/portal/src/app/components/home/Home.module.scss index 194b7f33..7a77a1b9 100644 --- a/portal/src/app/components/home/Home.module.scss +++ b/portal/src/app/components/home/Home.module.scss @@ -2,7 +2,9 @@ @import "src/styles/z-index"; .app_wrapper { - padding: 80px; + padding-block-start: 20px; + padding-inline-start: 80px; + padding-block-end: 40px; } .init_state_wrapper { diff --git a/portal/src/app/components/home/components/experiment/components/table-options/TableOptions.module.scss b/portal/src/app/components/home/components/experiment/components/table-options/TableOptions.module.scss index b05d5477..c7c6ff51 100644 --- a/portal/src/app/components/home/components/experiment/components/table-options/TableOptions.module.scss +++ b/portal/src/app/components/home/components/experiment/components/table-options/TableOptions.module.scss @@ -12,8 +12,6 @@ .view_in_grafana_wrapper { display: flex; align-items: center; - color: var($attPurple); - font-size: 16px; .eye_icon { margin-inline-end: 5px; @@ -56,11 +54,11 @@ display: none; } -.grafana_wrapper:hover .hover_image { +.view_in_grafana_wrapper:hover .hover_image { display: block; } -.grafana_wrapper:hover .default_image { +.view_in_grafana_wrapper:hover .default_image { display: none; } diff --git a/portal/src/app/components/protocol-query/ProtocolQuery.tsx b/portal/src/app/components/protocol-query/ProtocolQuery.tsx index af8abb12..0e87ab5a 100644 --- a/portal/src/app/components/protocol-query/ProtocolQuery.tsx +++ b/portal/src/app/components/protocol-query/ProtocolQuery.tsx @@ -31,9 +31,13 @@ export const ProtocolQuery: React.FC = (props: ProtocolQuery const [experimentName, setExperimentName] = useState(''); const [algorithms, setAlgorithms] = useState(); const [prevSelectedValues, setPrevSelectedValues] = useState([]); - const [iterationsCount, setIterationsCount] = useState(); const [description, setDescription] = useState(''); + const [iterationsCount, setIterationsCount] = useState([]); + const [showInputOption, setShowInputOption] = useState(false); + const [inputValue, setInputValue] = useState(''); + const [iterationsMenuIsOpen, setIterationsMenuIsOpen] = useState(false); + const onSubmitHandler = (event: React.FormEvent) => { event.preventDefault(); onRunClick({ @@ -57,7 +61,8 @@ export const ProtocolQuery: React.FC = (props: ProtocolQuery }, [algosBySection, algorithmOptions, prevSelectedValues]); const onIterationsNumChanged: OnSelectChanged = useCallback((options: SelectOptionType): void => { - const selectedIterationNum: Options = options as Options; + const selectedIterationNum: AttSelectOption[] = options as AttSelectOption[]; + setIterationsMenuIsOpen(true); setIterationsCount(selectedIterationNum); }, []); @@ -77,7 +82,7 @@ export const ProtocolQuery: React.FC = (props: ProtocolQuery
= (props: ProtocolQuery
= (props: ProtocolQuery
+ + }} />
diff --git a/portal/src/app/components/protocol-query/__snapshots__/ProtocolQuery.test.tsx.snap b/portal/src/app/components/protocol-query/__snapshots__/ProtocolQuery.test.tsx.snap index eda558d0..f4846208 100644 --- a/portal/src/app/components/protocol-query/__snapshots__/ProtocolQuery.test.tsx.snap +++ b/portal/src/app/components/protocol-query/__snapshots__/ProtocolQuery.test.tsx.snap @@ -175,9 +175,7 @@ exports[`ProtocolQuery should render ProtocolQuery 1`] = `
- + Add new -
+ />
{ }); const { result } = renderHook(() => useGetIterations()); - expect(result.current.iterationsOptions.length).toEqual(mockData.iterations.length); + expect(result.current.iterationsOptions.length).toEqual(mockData.iterations.length + 2); }); }); \ No newline at end of file diff --git a/portal/src/app/components/protocol-query/hooks/useGetIterations.ts b/portal/src/app/components/protocol-query/hooks/useGetIterations.ts index 05c9343a..044881be 100644 --- a/portal/src/app/components/protocol-query/hooks/useGetIterations.ts +++ b/portal/src/app/components/protocol-query/hooks/useGetIterations.ts @@ -2,6 +2,7 @@ import { AttSelectOption } from "../../../shared/components/att-select"; import { IHttp, useFetch } from "../../../shared/hooks/useFetch"; import { useEffect, useState } from "react"; import { APIS } from "../../../apis"; +import { SELECTOR_CUSTOM_OPTION_EN } from "../../../shared/components/selector-custom-option/translate/en"; export interface IUseGetIterations { iterationsOptions: AttSelectOption[]; @@ -24,7 +25,11 @@ export function useGetIterations(): IUseGetIterations { useEffect(() => { if (data) { const iterationsOptions: AttSelectOption[] = data.iterations.map((iteration: number) => ({ label: iteration.toString(), value: iteration.toString() })); - setIterations(iterationsOptions); + setIterations([ + ...iterationsOptions, + { label: SELECTOR_CUSTOM_OPTION_EN.ADD_NEW, value: SELECTOR_CUSTOM_OPTION_EN.ADD_NEW, metadata: { isInput: true }}, + { label: SELECTOR_CUSTOM_OPTION_EN.ADD_NEW_BUTTON, value: SELECTOR_CUSTOM_OPTION_EN.ADD_NEW_BUTTON, metadata: { isAddNewButton: true }} + ]); } }, [data]); diff --git a/portal/src/app/components/protocol-query/translate/en.ts b/portal/src/app/components/protocol-query/translate/en.ts index a0da79a1..8883be9b 100644 --- a/portal/src/app/components/protocol-query/translate/en.ts +++ b/portal/src/app/components/protocol-query/translate/en.ts @@ -10,7 +10,7 @@ export const PROTOCOL_QUERY_EN = { EXPORT: 'Export', }, FIELDS_LABEL: { - PLACEHOLDER: '+ Add new', + REQUIRED: '*', EXPERIMENT_NAME: 'Experiment name', ITERATIONS_NUMBER: 'Number of iterations', ALGORITHM: 'Algorithm(s)', diff --git a/portal/src/app/shared/components/att-select/AttSelect.test.tsx b/portal/src/app/shared/components/att-select/AttSelect.test.tsx index fccfa0bf..44cfa7f9 100644 --- a/portal/src/app/shared/components/att-select/AttSelect.test.tsx +++ b/portal/src/app/shared/components/att-select/AttSelect.test.tsx @@ -1,4 +1,4 @@ -import { render, RenderResult } from '@testing-library/react'; +import { fireEvent, render, RenderResult } from '@testing-library/react'; import { AttSelect, AttSelectProps } from './AttSelect'; import { AttSelectOption } from './AttSelect.model'; @@ -38,6 +38,39 @@ describe('AttSelect', () => { const spinner = container.querySelector('.att_select_spinner'); expect(spinner).toBeInTheDocument(); }); + + test('should open the menu when onMenuOpen is called', () => { + const props: AttSelectProps = { + options, + placeholder: '', + value: item1, + onChange: () => expect.anything(), + isProcessing: true, + }; + const setMenuIsOpen = jest.fn(); + const { getByRole } = render(); + + fireEvent.mouseDown(getByRole('combobox')); + + expect(setMenuIsOpen).toHaveBeenCalledWith(true); + }); + + test('should close the menu when onMenuClose is called', () => { + const props: AttSelectProps = { + options, + placeholder: '', + value: item1, + onChange: () => expect.anything(), + isProcessing: true, + }; + const setMenuIsOpen = jest.fn(); + const { getByRole } = render(); + + fireEvent.mouseDown(getByRole('combobox')); + fireEvent.blur(getByRole('combobox')); + + expect(setMenuIsOpen).toHaveBeenCalledWith(false); + }); }); const item1: AttSelectOption = { diff --git a/portal/src/app/shared/components/att-select/AttSelect.tsx b/portal/src/app/shared/components/att-select/AttSelect.tsx index 56c61f4e..0cb9930f 100644 --- a/portal/src/app/shared/components/att-select/AttSelect.tsx +++ b/portal/src/app/shared/components/att-select/AttSelect.tsx @@ -36,6 +36,8 @@ export interface AttSelectProps { theme?: AttSelectTheme; isMulti?: boolean; closeMenuOnSelect?: boolean; + menuIsOpen?: boolean; + setMenuIsOpen?: (value: boolean) => void; hideSelectedOptions?: boolean; e2eId?: string; id?: string; @@ -58,6 +60,9 @@ const AttSelectPrivate: AttSelectPrivateType = (props: AttSelectProps, ref: Forw placeholder={props.placeholder} isMulti={props.isMulti} isClearable={props.isClearable} + menuIsOpen={props.menuIsOpen} + onMenuOpen={() => props.setMenuIsOpen && props.setMenuIsOpen(true)} + onMenuClose={() => props.setMenuIsOpen && props.setMenuIsOpen(false)} closeMenuOnSelect={props.closeMenuOnSelect} hideSelectedOptions={props.hideSelectedOptions} required={props.required} diff --git a/portal/src/app/shared/components/selector-custom-option/SelectorCustomOption.module.scss b/portal/src/app/shared/components/selector-custom-option/SelectorCustomOption.module.scss index 7784ab20..49f29af9 100644 --- a/portal/src/app/shared/components/selector-custom-option/SelectorCustomOption.module.scss +++ b/portal/src/app/shared/components/selector-custom-option/SelectorCustomOption.module.scss @@ -11,6 +11,12 @@ cursor: pointer; } +.option_wrapper { + inline-size: 17px; + block-size: 17px; + margin-inline-end: 12px; +} + .iterations_input_option { margin-inline-end: 10px; cursor: pointer; @@ -18,10 +24,4 @@ .input_option { display: none; -} - -.option_wrapper { - inline-size: 17px; - block-size: 17px; - margin-inline-end: 12px; -} +} \ No newline at end of file diff --git a/portal/src/app/shared/components/selector-custom-option/SelectorCustomOption.test.tsx b/portal/src/app/shared/components/selector-custom-option/SelectorCustomOption.test.tsx index 08233bbe..2d26c1ad 100644 --- a/portal/src/app/shared/components/selector-custom-option/SelectorCustomOption.test.tsx +++ b/portal/src/app/shared/components/selector-custom-option/SelectorCustomOption.test.tsx @@ -1,7 +1,5 @@ -import { render, fireEvent, RenderResult } from '@testing-library/react'; -import { components } from 'react-select'; +import { render, RenderResult } from '@testing-library/react'; import { AlgorithmsSelectorCustomOption, IterationsSelectorCustomOption, SelectorCustomOptionProps } from './SelectorCustomOption'; -import { algorithmSections } from '../../../components/protocol-query/constants'; describe('SelectorCustomOption', () => { const mockOption = { value: 'option1', label: 'Option 1' }; @@ -20,7 +18,7 @@ describe('SelectorCustomOption', () => { cx: jest.fn(), getStyles: jest.fn(), getClassNames: jest.fn(), - getValue: jest.fn(), + getValue: jest.fn().mockReturnValue([{ label: 'Option 1', value: 'option1' }]), hasValue: true, isMulti: true, isRtl: false, @@ -29,6 +27,11 @@ describe('SelectorCustomOption', () => { setValue: jest.fn(), theme: expect.any(Object), onOptionChanged: jest.fn(), + showInputOption: false, + setShowInputOption: jest.fn(), + inputValue: '1111', + setInputValue: jest.fn(), + setMenuIsOpen: jest.fn(), }; it('should render AlgorithmsSelectorCustomOption correctly', () => { diff --git a/portal/src/app/shared/components/selector-custom-option/SelectorCustomOption.tsx b/portal/src/app/shared/components/selector-custom-option/SelectorCustomOption.tsx index 769957bc..b2166fc9 100644 --- a/portal/src/app/shared/components/selector-custom-option/SelectorCustomOption.tsx +++ b/portal/src/app/shared/components/selector-custom-option/SelectorCustomOption.tsx @@ -1,13 +1,21 @@ -import { OptionProps, components } from 'react-select'; import styles from './SelectorCustomOption.module.scss'; +import cn from 'classnames'; +import { GroupBase, OptionProps, components } from 'react-select'; import { AttSelectOption } from '../att-select'; import { algorithmSections } from '../../../components/protocol-query/constants'; -import cn from 'classnames'; import CheckedSvg from '../../../../assets/images/checked.svg'; import UnCheckedSvg from '../../../../assets/images/unchecked.svg'; +import { CustomInput } from './components'; + +const CheckedAriaLabel: string = 'checked'; -export type SelectorCustomOptionProps = OptionProps & { +export type SelectorCustomOptionProps = OptionProps, true, GroupBase>> & { onOptionChanged: (option: AttSelectOption) => void; + showInputOption: boolean; + setShowInputOption: (show: boolean) => void; + inputValue: string; + setInputValue: (value: string) => void; + setMenuIsOpen: (isOpen: boolean) => void; }; export const AlgorithmsSelectorCustomOption: React.FC = (props: SelectorCustomOptionProps) => { @@ -18,11 +26,13 @@ export const AlgorithmsSelectorCustomOption: React.FC
props.onOptionChanged} /> - checked + onChange={() => props.onOptionChanged} + /> + {CheckedAriaLabel}
{props.label}
@@ -31,16 +41,23 @@ export const AlgorithmsSelectorCustomOption: React.FC export const IterationsSelectorCustomOption: React.FC = (props: SelectorCustomOptionProps) => { return ( - -
- props.onOptionChanged} /> - checked -
- {props.label} -
+ <> + {!props.data.metadata && ( + +
+ props.onOptionChanged} + /> + {CheckedAriaLabel} +
+ {props.label} +
+ )} + + ); -}; \ No newline at end of file +}; diff --git a/portal/src/app/shared/components/selector-custom-option/__snapshots__/SelectorCustomOption.test.tsx.snap b/portal/src/app/shared/components/selector-custom-option/__snapshots__/SelectorCustomOption.test.tsx.snap index 79ce1375..b7f7f765 100644 --- a/portal/src/app/shared/components/selector-custom-option/__snapshots__/SelectorCustomOption.test.tsx.snap +++ b/portal/src/app/shared/components/selector-custom-option/__snapshots__/SelectorCustomOption.test.tsx.snap @@ -10,6 +10,7 @@ exports[`SelectorCustomOption should render AlgorithmsSelectorCustomOption corre > { + const mockOption = { value: 'option1', label: 'Option 1' }; + const mockProps: SelectorCustomOptionProps = { + data: mockOption, + isSelected: false, + selectOption: jest.fn(), + label: 'Option 1', + innerProps: {}, + innerRef: jest.fn(), + children: null, + type: 'option', + isDisabled: false, + isFocused: false, + clearValue: jest.fn(), + cx: jest.fn(), + getStyles: jest.fn(), + getClassNames: jest.fn(), + getValue: jest.fn().mockReturnValue([{ label: 'Option 1', value: 'option1' }]), + hasValue: true, + isMulti: true, + isRtl: false, + options: [], + selectProps: expect.any(Object), + setValue: jest.fn(), + theme: expect.any(Object), + onOptionChanged: jest.fn(), + showInputOption: false, + setShowInputOption: jest.fn(), + inputValue: '1111', + setInputValue: jest.fn(), + setMenuIsOpen: jest.fn(), + }; + + it('should render AlgorithmsSelectorCustomOption correctly', () => { + const { container }: RenderResult = render(); + expect(container.firstChild).toMatchSnapshot(); + }); +}); \ No newline at end of file diff --git a/portal/src/app/shared/components/selector-custom-option/components/CustomInput.tsx b/portal/src/app/shared/components/selector-custom-option/components/CustomInput.tsx new file mode 100644 index 00000000..6e4a45e8 --- /dev/null +++ b/portal/src/app/shared/components/selector-custom-option/components/CustomInput.tsx @@ -0,0 +1,123 @@ +import { useCallback, useEffect, useMemo, useRef } from "react"; +import styles from './CustomInput.module.scss'; +import cn from 'classnames'; +import { AttSelectOption } from "../../att-select"; +import { SelectorCustomOptionProps } from "../SelectorCustomOption"; +import { components } from "react-select"; +import CheckedSvg from '../../../../../assets/images/checked.svg'; +import UnCheckedSvg from '../../../../../assets/images/unchecked.svg'; +import CleanSvg from '../../../../../assets/images/clean.svg'; +import { Button, ButtonActionType, ButtonSize, ButtonStyleType } from "../../att-button"; +import { SELECTOR_CUSTOM_OPTION_EN } from "../translate/en"; + +const CheckedAriaLabel: string = 'checked'; +const CleanAriaLabel: string = 'clean'; + +type OnEventInputChange = (event: React.ChangeEvent) => void; +type OnEventHandler = () => void; + +export const CustomInput: React.FC = (props: SelectorCustomOptionProps) => { + const { showInputOption, setShowInputOption, inputValue, setInputValue, setMenuIsOpen } = props; + const inputOption = useMemo(() => ({ label: inputValue, value: inputValue, metadata: { isInput: true } }), [inputValue]); + const inputRef = useRef(null); + const isInputOptionExists = (props.options as AttSelectOption[]).some((option: AttSelectOption) => option.metadata?.isInput); + + const handleInputChange: OnEventInputChange = (event: React.ChangeEvent) => { + const newValue = event.target.value; + setInputValue(newValue); + if (isNaN(Number(newValue))) { + props.selectOption(inputOption as AttSelectOption); + } + }; + + const applyCustomOption: OnEventHandler = useCallback((): void => { + delete props.data.metadata; + setShowInputOption(false); + setTimeout(() => props.selectOption({ label: inputOption.label, value: inputOption.value }), 0); + }, [props, setShowInputOption, inputOption]); + + useEffect(() => { + if (showInputOption && props.data.metadata?.isInput) { + inputRef.current?.focus(); + props.data.label = props.data.value = inputValue; + } + }, [showInputOption, props.data, inputValue]); + + useEffect(() => { + const currentOptionsSelected = (props.getValue() ?? []).map((option: AttSelectOption) => option.label); + const lastOptionSelected = currentOptionsSelected[currentOptionsSelected.length - 1]; + + // handles deselection when the user selects the input option and then selects another option + if (currentOptionsSelected.includes(inputOption.label) && lastOptionSelected !== inputOption.label && props.data.metadata?.isInput) { + props.selectOption(inputOption as AttSelectOption); + } + // handles deselection when the user selects the input option and then types a wrong value + if (lastOptionSelected === inputOption.label && isNaN(Number(inputValue))) { + props.selectOption(inputOption as AttSelectOption); + } + }, [props, inputOption, inputValue]); + + return ( + <> + {showInputOption && props.data.metadata?.isInput && ( + +
+
+ props.onOptionChanged} + /> + {CheckedAriaLabel} +
+
+ setMenuIsOpen(true)} + className={styles.add_new_input_option} + disabled={!props.isSelected} + /> + {inputValue !== '' && ( + <> + setInputValue('')} + src={CleanSvg} + alt={CleanAriaLabel} + tabIndex={props.isSelected ? 0 : -1} + /> + + + )} + +
+
+
+ )} + {props.data.metadata?.isAddNewButton && isInputOptionExists && ( + + )} + + ); +} \ No newline at end of file diff --git a/portal/src/app/shared/components/selector-custom-option/components/__snapshots__/CustomInput.test.tsx.snap b/portal/src/app/shared/components/selector-custom-option/components/__snapshots__/CustomInput.test.tsx.snap new file mode 100644 index 00000000..b6a5efa5 --- /dev/null +++ b/portal/src/app/shared/components/selector-custom-option/components/__snapshots__/CustomInput.test.tsx.snap @@ -0,0 +1,3 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`CustomInput should render AlgorithmsSelectorCustomOption correctly 1`] = `null`; diff --git a/portal/src/app/shared/components/selector-custom-option/components/index.ts b/portal/src/app/shared/components/selector-custom-option/components/index.ts new file mode 100644 index 00000000..3369f2a3 --- /dev/null +++ b/portal/src/app/shared/components/selector-custom-option/components/index.ts @@ -0,0 +1 @@ +export * from './CustomInput'; diff --git a/portal/src/app/shared/components/selector-custom-option/translate/en.ts b/portal/src/app/shared/components/selector-custom-option/translate/en.ts new file mode 100644 index 00000000..c40dfc98 --- /dev/null +++ b/portal/src/app/shared/components/selector-custom-option/translate/en.ts @@ -0,0 +1,5 @@ +export const SELECTOR_CUSTOM_OPTION_EN = { + ADD_BUTTON: 'Add', + ADD_NEW: 'Add new', + ADD_NEW_BUTTON: '+ Add new', +} \ No newline at end of file diff --git a/portal/src/assets/images/clean.svg b/portal/src/assets/images/clean.svg new file mode 100644 index 00000000..3c90b415 --- /dev/null +++ b/portal/src/assets/images/clean.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/portal/src/assets/images/eye-hover.svg b/portal/src/assets/images/eye-hover.svg index 7067ec62..c8764220 100644 --- a/portal/src/assets/images/eye-hover.svg +++ b/portal/src/assets/images/eye-hover.svg @@ -1,10 +1,10 @@ - + - +