diff --git a/package.json b/package.json index 8edb72f..b240222 100644 --- a/package.json +++ b/package.json @@ -40,6 +40,7 @@ "slick-carousel": "^1.8.1", "tailwind-merge": "^2.5.1", "tailwindcss-animate": "^1.0.7", + "use-long-press": "^3.2.0", "vaul": "^0.9.1", "zod": "^3.23.8" }, diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index af45ef8..d87d5ff 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -77,6 +77,9 @@ importers: tailwindcss-animate: specifier: ^1.0.7 version: 1.0.7(tailwindcss@3.4.9) + use-long-press: + specifier: ^3.2.0 + version: 3.2.0(react@18.3.1) vaul: specifier: ^0.9.1 version: 0.9.1(@types/react-dom@18.3.0)(@types/react@18.3.3)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) @@ -4695,6 +4698,11 @@ packages: '@types/react': optional: true + use-long-press@3.2.0: + resolution: {integrity: sha512-uq5o2qFR1VRjHn8Of7Fl344/AGvgk7C5Mcb4aSb1ZRVp6PkgdXJJLdRrlSTJQVkkQcDuqFbFc3mDX4COg7mRTA==} + peerDependencies: + react: '>=16.8.0' + use-sidecar@1.1.2: resolution: {integrity: sha512-epTbsLuzZ7lPClpz2TyryBfztm7m+28DlEv2ZCQ3MDr5ssiwyOwGH/e5F9CkfWjJ1t4clvI58yF822/GUkjjhw==} engines: {node: '>=10'} @@ -9803,6 +9811,10 @@ snapshots: optionalDependencies: '@types/react': 18.3.3 + use-long-press@3.2.0(react@18.3.1): + dependencies: + react: 18.3.1 + use-sidecar@1.1.2(@types/react@18.3.3)(react@18.3.1): dependencies: detect-node-es: 1.1.0 diff --git a/src/__test__/utils/validation.test.ts b/src/__test__/utils/validation.test.ts new file mode 100644 index 0000000..cb419f7 --- /dev/null +++ b/src/__test__/utils/validation.test.ts @@ -0,0 +1,21 @@ +import { describe, expect, it } from 'vitest'; +import { isFalsy } from '@/utils/validation/isFalsy.ts'; + +describe('validation test: ', () => { + describe('function isFalsy test: ', () => { + it('should return false when input is not falsy', () => { + expect(isFalsy(1)).toBe(false); + expect(isFalsy('string')).toBe(false); + expect(isFalsy({})).toBe(false); + expect(isFalsy([])).toBe(false); + expect(isFalsy(true)).toBe(false); + }); + it('should return true when input is falsy', () => { + expect(isFalsy(0)).toBe(true); + expect(isFalsy('')).toBe(true); + expect(isFalsy(null)).toBe(true); + expect(isFalsy(undefined)).toBe(true); + expect(isFalsy(false)).toBe(true); + }); + }); +}); diff --git a/src/apis/location/addNewLocation.ts b/src/apis/location/addNewLocation.ts new file mode 100644 index 0000000..7be6401 --- /dev/null +++ b/src/apis/location/addNewLocation.ts @@ -0,0 +1,11 @@ +import baseAxios from '@/libs/baseAxios.ts'; + +// TODO: 추후 에러 처리 해야함 + +export interface AddNewLocationResponse { + id: number; + name: string; +} + +export const addNewLocation = async (name: string) => + baseAxios.post('/location', { name }); diff --git a/src/apis/location/deleteLocation.ts b/src/apis/location/deleteLocation.ts new file mode 100644 index 0000000..33feb6c --- /dev/null +++ b/src/apis/location/deleteLocation.ts @@ -0,0 +1,4 @@ +import baseAxios from '@/libs/baseAxios.ts'; + +export const deleteLocation = async (locationId: number) => + baseAxios.delete(`/location/${locationId}`); diff --git a/src/apis/location/getAllLocation.ts b/src/apis/location/getAllLocation.ts new file mode 100644 index 0000000..af84037 --- /dev/null +++ b/src/apis/location/getAllLocation.ts @@ -0,0 +1,8 @@ +import baseAxios from '@/libs/baseAxios.ts'; +import { PlantLocation } from '@/types/plantLocation'; + +export const getAllLocation = async () => { + const response = await baseAxios.get('/location'); + + return response.data; +}; diff --git a/src/apis/location/updateLocation.ts b/src/apis/location/updateLocation.ts new file mode 100644 index 0000000..fab8105 --- /dev/null +++ b/src/apis/location/updateLocation.ts @@ -0,0 +1,14 @@ +import baseAxios from '@/libs/baseAxios.ts'; + +export interface UpdateLocationParams { + locationId: number; + name: string; +} + +export interface UpdateLocationResponse { + id: number; + name: string; +} + +export const updateLocation = async ({ locationId, name }: UpdateLocationParams) => + baseAxios.patch(`/location/${locationId}`, { name }); diff --git a/src/assets/icon/BinLineMono.tsx b/src/assets/icon/BinLineMono.tsx new file mode 100644 index 0000000..4bc41ea --- /dev/null +++ b/src/assets/icon/BinLineMono.tsx @@ -0,0 +1,37 @@ +const BinLineMono = () => { + return ( + + + + + + + + + + + + ); +}; + +export default BinLineMono; diff --git a/src/assets/icon/icon-plus-mono.tsx b/src/assets/icon/icon-plus-mono.tsx new file mode 100644 index 0000000..c1305ac --- /dev/null +++ b/src/assets/icon/icon-plus-mono.tsx @@ -0,0 +1,17 @@ +const IconPlusMono = () => { + return ( + + + + + + ); +}; + +export default IconPlusMono; diff --git a/src/components/addPlant/LocationBadge.tsx b/src/components/addPlant/LocationBadge.tsx new file mode 100644 index 0000000..36c8b28 --- /dev/null +++ b/src/components/addPlant/LocationBadge.tsx @@ -0,0 +1,26 @@ +import Badge from '@/components/common/Badge'; +import { cn } from '@/utils.ts'; +import { PlantLocation } from '@/types/plantLocation'; +import { useLongPress } from 'use-long-press'; + +interface LocationBadgeProps { + location: PlantLocation; + selected: boolean; + onClick: () => void; + onLongPress: (defaultValue: string) => void; +} + +const LocationBadge = ({ location, selected, onClick, onLongPress }: LocationBadgeProps) => { + const bind = useLongPress(() => onLongPress(location.name), {}); + + return ( + + ); +}; + +export default LocationBadge; diff --git a/src/components/addPlant/PlantLocationBadgeList.tsx b/src/components/addPlant/PlantLocationBadgeList.tsx index 1d1a032..6316e62 100644 --- a/src/components/addPlant/PlantLocationBadgeList.tsx +++ b/src/components/addPlant/PlantLocationBadgeList.tsx @@ -1,30 +1,205 @@ +import { ChangeEventHandler } from 'react'; + +import { useUpdateLocation } from '@/queries/useUpdateLocation.ts'; +import { useDeleteLocation } from '@/queries/useDeleteLocation.ts'; +import { MODE, useLocationModalState } from '@/hooks/addNewPlant/useLocationModalState.ts'; +import { useNewLocation } from '@/hooks/addNewPlant/useNewLocation.ts'; +import { useEditLocation } from '@/hooks/addNewPlant/useEditLocation.ts'; +import { useDeleteLocationModal } from '@/hooks/addNewPlant/useDeleteLocationModal.ts'; +import { useAddNewLocation } from '@/queries/useAddNewLocation.ts'; +import { useGetAllLocation } from '@/queries/useGetAllLocation.ts'; +import useToast from '@/hooks/useToast.tsx'; + +import CTAButton from '@/components/common/CTAButton'; +import TextFieldV2 from '@/components/common/TextFieldV2'; import Label from '@/components/common/Label'; -import { plantLocation } from '@/constants/plantLocation.ts'; -import { cn } from '@/utils.ts'; import Badge from '@/components/common/Badge'; -import { useState } from 'react'; +import CenterBottomSheet from '@/components/common/CenterBottomSheet'; + +import BinLineMono from '@/assets/icon/BinLineMono.tsx'; +import RoundedGreenChecked from '@/assets/icon/RoundedGreenChecked.tsx'; + +import LocationBadge from '@/components/addPlant/LocationBadge.tsx'; + +import { FormKey, FormValue } from '@/pages/AddPlantPage.tsx'; import { PlantLocation } from '@/types/plantLocation'; -const PlantLocationBadgeList = () => { - const [selectedLocation, setSelectedLocation] = useState(plantLocation[0]); +import { cn } from '@/utils.ts'; +import { isFalsy } from '@/utils/validation/isFalsy.ts'; +import IconPlusMono from '@/assets/icon/icon-plus-mono.tsx'; + +interface PlantLocationBadgeListProps { + handleChange: (key: FormKey, value: FormValue) => void; + selectedLocation?: PlantLocation; +} + +const PlantLocationBadgeList = ({ + handleChange, + selectedLocation, +}: PlantLocationBadgeListProps) => { + const { newLocationName, initializeNewLocation, isError, changeNewLocationName } = + useNewLocation(); + const { modalState, initializeModalState, openEditModal, openAddModal } = useLocationModalState(); + const { editingLocationId, setEditingLocationId, initializeEditingLocation } = useEditLocation(); + const { deleteLocationId, openDeleteModal, isOpenDeleteModal, closeDeleteModal } = + useDeleteLocationModal(); + + const data = useGetAllLocation(); + const { mutate: addNewLocation } = useAddNewLocation(); + const { mutate: updateLocation } = useUpdateLocation(); + const { mutate: deleteLocation } = useDeleteLocation(); + const { openToast } = useToast(); + + const changeNewLocationHandler: ChangeEventHandler = (e) => { + changeNewLocationName(e.target.value); + }; + + const addNewLocationHandler = () => { + switch (modalState.mode) { + case MODE.ADD: + addNewLocation(newLocationName); + break; + case MODE.EDIT: + updateLocation({ locationId: editingLocationId!, name: newLocationName }); + break; + default: + throw Error('에러가 발생했습니다.'); + } + + initializeModalState(); + initializeNewLocation(); + + setTimeout(() => { + openToast({ + message: ( +
+ + 등록 되었습니다. +
+ ), + duration: 1000, + }); + }, 100); + }; + + const openAddNewModalHandler = () => { + openAddModal(); + }; + + const closeModalHandler = () => { + initializeModalState(); + initializeNewLocation(); + initializeEditingLocation(); + }; + + const openEditModalHandler = (location: PlantLocation) => { + openEditModal(); + changeNewLocationName(location.name); + setEditingLocationId(location.id); + }; + + const openDeleteModalHandler = () => { + if (!editingLocationId) throw Error('삭제 버튼 클릭시 editingLocationId 를 찾을 수 없습니다.'); + openDeleteModal(editingLocationId); + initializeModalState(); + }; + + const closeDeleteModalHandler = () => { + closeDeleteModal(); + }; + + const deleteLocationHandler = () => { + if (!deleteLocationId) throw Error('editingLocationId 를 찾을 수 없습니다.'); + deleteLocation(deleteLocationId); + closeDeleteModalHandler(); + setTimeout(() => { + openToast({ + message:

삭제 되었습니다.

, + }); + }, 100); + }; return (
+ } + actions={[ + , + ]} + isOpen={modalState.open} + onClose={closeModalHandler} + // 바텀 시트 제목 사이즈 조절할 때 사용되는 값입니다. true 일 경우 큰 사이즈, 아닐 경우 작은 사이즈 입니다. + headerAsLabel + /> + } + actionDirection={'row'} + actions={[ + , + deleteLocationHandler()} + className={'bg-Red500'} + />, + ]} + isOpen={isOpenDeleteModal} + onClose={closeDeleteModalHandler} + /> ); diff --git a/src/components/addPlant/PlantTypeTextField.tsx b/src/components/addPlant/PlantTypeTextField.tsx index 483cb66..fbd7bb6 100644 --- a/src/components/addPlant/PlantTypeTextField.tsx +++ b/src/components/addPlant/PlantTypeTextField.tsx @@ -1,8 +1,9 @@ import TextField from '@/components/common/TextField'; import IconSearchMono from '@/assets/icon/icon-search-mono.tsx'; import SearchPlantPage from '@/pages/SearchPlantPage.tsx'; -import { useEffect, useState } from 'react'; +import { MouseEventHandler, useEffect, useState } from 'react'; import { usePlantTypeSearchParams } from '@/hooks/usePlantTypeSearchParams.ts'; +import useToast from '@/hooks/useToast'; interface PlantTypeTextFieldProps { handleChange: (key: '식물 종류', value: { value: string; required: boolean }) => void; @@ -10,34 +11,34 @@ interface PlantTypeTextFieldProps { const PlantTypeTextField = ({ handleChange }: PlantTypeTextFieldProps) => { const [open, setOpen] = useState(false); + const { openToast } = useToast(); const { plantType } = usePlantTypeSearchParams(); useEffect(() => { - if (plantType) { + if (plantType !== null && plantType !== '') { handleChange('식물 종류', { value: plantType, required: true, }); } - }, [plantType, handleChange]); + }, [plantType, handleChange, openToast]); + + const onMouseDown: MouseEventHandler = (e) => { + e.preventDefault(); + setOpen(true); + }; return ( <> -
{ - e.stopPropagation(); - setOpen(true); - }} - > - } - onChange={(e) => handleChange('식물 종류', { value: e.target.value, required: true })} - value={plantType ?? ''} - /> -
+ } + onChange={(e) => handleChange('식물 종류', { value: e.target.value, required: true })} + value={plantType ?? ''} + onMouseDown={onMouseDown} + /> {open && (
setOpen(false)} /> diff --git "a/src/components/addPlant/\353\247\210\354\247\200\353\247\211\354\234\274\353\241\234\353\254\274\354\244\200\353\202\240.tsx" "b/src/components/addPlant/\353\247\210\354\247\200\353\247\211\354\234\274\353\241\234\353\254\274\354\244\200\353\202\240.tsx" index 2773f81..f9b2fa2 100644 --- "a/src/components/addPlant/\353\247\210\354\247\200\353\247\211\354\234\274\353\241\234\353\254\274\354\244\200\353\202\240.tsx" +++ "b/src/components/addPlant/\353\247\210\354\247\200\353\247\211\354\234\274\353\241\234\353\254\274\354\244\200\353\202\240.tsx" @@ -11,19 +11,21 @@ const 마지막으로물준날 = () => { const [open, setOpen] = useState(false); - const onClick = (e: MouseEvent) => { - e.stopPropagation(); + const onMouseDown = (e: MouseEvent) => { e.preventDefault(); setOpen(true); }; return ( <> - + { } actions={[]} isOpen={open} - onClose={() => {}} + onClose={() => { + setOpen(false); + }} /> ); diff --git "a/src/components/addPlant/\355\225\250\352\273\230\355\225\230\352\270\260\354\213\234\354\236\221\355\225\234\353\202\240.tsx" "b/src/components/addPlant/\355\225\250\352\273\230\355\225\230\352\270\260\354\213\234\354\236\221\355\225\234\353\202\240.tsx" index e07590b..a1e69a9 100644 --- "a/src/components/addPlant/\355\225\250\352\273\230\355\225\230\352\270\260\354\213\234\354\236\221\355\225\234\353\202\240.tsx" +++ "b/src/components/addPlant/\355\225\250\352\273\230\355\225\230\352\270\260\354\213\234\354\236\221\355\225\234\353\202\240.tsx" @@ -1,4 +1,4 @@ -import { MouseEvent, useState } from 'react'; +import { MouseEventHandler, useState } from 'react'; import TextField from '@/components/common/TextField'; import BottomSheet from '@/components/common/BottomSheet'; @@ -12,17 +12,19 @@ const 함께하기시작한날 = () => { const [open, setOpen] = useState(false); - const onClick = (e: MouseEvent) => { - e.stopPropagation(); + const onMouseDown: MouseEventHandler = (e) => { e.preventDefault(); setOpen(true); }; return ( <> - + { } actions={[]} isOpen={open} - onClose={() => {}} + onClose={() => { + setOpen(false); + }} /> ); diff --git a/src/components/common/BottomSheet/index.tsx b/src/components/common/BottomSheet/index.tsx index 0f4e3d6..39e0a27 100644 --- a/src/components/common/BottomSheet/index.tsx +++ b/src/components/common/BottomSheet/index.tsx @@ -6,6 +6,7 @@ import { DrawerTitle, } from '@/components/ui/drawer.tsx'; import { ReactNode } from 'react'; +import { cn } from '@/utils.ts'; interface BottomSheetProps { title: string; @@ -13,14 +14,30 @@ interface BottomSheetProps { actions: ReactNode[]; isOpen: boolean; onClose: () => void; + headerAsLabel?: boolean; } -const BottomSheet = ({ isOpen, onClose, title, content, actions }: BottomSheetProps) => { +const BottomSheet = ({ + isOpen, + onClose, + title, + content, + actions, + headerAsLabel, +}: BottomSheetProps) => { return ( - {title} + + {title} + {content} {...actions} diff --git a/src/components/common/CTAButton/index.tsx b/src/components/common/CTAButton/index.tsx index 0a3174f..43434da 100644 --- a/src/components/common/CTAButton/index.tsx +++ b/src/components/common/CTAButton/index.tsx @@ -1,9 +1,10 @@ import { Button, ButtonProps } from '@/components/ui/button.tsx'; import { cn } from '@/utils.ts'; +import { ReactNode } from 'react'; interface CTAButtonProps extends ButtonProps { color?: keyof typeof CTAButtonColor; - text: string; + text: string | ReactNode; } const CTAButtonColor = { diff --git a/src/components/common/CenterBottomSheet/index.tsx b/src/components/common/CenterBottomSheet/index.tsx new file mode 100644 index 0000000..6e655d3 --- /dev/null +++ b/src/components/common/CenterBottomSheet/index.tsx @@ -0,0 +1,54 @@ +import { + Drawer, + DrawerContent, + DrawerFooter, + DrawerHeader, + DrawerTitle, +} from '@/components/ui/centeredDrawer.tsx'; +import { ReactNode } from 'react'; +import { cn } from '@/utils.ts'; + +interface CenterBottomSheetProps { + title: string | ReactNode; + content: ReactNode; + actions: ReactNode[]; + isOpen: boolean; + onClose: () => void; + headerAsLabel?: boolean; + actionDirection?: 'row' | 'column'; +} + +const CenterBottomSheet = ({ + isOpen, + onClose, + title, + content, + actions, + headerAsLabel, + actionDirection = 'column', +}: CenterBottomSheetProps) => { + return ( + + + + + {title} + + + {content} + + {...actions} + + + + ); +}; + +export default CenterBottomSheet; diff --git a/src/components/common/SearchField/index.tsx b/src/components/common/SearchField/index.tsx index 2f7081a..6f812df 100644 --- a/src/components/common/SearchField/index.tsx +++ b/src/components/common/SearchField/index.tsx @@ -23,6 +23,7 @@ const SearchField: React.FC = ({ placeholder = '플레이스 type="text" value={query} onChange={handleChange} + autoFocus={true} placeholder={placeholder} className="w-full py-[8px] pl-[42px] pr-[22px] border border-Gray100 bg-Gray100 rounded-[10px] focus:outline-none text-regular-body font-medium caret-BloomingGreen500" /> diff --git a/src/components/common/TextField/index.tsx b/src/components/common/TextField/index.tsx index a44e75c..b0f489a 100644 --- a/src/components/common/TextField/index.tsx +++ b/src/components/common/TextField/index.tsx @@ -1,8 +1,9 @@ import { ChangeEventHandler, ReactNode, useEffect, useId, useRef, useState } from 'react'; import Label from '@/components/common/Label'; import { HiXCircle } from 'react-icons/hi'; +import { cn } from '@/utils.ts'; -interface TextFieldProps { +interface TextFieldProps extends React.InputHTMLAttributes { essential?: boolean; title: string; placeholder?: string; @@ -18,6 +19,7 @@ const TextField: React.FC = ({ title, icon, onClear, + disabled, ...inputProps }) => { const [isFocused, setIsFocused] = useState(false); @@ -42,7 +44,7 @@ const TextField: React.FC = ({ }, []); return ( -
+