From 0f6f112b6dc4b9e4d8398c154ec9f09dafe1708e Mon Sep 17 00:00:00 2001 From: Pavel Kirilin Date: Sun, 7 Jul 2024 16:15:55 +0300 Subject: [PATCH] Single dialog button with tabs for note input (#112) * Added input method tabs for NoteInputDialog * Hotfix eslint https://github.com/microsoft/vscode-eslint/issues/1856 * Refactor NoteInputDialog in a new component * Add reusable AddOrEditNoteFlow component * Setup tests and dsl for AddOrEditNoteFlow * Fixed act warning in AddOrEditNoteFlow tests * Moved tests from NoteInputDialog to AddOrEditNoteFlow * Use AddOrEditNoteFlow in EditNote * Added aider * Added reset for add/edit note mutations * Remove old NoteInputDialog * Upload and preview photo in AddOrEditNoteFlow * Merge repeated note props into FormValues prop * Hide tabs for edit note dialog * Implement recognize note in "From photo" tab * Remove unused components and hooks * Use type alias for productAutocompleteInput * Fix dialog and tab paddings * WIP: Fix clear note form after successful edit * Fixed focus trap issue * Fixed paddings --- .gitignore | 1 + src/frontend/.pnp.cjs | 4 +- src/frontend/package.json | 5 + src/frontend/src/entities/note/lib/index.ts | 1 + .../src/entities/note/lib/useFormValues.ts | 25 ++ src/frontend/src/entities/note/model.ts | 7 + .../entities/product/ui/ProductInputForm.tsx | 4 +- .../src/features/note/addEdit/lib/index.ts | 3 +- .../src/features/note/addEdit/lib/types.ts | 15 + .../addEdit/lib/useAddProductIfNotExists.ts | 33 +- .../note/addEdit/lib/useNoteDialog.tsx | 99 ----- .../note/addEdit/lib/useProductDialog.tsx | 59 --- .../note/addEdit/lib/useRecognizeNotes.ts | 6 + .../src/features/note/addEdit/model.ts | 15 +- .../src/features/note/addEdit/ui/AddNote.tsx | 113 ++++-- .../note/addEdit/ui/AddNoteByPhoto.tsx | 112 ------ .../note/addEdit/ui/AddNoteDialogContent.tsx | 110 ++++++ .../src/features/note/addEdit/ui/EditNote.tsx | 98 +++-- .../NoteInputDialog/NoteInputDialog.test.tsx | 367 ------------------ .../ui/NoteInputDialog/NoteInputDialog.tsx | 163 -------- .../note/addEdit/ui/NoteInputDialog/index.ts | 1 - .../addEdit/ui/NoteInputDialogByPhoto.tsx | 257 ------------ .../NoteInputFlow/NoteInputFlow.builder.tsx | 198 ++++++++++ .../NoteInputFlow.steps.tsx} | 148 +------ .../ui/NoteInputFlow/NoteInputFlow.test.tsx | 324 ++++++++++++++++ .../ui/NoteInputFlow/NoteInputFlow.tsx | 277 +++++++++++++ .../note/addEdit/ui/NoteInputFlow/index.ts | 1 + .../note/addEdit/ui/NoteInputForm.tsx | 33 +- .../addEdit/ui/NoteInputFromPhotoFlow.tsx | 128 ++++++ .../src/features/note/addEdit/ui/index.ts | 1 - .../product/addEdit/ui/AddProduct.tsx | 2 +- .../product/addEdit/ui/EditProduct.tsx | 2 +- .../ProductInputDialog.fixture.tsx | 2 +- .../ProductInputDialog/ProductInputDialog.tsx | 21 +- .../src/shared/ui/Dialog/FullScreenDialog.tsx | 14 +- .../src/shared/ui/Dialog/ModalDialog.tsx | 24 +- src/frontend/src/shared/ui/Dialog/types.ts | 3 + src/frontend/src/shared/ui/UploadButton.tsx | 37 ++ src/frontend/src/shared/ui/index.ts | 1 + .../src/widgets/MealsList/ui/NotesList.tsx | 33 +- src/frontend/yarn.lock | 3 + 41 files changed, 1384 insertions(+), 1366 deletions(-) create mode 100644 src/frontend/src/entities/note/lib/useFormValues.ts create mode 100644 src/frontend/src/features/note/addEdit/lib/types.ts delete mode 100644 src/frontend/src/features/note/addEdit/lib/useNoteDialog.tsx delete mode 100644 src/frontend/src/features/note/addEdit/lib/useProductDialog.tsx delete mode 100644 src/frontend/src/features/note/addEdit/ui/AddNoteByPhoto.tsx create mode 100644 src/frontend/src/features/note/addEdit/ui/AddNoteDialogContent.tsx delete mode 100644 src/frontend/src/features/note/addEdit/ui/NoteInputDialog/NoteInputDialog.test.tsx delete mode 100644 src/frontend/src/features/note/addEdit/ui/NoteInputDialog/NoteInputDialog.tsx delete mode 100644 src/frontend/src/features/note/addEdit/ui/NoteInputDialog/index.ts delete mode 100644 src/frontend/src/features/note/addEdit/ui/NoteInputDialogByPhoto.tsx create mode 100644 src/frontend/src/features/note/addEdit/ui/NoteInputFlow/NoteInputFlow.builder.tsx rename src/frontend/src/features/note/addEdit/ui/{NoteInputDialog/NoteInputDialog.fixture.tsx => NoteInputFlow/NoteInputFlow.steps.tsx} (56%) create mode 100644 src/frontend/src/features/note/addEdit/ui/NoteInputFlow/NoteInputFlow.test.tsx create mode 100644 src/frontend/src/features/note/addEdit/ui/NoteInputFlow/NoteInputFlow.tsx create mode 100644 src/frontend/src/features/note/addEdit/ui/NoteInputFlow/index.ts create mode 100644 src/frontend/src/features/note/addEdit/ui/NoteInputFromPhotoFlow.tsx create mode 100644 src/frontend/src/shared/ui/UploadButton.tsx diff --git a/.gitignore b/.gitignore index 84962b967..2dd8f3e27 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,4 @@ .DS_Store .env !tests/.env +.aider* diff --git a/src/frontend/.pnp.cjs b/src/frontend/.pnp.cjs index a3d4c7f4f..c8d24656f 100644 --- a/src/frontend/.pnp.cjs +++ b/src/frontend/.pnp.cjs @@ -7879,14 +7879,14 @@ const RAW_RUNTIME_STATE = ]],\ ["eslint-plugin-prettier", [\ ["npm:5.1.3", {\ - "packageLocation": "./.yarn/cache/eslint-plugin-prettier-npm-5.1.3-496c3b84df-4f26a30444.zip/node_modules/eslint-plugin-prettier/",\ + "packageLocation": "./.yarn/unplugged/eslint-plugin-prettier-virtual-a3c714f1b6/node_modules/eslint-plugin-prettier/",\ "packageDependencies": [\ ["eslint-plugin-prettier", "npm:5.1.3"]\ ],\ "linkType": "SOFT"\ }],\ ["virtual:a98f04960cc7eaa90b3b4031434bf51078616b854270c891bf505dc7080da50229384e574995251e6688b61b7cd512851213f0347ece1285c879b26dde9cc6b6#npm:5.1.3", {\ - "packageLocation": "./.yarn/__virtual__/eslint-plugin-prettier-virtual-a3c714f1b6/0/cache/eslint-plugin-prettier-npm-5.1.3-496c3b84df-4f26a30444.zip/node_modules/eslint-plugin-prettier/",\ + "packageLocation": "./.yarn/unplugged/eslint-plugin-prettier-virtual-a3c714f1b6/node_modules/eslint-plugin-prettier/",\ "packageDependencies": [\ ["eslint-plugin-prettier", "virtual:a98f04960cc7eaa90b3b4031434bf51078616b854270c891bf505dc7080da50229384e574995251e6688b61b7cd512851213f0347ece1285c879b26dde9cc6b6#npm:5.1.3"],\ ["@types/eslint", null],\ diff --git a/src/frontend/package.json b/src/frontend/package.json index e9b6f15ab..80c793e11 100644 --- a/src/frontend/package.json +++ b/src/frontend/package.json @@ -91,5 +91,10 @@ }, "engines": { "node": ">=18.6" + }, + "dependenciesMeta": { + "eslint-plugin-prettier@5.1.3": { + "unplugged": true + } } } diff --git a/src/frontend/src/entities/note/lib/index.ts b/src/frontend/src/entities/note/lib/index.ts index 110574109..d2379deb6 100644 --- a/src/frontend/src/entities/note/lib/index.ts +++ b/src/frontend/src/entities/note/lib/index.ts @@ -2,3 +2,4 @@ export * from './groupByMealType'; export * from './mealsHelpers'; export * from './useNotes'; export * from './useNextDisplayOrder'; +export * from './useFormValues'; diff --git a/src/frontend/src/entities/note/lib/useFormValues.ts b/src/frontend/src/entities/note/lib/useFormValues.ts new file mode 100644 index 000000000..53fd1f47b --- /dev/null +++ b/src/frontend/src/entities/note/lib/useFormValues.ts @@ -0,0 +1,25 @@ +import { useCallback, useMemo, useState } from 'react'; +import { type FormValues } from '../model'; + +interface Result { + values: FormValues; + setValues: (values: FormValues) => void; + clearValues: () => void; +} + +export const useFormValues = (initialValues: FormValues): Result => { + const [values, setValues] = useState(initialValues); + + const clearValues = useCallback(() => { + setValues(initialValues); + }, [initialValues]); + + return useMemo( + () => ({ + values, + setValues, + clearValues, + }), + [clearValues, values], + ); +}; diff --git a/src/frontend/src/entities/note/model.ts b/src/frontend/src/entities/note/model.ts index d838a1c9d..32e8671e9 100644 --- a/src/frontend/src/entities/note/model.ts +++ b/src/frontend/src/entities/note/model.ts @@ -16,3 +16,10 @@ export interface NoteItem { productDefaultQuantity: number; calories: number; } + +export interface FormValues { + pageId: number; + mealType: MealType; + displayOrder: number; + quantity: number; +} diff --git a/src/frontend/src/entities/product/ui/ProductInputForm.tsx b/src/frontend/src/entities/product/ui/ProductInputForm.tsx index 551b2ceba..81d75bf2e 100644 --- a/src/frontend/src/entities/product/ui/ProductInputForm.tsx +++ b/src/frontend/src/entities/product/ui/ProductInputForm.tsx @@ -15,7 +15,7 @@ import { validateDefaultQuantity, } from '../model'; -export interface ProductInputFormProps { +interface Props { id: string; values: FormValues; touched?: boolean; @@ -24,7 +24,7 @@ export interface ProductInputFormProps { onSubmitDisabledChange: (disabled: boolean) => void; } -export const ProductInputForm: FC = ({ +export const ProductInputForm: FC = ({ id, values, touched = false, diff --git a/src/frontend/src/features/note/addEdit/lib/index.ts b/src/frontend/src/features/note/addEdit/lib/index.ts index 1e5c40018..a9549c13d 100644 --- a/src/frontend/src/features/note/addEdit/lib/index.ts +++ b/src/frontend/src/features/note/addEdit/lib/index.ts @@ -1,5 +1,4 @@ +export * from './types'; export * from './mapping'; export * from './useAddProductIfNotExists'; -export * from './useNoteDialog'; -export * from './useProductDialog'; export * from './useRecognizeNotes'; diff --git a/src/frontend/src/features/note/addEdit/lib/types.ts b/src/frontend/src/features/note/addEdit/lib/types.ts new file mode 100644 index 000000000..3deb0a2a1 --- /dev/null +++ b/src/frontend/src/features/note/addEdit/lib/types.ts @@ -0,0 +1,15 @@ +import { type productLib, type productModel } from '@/entities/product'; +import { type Note } from '../model'; + +export interface RenderContentProps { + submitLoading: boolean; + submitDisabled: boolean; + productAutocompleteInput: productLib.AutocompleteInput; + productAutocompleteData: productLib.AutocompleteData; + productFormValues: productModel.FormValues; + onClose: () => void; + onSubmit: (note: Note) => Promise; + onSubmitDisabledChange: (disabled: boolean) => void; + onProductChange: (value: productModel.AutocompleteOption | null) => void; + onProductFormValuesChange: (values: productModel.FormValues) => void; +} diff --git a/src/frontend/src/features/note/addEdit/lib/useAddProductIfNotExists.ts b/src/frontend/src/features/note/addEdit/lib/useAddProductIfNotExists.ts index a3961e7c0..aa28f0299 100644 --- a/src/frontend/src/features/note/addEdit/lib/useAddProductIfNotExists.ts +++ b/src/frontend/src/features/note/addEdit/lib/useAddProductIfNotExists.ts @@ -1,8 +1,5 @@ -import { store } from '@/app/store'; import { type CreateProductRequest, productApi, type productModel } from '@/entities/product'; -type AddProductIfNotExistsFn = (product: productModel.AutocompleteOption) => Promise; - const mapToCreateProductRequest = ({ name, caloriesCost, @@ -15,30 +12,28 @@ const mapToCreateProductRequest = ({ categoryId: category?.id ?? 0, }); -export const addProductIfNotExists: AddProductIfNotExistsFn = async product => { - if (!product.freeSolo) { - return product.id; - } - - const request = mapToCreateProductRequest(product); - - const { id } = await store - .dispatch(productApi.endpoints.createProduct.initiate(request)) - .unwrap(); +type AddProductIfNotExistsFn = (product: productModel.AutocompleteOption) => Promise; - return id; -}; +interface Result { + sendRequest: AddProductIfNotExistsFn; + isLoading: boolean; +} -export const useAddProductIfNotExists = (): AddProductIfNotExistsFn => { - const [createProduct] = productApi.useCreateProductMutation(); +export const useAddProductIfNotExists = (): Result => { + const [addProduct, { isLoading }] = productApi.useCreateProductMutation(); - return async product => { + const sendRequest: AddProductIfNotExistsFn = async product => { if (!product.freeSolo) { return product.id; } const request = mapToCreateProductRequest(product); - const { id } = await createProduct(request).unwrap(); + const { id } = await addProduct(request).unwrap(); return id; }; + + return { + sendRequest, + isLoading, + }; }; diff --git a/src/frontend/src/features/note/addEdit/lib/useNoteDialog.tsx b/src/frontend/src/features/note/addEdit/lib/useNoteDialog.tsx deleted file mode 100644 index 695126794..000000000 --- a/src/frontend/src/features/note/addEdit/lib/useNoteDialog.tsx +++ /dev/null @@ -1,99 +0,0 @@ -import { useCallback, useState } from 'react'; -import { type noteModel } from '@/entities/note'; -import { ProductAutocomplete, type productLib, type productModel } from '@/entities/product'; -import { type Note, type DialogState } from '../model'; -import { NoteInputForm } from '../ui/NoteInputForm'; - -interface Args { - pageId: number; - mealType: noteModel.MealType; - displayOrder: number; - title: string; - submitText: string; - quantity: number; - productAutocompleteData: productLib.AutocompleteData; - productAutocompleteInput: productLib.AutocompleteInput; - productFormValues: productModel.FormValues; - onClose: () => void; - onSubmit: (note: Note) => Promise; - onProductChange: (value: productModel.AutocompleteOption | null) => void; -} - -interface Result { - state: DialogState; - onSubmitSuccess: () => void; - onSubmitDisabledChange: (disabled: boolean) => void; -} - -export const useNoteDialog = ({ - pageId, - mealType, - displayOrder, - title, - submitText, - quantity, - productAutocompleteData, - productAutocompleteInput, - productFormValues, - onClose, - onSubmit, - onProductChange, -}: Args): Result => { - const [submitDisabled, setSubmitDisabled] = useState(true); - const [submitLoading, setSubmitLoading] = useState(false); - const cancelDisabled = submitLoading; - - const handleSubmit = async (note: Note): Promise => { - try { - setSubmitLoading(true); - await onSubmit(note); - } catch (err) { - setSubmitLoading(false); - } - }; - - const handleSubmitDisabledChange = useCallback((disabled: boolean): void => { - setSubmitDisabled(disabled); - }, []); - - const handleSubmitSuccess = useCallback((): void => { - setSubmitLoading(false); - }, []); - - return { - state: { - type: 'note', - title, - submitText, - formId: 'note-form', - submitLoading, - submitDisabled, - cancelDisabled, - content: ( - ( - - )} - onSubmit={handleSubmit} - onSubmitDisabledChange={handleSubmitDisabledChange} - /> - ), - onClose, - }, - onSubmitSuccess: handleSubmitSuccess, - onSubmitDisabledChange: handleSubmitDisabledChange, - }; -}; diff --git a/src/frontend/src/features/note/addEdit/lib/useProductDialog.tsx b/src/frontend/src/features/note/addEdit/lib/useProductDialog.tsx deleted file mode 100644 index 87ed8392e..000000000 --- a/src/frontend/src/features/note/addEdit/lib/useProductDialog.tsx +++ /dev/null @@ -1,59 +0,0 @@ -import { useCallback, useState } from 'react'; -import { CategorySelect, type categoryLib } from '@/entities/category'; -import { ProductInputForm, type productModel } from '@/entities/product'; -import { type DialogState } from '../model'; - -interface Args { - productFormValues: productModel.FormValues; - categorySelect: categoryLib.CategorySelectData; - onClose: () => void; - onSubmit: (values: productModel.FormValues) => void | Promise; -} - -interface Result { - state: DialogState; -} - -export const useProductDialog = ({ - productFormValues, - categorySelect, - onClose, - onSubmit, -}: Args): Result => { - const [submitDisabled, setSubmitDisabled] = useState(true); - - const handleSubmitDisabledChange = useCallback((disabled: boolean): void => { - setSubmitDisabled(disabled); - }, []); - - return { - state: { - type: 'product', - title: 'New product', - submitText: 'Add', - submitLoading: false, - submitDisabled, - cancelDisabled: false, - formId: 'product-form', - content: ( - ( - - )} - onSubmit={onSubmit} - onSubmitDisabledChange={handleSubmitDisabledChange} - /> - ), - onClose, - }, - }; -}; diff --git a/src/frontend/src/features/note/addEdit/lib/useRecognizeNotes.ts b/src/frontend/src/features/note/addEdit/lib/useRecognizeNotes.ts index 19126833c..4663f4d16 100644 --- a/src/frontend/src/features/note/addEdit/lib/useRecognizeNotes.ts +++ b/src/frontend/src/features/note/addEdit/lib/useRecognizeNotes.ts @@ -4,6 +4,12 @@ import { parseClientError, type ClientError } from '@/shared/api'; type FetchFn = (file: File) => Promise; +export const EMPTY_RECOGNIZE_NOTES_RESULT: RecognizeNotesResult = { + notes: [], + isLoading: false, + isSuccess: false, +}; + export interface RecognizeNotesResult { notes: RecognizeNoteItem[]; isLoading: boolean; diff --git a/src/frontend/src/features/note/addEdit/model.ts b/src/frontend/src/features/note/addEdit/model.ts index 8e66bca66..8a663cc73 100644 --- a/src/frontend/src/features/note/addEdit/model.ts +++ b/src/frontend/src/features/note/addEdit/model.ts @@ -1,20 +1,7 @@ -import { type ReactElement } from 'react'; import { type noteModel } from '@/entities/note'; import { type productModel } from '@/entities/product'; -export type DialogStateType = 'note' | 'product'; - -export interface DialogState { - type: DialogStateType; - title: string; - submitText: string; - submitLoading: boolean; - submitDisabled: boolean; - cancelDisabled: boolean; - formId: string; - content: ReactElement; - onClose: () => void; -} +export type InputMethod = 'fromInput' | 'fromPhoto'; export interface Note { mealType: noteModel.MealType; diff --git a/src/frontend/src/features/note/addEdit/ui/AddNote.tsx b/src/frontend/src/features/note/addEdit/ui/AddNote.tsx index 1b81c46c3..1bf030979 100644 --- a/src/frontend/src/features/note/addEdit/ui/AddNote.tsx +++ b/src/frontend/src/features/note/addEdit/ui/AddNote.tsx @@ -1,62 +1,99 @@ import AddIcon from '@mui/icons-material/Add'; -import { useCallback, type FC } from 'react'; +import { useCallback, type FC, useState } from 'react'; import { categoryLib } from '@/entities/category'; import { noteApi, type noteModel, noteLib } from '@/entities/note'; import { productLib } from '@/entities/product'; -import { useToggle } from '@/shared/hooks'; import { Button } from '@/shared/ui'; +import { useAddProductIfNotExists, useRecognizeNotes } from '../lib'; import { mapToCreateNoteRequest } from '../lib/mapping'; -import { useAddProductIfNotExists } from '../lib/useAddProductIfNotExists'; -import { type Note } from '../model'; -import { NoteInputDialog } from './NoteInputDialog'; +import { type UploadedPhoto, type Note, type InputMethod } from '../model'; +import { AddNoteDialogContent } from './AddNoteDialogContent'; +import { NoteInputFlow } from './NoteInputFlow'; interface Props { pageId: number; mealType: noteModel.MealType; - displayOrder: number; } -export const AddNote: FC = ({ pageId, mealType, displayOrder }) => { - const [dialogOpened, toggleDialog] = useToggle(); - const [addNote, addNoteResponse] = noteApi.useCreateNoteMutation(); - const { reset: resetAddNote } = addNoteResponse; +export const AddNote: FC = ({ pageId, mealType }) => { + const [addNote, { reset, ...addNoteResponse }] = noteApi.useCreateNoteMutation(); + const addProductIfNotExists = useAddProductIfNotExists(); + const [recognizeNotes, recognizeNotesResult] = useRecognizeNotes(); + const notes = noteLib.useNotes(pageId); + const displayOrder = noteLib.useNextDisplayOrder(pageId); + + const { clearValues: clearNoteForm, ...noteForm } = noteLib.useFormValues({ + pageId, + mealType, + displayOrder, + quantity: 100, + }); + const productAutocompleteData = productLib.useAutocompleteData(); const categorySelect = categoryLib.useCategorySelectData(); - const handleSubmit = async (note: Note): Promise => { - const productId = await addProductIfNotExists(note.product); - const request = mapToCreateNoteRequest(note, productId); - await addNote(request); + const [uploadedPhotos, setUploadedPhotos] = useState([]); + const [selectedInputMethod, setSelectedInputMethod] = useState('fromInput'); + + const handleSubmit = async (formData: Note): Promise => { + const productId = await addProductIfNotExists.sendRequest(formData.product); + const request = mapToCreateNoteRequest(formData, productId); + await addNote(request).unwrap(); }; const handleSubmitSuccess = useCallback(() => { - toggleDialog(); - resetAddNote(); - }, [resetAddNote, toggleDialog]); + reset(); + setSelectedInputMethod('fromInput'); + clearNoteForm(); + }, [clearNoteForm, reset]); + + const handleCancel = useCallback(() => { + setUploadedPhotos([]); + setSelectedInputMethod('fromInput'); + clearNoteForm(); + }, [clearNoteForm]); + + const handleUploadSuccess = async (photos: UploadedPhoto[]): Promise => { + setUploadedPhotos(photos); + await recognizeNotes(photos[0].file); + }; return ( - <> - - - + ( + + )} + renderContent={dialogProps => ( + + )} + submitText="Add" + submitSuccess={addNoteResponse.isSuccess && notes.isChanged} + product={null} + productAutocompleteData={productAutocompleteData} + categorySelect={categorySelect} + recognizeNotesResult={recognizeNotesResult} + disableContentPaddingTop + onCancel={handleCancel} + onSubmit={handleSubmit} + onSubmitSuccess={handleSubmitSuccess} + /> ); }; diff --git a/src/frontend/src/features/note/addEdit/ui/AddNoteByPhoto.tsx b/src/frontend/src/features/note/addEdit/ui/AddNoteByPhoto.tsx deleted file mode 100644 index 1ecb2a6a9..000000000 --- a/src/frontend/src/features/note/addEdit/ui/AddNoteByPhoto.tsx +++ /dev/null @@ -1,112 +0,0 @@ -import AddAPhotoIcon from '@mui/icons-material/AddAPhoto'; -import { Box, styled } from '@mui/material'; -import { visuallyHidden } from '@mui/utils'; -import { type FC, useState, type ChangeEventHandler, useCallback } from 'react'; -import { categoryLib } from '@/entities/category'; -import { noteApi, noteLib, type noteModel } from '@/entities/note'; -import { productLib } from '@/entities/product'; -import { useToggle } from '@/shared/hooks'; -import { convertToBase64String, resizeImage } from '@/shared/lib'; -import { Button } from '@/shared/ui'; -import { mapToCreateNoteRequest, useAddProductIfNotExists, useRecognizeNotes } from '../lib'; -import { type UploadedPhoto, type Note } from '../model'; -import { NoteInputDialogByPhoto } from './NoteInputDialogByPhoto'; - -interface Props { - pageId: number; - mealType: noteModel.MealType; - displayOrder: number; -} - -const FileInputStyled = styled('input')(() => ({ ...visuallyHidden })); - -export const AddNoteByPhoto: FC = ({ pageId, mealType, displayOrder }) => { - const [dialogOpened, toggleDialog] = useToggle(); - const [uploadedPhotos, setUploadedPhotos] = useState([]); - - const [addNote, { isSuccess: addNoteSuccess, reset: addNoteReset }] = - noteApi.useCreateNoteMutation(); - const addProductIfNotExists = useAddProductIfNotExists(); - const [recognizeNotes, recognizeNoteResult] = useRecognizeNotes(); - - const notes = noteLib.useNotes(pageId); - const productAutocompleteData = productLib.useAutocompleteData(); - const categorySelect = categoryLib.useCategorySelectData(); - - const handlePhotoUploaded: ChangeEventHandler = async event => { - try { - const file = event.target?.files?.item(0); - - if (!file || !file.type.startsWith('image')) { - return; - } - - const base64 = await convertToBase64String(file); - const resizedFile = await resizeImage(base64, 512, file.name); - const resizedBase64 = await convertToBase64String(resizedFile); - - setUploadedPhotos([ - { - src: resizedBase64, - name: file.name, - file, - }, - ]); - - toggleDialog(); - await recognizeNotes(resizedFile); - } finally { - event.target.value = ''; - } - }; - - const handleRecognizeNotesRetry = async (): Promise => { - await recognizeNotes(uploadedPhotos[0].file); - }; - - const handleSubmit = async (note: Note): Promise => { - const productId = await addProductIfNotExists(note.product); - const request = mapToCreateNoteRequest(note, productId); - await addNote(request); - }; - - const handleSubmitSuccess = useCallback(() => { - toggleDialog(); - addNoteReset(); - }, [addNoteReset, toggleDialog]); - - return ( - - - - - ); -}; diff --git a/src/frontend/src/features/note/addEdit/ui/AddNoteDialogContent.tsx b/src/frontend/src/features/note/addEdit/ui/AddNoteDialogContent.tsx new file mode 100644 index 000000000..749d7a1c5 --- /dev/null +++ b/src/frontend/src/features/note/addEdit/ui/AddNoteDialogContent.tsx @@ -0,0 +1,110 @@ +import KeyboardIcon from '@mui/icons-material/Keyboard'; +import PhotoCameraIcon from '@mui/icons-material/PhotoCamera'; +import { TabContext, TabList, TabPanel } from '@mui/lab'; +import { Box, Tab } from '@mui/material'; +import { type FC } from 'react'; +import { type noteModel } from '@/entities/note'; +import { ProductAutocomplete } from '@/entities/product'; +import { type RenderContentProps, type RecognizeNotesResult } from '../lib'; +import { type UploadedPhoto, type InputMethod } from '../model'; +import { NoteInputForm } from './NoteInputForm'; +import { NoteInputFromPhotoFlow } from './NoteInputFromPhotoFlow'; + +interface Props extends RenderContentProps { + noteFormValues: noteModel.FormValues; + recognizeNotesResult: RecognizeNotesResult; + uploadedPhotos: UploadedPhoto[]; + selectedInputMethod: InputMethod; + onUploadSuccess: (photos: UploadedPhoto[]) => Promise; + onSelectedInputMethodChange: (value: InputMethod) => void; +} + +export const AddNoteDialogContent: FC = ({ + submitLoading, + submitDisabled, + noteFormValues, + productFormValues, + productAutocompleteInput, + productAutocompleteData, + recognizeNotesResult, + uploadedPhotos, + selectedInputMethod, + onClose, + onSubmit, + onSubmitDisabledChange, + onProductChange, + onUploadSuccess, + onProductFormValuesChange, + onSelectedInputMethodChange, +}) => { + const handleSelectedInputMethodChange = (_: React.SyntheticEvent, value: InputMethod): void => { + onSelectedInputMethodChange(value); + }; + + return ( + + + + } + iconPosition="start" + label="From input" + value={'fromInput' satisfies InputMethod} + /> + } + iconPosition="start" + label="From photo" + value={'fromPhoto' satisfies InputMethod} + /> + + + + ( + + )} + onSubmit={onSubmit} + onSubmitDisabledChange={onSubmitDisabledChange} + /> + + 0 ? 0 : undefined} + component={TabPanel} + value={'fromPhoto' satisfies InputMethod} + > + + + + ); +}; diff --git a/src/frontend/src/features/note/addEdit/ui/EditNote.tsx b/src/frontend/src/features/note/addEdit/ui/EditNote.tsx index 76b9d6129..ba5e297de 100644 --- a/src/frontend/src/features/note/addEdit/ui/EditNote.tsx +++ b/src/frontend/src/features/note/addEdit/ui/EditNote.tsx @@ -1,59 +1,89 @@ import { useMemo, type FC, type ReactElement, useCallback } from 'react'; import { categoryLib } from '@/entities/category'; import { noteApi, noteLib, type noteModel } from '@/entities/note'; -import { productLib } from '@/entities/product'; -import { useToggle } from '@/shared/hooks'; +import { ProductAutocomplete, productLib } from '@/entities/product'; +import { useAddProductIfNotExists, EMPTY_RECOGNIZE_NOTES_RESULT } from '../lib'; import { mapToEditNoteRequest, mapToProductSelectOption } from '../lib/mapping'; -import { useAddProductIfNotExists } from '../lib/useAddProductIfNotExists'; import { type Note } from '../model'; -import { NoteInputDialog } from './NoteInputDialog'; +import { NoteInputFlow } from './NoteInputFlow'; +import { NoteInputForm } from './NoteInputForm'; interface Props { note: noteModel.NoteItem; pageId: number; - renderTrigger: (openDialog: () => void) => ReactElement; + renderTrigger: (onClick: () => void) => ReactElement; } export const EditNote: FC = ({ note, pageId, renderTrigger }) => { - const [dialogOpened, toggleDialog] = useToggle(); - const notes = noteLib.useNotes(pageId); + const [editNote, { reset, ...editNoteResponse }] = noteApi.useEditNoteMutation(); const addProductIfNotExists = useAddProductIfNotExists(); - const product = useMemo(() => mapToProductSelectOption(note), [note]); - const [editNote, editNoteResponse] = noteApi.useEditNoteMutation(); - const { reset: resetEditNote } = editNoteResponse; + + const notes = noteLib.useNotes(pageId); + + const { clearValues: clearNoteForm, ...noteForm } = noteLib.useFormValues({ + pageId, + mealType: note.mealType, + displayOrder: note.displayOrder, + quantity: note.productQuantity, + }); + const productAutocompleteData = productLib.useAutocompleteData(); const categorySelect = categoryLib.useCategorySelectData(); + const product = useMemo(() => mapToProductSelectOption(note), [note]); + const handleSubmit = async (formData: Note): Promise => { - const productId = await addProductIfNotExists(formData.product); + const productId = await addProductIfNotExists.sendRequest(formData.product); const request = mapToEditNoteRequest(note.id, productId, formData); - await editNote(request); + await editNote(request).unwrap(); }; const handleSubmitSuccess = useCallback(() => { - toggleDialog(); - resetEditNote(); - }, [resetEditNote, toggleDialog]); + reset(); + clearNoteForm(); + }, [clearNoteForm, reset]); + + const handleCancel = useCallback(() => { + clearNoteForm(); + }, [clearNoteForm]); return ( - <> - {renderTrigger(toggleDialog)} - - + ( + ( + + )} + onSubmit={onSubmit} + onSubmitDisabledChange={onSubmitDisabledChange} + /> + )} + submitText="Save" + submitSuccess={editNoteResponse.isSuccess && notes.isChanged} + product={product} + productAutocompleteData={productAutocompleteData} + categorySelect={categorySelect} + recognizeNotesResult={EMPTY_RECOGNIZE_NOTES_RESULT} + onCancel={handleCancel} + onSubmit={handleSubmit} + onSubmitSuccess={handleSubmitSuccess} + /> ); }; diff --git a/src/frontend/src/features/note/addEdit/ui/NoteInputDialog/NoteInputDialog.test.tsx b/src/frontend/src/features/note/addEdit/ui/NoteInputDialog/NoteInputDialog.test.tsx deleted file mode 100644 index 6d02960d0..000000000 --- a/src/frontend/src/features/note/addEdit/ui/NoteInputDialog/NoteInputDialog.test.tsx +++ /dev/null @@ -1,367 +0,0 @@ -import { render } from '@testing-library/react'; -import userEvent from '@testing-library/user-event'; -import { - whenDialogOpened, - givenNoteInputDialog, - whenProductSelected, - thenQuantityHasValue, - whenQuantityChanged, - whenDialogClosed, - thenFormValueContains, - thenDialogShouldBeHidden, - whenAddedNotExistingProductOption, - thenProductFormShouldBeVisible, - whenProductNameChanged, - whenProductDefaultQuantityChanged, - whenProductCaloriesCostChanged, - whenProductCategorySelected, - whenProductAdded, - expectCategory, - expectNewProduct, - expectExistingProduct, - whenNoteSaved, - thenProductHasValue, - thenNoteFormShouldBeVisible, - thenProductNameHasValue, - thenProductDefaultQuantityHasValue, - thenProductCaloriesCostHasValue, - thenProductCategoryIsEmpty, - whenProductCleared, - thenSubmitNoteButtonIsDisabled, - thenProductNameIsInvalid, - thenProductIsInvalid, - thenAddProductButtonIsDisabled, - whenEditedNotExistingProductOption, - thenProductCategoryHasValue, -} from './NoteInputDialog.fixture'; - -describe('when opened for existing note', () => { - test(`should take quantity from note quantity`, async () => { - const user = userEvent.setup(); - - render( - givenNoteInputDialog() - .withProductForSelect({ name: 'Test product', defaultQuantity: 123 }) - .withSelectedProduct('Test product') - .withQuantity(321) - .please(), - ); - - await whenDialogOpened(user); - await thenQuantityHasValue(321); - }); -}); - -describe('when opened for edit, changed product, closed without save, and opened again', () => { - test('should take quantity from note quantity', async () => { - const user = userEvent.setup(); - - render( - givenNoteInputDialog() - .withProductForSelect({ name: 'First product', defaultQuantity: 200 }) - .withProductForSelect({ name: 'Second product', defaultQuantity: 300 }) - .withSelectedProduct('First product') - .withQuantity(100) - .please(), - ); - - await whenDialogOpened(user); - await whenProductSelected(user, /second product/i); - await whenDialogClosed(user); - await thenDialogShouldBeHidden(); - - await whenDialogOpened(user); - await thenQuantityHasValue(100); - }); -}); - -describe('when changed product', () => { - test(`should take quantity from product's default quantity if creating note`, async () => { - const user = userEvent.setup(); - - render( - givenNoteInputDialog() - .withQuantity(100) - .withProductForSelect({ name: 'Test product', defaultQuantity: 123 }) - .please(), - ); - - await whenDialogOpened(user); - await whenProductSelected(user, /test product/i); - await thenQuantityHasValue(123); - }); - - test(`should take quantity from product's default quantity if editing note`, async () => { - const user = userEvent.setup(); - - render( - givenNoteInputDialog() - .withProductForSelect({ name: 'First product', defaultQuantity: 200 }) - .withProductForSelect({ name: 'Second product', defaultQuantity: 300 }) - .withSelectedProduct('First product') - .please(), - ); - - await whenDialogOpened(user); - await whenProductSelected(user, /second product/i); - await thenQuantityHasValue(300); - }); -}); - -describe('when selected existing product with valid quantity', () => { - test('should add new note', async () => { - const user = userEvent.setup(); - const onSubmitMock = vi.fn(); - - render( - givenNoteInputDialog() - .withQuantity(100) - .withProductForSelect({ name: 'Test product', defaultQuantity: 123 }) - .withOnSubmit(onSubmitMock) - .please(), - ); - - await whenDialogOpened(user); - await whenProductSelected(user, /test product/i); - await whenQuantityChanged(user, 150); - await whenNoteSaved(user); - await thenFormValueContains(onSubmitMock, { - product: expectExistingProduct({ - name: 'Test product', - defaultQuantity: 123, - }), - productQuantity: 150, - }); - }); -}); - -describe('when added new product with valid quantity', () => { - test('should add new note', async () => { - const user = userEvent.setup(); - const onSubmitMock = vi.fn(); - - render( - givenNoteInputDialog() - .withQuantity(100) - .withCategoriesForSelect('Test Category') - .withOnSubmit(onSubmitMock) - .please(), - ); - - await whenDialogOpened(user); - await whenAddedNotExistingProductOption(user, 'New product'); - await thenProductFormShouldBeVisible(); - - await whenProductNameChanged(user, 'New super product'); - await whenProductCaloriesCostChanged(user, 200); - await whenProductDefaultQuantityChanged(user, 175); - await whenProductCategorySelected(user, /test category/i); - await whenProductAdded(user); - await thenQuantityHasValue(175); - - await whenQuantityChanged(user, 150); - await whenNoteSaved(user); - await thenFormValueContains(onSubmitMock, { - product: expectNewProduct({ - name: 'New super product', - defaultQuantity: 175, - caloriesCost: 200, - category: expectCategory('Test Category'), - }), - productQuantity: 150, - }); - }); - - test('should save existing note', async () => { - const user = userEvent.setup(); - const onSubmitMock = vi.fn(); - - render( - givenNoteInputDialog() - .withQuantity(100) - .withProductForSelect({ name: 'Test product', defaultQuantity: 123 }) - .withCategoriesForSelect('Test Category') - .withOnSubmit(onSubmitMock) - .withSelectedProduct('Test product') - .please(), - ); - - await whenDialogOpened(user); - await whenAddedNotExistingProductOption(user, 'New product'); - await thenProductFormShouldBeVisible(); - - await whenProductNameChanged(user, 'New super product'); - await whenProductCaloriesCostChanged(user, 200); - await whenProductDefaultQuantityChanged(user, 175); - await whenProductCategorySelected(user, /test category/i); - await whenProductAdded(user); - await thenQuantityHasValue(175); - - await whenNoteSaved(user); - await thenFormValueContains(onSubmitMock, { - product: expectNewProduct({ - name: 'New super product', - defaultQuantity: 175, - caloriesCost: 200, - category: expectCategory('Test Category'), - }), - productQuantity: 175, - }); - }); -}); - -describe('when dialog closed without save', () => { - test('should clear note values', async () => { - const user = userEvent.setup(); - - render( - givenNoteInputDialog() - .withQuantity(100) - .withCategoriesForSelect('Test Category') - .withProductForSelect({ name: 'Chicken' }) - .withProductForSelect({ name: 'Rice' }) - .withSelectedProduct('Chicken') - .please(), - ); - - await whenDialogOpened(user); - await whenProductSelected(user, /rice/i); - await whenQuantityChanged(user, 150); - await whenDialogClosed(user); - await thenDialogShouldBeHidden(); - - await whenDialogOpened(user); - await thenProductHasValue('Chicken'); - await thenQuantityHasValue(100); - }); - - test('should clear product values', async () => { - const user = userEvent.setup(); - - render( - givenNoteInputDialog() - .withQuantity(100) - .withCategoriesForSelect('Test Category') - .withProductForSelect({ name: 'Chicken' }) - .withProductForSelect({ name: 'Rice' }) - .withSelectedProduct('Chicken') - .please(), - ); - - await whenDialogOpened(user); - await whenAddedNotExistingProductOption(user, 'Bre'); - await thenProductFormShouldBeVisible(); - - await whenProductNameChanged(user, 'Bread'); - await whenProductCaloriesCostChanged(user, 400); - await whenProductDefaultQuantityChanged(user, 50); - await whenProductCategorySelected(user, /test category/i); - await whenDialogClosed(user); - await thenNoteFormShouldBeVisible(); - - await whenAddedNotExistingProductOption(user, 'Rye bread'); - await thenProductFormShouldBeVisible(); - await thenProductNameHasValue('Rye bread'); - await thenProductCaloriesCostHasValue(100); - await thenProductDefaultQuantityHasValue(100); - await thenProductCategoryIsEmpty(); - }); -}); - -describe('when input invalid', () => { - test('should disable submit button for note', async () => { - const user = userEvent.setup(); - - render( - givenNoteInputDialog() - .withQuantity(100) - .withCategoriesForSelect('Test Category') - .withProductForSelect({ name: 'Chicken' }) - .withSelectedProduct('Chicken') - .please(), - ); - - await whenDialogOpened(user); - await whenProductCleared(user); - await thenProductIsInvalid(); - await thenSubmitNoteButtonIsDisabled(); - }); - - test('should disable submit button for product', async () => { - const user = userEvent.setup(); - - render( - givenNoteInputDialog().withQuantity(100).withCategoriesForSelect('Test Category').please(), - ); - - await whenDialogOpened(user); - await whenAddedNotExistingProductOption(user, 'Test'); - await whenProductNameChanged(user, 'T'); - await whenProductCaloriesCostChanged(user, 120); - await thenProductNameIsInvalid(); - await thenAddProductButtonIsDisabled(); - }); -}); - -test('I cannot add note with new product which name is invalid', async () => { - const user = userEvent.setup(); - - render( - givenNoteInputDialog().withQuantity(100).withCategoriesForSelect('Test Category').please(), - ); - - await whenDialogOpened(user); - await whenAddedNotExistingProductOption(user, 'T'); - await whenProductCategorySelected(user, /test category/i); - await thenProductNameIsInvalid(); - await thenAddProductButtonIsDisabled(); -}); - -test(`I can continue editing new product I've added before`, async () => { - const user = userEvent.setup(); - - render(givenNoteInputDialog().withQuantity(100).withCategoriesForSelect('Vegetables').please()); - - await whenDialogOpened(user); - await whenAddedNotExistingProductOption(user, 'Potato'); - await whenProductCaloriesCostChanged(user, 120); - await whenProductDefaultQuantityChanged(user, 80); - await whenProductCategorySelected(user, /vegetables/i); - await whenProductAdded(user); - await thenNoteFormShouldBeVisible(); - - await whenEditedNotExistingProductOption(user, 'Potato'); - await thenProductNameHasValue('Potato'); - await thenProductCaloriesCostHasValue(120); - await thenProductDefaultQuantityHasValue(80); - await thenProductCategoryHasValue('Vegetables'); -}); - -test('New product form is cleared after I change product to existing one', async () => { - const user = userEvent.setup(); - - render( - givenNoteInputDialog() - .withQuantity(100) - .withProductForSelect({ name: 'Cucumber' }) - .withCategoriesForSelect('Vegetables') - .please(), - ); - - await whenDialogOpened(user); - await whenAddedNotExistingProductOption(user, 'Potato'); - await whenProductCaloriesCostChanged(user, 120); - await whenProductDefaultQuantityChanged(user, 80); - await whenProductCategorySelected(user, /vegetables/i); - await whenProductAdded(user); - await thenNoteFormShouldBeVisible(); - - await whenProductSelected(user, /cucumber/i); - await whenProductCleared(user); - await whenAddedNotExistingProductOption(user, 'Carrot'); - await thenProductFormShouldBeVisible(); - await thenProductNameHasValue('Carrot'); - await thenProductCaloriesCostHasValue(100); - await thenProductDefaultQuantityHasValue(100); - await thenProductCategoryIsEmpty(); -}); diff --git a/src/frontend/src/features/note/addEdit/ui/NoteInputDialog/NoteInputDialog.tsx b/src/frontend/src/features/note/addEdit/ui/NoteInputDialog/NoteInputDialog.tsx deleted file mode 100644 index 523fa09ab..000000000 --- a/src/frontend/src/features/note/addEdit/ui/NoteInputDialog/NoteInputDialog.tsx +++ /dev/null @@ -1,163 +0,0 @@ -import { type FC, useEffect, useState } from 'react'; -import { type categoryLib } from '@/entities/category'; -import { type noteModel } from '@/entities/note'; -import { productLib, type productModel } from '@/entities/product'; -import { Button, Dialog } from '@/shared/ui'; -import { useNoteDialog, useProductDialog } from '../../lib'; -import { type Note, type DialogState, type DialogStateType } from '../../model'; - -interface Props { - opened: boolean; - title: string; - submitText: string; - mealType: noteModel.MealType; - pageId: number; - product: productModel.AutocompleteOption | null; - quantity: number; - displayOrder: number; - productAutocompleteData: productLib.AutocompleteData; - categorySelect: categoryLib.CategorySelectData; - submitSuccess: boolean; - onClose: () => void; - onSubmit: (note: Note) => Promise; - onSubmitSuccess: () => void; -} - -export const NoteInputDialog: FC = ({ - opened, - title, - submitText, - mealType, - pageId, - product, - displayOrder, - categorySelect, - submitSuccess, - quantity, - productAutocompleteData, - onClose, - onSubmit, - onSubmitSuccess, -}) => { - const productAutocompleteInput = productLib.useAutocompleteInput(product); - - const { setValue: setProductAutocompleteValue, clearValue: clearProductAutocompleteValue } = - productAutocompleteInput; - - const { - values: productFormValues, - setValues: setProductFormValues, - clearValues: clearProductFormValues, - } = productLib.useFormValues(); - - const [currentInputDialogType, setCurrentInputDialogType] = useState('note'); - - const { state: noteDialogState, onSubmitSuccess: onNoteSubmitSuccess } = useNoteDialog({ - pageId, - mealType, - displayOrder, - title, - submitText, - quantity, - productAutocompleteData, - productAutocompleteInput, - productFormValues, - onSubmit, - onClose: () => { - onClose(); - clearProductFormValues(); - clearProductAutocompleteValue(); - }, - onProductChange: value => { - setProductAutocompleteValue(value); - clearProductFormValues(); - - if (value?.freeSolo === true) { - setCurrentInputDialogType('product'); - - setProductFormValues({ - name: value.name, - caloriesCost: value.caloriesCost, - defaultQuantity: value.defaultQuantity, - category: value.category, - }); - } - }, - }); - - const { state: productDialogState } = useProductDialog({ - productFormValues, - categorySelect, - onClose: () => { - setCurrentInputDialogType('note'); - }, - onSubmit: formValues => { - const { name, caloriesCost, defaultQuantity, category } = formValues; - - setProductAutocompleteValue({ - freeSolo: true, - editing: true, - name, - caloriesCost, - defaultQuantity, - category, - }); - - setProductFormValues(formValues); - setCurrentInputDialogType('note'); - }, - }); - - useEffect(() => { - if (opened) { - clearProductFormValues(); - clearProductAutocompleteValue(); - } - }, [clearProductAutocompleteValue, clearProductFormValues, opened]); - - useEffect(() => { - if (submitSuccess) { - onSubmitSuccess(); - onNoteSubmitSuccess(); - } - }, [onNoteSubmitSuccess, onSubmitSuccess, submitSuccess]); - - const dialogStates: DialogState[] = [noteDialogState, productDialogState]; - - const currentDialogState = dialogStates.find(s => s.type === currentInputDialogType); - - if (!currentDialogState) { - return null; - } - - return ( - ( - - )} - renderSubmit={submitProps => ( - - )} - /> - ); -}; diff --git a/src/frontend/src/features/note/addEdit/ui/NoteInputDialog/index.ts b/src/frontend/src/features/note/addEdit/ui/NoteInputDialog/index.ts deleted file mode 100644 index fb9bc4ba7..000000000 --- a/src/frontend/src/features/note/addEdit/ui/NoteInputDialog/index.ts +++ /dev/null @@ -1 +0,0 @@ -export * from './NoteInputDialog'; diff --git a/src/frontend/src/features/note/addEdit/ui/NoteInputDialogByPhoto.tsx b/src/frontend/src/features/note/addEdit/ui/NoteInputDialogByPhoto.tsx deleted file mode 100644 index 2a051323e..000000000 --- a/src/frontend/src/features/note/addEdit/ui/NoteInputDialogByPhoto.tsx +++ /dev/null @@ -1,257 +0,0 @@ -import { - Alert, - AlertTitle, - Box, - CircularProgress, - ImageList, - ImageListItem, - ImageListItemBar, - Stack, -} from '@mui/material'; -import { useEffect, type FC, useState } from 'react'; -import { type categoryLib } from '@/entities/category'; -import { type noteModel } from '@/entities/note'; -import { productLib } from '@/entities/product'; -import { Button, Dialog } from '@/shared/ui'; -import { type RecognizeNotesResult, useNoteDialog, useProductDialog } from '../lib'; -import { type UploadedPhoto, type Note, type DialogState, type DialogStateType } from '../model'; - -interface Props { - opened: boolean; - pageId: number; - mealType: noteModel.MealType; - displayOrder: number; - uploadedPhotos: UploadedPhoto[]; - productAutocompleteData: productLib.AutocompleteData; - categorySelect: categoryLib.CategorySelectData; - recognizeNotesResult: RecognizeNotesResult; - submitSuccess: boolean; - onSubmit: (note: Note) => Promise; - onSubmitSuccess: () => void; - onClose: () => void; - onRecognizeNotesRetry: () => Promise; -} - -export const NoteInputDialogByPhoto: FC = ({ - opened, - pageId, - mealType, - displayOrder, - uploadedPhotos, - productAutocompleteData, - categorySelect, - recognizeNotesResult, - submitSuccess, - onSubmit, - onSubmitSuccess, - onClose, - onRecognizeNotesRetry, -}) => { - const productAutocompleteInput = productLib.useAutocompleteInput(); - - const { setValue: setProductAutocompleteValue, clearValue: clearProductAutocompleteValue } = - productAutocompleteInput; - - const { - values: productFormValues, - setValues: setProductFormValues, - clearValues: clearProductFormValues, - } = productLib.useFormValues(); - - const [currentInputDialogType, setCurrentInputDialogType] = useState('note'); - - const { - state: noteDialogState, - onSubmitSuccess: onNoteSubmitSuccess, - onSubmitDisabledChange: onNoteSubmitDisabledChange, - } = useNoteDialog({ - pageId, - mealType, - displayOrder, - title: 'New note', - submitText: 'Add', - quantity: 100, - productAutocompleteData, - productAutocompleteInput, - productFormValues, - onSubmit, - onClose: () => { - onClose(); - clearProductFormValues(); - clearProductAutocompleteValue(); - }, - onProductChange: value => { - setProductAutocompleteValue(value); - clearProductFormValues(); - - if (value?.freeSolo === true) { - setCurrentInputDialogType('product'); - - setProductFormValues({ - name: value.name, - caloriesCost: value.caloriesCost, - defaultQuantity: value.defaultQuantity, - category: value.category, - }); - } - }, - }); - - const { state: productDialogState } = useProductDialog({ - productFormValues, - categorySelect, - onClose: () => { - setCurrentInputDialogType('note'); - }, - onSubmit: formValues => { - const { name, caloriesCost, defaultQuantity, category } = formValues; - - setProductAutocompleteValue({ - freeSolo: true, - editing: true, - name, - caloriesCost, - defaultQuantity, - category, - }); - - setProductFormValues(formValues); - setCurrentInputDialogType('note'); - }, - }); - - const dialogStates: DialogState[] = [noteDialogState, productDialogState]; - const currentDialogState = dialogStates.find(s => s.type === currentInputDialogType); - - useEffect(() => { - if (opened) { - clearProductFormValues(); - clearProductAutocompleteValue(); - } - }, [clearProductAutocompleteValue, clearProductFormValues, opened]); - - useEffect(() => { - if (submitSuccess) { - onSubmitSuccess(); - onNoteSubmitSuccess(); - } - }, [onNoteSubmitSuccess, onSubmitSuccess, submitSuccess]); - - useEffect(() => { - if (!recognizeNotesResult.isSuccess) { - onNoteSubmitDisabledChange(true); - return; - } - - const note = recognizeNotesResult.notes.at(0); - const category = categorySelect.data.at(0); - - if (!note || !category) { - onNoteSubmitDisabledChange(true); - return; - } - - onNoteSubmitDisabledChange(false); - - setProductAutocompleteValue({ - freeSolo: true, - editing: true, - name: note.product.name, - caloriesCost: note.product.caloriesCost, - defaultQuantity: note.quantity, - category, - }); - - setProductFormValues({ - name: note.product.name, - caloriesCost: note.product.caloriesCost, - defaultQuantity: note.quantity, - category, - }); - }, [ - categorySelect.data, - onNoteSubmitDisabledChange, - recognizeNotesResult.isSuccess, - recognizeNotesResult.notes, - setProductAutocompleteValue, - setProductFormValues, - ]); - - if (!currentDialogState) { - return null; - } - - return ( - - - {uploadedPhotos.map((photo, index) => ( - - - - - ))} - - {recognizeNotesResult.isLoading && ( - - - - )} - {recognizeNotesResult.error && ( - - Retry - - } - > - {recognizeNotesResult.error.title} - {recognizeNotesResult.error.message} - - )} - {recognizeNotesResult.isSuccess && ( - <> - {recognizeNotesResult.notes.length > 0 && currentDialogState.content} - {recognizeNotesResult.notes.length === 0 && ( - No food found on your photo - )} - - )} - - } - renderCancel={cancelProps => ( - - )} - renderSubmit={submitProps => ( - - )} - /> - ); -}; diff --git a/src/frontend/src/features/note/addEdit/ui/NoteInputFlow/NoteInputFlow.builder.tsx b/src/frontend/src/features/note/addEdit/ui/NoteInputFlow/NoteInputFlow.builder.tsx new file mode 100644 index 000000000..8a52ee3f3 --- /dev/null +++ b/src/frontend/src/features/note/addEdit/ui/NoteInputFlow/NoteInputFlow.builder.tsx @@ -0,0 +1,198 @@ +import { ThemeProvider } from '@mui/material'; +import { type ReactElement } from 'react'; +import { type Mock } from 'vitest'; +import { theme } from '@/app/theme'; +import { noteModel } from '@/entities/note'; +import { + ProductAutocomplete, + type productModel, + type ProductSelectOption, +} from '@/entities/product'; +import { type SelectOption } from '@/shared/types'; +import { EMPTY_RECOGNIZE_NOTES_RESULT, type RenderContentProps } from '../../lib'; +import { AddNoteDialogContent } from '../AddNoteDialogContent'; +import { NoteInputForm } from '../NoteInputForm'; +import { NoteInputFlow } from './NoteInputFlow'; + +class NoteInputFlowBuilder { + private readonly _products: ProductSelectOption[] = []; + private readonly _categories: SelectOption[] = []; + private _submitText = 'Add'; + private _selectedProductName: string | null = null; + private _onSubmitMock: Mock = vi.fn(); + + private _renderDialog: (props: RenderContentProps) => ReactElement = + this.renderAddDialog.bind(this); + + private readonly _noteFormValues: noteModel.FormValues = { + pageId: 1, + mealType: noteModel.MealType.Breakfast, + displayOrder: 1, + quantity: 100, + }; + + please(): ReactElement { + return ( + + ( + + )} + renderContent={this._renderDialog} + submitText={this._submitText} + submitSuccess={false} + product={this.getSelectedProduct()} + productAutocompleteData={{ + options: this._products, + isLoading: false, + }} + categorySelect={{ + data: this._categories, + isLoading: false, + }} + recognizeNotesResult={EMPTY_RECOGNIZE_NOTES_RESULT} + onCancel={vi.fn()} + onSubmit={this._onSubmitMock} + onSubmitSuccess={vi.fn()} + /> + + ); + } + + withEditDialog(): this { + this._renderDialog = this.renderEditDialog.bind(this); + this._submitText = 'Save'; + return this; + } + + withProductForSelect({ + name = 'Test', + defaultQuantity = 100, + }: Partial): this { + this._products.push({ + id: this._products.length + 1, + name, + defaultQuantity, + }); + + return this; + } + + withCategoriesForSelect(...categories: string[]): this { + this._categories.push(...categories.map((name, index) => ({ id: index + 1, name }))); + return this; + } + + withSelectedProduct(name: string): this { + this._selectedProductName = name; + return this; + } + + withQuantity(quantity: number): this { + this._noteFormValues.quantity = quantity; + return this; + } + + withOnSubmit(onSubmitMock: Mock): this { + this._onSubmitMock = onSubmitMock; + return this; + } + + private getSelectedProduct(): ProductSelectOption | null { + if (this._selectedProductName === null) { + return null; + } + + const product = this._products.find(p => p.name === this._selectedProductName) ?? null; + + if (product === null) { + throw new Error( + `Product '${this._selectedProductName}' cannot be selected because it is not added to products for select`, + ); + } + + return { ...product }; + } + + private renderAddDialog(props: RenderContentProps): ReactElement { + return ( + + ); + } + + private renderEditDialog({ + productAutocompleteInput, + productAutocompleteData, + productFormValues, + onProductChange, + onSubmit, + onSubmitDisabledChange, + }: RenderContentProps): ReactElement { + return ( + ( + + )} + onSubmit={onSubmit} + onSubmitDisabledChange={onSubmitDisabledChange} + /> + ); + } +} + +export const givenNoteInputFlow = (): NoteInputFlowBuilder => new NoteInputFlowBuilder(); + +export const expectExistingProduct = ({ + name, + defaultQuantity, +}: Omit): productModel.AutocompleteExistingOption => + expect.objectContaining({ + id: expect.any(Number), + name, + defaultQuantity, + }); + +export const expectNewProduct = ({ + name, + caloriesCost, + defaultQuantity, + category, +}: Omit< + productModel.AutocompleteFreeSoloOption, + 'freeSolo' | 'editing' +>): productModel.AutocompleteFreeSoloOption => + expect.objectContaining({ + freeSolo: true, + editing: true, + name, + caloriesCost, + defaultQuantity, + category, + }); + +export const expectCategory = (name: string): SelectOption => + expect.objectContaining>({ name }); diff --git a/src/frontend/src/features/note/addEdit/ui/NoteInputDialog/NoteInputDialog.fixture.tsx b/src/frontend/src/features/note/addEdit/ui/NoteInputFlow/NoteInputFlow.steps.tsx similarity index 56% rename from src/frontend/src/features/note/addEdit/ui/NoteInputDialog/NoteInputDialog.fixture.tsx rename to src/frontend/src/features/note/addEdit/ui/NoteInputFlow/NoteInputFlow.steps.tsx index 921e96259..1faea9a27 100644 --- a/src/frontend/src/features/note/addEdit/ui/NoteInputDialog/NoteInputDialog.fixture.tsx +++ b/src/frontend/src/features/note/addEdit/ui/NoteInputFlow/NoteInputFlow.steps.tsx @@ -1,107 +1,8 @@ -import { ThemeProvider } from '@mui/material'; import { screen } from '@testing-library/dom'; import { type UserEvent } from '@testing-library/user-event'; -import { type ReactElement } from 'react'; import { type Mock } from 'vitest'; -import { theme } from '@/app/theme'; import { noteModel } from '@/entities/note'; -import { type ProductSelectOption, type productModel } from '@/entities/product'; -import { type SelectOption } from '@/shared/types'; -import { WithTriggerButton } from '@tests/sideEffects'; import { type Note } from '../../model'; -import { NoteInputDialog } from './NoteInputDialog'; - -class NoteInputDialogBuilder { - private readonly _products: ProductSelectOption[] = []; - private readonly _categories: SelectOption[] = []; - private _selectedProductName: string | null = null; - private _quantity: number = 100; - private _onSubmitMock: Mock = vi.fn(); - - please(): ReactElement { - return ( - - - {({ active, onTriggerClick }) => ( - - )} - - - ); - } - - withProductForSelect({ - name = 'Test', - defaultQuantity = 100, - }: Partial): this { - this._products.push({ - id: this._products.length + 1, - name, - defaultQuantity, - }); - - return this; - } - - withCategoriesForSelect(...categories: string[]): this { - this._categories.push(...categories.map((name, index) => ({ id: index + 1, name }))); - return this; - } - - withSelectedProduct(name: string): this { - this._selectedProductName = name; - return this; - } - - withQuantity(quantity: number): this { - this._quantity = quantity; - return this; - } - - withOnSubmit(onSubmitMock: Mock): this { - this._onSubmitMock = onSubmitMock; - return this; - } - - private getSelectedProduct(): ProductSelectOption | null { - if (this._selectedProductName === null) { - return null; - } - - const product = this._products.find(p => p.name === this._selectedProductName) ?? null; - - if (product === null) { - throw new Error( - `Product '${this._selectedProductName}' cannot be selected because it is not added to products for select`, - ); - } - - return { ...product }; - } -} - -export const givenNoteInputDialog = (): NoteInputDialogBuilder => new NoteInputDialogBuilder(); export const whenDialogOpened = async (user: UserEvent): Promise => { await user.click(screen.getByRole('button', { name: /open/i })); @@ -141,8 +42,12 @@ export const whenQuantityChanged = async (user: UserEvent, value: number): Promi await user.type(screen.getByPlaceholderText(/product quantity/i), `${value}`); }; +export const whenNoteAdded = async (user: UserEvent): Promise => { + await user.click(screen.getByRole('button', { name: /add/i })); +}; + export const whenNoteSaved = async (user: UserEvent): Promise => { - await user.click(screen.getByRole('button', { name: /submit/i })); + await user.click(screen.getByRole('button', { name: /save/i })); }; export const whenProductNameChanged = async (user: UserEvent, name: string): Promise => { @@ -175,37 +80,6 @@ export const whenProductAdded = async (user: UserEvent): Promise => { await user.click(screen.getByRole('button', { name: /add/i })); }; -export const expectExistingProduct = ({ - name, - defaultQuantity, -}: Omit): productModel.AutocompleteExistingOption => - expect.objectContaining({ - id: expect.any(Number), - name, - defaultQuantity, - }); - -export const expectNewProduct = ({ - name, - caloriesCost, - defaultQuantity, - category, -}: Omit< - productModel.AutocompleteFreeSoloOption, - 'freeSolo' | 'editing' ->): productModel.AutocompleteFreeSoloOption => - expect.objectContaining({ - freeSolo: true, - editing: true, - name, - caloriesCost, - defaultQuantity, - category, - }); - -export const expectCategory = (name: string): SelectOption => - expect.objectContaining>({ name }); - export const thenProductHasValue = async (value: string): Promise => { expect(screen.getByRole('combobox', { name: /product/i })).toHaveValue(value); }; @@ -223,11 +97,11 @@ export const thenDialogShouldBeHidden = async (): Promise => { }; export const thenProductFormShouldBeVisible = async (): Promise => { - expect(screen.getByRole('dialog', { name: /product/i })); + expect(await screen.findByRole('dialog', { name: /product/i })); }; export const thenNoteFormShouldBeVisible = async (): Promise => { - expect(await screen.findByText(/note/i)); + expect(await screen.findByRole('dialog', { name: /note/i })); }; export const thenProductNameHasValue = async (value: string): Promise => { @@ -254,8 +128,12 @@ export const thenProductCategoryHasValue = async (value: string): Promise expect(screen.getByRole('combobox', { name: /category/i })).toHaveValue(value); }; -export const thenSubmitNoteButtonIsDisabled = async (): Promise => { - expect(screen.getByRole('button', { name: /submit/i })).toBeDisabled(); +export const thenAddNoteButtonIsDisabled = async (): Promise => { + expect(screen.getByRole('button', { name: /add/i })).toBeDisabled(); +}; + +export const thenSaveNoteButtonIsDisabled = async (): Promise => { + expect(screen.getByRole('button', { name: /save/i })).toBeDisabled(); }; export const thenAddProductButtonIsDisabled = async (): Promise => { diff --git a/src/frontend/src/features/note/addEdit/ui/NoteInputFlow/NoteInputFlow.test.tsx b/src/frontend/src/features/note/addEdit/ui/NoteInputFlow/NoteInputFlow.test.tsx new file mode 100644 index 000000000..0de99a248 --- /dev/null +++ b/src/frontend/src/features/note/addEdit/ui/NoteInputFlow/NoteInputFlow.test.tsx @@ -0,0 +1,324 @@ +import { render } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { + expectCategory, + expectExistingProduct, + expectNewProduct, + givenNoteInputFlow, +} from './NoteInputFlow.builder'; +import * as steps from './NoteInputFlow.steps'; + +test('Quantity is taken from note quantity when dialog opened', async () => { + const user = userEvent.setup(); + + render( + givenNoteInputFlow() + .withProductForSelect({ name: 'Test product', defaultQuantity: 123 }) + .withSelectedProduct('Test product') + .withQuantity(321) + .please(), + ); + + await steps.whenDialogOpened(user); + await steps.thenQuantityHasValue(321); +}); + +test('Quantity is taken from note quantity when dialog opened, closed without save, and opened again', async () => { + const user = userEvent.setup(); + + render( + givenNoteInputFlow() + .withProductForSelect({ name: 'First product', defaultQuantity: 200 }) + .withProductForSelect({ name: 'Second product', defaultQuantity: 300 }) + .withSelectedProduct('First product') + .withQuantity(100) + .please(), + ); + + await steps.whenDialogOpened(user); + await steps.whenProductSelected(user, /second product/i); + await steps.whenDialogClosed(user); + await steps.thenDialogShouldBeHidden(); + + await steps.whenDialogOpened(user); + await steps.thenQuantityHasValue(100); +}); + +test(`Quantity is taken from product's default quantity when adding note`, async () => { + const user = userEvent.setup(); + + render( + givenNoteInputFlow() + .withQuantity(100) + .withProductForSelect({ name: 'Test product', defaultQuantity: 123 }) + .please(), + ); + + await steps.whenDialogOpened(user); + await steps.whenProductSelected(user, /test product/i); + await steps.thenQuantityHasValue(123); +}); + +test(`Quantity is taken from product's default quantity when saving note`, async () => { + const user = userEvent.setup(); + + render( + givenNoteInputFlow() + .withProductForSelect({ name: 'First product', defaultQuantity: 200 }) + .withProductForSelect({ name: 'Second product', defaultQuantity: 300 }) + .withSelectedProduct('First product') + .please(), + ); + + await steps.whenDialogOpened(user); + await steps.whenProductSelected(user, /second product/i); + await steps.thenQuantityHasValue(300); +}); + +test('I can add new note with existing product and valid quantity', async () => { + const user = userEvent.setup(); + const onSubmitMock = vi.fn(); + + render( + givenNoteInputFlow() + .withQuantity(100) + .withProductForSelect({ name: 'Test product', defaultQuantity: 123 }) + .withOnSubmit(onSubmitMock) + .please(), + ); + + await steps.whenDialogOpened(user); + await steps.whenProductSelected(user, /test product/i); + await steps.whenQuantityChanged(user, 150); + await steps.whenNoteAdded(user); + await steps.thenFormValueContains(onSubmitMock, { + product: expectExistingProduct({ + name: 'Test product', + defaultQuantity: 123, + }), + productQuantity: 150, + }); +}); + +test('I can add new note with new product', async () => { + const user = userEvent.setup(); + const onSubmitMock = vi.fn(); + + render( + givenNoteInputFlow() + .withQuantity(100) + .withCategoriesForSelect('Test Category') + .withOnSubmit(onSubmitMock) + .please(), + ); + + await steps.whenDialogOpened(user); + await steps.whenAddedNotExistingProductOption(user, 'New product'); + await steps.thenProductFormShouldBeVisible(); + + await steps.whenProductNameChanged(user, 'New super product'); + await steps.whenProductCaloriesCostChanged(user, 200); + await steps.whenProductDefaultQuantityChanged(user, 175); + await steps.whenProductCategorySelected(user, /test category/i); + await steps.whenProductAdded(user); + await steps.thenQuantityHasValue(175); + + await steps.whenQuantityChanged(user, 150); + await steps.whenNoteAdded(user); + await steps.thenFormValueContains(onSubmitMock, { + product: expectNewProduct({ + name: 'New super product', + defaultQuantity: 175, + caloriesCost: 200, + category: expectCategory('Test Category'), + }), + productQuantity: 150, + }); +}); + +test('I can save existing note with new product', async () => { + const user = userEvent.setup(); + const onSubmitMock = vi.fn(); + + render( + givenNoteInputFlow() + .withQuantity(100) + .withProductForSelect({ name: 'Test product', defaultQuantity: 123 }) + .withCategoriesForSelect('Test Category') + .withOnSubmit(onSubmitMock) + .withSelectedProduct('Test product') + .withEditDialog() + .please(), + ); + + await steps.whenDialogOpened(user); + await steps.whenAddedNotExistingProductOption(user, 'New product'); + await steps.thenProductFormShouldBeVisible(); + + await steps.whenProductNameChanged(user, 'New super product'); + await steps.whenProductCaloriesCostChanged(user, 200); + await steps.whenProductDefaultQuantityChanged(user, 175); + await steps.whenProductCategorySelected(user, /test category/i); + await steps.whenProductAdded(user); + await steps.thenQuantityHasValue(175); + + await steps.whenNoteSaved(user); + await steps.thenFormValueContains(onSubmitMock, { + product: expectNewProduct({ + name: 'New super product', + defaultQuantity: 175, + caloriesCost: 200, + category: expectCategory('Test Category'), + }), + productQuantity: 175, + }); +}); + +test('I can close note dialog without save and add another note', async () => { + const user = userEvent.setup(); + + render( + givenNoteInputFlow() + .withQuantity(100) + .withCategoriesForSelect('Test Category') + .withProductForSelect({ name: 'Chicken' }) + .withProductForSelect({ name: 'Rice' }) + .withSelectedProduct('Chicken') + .please(), + ); + + await steps.whenDialogOpened(user); + await steps.whenProductSelected(user, /rice/i); + await steps.whenQuantityChanged(user, 150); + await steps.whenDialogClosed(user); + await steps.thenDialogShouldBeHidden(); + + await steps.whenDialogOpened(user); + await steps.thenProductHasValue('Chicken'); + await steps.thenQuantityHasValue(100); +}); + +test('I can close product dialog without save and add another product', async () => { + const user = userEvent.setup(); + + render( + givenNoteInputFlow() + .withQuantity(100) + .withCategoriesForSelect('Test Category') + .withProductForSelect({ name: 'Chicken' }) + .withProductForSelect({ name: 'Rice' }) + .withSelectedProduct('Chicken') + .please(), + ); + + await steps.whenDialogOpened(user); + await steps.whenAddedNotExistingProductOption(user, 'Bre'); + await steps.thenProductFormShouldBeVisible(); + + await steps.whenProductNameChanged(user, 'Bread'); + await steps.whenProductCaloriesCostChanged(user, 400); + await steps.whenProductDefaultQuantityChanged(user, 50); + await steps.whenProductCategorySelected(user, /test category/i); + await steps.whenDialogClosed(user); + await steps.thenNoteFormShouldBeVisible(); + + await steps.whenAddedNotExistingProductOption(user, 'Rye bread'); + await steps.thenProductFormShouldBeVisible(); + await steps.thenProductNameHasValue('Rye bread'); + await steps.thenProductCaloriesCostHasValue(100); + await steps.thenProductDefaultQuantityHasValue(100); + await steps.thenProductCategoryIsEmpty(); +}); + +test('I cannot add note if input invalid', async () => { + const user = userEvent.setup(); + + render( + givenNoteInputFlow() + .withQuantity(100) + .withCategoriesForSelect('Test Category') + .withProductForSelect({ name: 'Chicken' }) + .withSelectedProduct('Chicken') + .please(), + ); + + await steps.whenDialogOpened(user); + await steps.whenProductCleared(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(); + + render(givenNoteInputFlow().withQuantity(100).withCategoriesForSelect('Test Category').please()); + + await steps.whenDialogOpened(user); + await steps.whenAddedNotExistingProductOption(user, 'Test'); + await steps.whenProductNameChanged(user, 'T'); + await steps.whenProductCaloriesCostChanged(user, 120); + await steps.thenProductNameIsInvalid(); + await steps.thenAddProductButtonIsDisabled(); +}); + +test('I cannot add note with new product if its name is invalid', async () => { + const user = userEvent.setup(); + + render(givenNoteInputFlow().withQuantity(100).withCategoriesForSelect('Test Category').please()); + + await steps.whenDialogOpened(user); + await steps.whenAddedNotExistingProductOption(user, 'T'); + await steps.whenProductCategorySelected(user, /test category/i); + await steps.thenProductNameIsInvalid(); + await steps.thenAddProductButtonIsDisabled(); +}); + +test(`I can continue editing new product I've added before`, async () => { + const user = userEvent.setup(); + + render(givenNoteInputFlow().withQuantity(100).withCategoriesForSelect('Vegetables').please()); + + await steps.whenDialogOpened(user); + await steps.whenAddedNotExistingProductOption(user, 'Potato'); + await steps.whenProductCaloriesCostChanged(user, 120); + await steps.whenProductDefaultQuantityChanged(user, 80); + await steps.whenProductCategorySelected(user, /vegetables/i); + await steps.whenProductAdded(user); + await steps.thenNoteFormShouldBeVisible(); + + await steps.whenEditedNotExistingProductOption(user, 'Potato'); + await steps.thenProductFormShouldBeVisible(); + await steps.thenProductNameHasValue('Potato'); + await steps.thenProductCaloriesCostHasValue(120); + await steps.thenProductDefaultQuantityHasValue(80); + await steps.thenProductCategoryHasValue('Vegetables'); +}); + +test('New product form is cleared after I change product to existing one', async () => { + const user = userEvent.setup(); + + render( + givenNoteInputFlow() + .withQuantity(100) + .withProductForSelect({ name: 'Cucumber' }) + .withCategoriesForSelect('Vegetables') + .please(), + ); + + await steps.whenDialogOpened(user); + await steps.whenAddedNotExistingProductOption(user, 'Potato'); + await steps.whenProductCaloriesCostChanged(user, 120); + await steps.whenProductDefaultQuantityChanged(user, 80); + await steps.whenProductCategorySelected(user, /vegetables/i); + await steps.whenProductAdded(user); + await steps.thenNoteFormShouldBeVisible(); + + await steps.whenProductSelected(user, /cucumber/i); + await steps.whenProductCleared(user); + await steps.whenAddedNotExistingProductOption(user, 'Carrot'); + await steps.thenProductFormShouldBeVisible(); + await steps.thenProductNameHasValue('Carrot'); + await steps.thenProductCaloriesCostHasValue(100); + await steps.thenProductDefaultQuantityHasValue(100); + await steps.thenProductCategoryIsEmpty(); +}); diff --git a/src/frontend/src/features/note/addEdit/ui/NoteInputFlow/NoteInputFlow.tsx b/src/frontend/src/features/note/addEdit/ui/NoteInputFlow/NoteInputFlow.tsx new file mode 100644 index 000000000..6d380e3d5 --- /dev/null +++ b/src/frontend/src/features/note/addEdit/ui/NoteInputFlow/NoteInputFlow.tsx @@ -0,0 +1,277 @@ +import { type ReactElement, type FC, useState, useCallback, useEffect } from 'react'; +import { CategorySelect, type categoryLib } from '@/entities/category'; +import { type productModel, productLib, ProductInputForm } from '@/entities/product'; +import { useToggle } from '@/shared/hooks'; +import { Button, Dialog } from '@/shared/ui'; +import { type RecognizeNotesResult, type RenderContentProps } from '../../lib'; +import { type Note } from '../../model'; + +type DialogStateType = 'note' | 'product'; + +interface DialogState { + form: string; + title: string; + content: ReactElement; + submitText: string; + submitLoading: boolean; + submitDisabled: boolean; + onCancel: () => void; +} + +interface Props { + submitText: string; + submitSuccess: boolean; + product: productModel.AutocompleteOption | null; + productAutocompleteData: productLib.AutocompleteData; + categorySelect: categoryLib.CategorySelectData; + recognizeNotesResult: RecognizeNotesResult; + disableContentPaddingTop?: boolean; + renderTrigger: (onClick: () => void) => ReactElement; + renderContent: (props: RenderContentProps) => ReactElement; + onCancel: () => void; + onSubmit: (note: Note) => Promise; + onSubmitSuccess: () => void; +} + +export const NoteInputFlow: FC = ({ + submitText, + submitSuccess, + product, + productAutocompleteData, + categorySelect, + recognizeNotesResult, + disableContentPaddingTop, + renderTrigger, + renderContent, + onCancel, + onSubmit, + onSubmitSuccess, +}) => { + const [dialogOpened, toggleNoteDialog] = useToggle(); + + const [dialogStateType, setDialogStateType] = useState('note'); + + const [noteSubmitLoading, setNoteSubmitLoading] = useState(false); + const [noteSubmitDisabled, setNoteSubmitDisabled] = useState(true); + const [productSubmitDisabled, setProductSubmitDisabled] = useState(true); + + const productForm = productLib.useFormValues(); + const productAutocompleteInput = productLib.useAutocompleteInput(product); + + const { setValues: setProductFormValues } = productForm; + const { setValue: setProductAutocompleteValue, clearValue: clearProductAutocomplete } = + productAutocompleteInput; + + const handleNoteSubmitDisabledChange = useCallback((disabled: boolean): void => { + setNoteSubmitDisabled(disabled); + }, []); + + const handleProductSubmitDisabledChange = useCallback((disabled: boolean): void => { + setProductSubmitDisabled(disabled); + }, []); + + useEffect(() => { + if (submitSuccess) { + onSubmitSuccess(); + clearProductAutocomplete(); + toggleNoteDialog(); + setNoteSubmitLoading(false); + } + }, [clearProductAutocomplete, onSubmitSuccess, submitSuccess, toggleNoteDialog]); + + useEffect(() => { + if (recognizeNotesResult.isSuccess) { + const note = recognizeNotesResult.notes.at(0); + const category = categorySelect.data.at(0); + + if (!note || !category) { + handleNoteSubmitDisabledChange(true); + return; + } + + handleNoteSubmitDisabledChange(false); + + setProductAutocompleteValue({ + freeSolo: true, + editing: true, + name: note.product.name, + caloriesCost: note.product.caloriesCost, + defaultQuantity: note.quantity, + category, + }); + + setProductFormValues({ + name: note.product.name, + caloriesCost: note.product.caloriesCost, + defaultQuantity: note.quantity, + category, + }); + } + }, [ + categorySelect.data, + handleNoteSubmitDisabledChange, + recognizeNotesResult.isSuccess, + recognizeNotesResult.notes, + setProductAutocompleteValue, + setProductFormValues, + ]); + + const handleNoteSubmit = async (note: Note): Promise => { + try { + setNoteSubmitLoading(true); + await onSubmit(note); + } catch (err) { + setNoteSubmitLoading(false); + } + }; + + const handleNoteCancel = (): void => { + toggleNoteDialog(); + productAutocompleteInput.clearValue(); + onCancel(); + }; + + const handleProductSubmit = (product: productModel.FormValues): void => { + const { name, caloriesCost, defaultQuantity, category } = product; + + productAutocompleteInput.setValue({ + freeSolo: true, + editing: true, + name, + caloriesCost, + defaultQuantity, + category, + }); + + productForm.setValues(product); + setDialogStateType('note'); + }; + + const handleProductCancel = (): void => { + productForm.clearValues(); + setDialogStateType('note'); + }; + + const handleProductChange = (value: productModel.AutocompleteOption | null): void => { + productAutocompleteInput.setValue(value); + productForm.clearValues(); + + if (value?.freeSolo === true) { + // Timeout to avoid instant validation of the dialog's form + // Also fixes "Warning: An update to ... inside a test was not wrapped in act(...)" in tests + setTimeout(() => { + productForm.setValues({ + name: value.name, + caloriesCost: value.caloriesCost, + defaultQuantity: value.defaultQuantity, + category: value.category, + }); + + setDialogStateType('product'); + }); + } + }; + + const renderNoteContent = (): ReactElement => + renderContent({ + submitLoading: noteSubmitLoading, + submitDisabled: noteSubmitDisabled, + productAutocompleteInput, + productAutocompleteData, + productFormValues: productForm.values, + onClose: handleNoteCancel, + onSubmit: handleNoteSubmit, + onSubmitDisabledChange: handleNoteSubmitDisabledChange, + onProductChange: handleProductChange, + onProductFormValuesChange: productForm.setValues, + }); + + const renderProductContent = (form: string): ReactElement => ( + ( + + )} + id={form} + values={productForm.values} + touched={productAutocompleteInput.value?.freeSolo} + onSubmit={handleProductSubmit} + onSubmitDisabledChange={handleProductSubmitDisabledChange} + /> + ); + + const getDialogState = (): DialogState | null => { + switch (dialogStateType) { + case 'note': + return { + form: 'note-input-form', + title: 'New note', + content: renderNoteContent(), + submitText, + submitLoading: noteSubmitLoading, + submitDisabled: noteSubmitDisabled, + onCancel: handleNoteCancel, + }; + case 'product': + return { + form: 'product-input-form', + title: 'Edit product', + content: renderProductContent('product-input-form'), + submitText: 'Add', + submitLoading: false, + submitDisabled: productSubmitDisabled, + onCancel: handleProductCancel, + }; + default: + return null; + } + }; + + const dialogState = getDialogState(); + + if (!dialogState) { + return null; + } + + return ( + <> + {renderTrigger(toggleNoteDialog)} + ( + + )} + renderSubmit={submitProps => ( + + )} + /> + + ); +}; diff --git a/src/frontend/src/features/note/addEdit/ui/NoteInputFlow/index.ts b/src/frontend/src/features/note/addEdit/ui/NoteInputFlow/index.ts new file mode 100644 index 000000000..41a4c51a5 --- /dev/null +++ b/src/frontend/src/features/note/addEdit/ui/NoteInputFlow/index.ts @@ -0,0 +1 @@ +export * from './NoteInputFlow'; diff --git a/src/frontend/src/features/note/addEdit/ui/NoteInputForm.tsx b/src/frontend/src/features/note/addEdit/ui/NoteInputForm.tsx index 585a7e7fc..a73122754 100644 --- a/src/frontend/src/features/note/addEdit/ui/NoteInputForm.tsx +++ b/src/frontend/src/features/note/addEdit/ui/NoteInputForm.tsx @@ -1,33 +1,24 @@ import { TextField } from '@mui/material'; import { useEffect, type FC, type FormEventHandler, type ReactElement } from 'react'; import { noteLib, type noteModel } from '@/entities/note'; -import { type productLib, type productModel } from '@/entities/product'; -import { useInput, type UseInputResult } from '@/shared/hooks'; +import { type productLib } from '@/entities/product'; +import { useInput } from '@/shared/hooks'; import { mapToNumericInputProps, validateQuantity } from '@/shared/lib'; import { type Note } from '../model'; -export interface NoteInputFormProps { +interface Props { id: string; - pageId: number; - mealType: noteModel.MealType; - displayOrder: number; - productAutocompleteInput: UseInputResult< - productModel.AutocompleteOption | null, - productLib.AutocompleteInputProps - >; - quantity: number; + values: noteModel.FormValues; + productAutocompleteInput: productLib.AutocompleteInput; renderProductAutocomplete: (props: productLib.AutocompleteInputProps) => ReactElement; onSubmit: (note: Note) => Promise; onSubmitDisabledChange: (disabled: boolean) => void; } -export const NoteInputForm: FC = ({ +export const NoteInputForm: FC = ({ id, - pageId, - mealType, - displayOrder, + values, productAutocompleteInput, - quantity, renderProductAutocomplete, onSubmit, onSubmitDisabledChange, @@ -37,7 +28,7 @@ export const NoteInputForm: FC = ({ clearValue: clearQuantity, ...quantityInput } = useInput({ - initialValue: quantity, + initialValue: values.quantity, errorHelperText: 'Quantity is invalid', validate: validateQuantity, mapToInputProps: mapToNumericInputProps, @@ -71,9 +62,9 @@ export const NoteInputForm: FC = ({ } onSubmit({ - pageId, - mealType, - displayOrder, + pageId: values.pageId, + mealType: values.mealType, + displayOrder: values.displayOrder, productQuantity: quantityInput.value, product: productAutocompleteInput.value, }); @@ -83,7 +74,7 @@ export const NoteInputForm: FC = ({
Promise; + onProductFormValuesChange: (values: productModel.FormValues) => void; +} + +export const NoteInputFromPhotoFlow: FC = ({ + noteFormValues, + productFormValues, + productAutocompleteInput, + productAutocompleteData, + recognizeNotesResult, + uploadedPhotos, + onSubmit, + onSubmitDisabledChange, + onProductChange, + onUploadSuccess, +}) => { + const handleUpload = async (file: File): Promise => { + const base64 = await convertToBase64String(file); + const resizedFile = await resizeImage(base64, 512, file.name); + const resizedBase64 = await convertToBase64String(resizedFile); + + await onUploadSuccess([ + { + src: resizedBase64, + name: file.name, + file: resizedFile, + }, + ]); + }; + + const handleRetry = async (): Promise => { + await onUploadSuccess(uploadedPhotos); + }; + + if (uploadedPhotos.length === 0) { + return ( + + Upload photo + + ); + } + + return ( + + + {uploadedPhotos.map((photo, index) => ( + + + + + ))} + + {recognizeNotesResult.isLoading && ( + + + + )} + {recognizeNotesResult.error && ( + + Retry + + } + > + {recognizeNotesResult.error.title} + {recognizeNotesResult.error.message} + + )} + {recognizeNotesResult.isSuccess && ( + <> + {recognizeNotesResult.notes.length === 0 && ( + No food found on your photo + )} + {recognizeNotesResult.notes.length > 0 && ( + ( + + )} + onSubmit={onSubmit} + onSubmitDisabledChange={onSubmitDisabledChange} + /> + )} + + )} + + ); +}; diff --git a/src/frontend/src/features/note/addEdit/ui/index.ts b/src/frontend/src/features/note/addEdit/ui/index.ts index e579ed7c7..567443765 100644 --- a/src/frontend/src/features/note/addEdit/ui/index.ts +++ b/src/frontend/src/features/note/addEdit/ui/index.ts @@ -1,4 +1,3 @@ export * from './AddNote'; -export * from './AddNoteByPhoto'; export * from './EditNote'; export * from './NoteInputForm'; diff --git a/src/frontend/src/features/product/addEdit/ui/AddProduct.tsx b/src/frontend/src/features/product/addEdit/ui/AddProduct.tsx index ab11b4aeb..e0c805ced 100644 --- a/src/frontend/src/features/product/addEdit/ui/AddProduct.tsx +++ b/src/frontend/src/features/product/addEdit/ui/AddProduct.tsx @@ -52,7 +52,7 @@ export const AddProduct: FC = () => { title="New product" submitText="Add" isLoading={createProductRequest.isLoading || products.isFetching} - product={product} + productFormValues={product} categories={categorySelect.data} categoriesLoading={categorySelect.isLoading} onSubmit={handleDialogSubmit} diff --git a/src/frontend/src/features/product/addEdit/ui/EditProduct.tsx b/src/frontend/src/features/product/addEdit/ui/EditProduct.tsx index 98a2429ab..0fc7ffbc0 100644 --- a/src/frontend/src/features/product/addEdit/ui/EditProduct.tsx +++ b/src/frontend/src/features/product/addEdit/ui/EditProduct.tsx @@ -45,7 +45,7 @@ export const EditProduct: FC = ({ product, renderTrigger }) => { title="Edit product" submitText="Save" isLoading={editProductRequest.isLoading || products.isFetching} - product={productFormData} + productFormValues={productFormData} categories={categorySelect.data} categoriesLoading={categorySelect.isLoading} onSubmit={handleEditDialogSubmit} diff --git a/src/frontend/src/features/product/addEdit/ui/ProductInputDialog/ProductInputDialog.fixture.tsx b/src/frontend/src/features/product/addEdit/ui/ProductInputDialog/ProductInputDialog.fixture.tsx index 26a5d7cef..f6c6c81a5 100644 --- a/src/frontend/src/features/product/addEdit/ui/ProductInputDialog/ProductInputDialog.fixture.tsx +++ b/src/frontend/src/features/product/addEdit/ui/ProductInputDialog/ProductInputDialog.fixture.tsx @@ -26,7 +26,7 @@ class ProductInputDialogBuilder { isLoading={false} categories={this._categories} categoriesLoading={false} - product={this._product} + productFormValues={this._product} onSubmit={this._onSubmitMock} onClose={onTriggerClick} /> diff --git a/src/frontend/src/features/product/addEdit/ui/ProductInputDialog/ProductInputDialog.tsx b/src/frontend/src/features/product/addEdit/ui/ProductInputDialog/ProductInputDialog.tsx index 07c7add29..a59293e3f 100644 --- a/src/frontend/src/features/product/addEdit/ui/ProductInputDialog/ProductInputDialog.tsx +++ b/src/frontend/src/features/product/addEdit/ui/ProductInputDialog/ProductInputDialog.tsx @@ -1,6 +1,6 @@ -import { type FC, useState, useCallback, useEffect } from 'react'; +import { type FC, useState, useCallback } from 'react'; import { CategorySelect } from '@/entities/category'; -import { ProductInputForm, productLib, type productModel } from '@/entities/product'; +import { ProductInputForm, type productModel } from '@/entities/product'; import { type SelectOption } from '@/shared/types'; import { Button, Dialog } from '@/shared/ui'; @@ -11,7 +11,8 @@ interface ProductInputDialogProps { isLoading: boolean; categories: SelectOption[]; categoriesLoading: boolean; - product: productModel.FormValues; + productFormValues: productModel.FormValues; + freeSolo?: boolean; onSubmit: (product: productModel.FormValues) => void; onClose: () => void; } @@ -23,27 +24,20 @@ export const ProductInputDialog: FC = ({ isLoading, categories, categoriesLoading, - product, + productFormValues, + freeSolo, onSubmit, onClose, }) => { const [submitDisabled, setSubmitDisabled] = useState(true); - const { values: productFormValues, clearValues: clearProductFormValues } = - productLib.useFormValues(product); - - useEffect(() => { - if (opened) { - clearProductFormValues(); - } - }, [clearProductFormValues, opened]); - const handleSubmitDisabledChange = useCallback((disabled: boolean): void => { setSubmitDisabled(disabled); }, []); return ( = ({ content={ ; }); -const FullScreenDialog: FC = ({ title, opened, content, onClose, renderSubmit }) => ( +const FullScreenDialog: FC = ({ + title, + opened, + content, + disableContentPaddingTop, + disableContentPaddingBottom, + onClose, + renderSubmit, +}) => ( @@ -40,7 +48,9 @@ const FullScreenDialog: FC = ({ title, opened, content, onClose, renderSu })} - {content} + + {content} + ); diff --git a/src/frontend/src/shared/ui/Dialog/ModalDialog.tsx b/src/frontend/src/shared/ui/Dialog/ModalDialog.tsx index 8e8d3bb6e..dd2eba931 100644 --- a/src/frontend/src/shared/ui/Dialog/ModalDialog.tsx +++ b/src/frontend/src/shared/ui/Dialog/ModalDialog.tsx @@ -17,11 +17,23 @@ const ModalDialog: FC = ({ title, opened, content, + disableContentPaddingTop, + disableContentPaddingBottom, + pinToTop, renderSubmit, renderCancel, onClose, }) => ( - + = ({ {title} - {content} + + {content} + ({ diff --git a/src/frontend/src/shared/ui/Dialog/types.ts b/src/frontend/src/shared/ui/Dialog/types.ts index 1d90e97d1..fa3ba760d 100644 --- a/src/frontend/src/shared/ui/Dialog/types.ts +++ b/src/frontend/src/shared/ui/Dialog/types.ts @@ -7,6 +7,9 @@ export interface DialogBaseProps { title: string; opened: boolean; content: ReactElement; + disableContentPaddingTop?: boolean; + disableContentPaddingBottom?: boolean; + pinToTop?: boolean; onClose: () => void; renderSubmit: RenderActionFn; } diff --git a/src/frontend/src/shared/ui/UploadButton.tsx b/src/frontend/src/shared/ui/UploadButton.tsx new file mode 100644 index 000000000..345e19f9e --- /dev/null +++ b/src/frontend/src/shared/ui/UploadButton.tsx @@ -0,0 +1,37 @@ +import { Button, styled } from '@mui/material'; +import { visuallyHidden } from '@mui/utils'; +import { type PropsWithChildren, type ChangeEventHandler, type FC } from 'react'; + +const FileInputStyled = styled('input')(() => ({ ...visuallyHidden })); + +interface Props { + name: string; + accept: string; + onUpload: (file: File) => Promise; +} + +export const UploadButton: FC> = ({ + children, + name, + accept, + onUpload, +}) => { + const handleUpload: ChangeEventHandler = async event => { + try { + const file = event.target?.files?.item(0); + + if (file) { + await onUpload(file); + } + } finally { + event.target.value = ''; + } + }; + + return ( + + ); +}; diff --git a/src/frontend/src/shared/ui/index.ts b/src/frontend/src/shared/ui/index.ts index 04a5f669a..92cf82ca3 100644 --- a/src/frontend/src/shared/ui/index.ts +++ b/src/frontend/src/shared/ui/index.ts @@ -8,3 +8,4 @@ export * from './DatePicker'; export * from './Dialog'; export * from './AppDialog'; export * from './LoadingContainer'; +export * from './UploadButton'; diff --git a/src/frontend/src/widgets/MealsList/ui/NotesList.tsx b/src/frontend/src/widgets/MealsList/ui/NotesList.tsx index 59a47c307..75d43415a 100644 --- a/src/frontend/src/widgets/MealsList/ui/NotesList.tsx +++ b/src/frontend/src/widgets/MealsList/ui/NotesList.tsx @@ -1,7 +1,7 @@ -import { ButtonGroup, List, ListItem } from '@mui/material'; +import { List, ListItem } from '@mui/material'; import { type FC } from 'react'; -import { noteLib, type noteModel } from '@/entities/note'; -import { AddNote, AddNoteByPhoto } from '@/features/note/addEdit'; +import { type noteModel } from '@/entities/note'; +import { AddNote } from '@/features/note/addEdit'; import { NotesListItem } from './NotesListItem'; interface Props { @@ -10,20 +10,13 @@ interface Props { notes: noteModel.NoteItem[]; } -export const NotesList: FC = ({ pageId, mealType, notes }) => { - const nextDisplayOrder = noteLib.useNextDisplayOrder(pageId); - - return ( - - {notes.map(note => ( - - ))} - - - - - - - - ); -}; +export const NotesList: FC = ({ pageId, mealType, notes }) => ( + + {notes.map(note => ( + + ))} + + + + +); diff --git a/src/frontend/yarn.lock b/src/frontend/yarn.lock index 973db518f..0492a68da 100644 --- a/src/frontend/yarn.lock +++ b/src/frontend/yarn.lock @@ -6078,6 +6078,9 @@ __metadata: vitest-preview: "npm:^0.0.1" workbox-precaching: "npm:^7.1.0" workbox-window: "npm:^7.1.0" + dependenciesMeta: + eslint-plugin-prettier@5.1.3: + unplugged: true languageName: unknown linkType: soft