Skip to content

Commit

Permalink
Note input form improvements (#114)
Browse files Browse the repository at this point in the history
* Do not clear product when input is closed

* Validate product option that wasn't explicitly added

* Use numeric text input instead of type="number" for numeric fields

* Added blurOnSelect only for touch events

* Removed AppSelect component

* Fixed product form top padding

* Fixed inputMode

* Do not set value if any non numeric symbol entered

* Trying to fix flaky test

* Added input adornments

* Trying to fix flaky test
  • Loading branch information
pkirilin authored Jul 16, 2024
1 parent b8f7adf commit 0d06bd3
Show file tree
Hide file tree
Showing 16 changed files with 135 additions and 103 deletions.
38 changes: 27 additions & 11 deletions src/frontend/src/entities/category/ui/CategorySelect.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { type FC } from 'react';
import { Autocomplete, CircularProgress, TextField } from '@mui/material';
import { type SyntheticEvent, type FC } from 'react';
import { type SelectOption, type SelectProps } from '@/shared/types';
import { AppSelect } from '@/shared/ui';

interface CategorySelectProps extends SelectProps<SelectOption> {
options: SelectOption[];
Expand All @@ -17,20 +17,36 @@ export const CategorySelect: FC<CategorySelectProps> = ({
options,
optionsLoading,
}) => {
const handleChange = (value: SelectOption | null): void => {
setValue(value);
const handleChange = (_: SyntheticEvent, newValue: SelectOption | null): void => {
setValue(newValue);
};

return (
<AppSelect
options={options}
<Autocomplete
blurOnSelect="touch"
value={value}
options={options}
getOptionLabel={option => option.name}
isOptionEqualToValue={(first, second) => first.name === second.name}
onChange={handleChange}
isLoading={optionsLoading}
isInvalid={isInvalid}
label={label}
placeholder={placeholder}
helperText={helperText}
renderInput={params => (
<TextField
{...params}
label={label}
placeholder={placeholder}
error={isInvalid}
helperText={helperText}
margin="normal"
InputProps={{
...params.InputProps,
endAdornment: optionsLoading ? (
<CircularProgress color="inherit" size={20} />
) : (
params.InputProps.endAdornment
),
}}
/>
)}
/>
);
};
5 changes: 3 additions & 2 deletions src/frontend/src/entities/product/lib/useAutocompleteInput.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,21 +5,22 @@ import { type ProductAutocompleteProps } from '../ui/ProductAutocomplete';

export type AutocompleteInputProps = Pick<
ProductAutocompleteProps,
'value' | 'onChange' | 'error' | 'helperText'
'value' | 'onChange' | 'error' | 'helperText' | 'forceValidate'
>;

export type AutocompleteInput = UseInputResult<AutocompleteOption | null, AutocompleteInputProps>;

const mapToInputProps: MapToInputPropsFunction<
AutocompleteOption | null,
AutocompleteInputProps
> = ({ value, setValue, isInvalid, helperText }) => ({
> = ({ value, setValue, isInvalid, helperText, forceValidate }) => ({
value,
onChange: newValue => {
setValue(newValue);
},
error: isInvalid,
helperText,
forceValidate,
});

const validate: ValidatorFunction<AutocompleteOption | null> = value => value !== null;
Expand Down
16 changes: 13 additions & 3 deletions src/frontend/src/entities/product/ui/ProductAutocomplete.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import {
createFilterOptions,
type SvgIconOwnProps,
Box,
type AutocompleteCloseReason,
} from '@mui/material';
import TextField from '@mui/material/TextField';
import { useState, type FC, type SyntheticEvent } from 'react';
Expand All @@ -31,22 +32,24 @@ export interface ProductAutocompleteProps {
options: readonly AutocompleteOption[];
loading: boolean;
value: AutocompleteOption | null;
onChange: (selectedProduct: AutocompleteOption | null) => void;
formValues: FormValues;
forceValidate: () => void;
helperText?: string;
error?: boolean;
autoFocus?: boolean;
onChange: (selectedProduct: AutocompleteOption | null) => void;
}

export const ProductAutocomplete: FC<ProductAutocompleteProps> = ({
options,
loading,
value,
onChange,
formValues,
forceValidate,
helperText,
error,
autoFocus,
onChange,
}) => {
const [newProductIconColor, setNewProductIconColor] =
useState<SvgIconOwnProps['color']>('action');
Expand Down Expand Up @@ -135,14 +138,21 @@ export const ProductAutocomplete: FC<ProductAutocompleteProps> = ({
onChange(selectedProduct);
};

const handleClose = (_: SyntheticEvent, reason: AutocompleteCloseReason): void => {
if (reason === 'blur' || reason === 'escape' || reason === 'toggleInput') {
forceValidate();
}
};

return (
<Autocomplete
value={value}
onChange={handleOptionChange}
onClose={handleClose}
options={options}
selectOnFocus
clearOnBlur
handleHomeEndKeys
blurOnSelect="touch"
freeSolo
getOptionLabel={getOptionLabel}
filterOptions={filterOptions}
Expand Down
18 changes: 15 additions & 3 deletions src/frontend/src/entities/product/ui/ProductInputForm.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { TextField } from '@mui/material';
import { InputAdornment, TextField } from '@mui/material';
import { useEffect, type FC, type FormEventHandler, type ReactElement } from 'react';
import { useInput } from '@/shared/hooks';
import {
Expand Down Expand Up @@ -101,19 +101,31 @@ export const ProductInputForm: FC<Props> = ({
/>
<TextField
{...caloriesCostInput.inputProps}
type="number"
fullWidth
margin="normal"
label="Calories cost"
placeholder="Enter calories cost"
inputProps={{
type: 'text',
inputMode: 'numeric',
}}
InputProps={{
endAdornment: <InputAdornment position="end">kcal</InputAdornment>,
}}
/>
<TextField
{...defaultQuantityInput.inputProps}
type="number"
fullWidth
margin="normal"
label="Default quantity"
placeholder="Enter default quantity"
inputProps={{
type: 'text',
inputMode: 'numeric',
}}
InputProps={{
endAdornment: <InputAdornment position="end">g</InputAdornment>,
}}
/>
{renderCategoryInput(categoryInput.inputProps)}
</form>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,18 @@ export const whenProductCleared = async (user: UserEvent): Promise<void> => {
await user.clear(screen.getByRole('combobox', { name: /product/i }));
};

export const whenProductSelectedNameChanged = async (
user: UserEvent,
name: string,
): Promise<void> => {
await user.clear(screen.getByRole('combobox', { name: /product/i }));
await user.type(screen.getByRole('combobox', { name: /product/i }), name);
};

export const whenProductSelectClosed = async (user: UserEvent): Promise<void> => {
await user.click(screen.getByRole('combobox', { name: /product/i }));
};

export const whenAddedNotExistingProductOption = async (
user: UserEvent,
name: string,
Expand Down Expand Up @@ -89,7 +101,7 @@ export const thenProductIsInvalid = async (): Promise<void> => {
};

export const thenQuantityHasValue = async (value: number): Promise<void> => {
expect(await screen.findByPlaceholderText(/product quantity/i)).toHaveValue(value);
expect(await screen.findByPlaceholderText(/product quantity/i)).toHaveValue(value.toString());
};

export const thenDialogShouldBeHidden = async (): Promise<void> => {
Expand All @@ -113,11 +125,11 @@ export const thenProductNameIsInvalid = async (): Promise<void> => {
};

export const thenProductCaloriesCostHasValue = async (value: number): Promise<void> => {
expect(screen.getByPlaceholderText(/calories cost/i)).toHaveValue(value);
expect(screen.getByPlaceholderText(/calories cost/i)).toHaveValue(value.toString());
};

export const thenProductDefaultQuantityHasValue = async (value: number): Promise<void> => {
expect(screen.getByPlaceholderText(/default quantity/i)).toHaveValue(value);
expect(screen.getByPlaceholderText(/default quantity/i)).toHaveValue(value.toString());
};

export const thenProductCategoryIsEmpty = async (): Promise<void> => {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -221,6 +221,7 @@ test('I can close product dialog without save and add another product', async ()
await steps.whenProductCategorySelected(user, /test category/i);
await steps.whenDialogClosed(user);
await steps.thenNoteFormShouldBeVisible();
await steps.thenProductHasValue('Chicken');

await steps.whenAddedNotExistingProductOption(user, 'Rye bread');
await steps.thenProductFormShouldBeVisible();
Expand All @@ -230,7 +231,7 @@ test('I can close product dialog without save and add another product', async ()
await steps.thenProductCategoryIsEmpty();
});

test('I cannot add note if input invalid', async () => {
test('I cannot add note if product is empty', async () => {
const user = userEvent.setup();

render(
Expand All @@ -248,6 +249,25 @@ test('I cannot add note if input invalid', async () => {
await steps.thenAddNoteButtonIsDisabled();
});

test(`I cannot add note if product has value that wasn't explicitly added`, async () => {
const user = userEvent.setup();

render(
givenNoteInputFlow()
.withQuantity(100)
.withCategoriesForSelect('Test Category')
.withProductForSelect({ name: 'Chicken' })
.withSelectedProduct('Chicken')
.please(),
);

await steps.whenDialogOpened(user);
await steps.whenProductSelectedNameChanged(user, 'ch');
await steps.whenProductSelectClosed(user);
await steps.thenProductIsInvalid();
await steps.thenAddNoteButtonIsDisabled();
});

test('I cannot add note with new product if its name changed to invalid', async () => {
const user = userEvent.setup();

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -149,6 +149,7 @@ export const NoteInputFlow: FC<Props> = ({

const handleProductCancel = (): void => {
productForm.clearValues();
productAutocompleteInput.clearValue();
setDialogStateType('note');
};

Expand Down Expand Up @@ -244,7 +245,7 @@ export const NoteInputFlow: FC<Props> = ({
<Dialog
pinToTop
renderMode="fullScreenOnMobile"
disableContentPaddingTop={disableContentPaddingTop}
disableContentPaddingTop={dialogStateType === 'note' && disableContentPaddingTop}
disableContentPaddingBottom
opened={dialogOpened}
title={dialogState.title}
Expand Down
12 changes: 9 additions & 3 deletions src/frontend/src/features/note/addEdit/ui/NoteInputForm.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { TextField } from '@mui/material';
import { InputAdornment, TextField } from '@mui/material';
import { useEffect, type FC, type FormEventHandler, type ReactElement } from 'react';
import { noteLib, type noteModel } from '@/entities/note';
import { type productLib } from '@/entities/product';
Expand Down Expand Up @@ -83,11 +83,17 @@ export const NoteInputForm: FC<Props> = ({
{renderProductAutocomplete(productAutocompleteInput.inputProps)}
<TextField
{...quantityInput.inputProps}
type="number"
label="Quantity"
placeholder="Product quantity, g"
placeholder="Product quantity"
margin="normal"
fullWidth
inputProps={{
type: 'text',
inputMode: 'numeric',
}}
InputProps={{
endAdornment: <InputAdornment position="end">g</InputAdornment>,
}}
/>
</form>
);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -77,8 +77,8 @@ export const whenDialogClosed = async (user: UserEvent): Promise<void> => {
};

export const whenProductNameChanged = async (user: UserEvent, name: string): Promise<void> => {
await user.clear(screen.getByPlaceholderText(/product name/i));
await user.type(screen.getByPlaceholderText(/product name/i), name);
await user.clear(screen.getByRole('textbox', { name: /name/i }));
await user.type(screen.getByRole('textbox', { name: /name/i }), name);
};

export const whenCaloriesCostChanged = async (user: UserEvent, cost: string): Promise<void> => {
Expand Down Expand Up @@ -110,6 +110,10 @@ export const whenProductSaved = async (user: UserEvent): Promise<void> => {
export const expectCategory = (name: string): SelectOption =>
expect.objectContaining<Partial<SelectOption>>({ name });

export const thenProductFormIsVisible = async (): Promise<void> => {
expect(await screen.findByRole('dialog', { name: /product/i })).toBeVisible();
};

export const thenFormValueContains = async (
onSubmitMock: Mock,
product: productModel.FormValues,
Expand All @@ -134,15 +138,15 @@ export const thenCaloriesCostIsInvalid = async (): Promise<void> => {
};

export const thenCaloriesCostHasValue = async (value: number): Promise<void> => {
expect(screen.getByPlaceholderText(/calories cost/i)).toHaveValue(value);
expect(screen.getByPlaceholderText(/calories cost/i)).toHaveValue(value.toString());
};

export const thenDefaultQuantityIsInvalid = async (): Promise<void> => {
expect(screen.getByPlaceholderText(/default quantity/i)).toBeInvalid();
};

export const thenDefaultQuantityHasValue = async (value: number): Promise<void> => {
expect(screen.getByPlaceholderText(/default quantity/i)).toHaveValue(value);
expect(screen.getByPlaceholderText(/default quantity/i)).toHaveValue(value.toString());
};

export const thenCategoryIsInvalid = async (): Promise<void> => {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import {
thenDefaultQuantityIsInvalid,
thenDialogShouldBeHidden,
thenFormValueContains,
thenProductFormIsVisible,
thenProductNameHasValue,
thenProductNameIsInvalid,
thenProductNameIsValid,
Expand Down Expand Up @@ -39,6 +40,8 @@ test('I can add new product', async () => {
);

await whenDialogOpened(user);
await thenProductFormIsVisible();

await whenProductNameChanged(user, 'Potato');
await whenCaloriesCostChanged(user, '150');
await whenDefaultQuantityChanged(user, '120');
Expand Down Expand Up @@ -71,6 +74,8 @@ test('I can edit product', async () => {
);

await whenDialogOpened(user);
await thenProductFormIsVisible();

await whenProductNameChanged(user, 'Potato edited');
await whenCaloriesCostChanged(user, '140');
await whenDefaultQuantityChanged(user, '110');
Expand Down
Loading

0 comments on commit 0d06bd3

Please sign in to comment.