From a1a27dca8aef4b656eee5865d0b5635759002233 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?s=E1=B4=8F=CA=9F=CA=99=C9=AA=20=E2=98=94=EF=B8=8F?= Date: Fri, 6 Oct 2023 15:47:55 +0900 Subject: [PATCH 01/55] =?UTF-8?q?[FE]=20feat:=20=ED=86=A0=EC=8A=A4?= =?UTF-8?q?=ED=8A=B8=20=EA=B5=AC=ED=98=84=20(#608)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat: 토스트 구현 * feat: portal 구현 * chore: 파일 이름 변경 * chore: 변경된 파일 이름에 따라 import명 변경 * refactor: animation 분리 * feat: 에러일 때 스타일 추가 * refactor: 기본, 에러 버전 구현 * refactor: 리뷰 반영 * refactor: toast context로 변경 * refactor: context action과 value로 분리 * refactor: toast context에서 portal 실행되도록 변경 * refactor: portal 위치 변경 * refactor: context를 사용해 스토리북 변경 * chore: 필요없는 fragment 삭제 * refactor: 구조 분해 할당으로 변경 * refactor: style 이름 변경 * refactor: 사용하지 않는 action 삭제 * refactor: useToast로 분리 Co-authored-by: Leejin Yang * chore: 사용하지 않는 테스트 버튼 삭제 --------- Co-authored-by: Leejin Yang --- frontend/.storybook/preview-body.html | 1 + frontend/public/index.html | 1 + frontend/public/mockServiceWorker.js | 2 +- .../components/Common/Toast/Toast.stories.tsx | 49 + .../src/components/Common/Toast/Toast.tsx | 41 + frontend/src/components/Common/index.ts | 1 + frontend/src/contexts/ToastContext.tsx | 80 + frontend/src/hooks/common/index.ts | 2 + frontend/src/hooks/common/useToast.ts | 37 + frontend/src/hooks/context/index.ts | 2 + .../hooks/context/useToastActionContext.ts | 14 + .../src/hooks/context/useToastValueContext.ts | 14 + frontend/src/index.tsx | 7 +- frontend/src/styles/animations.ts | 23 + .../src/styles/{index.ts => globalStyle.ts} | 0 frontend/yarn.lock | 2618 +++++++++-------- 16 files changed, 1593 insertions(+), 1299 deletions(-) create mode 100644 frontend/src/components/Common/Toast/Toast.stories.tsx create mode 100644 frontend/src/components/Common/Toast/Toast.tsx create mode 100644 frontend/src/contexts/ToastContext.tsx create mode 100644 frontend/src/hooks/common/useToast.ts create mode 100644 frontend/src/hooks/context/useToastActionContext.ts create mode 100644 frontend/src/hooks/context/useToastValueContext.ts create mode 100644 frontend/src/styles/animations.ts rename frontend/src/styles/{index.ts => globalStyle.ts} (100%) diff --git a/frontend/.storybook/preview-body.html b/frontend/.storybook/preview-body.html index 9ce666761..c74febeca 100644 --- a/frontend/.storybook/preview-body.html +++ b/frontend/.storybook/preview-body.html @@ -102,3 +102,4 @@ +
diff --git a/frontend/public/index.html b/frontend/public/index.html index bb66bebda..0359ca16d 100644 --- a/frontend/public/index.html +++ b/frontend/public/index.html @@ -27,5 +27,6 @@
+
diff --git a/frontend/public/mockServiceWorker.js b/frontend/public/mockServiceWorker.js index d8bc3ace3..51d85eeeb 100644 --- a/frontend/public/mockServiceWorker.js +++ b/frontend/public/mockServiceWorker.js @@ -2,7 +2,7 @@ /* tslint:disable */ /** - * Mock Service Worker (1.3.0). + * Mock Service Worker (1.3.2). * @see https://github.com/mswjs/msw * - Please do NOT modify this file. * - Please do NOT serve this file on production. diff --git a/frontend/src/components/Common/Toast/Toast.stories.tsx b/frontend/src/components/Common/Toast/Toast.stories.tsx new file mode 100644 index 000000000..383c43751 --- /dev/null +++ b/frontend/src/components/Common/Toast/Toast.stories.tsx @@ -0,0 +1,49 @@ +import type { Meta, StoryObj } from '@storybook/react'; + +import Toast from './Toast'; + +import ToastProvider from '@/contexts/ToastContext'; +import { useToastActionContext } from '@/hooks/context'; + +const meta: Meta = { + title: 'common/Toast', + component: Toast, + decorators: [ + (Story) => ( + + + + ), + ], +}; + +export default meta; +type Story = StoryObj; + +export const Default: Story = { + render: () => { + const { toast } = useToastActionContext(); + const handleClick = () => { + toast.success('성공'); + }; + return ( +
+ +
+ ); + }, +}; + +export const Error: Story = { + render: () => { + const { toast } = useToastActionContext(); + const handleClick = () => { + toast.error('실패'); + }; + return ( +
+ +
+ ); + }, +}; diff --git a/frontend/src/components/Common/Toast/Toast.tsx b/frontend/src/components/Common/Toast/Toast.tsx new file mode 100644 index 000000000..28571c6af --- /dev/null +++ b/frontend/src/components/Common/Toast/Toast.tsx @@ -0,0 +1,41 @@ +import { Text, useTheme } from '@fun-eat/design-system'; +import styled from 'styled-components'; + +import { useToast } from '@/hooks/common'; +import { fadeOut, slideIn } from '@/styles/animations'; + +interface ToastProps { + id: number; + message: string; + isError?: boolean; +} + +const Toast = ({ id, message, isError = false }: ToastProps) => { + const theme = useTheme(); + const isShown = useToast(id); + + return ( + + {message} + + ); +}; + +export default Toast; + +type ToastStyleProps = Pick & { isAnimating?: boolean }; + +const ToastWrapper = styled.div` + position: relative; + width: 100%; + height: 55px; + max-width: 560px; + border-radius: 10px; + background: ${({ isError, theme }) => (isError ? theme.colors.error : theme.colors.black)}; + animation: ${({ isAnimating }) => (isAnimating ? slideIn : fadeOut)} 0.3s ease-in-out forwards; +`; + +const Message = styled(Text)` + margin-left: 20px; + line-height: 55px; +`; diff --git a/frontend/src/components/Common/index.ts b/frontend/src/components/Common/index.ts index 8668ceb21..7d9a7c747 100644 --- a/frontend/src/components/Common/index.ts +++ b/frontend/src/components/Common/index.ts @@ -19,6 +19,7 @@ export { default as MarkedText } from './MarkedText/MarkedText'; export { default as NavigableSectionTitle } from './NavigableSectionTitle/NavigableSectionTitle'; export { default as Carousel } from './Carousel/Carousel'; export { default as RegisterButton } from './RegisterButton/RegisterButton'; +export { default as Toast } from './Toast/Toast'; export { default as CategoryItem } from './CategoryItem/CategoryItem'; export { default as CategoryFoodList } from './CategoryFoodList/CategoryFoodList'; export { default as CategoryStoreList } from './CategoryStoreList/CategoryStoreList'; diff --git a/frontend/src/contexts/ToastContext.tsx b/frontend/src/contexts/ToastContext.tsx new file mode 100644 index 000000000..0bfdb0837 --- /dev/null +++ b/frontend/src/contexts/ToastContext.tsx @@ -0,0 +1,80 @@ +import type { PropsWithChildren } from 'react'; +import { createContext, useState } from 'react'; +import { createPortal } from 'react-dom'; +import styled from 'styled-components'; + +import { Toast } from '@/components/Common'; + +interface ToastState { + id: number; + message: string; + isError?: boolean; +} + +interface ToastValue { + toasts: ToastState[]; +} +interface ToastAction { + toast: { + success: (message: string) => void; + error: (message: string) => void; + }; + deleteToast: (id: number) => void; +} + +export const ToastValueContext = createContext(null); +export const ToastActionContext = createContext(null); + +const ToastProvider = ({ children }: PropsWithChildren) => { + const [toasts, setToasts] = useState([]); + + const showToast = (id: number, message: string, isError?: boolean) => { + setToasts([...toasts, { id, message, isError }]); + }; + + const deleteToast = (id: number) => { + setToasts((prevToasts) => prevToasts.filter((toast) => toast.id !== id)); + }; + + const toast = { + success: (message: string) => showToast(Number(Date.now()), message), + error: (message: string) => showToast(Number(Date.now()), message, true), + }; + + const toastValue = { + toasts, + }; + + const toastAction = { + toast, + deleteToast, + }; + + return ( + + + {children} + {createPortal( + + {toasts.map(({ id, message, isError }) => ( + + ))} + , + document.getElementById('toast-container') as HTMLElement + )} + + + ); +}; + +export default ToastProvider; + +const ToastContainer = styled.div` + position: fixed; + z-index: 1000; + display: flex; + flex-direction: column; + align-items: center; + width: calc(100% - 20px); + transform: translate(0, -10px); +`; diff --git a/frontend/src/hooks/common/index.ts b/frontend/src/hooks/common/index.ts index 2cbf28c1f..61c985f67 100644 --- a/frontend/src/hooks/common/index.ts +++ b/frontend/src/hooks/common/index.ts @@ -10,4 +10,6 @@ export { default as useTimeout } from './useTimeout'; export { default as useRouteChangeTracker } from './useRouteChangeTracker'; export { default as useTabMenu } from './useTabMenu'; export { default as useScrollRestoration } from './useScrollRestoration'; +export { default as useToast } from './useToast'; export { default as useGA } from './useGA'; + diff --git a/frontend/src/hooks/common/useToast.ts b/frontend/src/hooks/common/useToast.ts new file mode 100644 index 000000000..f95f33ef9 --- /dev/null +++ b/frontend/src/hooks/common/useToast.ts @@ -0,0 +1,37 @@ +import { useEffect, useRef, useState } from 'react'; + +import { useToastActionContext } from '../context'; + +const useToast = (id: number) => { + const { deleteToast } = useToastActionContext(); + const [isShown, setIsShown] = useState(true); + + const showTimeoutRef = useRef(null); + const deleteTimeoutRef = useRef(null); + + useEffect(() => { + showTimeoutRef.current = window.setTimeout(() => setIsShown(false), 2000); + + return () => { + if (showTimeoutRef.current) { + clearTimeout(showTimeoutRef.current); + } + }; + }, []); + + useEffect(() => { + if (!isShown) { + deleteTimeoutRef.current = window.setTimeout(() => deleteToast(id), 2000); + } + + return () => { + if (deleteTimeoutRef.current) { + clearTimeout(deleteTimeoutRef.current); + } + }; + }, [isShown]); + + return isShown; +}; + +export default useToast; diff --git a/frontend/src/hooks/context/index.ts b/frontend/src/hooks/context/index.ts index 56470cfbb..dd03253c9 100644 --- a/frontend/src/hooks/context/index.ts +++ b/frontend/src/hooks/context/index.ts @@ -4,3 +4,5 @@ export { default as useReviewFormActionContext } from './useReviewFormActionCont export { default as useReviewFormValueContext } from './useReviewFormValueContext'; export { default as useRecipeFormActionContext } from './useRecipeFormActionContext'; export { default as useRecipeFormValueContext } from './useRecipeFormValueContext'; +export { default as useToastActionContext } from './useToastActionContext'; +export { default as useToastValueContext } from './useToastValueContext'; diff --git a/frontend/src/hooks/context/useToastActionContext.ts b/frontend/src/hooks/context/useToastActionContext.ts new file mode 100644 index 000000000..e0d7e31a2 --- /dev/null +++ b/frontend/src/hooks/context/useToastActionContext.ts @@ -0,0 +1,14 @@ +import { useContext } from 'react'; + +import { ToastActionContext } from '@/contexts/ToastContext'; + +const useToastActionContext = () => { + const toastAction = useContext(ToastActionContext); + if (toastAction === null || toastAction === undefined) { + throw new Error('useToastActionContext는 Toast Provider 안에서 사용해야 합니다.'); + } + + return toastAction; +}; + +export default useToastActionContext; diff --git a/frontend/src/hooks/context/useToastValueContext.ts b/frontend/src/hooks/context/useToastValueContext.ts new file mode 100644 index 000000000..ca4b65ca3 --- /dev/null +++ b/frontend/src/hooks/context/useToastValueContext.ts @@ -0,0 +1,14 @@ +import { useContext } from 'react'; + +import { ToastValueContext } from '@/contexts/ToastContext'; + +const useToastValueContext = () => { + const toastValue = useContext(ToastValueContext); + if (toastValue === null || toastValue === undefined) { + throw new Error('useToastValueContext는 Toast Provider 안에서 사용해야 합니다.'); + } + + return toastValue; +}; + +export default useToastValueContext; diff --git a/frontend/src/index.tsx b/frontend/src/index.tsx index 113f22f69..7cb7bc771 100644 --- a/frontend/src/index.tsx +++ b/frontend/src/index.tsx @@ -8,8 +8,9 @@ import { RouterProvider } from 'react-router-dom'; import { SvgSprite } from './components/Common'; import { ENVIRONMENT } from './constants'; +import ToastProvider from './contexts/ToastContext'; import router from './router'; -import GlobalStyle from './styles'; +import GlobalStyle from './styles/globalStyle'; const initializeReactGA = () => { if (process.env.NODE_ENV === 'development') return; @@ -42,7 +43,9 @@ root.render( - ...loading

} /> + + ...loading

} /> +
diff --git a/frontend/src/styles/animations.ts b/frontend/src/styles/animations.ts new file mode 100644 index 000000000..aff72c3d4 --- /dev/null +++ b/frontend/src/styles/animations.ts @@ -0,0 +1,23 @@ +import { keyframes } from 'styled-components'; + +export const slideIn = keyframes` + 0% { + transform: translateY(-100px); + } + + 100% { + transform: translateY(70px); + } +`; + +export const fadeOut = keyframes` + 0% { + transform: translateY(70px); + opacity: 1; + } + + 100% { + transform: translateY(70px); + opacity:0; + } +`; diff --git a/frontend/src/styles/index.ts b/frontend/src/styles/globalStyle.ts similarity index 100% rename from frontend/src/styles/index.ts rename to frontend/src/styles/globalStyle.ts diff --git a/frontend/yarn.lock b/frontend/yarn.lock index c78fae96a..60977eeee 100644 --- a/frontend/yarn.lock +++ b/frontend/yarn.lock @@ -28,13 +28,13 @@ default-browser-id "3.0.0" "@babel/cli@^7.21.0": - version "7.22.15" - resolved "https://registry.yarnpkg.com/@babel/cli/-/cli-7.22.15.tgz#22ed82d76745a43caa60a89917bedb7c9b5bd145" - integrity sha512-prtg5f6zCERIaECeTZzd2fMtVjlfjhUcO+fBLQ6DXXdq5FljN+excVitJ2nogsusdf31LeqkjAfXZ7Xq+HmN8g== + version "7.23.0" + resolved "https://registry.yarnpkg.com/@babel/cli/-/cli-7.23.0.tgz#1d7f37c44d4117c67df46749e0c86e11a58cc64b" + integrity sha512-17E1oSkGk2IwNILM4jtfAvgjt+ohmpfBky8aLerUfYZhiPNg7ca+CRCxZn8QDxwNhV/upsc2VHBCqGFIR+iBfA== dependencies: "@jridgewell/trace-mapping" "^0.3.17" commander "^4.0.1" - convert-source-map "^1.1.0" + convert-source-map "^2.0.0" fs-readdir-recursive "^1.1.0" glob "^7.2.0" make-dir "^2.1.0" @@ -51,38 +51,38 @@ "@babel/highlight" "^7.22.13" chalk "^2.4.2" -"@babel/compat-data@^7.20.5", "@babel/compat-data@^7.22.6", "@babel/compat-data@^7.22.9": - version "7.22.9" - resolved "https://registry.yarnpkg.com/@babel/compat-data/-/compat-data-7.22.9.tgz#71cdb00a1ce3a329ce4cbec3a44f9fef35669730" - integrity sha512-5UamI7xkUcJ3i9qVDS+KFDEK8/7oJ55/sJMB1Ge7IEapr7KfdfV/HErR+koZwOfd+SgtFKOKRhRakdg++DcJpQ== +"@babel/compat-data@^7.20.5", "@babel/compat-data@^7.22.20", "@babel/compat-data@^7.22.6", "@babel/compat-data@^7.22.9": + version "7.22.20" + resolved "https://registry.yarnpkg.com/@babel/compat-data/-/compat-data-7.22.20.tgz#8df6e96661209623f1975d66c35ffca66f3306d0" + integrity sha512-BQYjKbpXjoXwFW5jGqiizJQQT/aC7pFm9Ok1OWssonuguICi264lbgMzRp2ZMmRSlfkX6DsWDDcsrctK8Rwfiw== -"@babel/core@^7.11.6", "@babel/core@^7.12.3", "@babel/core@^7.13.16", "@babel/core@^7.21.0", "@babel/core@^7.21.3", "@babel/core@^7.22.0", "@babel/core@^7.22.9", "@babel/core@^7.7.5": - version "7.22.17" - resolved "https://registry.yarnpkg.com/@babel/core/-/core-7.22.17.tgz#2f9b0b395985967203514b24ee50f9fd0639c866" - integrity sha512-2EENLmhpwplDux5PSsZnSbnSkB3tZ6QTksgO25xwEL7pIDcNOMhF5v/s6RzwjMZzZzw9Ofc30gHv5ChCC8pifQ== +"@babel/core@^7.11.6", "@babel/core@^7.12.3", "@babel/core@^7.13.16", "@babel/core@^7.21.0", "@babel/core@^7.21.3", "@babel/core@^7.22.9", "@babel/core@^7.7.5": + version "7.23.0" + resolved "https://registry.yarnpkg.com/@babel/core/-/core-7.23.0.tgz#f8259ae0e52a123eb40f552551e647b506a94d83" + integrity sha512-97z/ju/Jy1rZmDxybphrBuI+jtJjFVoz7Mr9yUQVVVi+DNZE333uFQeMOqcCIy1x3WYBIbWftUSLmbNXNT7qFQ== dependencies: "@ampproject/remapping" "^2.2.0" "@babel/code-frame" "^7.22.13" - "@babel/generator" "^7.22.15" + "@babel/generator" "^7.23.0" "@babel/helper-compilation-targets" "^7.22.15" - "@babel/helper-module-transforms" "^7.22.17" - "@babel/helpers" "^7.22.15" - "@babel/parser" "^7.22.16" + "@babel/helper-module-transforms" "^7.23.0" + "@babel/helpers" "^7.23.0" + "@babel/parser" "^7.23.0" "@babel/template" "^7.22.15" - "@babel/traverse" "^7.22.17" - "@babel/types" "^7.22.17" - convert-source-map "^1.7.0" + "@babel/traverse" "^7.23.0" + "@babel/types" "^7.23.0" + convert-source-map "^2.0.0" debug "^4.1.0" gensync "^1.0.0-beta.2" json5 "^2.2.3" semver "^6.3.1" -"@babel/generator@^7.12.11", "@babel/generator@^7.22.15", "@babel/generator@^7.22.9", "@babel/generator@^7.7.2": - version "7.22.15" - resolved "https://registry.yarnpkg.com/@babel/generator/-/generator-7.22.15.tgz#1564189c7ec94cb8f77b5e8a90c4d200d21b2339" - integrity sha512-Zu9oWARBqeVOW0dZOjXc3JObrzuqothQ3y/n1kUtrjCoCPLkXUwMvOo/F/TCfoHMbWIFlWwpZtkZVb9ga4U2pA== +"@babel/generator@^7.12.11", "@babel/generator@^7.22.9", "@babel/generator@^7.23.0", "@babel/generator@^7.7.2": + version "7.23.0" + resolved "https://registry.yarnpkg.com/@babel/generator/-/generator-7.23.0.tgz#df5c386e2218be505b34837acbcb874d7a983420" + integrity sha512-lN85QRR+5IbYrMWM6Y4pE/noaQtg4pNiqeNGX60eqOfo6gtEj6uw/JagelB8vVztSd7R6M5n1+PQkDbHbBRU4g== dependencies: - "@babel/types" "^7.22.15" + "@babel/types" "^7.23.0" "@jridgewell/gen-mapping" "^0.3.2" "@jridgewell/trace-mapping" "^0.3.17" jsesc "^2.5.1" @@ -147,18 +147,18 @@ lodash.debounce "^4.0.8" resolve "^1.14.2" -"@babel/helper-environment-visitor@^7.22.5": - version "7.22.5" - resolved "https://registry.yarnpkg.com/@babel/helper-environment-visitor/-/helper-environment-visitor-7.22.5.tgz#f06dd41b7c1f44e1f8da6c4055b41ab3a09a7e98" - integrity sha512-XGmhECfVA/5sAt+H+xpSg0mfrHq6FzNr9Oxh7PSEBBRUb/mL7Kz3NICXb194rCqAEdxkhPT1a88teizAFyvk8Q== +"@babel/helper-environment-visitor@^7.22.20", "@babel/helper-environment-visitor@^7.22.5": + version "7.22.20" + resolved "https://registry.yarnpkg.com/@babel/helper-environment-visitor/-/helper-environment-visitor-7.22.20.tgz#96159db61d34a29dba454c959f5ae4a649ba9167" + integrity sha512-zfedSIzFhat/gFhWfHtgWvlec0nqB9YEIVrpuwjruLlXfUSnA8cJB0miHKwqDnQ7d32aKo2xt88/xZptwxbfhA== -"@babel/helper-function-name@^7.22.5": - version "7.22.5" - resolved "https://registry.yarnpkg.com/@babel/helper-function-name/-/helper-function-name-7.22.5.tgz#ede300828905bb15e582c037162f99d5183af1be" - integrity sha512-wtHSq6jMRE3uF2otvfuD3DIvVhOsSNshQl0Qrd7qC9oQJzHvOL4qQXlQn2916+CXGywIjpGuIkoyZRRxHPiNQQ== +"@babel/helper-function-name@^7.22.5", "@babel/helper-function-name@^7.23.0": + version "7.23.0" + resolved "https://registry.yarnpkg.com/@babel/helper-function-name/-/helper-function-name-7.23.0.tgz#1f9a3cdbd5b2698a670c30d2735f9af95ed52759" + integrity sha512-OErEqsrxjZTJciZ4Oo+eoZqeW9UIiOcuYKRJA4ZAgV9myA+pOXhhmpfNCKjEH/auVfEYVFJ6y1Tc4r0eIApqiw== dependencies: - "@babel/template" "^7.22.5" - "@babel/types" "^7.22.5" + "@babel/template" "^7.22.15" + "@babel/types" "^7.23.0" "@babel/helper-hoist-variables@^7.22.5": version "7.22.5" @@ -167,12 +167,12 @@ dependencies: "@babel/types" "^7.22.5" -"@babel/helper-member-expression-to-functions@^7.22.15", "@babel/helper-member-expression-to-functions@^7.22.5": - version "7.22.15" - resolved "https://registry.yarnpkg.com/@babel/helper-member-expression-to-functions/-/helper-member-expression-to-functions-7.22.15.tgz#b95a144896f6d491ca7863576f820f3628818621" - integrity sha512-qLNsZbgrNh0fDQBCPocSL8guki1hcPvltGDv/NxvUoABwFq7GkKSu1nRXeJkVZc+wJvne2E0RKQz+2SQrz6eAA== +"@babel/helper-member-expression-to-functions@^7.22.15": + version "7.23.0" + resolved "https://registry.yarnpkg.com/@babel/helper-member-expression-to-functions/-/helper-member-expression-to-functions-7.23.0.tgz#9263e88cc5e41d39ec18c9a3e0eced59a3e7d366" + integrity sha512-6gfrPwh7OuT6gZyJZvd6WbTfrqAo7vm4xCzAXOusKqq/vWdKXphTpj5klHKNmRUU6/QRGlBsyU9mAIPaWHlqJA== dependencies: - "@babel/types" "^7.22.15" + "@babel/types" "^7.23.0" "@babel/helper-module-imports@^7.18.6", "@babel/helper-module-imports@^7.22.15", "@babel/helper-module-imports@^7.22.5": version "7.22.15" @@ -181,16 +181,16 @@ dependencies: "@babel/types" "^7.22.15" -"@babel/helper-module-transforms@^7.22.15", "@babel/helper-module-transforms@^7.22.17", "@babel/helper-module-transforms@^7.22.5", "@babel/helper-module-transforms@^7.22.9": - version "7.22.17" - resolved "https://registry.yarnpkg.com/@babel/helper-module-transforms/-/helper-module-transforms-7.22.17.tgz#7edf129097a51ccc12443adbc6320e90eab76693" - integrity sha512-XouDDhQESrLHTpnBtCKExJdyY4gJCdrvH2Pyv8r8kovX2U8G0dRUOT45T9XlbLtuu9CLXP15eusnkprhoPV5iQ== +"@babel/helper-module-transforms@^7.22.5", "@babel/helper-module-transforms@^7.23.0": + version "7.23.0" + resolved "https://registry.yarnpkg.com/@babel/helper-module-transforms/-/helper-module-transforms-7.23.0.tgz#3ec246457f6c842c0aee62a01f60739906f7047e" + integrity sha512-WhDWw1tdrlT0gMgUJSlX0IQvoO1eN279zrAUbVB+KpV2c3Tylz8+GnKOLllCS6Z/iZQEyVYxhZVUdPTqs2YYPw== dependencies: - "@babel/helper-environment-visitor" "^7.22.5" + "@babel/helper-environment-visitor" "^7.22.20" "@babel/helper-module-imports" "^7.22.15" "@babel/helper-simple-access" "^7.22.5" "@babel/helper-split-export-declaration" "^7.22.6" - "@babel/helper-validator-identifier" "^7.22.15" + "@babel/helper-validator-identifier" "^7.22.20" "@babel/helper-optimise-call-expression@^7.22.5": version "7.22.5" @@ -205,21 +205,21 @@ integrity sha512-uLls06UVKgFG9QD4OeFYLEGteMIAa5kpTPcFL28yuCIIzsf6ZyKZMllKVOCZFhiZ5ptnwX4mtKdWCBE/uT4amg== "@babel/helper-remap-async-to-generator@^7.22.5", "@babel/helper-remap-async-to-generator@^7.22.9": - version "7.22.17" - resolved "https://registry.yarnpkg.com/@babel/helper-remap-async-to-generator/-/helper-remap-async-to-generator-7.22.17.tgz#dabaa50622b3b4670bd6546fc8db23eb12d89da0" - integrity sha512-bxH77R5gjH3Nkde6/LuncQoLaP16THYPscurp1S8z7S9ZgezCyV3G8Hc+TZiCmY8pz4fp8CvKSgtJMW0FkLAxA== + version "7.22.20" + resolved "https://registry.yarnpkg.com/@babel/helper-remap-async-to-generator/-/helper-remap-async-to-generator-7.22.20.tgz#7b68e1cb4fa964d2996fd063723fb48eca8498e0" + integrity sha512-pBGyV4uBqOns+0UvhsTO8qgl8hO89PmiDYv+/COyp1aeMcmfrfruz+/nCMFiYyFF/Knn0yfrC85ZzNFjembFTw== dependencies: "@babel/helper-annotate-as-pure" "^7.22.5" - "@babel/helper-environment-visitor" "^7.22.5" - "@babel/helper-wrap-function" "^7.22.17" + "@babel/helper-environment-visitor" "^7.22.20" + "@babel/helper-wrap-function" "^7.22.20" "@babel/helper-replace-supers@^7.22.5", "@babel/helper-replace-supers@^7.22.9": - version "7.22.9" - resolved "https://registry.yarnpkg.com/@babel/helper-replace-supers/-/helper-replace-supers-7.22.9.tgz#cbdc27d6d8d18cd22c81ae4293765a5d9afd0779" - integrity sha512-LJIKvvpgPOPUThdYqcX6IXRuIcTkcAub0IaDRGCZH0p5GPUp7PhRU9QVgFcDDd51BaPkk77ZjqFwh6DZTAEmGg== + version "7.22.20" + resolved "https://registry.yarnpkg.com/@babel/helper-replace-supers/-/helper-replace-supers-7.22.20.tgz#e37d367123ca98fe455a9887734ed2e16eb7a793" + integrity sha512-qsW0In3dbwQUbK8kejJ4R7IHVGwHJlV6lpG6UA7a9hSa2YEiAib+N1T2kr6PEeUT+Fl7najmSOS6SmAwCHK6Tw== dependencies: - "@babel/helper-environment-visitor" "^7.22.5" - "@babel/helper-member-expression-to-functions" "^7.22.5" + "@babel/helper-environment-visitor" "^7.22.20" + "@babel/helper-member-expression-to-functions" "^7.22.15" "@babel/helper-optimise-call-expression" "^7.22.5" "@babel/helper-simple-access@^7.22.5": @@ -248,47 +248,47 @@ resolved "https://registry.yarnpkg.com/@babel/helper-string-parser/-/helper-string-parser-7.22.5.tgz#533f36457a25814cf1df6488523ad547d784a99f" integrity sha512-mM4COjgZox8U+JcXQwPijIZLElkgEpO5rsERVDJTc2qfCDfERyob6k5WegS14SX18IIjv+XD+GrqNumY5JRCDw== -"@babel/helper-validator-identifier@^7.22.15", "@babel/helper-validator-identifier@^7.22.5": - version "7.22.15" - resolved "https://registry.yarnpkg.com/@babel/helper-validator-identifier/-/helper-validator-identifier-7.22.15.tgz#601fa28e4cc06786c18912dca138cec73b882044" - integrity sha512-4E/F9IIEi8WR94324mbDUMo074YTheJmd7eZF5vITTeYchqAi6sYXRLHUVsmkdmY4QjfKTcB2jB7dVP3NaBElQ== +"@babel/helper-validator-identifier@^7.22.20": + version "7.22.20" + resolved "https://registry.yarnpkg.com/@babel/helper-validator-identifier/-/helper-validator-identifier-7.22.20.tgz#c4ae002c61d2879e724581d96665583dbc1dc0e0" + integrity sha512-Y4OZ+ytlatR8AI+8KZfKuL5urKp7qey08ha31L8b3BwewJAoJamTzyvxPR/5D+KkdJCGPq/+8TukHBlY10FX9A== "@babel/helper-validator-option@^7.22.15": version "7.22.15" resolved "https://registry.yarnpkg.com/@babel/helper-validator-option/-/helper-validator-option-7.22.15.tgz#694c30dfa1d09a6534cdfcafbe56789d36aba040" integrity sha512-bMn7RmyFjY/mdECUbgn9eoSY4vqvacUnS9i9vGAGttgFWesO6B4CYWA7XlpbWgBt71iv/hfbPlynohStqnu5hA== -"@babel/helper-wrap-function@^7.22.17": - version "7.22.17" - resolved "https://registry.yarnpkg.com/@babel/helper-wrap-function/-/helper-wrap-function-7.22.17.tgz#222ac3ff9cc8f9b617cc1e5db75c0b538e722801" - integrity sha512-nAhoheCMlrqU41tAojw9GpVEKDlTS8r3lzFmF0lP52LwblCPbuFSO7nGIZoIcoU5NIm1ABrna0cJExE4Ay6l2Q== +"@babel/helper-wrap-function@^7.22.20": + version "7.22.20" + resolved "https://registry.yarnpkg.com/@babel/helper-wrap-function/-/helper-wrap-function-7.22.20.tgz#15352b0b9bfb10fc9c76f79f6342c00e3411a569" + integrity sha512-pms/UwkOpnQe/PDAEdV/d7dVCoBbB+R4FvYoHGZz+4VPcg7RtYy2KP7S2lbuWM6FCSgob5wshfGESbC/hzNXZw== dependencies: "@babel/helper-function-name" "^7.22.5" "@babel/template" "^7.22.15" - "@babel/types" "^7.22.17" + "@babel/types" "^7.22.19" -"@babel/helpers@^7.22.15": - version "7.22.15" - resolved "https://registry.yarnpkg.com/@babel/helpers/-/helpers-7.22.15.tgz#f09c3df31e86e3ea0b7ff7556d85cdebd47ea6f1" - integrity sha512-7pAjK0aSdxOwR+CcYAqgWOGy5dcfvzsTIfFTb2odQqW47MDfv14UaJDY6eng8ylM2EaeKXdxaSWESbkmaQHTmw== +"@babel/helpers@^7.23.0": + version "7.23.1" + resolved "https://registry.yarnpkg.com/@babel/helpers/-/helpers-7.23.1.tgz#44e981e8ce2b9e99f8f0b703f3326a4636c16d15" + integrity sha512-chNpneuK18yW5Oxsr+t553UZzzAs3aZnFm4bxhebsNTeshrC95yA7l5yl7GBAG+JG1rF0F7zzD2EixK9mWSDoA== dependencies: "@babel/template" "^7.22.15" - "@babel/traverse" "^7.22.15" - "@babel/types" "^7.22.15" + "@babel/traverse" "^7.23.0" + "@babel/types" "^7.23.0" "@babel/highlight@^7.22.13": - version "7.22.13" - resolved "https://registry.yarnpkg.com/@babel/highlight/-/highlight-7.22.13.tgz#9cda839e5d3be9ca9e8c26b6dd69e7548f0cbf16" - integrity sha512-C/BaXcnnvBCmHTpz/VGZ8jgtE2aYlW4hxDhseJAWZb7gqGM/qtCK6iZUb0TyKFf7BOUsBH7Q7fkRsDRhg1XklQ== + version "7.22.20" + resolved "https://registry.yarnpkg.com/@babel/highlight/-/highlight-7.22.20.tgz#4ca92b71d80554b01427815e06f2df965b9c1f54" + integrity sha512-dkdMCN3py0+ksCgYmGG8jKeGA/8Tk+gJwSYYlFGxG5lmhfKNoAy004YpLxpS1W2J8m/EK2Ew+yOs9pVRwO89mg== dependencies: - "@babel/helper-validator-identifier" "^7.22.5" + "@babel/helper-validator-identifier" "^7.22.20" chalk "^2.4.2" js-tokens "^4.0.0" -"@babel/parser@^7.1.0", "@babel/parser@^7.13.16", "@babel/parser@^7.14.7", "@babel/parser@^7.20.7", "@babel/parser@^7.22.15", "@babel/parser@^7.22.16", "@babel/parser@^7.22.7": - version "7.22.16" - resolved "https://registry.yarnpkg.com/@babel/parser/-/parser-7.22.16.tgz#180aead7f247305cce6551bea2720934e2fa2c95" - integrity sha512-+gPfKv8UWeKKeJTUxe59+OobVcrYHETCsORl61EmSkmgymguYk/X5bp7GuUIXaFsc6y++v8ZxPsLSSuujqDphA== +"@babel/parser@^7.1.0", "@babel/parser@^7.13.16", "@babel/parser@^7.14.7", "@babel/parser@^7.20.7", "@babel/parser@^7.22.15", "@babel/parser@^7.22.7", "@babel/parser@^7.23.0": + version "7.23.0" + resolved "https://registry.yarnpkg.com/@babel/parser/-/parser-7.23.0.tgz#da950e622420bf96ca0d0f2909cdddac3acd8719" + integrity sha512-vvPKKdMemU85V9WE/l5wZEmImpCtLqbnTvqDS2U1fJ96KrxoW7KrXhNsNCblQlg8Ck4b85yxdTyelsMUgFUXiw== "@babel/plugin-bugfix-safari-id-destructuring-collision-in-function-expression@^7.22.15": version "7.22.15" @@ -543,9 +543,9 @@ "@babel/helper-plugin-utils" "^7.22.5" "@babel/plugin-transform-block-scoping@^7.22.15": - version "7.22.15" - resolved "https://registry.yarnpkg.com/@babel/plugin-transform-block-scoping/-/plugin-transform-block-scoping-7.22.15.tgz#494eb82b87b5f8b1d8f6f28ea74078ec0a10a841" - integrity sha512-G1czpdJBZCtngoK1sJgloLiOHUnkb/bLZwqVZD8kXmq0ZnVfTTWUcs9OWtp0mBtYJ+4LQY1fllqBkOIPhXmFmw== + version "7.23.0" + resolved "https://registry.yarnpkg.com/@babel/plugin-transform-block-scoping/-/plugin-transform-block-scoping-7.23.0.tgz#8744d02c6c264d82e1a4bc5d2d501fd8aff6f022" + integrity sha512-cOsrbmIOXmf+5YbL99/S49Y3j46k/T16b9ml8bm9lP6N9US5iQ2yBK7gpui1pg0V/WMcXdkfKbTb7HXq9u+v4g== dependencies: "@babel/helper-plugin-utils" "^7.22.5" @@ -590,9 +590,9 @@ "@babel/template" "^7.22.5" "@babel/plugin-transform-destructuring@^7.22.15": - version "7.22.15" - resolved "https://registry.yarnpkg.com/@babel/plugin-transform-destructuring/-/plugin-transform-destructuring-7.22.15.tgz#e7404ea5bb3387073b9754be654eecb578324694" - integrity sha512-HzG8sFl1ZVGTme74Nw+X01XsUTqERVQ6/RLHo3XjGRzm7XD6QTtfS3NJotVgCGy8BzkDqRjRBD8dAyJn5TuvSQ== + version "7.23.0" + resolved "https://registry.yarnpkg.com/@babel/plugin-transform-destructuring/-/plugin-transform-destructuring-7.23.0.tgz#6447aa686be48b32eaf65a73e0e2c0bd010a266c" + integrity sha512-vaMdgNXFkYrB+8lbgniSYWHsgqK5gjaMNcc84bMIOMRLH0L9AqYq3hwMdvnyqj1OPqea8UtjPEuS/DCenah1wg== dependencies: "@babel/helper-plugin-utils" "^7.22.5" @@ -690,31 +690,31 @@ "@babel/helper-plugin-utils" "^7.22.5" "@babel/plugin-transform-modules-amd@^7.22.5": - version "7.22.5" - resolved "https://registry.yarnpkg.com/@babel/plugin-transform-modules-amd/-/plugin-transform-modules-amd-7.22.5.tgz#4e045f55dcf98afd00f85691a68fc0780704f526" - integrity sha512-R+PTfLTcYEmb1+kK7FNkhQ1gP4KgjpSO6HfH9+f8/yfp2Nt3ggBjiVpRwmwTlfqZLafYKJACy36yDXlEmI9HjQ== + version "7.23.0" + resolved "https://registry.yarnpkg.com/@babel/plugin-transform-modules-amd/-/plugin-transform-modules-amd-7.23.0.tgz#05b2bc43373faa6d30ca89214731f76f966f3b88" + integrity sha512-xWT5gefv2HGSm4QHtgc1sYPbseOyf+FFDo2JbpE25GWl5BqTGO9IMwTYJRoIdjsF85GE+VegHxSCUt5EvoYTAw== dependencies: - "@babel/helper-module-transforms" "^7.22.5" + "@babel/helper-module-transforms" "^7.23.0" "@babel/helper-plugin-utils" "^7.22.5" -"@babel/plugin-transform-modules-commonjs@^7.13.8", "@babel/plugin-transform-modules-commonjs@^7.22.15": - version "7.22.15" - resolved "https://registry.yarnpkg.com/@babel/plugin-transform-modules-commonjs/-/plugin-transform-modules-commonjs-7.22.15.tgz#b11810117ed4ee7691b29bd29fd9f3f98276034f" - integrity sha512-jWL4eh90w0HQOTKP2MoXXUpVxilxsB2Vl4ji69rSjS3EcZ/v4sBmn+A3NpepuJzBhOaEBbR7udonlHHn5DWidg== +"@babel/plugin-transform-modules-commonjs@^7.13.8", "@babel/plugin-transform-modules-commonjs@^7.22.15", "@babel/plugin-transform-modules-commonjs@^7.23.0": + version "7.23.0" + resolved "https://registry.yarnpkg.com/@babel/plugin-transform-modules-commonjs/-/plugin-transform-modules-commonjs-7.23.0.tgz#b3dba4757133b2762c00f4f94590cf6d52602481" + integrity sha512-32Xzss14/UVc7k9g775yMIvkVK8xwKE0DPdP5JTapr3+Z9w4tzeOuLNY6BXDQR6BdnzIlXnCGAzsk/ICHBLVWQ== dependencies: - "@babel/helper-module-transforms" "^7.22.15" + "@babel/helper-module-transforms" "^7.23.0" "@babel/helper-plugin-utils" "^7.22.5" "@babel/helper-simple-access" "^7.22.5" "@babel/plugin-transform-modules-systemjs@^7.22.11": - version "7.22.11" - resolved "https://registry.yarnpkg.com/@babel/plugin-transform-modules-systemjs/-/plugin-transform-modules-systemjs-7.22.11.tgz#3386be5875d316493b517207e8f1931d93154bb1" - integrity sha512-rIqHmHoMEOhI3VkVf5jQ15l539KrwhzqcBO6wdCNWPWc/JWt9ILNYNUssbRpeq0qWns8svuw8LnMNCvWBIJ8wA== + version "7.23.0" + resolved "https://registry.yarnpkg.com/@babel/plugin-transform-modules-systemjs/-/plugin-transform-modules-systemjs-7.23.0.tgz#77591e126f3ff4132a40595a6cccd00a6b60d160" + integrity sha512-qBej6ctXZD2f+DhlOC9yO47yEYgUh5CZNz/aBoH4j/3NOlRfJXJbY7xDQCqQVf9KbrqGzIWER1f23doHGrIHFg== dependencies: "@babel/helper-hoist-variables" "^7.22.5" - "@babel/helper-module-transforms" "^7.22.9" + "@babel/helper-module-transforms" "^7.23.0" "@babel/helper-plugin-utils" "^7.22.5" - "@babel/helper-validator-identifier" "^7.22.5" + "@babel/helper-validator-identifier" "^7.22.20" "@babel/plugin-transform-modules-umd@^7.22.5": version "7.22.5" @@ -783,9 +783,9 @@ "@babel/plugin-syntax-optional-catch-binding" "^7.8.3" "@babel/plugin-transform-optional-chaining@^7.22.15": - version "7.22.15" - resolved "https://registry.yarnpkg.com/@babel/plugin-transform-optional-chaining/-/plugin-transform-optional-chaining-7.22.15.tgz#d7a5996c2f7ca4ad2ad16dbb74444e5c4385b1ba" - integrity sha512-ngQ2tBhq5vvSJw2Q2Z9i7ealNkpDMU0rGWnHPKqRZO0tzZ5tlaoz4hDvhXioOoaE0X2vfNss1djwg0DXlfu30A== + version "7.23.0" + resolved "https://registry.yarnpkg.com/@babel/plugin-transform-optional-chaining/-/plugin-transform-optional-chaining-7.23.0.tgz#73ff5fc1cf98f542f09f29c0631647d8ad0be158" + integrity sha512-sBBGXbLJjxTzLBF5rFWaikMnOGOk/BmK6vVByIdEggZ7Vn6CvWXZyRkkLFK6WE0IF8jSliyOkUN6SScFgzCM0g== dependencies: "@babel/helper-plugin-utils" "^7.22.5" "@babel/helper-skip-transparent-expression-wrappers" "^7.22.5" @@ -956,11 +956,11 @@ "@babel/helper-plugin-utils" "^7.22.5" "@babel/preset-env@^7.20.2", "@babel/preset-env@^7.22.9": - version "7.22.15" - resolved "https://registry.yarnpkg.com/@babel/preset-env/-/preset-env-7.22.15.tgz#142716f8e00bc030dae5b2ac6a46fbd8b3e18ff8" - integrity sha512-tZFHr54GBkHk6hQuVA8w4Fmq+MSPsfvMG0vPnOYyTnJpyfMqybL8/MbNCPRT9zc2KBO2pe4tq15g6Uno4Jpoag== + version "7.22.20" + resolved "https://registry.yarnpkg.com/@babel/preset-env/-/preset-env-7.22.20.tgz#de9e9b57e1127ce0a2f580831717f7fb677ceedb" + integrity sha512-11MY04gGC4kSzlPHRfvVkNAZhUxOvm7DCJ37hPDnUENwe06npjIRAfInEMTGSb4LZK5ZgDFkv5hw0lGebHeTyg== dependencies: - "@babel/compat-data" "^7.22.9" + "@babel/compat-data" "^7.22.20" "@babel/helper-compilation-targets" "^7.22.15" "@babel/helper-plugin-utils" "^7.22.5" "@babel/helper-validator-option" "^7.22.15" @@ -1034,7 +1034,7 @@ "@babel/plugin-transform-unicode-regex" "^7.22.5" "@babel/plugin-transform-unicode-sets-regex" "^7.22.5" "@babel/preset-modules" "0.1.6-no-external-plugins" - "@babel/types" "^7.22.15" + "@babel/types" "^7.22.19" babel-plugin-polyfill-corejs2 "^0.4.5" babel-plugin-polyfill-corejs3 "^0.8.3" babel-plugin-polyfill-regenerator "^0.5.2" @@ -1072,14 +1072,14 @@ "@babel/plugin-transform-react-pure-annotations" "^7.22.5" "@babel/preset-typescript@^7.13.0", "@babel/preset-typescript@^7.21.0", "@babel/preset-typescript@^7.22.5": - version "7.22.15" - resolved "https://registry.yarnpkg.com/@babel/preset-typescript/-/preset-typescript-7.22.15.tgz#43db30516fae1d417d748105a0bc95f637239d48" - integrity sha512-HblhNmh6yM+cU4VwbBRpxFhxsTdfS1zsvH9W+gEjD0ARV9+8B4sNfpI6GuhePti84nuvhiwKS539jKPFHskA9A== + version "7.23.0" + resolved "https://registry.yarnpkg.com/@babel/preset-typescript/-/preset-typescript-7.23.0.tgz#cc6602d13e7e5b2087c811912b87cf937a9129d9" + integrity sha512-6P6VVa/NM/VlAYj5s2Aq/gdVg8FSENCg3wlZ6Qau9AcPaoF5LbN1nyGlR9DTRIw9PpxI94e+ReydsJHcjwAweg== dependencies: "@babel/helper-plugin-utils" "^7.22.5" "@babel/helper-validator-option" "^7.22.15" "@babel/plugin-syntax-jsx" "^7.22.5" - "@babel/plugin-transform-modules-commonjs" "^7.22.15" + "@babel/plugin-transform-modules-commonjs" "^7.23.0" "@babel/plugin-transform-typescript" "^7.22.15" "@babel/register@^7.13.16": @@ -1099,9 +1099,9 @@ integrity sha512-x/rqGMdzj+fWZvCOYForTghzbtqPDZ5gPwaoNGHdgDfF2QA/XZbCBp4Moo5scrkAMPhB7z26XM/AaHuIJdgauA== "@babel/runtime@^7.12.5", "@babel/runtime@^7.13.10", "@babel/runtime@^7.17.8", "@babel/runtime@^7.7.6", "@babel/runtime@^7.8.4", "@babel/runtime@^7.9.2": - version "7.22.15" - resolved "https://registry.yarnpkg.com/@babel/runtime/-/runtime-7.22.15.tgz#38f46494ccf6cf020bd4eed7124b425e83e523b8" - integrity sha512-T0O+aa+4w0u06iNmapipJXMV4HoUir03hpx3/YqXXhu9xim3w+dVphjFWl1OH8NbZHw5Lbm9k45drDkgq2VNNA== + version "7.23.1" + resolved "https://registry.yarnpkg.com/@babel/runtime/-/runtime-7.23.1.tgz#72741dc4d413338a91dcb044a86f3c0bc402646d" + integrity sha512-hC2v6p8ZSI/W0HUzh3V8C5g+NwSKzKPtJwSpTjwl0o297GP9+ZLQSkdvHz46CM3LqyoXxq+5G9komY+eSqSO0g== dependencies: regenerator-runtime "^0.14.0" @@ -1114,29 +1114,29 @@ "@babel/parser" "^7.22.15" "@babel/types" "^7.22.15" -"@babel/traverse@^7.1.6", "@babel/traverse@^7.21.2", "@babel/traverse@^7.22.15", "@babel/traverse@^7.22.17", "@babel/traverse@^7.22.8": - version "7.22.17" - resolved "https://registry.yarnpkg.com/@babel/traverse/-/traverse-7.22.17.tgz#b23c203ab3707e3be816043081b4a994fcacec44" - integrity sha512-xK4Uwm0JnAMvxYZxOVecss85WxTEIbTa7bnGyf/+EgCL5Zt3U7htUpEOWv9detPlamGKuRzCqw74xVglDWpPdg== +"@babel/traverse@^7.1.6", "@babel/traverse@^7.21.2", "@babel/traverse@^7.22.8", "@babel/traverse@^7.23.0": + version "7.23.0" + resolved "https://registry.yarnpkg.com/@babel/traverse/-/traverse-7.23.0.tgz#18196ddfbcf4ccea324b7f6d3ada00d8c5a99c53" + integrity sha512-t/QaEvyIoIkwzpiZ7aoSKK8kObQYeF7T2v+dazAYCb8SXtp58zEVkWW7zAnju8FNKNdr4ScAOEDmMItbyOmEYw== dependencies: "@babel/code-frame" "^7.22.13" - "@babel/generator" "^7.22.15" - "@babel/helper-environment-visitor" "^7.22.5" - "@babel/helper-function-name" "^7.22.5" + "@babel/generator" "^7.23.0" + "@babel/helper-environment-visitor" "^7.22.20" + "@babel/helper-function-name" "^7.23.0" "@babel/helper-hoist-variables" "^7.22.5" "@babel/helper-split-export-declaration" "^7.22.6" - "@babel/parser" "^7.22.16" - "@babel/types" "^7.22.17" + "@babel/parser" "^7.23.0" + "@babel/types" "^7.23.0" debug "^4.1.0" globals "^11.1.0" -"@babel/types@^7.0.0", "@babel/types@^7.2.0", "@babel/types@^7.20.7", "@babel/types@^7.21.3", "@babel/types@^7.22.15", "@babel/types@^7.22.17", "@babel/types@^7.22.5", "@babel/types@^7.3.3", "@babel/types@^7.4.4": - version "7.22.17" - resolved "https://registry.yarnpkg.com/@babel/types/-/types-7.22.17.tgz#f753352c4610ffddf9c8bc6823f9ff03e2303eee" - integrity sha512-YSQPHLFtQNE5xN9tHuZnzu8vPr61wVTBZdfv1meex1NBosa4iT05k/Jw06ddJugi4bk7The/oSwQGFcksmEJQg== +"@babel/types@^7.0.0", "@babel/types@^7.2.0", "@babel/types@^7.20.7", "@babel/types@^7.21.3", "@babel/types@^7.22.15", "@babel/types@^7.22.19", "@babel/types@^7.22.5", "@babel/types@^7.23.0", "@babel/types@^7.3.3", "@babel/types@^7.4.4": + version "7.23.0" + resolved "https://registry.yarnpkg.com/@babel/types/-/types-7.23.0.tgz#8c1f020c9df0e737e4e247c0619f58c68458aaeb" + integrity sha512-0oIyUfKoI3mSqMvsxBdclDwxXKXAUA8v/apZbc+iSyARYou1o8ZGDxbUYyLFoW2arqS2jDGqJuZvv1d/io1axg== dependencies: "@babel/helper-string-parser" "^7.22.5" - "@babel/helper-validator-identifier" "^7.22.15" + "@babel/helper-validator-identifier" "^7.22.20" to-fast-properties "^2.0.0" "@base2/pretty-print-object@1.0.1": @@ -1155,19 +1155,19 @@ integrity sha512-ooWCrlZP11i8GImSjTHYHLkvFDP48nS4+204nGb1RiX/WXYHmJA2III9/e2DWVabCESdW7hBAEzHRqUn9OUVvQ== "@csstools/css-parser-algorithms@^2.3.1": - version "2.3.1" - resolved "https://registry.yarnpkg.com/@csstools/css-parser-algorithms/-/css-parser-algorithms-2.3.1.tgz#ec4fc764ba45d2bb7ee2774667e056aa95003f3a" - integrity sha512-xrvsmVUtefWMWQsGgFffqWSK03pZ1vfDki4IVIIUxxDKnGBzqNgv0A7SB1oXtVNEkcVO8xi1ZrTL29HhSu5kGA== + version "2.3.2" + resolved "https://registry.yarnpkg.com/@csstools/css-parser-algorithms/-/css-parser-algorithms-2.3.2.tgz#1e0d581dbf4518cb3e939c3b863cb7180c8cedad" + integrity sha512-sLYGdAdEY2x7TSw9FtmdaTrh2wFtRJO5VMbBrA8tEqEod7GEggFmxTSK9XqExib3yMuYNcvcTdCZIP6ukdjAIA== "@csstools/css-tokenizer@^2.2.0": - version "2.2.0" - resolved "https://registry.yarnpkg.com/@csstools/css-tokenizer/-/css-tokenizer-2.2.0.tgz#9d70e6dcbe94e44c7400a2929928db35c4de32b5" - integrity sha512-wErmsWCbsmig8sQKkM6pFhr/oPha1bHfvxsUY5CYSQxwyhA9Ulrs8EqCgClhg4Tgg2XapVstGqSVcz0xOYizZA== + version "2.2.1" + resolved "https://registry.yarnpkg.com/@csstools/css-tokenizer/-/css-tokenizer-2.2.1.tgz#9dc431c9a5f61087af626e41ac2a79cce7bb253d" + integrity sha512-Zmsf2f/CaEPWEVgw29odOj+WEVoiJy9s9NOv5GgNY9mZ1CZ7394By6wONrONrTsnNDv6F9hR02nvFihrGVGHBg== "@csstools/media-query-list-parser@^2.1.4": - version "2.1.4" - resolved "https://registry.yarnpkg.com/@csstools/media-query-list-parser/-/media-query-list-parser-2.1.4.tgz#0017f99945f6c16dd81a7aacf6821770933c3a5c" - integrity sha512-V/OUXYX91tAC1CDsiY+HotIcJR+vPtzrX8pCplCpT++i8ThZZsq5F5dzZh/bDM3WUOjrvC1ljed1oSJxMfjqhw== + version "2.1.5" + resolved "https://registry.yarnpkg.com/@csstools/media-query-list-parser/-/media-query-list-parser-2.1.5.tgz#94bc8b3c3fd7112a40b7bf0b483e91eba0654a0f" + integrity sha512-IxVBdYzR8pYe89JiyXQuYk4aVVoCPhMJkz6ElRwlVysjwURTsTk/bmY/z4FfeRE+CRBMlykPwXEVUg8lThv7AQ== "@csstools/selector-specificity@^3.0.0": version "3.0.0" @@ -1319,9 +1319,9 @@ eslint-visitor-keys "^3.3.0" "@eslint-community/regexpp@^4.4.0", "@eslint-community/regexpp@^4.6.1": - version "4.8.0" - resolved "https://registry.yarnpkg.com/@eslint-community/regexpp/-/regexpp-4.8.0.tgz#11195513186f68d42fbf449f9a7136b2c0c92005" - integrity sha512-JylOEEzDiOryeUnFbQz+oViCXS0KsvR1mvHkoMiu5+UiBvy+RYX7tzlIIIEstF/gVa2tj9AQXk3dgnxv6KxhFg== + version "4.9.1" + resolved "https://registry.yarnpkg.com/@eslint-community/regexpp/-/regexpp-4.9.1.tgz#449dfa81a57a1d755b09aa58d826c1262e4283b4" + integrity sha512-Y27x+MBLjXa+0JWDhykM3+JE+il3kHKAEqabfEWq3SDhZjLYb6/BHL/JKFnH3fe207JaXkyDo685Oc2Glt6ifA== "@eslint/eslintrc@^2.1.2": version "2.1.2" @@ -1338,30 +1338,30 @@ minimatch "^3.1.2" strip-json-comments "^3.1.1" -"@eslint/js@8.49.0": - version "8.49.0" - resolved "https://registry.yarnpkg.com/@eslint/js/-/js-8.49.0.tgz#86f79756004a97fa4df866835093f1df3d03c333" - integrity sha512-1S8uAY/MTJqVx0SC4epBq+N2yhuwtNwLbJYNZyhL2pO1ZVKn5HFXav5T41Ryzy9K9V7ZId2JB2oy/W4aCd9/2w== +"@eslint/js@8.50.0": + version "8.50.0" + resolved "https://registry.yarnpkg.com/@eslint/js/-/js-8.50.0.tgz#9e93b850f0f3fa35f5fa59adfd03adae8488e484" + integrity sha512-NCC3zz2+nvYd+Ckfh87rA47zfu2QsQpvc6k1yzTk+b9KzRj0wkGa8LSoGOXN6Zv4lRf/EIoZ80biDh9HOI+RNQ== "@fal-works/esbuild-plugin-global-externals@^2.1.2": version "2.1.2" resolved "https://registry.yarnpkg.com/@fal-works/esbuild-plugin-global-externals/-/esbuild-plugin-global-externals-2.1.2.tgz#c05ed35ad82df8e6ac616c68b92c2282bd083ba4" integrity sha512-cEee/Z+I12mZcFJshKcCqC8tuX5hG3s+d+9nZ3LabqKF1vKdF41B92pJVCBggjAGORAeOzyyDDKrZwIkLffeOQ== -"@floating-ui/core@^1.4.1": - version "1.4.1" - resolved "https://registry.yarnpkg.com/@floating-ui/core/-/core-1.4.1.tgz#0d633f4b76052668afb932492ac452f7ebe97f17" - integrity sha512-jk3WqquEJRlcyu7997NtR5PibI+y5bi+LS3hPmguVClypenMsCY3CBa3LAQnozRCtCrYWSEtAdiskpamuJRFOQ== +"@floating-ui/core@^1.4.2": + version "1.5.0" + resolved "https://registry.yarnpkg.com/@floating-ui/core/-/core-1.5.0.tgz#5c05c60d5ae2d05101c3021c1a2a350ddc027f8c" + integrity sha512-kK1h4m36DQ0UHGj5Ah4db7R0rHemTqqO0QLvUqi1/mUUp3LuAWbWxdxSIf/XsnH9VS6rRVPLJCncjRzUvyCLXg== dependencies: - "@floating-ui/utils" "^0.1.1" + "@floating-ui/utils" "^0.1.3" "@floating-ui/dom@^1.5.1": - version "1.5.2" - resolved "https://registry.yarnpkg.com/@floating-ui/dom/-/dom-1.5.2.tgz#6812e89d1d4d4ea32f10d15c3b81feb7f9836d89" - integrity sha512-6ArmenS6qJEWmwzczWyhvrXRdI/rI78poBcW0h/456+onlabit+2G+QxHx5xTOX60NBJQXjsCLFbW2CmsXpUog== + version "1.5.3" + resolved "https://registry.yarnpkg.com/@floating-ui/dom/-/dom-1.5.3.tgz#54e50efcb432c06c23cd33de2b575102005436fa" + integrity sha512-ClAbQnEqJAKCJOEbbLo5IUlZHkNszqhuxS4fHAVxRPXPya6Ysf2G8KypnYcOTpx6I8xcgF9bbHb6g/2KpbV8qA== dependencies: - "@floating-ui/core" "^1.4.1" - "@floating-ui/utils" "^0.1.1" + "@floating-ui/core" "^1.4.2" + "@floating-ui/utils" "^0.1.3" "@floating-ui/react-dom@^2.0.0": version "2.0.2" @@ -1370,10 +1370,10 @@ dependencies: "@floating-ui/dom" "^1.5.1" -"@floating-ui/utils@^0.1.1": - version "0.1.1" - resolved "https://registry.yarnpkg.com/@floating-ui/utils/-/utils-0.1.1.tgz#1a5b1959a528e374e8037c4396c3e825d6cf4a83" - integrity sha512-m0G6wlnhm/AX0H12IOWtK8gASEMffnX08RtKkCgTdHb9JpHKGloI7icFfLg9ZmQeavcvR0PKmzxClyuFPSjKWw== +"@floating-ui/utils@^0.1.3": + version "0.1.6" + resolved "https://registry.yarnpkg.com/@floating-ui/utils/-/utils-0.1.6.tgz#22958c042e10b67463997bd6ea7115fe28cbcaf9" + integrity sha512-OfX7E2oUDYxtBvsuS4e/jSn4Q9Qb6DzgeYtsAdkPZ47znpoNsMgZw0+tVijiv3uGNR6dgNlty6r9rzIzHjtd/A== "@fun-eat/design-system@^0.3.12": version "0.3.12" @@ -1415,27 +1415,27 @@ resolved "https://registry.yarnpkg.com/@istanbuljs/schema/-/schema-0.1.3.tgz#e45e384e4b8ec16bce2fd903af78450f6bf7ec98" integrity sha512-ZXRY4jNvVgSVQ8DL3LTcakaAtXwTVUxE81hslsyD2AtoXW/wVob10HkOJ1X/pAlcI7D+2YoZKg5do8G/w6RYgA== -"@jest/console@^29.6.4": - version "29.6.4" - resolved "https://registry.yarnpkg.com/@jest/console/-/console-29.6.4.tgz#a7e2d84516301f986bba0dd55af9d5fe37f46527" - integrity sha512-wNK6gC0Ha9QeEPSkeJedQuTQqxZYnDPuDcDhVuVatRvMkL4D0VTvFVZj+Yuh6caG2aOfzkUZ36KtCmLNtR02hw== +"@jest/console@^29.7.0": + version "29.7.0" + resolved "https://registry.yarnpkg.com/@jest/console/-/console-29.7.0.tgz#cd4822dbdb84529265c5a2bdb529a3c9cc950ffc" + integrity sha512-5Ni4CU7XHQi32IJ398EEP4RrB8eV09sXP2ROqD4bksHrnTree52PsxvX8tpL8LvTZ3pFzXyPbNQReSN41CAhOg== dependencies: "@jest/types" "^29.6.3" "@types/node" "*" chalk "^4.0.0" - jest-message-util "^29.6.3" - jest-util "^29.6.3" + jest-message-util "^29.7.0" + jest-util "^29.7.0" slash "^3.0.0" -"@jest/core@^29.6.4": - version "29.6.4" - resolved "https://registry.yarnpkg.com/@jest/core/-/core-29.6.4.tgz#265ebee05ec1ff3567757e7a327155c8d6bdb126" - integrity sha512-U/vq5ccNTSVgYH7mHnodHmCffGWHJnz/E1BEWlLuK5pM4FZmGfBn/nrJGLjUsSmyx3otCeqc1T31F4y08AMDLg== +"@jest/core@^29.7.0": + version "29.7.0" + resolved "https://registry.yarnpkg.com/@jest/core/-/core-29.7.0.tgz#b6cccc239f30ff36609658c5a5e2291757ce448f" + integrity sha512-n7aeXWKMnGtDA48y8TLWJPJmLmmZ642Ceo78cYWEpiD7FzDgmNDV/GCVRorPABdXLJZ/9wzzgZAlHjXjxDHGsg== dependencies: - "@jest/console" "^29.6.4" - "@jest/reporters" "^29.6.4" - "@jest/test-result" "^29.6.4" - "@jest/transform" "^29.6.4" + "@jest/console" "^29.7.0" + "@jest/reporters" "^29.7.0" + "@jest/test-result" "^29.7.0" + "@jest/transform" "^29.7.0" "@jest/types" "^29.6.3" "@types/node" "*" ansi-escapes "^4.2.1" @@ -1443,80 +1443,80 @@ ci-info "^3.2.0" exit "^0.1.2" graceful-fs "^4.2.9" - jest-changed-files "^29.6.3" - jest-config "^29.6.4" - jest-haste-map "^29.6.4" - jest-message-util "^29.6.3" + jest-changed-files "^29.7.0" + jest-config "^29.7.0" + jest-haste-map "^29.7.0" + jest-message-util "^29.7.0" jest-regex-util "^29.6.3" - jest-resolve "^29.6.4" - jest-resolve-dependencies "^29.6.4" - jest-runner "^29.6.4" - jest-runtime "^29.6.4" - jest-snapshot "^29.6.4" - jest-util "^29.6.3" - jest-validate "^29.6.3" - jest-watcher "^29.6.4" + jest-resolve "^29.7.0" + jest-resolve-dependencies "^29.7.0" + jest-runner "^29.7.0" + jest-runtime "^29.7.0" + jest-snapshot "^29.7.0" + jest-util "^29.7.0" + jest-validate "^29.7.0" + jest-watcher "^29.7.0" micromatch "^4.0.4" - pretty-format "^29.6.3" + pretty-format "^29.7.0" slash "^3.0.0" strip-ansi "^6.0.0" -"@jest/environment@^29.6.4": - version "29.6.4" - resolved "https://registry.yarnpkg.com/@jest/environment/-/environment-29.6.4.tgz#78ec2c9f8c8829a37616934ff4fea0c028c79f4f" - integrity sha512-sQ0SULEjA1XUTHmkBRl7A1dyITM9yb1yb3ZNKPX3KlTd6IG7mWUe3e2yfExtC2Zz1Q+mMckOLHmL/qLiuQJrBQ== +"@jest/environment@^29.7.0": + version "29.7.0" + resolved "https://registry.yarnpkg.com/@jest/environment/-/environment-29.7.0.tgz#24d61f54ff1f786f3cd4073b4b94416383baf2a7" + integrity sha512-aQIfHDq33ExsN4jP1NWGXhxgQ/wixs60gDiKO+XVMd8Mn0NWPWgc34ZQDTb2jKaUWQ7MuwoitXAsN2XVXNMpAw== dependencies: - "@jest/fake-timers" "^29.6.4" + "@jest/fake-timers" "^29.7.0" "@jest/types" "^29.6.3" "@types/node" "*" - jest-mock "^29.6.3" + jest-mock "^29.7.0" -"@jest/expect-utils@^29.6.4": - version "29.6.4" - resolved "https://registry.yarnpkg.com/@jest/expect-utils/-/expect-utils-29.6.4.tgz#17c7dfe6cec106441f218b0aff4b295f98346679" - integrity sha512-FEhkJhqtvBwgSpiTrocquJCdXPsyvNKcl/n7A3u7X4pVoF4bswm11c9d4AV+kfq2Gpv/mM8x7E7DsRvH+djkrg== +"@jest/expect-utils@^29.7.0": + version "29.7.0" + resolved "https://registry.yarnpkg.com/@jest/expect-utils/-/expect-utils-29.7.0.tgz#023efe5d26a8a70f21677d0a1afc0f0a44e3a1c6" + integrity sha512-GlsNBWiFQFCVi9QVSx7f5AgMeLxe9YCCs5PuP2O2LdjDAA8Jh9eX7lA1Jq/xdXw3Wb3hyvlFNfZIfcRetSzYcA== dependencies: jest-get-type "^29.6.3" -"@jest/expect@^29.6.4": - version "29.6.4" - resolved "https://registry.yarnpkg.com/@jest/expect/-/expect-29.6.4.tgz#1d6ae17dc68d906776198389427ab7ce6179dba6" - integrity sha512-Warhsa7d23+3X5bLbrbYvaehcgX5TLYhI03JKoedTiI8uJU4IhqYBWF7OSSgUyz4IgLpUYPkK0AehA5/fRclAA== +"@jest/expect@^29.7.0": + version "29.7.0" + resolved "https://registry.yarnpkg.com/@jest/expect/-/expect-29.7.0.tgz#76a3edb0cb753b70dfbfe23283510d3d45432bf2" + integrity sha512-8uMeAMycttpva3P1lBHB8VciS9V0XAr3GymPpipdyQXbBcuhkLQOSe8E/p92RyAdToS6ZD1tFkX+CkhoECE0dQ== dependencies: - expect "^29.6.4" - jest-snapshot "^29.6.4" + expect "^29.7.0" + jest-snapshot "^29.7.0" -"@jest/fake-timers@^29.6.4": - version "29.6.4" - resolved "https://registry.yarnpkg.com/@jest/fake-timers/-/fake-timers-29.6.4.tgz#45a27f093c43d5d989362a3e7a8c70c83188b4f6" - integrity sha512-6UkCwzoBK60edXIIWb0/KWkuj7R7Qq91vVInOe3De6DSpaEiqjKcJw4F7XUet24Wupahj9J6PlR09JqJ5ySDHw== +"@jest/fake-timers@^29.7.0": + version "29.7.0" + resolved "https://registry.yarnpkg.com/@jest/fake-timers/-/fake-timers-29.7.0.tgz#fd91bf1fffb16d7d0d24a426ab1a47a49881a565" + integrity sha512-q4DH1Ha4TTFPdxLsqDXK1d3+ioSL7yL5oCMJZgDYm6i+6CygW5E5xVr/D1HdsGxjt1ZWSfUAs9OxSB/BNelWrQ== dependencies: "@jest/types" "^29.6.3" "@sinonjs/fake-timers" "^10.0.2" "@types/node" "*" - jest-message-util "^29.6.3" - jest-mock "^29.6.3" - jest-util "^29.6.3" + jest-message-util "^29.7.0" + jest-mock "^29.7.0" + jest-util "^29.7.0" -"@jest/globals@^29.6.4": - version "29.6.4" - resolved "https://registry.yarnpkg.com/@jest/globals/-/globals-29.6.4.tgz#4f04f58731b062b44ef23036b79bdb31f40c7f63" - integrity sha512-wVIn5bdtjlChhXAzVXavcY/3PEjf4VqM174BM3eGL5kMxLiZD5CLnbmkEyA1Dwh9q8XjP6E8RwjBsY/iCWrWsA== +"@jest/globals@^29.7.0": + version "29.7.0" + resolved "https://registry.yarnpkg.com/@jest/globals/-/globals-29.7.0.tgz#8d9290f9ec47ff772607fa864ca1d5a2efae1d4d" + integrity sha512-mpiz3dutLbkW2MNFubUGUEVLkTGiqW6yLVTA+JbP6fI6J5iL9Y0Nlg8k95pcF8ctKwCS7WVxteBs29hhfAotzQ== dependencies: - "@jest/environment" "^29.6.4" - "@jest/expect" "^29.6.4" + "@jest/environment" "^29.7.0" + "@jest/expect" "^29.7.0" "@jest/types" "^29.6.3" - jest-mock "^29.6.3" + jest-mock "^29.7.0" -"@jest/reporters@^29.6.4": - version "29.6.4" - resolved "https://registry.yarnpkg.com/@jest/reporters/-/reporters-29.6.4.tgz#9d6350c8a2761ece91f7946e97ab0dabc06deab7" - integrity sha512-sxUjWxm7QdchdrD3NfWKrL8FBsortZeibSJv4XLjESOOjSUOkjQcb0ZHJwfhEGIvBvTluTzfG2yZWZhkrXJu8g== +"@jest/reporters@^29.7.0": + version "29.7.0" + resolved "https://registry.yarnpkg.com/@jest/reporters/-/reporters-29.7.0.tgz#04b262ecb3b8faa83b0b3d321623972393e8f4c7" + integrity sha512-DApq0KJbJOEzAFYjHADNNxAE3KbhxQB1y5Kplb5Waqw6zVbuWatSnMjE5gs8FUgEPmNsnZA3NCWl9NG0ia04Pg== dependencies: "@bcoe/v8-coverage" "^0.2.3" - "@jest/console" "^29.6.4" - "@jest/test-result" "^29.6.4" - "@jest/transform" "^29.6.4" + "@jest/console" "^29.7.0" + "@jest/test-result" "^29.7.0" + "@jest/transform" "^29.7.0" "@jest/types" "^29.6.3" "@jridgewell/trace-mapping" "^0.3.18" "@types/node" "*" @@ -1530,9 +1530,9 @@ istanbul-lib-report "^3.0.0" istanbul-lib-source-maps "^4.0.0" istanbul-reports "^3.1.3" - jest-message-util "^29.6.3" - jest-util "^29.6.3" - jest-worker "^29.6.4" + jest-message-util "^29.7.0" + jest-util "^29.7.0" + jest-worker "^29.7.0" slash "^3.0.0" string-length "^4.0.1" strip-ansi "^6.0.0" @@ -1554,30 +1554,30 @@ callsites "^3.0.0" graceful-fs "^4.2.9" -"@jest/test-result@^29.6.4": - version "29.6.4" - resolved "https://registry.yarnpkg.com/@jest/test-result/-/test-result-29.6.4.tgz#adf5c79f6e1fb7405ad13d67d9e2b6ff54b54c6b" - integrity sha512-uQ1C0AUEN90/dsyEirgMLlouROgSY+Wc/JanVVk0OiUKa5UFh7sJpMEM3aoUBAz2BRNvUJ8j3d294WFuRxSyOQ== +"@jest/test-result@^29.7.0": + version "29.7.0" + resolved "https://registry.yarnpkg.com/@jest/test-result/-/test-result-29.7.0.tgz#8db9a80aa1a097bb2262572686734baed9b1657c" + integrity sha512-Fdx+tv6x1zlkJPcWXmMDAG2HBnaR9XPSd5aDWQVsfrZmLVT3lU1cwyxLgRmXR9yrq4NBoEm9BMsfgFzTQAbJYA== dependencies: - "@jest/console" "^29.6.4" + "@jest/console" "^29.7.0" "@jest/types" "^29.6.3" "@types/istanbul-lib-coverage" "^2.0.0" collect-v8-coverage "^1.0.0" -"@jest/test-sequencer@^29.6.4": - version "29.6.4" - resolved "https://registry.yarnpkg.com/@jest/test-sequencer/-/test-sequencer-29.6.4.tgz#86aef66aaa22b181307ed06c26c82802fb836d7b" - integrity sha512-E84M6LbpcRq3fT4ckfKs9ryVanwkaIB0Ws9bw3/yP4seRLg/VaCZ/LgW0MCq5wwk4/iP/qnilD41aj2fsw2RMg== +"@jest/test-sequencer@^29.7.0": + version "29.7.0" + resolved "https://registry.yarnpkg.com/@jest/test-sequencer/-/test-sequencer-29.7.0.tgz#6cef977ce1d39834a3aea887a1726628a6f072ce" + integrity sha512-GQwJ5WZVrKnOJuiYiAF52UNUJXgTZx1NHjFSEB0qEMmSZKAkdMoIzw/Cj6x6NF4AvV23AUqDpFzQkN/eYCYTxw== dependencies: - "@jest/test-result" "^29.6.4" + "@jest/test-result" "^29.7.0" graceful-fs "^4.2.9" - jest-haste-map "^29.6.4" + jest-haste-map "^29.7.0" slash "^3.0.0" -"@jest/transform@^29.3.1", "@jest/transform@^29.6.4": - version "29.6.4" - resolved "https://registry.yarnpkg.com/@jest/transform/-/transform-29.6.4.tgz#a6bc799ef597c5d85b2e65a11fd96b6b239bab5a" - integrity sha512-8thgRSiXUqtr/pPGY/OsyHuMjGyhVnWrFAwoxmIemlBuiMyU1WFs0tXoNxzcr4A4uErs/ABre76SGmrr5ab/AA== +"@jest/transform@^29.3.1", "@jest/transform@^29.7.0": + version "29.7.0" + resolved "https://registry.yarnpkg.com/@jest/transform/-/transform-29.7.0.tgz#df2dd9c346c7d7768b8a06639994640c642e284c" + integrity sha512-ok/BTPFzFKVMwO5eOHRrvnBVHdRy9IrsrW1GpMaQ9MCnilNLXQKmAX8s1YXDFaai9xJpac2ySzV0YeRRECr2Vw== dependencies: "@babel/core" "^7.11.6" "@jest/types" "^29.6.3" @@ -1587,9 +1587,9 @@ convert-source-map "^2.0.0" fast-json-stable-stringify "^2.1.0" graceful-fs "^4.2.9" - jest-haste-map "^29.6.4" + jest-haste-map "^29.7.0" jest-regex-util "^29.6.3" - jest-util "^29.6.3" + jest-util "^29.7.0" micromatch "^4.0.4" pirates "^4.0.4" slash "^3.0.0" @@ -1684,16 +1684,16 @@ "@types/set-cookie-parser" "^2.4.0" set-cookie-parser "^2.4.6" -"@mswjs/interceptors@^0.17.5": - version "0.17.9" - resolved "https://registry.yarnpkg.com/@mswjs/interceptors/-/interceptors-0.17.9.tgz#0096fc88fea63ee42e36836acae8f4ae33651c04" - integrity sha512-4LVGt03RobMH/7ZrbHqRxQrS9cc2uh+iNKSj8UWr8M26A2i793ju+csaB5zaqYltqJmA2jUq4VeYfKmVqvsXQg== +"@mswjs/interceptors@^0.17.10": + version "0.17.10" + resolved "https://registry.yarnpkg.com/@mswjs/interceptors/-/interceptors-0.17.10.tgz#857b41f30e2b92345ed9a4e2b1d0a08b8b6fcad4" + integrity sha512-N8x7eSLGcmUFNWZRxT1vsHvypzIRgQYdG0rJey/rZCy6zT/30qDt8Joj7FxzGNLSwXbeZqJOMqDurp7ra4hgbw== dependencies: "@open-draft/until" "^1.0.3" "@types/debug" "^4.1.7" "@xmldom/xmldom" "^0.8.3" debug "^4.3.3" - headers-polyfill "^3.1.0" + headers-polyfill "3.2.5" outvariant "^1.2.1" strict-event-emitter "^0.2.4" web-encoding "^1.1.5" @@ -2048,10 +2048,10 @@ dependencies: "@babel/runtime" "^7.13.10" -"@remix-run/router@1.8.0": - version "1.8.0" - resolved "https://registry.yarnpkg.com/@remix-run/router/-/router-1.8.0.tgz#e848d2f669f601544df15ce2a313955e4bf0bafc" - integrity sha512-mrfKqIHnSZRyIzBcanNJmVQELTnX+qagEDlcKO90RgRBVOZGSGvZKeDihTRfWcqoDn5N/NkUcwWTccnpN18Tfg== +"@remix-run/router@1.9.0": + version "1.9.0" + resolved "https://registry.yarnpkg.com/@remix-run/router/-/router-1.9.0.tgz#9033238b41c4cbe1e961eccb3f79e2c588328cf6" + integrity sha512-bV63itrKBC0zdT27qYm6SDZHlkXwFL1xMBuhkn+X7l0+IIhNaH5wuuvZKp6eKhCD4KFhujhfhCT1YxXW6esUIA== "@sinclair/typebox@^0.27.8": version "0.27.8" @@ -2072,19 +2072,19 @@ dependencies: "@sinonjs/commons" "^3.0.0" -"@storybook/addon-actions@7.4.0": - version "7.4.0" - resolved "https://registry.yarnpkg.com/@storybook/addon-actions/-/addon-actions-7.4.0.tgz#709988f46422b85b3672d2e6f90bf623af59faa9" - integrity sha512-0lHLLUlrGE7CBFrfmAXrBKu7fUIsiQlnNekuE3cIAjSgVR481bJEzYHUUoMATqpPC4GGErBdP1CZxVDDwWV8jA== +"@storybook/addon-actions@7.4.6": + version "7.4.6" + resolved "https://registry.yarnpkg.com/@storybook/addon-actions/-/addon-actions-7.4.6.tgz#080bd1612a744cc3fc4a435a07a63d2d8c05f030" + integrity sha512-SsqZr3js5NinKPnC8AeNI7Ij+Q6fIl9tRdRmSulEgjksjOg7E5S1/Wsn5Bb2CCgj7MaX6VxGyC7s3XskQtDiIQ== dependencies: - "@storybook/client-logger" "7.4.0" - "@storybook/components" "7.4.0" - "@storybook/core-events" "7.4.0" + "@storybook/client-logger" "7.4.6" + "@storybook/components" "7.4.6" + "@storybook/core-events" "7.4.6" "@storybook/global" "^5.0.0" - "@storybook/manager-api" "7.4.0" - "@storybook/preview-api" "7.4.0" - "@storybook/theming" "7.4.0" - "@storybook/types" "7.4.0" + "@storybook/manager-api" "7.4.6" + "@storybook/preview-api" "7.4.6" + "@storybook/theming" "7.4.6" + "@storybook/types" "7.4.6" dequal "^2.0.2" lodash "^4.17.21" polished "^4.2.2" @@ -2094,141 +2094,141 @@ ts-dedent "^2.0.0" uuid "^9.0.0" -"@storybook/addon-backgrounds@7.4.0": - version "7.4.0" - resolved "https://registry.yarnpkg.com/@storybook/addon-backgrounds/-/addon-backgrounds-7.4.0.tgz#7d3048329b8ef73145a2e9b435b7b35004a65f86" - integrity sha512-cEO/Tp/eRE+5bf1FGN4wKLqLDBv3EYp9enJyXV7B3cFdciqtoE7VJPZuFZkzjJN1rRcOKSZp8g5agsx+x9uNGQ== +"@storybook/addon-backgrounds@7.4.6": + version "7.4.6" + resolved "https://registry.yarnpkg.com/@storybook/addon-backgrounds/-/addon-backgrounds-7.4.6.tgz#315ac726e606259ce868b78c7b966cc9489b51ea" + integrity sha512-+LHTZB/ZYMAzkyD5ZxSriBsqmsrvIaW/Nnd/BeuXGbkrVKKqM0qAKiFZAfjc2WchA1piVNy0/1Rsf+kuYCEiJw== dependencies: - "@storybook/client-logger" "7.4.0" - "@storybook/components" "7.4.0" - "@storybook/core-events" "7.4.0" + "@storybook/client-logger" "7.4.6" + "@storybook/components" "7.4.6" + "@storybook/core-events" "7.4.6" "@storybook/global" "^5.0.0" - "@storybook/manager-api" "7.4.0" - "@storybook/preview-api" "7.4.0" - "@storybook/theming" "7.4.0" - "@storybook/types" "7.4.0" + "@storybook/manager-api" "7.4.6" + "@storybook/preview-api" "7.4.6" + "@storybook/theming" "7.4.6" + "@storybook/types" "7.4.6" memoizerific "^1.11.3" ts-dedent "^2.0.0" -"@storybook/addon-controls@7.4.0": - version "7.4.0" - resolved "https://registry.yarnpkg.com/@storybook/addon-controls/-/addon-controls-7.4.0.tgz#b212d60fd74d69f6b63c53e4d52ab6c77ee51247" - integrity sha512-tYDfqpTR+c9y4kElmr3aWNHPot6kYd+nruYb697LpkCdy4lFErqSo0mhvPyZfMZp2KEajfp6YJAurhQWbvbj/A== - dependencies: - "@storybook/blocks" "7.4.0" - "@storybook/client-logger" "7.4.0" - "@storybook/components" "7.4.0" - "@storybook/core-common" "7.4.0" - "@storybook/core-events" "7.4.0" - "@storybook/manager-api" "7.4.0" - "@storybook/node-logger" "7.4.0" - "@storybook/preview-api" "7.4.0" - "@storybook/theming" "7.4.0" - "@storybook/types" "7.4.0" +"@storybook/addon-controls@7.4.6": + version "7.4.6" + resolved "https://registry.yarnpkg.com/@storybook/addon-controls/-/addon-controls-7.4.6.tgz#b1db7a0faacb25b9a6f54c2dff2ba94d06619bd4" + integrity sha512-4lq3sycEUIsK8SUWDYc60QgF4vV9FZZ3lDr6M7j2W9bOnvGw49d2fbdlnq+bX1ZprZZ9VgglQpBAorQB3BXZRw== + dependencies: + "@storybook/blocks" "7.4.6" + "@storybook/client-logger" "7.4.6" + "@storybook/components" "7.4.6" + "@storybook/core-common" "7.4.6" + "@storybook/core-events" "7.4.6" + "@storybook/manager-api" "7.4.6" + "@storybook/node-logger" "7.4.6" + "@storybook/preview-api" "7.4.6" + "@storybook/theming" "7.4.6" + "@storybook/types" "7.4.6" lodash "^4.17.21" ts-dedent "^2.0.0" -"@storybook/addon-docs@7.4.0": - version "7.4.0" - resolved "https://registry.yarnpkg.com/@storybook/addon-docs/-/addon-docs-7.4.0.tgz#e07233c264eaec149a0fcca0e27c586d4e80b403" - integrity sha512-LJE92LUeVTgi8W4tLBEbSvCqF54snmBfTFCr46vhCFov2CE2VBgEvIX1XT3dfUgYUOtPu3RXR2C89fYgU6VYZw== +"@storybook/addon-docs@7.4.6": + version "7.4.6" + resolved "https://registry.yarnpkg.com/@storybook/addon-docs/-/addon-docs-7.4.6.tgz#f2cc635a77cfb3e2910d6ca813add9a16785595d" + integrity sha512-dLaub+XWFq4hChw+xfuF9yYg0Txp77FUawKoAigccfjWXx+OOhRV3XTuAcknpXkYq94GWynHgUFXosXT9kbDNA== dependencies: "@jest/transform" "^29.3.1" "@mdx-js/react" "^2.1.5" - "@storybook/blocks" "7.4.0" - "@storybook/client-logger" "7.4.0" - "@storybook/components" "7.4.0" - "@storybook/csf-plugin" "7.4.0" - "@storybook/csf-tools" "7.4.0" + "@storybook/blocks" "7.4.6" + "@storybook/client-logger" "7.4.6" + "@storybook/components" "7.4.6" + "@storybook/csf-plugin" "7.4.6" + "@storybook/csf-tools" "7.4.6" "@storybook/global" "^5.0.0" "@storybook/mdx2-csf" "^1.0.0" - "@storybook/node-logger" "7.4.0" - "@storybook/postinstall" "7.4.0" - "@storybook/preview-api" "7.4.0" - "@storybook/react-dom-shim" "7.4.0" - "@storybook/theming" "7.4.0" - "@storybook/types" "7.4.0" + "@storybook/node-logger" "7.4.6" + "@storybook/postinstall" "7.4.6" + "@storybook/preview-api" "7.4.6" + "@storybook/react-dom-shim" "7.4.6" + "@storybook/theming" "7.4.6" + "@storybook/types" "7.4.6" fs-extra "^11.1.0" remark-external-links "^8.0.0" remark-slug "^6.0.0" ts-dedent "^2.0.0" "@storybook/addon-essentials@^7.0.27": - version "7.4.0" - resolved "https://registry.yarnpkg.com/@storybook/addon-essentials/-/addon-essentials-7.4.0.tgz#b5d19c60233e5bd5e1a29b76b51059c889f18d52" - integrity sha512-nZmNM9AKw2JXxnYUXyFKLeUF/cL7Z9E1WTeZyOFTDtU2aITRt8+LvaepwjchtPqu2B0GcQxLB5FRDdhy0I19nw== - dependencies: - "@storybook/addon-actions" "7.4.0" - "@storybook/addon-backgrounds" "7.4.0" - "@storybook/addon-controls" "7.4.0" - "@storybook/addon-docs" "7.4.0" - "@storybook/addon-highlight" "7.4.0" - "@storybook/addon-measure" "7.4.0" - "@storybook/addon-outline" "7.4.0" - "@storybook/addon-toolbars" "7.4.0" - "@storybook/addon-viewport" "7.4.0" - "@storybook/core-common" "7.4.0" - "@storybook/manager-api" "7.4.0" - "@storybook/node-logger" "7.4.0" - "@storybook/preview-api" "7.4.0" + version "7.4.6" + resolved "https://registry.yarnpkg.com/@storybook/addon-essentials/-/addon-essentials-7.4.6.tgz#b9c83dbdae0ffd6f24fa9328b36488d563714260" + integrity sha512-dWodufrt71TK7ELkeIvVae/x4PzECUlbOm57Iqqt4yQCyR291CgvI4PjeB8un2HbpcXCGZ+N/Oj3YkytvzBi4A== + dependencies: + "@storybook/addon-actions" "7.4.6" + "@storybook/addon-backgrounds" "7.4.6" + "@storybook/addon-controls" "7.4.6" + "@storybook/addon-docs" "7.4.6" + "@storybook/addon-highlight" "7.4.6" + "@storybook/addon-measure" "7.4.6" + "@storybook/addon-outline" "7.4.6" + "@storybook/addon-toolbars" "7.4.6" + "@storybook/addon-viewport" "7.4.6" + "@storybook/core-common" "7.4.6" + "@storybook/manager-api" "7.4.6" + "@storybook/node-logger" "7.4.6" + "@storybook/preview-api" "7.4.6" ts-dedent "^2.0.0" -"@storybook/addon-highlight@7.4.0": - version "7.4.0" - resolved "https://registry.yarnpkg.com/@storybook/addon-highlight/-/addon-highlight-7.4.0.tgz#ea33826a7f610f5e76cfa59ff22283e01cfd76cd" - integrity sha512-kpYSb3oXI9t/1+aRJhToDZ0/1W4mu+SzTBfv9Bl2d/DogEkFzgJricoy5LtvS5EpcXUmKO1FJsw/DCm9buSL2g== +"@storybook/addon-highlight@7.4.6": + version "7.4.6" + resolved "https://registry.yarnpkg.com/@storybook/addon-highlight/-/addon-highlight-7.4.6.tgz#ee688232fe260f1b926205ddc1ceb1e0781dcbaf" + integrity sha512-zCufxxD2KS5VwczxfkcBxe1oR/juTTn2H1Qm8kYvWCJQx3UxzX0+G9cwafbpV7eivqaufLweEwROkH+0KjAtkQ== dependencies: - "@storybook/core-events" "7.4.0" + "@storybook/core-events" "7.4.6" "@storybook/global" "^5.0.0" - "@storybook/preview-api" "7.4.0" + "@storybook/preview-api" "7.4.6" "@storybook/addon-interactions@^7.0.27": - version "7.4.0" - resolved "https://registry.yarnpkg.com/@storybook/addon-interactions/-/addon-interactions-7.4.0.tgz#d0c15303999ac1e8f33705146e9a0a6db6df339c" - integrity sha512-nEWP+Ib0Y/ShXfpCm40FBTbBy1/MT8XxTEAhcNN+3ZJ07Vhhkrb8GMlWHTKQv2PyghEVBYEoPFHhElUJQOe00g== - dependencies: - "@storybook/client-logger" "7.4.0" - "@storybook/components" "7.4.0" - "@storybook/core-common" "7.4.0" - "@storybook/core-events" "7.4.0" + version "7.4.6" + resolved "https://registry.yarnpkg.com/@storybook/addon-interactions/-/addon-interactions-7.4.6.tgz#643659a6fd1a90e9fecaf6309276d29c3cee8af1" + integrity sha512-zVZYrEPZPhNrXBuPqM7HbQvr6jwsje1sbCYj3wnp83U5wjciuqrngqHIlaSZ30zOWSfRVyzbyqL+JQZKA58BNA== + dependencies: + "@storybook/client-logger" "7.4.6" + "@storybook/components" "7.4.6" + "@storybook/core-common" "7.4.6" + "@storybook/core-events" "7.4.6" "@storybook/global" "^5.0.0" - "@storybook/instrumenter" "7.4.0" - "@storybook/manager-api" "7.4.0" - "@storybook/preview-api" "7.4.0" - "@storybook/theming" "7.4.0" - "@storybook/types" "7.4.0" + "@storybook/instrumenter" "7.4.6" + "@storybook/manager-api" "7.4.6" + "@storybook/preview-api" "7.4.6" + "@storybook/theming" "7.4.6" + "@storybook/types" "7.4.6" jest-mock "^27.0.6" polished "^4.2.2" ts-dedent "^2.2.0" "@storybook/addon-links@^7.0.27": - version "7.4.0" - resolved "https://registry.yarnpkg.com/@storybook/addon-links/-/addon-links-7.4.0.tgz#f10ba388143d0de75150a27e94241d5fb4dfba7e" - integrity sha512-lFj8fiokWKk3jx5YUQ4anQo1uCNDMP1y6nJ/92Y85vnOd1vJr3w4GlLy8eOWMABRE33AKLI5Yp6wcpWZDe7hhQ== + version "7.4.6" + resolved "https://registry.yarnpkg.com/@storybook/addon-links/-/addon-links-7.4.6.tgz#6bf1730b8f44d85a0b601d268fd6fb0726bbc360" + integrity sha512-BPygElZKX+CPI9Se6GJNk1dYc5oxuhA+vHigO1tBqhiM6VkHyFP3cvezJNQvpNYhkUnu3cxnZXb3UJnlRbPY3g== dependencies: - "@storybook/client-logger" "7.4.0" - "@storybook/core-events" "7.4.0" + "@storybook/client-logger" "7.4.6" + "@storybook/core-events" "7.4.6" "@storybook/csf" "^0.1.0" "@storybook/global" "^5.0.0" - "@storybook/manager-api" "7.4.0" - "@storybook/preview-api" "7.4.0" - "@storybook/router" "7.4.0" - "@storybook/types" "7.4.0" + "@storybook/manager-api" "7.4.6" + "@storybook/preview-api" "7.4.6" + "@storybook/router" "7.4.6" + "@storybook/types" "7.4.6" prop-types "^15.7.2" ts-dedent "^2.0.0" -"@storybook/addon-measure@7.4.0": - version "7.4.0" - resolved "https://registry.yarnpkg.com/@storybook/addon-measure/-/addon-measure-7.4.0.tgz#61bc0d0af5af8c22e81b70e1690b2f58262944cd" - integrity sha512-8YjBqm6jPOBgkRn9YnJkLN0+ghgJiukdHOa0VB3qhiT+oww4ZOZ7mc2aQRwXQoFb05UbVVG9UNxE7lhyTyaG2w== +"@storybook/addon-measure@7.4.6": + version "7.4.6" + resolved "https://registry.yarnpkg.com/@storybook/addon-measure/-/addon-measure-7.4.6.tgz#12cb5ffab78c922809178c0ee8574b264ff2de5d" + integrity sha512-nCymMLaHnxv8TE3yEM1A9Tulb1NuRXRNmtsdHTkjv7P1aWCxZo8A/GZaottKe/GLT8jSRjZ+dnpYWrbAhw6wTQ== dependencies: - "@storybook/client-logger" "7.4.0" - "@storybook/components" "7.4.0" - "@storybook/core-events" "7.4.0" + "@storybook/client-logger" "7.4.6" + "@storybook/components" "7.4.6" + "@storybook/core-events" "7.4.6" "@storybook/global" "^5.0.0" - "@storybook/manager-api" "7.4.0" - "@storybook/preview-api" "7.4.0" - "@storybook/types" "7.4.0" + "@storybook/manager-api" "7.4.6" + "@storybook/preview-api" "7.4.6" + "@storybook/types" "7.4.6" tiny-invariant "^1.3.1" "@storybook/addon-onboarding@^1.0.8": @@ -2239,62 +2239,71 @@ "@storybook/telemetry" "^7.1.0-alpha.32" react-confetti "^6.1.0" -"@storybook/addon-outline@7.4.0": - version "7.4.0" - resolved "https://registry.yarnpkg.com/@storybook/addon-outline/-/addon-outline-7.4.0.tgz#63fef45815f209a3ad7ac2b3765f0734093af668" - integrity sha512-CCAWFC3bfkmYPzFjOemfH/kjpqJOHt+SdJgBKmwujDy+zum0DHlUL/7rd+U32cEpezCA8bapd0hlWn59C4agHQ== +"@storybook/addon-outline@7.4.6": + version "7.4.6" + resolved "https://registry.yarnpkg.com/@storybook/addon-outline/-/addon-outline-7.4.6.tgz#d375046bb6858ea9ec09fdaf03d5802a543b2a30" + integrity sha512-errNUblRVDLpuEaHQPr/nsrnsUkD2ARmXawkRvizgDWLIDMDJYjTON3MUCaVx3x+hlZ3I6X//G5TVcma8tCc8A== dependencies: - "@storybook/client-logger" "7.4.0" - "@storybook/components" "7.4.0" - "@storybook/core-events" "7.4.0" + "@storybook/client-logger" "7.4.6" + "@storybook/components" "7.4.6" + "@storybook/core-events" "7.4.6" "@storybook/global" "^5.0.0" - "@storybook/manager-api" "7.4.0" - "@storybook/preview-api" "7.4.0" - "@storybook/types" "7.4.0" + "@storybook/manager-api" "7.4.6" + "@storybook/preview-api" "7.4.6" + "@storybook/types" "7.4.6" ts-dedent "^2.0.0" -"@storybook/addon-toolbars@7.4.0": - version "7.4.0" - resolved "https://registry.yarnpkg.com/@storybook/addon-toolbars/-/addon-toolbars-7.4.0.tgz#db1a3bc1d6e6aa0142b62aaf8c44d5a9f82fd6b7" - integrity sha512-00PDLchlQXI3ZClQHU0YQBfikAAxHOhVNv2QKW54yFKmxPl+P2c/VIeir9LcPhA04smKrJTD1u+Nszd66A9xAA== - dependencies: - "@storybook/client-logger" "7.4.0" - "@storybook/components" "7.4.0" - "@storybook/manager-api" "7.4.0" - "@storybook/preview-api" "7.4.0" - "@storybook/theming" "7.4.0" - -"@storybook/addon-viewport@7.4.0": - version "7.4.0" - resolved "https://registry.yarnpkg.com/@storybook/addon-viewport/-/addon-viewport-7.4.0.tgz#a9bc167b822d31491cec6aad21cc0a420f1ae5b7" - integrity sha512-Bfoilf9eJV/C7tR8XHDxz3h8JlZ+iggoESp2Tc0bW9tlRvz+PsCqeyHhF/IgHY+gLnPal2PkK/PIM+ruO45HXA== - dependencies: - "@storybook/client-logger" "7.4.0" - "@storybook/components" "7.4.0" - "@storybook/core-events" "7.4.0" +"@storybook/addon-toolbars@7.4.6": + version "7.4.6" + resolved "https://registry.yarnpkg.com/@storybook/addon-toolbars/-/addon-toolbars-7.4.6.tgz#04d270bb45f6cea45cecce084f7713c7dda047f9" + integrity sha512-L9m2FBcKeteGq7qIYsMJr0LEfiH7Wdrv5IDcldZTn68eZUJTh1p4GdJZcOmzX1P5IFRr76hpu03iWsNlWQjpbQ== + dependencies: + "@storybook/client-logger" "7.4.6" + "@storybook/components" "7.4.6" + "@storybook/manager-api" "7.4.6" + "@storybook/preview-api" "7.4.6" + "@storybook/theming" "7.4.6" + +"@storybook/addon-viewport@7.4.6": + version "7.4.6" + resolved "https://registry.yarnpkg.com/@storybook/addon-viewport/-/addon-viewport-7.4.6.tgz#ff6fbe9b67310d16cd7b4a38dbc445a8fede6373" + integrity sha512-INDtk54j7bi7NgxMfd2ATmbA0J7nAd6X8itMkLIyPuPJtx8bYHPDORyemDOd0AojgmAdTOAyUtDYdI/PFeo4Cw== + dependencies: + "@storybook/client-logger" "7.4.6" + "@storybook/components" "7.4.6" + "@storybook/core-events" "7.4.6" "@storybook/global" "^5.0.0" - "@storybook/manager-api" "7.4.0" - "@storybook/preview-api" "7.4.0" - "@storybook/theming" "7.4.0" + "@storybook/manager-api" "7.4.6" + "@storybook/preview-api" "7.4.6" + "@storybook/theming" "7.4.6" memoizerific "^1.11.3" prop-types "^15.7.2" -"@storybook/blocks@7.4.0", "@storybook/blocks@^7.0.27": - version "7.4.0" - resolved "https://registry.yarnpkg.com/@storybook/blocks/-/blocks-7.4.0.tgz#6a9240e2b58bac99a998c559d719be7ff4e19dcc" - integrity sha512-YQznNjJm+l32fCfPxrZso9+MbcyG0pWZSpx3RKI1+pxDMsAs4mbXsIw4//jKfjoDP/6/Cz/FJcSx8LT7i4BJ2w== +"@storybook/addons@7.4.6": + version "7.4.6" + resolved "https://registry.yarnpkg.com/@storybook/addons/-/addons-7.4.6.tgz#cca556ca30aa34652f9bbab30467a538df5b10fe" + integrity sha512-c+4awrtwNlJayFdgLkEXa5H2Gj+KNlxuN+Z5oDAdZBLqXI8g0gn7eYO2F/eCSIDWdd/+zcU2uq57XPFKc8veHQ== dependencies: - "@storybook/channels" "7.4.0" - "@storybook/client-logger" "7.4.0" - "@storybook/components" "7.4.0" - "@storybook/core-events" "7.4.0" + "@storybook/manager-api" "7.4.6" + "@storybook/preview-api" "7.4.6" + "@storybook/types" "7.4.6" + +"@storybook/blocks@7.4.6", "@storybook/blocks@^7.0.27": + version "7.4.6" + resolved "https://registry.yarnpkg.com/@storybook/blocks/-/blocks-7.4.6.tgz#03134130fa20d6c36c6985008bc2c38892c5b8f5" + integrity sha512-HxBSAeOiTZW2jbHQlo1upRWFgoMsaAyKijUFf5MwwMNIesXCuuTGZDJ3xTABwAVLK2qC9Ektfbo0CZCiPVuDRQ== + dependencies: + "@storybook/channels" "7.4.6" + "@storybook/client-logger" "7.4.6" + "@storybook/components" "7.4.6" + "@storybook/core-events" "7.4.6" "@storybook/csf" "^0.1.0" - "@storybook/docs-tools" "7.4.0" + "@storybook/docs-tools" "7.4.6" "@storybook/global" "^5.0.0" - "@storybook/manager-api" "7.4.0" - "@storybook/preview-api" "7.4.0" - "@storybook/theming" "7.4.0" - "@storybook/types" "7.4.0" + "@storybook/manager-api" "7.4.6" + "@storybook/preview-api" "7.4.6" + "@storybook/theming" "7.4.6" + "@storybook/types" "7.4.6" "@types/lodash" "^4.14.167" color-convert "^2.0.1" dequal "^2.0.2" @@ -2308,15 +2317,15 @@ ts-dedent "^2.0.0" util-deprecate "^1.0.2" -"@storybook/builder-manager@7.4.0": - version "7.4.0" - resolved "https://registry.yarnpkg.com/@storybook/builder-manager/-/builder-manager-7.4.0.tgz#80cf72ea83f88e16d585c5bdb40d563874c7d8ca" - integrity sha512-4fuxVzBIBbZh2aVBizSOU5EJ8b74IhR6x2TAZjifZZf5Gdxgfgio8sAyrrd/C78vrFOFhFEgmQhMqZRuCLHxvQ== +"@storybook/builder-manager@7.4.6": + version "7.4.6" + resolved "https://registry.yarnpkg.com/@storybook/builder-manager/-/builder-manager-7.4.6.tgz#942a1a5e0a8b5956bd30867841fa1c542eb3d0bf" + integrity sha512-zylZCD2rmyLOOFBFmUgtJg6UNUKmRNgXiig1XApzS2TkIbTZP827DsVEUl0ey/lskCe0uArkrEBR6ICba8p/Rw== dependencies: "@fal-works/esbuild-plugin-global-externals" "^2.1.2" - "@storybook/core-common" "7.4.0" - "@storybook/manager" "7.4.0" - "@storybook/node-logger" "7.4.0" + "@storybook/core-common" "7.4.6" + "@storybook/manager" "7.4.6" + "@storybook/node-logger" "7.4.6" "@types/ejs" "^3.1.1" "@types/find-cache-dir" "^3.2.1" "@yarnpkg/esbuild-plugin-pnp" "^3.0.0-rc.10" @@ -2330,20 +2339,28 @@ process "^0.11.10" util "^0.12.4" -"@storybook/builder-webpack5@7.4.0": - version "7.4.0" - resolved "https://registry.yarnpkg.com/@storybook/builder-webpack5/-/builder-webpack5-7.4.0.tgz#c9a4ee5a6424dd70f5b35f057de24afd268a5fd3" - integrity sha512-CYeXppqGACzDUpLCFvWvwD7IjN7VNi7+nwQ1uRNgW2NgBMOIldZe+gcTXcc0BuHyIitU5/vvquYM0qjis05LYw== - dependencies: - "@babel/core" "^7.22.0" - "@storybook/channels" "7.4.0" - "@storybook/client-logger" "7.4.0" - "@storybook/core-common" "7.4.0" - "@storybook/core-events" "7.4.0" - "@storybook/core-webpack" "7.4.0" - "@storybook/node-logger" "7.4.0" - "@storybook/preview" "7.4.0" - "@storybook/preview-api" "7.4.0" +"@storybook/builder-webpack5@7.4.6": + version "7.4.6" + resolved "https://registry.yarnpkg.com/@storybook/builder-webpack5/-/builder-webpack5-7.4.6.tgz#a3d5b3e270d80cbbe31903de9dafb4a25e36e08e" + integrity sha512-j7AyDPlUuO2GiH6riB8iGbT7blQpyVGB+rMHXPSm7v6/U7IITbNzxFwe+sSMLoFr8K1e2VXpgqQ9p3rHFey+nw== + dependencies: + "@babel/core" "^7.22.9" + "@storybook/addons" "7.4.6" + "@storybook/channels" "7.4.6" + "@storybook/client-api" "7.4.6" + "@storybook/client-logger" "7.4.6" + "@storybook/components" "7.4.6" + "@storybook/core-common" "7.4.6" + "@storybook/core-events" "7.4.6" + "@storybook/core-webpack" "7.4.6" + "@storybook/global" "^5.0.0" + "@storybook/manager-api" "7.4.6" + "@storybook/node-logger" "7.4.6" + "@storybook/preview" "7.4.6" + "@storybook/preview-api" "7.4.6" + "@storybook/router" "7.4.6" + "@storybook/store" "7.4.6" + "@storybook/theming" "7.4.6" "@swc/core" "^1.3.49" "@types/node" "^16.0.0" "@types/semver" "^7.3.4" @@ -2372,34 +2389,35 @@ webpack-hot-middleware "^2.25.1" webpack-virtual-modules "^0.5.0" -"@storybook/channels@7.4.0": - version "7.4.0" - resolved "https://registry.yarnpkg.com/@storybook/channels/-/channels-7.4.0.tgz#4ab69fce09c0fe7299f1595628b3de10b0fdcd8f" - integrity sha512-/1CU0s3npFumzVHLGeubSyPs21O3jNqtSppOjSB9iDTyV2GtQrjh5ntVwebfKpCkUSitx3x7TkCb9dylpEZ8+w== +"@storybook/channels@7.4.6": + version "7.4.6" + resolved "https://registry.yarnpkg.com/@storybook/channels/-/channels-7.4.6.tgz#cadd16b91db08005c2b9e4938d3e1d1290d27a40" + integrity sha512-yPv/sfo2c18fM3fvG0i1xse63vG8l33Al/OU0k/dtovltPu001/HVa1QgBgsb/QrEfZtvGjGhmtdVeYb39fv3A== dependencies: - "@storybook/client-logger" "7.4.0" - "@storybook/core-events" "7.4.0" + "@storybook/client-logger" "7.4.6" + "@storybook/core-events" "7.4.6" "@storybook/global" "^5.0.0" qs "^6.10.0" telejson "^7.2.0" tiny-invariant "^1.3.1" -"@storybook/cli@7.4.0": - version "7.4.0" - resolved "https://registry.yarnpkg.com/@storybook/cli/-/cli-7.4.0.tgz#a50f435d55e3056547c983c0bfacb2eed63cd692" - integrity sha512-yn27cn3LzhTqpEVX6CzUz13KTJ3jPLA2eM4bO1t7SYUqpDlzw3lET9DIcYIaUAIiL+0r2Js3jW2BsyN/5KmO5w== +"@storybook/cli@7.4.6": + version "7.4.6" + resolved "https://registry.yarnpkg.com/@storybook/cli/-/cli-7.4.6.tgz#c322fcfdacf839a55a9c2aaa592a820fdaa1422c" + integrity sha512-rRwaH8pOL+FHz/pJMEkNpMH2xvZvWsrl7obBYw26NQiHmiVSAkfHJicndSN1mwc+p5w+9iXthrgzbLtSAOSvkA== dependencies: "@babel/core" "^7.22.9" "@babel/preset-env" "^7.22.9" "@babel/types" "^7.22.5" "@ndelangen/get-tarball" "^3.0.7" - "@storybook/codemod" "7.4.0" - "@storybook/core-common" "7.4.0" - "@storybook/core-server" "7.4.0" - "@storybook/csf-tools" "7.4.0" - "@storybook/node-logger" "7.4.0" - "@storybook/telemetry" "7.4.0" - "@storybook/types" "7.4.0" + "@storybook/codemod" "7.4.6" + "@storybook/core-common" "7.4.6" + "@storybook/core-events" "7.4.6" + "@storybook/core-server" "7.4.6" + "@storybook/csf-tools" "7.4.6" + "@storybook/node-logger" "7.4.6" + "@storybook/telemetry" "7.4.6" + "@storybook/types" "7.4.6" "@types/semver" "^7.3.4" "@yarnpkg/fslib" "2.10.3" "@yarnpkg/libzip" "2.3.0" @@ -2430,25 +2448,33 @@ ts-dedent "^2.0.0" util-deprecate "^1.0.2" -"@storybook/client-logger@7.4.0", "@storybook/client-logger@^7.0.0-beta.0 || ^7.0.0-rc.0 || ^7.0.0": - version "7.4.0" - resolved "https://registry.yarnpkg.com/@storybook/client-logger/-/client-logger-7.4.0.tgz#f90aa5ee29d540074f6e4890bae71836ac87273c" - integrity sha512-4pBnf7+df1wXEVcF1civqxbrtccGGHQkfWQkJo49s53RXvF7SRTcif6XTx0V3cQV0v7I1C5mmLm0LNlmjPRP1Q== +"@storybook/client-api@7.4.6": + version "7.4.6" + resolved "https://registry.yarnpkg.com/@storybook/client-api/-/client-api-7.4.6.tgz#0007fa86d9aae34b6bde0220c0b68fc9d78bde73" + integrity sha512-O8yA/xEzPW9Oe3s5VJAFor2d2KwXHjUZ1gvou3o14zu/TJLgXwol0qBBr+YLRO2rcNNJ51pAIGwAT5bgmpUaeg== + dependencies: + "@storybook/client-logger" "7.4.6" + "@storybook/preview-api" "7.4.6" + +"@storybook/client-logger@7.4.6", "@storybook/client-logger@^7.0.0-beta.0 || ^7.0.0-rc.0 || ^7.0.0": + version "7.4.6" + resolved "https://registry.yarnpkg.com/@storybook/client-logger/-/client-logger-7.4.6.tgz#3346f3ae51abb3ce61bf1a7d083d32f27b8f718f" + integrity sha512-XDw31ZziU//86PKuMRnmc+L/G0VopaGKENQOGEpvAXCU9IZASwGKlKAtcyosjrpi+ZiUXlMgUXCpXM7x3b1Ehw== dependencies: "@storybook/global" "^5.0.0" -"@storybook/codemod@7.4.0": - version "7.4.0" - resolved "https://registry.yarnpkg.com/@storybook/codemod/-/codemod-7.4.0.tgz#c23ef80253b5a5998c83e49e74bd6ff62683d27a" - integrity sha512-XqNhv5bec+L7TJ5tXdsMalmJazwaFMVVxoNlnb0f9zKhovAEF2F6hl6+Pnd2avRomH9+1q7EM+GwrTCAvzAfzg== +"@storybook/codemod@7.4.6": + version "7.4.6" + resolved "https://registry.yarnpkg.com/@storybook/codemod/-/codemod-7.4.6.tgz#b884304c246de6de22faa94e76cd38f129bd827e" + integrity sha512-lxmwEpwksCaAq96APN2YlooSDfKjJ1vKzN5Ni2EqQzf2TEXl7XQjLacHd7OOaII1kfsy+D5gNG4N5wBo7Ub30g== dependencies: "@babel/core" "^7.22.9" "@babel/preset-env" "^7.22.9" "@babel/types" "^7.22.5" "@storybook/csf" "^0.1.0" - "@storybook/csf-tools" "7.4.0" - "@storybook/node-logger" "7.4.0" - "@storybook/types" "7.4.0" + "@storybook/csf-tools" "7.4.6" + "@storybook/node-logger" "7.4.6" + "@storybook/types" "7.4.6" "@types/cross-spawn" "^6.0.2" cross-spawn "^7.0.3" globby "^11.0.2" @@ -2457,37 +2483,38 @@ prettier "^2.8.0" recast "^0.23.1" -"@storybook/components@7.4.0": - version "7.4.0" - resolved "https://registry.yarnpkg.com/@storybook/components/-/components-7.4.0.tgz#0cc83ff89dd9cdcde3eaeeb7b3fbcf2036ba6fb8" - integrity sha512-GGnQrI4NXwri/PqNjhO1vNv4tC7RBjY87ce9WHBq1ueat3kBakdqV97NzScoldXarkkKK6grBqmhw9jE5PfzhQ== +"@storybook/components@7.4.6": + version "7.4.6" + resolved "https://registry.yarnpkg.com/@storybook/components/-/components-7.4.6.tgz#e804407bd3a047e9f6026edfbbe188244661b55a" + integrity sha512-nIRBhewAgrJJVafyCzuaLx1l+YOfvvD5dOZ0JxZsxJsefOdw1jFpUqUZ5fIpQ2moyvrR0mAUFw378rBfMdHz5Q== dependencies: "@radix-ui/react-select" "^1.2.2" "@radix-ui/react-toolbar" "^1.0.4" - "@storybook/client-logger" "7.4.0" + "@storybook/client-logger" "7.4.6" "@storybook/csf" "^0.1.0" "@storybook/global" "^5.0.0" - "@storybook/theming" "7.4.0" - "@storybook/types" "7.4.0" + "@storybook/theming" "7.4.6" + "@storybook/types" "7.4.6" memoizerific "^1.11.3" use-resize-observer "^9.1.0" util-deprecate "^1.0.2" -"@storybook/core-client@7.4.0": - version "7.4.0" - resolved "https://registry.yarnpkg.com/@storybook/core-client/-/core-client-7.4.0.tgz#b2b683ebc44d0dfaa7a886f7bb1a5fc74a3d0965" - integrity sha512-AhysJS2HnydB8Jc+BMVzK5VLHa1liJjxroNsd+ZTgGUhD7R8wvozrswQgY4MLFtcaLwN/wDWlK2YavSBqmc94Q== +"@storybook/core-client@7.4.6": + version "7.4.6" + resolved "https://registry.yarnpkg.com/@storybook/core-client/-/core-client-7.4.6.tgz#880ab2a431133912d0b76f2273cefe95b287d8ca" + integrity sha512-tfgxAHeCvMcs6DsVgtb4hQSDaCHeAPJOsoyhb47eDQfk4OmxzriM0qWucJV5DePSMi+KutX/rN2u0JxfOuN68g== dependencies: - "@storybook/client-logger" "7.4.0" - "@storybook/preview-api" "7.4.0" + "@storybook/client-logger" "7.4.6" + "@storybook/preview-api" "7.4.6" -"@storybook/core-common@7.4.0": - version "7.4.0" - resolved "https://registry.yarnpkg.com/@storybook/core-common/-/core-common-7.4.0.tgz#da71afd79a12cfb5565351f184f6797214a5da79" - integrity sha512-QKrBL46ZFdfTjlZE3f7b59Q5+frOHWIJ64sC9BZ2PHkZkGjFeYRDdJJ6EHLYBb+nToynl33dYN1GQz+hQn2vww== +"@storybook/core-common@7.4.6": + version "7.4.6" + resolved "https://registry.yarnpkg.com/@storybook/core-common/-/core-common-7.4.6.tgz#8cb3fd94c4c7c5d307fb1606dcb0e06fc8672410" + integrity sha512-05MJFmOM86qvTLtgDskokIFz9txe0Lbhq4L3by1FtF0GwgH+p+W6I94KI7c6ANER+kVZkXQZhiRzwBFnVTW+Cg== dependencies: - "@storybook/node-logger" "7.4.0" - "@storybook/types" "7.4.0" + "@storybook/core-events" "7.4.6" + "@storybook/node-logger" "7.4.6" + "@storybook/types" "7.4.6" "@types/find-cache-dir" "^3.2.1" "@types/node" "^16.0.0" "@types/node-fetch" "^2.6.4" @@ -2509,33 +2536,33 @@ resolve-from "^5.0.0" ts-dedent "^2.0.0" -"@storybook/core-events@7.4.0": - version "7.4.0" - resolved "https://registry.yarnpkg.com/@storybook/core-events/-/core-events-7.4.0.tgz#0d50d254d65a678065d5906ac1dcab64396f2f6a" - integrity sha512-JavEo4dw7TQdF5pSKjk4RtqLgsG2R/eWRI8vZ3ANKa0ploGAnQR/eMTfSxf6TUH3ElBWLJhi+lvUCkKXPQD+dw== +"@storybook/core-events@7.4.6": + version "7.4.6" + resolved "https://registry.yarnpkg.com/@storybook/core-events/-/core-events-7.4.6.tgz#f0b652c623c1afebda42d1702d631cecc1c137bf" + integrity sha512-r5vrE+32lwrJh1NGFr1a0mWjvxo7q8FXYShylcwRWpacmL5NTtLkrXOoJSeGvJ4yKNYkvxQFtOPId4lzDxa32w== dependencies: ts-dedent "^2.0.0" -"@storybook/core-server@7.4.0": - version "7.4.0" - resolved "https://registry.yarnpkg.com/@storybook/core-server/-/core-server-7.4.0.tgz#9e624789ff30d9538ac014b038c48fac0ebb7272" - integrity sha512-AcbfXatHVx1by4R2CiPIMgjQlOL3sUbVarkhmgUcL0AWT0zC0SCQWUZdo22en+jZhAraazgXyLGNCVP7A+6Tqg== +"@storybook/core-server@7.4.6": + version "7.4.6" + resolved "https://registry.yarnpkg.com/@storybook/core-server/-/core-server-7.4.6.tgz#82a3834d9a063ff01a126f7c1724c8997bdd1555" + integrity sha512-jqmRTGCJ1W0WReImivkisPVaLFT5sjtLnFoAk0feHp6QS5j7EYOPN7CYzliyQmARWTLUEXOVaFf3VD6nJZQhJQ== dependencies: "@aw-web-design/x-default-browser" "1.4.126" "@discoveryjs/json-ext" "^0.5.3" - "@storybook/builder-manager" "7.4.0" - "@storybook/channels" "7.4.0" - "@storybook/core-common" "7.4.0" - "@storybook/core-events" "7.4.0" + "@storybook/builder-manager" "7.4.6" + "@storybook/channels" "7.4.6" + "@storybook/core-common" "7.4.6" + "@storybook/core-events" "7.4.6" "@storybook/csf" "^0.1.0" - "@storybook/csf-tools" "7.4.0" + "@storybook/csf-tools" "7.4.6" "@storybook/docs-mdx" "^0.1.0" "@storybook/global" "^5.0.0" - "@storybook/manager" "7.4.0" - "@storybook/node-logger" "7.4.0" - "@storybook/preview-api" "7.4.0" - "@storybook/telemetry" "7.4.0" - "@storybook/types" "7.4.0" + "@storybook/manager" "7.4.6" + "@storybook/node-logger" "7.4.6" + "@storybook/preview-api" "7.4.6" + "@storybook/telemetry" "7.4.6" + "@storybook/types" "7.4.6" "@types/detect-port" "^1.3.0" "@types/node" "^16.0.0" "@types/pretty-hrtime" "^1.0.0" @@ -2555,7 +2582,6 @@ prompts "^2.4.0" read-pkg-up "^7.0.1" semver "^7.3.7" - serve-favicon "^2.5.0" telejson "^7.2.0" tiny-invariant "^1.3.1" ts-dedent "^2.0.0" @@ -2564,36 +2590,36 @@ watchpack "^2.2.0" ws "^8.2.3" -"@storybook/core-webpack@7.4.0": - version "7.4.0" - resolved "https://registry.yarnpkg.com/@storybook/core-webpack/-/core-webpack-7.4.0.tgz#0ff348a1590e9b8d425e9aec1ed850e3cfa3e75c" - integrity sha512-1zxzJjRbkcjl++OjYBVTDi0V/yO22Kz3ciPASTvXwrg0fXTXgxwxhJBmgOI4r17oY0kOWnJ1RDsmd95NLGAbGw== +"@storybook/core-webpack@7.4.6": + version "7.4.6" + resolved "https://registry.yarnpkg.com/@storybook/core-webpack/-/core-webpack-7.4.6.tgz#d8063c3854f49e383b68ff13311b3767a55eaaed" + integrity sha512-EqQDmd+vKAWOAjoe539LsfP8WvQG9V9i1priMA53u1FOEged8o0NBtRiRy2+JDdUSiGUdpe/X5+V/TyyQw/KWw== dependencies: - "@storybook/core-common" "7.4.0" - "@storybook/node-logger" "7.4.0" - "@storybook/types" "7.4.0" + "@storybook/core-common" "7.4.6" + "@storybook/node-logger" "7.4.6" + "@storybook/types" "7.4.6" "@types/node" "^16.0.0" ts-dedent "^2.0.0" -"@storybook/csf-plugin@7.4.0": - version "7.4.0" - resolved "https://registry.yarnpkg.com/@storybook/csf-plugin/-/csf-plugin-7.4.0.tgz#f25ebb30affbc9b4dd61b1fdb12c4a4257a275dc" - integrity sha512-X1L3l/dpz2UYjCEQlFLkW7w1A13pmzDZpJ0lotkV79PALlakMXBeoX3I2E0VMjJATV8wC9RSj56COBAs6HsPeg== +"@storybook/csf-plugin@7.4.6": + version "7.4.6" + resolved "https://registry.yarnpkg.com/@storybook/csf-plugin/-/csf-plugin-7.4.6.tgz#63b4498c9be329ba9cdd53bb8cbe66ef225230ec" + integrity sha512-yi7Qa4NSqKOyiJTWCxlB0ih2ijXq6oY5qZKW6MuMMBP14xJNRGLbH5KabpfXgN2T7YECcOWG1uWaGj2veJb1KA== dependencies: - "@storybook/csf-tools" "7.4.0" + "@storybook/csf-tools" "7.4.6" unplugin "^1.3.1" -"@storybook/csf-tools@7.4.0": - version "7.4.0" - resolved "https://registry.yarnpkg.com/@storybook/csf-tools/-/csf-tools-7.4.0.tgz#db5c97ee603da9a68511192d701534e356f9e592" - integrity sha512-bKyOmWPyvT50Neq2wCRr2PmVGLVVm6pOw8WL5t5jueD8sRRzo9QdfhEkqmuSyqdsBdt3SiJKL5oA6dqY5Vl9ww== +"@storybook/csf-tools@7.4.6": + version "7.4.6" + resolved "https://registry.yarnpkg.com/@storybook/csf-tools/-/csf-tools-7.4.6.tgz#3987e31d5975dcaa8f3dfa4f5e0fb5cd457cbae6" + integrity sha512-ocKpcIUtTBy6hlLY34RUFQyX403cWpB2gGfqvkHbpGe2BQj7EyV0zpWnjsfVxvw+M9OWlCdxHWDOPUgXM33ELw== dependencies: "@babel/generator" "^7.22.9" "@babel/parser" "^7.22.7" "@babel/traverse" "^7.22.8" "@babel/types" "^7.22.5" "@storybook/csf" "^0.1.0" - "@storybook/types" "7.4.0" + "@storybook/types" "7.4.6" fs-extra "^11.1.0" recast "^0.23.1" ts-dedent "^2.0.0" @@ -2617,14 +2643,14 @@ resolved "https://registry.yarnpkg.com/@storybook/docs-mdx/-/docs-mdx-0.1.0.tgz#33ba0e39d1461caf048b57db354b2cc410705316" integrity sha512-JDaBR9lwVY4eSH5W8EGHrhODjygPd6QImRbwjAuJNEnY0Vw4ie3bPkeGfnacB3OBW6u/agqPv2aRlR46JcAQLg== -"@storybook/docs-tools@7.4.0": - version "7.4.0" - resolved "https://registry.yarnpkg.com/@storybook/docs-tools/-/docs-tools-7.4.0.tgz#d9109c9c8ec4e90bb24d1acfcc16834a252618eb" - integrity sha512-DzXmt4JorAOePoS+sjQznf8jLPI9D5mdB1eSXjfvmGBQyyehKTZv5+TXuxYvT3iPN4rW4OPrIrQCSIrbULFdwA== +"@storybook/docs-tools@7.4.6": + version "7.4.6" + resolved "https://registry.yarnpkg.com/@storybook/docs-tools/-/docs-tools-7.4.6.tgz#cbba8dadd13adc7a9a3c792261ca875ff12ae451" + integrity sha512-nZj1L/8WwKWWJ41FW4MaKGajZUtrhnr9UwflRCkQJaWhAKmDfOb5M5TqI93uCOULpFPOm5wpoMBz2IHInQ2Lrg== dependencies: - "@storybook/core-common" "7.4.0" - "@storybook/preview-api" "7.4.0" - "@storybook/types" "7.4.0" + "@storybook/core-common" "7.4.6" + "@storybook/preview-api" "7.4.6" + "@storybook/types" "7.4.6" "@types/doctrine" "^0.0.3" doctrine "^3.0.0" lodash "^4.17.21" @@ -2634,30 +2660,30 @@ resolved "https://registry.yarnpkg.com/@storybook/global/-/global-5.0.0.tgz#b793d34b94f572c1d7d9e0f44fac4e0dbc9572ed" integrity sha512-FcOqPAXACP0I3oJ/ws6/rrPT9WGhu915Cg8D02a9YxLo0DE9zI+a9A5gRGvmQ09fiWPukqI8ZAEoQEdWUKMQdQ== -"@storybook/instrumenter@7.4.0", "@storybook/instrumenter@^7.0.0-beta.0 || ^7.0.0-rc.0 || ^7.0.0": - version "7.4.0" - resolved "https://registry.yarnpkg.com/@storybook/instrumenter/-/instrumenter-7.4.0.tgz#197335f25a45ecdc2c5f458bff1c2481d7ffe08c" - integrity sha512-jZKxLK0lGKxY8LEul6GP7s+PDlNuXT4JU6MnPY9+SVSo23lP0pAOxo/ojV8WTLf48tcoyL3ztSfbYhxnaJvBfw== +"@storybook/instrumenter@7.4.6", "@storybook/instrumenter@^7.0.0-beta.0 || ^7.0.0-rc.0 || ^7.0.0": + version "7.4.6" + resolved "https://registry.yarnpkg.com/@storybook/instrumenter/-/instrumenter-7.4.6.tgz#7a941a31aeae6cc1864689288d66cb282368b602" + integrity sha512-K5atRoVFCl6HEgkSxIbwygpzgE/iROc7BrtJ3z3a7E70sanFr6Jxt6Egu6fz2QkL3ef4EWpXMnle2vhEfG29pA== dependencies: - "@storybook/channels" "7.4.0" - "@storybook/client-logger" "7.4.0" - "@storybook/core-events" "7.4.0" + "@storybook/channels" "7.4.6" + "@storybook/client-logger" "7.4.6" + "@storybook/core-events" "7.4.6" "@storybook/global" "^5.0.0" - "@storybook/preview-api" "7.4.0" + "@storybook/preview-api" "7.4.6" -"@storybook/manager-api@7.4.0": - version "7.4.0" - resolved "https://registry.yarnpkg.com/@storybook/manager-api/-/manager-api-7.4.0.tgz#aee0153df1583459b7e1e64e1d8c46fb49a584c8" - integrity sha512-sBfkkt0eZGTozeKrbzMtWLEOQrgqdk24OUJlkc2IDaucR1CBNjoCMjNeYg7cLDw0rXE8W3W3AdWtJnfsUbLMAQ== +"@storybook/manager-api@7.4.6": + version "7.4.6" + resolved "https://registry.yarnpkg.com/@storybook/manager-api/-/manager-api-7.4.6.tgz#e74bd0a0a983d6b9b7f66dfe0d94d8465f5e7a34" + integrity sha512-inrm3DIbCp8wjXSN/wK6e6i2ysQ/IEmtC7IN0OJ7vdrp+USCooPT448SQTUmVctUGCFmOU3fxXByq8g77oIi7w== dependencies: - "@storybook/channels" "7.4.0" - "@storybook/client-logger" "7.4.0" - "@storybook/core-events" "7.4.0" + "@storybook/channels" "7.4.6" + "@storybook/client-logger" "7.4.6" + "@storybook/core-events" "7.4.6" "@storybook/csf" "^0.1.0" "@storybook/global" "^5.0.0" - "@storybook/router" "7.4.0" - "@storybook/theming" "7.4.0" - "@storybook/types" "7.4.0" + "@storybook/router" "7.4.6" + "@storybook/theming" "7.4.6" + "@storybook/types" "7.4.6" dequal "^2.0.2" lodash "^4.17.21" memoizerific "^1.11.3" @@ -2666,38 +2692,38 @@ telejson "^7.2.0" ts-dedent "^2.0.0" -"@storybook/manager@7.4.0": - version "7.4.0" - resolved "https://registry.yarnpkg.com/@storybook/manager/-/manager-7.4.0.tgz#21a825c9145f56ca6c38d3e9d3546b311a6db14e" - integrity sha512-uOSdPBEBKg8WORUZ5HKHb4KnKcTyA5j5Q8MWy/NBaRd22JR3fQkZiKuHer9WJIOQTU+fb6KDmzhZbCTKg5Euog== +"@storybook/manager@7.4.6": + version "7.4.6" + resolved "https://registry.yarnpkg.com/@storybook/manager/-/manager-7.4.6.tgz#96acb0ab60e05b74947d7895e04efa557fd8892f" + integrity sha512-kA1hUDxpn1i2SO9OinvLvVXDeL4xgJkModp+pbE8IXv4NJWReNq1ecMeQCzPLS3Sil2gnrullQ9uYXsnZ9bxxA== "@storybook/mdx2-csf@^1.0.0": version "1.1.0" resolved "https://registry.yarnpkg.com/@storybook/mdx2-csf/-/mdx2-csf-1.1.0.tgz#97f6df04d0bf616991cc1005a073ac004a7281e5" integrity sha512-TXJJd5RAKakWx4BtpwvSNdgTDkKM6RkXU8GK34S/LhidQ5Pjz3wcnqb0TxEkfhK/ztbP8nKHqXFwLfa2CYkvQw== -"@storybook/node-logger@7.4.0": - version "7.4.0" - resolved "https://registry.yarnpkg.com/@storybook/node-logger/-/node-logger-7.4.0.tgz#808ed8a63e3bc2f97a2d276b4e8ddaa72b79deb0" - integrity sha512-tWSWkYyAvp6SxjIBaTklg29avzv/3Lv4c0dOG2o5tz79PyZkq9v6sQtwLLoI8EJA9Mo8Z08vaJp8NZyDQ9RCuA== +"@storybook/node-logger@7.4.6": + version "7.4.6" + resolved "https://registry.yarnpkg.com/@storybook/node-logger/-/node-logger-7.4.6.tgz#d92eb7e99cb8aefffe67eb63583a21398ce9a0ab" + integrity sha512-djZb310Q27GviDug1XBv0jOEDLCiwr4hhDE0aifCEKZpfNCi/EaP31nbWimFzZwxu4hE/YAPWExzScruR1zw9Q== -"@storybook/postinstall@7.4.0": - version "7.4.0" - resolved "https://registry.yarnpkg.com/@storybook/postinstall/-/postinstall-7.4.0.tgz#81f3bef31b566e26d616f9c3ce567f07ff143cc7" - integrity sha512-ZVBZggqkuj7ysfuHSCd/J7ovWV06zY9uWf+VU+Zw7ZeojDT8QHFrCurPsN7D9679j9vRU1/kSzqvAiStALS33g== +"@storybook/postinstall@7.4.6": + version "7.4.6" + resolved "https://registry.yarnpkg.com/@storybook/postinstall/-/postinstall-7.4.6.tgz#2d5da361fc8baee1866c80b2244815a9e217b843" + integrity sha512-TqI5BucPAGRWrkh55BYiG2/gHLFtC0In4cuu0GsUzB/1jc4i51npLRorCwhmT7r7YliGl5F7JaP0Bni/qHN3Lg== -"@storybook/preset-react-webpack@7.4.0": - version "7.4.0" - resolved "https://registry.yarnpkg.com/@storybook/preset-react-webpack/-/preset-react-webpack-7.4.0.tgz#5d8c1a776fe46ab732a165129da57b89483e1e6b" - integrity sha512-9iZ9lvhRUYtxXmJMqR7txNyatrHryqo6FSKzfpUzmcCySn3d7mu9I6LEPxEir43TkPnBio3W4EsbvtIhjJ5ekA== +"@storybook/preset-react-webpack@7.4.6": + version "7.4.6" + resolved "https://registry.yarnpkg.com/@storybook/preset-react-webpack/-/preset-react-webpack-7.4.6.tgz#11c07fbfc06e87d42da87c9e07f05e5a5b9a1339" + integrity sha512-FfJvlk3bJfg66t06YLiyu+1o/DZN3uNfFP37zv5cJux7TpdmJRV/4m9LKQPJOvcnWBQYem8xX8k5cRS29vdW5g== dependencies: "@babel/preset-flow" "^7.22.5" "@babel/preset-react" "^7.22.5" "@pmmmwh/react-refresh-webpack-plugin" "^0.5.5" - "@storybook/core-webpack" "7.4.0" - "@storybook/docs-tools" "7.4.0" - "@storybook/node-logger" "7.4.0" - "@storybook/react" "7.4.0" + "@storybook/core-webpack" "7.4.6" + "@storybook/docs-tools" "7.4.6" + "@storybook/node-logger" "7.4.6" + "@storybook/react" "7.4.6" "@storybook/react-docgen-typescript-plugin" "1.0.6--canary.9.0c3f3b7.0" "@types/node" "^16.0.0" "@types/semver" "^7.3.4" @@ -2708,17 +2734,17 @@ semver "^7.3.7" webpack "5" -"@storybook/preview-api@7.4.0": - version "7.4.0" - resolved "https://registry.yarnpkg.com/@storybook/preview-api/-/preview-api-7.4.0.tgz#46818910545735bef43965651eef380a6f481f4b" - integrity sha512-ndXO0Nx+eE7ktVE4EqHpQZ0guX7yYBdruDdJ7B739C0+OoPWsJN7jAzUqq0NXaBcYrdaU5gTy+KnWJUt8R+OyA== +"@storybook/preview-api@7.4.6": + version "7.4.6" + resolved "https://registry.yarnpkg.com/@storybook/preview-api/-/preview-api-7.4.6.tgz#a42749ff867216b89849ada6ac0f49f4fa8f03a8" + integrity sha512-byUS/Opt3ytWD4cWz3sNEKw5Yks8MkQgRN+GDSyIomaEAQkLAM0rchPC0MYjwCeUSecV7IIQweNX5RbV4a34BA== dependencies: - "@storybook/channels" "7.4.0" - "@storybook/client-logger" "7.4.0" - "@storybook/core-events" "7.4.0" + "@storybook/channels" "7.4.6" + "@storybook/client-logger" "7.4.6" + "@storybook/core-events" "7.4.6" "@storybook/csf" "^0.1.0" "@storybook/global" "^5.0.0" - "@storybook/types" "7.4.0" + "@storybook/types" "7.4.6" "@types/qs" "^6.9.5" dequal "^2.0.2" lodash "^4.17.21" @@ -2728,10 +2754,10 @@ ts-dedent "^2.0.0" util-deprecate "^1.0.2" -"@storybook/preview@7.4.0": - version "7.4.0" - resolved "https://registry.yarnpkg.com/@storybook/preview/-/preview-7.4.0.tgz#a58756ac9b12ea21f203032eca47991946257b53" - integrity sha512-R4LMTvUrVAbcUetRbAXpY3frkwD0eysqHrByiR73040+ngzDwtZOBAy0JfO3jw3WrWv2dn3kWlao5aEwVc9Exw== +"@storybook/preview@7.4.6": + version "7.4.6" + resolved "https://registry.yarnpkg.com/@storybook/preview/-/preview-7.4.6.tgz#b0d9f5a843d4c7aea8857f6bc5d7253cc04c7c4b" + integrity sha512-2RPXusJ4CTDrIipIKKvbotD7fP0+8VzoFjImunflIrzN9rni+2rq5eMjqlXAaB+77w064zIR4uDUzI9fxsMDeQ== "@storybook/react-docgen-typescript-plugin@1.0.6--canary.9.0c3f3b7.0": version "1.0.6--canary.9.0c3f3b7.0" @@ -2746,33 +2772,33 @@ react-docgen-typescript "^2.2.2" tslib "^2.0.0" -"@storybook/react-dom-shim@7.4.0": - version "7.4.0" - resolved "https://registry.yarnpkg.com/@storybook/react-dom-shim/-/react-dom-shim-7.4.0.tgz#12f137f00f2a209cb49a4084475dd93f23e0678a" - integrity sha512-TLpb8a2hnWJoRLqoXpMADh82BFfRZll6JI2Waf1FjnvJ4SF9eS0zBbxybrjW3lFAHWy2XJi+rwcK8FiPj0iBoQ== +"@storybook/react-dom-shim@7.4.6": + version "7.4.6" + resolved "https://registry.yarnpkg.com/@storybook/react-dom-shim/-/react-dom-shim-7.4.6.tgz#7f7e01dbb6abe104ae140e25b7ed98c347a5fb9f" + integrity sha512-DSq8l9FDocUF1ooVI+TF83pddj1LynE/Hv0/y8XZhc3IgJ/HkuOQuUmfz29ezgfAi9gFYUR8raTIBi3/xdoRmw== "@storybook/react-webpack5@^7.0.27": - version "7.4.0" - resolved "https://registry.yarnpkg.com/@storybook/react-webpack5/-/react-webpack5-7.4.0.tgz#084be7488809b7874654be4e90da3024c4bdf37b" - integrity sha512-dhcWU1gpY3KgbrHJwd10ND+VdOVU07QVeijRnR0qONnruOCXKawjhTGoWdCOWXkWX5IZjjLczMflnmrQ2eSkjA== + version "7.4.6" + resolved "https://registry.yarnpkg.com/@storybook/react-webpack5/-/react-webpack5-7.4.6.tgz#585256d9e44ba24514a7391ac786379f8f81551a" + integrity sha512-OSwf+E2tRcfBmzCH+WwM7JlfEYjg5Womi1yrtotfcjVXAU6ubHOk2G87zsrKLp/TeCOFM2aHohHBTyWUCejQKQ== dependencies: - "@storybook/builder-webpack5" "7.4.0" - "@storybook/preset-react-webpack" "7.4.0" - "@storybook/react" "7.4.0" + "@storybook/builder-webpack5" "7.4.6" + "@storybook/preset-react-webpack" "7.4.6" + "@storybook/react" "7.4.6" "@types/node" "^16.0.0" -"@storybook/react@7.4.0", "@storybook/react@^7.0.27": - version "7.4.0" - resolved "https://registry.yarnpkg.com/@storybook/react/-/react-7.4.0.tgz#18d29aa49f0b784b46613d26a243caf473177403" - integrity sha512-QWsFw/twsNkcWI6brW06sugQQ5dV+fJm4IrEeI28cA4cBHK9G9HKOwCHoXDUWikzZx48XYMpNfs/WyIkuGmEqg== +"@storybook/react@7.4.6", "@storybook/react@^7.0.27": + version "7.4.6" + resolved "https://registry.yarnpkg.com/@storybook/react/-/react-7.4.6.tgz#240a78547add8db69b5d2eeae3d6c45feb79559b" + integrity sha512-w0dVo64baFFPTGpUOWFqkKsu6pQincoymegSNgqaBd5DxEyMDRiRoTWSJHMKE9BwgE8SyWhRkP1ak1mkccSOhQ== dependencies: - "@storybook/client-logger" "7.4.0" - "@storybook/core-client" "7.4.0" - "@storybook/docs-tools" "7.4.0" + "@storybook/client-logger" "7.4.6" + "@storybook/core-client" "7.4.6" + "@storybook/docs-tools" "7.4.6" "@storybook/global" "^5.0.0" - "@storybook/preview-api" "7.4.0" - "@storybook/react-dom-shim" "7.4.0" - "@storybook/types" "7.4.0" + "@storybook/preview-api" "7.4.6" + "@storybook/react-dom-shim" "7.4.6" + "@storybook/types" "7.4.6" "@types/escodegen" "^0.0.6" "@types/estree" "^0.0.51" "@types/node" "^16.0.0" @@ -2788,23 +2814,31 @@ type-fest "~2.19" util-deprecate "^1.0.2" -"@storybook/router@7.4.0": - version "7.4.0" - resolved "https://registry.yarnpkg.com/@storybook/router/-/router-7.4.0.tgz#627f824bfd9cc4653ee84581fc09373ab1463336" - integrity sha512-IATdtFL5C3ryjNQSwaQfrmiOZiVFoVNMevMoBGDC++g0laSW40TGiNK6fUjUDBKuOgbuDt4Svfbl29k21GefEg== +"@storybook/router@7.4.6": + version "7.4.6" + resolved "https://registry.yarnpkg.com/@storybook/router/-/router-7.4.6.tgz#54d3014af26f82d79eae7dc5b0e6a89509b11912" + integrity sha512-Vl1esrHkcHxDKqc+HY7+6JQpBPW3zYvGk0cQ2rxVMhWdLZTAz1hss9DqzN9tFnPyfn0a1Q77EpMySkUrvWKKNQ== dependencies: - "@storybook/client-logger" "7.4.0" + "@storybook/client-logger" "7.4.6" memoizerific "^1.11.3" qs "^6.10.0" -"@storybook/telemetry@7.4.0", "@storybook/telemetry@^7.1.0-alpha.32": - version "7.4.0" - resolved "https://registry.yarnpkg.com/@storybook/telemetry/-/telemetry-7.4.0.tgz#04e47a2d9decf7671273130a9af9d231a8c3d2e8" - integrity sha512-oxCB3kIbpiDWuXEtQhk/j6t1/h0KKWAuvxmcwGPxwhEvj/uNtoM+f1qhoDID9waxNo4AccU9Px+1ZJQ+2ejcDg== +"@storybook/store@7.4.6": + version "7.4.6" + resolved "https://registry.yarnpkg.com/@storybook/store/-/store-7.4.6.tgz#e83d65af2e0c142d8da941defc252ccc18f504fc" + integrity sha512-tlm9rQ+djkYjEyCuEjaUv+c+jVgwnMEF9mZxnOoA6zrzU2g0S/1oE9/MdVLByGbH67U0NuuP0FcvsWLhAOQzjQ== + dependencies: + "@storybook/client-logger" "7.4.6" + "@storybook/preview-api" "7.4.6" + +"@storybook/telemetry@7.4.6", "@storybook/telemetry@^7.1.0-alpha.32": + version "7.4.6" + resolved "https://registry.yarnpkg.com/@storybook/telemetry/-/telemetry-7.4.6.tgz#748c978a188c988d688a50635025c12e7e90f924" + integrity sha512-c8p/C1NIH8EMBviZkBCx8MMDk6rrITJ+b29DEp5MaWSRlklIVyhGiC4RPIRv6sxJwlD41PnqWVFtfu2j2eXLdQ== dependencies: - "@storybook/client-logger" "7.4.0" - "@storybook/core-common" "7.4.0" - "@storybook/csf-tools" "7.4.0" + "@storybook/client-logger" "7.4.6" + "@storybook/core-common" "7.4.6" + "@storybook/csf-tools" "7.4.6" chalk "^4.1.0" detect-package-manager "^2.0.1" fetch-retry "^5.0.2" @@ -2822,25 +2856,24 @@ "@testing-library/user-event" "^13.2.1" ts-dedent "^2.2.0" -"@storybook/theming@7.4.0": - version "7.4.0" - resolved "https://registry.yarnpkg.com/@storybook/theming/-/theming-7.4.0.tgz#f5d9f8f55c41e08c0f50b57d9fb0e159ed595274" - integrity sha512-eLjEf6G3cqlegfutF/iUrec9LrUjKDj7K4ZhGdACWrf7bQcODs99EK62e9/d8GNKr4b+QMSEuM6XNGaqdPnuzQ== +"@storybook/theming@7.4.6": + version "7.4.6" + resolved "https://registry.yarnpkg.com/@storybook/theming/-/theming-7.4.6.tgz#72f7e42a57347d84128cef9abfba5ac1a810118e" + integrity sha512-HW77iJ9ptCMqhoBOYFjRQw7VBap+38fkJGHP5KylEJCyYCgIAm2dEcQmtWpMVYFssSGcb6djfbtAMhYU4TL4Iw== dependencies: "@emotion/use-insertion-effect-with-fallbacks" "^1.0.0" - "@storybook/client-logger" "7.4.0" + "@storybook/client-logger" "7.4.6" "@storybook/global" "^5.0.0" memoizerific "^1.11.3" -"@storybook/types@7.4.0": - version "7.4.0" - resolved "https://registry.yarnpkg.com/@storybook/types/-/types-7.4.0.tgz#71ce550d4d469f6aaf9777fc7432db9fb67f53f9" - integrity sha512-XyzYkmeklywxvElPrIWLczi/PWtEdgTL6ToT3++FVxptsC2LZKS3Ue+sBcQ9xRZhkRemw4HQHwed5EW3dO8yUg== +"@storybook/types@7.4.6": + version "7.4.6" + resolved "https://registry.yarnpkg.com/@storybook/types/-/types-7.4.6.tgz#536f21b82e1f809052b4d09802f99a580e960175" + integrity sha512-6QLXtMVsFZFpzPkdGWsu/iuc8na9dnS67AMOBKm5qCLPwtUJOYkwhMdFRSSeJthLRpzV7JLAL8Kwvl7MFP3QSw== dependencies: - "@storybook/channels" "7.4.0" + "@storybook/channels" "7.4.6" "@types/babel__core" "^7.0.0" "@types/express" "^4.7.0" - "@types/react" "^16.14.34" file-system-cache "2.3.0" "@svgr/babel-plugin-add-jsx-attribute@8.0.0": @@ -2949,78 +2982,84 @@ "@svgr/plugin-jsx" "8.1.0" "@svgr/plugin-svgo" "8.1.0" -"@swc/core-darwin-arm64@1.3.83": - version "1.3.83" - resolved "https://registry.yarnpkg.com/@swc/core-darwin-arm64/-/core-darwin-arm64-1.3.83.tgz#eaeafce9bc9b8fce7d7c3d872b160b7660db8149" - integrity sha512-Plz2IKeveVLivbXTSCC3OZjD2MojyKYllhPrn9RotkDIZEFRYJZtW5/Ik1tJW/2rzu5HVKuGYrDKdScVVTbOxQ== - -"@swc/core-darwin-x64@1.3.83": - version "1.3.83" - resolved "https://registry.yarnpkg.com/@swc/core-darwin-x64/-/core-darwin-x64-1.3.83.tgz#45c2d73843e5e2e34e6e9b8e5fd6e19b419b75ae" - integrity sha512-FBGVg5IPF/8jQ6FbK60iDUHjv0H5+LwfpJHKH6wZnRaYWFtm7+pzYgreLu3NTsm3m7/1a7t0+7KURwBGUaJCCw== - -"@swc/core-linux-arm-gnueabihf@1.3.83": - version "1.3.83" - resolved "https://registry.yarnpkg.com/@swc/core-linux-arm-gnueabihf/-/core-linux-arm-gnueabihf-1.3.83.tgz#3dcee525f6667dd92db0640991a3f03764b76dea" - integrity sha512-EZcsuRYhGkzofXtzwDjuuBC/suiX9s7zeg2YYXOVjWwyebb6BUhB1yad3mcykFQ20rTLO9JUyIaiaMYDHGobqw== - -"@swc/core-linux-arm64-gnu@1.3.83": - version "1.3.83" - resolved "https://registry.yarnpkg.com/@swc/core-linux-arm64-gnu/-/core-linux-arm64-gnu-1.3.83.tgz#3521c8b2f05a5d0348e538af3a5742d607ad6a8d" - integrity sha512-khI41szLHrCD/cFOcN4p2SYvZgHjhhHlcMHz5BksRrDyteSJKu0qtWRZITVom0N/9jWoAleoFhMnFTUs0H8IWA== - -"@swc/core-linux-arm64-musl@1.3.83": - version "1.3.83" - resolved "https://registry.yarnpkg.com/@swc/core-linux-arm64-musl/-/core-linux-arm64-musl-1.3.83.tgz#2c16d47e036176591761187455d4c2c4d984c14c" - integrity sha512-zgT7yNOdbjHcGAwvys79mbfNLK65KBlPJWzeig+Yk7I8TVzmaQge7B6ZS/gwF9/p+8TiLYo/tZ5aF2lqlgdSVw== - -"@swc/core-linux-x64-gnu@1.3.83": - version "1.3.83" - resolved "https://registry.yarnpkg.com/@swc/core-linux-x64-gnu/-/core-linux-x64-gnu-1.3.83.tgz#2eb0222eafeb247b9d1715f106e312566160bca1" - integrity sha512-x+mH0Y3NC/G0YNlFmGi3vGD4VOm7IPDhh+tGrx6WtJp0BsShAbOpxtfU885rp1QweZe4qYoEmGqiEjE2WrPIdA== - -"@swc/core-linux-x64-musl@1.3.83": - version "1.3.83" - resolved "https://registry.yarnpkg.com/@swc/core-linux-x64-musl/-/core-linux-x64-musl-1.3.83.tgz#8c4edd4754410cfe662a112a92f0c71d4fbf070a" - integrity sha512-s5AYhAOmetUwUZwS5g9qb92IYgNHHBGiY2mTLImtEgpAeBwe0LPDj6WrujxCBuZnaS55mKRLLOuiMZE5TpjBNA== - -"@swc/core-win32-arm64-msvc@1.3.83": - version "1.3.83" - resolved "https://registry.yarnpkg.com/@swc/core-win32-arm64-msvc/-/core-win32-arm64-msvc-1.3.83.tgz#153ef6fc6e41e33a47f9f1524fe6bad2fc0158b3" - integrity sha512-yw2rd/KVOGs95lRRB+killLWNaO1dy4uVa8Q3/4wb5txlLru07W1m041fZLzwOg/1Sh0TMjJgGxj0XHGR3ZXhQ== - -"@swc/core-win32-ia32-msvc@1.3.83": - version "1.3.83" - resolved "https://registry.yarnpkg.com/@swc/core-win32-ia32-msvc/-/core-win32-ia32-msvc-1.3.83.tgz#7757277e8618a638cb89e51a1e0959417eec6441" - integrity sha512-POW+rgZ6KWqBpwPGIRd2/3pcf46P+UrKBm4HLt5IwbHvekJ4avIM8ixJa9kK0muJNVJcDpaZgxaU1ELxtJ1j8w== - -"@swc/core-win32-x64-msvc@1.3.83": - version "1.3.83" - resolved "https://registry.yarnpkg.com/@swc/core-win32-x64-msvc/-/core-win32-x64-msvc-1.3.83.tgz#92da90b84a9b88fdd92b6d1256fd6608e668541f" - integrity sha512-CiWQtkFnZElXQUalaHp+Wacw0Jd+24ncRYhqaJ9YKnEQP1H82CxIIuQqLM8IFaLpn5dpY6SgzaeubWF46hjcLA== +"@swc/core-darwin-arm64@1.3.91": + version "1.3.91" + resolved "https://registry.yarnpkg.com/@swc/core-darwin-arm64/-/core-darwin-arm64-1.3.91.tgz#3bb751899cd208be261c48190b75c87171be79b4" + integrity sha512-7kHGiQ1he5khcEeJuHDmLZPM3rRL/ith5OTmV6bOPsoHi46kLeixORW+ts1opC3tC9vu6xbk16xgX0QAJchc1w== + +"@swc/core-darwin-x64@1.3.91": + version "1.3.91" + resolved "https://registry.yarnpkg.com/@swc/core-darwin-x64/-/core-darwin-x64-1.3.91.tgz#9860087421ac258f59c5aaede4b76dfe680bb28d" + integrity sha512-8SpU18FbFpZDVzsHsAwdI1thF/picQGxq9UFxa8W+T9SDnbsqwFJv/6RqKJeJoDV6qFdl2OLjuO0OL7xrp0qnQ== + +"@swc/core-linux-arm-gnueabihf@1.3.91": + version "1.3.91" + resolved "https://registry.yarnpkg.com/@swc/core-linux-arm-gnueabihf/-/core-linux-arm-gnueabihf-1.3.91.tgz#ed321346fea49cc07cdb1217b3ae5c7f3f8ba7ae" + integrity sha512-fOq4Cy8UbwX1yf0WB0d8hWZaIKCnPtPGguRqdXGLfwvhjZ9SIErT6PnmGTGRbQCNCIkOZWHKyTU0r8t2dN3haQ== + +"@swc/core-linux-arm64-gnu@1.3.91": + version "1.3.91" + resolved "https://registry.yarnpkg.com/@swc/core-linux-arm64-gnu/-/core-linux-arm64-gnu-1.3.91.tgz#bdabe0364f2851f7c1446a5da82b2ccc2a021f65" + integrity sha512-fki4ioRP/Esy4vdp8T34RCV+V9dqkRmOt763pf74pdiyFV2dPLXa5lnw/XvR1RTfPGknrYgjEQLCfZlReTryRw== + +"@swc/core-linux-arm64-musl@1.3.91": + version "1.3.91" + resolved "https://registry.yarnpkg.com/@swc/core-linux-arm64-musl/-/core-linux-arm64-musl-1.3.91.tgz#e9e14a8f228bddc68ffc5ab4495000ae8a56ced2" + integrity sha512-XrG+DUUqNtfVLcJ20imby7fpBwQNG5VsEQBzQndSonPyUOa2YkTbBb60YDondfQGDABopuHH8gHN8o2H2/VCnQ== + +"@swc/core-linux-x64-gnu@1.3.91": + version "1.3.91" + resolved "https://registry.yarnpkg.com/@swc/core-linux-x64-gnu/-/core-linux-x64-gnu-1.3.91.tgz#283baa984b370944cec8fe4a3a64a756cc40cccc" + integrity sha512-d11bYhX+YPBr/Frcjc6eVn3C0LuS/9U1Li9EmQ+6s9EpYtYRl2ygSlC8eueLbaiazBnCVYFnc8bU4o0kc5B9sw== + +"@swc/core-linux-x64-musl@1.3.91": + version "1.3.91" + resolved "https://registry.yarnpkg.com/@swc/core-linux-x64-musl/-/core-linux-x64-musl-1.3.91.tgz#18d3e773d150061aa2210149d4abff56ff1465fc" + integrity sha512-2SRp5Dke2P4jCQePkDx9trkkTstnRpZJVw5r3jvYdk0zeO6iC4+ZPvvoWXJLigqQv/fZnIiSUfJ6ssOoaEqTzQ== + +"@swc/core-win32-arm64-msvc@1.3.91": + version "1.3.91" + resolved "https://registry.yarnpkg.com/@swc/core-win32-arm64-msvc/-/core-win32-arm64-msvc-1.3.91.tgz#85a1d2b5c6292c98865ebbc2ad07be23e6869005" + integrity sha512-l9qKXikOxj42UIjbeZpz9xtBmr736jOMqInNP8mVF2/U+ws5sI8zJjcOFFtfis4ru7vWCXhB1wtltdlJYO2vGA== + +"@swc/core-win32-ia32-msvc@1.3.91": + version "1.3.91" + resolved "https://registry.yarnpkg.com/@swc/core-win32-ia32-msvc/-/core-win32-ia32-msvc-1.3.91.tgz#023501d012e3993933e06a9874e5568d38fbdb24" + integrity sha512-+s+52O0QVPmzOgjEe/rcb0AK6q/J7EHKwAyJCu/FaYO9df5ovE0HJjSKP6HAF0dGPO5hkENrXuNGujofUH9vtQ== + +"@swc/core-win32-x64-msvc@1.3.91": + version "1.3.91" + resolved "https://registry.yarnpkg.com/@swc/core-win32-x64-msvc/-/core-win32-x64-msvc-1.3.91.tgz#a70898460d3a91e59d14c4a0a1e772fb00643d11" + integrity sha512-7u9HDQhjUC3Gv43EFW84dZtduWCSa4MgltK+Sp9zEGti6WXqDPu/ESjvDsQEVYTBEMEvZs/xVAXPgLVHorV5nQ== "@swc/core@^1.3.49": - version "1.3.83" - resolved "https://registry.yarnpkg.com/@swc/core/-/core-1.3.83.tgz#2902e0bc5ee9c2fcdfb241c8b993c216bc730fe5" - integrity sha512-PccHDgGQlFjpExgJxH91qA3a4aifR+axCFJ4RieCoiI0m5gURE4nBhxzTBY5YU/YKTBmPO8Gc5Q6inE3+NquWg== + version "1.3.91" + resolved "https://registry.yarnpkg.com/@swc/core/-/core-1.3.91.tgz#4d7978846da67bfbe20486574d07f204d6891805" + integrity sha512-r950d0fdlZ8qbSDyvApn3HyCojiZE8xpgJzQvypeMi32dalYwugdJKWyLB55JIGMRGJ8+lmVvY4MPGkSR3kXgA== dependencies: - "@swc/types" "^0.1.4" + "@swc/counter" "^0.1.1" + "@swc/types" "^0.1.5" optionalDependencies: - "@swc/core-darwin-arm64" "1.3.83" - "@swc/core-darwin-x64" "1.3.83" - "@swc/core-linux-arm-gnueabihf" "1.3.83" - "@swc/core-linux-arm64-gnu" "1.3.83" - "@swc/core-linux-arm64-musl" "1.3.83" - "@swc/core-linux-x64-gnu" "1.3.83" - "@swc/core-linux-x64-musl" "1.3.83" - "@swc/core-win32-arm64-msvc" "1.3.83" - "@swc/core-win32-ia32-msvc" "1.3.83" - "@swc/core-win32-x64-msvc" "1.3.83" - -"@swc/types@^0.1.4": - version "0.1.4" - resolved "https://registry.yarnpkg.com/@swc/types/-/types-0.1.4.tgz#8d647e111dc97a8e2881bf71c2ee2d011698ff10" - integrity sha512-z/G02d+59gyyUb7KYhKi9jOhicek6QD2oMaotUyG+lUkybpXoV49dY9bj7Ah5Q+y7knK2jU67UTX9FyfGzaxQg== + "@swc/core-darwin-arm64" "1.3.91" + "@swc/core-darwin-x64" "1.3.91" + "@swc/core-linux-arm-gnueabihf" "1.3.91" + "@swc/core-linux-arm64-gnu" "1.3.91" + "@swc/core-linux-arm64-musl" "1.3.91" + "@swc/core-linux-x64-gnu" "1.3.91" + "@swc/core-linux-x64-musl" "1.3.91" + "@swc/core-win32-arm64-msvc" "1.3.91" + "@swc/core-win32-ia32-msvc" "1.3.91" + "@swc/core-win32-x64-msvc" "1.3.91" + +"@swc/counter@^0.1.1": + version "0.1.2" + resolved "https://registry.yarnpkg.com/@swc/counter/-/counter-0.1.2.tgz#bf06d0770e47c6f1102270b744e17b934586985e" + integrity sha512-9F4ys4C74eSTEUNndnER3VJ15oru2NumfQxS8geE+f3eB5xvfxpWyqE5XlVnxb/R14uoXi6SLbBwwiDSkv+XEw== + +"@swc/types@^0.1.5": + version "0.1.5" + resolved "https://registry.yarnpkg.com/@swc/types/-/types-0.1.5.tgz#043b731d4f56a79b4897a3de1af35e75d56bc63a" + integrity sha512-myfUej5naTBWnqOCc/MdVOLVjXUXtIA+NpDrDBKJtLLg2shUjBu3cZmB/85RyitKc55+lUUyl7oRfLOvkr2hsw== "@tanstack/match-sorter-utils@^8.7.0": version "8.8.4" @@ -3029,26 +3068,26 @@ dependencies: remove-accents "0.4.2" -"@tanstack/query-core@4.35.0": - version "4.35.0" - resolved "https://registry.yarnpkg.com/@tanstack/query-core/-/query-core-4.35.0.tgz#61e5cf9363b423ed665740c422902e65619eb7da" - integrity sha512-4GMcKQuLZQi6RFBiBZNsLhl+hQGYScRZ5ZoVq8QAzfqz9M7vcGin/2YdSESwl7WaV+Qzsb5CZOAbMBes4lNTnA== +"@tanstack/query-core@4.35.7": + version "4.35.7" + resolved "https://registry.yarnpkg.com/@tanstack/query-core/-/query-core-4.35.7.tgz#31d6f520ab8abedb6024d2d870af8afca764d048" + integrity sha512-PgDJtX75ubFS0WCYFM7DqEoJ4QbxU3S5OH3gJSI40xr7UVVax3/J4CM3XUMOTs+EOT5YGEfssi3tfRVGte4DEw== "@tanstack/react-query-devtools@^4.32.6": - version "4.35.0" - resolved "https://registry.yarnpkg.com/@tanstack/react-query-devtools/-/react-query-devtools-4.35.0.tgz#5c28920f238c01033cb374e4664cd5f0fbccb113" - integrity sha512-tzN0K70idRsqnfLdUcQC3eCrv28kLIAB6/H1zsGdIw7Wmj5VgTxPmpEVc3rHQjKt0LZsvZTLmaLnI6FCI3VUZw== + version "4.35.7" + resolved "https://registry.yarnpkg.com/@tanstack/react-query-devtools/-/react-query-devtools-4.35.7.tgz#782a09b4ebf29fda445b018b462aacc96e864c2f" + integrity sha512-oe3reHNvXBTUvNb9jwLY8EYOXyp8Oq8/c40iwpXBnEkAtJI+RryrCXaGKFTivg72roPcYHzKILQHR9jbX8sn1Q== dependencies: "@tanstack/match-sorter-utils" "^8.7.0" superjson "^1.10.0" use-sync-external-store "^1.2.0" "@tanstack/react-query@^4.32.6": - version "4.35.0" - resolved "https://registry.yarnpkg.com/@tanstack/react-query/-/react-query-4.35.0.tgz#6286d2706e69384accc4d6b9ff8e4becd427db74" - integrity sha512-LLYDNnM9ewYHgjm2rzhk4KG/puN2rdoqCUD+N9+V7SwlsYwJk5ypX58rpkoZAhFyZ+KmFUJ7Iv2lIEOoUqydIg== + version "4.35.7" + resolved "https://registry.yarnpkg.com/@tanstack/react-query/-/react-query-4.35.7.tgz#35a1db50156411b2a9eb68a020bae416948939db" + integrity sha512-0MankquP/6EOM2ATfEov6ViiKemey5uTbjGlFMX1xGotwNaqC76YKDMJdHumZupPbZcZPWAeoPGEHQmVKIKoOQ== dependencies: - "@tanstack/query-core" "4.35.0" + "@tanstack/query-core" "4.35.7" use-sync-external-store "^1.2.0" "@testing-library/dom@^8.3.0": @@ -3066,9 +3105,9 @@ pretty-format "^27.0.2" "@testing-library/dom@^9.0.0": - version "9.3.1" - resolved "https://registry.yarnpkg.com/@testing-library/dom/-/dom-9.3.1.tgz#8094f560e9389fb973fe957af41bf766937a9ee9" - integrity sha512-0DGPd9AR3+iDTjGoMpxIkAsUihHZ3Ai6CneU6bRRrffXMgzCdlNk43jTrD2/5LT6CBb3MWTP8v510JzYtahD2w== + version "9.3.3" + resolved "https://registry.yarnpkg.com/@testing-library/dom/-/dom-9.3.3.tgz#108c23a5b0ef51121c26ae92eb3179416b0434f5" + integrity sha512-fB0R+fa3AUqbLHWyxXa2kGVtf1Fe1ZZFr0Zp6AIbIAzXb2mKbEXl+PCQNUOaq5lbTab5tfctfXRNsWXxa2f7Aw== dependencies: "@babel/code-frame" "^7.10.4" "@babel/runtime" "^7.12.5" @@ -3111,9 +3150,9 @@ "@babel/runtime" "^7.12.5" "@testing-library/user-event@^14.4.3": - version "14.4.3" - resolved "https://registry.yarnpkg.com/@testing-library/user-event/-/user-event-14.4.3.tgz#af975e367743fa91989cd666666aec31a8f50591" - integrity sha512-kCUc5MEwaEMakkO5x7aoD+DLi02ehmEM2QCGWvNqAS1dV/fAvORWEjnjsEIvml59M7Y5kCkWN6fCCyPOe8OL6Q== + version "14.5.1" + resolved "https://registry.yarnpkg.com/@testing-library/user-event/-/user-event-14.5.1.tgz#27337d72046d5236b32fd977edee3f74c71d332f" + integrity sha512-UCcUKrUYGj7ClomOo2SpNVvx4/fkd/2BbIHDCle8A0ax+P3bU7yJwDBDrS6ZwdTMARWTGODX1hEsCcO+7beJjg== "@tootallnate/once@2": version "2.0.0" @@ -3126,14 +3165,14 @@ integrity sha512-L7z9BgrNEcYyUYtF+HaEfiS5ebkh9jXqbszz7pC0hRBPaatV0XjSD3+eHrpqFemQfgwiFF0QPIarnIihIDn7OA== "@types/aria-query@^5.0.1": - version "5.0.1" - resolved "https://registry.yarnpkg.com/@types/aria-query/-/aria-query-5.0.1.tgz#3286741fb8f1e1580ac28784add4c7a1d49bdfbc" - integrity sha512-XTIieEY+gvJ39ChLcB4If5zHtPxt3Syj5rgZR+e1ctpmK8NjPf0zFqsz4JpLJT0xla9GFDKjy8Cpu331nrmE1Q== + version "5.0.2" + resolved "https://registry.yarnpkg.com/@types/aria-query/-/aria-query-5.0.2.tgz#6f1225829d89794fd9f891989c9ce667422d7f64" + integrity sha512-PHKZuMN+K5qgKIWhBodXzQslTo5P+K/6LqeKXS6O/4liIDdZqaX5RXrCK++LAw+y/nptN48YmUMFiQHRSWYwtQ== "@types/babel__core@^7.0.0", "@types/babel__core@^7.1.14": - version "7.20.1" - resolved "https://registry.yarnpkg.com/@types/babel__core/-/babel__core-7.20.1.tgz#916ecea274b0c776fec721e333e55762d3a9614b" - integrity sha512-aACu/U/omhdk15O4Nfb+fHgH/z3QsfQzpnvRZhYhThms83ZnAOZz7zZAWO7mn2yyNQaA4xTO8GLK3uqFU4bYYw== + version "7.20.2" + resolved "https://registry.yarnpkg.com/@types/babel__core/-/babel__core-7.20.2.tgz#215db4f4a35d710256579784a548907237728756" + integrity sha512-pNpr1T1xLUc2l3xJKuPtsEky3ybxN3m4fJkknfIpTCTfIZCDW57oAg+EfCgIIp2rvCe0Wn++/FfodDS4YXxBwA== dependencies: "@babel/parser" "^7.20.7" "@babel/types" "^7.20.7" @@ -3142,39 +3181,39 @@ "@types/babel__traverse" "*" "@types/babel__generator@*": - version "7.6.4" - resolved "https://registry.yarnpkg.com/@types/babel__generator/-/babel__generator-7.6.4.tgz#1f20ce4c5b1990b37900b63f050182d28c2439b7" - integrity sha512-tFkciB9j2K755yrTALxD44McOrk+gfpIpvC3sxHjRawj6PfnQxrse4Clq5y/Rq+G3mrBurMax/lG8Qn2t9mSsg== + version "7.6.5" + resolved "https://registry.yarnpkg.com/@types/babel__generator/-/babel__generator-7.6.5.tgz#281f4764bcbbbc51fdded0f25aa587b4ce14da95" + integrity sha512-h9yIuWbJKdOPLJTbmSpPzkF67e659PbQDba7ifWm5BJ8xTv+sDmS7rFmywkWOvXedGTivCdeGSIIX8WLcRTz8w== dependencies: "@babel/types" "^7.0.0" "@types/babel__template@*": - version "7.4.1" - resolved "https://registry.yarnpkg.com/@types/babel__template/-/babel__template-7.4.1.tgz#3d1a48fd9d6c0edfd56f2ff578daed48f36c8969" - integrity sha512-azBFKemX6kMg5Io+/rdGT0dkGreboUVR0Cdm3fz9QJWpaQGJRQXl7C+6hOTCZcMll7KFyEQpgbYI2lHdsS4U7g== + version "7.4.2" + resolved "https://registry.yarnpkg.com/@types/babel__template/-/babel__template-7.4.2.tgz#843e9f1f47c957553b0c374481dc4772921d6a6b" + integrity sha512-/AVzPICMhMOMYoSx9MoKpGDKdBRsIXMNByh1PXSZoa+v6ZoLa8xxtsT/uLQ/NJm0XVAWl/BvId4MlDeXJaeIZQ== dependencies: "@babel/parser" "^7.1.0" "@babel/types" "^7.0.0" "@types/babel__traverse@*", "@types/babel__traverse@^7.0.6": - version "7.20.1" - resolved "https://registry.yarnpkg.com/@types/babel__traverse/-/babel__traverse-7.20.1.tgz#dd6f1d2411ae677dcb2db008c962598be31d6acf" - integrity sha512-MitHFXnhtgwsGZWtT68URpOvLN4EREih1u3QtQiN4VdAxWKRVvGCSvw/Qth0M0Qq3pJpnGOu5JaM/ydK7OGbqg== + version "7.20.2" + resolved "https://registry.yarnpkg.com/@types/babel__traverse/-/babel__traverse-7.20.2.tgz#4ddf99d95cfdd946ff35d2b65c978d9c9bf2645d" + integrity sha512-ojlGK1Hsfce93J0+kn3H5R73elidKUaZonirN33GSmgTUMpzI/MIFfSpF3haANe3G1bEBS9/9/QEqwTzwqFsKw== dependencies: "@babel/types" "^7.20.7" "@types/body-parser@*": - version "1.19.2" - resolved "https://registry.yarnpkg.com/@types/body-parser/-/body-parser-1.19.2.tgz#aea2059e28b7658639081347ac4fab3de166e6f0" - integrity sha512-ALYone6pm6QmwZoAgeyNksccT9Q4AWZQ6PvfwR37GT6r6FWUPguq6sUmNGSMV2Wr761oQoBxwGGa6DR5o1DC9g== + version "1.19.3" + resolved "https://registry.yarnpkg.com/@types/body-parser/-/body-parser-1.19.3.tgz#fb558014374f7d9e56c8f34bab2042a3a07d25cd" + integrity sha512-oyl4jvAfTGX9Bt6Or4H9ni1Z447/tQuxnZsytsCaExKlmJiU8sFgnIBRzJUpKwB5eWn9HuBYlUlVA74q/yN0eQ== dependencies: "@types/connect" "*" "@types/node" "*" "@types/bonjour@^3.5.9": - version "3.5.10" - resolved "https://registry.yarnpkg.com/@types/bonjour/-/bonjour-3.5.10.tgz#0f6aadfe00ea414edc86f5d106357cda9701e275" - integrity sha512-p7ienRMiS41Nu2/igbJxxLDWrSZ0WxM8UQgCeO9KhoVF7cOVFkrKsiDr1EsJIla8vV3oEEjGcz11jc5yimhzZw== + version "3.5.11" + resolved "https://registry.yarnpkg.com/@types/bonjour/-/bonjour-3.5.11.tgz#fbaa46a1529ea5c5e46cde36e4be6a880db55b84" + integrity sha512-isGhjmBtLIxdHBDl2xGwUzEM8AOyOvWsADWq7rqirdi/ZQoHnLWErHvsThcEzTX8juDRiZtzp2Qkv5bgNh6mAg== dependencies: "@types/node" "*" @@ -3206,9 +3245,9 @@ "@types/node" "*" "@types/debug@^4.1.7": - version "4.1.8" - resolved "https://registry.yarnpkg.com/@types/debug/-/debug-4.1.8.tgz#cef723a5d0a90990313faec2d1e22aee5eecb317" - integrity sha512-/vPO1EPOs306Cvhwv7KfVfYvOJqA/S/AXjaHQiJboCZzcNDb+TIJFN9/2C9DZ//ijSKWioNyUxD792QmDJ+HKQ== + version "4.1.9" + resolved "https://registry.yarnpkg.com/@types/debug/-/debug-4.1.9.tgz#906996938bc672aaf2fb8c0d3733ae1dda05b005" + integrity sha512-8Hz50m2eoS56ldRlepxSBa6PWEVCtzUo/92HgLc2qTMnotJNIm7xP+UZhyWoYsyOdd5dxZ+NZLb24rsKyFs2ow== dependencies: "@types/ms" "*" @@ -3223,14 +3262,14 @@ integrity sha512-w5jZ0ee+HaPOaX25X2/2oGR/7rgAQSYII7X7pp0m9KgBfMP7uKfMfTvcpl5Dj+eDBbpxKGiqE+flqDr6XTd2RA== "@types/ejs@^3.1.1": - version "3.1.2" - resolved "https://registry.yarnpkg.com/@types/ejs/-/ejs-3.1.2.tgz#75d277b030bc11b3be38c807e10071f45ebc78d9" - integrity sha512-ZmiaE3wglXVWBM9fyVC17aGPkLo/UgaOjEiI2FXQfyczrCefORPxIe+2dVmnmk3zkVIbizjrlQzmPGhSYGXG5g== + version "3.1.3" + resolved "https://registry.yarnpkg.com/@types/ejs/-/ejs-3.1.3.tgz#ad91d1dd6e24fb60bbf96c534bce58b95eef9b57" + integrity sha512-mv5T/JI/bu+pbfz1o+TLl1NF0NIBbjS0Vl6Ppz1YY9DkXfzZT0lelXpfS5i3ZS3U/p90it7uERQpBvLYoK8e4A== "@types/emscripten@^1.39.6": - version "1.39.7" - resolved "https://registry.yarnpkg.com/@types/emscripten/-/emscripten-1.39.7.tgz#3025183ea56e12bf4d096aadc48ce74ca051233d" - integrity sha512-tLqYV94vuqDrXh515F/FOGtBcRMTPGvVV1LzLbtYDcQmmhtpf/gLYf+hikBbQk8MzOHNz37wpFfJbYAuSn8HqA== + version "1.39.8" + resolved "https://registry.yarnpkg.com/@types/emscripten/-/emscripten-1.39.8.tgz#5e3e81fb37397345cc7c12d189bd72c7d0095af8" + integrity sha512-Rk0HKcMXFUuqT32k1kXHZWgxiMvsyYsmlnjp0rLKa0MMoqXLE3T9dogDBTRfuc3SAsXu97KD3k4SKR1lHqd57w== "@types/escodegen@^0.0.6": version "0.0.6" @@ -3238,25 +3277,25 @@ integrity sha512-AjwI4MvWx3HAOaZqYsjKWyEObT9lcVV0Y0V8nXo6cXzN8ZiMxVhf6F3d/UNvXVGKrEzL/Dluc5p+y9GkzlTWig== "@types/eslint-scope@^3.7.3": - version "3.7.4" - resolved "https://registry.yarnpkg.com/@types/eslint-scope/-/eslint-scope-3.7.4.tgz#37fc1223f0786c39627068a12e94d6e6fc61de16" - integrity sha512-9K4zoImiZc3HlIp6AVUDE4CWYx22a+lhSZMYNpbjW04+YF0KWj4pJXnEMjdnFTiQibFFmElcsasJXDbdI/EPhA== + version "3.7.5" + resolved "https://registry.yarnpkg.com/@types/eslint-scope/-/eslint-scope-3.7.5.tgz#e28b09dbb1d9d35fdfa8a884225f00440dfc5a3e" + integrity sha512-JNvhIEyxVW6EoMIFIvj93ZOywYFatlpu9deeH6eSx6PE3WHYvHaQtmHmQeNw7aA81bYGBPPQqdtBm6b1SsQMmA== dependencies: "@types/eslint" "*" "@types/estree" "*" "@types/eslint@*": - version "8.44.2" - resolved "https://registry.yarnpkg.com/@types/eslint/-/eslint-8.44.2.tgz#0d21c505f98a89b8dd4d37fa162b09da6089199a" - integrity sha512-sdPRb9K6iL5XZOmBubg8yiFp5yS/JdUDQsq5e6h95km91MCYMuvp7mh1fjPEYUhvHepKpZOjnEaMBR4PxjWDzg== + version "8.44.3" + resolved "https://registry.yarnpkg.com/@types/eslint/-/eslint-8.44.3.tgz#96614fae4875ea6328f56de38666f582d911d962" + integrity sha512-iM/WfkwAhwmPff3wZuPLYiHX18HI24jU8k1ZSH7P8FHwxTjZ2P6CoX2wnF43oprR+YXJM6UUxATkNvyv/JHd+g== dependencies: "@types/estree" "*" "@types/json-schema" "*" "@types/estree@*", "@types/estree@^1.0.0": - version "1.0.1" - resolved "https://registry.yarnpkg.com/@types/estree/-/estree-1.0.1.tgz#aa22750962f3bf0e79d753d3cc067f010c95f194" - integrity sha512-LG4opVs2ANWZ1TJoKc937iMmNstM/d0ae1vNbnBvBhqCSezgVUOzcLCqbI5elV8Vy6WKwKjaqR+zO9VKirBBCA== + version "1.0.2" + resolved "https://registry.yarnpkg.com/@types/estree/-/estree-1.0.2.tgz#ff02bc3dc8317cd668dfec247b750ba1f1d62453" + integrity sha512-VeiPZ9MMwXjO32/Xu7+OwflfmeoRwkE/qzndw42gGtgJwZopBnzy2gD//NN1+go1mADzkDcqf/KnFRSjTJ8xJA== "@types/estree@^0.0.51": version "0.0.51" @@ -3264,9 +3303,9 @@ integrity sha512-CuPgU6f3eT/XgKKPqKd/gLZV1Xmvf1a2R5POBOGQa6uv82xpls89HU5zKeVoyR8XzHd1RGNOlQlvUe3CFkjWNQ== "@types/express-serve-static-core@*", "@types/express-serve-static-core@^4.17.33": - version "4.17.36" - resolved "https://registry.yarnpkg.com/@types/express-serve-static-core/-/express-serve-static-core-4.17.36.tgz#baa9022119bdc05a4adfe740ffc97b5f9360e545" - integrity sha512-zbivROJ0ZqLAtMzgzIUC4oNqDG9iF0lSsAqpOD9kbs5xcIM3dTiyuHvBc7R8MtWBp3AAWGaovJa+wzWPjLYW7Q== + version "4.17.37" + resolved "https://registry.yarnpkg.com/@types/express-serve-static-core/-/express-serve-static-core-4.17.37.tgz#7e4b7b59da9142138a2aaa7621f5abedce8c7320" + integrity sha512-ZohaCYTgGFcOP7u6aJOhY9uIZQgZ2vxC2yWoArY+FeDXlqeH66ZVBjgvg+RLVAS/DWNq4Ap9ZXu1+SUQiiWYMg== dependencies: "@types/node" "*" "@types/qs" "*" @@ -3274,9 +3313,9 @@ "@types/send" "*" "@types/express@*", "@types/express@^4.17.13", "@types/express@^4.7.0": - version "4.17.17" - resolved "https://registry.yarnpkg.com/@types/express/-/express-4.17.17.tgz#01d5437f6ef9cfa8668e616e13c2f2ac9a491ae4" - integrity sha512-Q4FmmuLGBG58btUnfS1c1r/NQdlp3DMfGDGig8WhfpA2YRUtEkxAjkZb0yvplJGYdF1fsQ81iMDcH24sSCNC/Q== + version "4.17.18" + resolved "https://registry.yarnpkg.com/@types/express/-/express-4.17.18.tgz#efabf5c4495c1880df1bdffee604b143b29c4a95" + integrity sha512-Sxv8BSLLgsBYmcnGdGjjEjqET2U+AKAdCRODmMiq02FgjwuV75Ut85DRpvFjyw/Mk0vgUOliGRU0UUmuuZHByQ== dependencies: "@types/body-parser" "*" "@types/express-serve-static-core" "^4.17.33" @@ -3289,16 +3328,16 @@ integrity sha512-frsJrz2t/CeGifcu/6uRo4b+SzAwT4NYCVPu1GN8IB9XTzrpPkGuV0tmh9mN+/L0PklAlsC3u5Fxt0ju00LXIw== "@types/graceful-fs@^4.1.3": - version "4.1.6" - resolved "https://registry.yarnpkg.com/@types/graceful-fs/-/graceful-fs-4.1.6.tgz#e14b2576a1c25026b7f02ede1de3b84c3a1efeae" - integrity sha512-Sig0SNORX9fdW+bQuTEovKj3uHcUL6LQKbCrrqb1X7J6/ReAbhCXRAhc+SMejhLELFj2QcyuxmUooZ4bt5ReSw== + version "4.1.7" + resolved "https://registry.yarnpkg.com/@types/graceful-fs/-/graceful-fs-4.1.7.tgz#30443a2e64fd51113bc3e2ba0914d47109695e2a" + integrity sha512-MhzcwU8aUygZroVwL2jeYk6JisJrPl/oov/gsgGCue9mkgl9wjGbzReYQClxiUgFDnib9FuHqTndccKeZKxTRw== dependencies: "@types/node" "*" "@types/hoist-non-react-statics@*": - version "3.3.1" - resolved "https://registry.yarnpkg.com/@types/hoist-non-react-statics/-/hoist-non-react-statics-3.3.1.tgz#1124aafe5118cb591977aeb1ceaaed1070eb039f" - integrity sha512-iMIqiko6ooLrTh1joXodJK5X9xeEALT1kM5G3ZLhD3hszxBdIEd5C75U834D9mLcINgD4OyZf5uQXjkuYydWvA== + version "3.3.2" + resolved "https://registry.yarnpkg.com/@types/hoist-non-react-statics/-/hoist-non-react-statics-3.3.2.tgz#dc1e9ded53375d37603c479cc12c693b0878aa2a" + integrity sha512-YIQtIg4PKr7ZyqNPZObpxfHsHEmuB8dXCxd6qVcGuQVDK2bpsF7bYNnBJ4Nn7giuACZg+WewExgrtAJ3XnA4Xw== dependencies: "@types/react" "*" hoist-non-react-statics "^3.3.0" @@ -3309,14 +3348,14 @@ integrity sha512-oh/6byDPnL1zeNXFrDXFLyZjkr1MsBG667IM792caf1L2UPOOMf65NFzjUH/ltyfwjAGfs1rsX1eftK0jC/KIg== "@types/http-errors@*": - version "2.0.1" - resolved "https://registry.yarnpkg.com/@types/http-errors/-/http-errors-2.0.1.tgz#20172f9578b225f6c7da63446f56d4ce108d5a65" - integrity sha512-/K3ds8TRAfBvi5vfjuz8y6+GiAYBZ0x4tXv1Av6CWBWn0IlADc+ZX9pMq7oU0fNQPnBwIZl3rmeLp6SBApbxSQ== + version "2.0.2" + resolved "https://registry.yarnpkg.com/@types/http-errors/-/http-errors-2.0.2.tgz#a86e00bbde8950364f8e7846687259ffcd96e8c2" + integrity sha512-lPG6KlZs88gef6aD85z3HNkztpj7w2R7HmR3gygjfXCQmsLloWNARFkMuzKiiY8FGdh1XDpgBdrSf4aKDiA7Kg== "@types/http-proxy@^1.17.8": - version "1.17.11" - resolved "https://registry.yarnpkg.com/@types/http-proxy/-/http-proxy-1.17.11.tgz#0ca21949a5588d55ac2b659b69035c84bd5da293" - integrity sha512-HC8G7c1WmaF2ekqpnFq626xd3Zz0uvaqFmBJNRZCGEZCXkvSdJoNFn/8Ygbd9fKNQj8UzLdCETaI0UWPAjK7IA== + version "1.17.12" + resolved "https://registry.yarnpkg.com/@types/http-proxy/-/http-proxy-1.17.12.tgz#86e849e9eeae0362548803c37a0a1afc616bd96b" + integrity sha512-kQtujO08dVtQ2wXAuSFfk9ASy3sug4+ogFR8Kd8UgP8PEuc1/G/8yjYRmp//PcDNJEUKOza/MrQu15bouEUCiw== dependencies: "@types/node" "*" @@ -3326,23 +3365,23 @@ integrity sha512-z/QT1XN4K4KYuslS23k62yDIDLwLFkzxOuMplDtObz0+y7VqJCaO2o+SPwHCvLFZh7xazvvoor2tA/hPz9ee7g== "@types/istanbul-lib-report@*": - version "3.0.0" - resolved "https://registry.yarnpkg.com/@types/istanbul-lib-report/-/istanbul-lib-report-3.0.0.tgz#c14c24f18ea8190c118ee7562b7ff99a36552686" - integrity sha512-plGgXAPfVKFoYfa9NpYDAkseG+g6Jr294RqeqcqDixSbU34MZVJRi/P+7Y8GDpzkEwLaGZZOpKIEmeVZNtKsrg== + version "3.0.1" + resolved "https://registry.yarnpkg.com/@types/istanbul-lib-report/-/istanbul-lib-report-3.0.1.tgz#412e0725ef41cde73bfa03e0e833eaff41e0fd63" + integrity sha512-gPQuzaPR5h/djlAv2apEG1HVOyj1IUs7GpfMZixU0/0KXT3pm64ylHuMUI1/Akh+sq/iikxg6Z2j+fcMDXaaTQ== dependencies: "@types/istanbul-lib-coverage" "*" "@types/istanbul-reports@^3.0.0": - version "3.0.1" - resolved "https://registry.yarnpkg.com/@types/istanbul-reports/-/istanbul-reports-3.0.1.tgz#9153fe98bba2bd565a63add9436d6f0d7f8468ff" - integrity sha512-c3mAZEuK0lvBp8tmuL74XRKn1+y2dcwOUpH7x4WrF6gk1GIgiluDRgMYQtw2OFcBvAJWlt6ASU3tSqxp0Uu0Aw== + version "3.0.2" + resolved "https://registry.yarnpkg.com/@types/istanbul-reports/-/istanbul-reports-3.0.2.tgz#edc8e421991a3b4df875036d381fc0a5a982f549" + integrity sha512-kv43F9eb3Lhj+lr/Hn6OcLCs/sSM8bt+fIaP11rCYngfV6NVjzWXJ17owQtDQTL9tQ8WSLUrGsSJ6rJz0F1w1A== dependencies: "@types/istanbul-lib-report" "*" "@types/jest@*", "@types/jest@^29.5.3": - version "29.5.4" - resolved "https://registry.yarnpkg.com/@types/jest/-/jest-29.5.4.tgz#9d0a16edaa009a71e6a71a999acd582514dab566" - integrity sha512-PhglGmhWeD46FYOVLt3X7TiWjzwuVGW9wG/4qocPevXMjCmrIc5b6db9WjeGE4QYVpUAWMDv3v0IiBwObY289A== + version "29.5.5" + resolved "https://registry.yarnpkg.com/@types/jest/-/jest-29.5.5.tgz#727204e06228fe24373df9bae76b90f3e8236a2a" + integrity sha512-ebylz2hnsWR9mYvmBFbXJXr+33UPc4+ZdxyDXh5w0FlPBTfCVN3wPL+kuOiQt3xvrK419v7XWeAs+AeOksafXg== dependencies: expect "^29.0.0" pretty-format "^29.0.0" @@ -3362,9 +3401,9 @@ parse5 "^7.0.0" "@types/json-schema@*", "@types/json-schema@^7.0.8", "@types/json-schema@^7.0.9": - version "7.0.12" - resolved "https://registry.yarnpkg.com/@types/json-schema/-/json-schema-7.0.12.tgz#d70faba7039d5fca54c83c7dbab41051d2b6f6cb" - integrity sha512-Hr5Jfhc9eYOQNPYO5WLDq/n4jqijdHNlDXjuAQkkt+mWdQR+XJToOHrsD4cPaMXpn6KO7y2+wM8AZEs8VpBLVA== + version "7.0.13" + resolved "https://registry.yarnpkg.com/@types/json-schema/-/json-schema-7.0.13.tgz#02c24f4363176d2d18fc8b70b9f3c54aba178a85" + integrity sha512-RbSSoHliUbnXj3ny0CNFOoxrIDV6SUGyStHsvDqosw6CkdPV8TtWGlfecuK4ToyMEAql6pzNxgCFKanovUzlgQ== "@types/json5@^0.0.29": version "0.0.29" @@ -3372,62 +3411,62 @@ integrity sha512-dRLjCWHYg4oaA77cxO64oO+7JwCwnIzkZPdrrC71jQmQtlhM556pwKo5bUzqvZndkVbeFLIIi+9TC40JNF5hNQ== "@types/lodash@^4.14.167": - version "4.14.198" - resolved "https://registry.yarnpkg.com/@types/lodash/-/lodash-4.14.198.tgz#4d27465257011aedc741a809f1269941fa2c5d4c" - integrity sha512-trNJ/vtMZYMLhfN45uLq4ShQSw0/S7xCTLLVM+WM1rmFpba/VS42jVUgaO3w/NOLiWR/09lnYk0yMaA/atdIsg== + version "4.14.199" + resolved "https://registry.yarnpkg.com/@types/lodash/-/lodash-4.14.199.tgz#c3edb5650149d847a277a8961a7ad360c474e9bf" + integrity sha512-Vrjz5N5Ia4SEzWWgIVwnHNEnb1UE1XMkvY5DGXrAeOGE9imk0hgTHh5GyDjLDJi9OTCn9oo9dXH1uToK1VRfrg== "@types/mdx@^2.0.0": - version "2.0.7" - resolved "https://registry.yarnpkg.com/@types/mdx/-/mdx-2.0.7.tgz#c7482e995673e01b83f8e96df83b3843ea76401f" - integrity sha512-BG4tyr+4amr3WsSEmHn/fXPqaCba/AYZ7dsaQTiavihQunHSIxk+uAtqsjvicNpyHN6cm+B9RVrUOtW9VzIKHw== + version "2.0.8" + resolved "https://registry.yarnpkg.com/@types/mdx/-/mdx-2.0.8.tgz#585229ff7057ab30c5e4a23fe126858881d818e5" + integrity sha512-r7/zWe+f9x+zjXqGxf821qz++ld8tp6Z4jUS6qmPZUXH6tfh4riXOhAqb12tWGWAevCFtMt1goLWkQMqIJKpsA== "@types/mime-types@^2.1.0": - version "2.1.1" - resolved "https://registry.yarnpkg.com/@types/mime-types/-/mime-types-2.1.1.tgz#d9ba43490fa3a3df958759adf69396c3532cf2c1" - integrity sha512-vXOTGVSLR2jMw440moWTC7H19iUyLtP3Z1YTj7cSsubOICinjMxFeb/V57v9QdyyPGbbWolUFSSmSiRSn94tFw== + version "2.1.2" + resolved "https://registry.yarnpkg.com/@types/mime-types/-/mime-types-2.1.2.tgz#b4fe6996d2f32975b6603b26b4e4b3b6c92c9901" + integrity sha512-q9QGHMGCiBJCHEvd4ZLdasdqXv570agPsUW0CeIm/B8DzhxsYMerD0l3IlI+EQ1A2RWHY2mmM9x1YIuuWxisCg== "@types/mime@*": - version "3.0.1" - resolved "https://registry.yarnpkg.com/@types/mime/-/mime-3.0.1.tgz#5f8f2bca0a5863cb69bc0b0acd88c96cb1d4ae10" - integrity sha512-Y4XFY5VJAuw0FgAqPNd6NNoV44jbq9Bz2L7Rh/J6jLTiHBSBJa9fxqQIvkIld4GsoDOcCbvzOUAbLPsSKKg+uA== + version "3.0.2" + resolved "https://registry.yarnpkg.com/@types/mime/-/mime-3.0.2.tgz#c1ae807f13d308ee7511a5b81c74f327028e66e8" + integrity sha512-Wj+fqpTLtTbG7c0tH47dkahefpLKEbB+xAZuLq7b4/IDHPl/n6VoXcyUQ2bypFlbSwvCr0y+bD4euTTqTJsPxQ== "@types/mime@^1": - version "1.3.2" - resolved "https://registry.yarnpkg.com/@types/mime/-/mime-1.3.2.tgz#93e25bf9ee75fe0fd80b594bc4feb0e862111b5a" - integrity sha512-YATxVxgRqNH6nHEIsvg6k2Boc1JHI9ZbH5iWFFv/MTkchz3b1ieGDa5T0a9RznNdI0KhVbdbWSN+KWWrQZRxTw== + version "1.3.3" + resolved "https://registry.yarnpkg.com/@types/mime/-/mime-1.3.3.tgz#bbe64987e0eb05de150c305005055c7ad784a9ce" + integrity sha512-Ys+/St+2VF4+xuY6+kDIXGxbNRO0mesVg0bbxEfB97Od1Vjpjx9KD1qxs64Gcb3CWPirk9Xe+PT4YiiHQ9T+eg== "@types/minimist@^1.2.2": - version "1.2.2" - resolved "https://registry.yarnpkg.com/@types/minimist/-/minimist-1.2.2.tgz#ee771e2ba4b3dc5b372935d549fd9617bf345b8c" - integrity sha512-jhuKLIRrhvCPLqwPcx6INqmKeiA5EWrsCOPhrlFSrbrmU4ZMPjj5Ul/oLCMDO98XRUIwVm78xICz4EPCektzeQ== + version "1.2.3" + resolved "https://registry.yarnpkg.com/@types/minimist/-/minimist-1.2.3.tgz#dd249cef80c6fff2ba6a0d4e5beca913e04e25f8" + integrity sha512-ZYFzrvyWUNhaPomn80dsMNgMeXxNWZBdkuG/hWlUvXvbdUH8ZERNBGXnU87McuGcWDsyzX2aChCv/SVN348k3A== "@types/ms@*": - version "0.7.31" - resolved "https://registry.yarnpkg.com/@types/ms/-/ms-0.7.31.tgz#31b7ca6407128a3d2bbc27fe2d21b345397f6197" - integrity sha512-iiUgKzV9AuaEkZqkOLDIvlQiL6ltuZd9tGcW3gwpnX8JbuiuhFlEGmmFXEXkN50Cvq7Os88IY2v0dkDqXYWVgA== + version "0.7.32" + resolved "https://registry.yarnpkg.com/@types/ms/-/ms-0.7.32.tgz#f6cd08939ae3ad886fcc92ef7f0109dacddf61ab" + integrity sha512-xPSg0jm4mqgEkNhowKgZFBNtwoEwF6gJ4Dhww+GFpm3IgtNseHQZ5IqdNwnquZEoANxyDAKDRAdVo4Z72VvD/g== "@types/node-fetch@^2.6.4": - version "2.6.4" - resolved "https://registry.yarnpkg.com/@types/node-fetch/-/node-fetch-2.6.4.tgz#1bc3a26de814f6bf466b25aeb1473fa1afe6a660" - integrity sha512-1ZX9fcN4Rvkvgv4E6PAY5WXUFWFcRWxZa3EW83UjycOB9ljJCedb2CupIP4RZMEwF/M3eTcCihbBRgwtGbg5Rg== + version "2.6.6" + resolved "https://registry.yarnpkg.com/@types/node-fetch/-/node-fetch-2.6.6.tgz#b72f3f4bc0c0afee1c0bc9cff68e041d01e3e779" + integrity sha512-95X8guJYhfqiuVVhRFxVQcf4hW/2bCuoPwDasMf/531STFoNoWTT7YDnWdXHEZKqAGUigmpG31r2FE70LwnzJw== dependencies: "@types/node" "*" - form-data "^3.0.0" + form-data "^4.0.0" "@types/node@*": - version "20.6.0" - resolved "https://registry.yarnpkg.com/@types/node/-/node-20.6.0.tgz#9d7daa855d33d4efec8aea88cd66db1c2f0ebe16" - integrity sha512-najjVq5KN2vsH2U/xyh2opaSEz6cZMR2SetLIlxlj08nOcmPOemJmUK2o4kUzfLqfrWE0PIrNeE16XhYDd3nqg== + version "20.8.2" + resolved "https://registry.yarnpkg.com/@types/node/-/node-20.8.2.tgz#d76fb80d87d0d8abfe334fc6d292e83e5524efc4" + integrity sha512-Vvycsc9FQdwhxE3y3DzeIxuEJbWGDsnrxvMADzTDF/lcdR9/K+AQIeAghTQsHtotg/q0j3WEOYS/jQgSdWue3w== "@types/node@^16.0.0": - version "16.18.50" - resolved "https://registry.yarnpkg.com/@types/node/-/node-16.18.50.tgz#93003cf0251a2ecd26dad6dc757168d648519805" - integrity sha512-OiDU5xRgYTJ203v4cprTs0RwOCd5c5Zjv+K5P8KSqfiCsB1W3LcamTUMcnQarpq5kOYbhHfSOgIEJvdPyb5xyw== + version "16.18.57" + resolved "https://registry.yarnpkg.com/@types/node/-/node-16.18.57.tgz#1ba31c0e5c403aab90a3b7826576e6782ded779b" + integrity sha512-piPoDozdPaX1hNWFJQzzgWqE40gh986VvVx/QO9RU4qYRE55ld7iepDVgZ3ccGUw0R4wge0Oy1dd+3xOQNkkUQ== "@types/normalize-package-data@^2.4.0": - version "2.4.1" - resolved "https://registry.yarnpkg.com/@types/normalize-package-data/-/normalize-package-data-2.4.1.tgz#d3357479a0fdfdd5907fe67e17e0a85c906e1301" - integrity sha512-Gj7cI7z+98M282Tqmp2K5EIsoouUEzbBJhQQzDE3jSIRk6r9gsz0oUokqIUR4u1R3dMHo0pDHM7sNOHyhulypw== + version "2.4.2" + resolved "https://registry.yarnpkg.com/@types/normalize-package-data/-/normalize-package-data-2.4.2.tgz#9b0e3e8533fe5024ad32d6637eb9589988b6fdca" + integrity sha512-lqa4UEhhv/2sjjIQgjX8B+RBjj47eo0mzGasklVJ78UKGQY1r0VpB9XHDaZZO9qzEFDdy4MrXLuEaSmPrPSe/A== "@types/parse-json@^4.0.0": version "4.0.0" @@ -3440,9 +3479,9 @@ integrity sha512-VjID5MJb1eGKthz2qUerWT8+R4b9N+CHvGCzg9fn4kWZgaF9AhdYikQio3R7wV8YY1NsQKPaCwKz1Yff+aHNUQ== "@types/prop-types@*": - version "15.7.5" - resolved "https://registry.yarnpkg.com/@types/prop-types/-/prop-types-15.7.5.tgz#5f19d2b85a98e9558036f6a3cacc8819420f05cf" - integrity sha512-JCB8C6SnDoQf0cNycqd/35A7MjcnK+ZTqE7judS6o7utxUCg6imJg3QK2qzHKszlTjcj2cn+NwMB2i96ubpj7w== + version "15.7.8" + resolved "https://registry.yarnpkg.com/@types/prop-types/-/prop-types-15.7.8.tgz#805eae6e8f41bd19e88917d2ea200dc992f405d3" + integrity sha512-kMpQpfZKSCBqltAJwskgePRaYRFukDkm1oItcAbC3gNELR20XIBcN9VRgg4+m8DKsTfkWeA4m4Imp4DDuWy7FQ== "@types/qs@*", "@types/qs@^6.9.5": version "6.9.8" @@ -3450,30 +3489,21 @@ integrity sha512-u95svzDlTysU5xecFNTgfFG5RUWu1A9P0VzgpcIiGZA9iraHOdSzcxMxQ55DyeRaGCSxQi7LxXDI4rzq/MYfdg== "@types/range-parser@*": - version "1.2.4" - resolved "https://registry.yarnpkg.com/@types/range-parser/-/range-parser-1.2.4.tgz#cd667bcfdd025213aafb7ca5915a932590acdcdc" - integrity sha512-EEhsLsD6UsDM1yFhAvy0Cjr6VwmpMWqFBCb9w07wVugF7w9nfajxLuVmngTIpgS6svCnm6Vaw+MZhoDCKnOfsw== + version "1.2.5" + resolved "https://registry.yarnpkg.com/@types/range-parser/-/range-parser-1.2.5.tgz#38bd1733ae299620771bd414837ade2e57757498" + integrity sha512-xrO9OoVPqFuYyR/loIHjnbvvyRZREYKLjxV4+dY6v3FQR3stQ9ZxIGkaclF7YhI9hfjpuTbu14hZEy94qKLtOA== "@types/react-dom@^18.0.0", "@types/react-dom@^18.2.6": - version "18.2.7" - resolved "https://registry.yarnpkg.com/@types/react-dom/-/react-dom-18.2.7.tgz#67222a08c0a6ae0a0da33c3532348277c70abb63" - integrity sha512-GRaAEriuT4zp9N4p1i8BDBYmEyfo+xQ3yHjJU4eiK5NDa1RmUZG+unZABUTK4/Ox/M+GaHwb6Ow8rUITrtjszA== + version "18.2.8" + resolved "https://registry.yarnpkg.com/@types/react-dom/-/react-dom-18.2.8.tgz#338f1b0a646c9f10e0a97208c1d26b9f473dffd6" + integrity sha512-bAIvO5lN/U8sPGvs1Xm61rlRHHaq5rp5N3kp9C+NJ/Q41P8iqjkXSu0+/qu8POsjH9pNWb0OYabFez7taP7omw== dependencies: "@types/react" "*" "@types/react@*", "@types/react@>=16", "@types/react@^18.2.14": - version "18.2.21" - resolved "https://registry.yarnpkg.com/@types/react/-/react-18.2.21.tgz#774c37fd01b522d0b91aed04811b58e4e0514ed9" - integrity sha512-neFKG/sBAwGxHgXiIxnbm3/AAVQ/cMRS93hvBpg8xYRbeQSPVABp9U2bRnPf0iI4+Ucdv3plSxKK+3CW2ENJxA== - dependencies: - "@types/prop-types" "*" - "@types/scheduler" "*" - csstype "^3.0.2" - -"@types/react@^16.14.34": - version "16.14.46" - resolved "https://registry.yarnpkg.com/@types/react/-/react-16.14.46.tgz#42ac91aece416176e6b6127cd9ec9e381ea67e16" - integrity sha512-Am4pyXMrr6cWWw/TN3oqHtEZl0j+G6Up/O8m65+xF/3ZaUgkv1GAtTPWw4yNRmH0HJXmur6xKCKoMo3rBGynuw== + version "18.2.24" + resolved "https://registry.yarnpkg.com/@types/react/-/react-18.2.24.tgz#3c7d68c02e0205a472f04abe4a0c1df35d995c05" + integrity sha512-Ee0Jt4sbJxMu1iDcetZEIKQr99J1Zfb6D4F3qfUWoR1JpInkY1Wdg4WwCyBjL257D0+jGqSl1twBjV8iCaC0Aw== dependencies: "@types/prop-types" "*" "@types/scheduler" "*" @@ -3485,50 +3515,50 @@ integrity sha512-wWKOClTTiizcZhXnPY4wikVAwmdYHp8q6DmC+EJUzAMsycb7HB32Kh9RN4+0gExjmPmZSAQjgURXIGATPegAvA== "@types/scheduler@*": - version "0.16.3" - resolved "https://registry.yarnpkg.com/@types/scheduler/-/scheduler-0.16.3.tgz#cef09e3ec9af1d63d2a6cc5b383a737e24e6dcf5" - integrity sha512-5cJ8CB4yAx7BH1oMvdU0Jh9lrEXyPkar6F9G/ERswkCuvP4KQZfZkSjcMbAICCpQTN4OuZn8tz0HiKv9TGZgrQ== + version "0.16.4" + resolved "https://registry.yarnpkg.com/@types/scheduler/-/scheduler-0.16.4.tgz#fedc3e5b15c26dc18faae96bf1317487cb3658cf" + integrity sha512-2L9ifAGl7wmXwP4v3pN4p2FLhD0O1qsJpvKmNin5VA8+UvNVb447UDaAEV6UdrkA+m/Xs58U1RFps44x6TFsVQ== "@types/semver@^7.3.12", "@types/semver@^7.3.4": - version "7.5.1" - resolved "https://registry.yarnpkg.com/@types/semver/-/semver-7.5.1.tgz#0480eeb7221eb9bc398ad7432c9d7e14b1a5a367" - integrity sha512-cJRQXpObxfNKkFAZbJl2yjWtJCqELQIdShsogr1d2MilP8dKD9TE/nEKHkJgUNHdGKCQaf9HbIynuV2csLGVLg== + version "7.5.3" + resolved "https://registry.yarnpkg.com/@types/semver/-/semver-7.5.3.tgz#9a726e116beb26c24f1ccd6850201e1246122e04" + integrity sha512-OxepLK9EuNEIPxWNME+C6WwbRAOOI2o2BaQEGzz5Lu2e4Z5eDnEo+/aVEDMIXywoJitJ7xWd641wrGLZdtwRyw== "@types/send@*": - version "0.17.1" - resolved "https://registry.yarnpkg.com/@types/send/-/send-0.17.1.tgz#ed4932b8a2a805f1fe362a70f4e62d0ac994e301" - integrity sha512-Cwo8LE/0rnvX7kIIa3QHCkcuF21c05Ayb0ZfxPiv0W8VRiZiNW/WuRupHKpqqGVGf7SUA44QSOUKaEd9lIrd/Q== + version "0.17.2" + resolved "https://registry.yarnpkg.com/@types/send/-/send-0.17.2.tgz#af78a4495e3c2b79bfbdac3955fdd50e03cc98f2" + integrity sha512-aAG6yRf6r0wQ29bkS+x97BIs64ZLxeE/ARwyS6wrldMm3C1MdKwCcnnEwMC1slI8wuxJOpiUH9MioC0A0i+GJw== dependencies: "@types/mime" "^1" "@types/node" "*" "@types/serve-index@^1.9.1": - version "1.9.1" - resolved "https://registry.yarnpkg.com/@types/serve-index/-/serve-index-1.9.1.tgz#1b5e85370a192c01ec6cec4735cf2917337a6278" - integrity sha512-d/Hs3nWDxNL2xAczmOVZNj92YZCS6RGxfBPjKzuu/XirCgXdpKEb88dYNbrYGint6IVWLNP+yonwVAuRC0T2Dg== + version "1.9.2" + resolved "https://registry.yarnpkg.com/@types/serve-index/-/serve-index-1.9.2.tgz#cb26e775678a8526b73a5d980a147518740aaecd" + integrity sha512-asaEIoc6J+DbBKXtO7p2shWUpKacZOoMBEGBgPG91P8xhO53ohzHWGCs4ScZo5pQMf5ukQzVT9fhX1WzpHihig== dependencies: "@types/express" "*" "@types/serve-static@*", "@types/serve-static@^1.13.10": - version "1.15.2" - resolved "https://registry.yarnpkg.com/@types/serve-static/-/serve-static-1.15.2.tgz#3e5419ecd1e40e7405d34093f10befb43f63381a" - integrity sha512-J2LqtvFYCzaj8pVYKw8klQXrLLk7TBZmQ4ShlcdkELFKGwGMfevMLneMMRkMgZxotOD9wg497LpC7O8PcvAmfw== + version "1.15.3" + resolved "https://registry.yarnpkg.com/@types/serve-static/-/serve-static-1.15.3.tgz#2cfacfd1fd4520bbc3e292cca432d5e8e2e3ee61" + integrity sha512-yVRvFsEMrv7s0lGhzrggJjNOSmZCdgCjw9xWrPr/kNNLp6FaDfMC1KaYl3TSJ0c58bECwNBMoQrZJ8hA8E1eFg== dependencies: "@types/http-errors" "*" "@types/mime" "*" "@types/node" "*" "@types/set-cookie-parser@^2.4.0": - version "2.4.3" - resolved "https://registry.yarnpkg.com/@types/set-cookie-parser/-/set-cookie-parser-2.4.3.tgz#963a7437ed026c6adec2a71478ed6e3df2837160" - integrity sha512-7QhnH7bi+6KAhBB+Auejz1uV9DHiopZqu7LfR/5gZZTkejJV5nYeZZpgfFoE0N8aDsXuiYpfKyfyMatCwQhyTQ== + version "2.4.4" + resolved "https://registry.yarnpkg.com/@types/set-cookie-parser/-/set-cookie-parser-2.4.4.tgz#3c36c9147960cca0fc7c508aacb18ea41f6b5003" + integrity sha512-xCfTC/eL/GmvMC24b42qJpYSTdCIBwWcfskDF80ztXtnU6pKXyvuZP2EConb2K9ps0s7gMhCa0P1foy7wiItMA== dependencies: "@types/node" "*" "@types/sockjs@^0.3.33": - version "0.3.33" - resolved "https://registry.yarnpkg.com/@types/sockjs/-/sockjs-0.3.33.tgz#570d3a0b99ac995360e3136fd6045113b1bd236f" - integrity sha512-f0KEEe05NvUnat+boPTZ0dgaLZ4SfSouXUgv5noUiefG2ajgKjmETo9ZJyuqsl7dfl2aHlLJUiki6B4ZYldiiw== + version "0.3.34" + resolved "https://registry.yarnpkg.com/@types/sockjs/-/sockjs-0.3.34.tgz#43e10e549b36d2ba2589278f00f81b5d7ccda167" + integrity sha512-R+n7qBFnm/6jinlteC9DBL5dGiDGjWAvjo4viUanpnc/dG1y7uDoacXPIQ/PQEg1fI912SMHIa014ZjRpvDw4g== dependencies: "@types/node" "*" @@ -3538,18 +3568,18 @@ integrity sha512-Hl219/BT5fLAaz6NDkSuhzasy49dwQS/DSdu4MdggFB8zcXv7vflBI3xp7FEmkmdDkBUI2bPUNeMttp2knYdxw== "@types/styled-components@^5.1.26": - version "5.1.27" - resolved "https://registry.yarnpkg.com/@types/styled-components/-/styled-components-5.1.27.tgz#1915eedba0d52f26ba5a383fccb7fe50e2e71106" - integrity sha512-oY9c1SdztRRF0QDQdwXEenfAjGN4WGUkaMpx5hvdTbYYqw01qoY2GrHi+kAR6SVofynzD6KbGoF5ITP0zh5pvg== + version "5.1.28" + resolved "https://registry.yarnpkg.com/@types/styled-components/-/styled-components-5.1.28.tgz#3b86c4d373924ff6976de788843cab445d9ab15b" + integrity sha512-nu0VKNybkjvUqJAXWtRqKd7j3iRUl8GbYSTvZNuIBJcw/HUp1Y4QUXNLlj7gcnRV/t784JnHAlvRnSnE3nPbJA== dependencies: "@types/hoist-non-react-statics" "*" "@types/react" "*" csstype "^3.0.2" "@types/stylis@^4.0.2": - version "4.2.0" - resolved "https://registry.yarnpkg.com/@types/stylis/-/stylis-4.2.0.tgz#199a3f473f0c3a6f6e4e1b17cdbc967f274bdc6b" - integrity sha512-n4sx2bqL0mW1tvDf/loQ+aMX7GQD3lc3fkCMC55VFNDu/vBOabO+LTIeXKM14xK0ppk5TUGcWRjiSpIlUpghKw== + version "4.2.1" + resolved "https://registry.yarnpkg.com/@types/stylis/-/stylis-4.2.1.tgz#867fcb0f81719d9ecef533fdda03e32083b959f6" + integrity sha512-OSaMrXUKxVigGlKRrET39V2xdhzlztQ9Aqumn1WbCBKHOi9ry7jKSd7rkyj0GzmWaU960Rd+LpOFpLfx5bMQAg== "@types/testing-library__jest-dom@^5.9.1": version "5.14.9" @@ -3559,9 +3589,9 @@ "@types/jest" "*" "@types/tough-cookie@*": - version "4.0.2" - resolved "https://registry.yarnpkg.com/@types/tough-cookie/-/tough-cookie-4.0.2.tgz#6286b4c7228d58ab7866d19716f3696e03a09397" - integrity sha512-Q5vtl1W5ue16D+nIaW8JWebSSraJVlK+EthKn7e7UcD4KWsaSJ8BqGPXNaPghgtcn/fhvrN17Tv8ksUsQpiplw== + version "4.0.3" + resolved "https://registry.yarnpkg.com/@types/tough-cookie/-/tough-cookie-4.0.3.tgz#3d06b6769518450871fbc40770b7586334bdfd90" + integrity sha512-THo502dA5PzG/sfQH+42Lw3fvmYkceefOspdCwpHRul8ik2Jv1K8I5OZz1AT3/rs46kwgMCe9bSBmDLYkkOMGg== "@types/unist@^2.0.0": version "2.0.8" @@ -3569,28 +3599,28 @@ integrity sha512-d0XxK3YTObnWVp6rZuev3c49+j4Lo8g4L1ZRm9z5L0xpoZycUPshHgczK5gsUMaZOstjVYYi09p5gYvUtfChYw== "@types/ws@^8.5.5": - version "8.5.5" - resolved "https://registry.yarnpkg.com/@types/ws/-/ws-8.5.5.tgz#af587964aa06682702ee6dcbc7be41a80e4b28eb" - integrity sha512-lwhs8hktwxSjf9UaZ9tG5M03PGogvFaH8gUgLNbN9HKIg0dvv6q+gkSuJ8HN4/VbyxkuLzCjlN7GquQ0gUJfIg== + version "8.5.6" + resolved "https://registry.yarnpkg.com/@types/ws/-/ws-8.5.6.tgz#e9ad51f0ab79b9110c50916c9fcbddc36d373065" + integrity sha512-8B5EO9jLVCy+B58PLHvLDuOD8DRVMgQzq8d55SjLCOn9kqGyqOvy27exVaTio1q1nX5zLu8/6N0n2ThSxOM6tg== dependencies: "@types/node" "*" "@types/yargs-parser@*": - version "21.0.0" - resolved "https://registry.yarnpkg.com/@types/yargs-parser/-/yargs-parser-21.0.0.tgz#0c60e537fa790f5f9472ed2776c2b71ec117351b" - integrity sha512-iO9ZQHkZxHn4mSakYV0vFHAVDyEOIJQrV2uZ06HxEPcx+mt8swXoZHIbaaJ2crJYFfErySgktuTZ3BeLz+XmFA== + version "21.0.1" + resolved "https://registry.yarnpkg.com/@types/yargs-parser/-/yargs-parser-21.0.1.tgz#07773d7160494d56aa882d7531aac7319ea67c3b" + integrity sha512-axdPBuLuEJt0c4yI5OZssC19K2Mq1uKdrfZBzuxLvaztgqUtFYZUNw7lETExPYJR9jdEoIg4mb7RQKRQzOkeGQ== "@types/yargs@^16.0.0": - version "16.0.5" - resolved "https://registry.yarnpkg.com/@types/yargs/-/yargs-16.0.5.tgz#12cc86393985735a283e387936398c2f9e5f88e3" - integrity sha512-AxO/ADJOBFJScHbWhq2xAhlWP24rY4aCEG/NFaMvbT3X2MgRsLjhjQwsn0Zi5zn0LG9jUhCCZMeX9Dkuw6k+vQ== + version "16.0.6" + resolved "https://registry.yarnpkg.com/@types/yargs/-/yargs-16.0.6.tgz#cc0c63684d68d23498cf0b5f32aa4c3fb437c638" + integrity sha512-oTP7/Q13GSPrgcwEwdlnkoZSQ1Hg9THe644qq8PG6hhJzjZ3qj1JjEFPIwWV/IXVs5XGIVqtkNOS9kh63WIJ+A== dependencies: "@types/yargs-parser" "*" "@types/yargs@^17.0.8": - version "17.0.24" - resolved "https://registry.yarnpkg.com/@types/yargs/-/yargs-17.0.24.tgz#b3ef8d50ad4aa6aecf6ddc97c580a00f5aa11902" - integrity sha512-6i0aC7jV6QzQB8ne1joVZ0eSFIstHsCrobmOtghM11yGlH0j43FKL2UhWdELkyps0zuf7qVTUVCCR+tgSlyLLw== + version "17.0.26" + resolved "https://registry.yarnpkg.com/@types/yargs/-/yargs-17.0.26.tgz#388e5002a8b284ad7b4599ba89920a6d74d8d79a" + integrity sha512-Y3vDy2X6zw/ZCumcwLpdhM5L7jmyGpmBCTYMHDLqT2IKVMYRRLdv6ZakA+wxhra6Z/3bwhNbNl9bDGXaFU+6rw== dependencies: "@types/yargs-parser" "*" @@ -3903,7 +3933,7 @@ acorn@^7.4.1: resolved "https://registry.yarnpkg.com/acorn/-/acorn-7.4.1.tgz#feaed255973d2e77555b83dbc08851a6c63520fa" integrity sha512-nQyp0o1/mNdbTO1PO6kHkwSrmgZ0MT/jCCpNiwbUjGoRN4dlBhqJtoQuCnEOKzgTVwg0ZWiCoQy6SxMebQVh8A== -acorn@^8.1.0, acorn@^8.7.1, acorn@^8.8.1, acorn@^8.8.2, acorn@^8.9.0: +acorn@^8.1.0, acorn@^8.10.0, acorn@^8.7.1, acorn@^8.8.1, acorn@^8.8.2, acorn@^8.9.0: version "8.10.0" resolved "https://registry.yarnpkg.com/acorn/-/acorn-8.10.0.tgz#8be5b3907a67221a81ab23c7889c4c5526b62ec5" integrity sha512-F0SAmZ8iUtS//m8DmCTA0jlh6TDKkHQyK6xc6V4KDTyZKA9dnvX9/3sRTVQrWm79glUAZbnmmNcdYwUIHWVybw== @@ -4140,7 +4170,7 @@ array.prototype.tosorted@^1.1.1: es-shim-unscopables "^1.0.0" get-intrinsic "^1.2.1" -arraybuffer.prototype.slice@^1.0.1: +arraybuffer.prototype.slice@^1.0.2: version "1.0.2" resolved "https://registry.yarnpkg.com/arraybuffer.prototype.slice/-/arraybuffer.prototype.slice-1.0.2.tgz#98bd561953e3e74bb34938e77647179dfe6e9f12" integrity sha512-yMBKppFur/fbHu9/6USUe03bZ4knMYiwFBcyiaXB8Go0qNehwX6inYPzK9U0NeQvGxKthcmHcaR8P5MStSRBAw== @@ -4227,12 +4257,12 @@ babel-core@^7.0.0-bridge.0: resolved "https://registry.yarnpkg.com/babel-core/-/babel-core-7.0.0-bridge.0.tgz#95a492ddd90f9b4e9a4a1da14eb335b87b634ece" integrity sha512-poPX9mZH/5CSanm50Q+1toVci6pv5KSRv/5TWCwtzQS5XEwn40BcCrgIeMFWP9CKKIniKXNxoIOnOq4VVlGXhg== -babel-jest@^29.6.4: - version "29.6.4" - resolved "https://registry.yarnpkg.com/babel-jest/-/babel-jest-29.6.4.tgz#98dbc45d1c93319c82a8ab4a478b670655dd2585" - integrity sha512-meLj23UlSLddj6PC+YTOFRgDAtjnZom8w/ACsrx0gtPtv5cJZk0A5Unk5bV4wixD7XaPCN1fQvpww8czkZURmw== +babel-jest@^29.7.0: + version "29.7.0" + resolved "https://registry.yarnpkg.com/babel-jest/-/babel-jest-29.7.0.tgz#f4369919225b684c56085998ac63dbd05be020d5" + integrity sha512-BrvGY3xZSwEcCzKvKsCi2GgHqDqsYkOP4/by5xCgIwGXQxIEh+8ew3gmrE1y7XRR6LHZIj6yLYnUi/mm2KXKBg== dependencies: - "@jest/transform" "^29.6.4" + "@jest/transform" "^29.7.0" "@types/babel__core" "^7.1.14" babel-plugin-istanbul "^6.1.1" babel-preset-jest "^29.6.3" @@ -4289,12 +4319,12 @@ babel-plugin-polyfill-corejs2@^0.4.5: semver "^6.3.1" babel-plugin-polyfill-corejs3@^0.8.3: - version "0.8.3" - resolved "https://registry.yarnpkg.com/babel-plugin-polyfill-corejs3/-/babel-plugin-polyfill-corejs3-0.8.3.tgz#b4f719d0ad9bb8e0c23e3e630c0c8ec6dd7a1c52" - integrity sha512-z41XaniZL26WLrvjy7soabMXrfPWARN25PZoriDEiLMxAp50AUW3t35BGQUMg5xK3UrpVTtagIDklxYa+MhiNA== + version "0.8.4" + resolved "https://registry.yarnpkg.com/babel-plugin-polyfill-corejs3/-/babel-plugin-polyfill-corejs3-0.8.4.tgz#1fac2b1dcef6274e72b3c72977ed8325cb330591" + integrity sha512-9l//BZZsPR+5XjyJMPtZSK4jv0BsTO1zDac2GC6ygx9WLGlcsnRd1Co0B2zT5fF5Ic6BZy+9m3HNZ3QcOeDKfg== dependencies: "@babel/helper-define-polyfill-provider" "^0.4.2" - core-js-compat "^3.31.0" + core-js-compat "^3.32.2" babel-plugin-polyfill-regenerator@^0.5.2: version "0.5.2" @@ -4474,15 +4504,15 @@ browserify-zlib@^0.1.4: dependencies: pako "~0.2.0" -browserslist@^4.14.5, browserslist@^4.21.10, browserslist@^4.21.9: - version "4.21.10" - resolved "https://registry.yarnpkg.com/browserslist/-/browserslist-4.21.10.tgz#dbbac576628c13d3b2231332cb2ec5a46e015bb0" - integrity sha512-bipEBdZfVH5/pwrvqc+Ub0kUPVfGUhlKxbvfD+z1BDnPEO/X98ruXGA1WP5ASpAFKan7Qr6j736IacbZQuAlKQ== +browserslist@^4.14.5, browserslist@^4.21.9, browserslist@^4.22.1: + version "4.22.1" + resolved "https://registry.yarnpkg.com/browserslist/-/browserslist-4.22.1.tgz#ba91958d1a59b87dab6fed8dfbcb3da5e2e9c619" + integrity sha512-FEVc202+2iuClEhZhrWy6ZiAcRLvNMyYcxZ8raemul1DYVOVdFsbqckWLdsixQZCpJlwe77Z3UTalE7jsjnKfQ== dependencies: - caniuse-lite "^1.0.30001517" - electron-to-chromium "^1.4.477" + caniuse-lite "^1.0.30001541" + electron-to-chromium "^1.4.535" node-releases "^2.0.13" - update-browserslist-db "^1.0.11" + update-browserslist-db "^1.0.13" bs-logger@0.x: version "0.2.6" @@ -4590,10 +4620,10 @@ camelize@^1.0.0: resolved "https://registry.yarnpkg.com/camelize/-/camelize-1.0.1.tgz#89b7e16884056331a35d6b5ad064332c91daa6c3" integrity sha512-dU+Tx2fsypxTgtLoE36npi3UqcjSSMNYfkqgmoEhtZrraP5VWq0K7FkWVTYa8eMPtnU/G2txVsfdCJTn9uzpuQ== -caniuse-lite@^1.0.30001517: - version "1.0.30001532" - resolved "https://registry.yarnpkg.com/caniuse-lite/-/caniuse-lite-1.0.30001532.tgz#c6a4d5d2da6d2b967f0ee5e12e7f680db6ad2fca" - integrity sha512-FbDFnNat3nMnrROzqrsg314zhqN5LGQ1kyyMk2opcrwGbVGpHRhgCWtAgD5YJUqNAiQ+dklreil/c3Qf1dfCTw== +caniuse-lite@^1.0.30001541: + version "1.0.30001543" + resolved "https://registry.yarnpkg.com/caniuse-lite/-/caniuse-lite-1.0.30001543.tgz#478a3e9dddbb353c5ab214b0ecb0dbed529ed1d8" + integrity sha512-qxdO8KPWPQ+Zk6bvNpPeQIOH47qZSYdFZd6dXQzb2KzhnSXju4Kd7H1PkSJx6NICSMgo/IhRZRhhfPTHYpJUCA== case-sensitive-paths-webpack-plugin@^2.4.0: version "2.4.0" @@ -4666,9 +4696,9 @@ chrome-trace-event@^1.0.2: integrity sha512-p3KULyQg4S7NIHixdwbGX+nFHkoBiA4YQmyWtjb8XngSKV124nJmRysgAeujbUVb15vh+RvFUfCPqU7rXk+hZg== ci-info@^3.2.0: - version "3.8.0" - resolved "https://registry.yarnpkg.com/ci-info/-/ci-info-3.8.0.tgz#81408265a5380c929f0bc665d62256628ce9ef91" - integrity sha512-eXTggHWSooYhq49F2opQhuHWgzucfF2YgODK4e1566GQs5BIfP30B0oenwBJHfWxAs2fyPB1s7Mg949zLf61Yw== + version "3.9.0" + resolved "https://registry.yarnpkg.com/ci-info/-/ci-info-3.9.0.tgz#4279a62028a7b1f262f3473fc9605f5e218c59b4" + integrity sha512-NIxF55hv4nSqQswkAeiOi1r83xy8JldOFDTWiug55KBu9Jnblncd2U6ViHmYgHf01TPZS77NJBhBMKdWj9HQMQ== cjs-module-lexer@^1.0.0: version "1.2.3" @@ -4695,9 +4725,9 @@ cli-cursor@^3.1.0: restore-cursor "^3.1.0" cli-spinners@^2.5.0: - version "2.9.0" - resolved "https://registry.yarnpkg.com/cli-spinners/-/cli-spinners-2.9.0.tgz#5881d0ad96381e117bbe07ad91f2008fe6ffd8db" - integrity sha512-4/aL9X3Wh0yiMQlE+eeRhWP6vclO3QRtw1JHKIT0FFUs5FjpFmESqtMvYZ0+lbzBw900b95mS0hohy+qn2VK/g== + version "2.9.1" + resolved "https://registry.yarnpkg.com/cli-spinners/-/cli-spinners-2.9.1.tgz#9c0b9dad69a6d47cbb4333c14319b060ed395a35" + integrity sha512-jHgecW0pxkonBJdrKsqxgRX9AcG+u/5k0Q7WPDfi8AogLAdwxEkyYYNWwZ5GvVFoFx2uiY1eNcSK00fh+1+FyQ== cli-table3@^0.6.1: version "0.6.3" @@ -4893,11 +4923,6 @@ content-type@~1.0.4: resolved "https://registry.yarnpkg.com/content-type/-/content-type-1.0.5.tgz#8b773162656d1d1086784c8f23a54ce6d73d7918" integrity sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA== -convert-source-map@^1.1.0, convert-source-map@^1.6.0, convert-source-map@^1.7.0: - version "1.9.0" - resolved "https://registry.yarnpkg.com/convert-source-map/-/convert-source-map-1.9.0.tgz#7faae62353fb4213366d0ca98358d22e8368b05f" - integrity sha512-ASFBup0Mz1uyiIjANan1jzLQami9z1PoYSZCiiYW2FczPbenXc45FZdBZLzOT+r6+iciuEModtmCti+hjaAk0A== - convert-source-map@^2.0.0: version "2.0.0" resolved "https://registry.yarnpkg.com/convert-source-map/-/convert-source-map-2.0.0.tgz#4b560f649fc4e918dd0ab75cf4961e8bc882d82a" @@ -4937,17 +4962,17 @@ copy-webpack-plugin@^11.0.0: schema-utils "^4.0.0" serialize-javascript "^6.0.0" -core-js-compat@^3.31.0: - version "3.32.2" - resolved "https://registry.yarnpkg.com/core-js-compat/-/core-js-compat-3.32.2.tgz#8047d1a8b3ac4e639f0d4f66d4431aa3b16e004c" - integrity sha512-+GjlguTDINOijtVRUxrQOv3kfu9rl+qPNdX2LTbJ/ZyVTuxK+ksVSAGX1nHstu4hrv1En/uPTtWgq2gI5wt4AQ== +core-js-compat@^3.31.0, core-js-compat@^3.32.2: + version "3.33.0" + resolved "https://registry.yarnpkg.com/core-js-compat/-/core-js-compat-3.33.0.tgz#24aa230b228406450b2277b7c8bfebae932df966" + integrity sha512-0w4LcLXsVEuNkIqwjjf9rjCoPhK8uqA4tMRh4Ge26vfLtUutshn+aRJU21I9LCJlh2QQHfisNToLjw1XEJLTWw== dependencies: - browserslist "^4.21.10" + browserslist "^4.22.1" core-js-pure@^3.23.3: - version "3.32.2" - resolved "https://registry.yarnpkg.com/core-js-pure/-/core-js-pure-3.32.2.tgz#b7dbdac528625cf87eb0523b532eb61551b9a6d1" - integrity sha512-Y2rxThOuNywTjnX/PgA5vWM6CZ9QB9sz9oGeCixV8MqXZO70z/5SHzf9EeBrEBK0PN36DnEBBu9O/aGWzKuMZQ== + version "3.33.0" + resolved "https://registry.yarnpkg.com/core-js-pure/-/core-js-pure-3.33.0.tgz#938a28754b4d82017a7a8cbd2727b1abecc63591" + integrity sha512-FKSIDtJnds/YFIEaZ4HszRX7hkxGpNKM7FC9aJ9WLJbSd3lD4vOltFuVIBLR8asSx9frkTSqL0dw90SKQxgKrg== core-util-is@~1.0.0: version "1.0.3" @@ -4966,15 +4991,28 @@ cosmiconfig@^7.0.1: yaml "^1.10.0" cosmiconfig@^8.1.3, cosmiconfig@^8.2.0: - version "8.3.5" - resolved "https://registry.yarnpkg.com/cosmiconfig/-/cosmiconfig-8.3.5.tgz#3b3897ddd042d022d5a207d4c8832e54f5301977" - integrity sha512-A5Xry3xfS96wy2qbiLkQLAg4JUrR2wvfybxj6yqLmrUfMAvhS3MZxIP2oQn0grgYIvJqzpeTEWu4vK0t+12NNw== + version "8.3.6" + resolved "https://registry.yarnpkg.com/cosmiconfig/-/cosmiconfig-8.3.6.tgz#060a2b871d66dba6c8538ea1118ba1ac16f5fae3" + integrity sha512-kcZ6+W5QzcJ3P1Mt+83OUv/oHFqZHIx8DuxG6eZ5RGMERoLqp4BuGjhHLYGK+Kf5XVkQvqBSmAy/nGWN3qDgEA== dependencies: import-fresh "^3.3.0" js-yaml "^4.1.0" parse-json "^5.2.0" path-type "^4.0.0" +create-jest@^29.7.0: + version "29.7.0" + resolved "https://registry.yarnpkg.com/create-jest/-/create-jest-29.7.0.tgz#a355c5b3cb1e1af02ba177fe7afd7feee49a5320" + integrity sha512-Adz2bdH0Vq3F53KEMJOoftQFutWCukm6J24wbPWRO4k1kMY7gS7ds/uoJkNuV8wDCtWWnuwGcJwpWcih+zEW1Q== + dependencies: + "@jest/types" "^29.6.3" + chalk "^4.0.0" + exit "^0.1.2" + graceful-fs "^4.2.9" + jest-config "^29.7.0" + jest-util "^29.7.0" + prompts "^2.0.1" + cross-spawn@^7.0.0, cross-spawn@^7.0.2, cross-spawn@^7.0.3: version "7.0.3" resolved "https://registry.yarnpkg.com/cross-spawn/-/cross-spawn-7.0.3.tgz#f73a85b9d5d41d045551c177e2882d4ac85728a6" @@ -5114,9 +5152,9 @@ data-urls@^3.0.2: whatwg-url "^11.0.0" dayjs@^1.11.9: - version "1.11.9" - resolved "https://registry.yarnpkg.com/dayjs/-/dayjs-1.11.9.tgz#9ca491933fadd0a60a2c19f6c237c03517d71d1a" - integrity sha512-QvzAURSbQ0pKdIye2txOzNaHmxtUBXerpY0FJsFXUMKbIZeFm5ht1LS/jFsrncjnmtv8HsG0W2g6c0zUjZWmpA== + version "1.11.10" + resolved "https://registry.yarnpkg.com/dayjs/-/dayjs-1.11.10.tgz#68acea85317a6e164457d6d6947564029a6a16a0" + integrity sha512-vjAczensTgRcqDERK0SR2XMwsF/tSvnvlv6VcF2GIhg6Sx4yOIt/irsr1RDJsKiIyBzJDpCoXiWWq28MqH2cnQ== debug@2.6.9, debug@^2.6.9: version "2.6.9" @@ -5228,16 +5266,26 @@ defaults@^1.0.3: dependencies: clone "^1.0.2" +define-data-property@^1.0.1: + version "1.1.0" + resolved "https://registry.yarnpkg.com/define-data-property/-/define-data-property-1.1.0.tgz#0db13540704e1d8d479a0656cf781267531b9451" + integrity sha512-UzGwzcjyv3OtAvolTj1GoyNYzfFR+iqbGjcnBEENZVCpM4/Ng1yhGNvS3lR/xDS74Tb2wGG9WzNSNIOS9UVb2g== + dependencies: + get-intrinsic "^1.2.1" + gopd "^1.0.1" + has-property-descriptors "^1.0.0" + define-lazy-prop@^2.0.0: version "2.0.0" resolved "https://registry.yarnpkg.com/define-lazy-prop/-/define-lazy-prop-2.0.0.tgz#3f7ae421129bcaaac9bc74905c98a0009ec9ee7f" integrity sha512-Ds09qNh8yw3khSjiJjiUInaGX9xlqZDY7JVryGxdxV7NPeuqQfplOpQ66yJFZut3jLa5zOwkXw1g9EI2uKh4Og== -define-properties@^1.1.3, define-properties@^1.1.4, define-properties@^1.2.0: - version "1.2.0" - resolved "https://registry.yarnpkg.com/define-properties/-/define-properties-1.2.0.tgz#52988570670c9eacedd8064f4a990f2405849bd5" - integrity sha512-xvqAVKGfT1+UAvPwKTVw/njhdQ8ZhXK4lI0bCIuCMrp2up9nPnaDftrLtmpTazqd1o+UY4zgzU+avtMbDP+ldA== +define-properties@^1.1.3, define-properties@^1.1.4, define-properties@^1.2.0, define-properties@^1.2.1: + version "1.2.1" + resolved "https://registry.yarnpkg.com/define-properties/-/define-properties-1.2.1.tgz#10781cc616eb951a80a034bafcaa7377f6af2b6c" + integrity sha512-8QmQKqEASLd5nx0U1B1okLElbUuuttJ/AnYmRXbbbGDWh6uS208EjD4Xqq/I9wK7u0v6O08XhTWnt5XtEbR6Dg== dependencies: + define-data-property "^1.0.1" has-property-descriptors "^1.0.0" object-keys "^1.1.1" @@ -5491,10 +5539,10 @@ ejs@^3.1.8: dependencies: jake "^10.8.5" -electron-to-chromium@^1.4.477: - version "1.4.513" - resolved "https://registry.yarnpkg.com/electron-to-chromium/-/electron-to-chromium-1.4.513.tgz#41a50bf749aa7d8058ffbf7a131fc3327a7b1675" - integrity sha512-cOB0xcInjm+E5qIssHeXJ29BaUyWpMyFKT5RB3bsLENDheCja0wMkHJyiPl0NBE/VzDI7JDuNEQWhe6RitEUcw== +electron-to-chromium@^1.4.535: + version "1.4.540" + resolved "https://registry.yarnpkg.com/electron-to-chromium/-/electron-to-chromium-1.4.540.tgz#c685f2f035e93eb21dd6a9cfe2c735bad8f77401" + integrity sha512-aoCqgU6r9+o9/S7wkcSbmPRFi7OWZWiXS9rtjEd+Ouyu/Xyw5RSq2XN8s5Qp8IaFOLiRrhQCphCIjAxgG3eCAg== emittery@^0.13.1: version "0.13.1" @@ -5579,17 +5627,17 @@ error-stack-parser@^2.0.6: stackframe "^1.3.4" es-abstract@^1.22.1: - version "1.22.1" - resolved "https://registry.yarnpkg.com/es-abstract/-/es-abstract-1.22.1.tgz#8b4e5fc5cefd7f1660f0f8e1a52900dfbc9d9ccc" - integrity sha512-ioRRcXMO6OFyRpyzV3kE1IIBd4WG5/kltnzdxSCqoP8CMGs/Li+M1uF5o7lOkZVFjDs+NLesthnF66Pg/0q0Lw== + version "1.22.2" + resolved "https://registry.yarnpkg.com/es-abstract/-/es-abstract-1.22.2.tgz#90f7282d91d0ad577f505e423e52d4c1d93c1b8a" + integrity sha512-YoxfFcDmhjOgWPWsV13+2RNjq1F6UQnfs+8TftwNqtzlmFzEXvlUwdrNrYeaizfjQzRMxkZ6ElWMOJIFKdVqwA== dependencies: array-buffer-byte-length "^1.0.0" - arraybuffer.prototype.slice "^1.0.1" + arraybuffer.prototype.slice "^1.0.2" available-typed-arrays "^1.0.5" call-bind "^1.0.2" es-set-tostringtag "^2.0.1" es-to-primitive "^1.2.1" - function.prototype.name "^1.1.5" + function.prototype.name "^1.1.6" get-intrinsic "^1.2.1" get-symbol-description "^1.0.0" globalthis "^1.0.3" @@ -5605,23 +5653,23 @@ es-abstract@^1.22.1: is-regex "^1.1.4" is-shared-array-buffer "^1.0.2" is-string "^1.0.7" - is-typed-array "^1.1.10" + is-typed-array "^1.1.12" is-weakref "^1.0.2" object-inspect "^1.12.3" object-keys "^1.1.1" object.assign "^4.1.4" - regexp.prototype.flags "^1.5.0" - safe-array-concat "^1.0.0" + regexp.prototype.flags "^1.5.1" + safe-array-concat "^1.0.1" safe-regex-test "^1.0.0" - string.prototype.trim "^1.2.7" - string.prototype.trimend "^1.0.6" - string.prototype.trimstart "^1.0.6" + string.prototype.trim "^1.2.8" + string.prototype.trimend "^1.0.7" + string.prototype.trimstart "^1.0.7" typed-array-buffer "^1.0.0" typed-array-byte-length "^1.0.0" typed-array-byte-offset "^1.0.0" typed-array-length "^1.0.4" unbox-primitive "^1.0.2" - which-typed-array "^1.1.10" + which-typed-array "^1.1.11" es-get-iterator@^1.1.3: version "1.1.3" @@ -5639,13 +5687,13 @@ es-get-iterator@^1.1.3: stop-iteration-iterator "^1.0.0" es-iterator-helpers@^1.0.12: - version "1.0.14" - resolved "https://registry.yarnpkg.com/es-iterator-helpers/-/es-iterator-helpers-1.0.14.tgz#19cd7903697d97e21198f3293b55e8985791c365" - integrity sha512-JgtVnwiuoRuzLvqelrvN3Xu7H9bu2ap/kQ2CrM62iidP8SKuD99rWU3CJy++s7IVL2qb/AjXPGR/E7i9ngd/Cw== + version "1.0.15" + resolved "https://registry.yarnpkg.com/es-iterator-helpers/-/es-iterator-helpers-1.0.15.tgz#bd81d275ac766431d19305923707c3efd9f1ae40" + integrity sha512-GhoY8uYqd6iwUl2kgjTm4CZAf6oo5mHK7BPqx3rKgx893YSsy0LGHV6gfqqQvZt/8xM8xeOnfXBCfqclMKkJ5g== dependencies: asynciterator.prototype "^1.0.0" call-bind "^1.0.2" - define-properties "^1.2.0" + define-properties "^1.2.1" es-abstract "^1.22.1" es-set-tostringtag "^2.0.1" function-bind "^1.1.1" @@ -5655,8 +5703,8 @@ es-iterator-helpers@^1.0.12: has-proto "^1.0.1" has-symbols "^1.0.3" internal-slot "^1.0.5" - iterator.prototype "^1.1.0" - safe-array-concat "^1.0.0" + iterator.prototype "^1.1.2" + safe-array-concat "^1.0.1" es-module-lexer@^1.2.1: version "1.3.1" @@ -5694,9 +5742,9 @@ esbuild-plugin-alias@^0.2.1: integrity sha512-jyfL/pwPqaFXyKnj8lP8iLk6Z0m099uXR45aSN8Av1XD4vhvQutxxPzgA2bTcAwQpa1zCXDcWOlhFgyP3GKqhQ== esbuild-register@^3.4.0: - version "3.4.2" - resolved "https://registry.yarnpkg.com/esbuild-register/-/esbuild-register-3.4.2.tgz#1e39ee0a77e8f320a9790e68c64c3559620b9175" - integrity sha512-kG/XyTDyz6+YDuyfB9ZoSIOOmgyFCH+xPRtsCa8W85HLRV5Csp+o3jWVbOSHgSLfyLc5DmP+KFDNwty4mEjC+Q== + version "3.5.0" + resolved "https://registry.yarnpkg.com/esbuild-register/-/esbuild-register-3.5.0.tgz#449613fb29ab94325c722f560f800dd946dc8ea8" + integrity sha512-+4G/XmakeBAsvJuDugJvtyF1x+XJT4FMocynNpxrvEBViirpfUn2PgNpCHedfWhF4WokNsO/OvMKrmJOIJsI5A== dependencies: debug "^4.3.4" @@ -5774,9 +5822,9 @@ eslint-import-resolver-node@^0.3.7: resolve "^1.22.4" eslint-import-resolver-typescript@^3.5.5: - version "3.6.0" - resolved "https://registry.yarnpkg.com/eslint-import-resolver-typescript/-/eslint-import-resolver-typescript-3.6.0.tgz#36f93e1eb65a635e688e16cae4bead54552e3bbd" - integrity sha512-QTHR9ddNnn35RTxlaEnx2gCxqFlF2SEN0SE2d17SqwyM7YOSI2GHWRYp5BiRkObTUNYPupC/3Fq2a0PpT+EKpg== + version "3.6.1" + resolved "https://registry.yarnpkg.com/eslint-import-resolver-typescript/-/eslint-import-resolver-typescript-3.6.1.tgz#7b983680edd3f1c5bce1a5829ae0bc2d57fe9efa" + integrity sha512-xgdptdoi5W3niYeuQxKmzVDTATvLYqhpwmykwsh7f6HIOStGWEIL9iqZgQDF9u9OEzrRwR8no5q2VT+bjAujTg== dependencies: debug "^4.3.4" enhanced-resolve "^5.12.0" @@ -5856,9 +5904,9 @@ eslint-plugin-react@^7.32.2: string.prototype.matchall "^4.0.8" eslint-plugin-storybook@^0.6.12: - version "0.6.13" - resolved "https://registry.yarnpkg.com/eslint-plugin-storybook/-/eslint-plugin-storybook-0.6.13.tgz#897a9f6a9bb88c63b02f05850f30c28a9848a3f7" - integrity sha512-smd+CS0WH1jBqUEJ3znGS7DU4ayBE9z6lkQAK2yrSUv1+rq8BT/tiI5C/rKE7rmiqiAfojtNYZRhzo5HrulccQ== + version "0.6.14" + resolved "https://registry.yarnpkg.com/eslint-plugin-storybook/-/eslint-plugin-storybook-0.6.14.tgz#dfc2b58700e45eb4a13c172dda2973c2e033cd71" + integrity sha512-IeYigPur/MvESNDo43Z+Z5UvlcEVnt0dDZmnw1odi9X2Th1R3bpGyOZsHXb9bp1pFecOpRUuoMG5xdID2TwwOg== dependencies: "@storybook/csf" "^0.0.1" "@typescript-eslint/utils" "^5.45.0" @@ -5887,14 +5935,14 @@ eslint-visitor-keys@^3.3.0, eslint-visitor-keys@^3.4.1, eslint-visitor-keys@^3.4 integrity sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag== eslint@^8.44.0: - version "8.49.0" - resolved "https://registry.yarnpkg.com/eslint/-/eslint-8.49.0.tgz#09d80a89bdb4edee2efcf6964623af1054bf6d42" - integrity sha512-jw03ENfm6VJI0jA9U+8H5zfl5b+FvuU3YYvZRdZHOlU2ggJkxrlkJH4HcDrZpj6YwD8kuYqvQM8LyesoazrSOQ== + version "8.50.0" + resolved "https://registry.yarnpkg.com/eslint/-/eslint-8.50.0.tgz#2ae6015fee0240fcd3f83e1e25df0287f487d6b2" + integrity sha512-FOnOGSuFuFLv/Sa+FDVRZl4GGVAAFFi8LecRsI5a1tMO5HIE8nCm4ivAlzt4dT3ol/PaaGC0rJEEXQmHJBGoOg== dependencies: "@eslint-community/eslint-utils" "^4.2.0" "@eslint-community/regexpp" "^4.6.1" "@eslint/eslintrc" "^2.1.2" - "@eslint/js" "8.49.0" + "@eslint/js" "8.50.0" "@humanwhocodes/config-array" "^0.11.11" "@humanwhocodes/module-importer" "^1.0.1" "@nodelib/fs.walk" "^1.2.8" @@ -6021,16 +6069,16 @@ exit@^0.1.2: resolved "https://registry.yarnpkg.com/exit/-/exit-0.1.2.tgz#0632638f8d877cc82107d30a0fff1a17cba1cd0c" integrity sha512-Zk/eNKV2zbjpKzrsQ+n1G6poVbErQxJ0LBOJXaKZ1EViLzH+hrLu9cdXI4zw9dBQJslwBEpbQ2P1oS7nDxs6jQ== -expect@^29.0.0, expect@^29.6.4: - version "29.6.4" - resolved "https://registry.yarnpkg.com/expect/-/expect-29.6.4.tgz#a6e6f66d4613717859b2fe3da98a739437b6f4b8" - integrity sha512-F2W2UyQ8XYyftHT57dtfg8Ue3X5qLgm2sSug0ivvLRH/VKNRL/pDxg/TH7zVzbQB0tu80clNFy6LU7OS/VSEKA== +expect@^29.0.0, expect@^29.7.0: + version "29.7.0" + resolved "https://registry.yarnpkg.com/expect/-/expect-29.7.0.tgz#578874590dcb3214514084c08115d8aee61e11bc" + integrity sha512-2Zks0hf1VLFYI1kbh0I5jP3KHHyCHpkfyHBzsSXRFgl/Bg9mWYfMW8oD+PdMPlEwy5HNsR9JutYy6pMeOh61nw== dependencies: - "@jest/expect-utils" "^29.6.4" + "@jest/expect-utils" "^29.7.0" jest-get-type "^29.6.3" - jest-matcher-utils "^29.6.4" - jest-message-util "^29.6.3" - jest-util "^29.6.3" + jest-matcher-utils "^29.7.0" + jest-message-util "^29.7.0" + jest-util "^29.7.0" express@^4.17.3: version "4.18.2" @@ -6283,19 +6331,19 @@ flat-cache@^3.0.4: rimraf "^3.0.2" flatted@^3.2.7: - version "3.2.7" - resolved "https://registry.yarnpkg.com/flatted/-/flatted-3.2.7.tgz#609f39207cb614b89d0765b477cb2d437fbf9787" - integrity sha512-5nqDSxl8nn5BSNxyR3n4I6eDmbolI6WT+QqR547RwxQapgjQBmtktdP+HTBb/a/zLsbzERTONyUB5pefh5TtjQ== + version "3.2.9" + resolved "https://registry.yarnpkg.com/flatted/-/flatted-3.2.9.tgz#7eb4c67ca1ba34232ca9d2d93e9886e611ad7daf" + integrity sha512-36yxDn5H7OFZQla0/jFJmbIKTdZAQHngCedGxiMmpNfEZM0sdEeT+WczLQrjK6D7o2aiyLYDnkw0R3JK0Qv1RQ== flow-parser@0.*: - version "0.216.1" - resolved "https://registry.yarnpkg.com/flow-parser/-/flow-parser-0.216.1.tgz#eeba9b0b689deeccc34a6b7d2b1f97b8f943afc0" - integrity sha512-wstw46/C/8bRv/8RySCl15lK376j8DHxm41xFjD9eVL+jSS1UmVpbdLdA0LzGuS2v5uGgQiBLEj6mgSJQwW+MA== + version "0.217.2" + resolved "https://registry.yarnpkg.com/flow-parser/-/flow-parser-0.217.2.tgz#3a4aade40ea55a863295120a0b0da8a960967ad6" + integrity sha512-O+nt/FLXa1hTwtW0O9h36iZjbL84G8e1uByx5dDXMC97AJEbZXwJ4ohfaE8BNWrYFyYX0NGfz1o8AtLQvaaD/Q== follow-redirects@^1.0.0: - version "1.15.2" - resolved "https://registry.yarnpkg.com/follow-redirects/-/follow-redirects-1.15.2.tgz#b460864144ba63f2681096f274c4e57026da2c13" - integrity sha512-VQLG33o04KaQ8uYi2tVNbdrWp1QWxNNea+nmIB4EVM28v0hmP17z7aG1+wAkNzVq4KeXTq3221ye5qTJP91JwA== + version "1.15.3" + resolved "https://registry.yarnpkg.com/follow-redirects/-/follow-redirects-1.15.3.tgz#fe2f3ef2690afce7e82ed0b44db08165b207123a" + integrity sha512-1VzOtuEM8pC9SFU1E+8KfTjZyMztRsgEfwQl44z8A25uy13jSzTj6dyK2Df52iV0vgHCfBwLhDWevLn95w5v6Q== for-each@^0.3.3: version "0.3.3" @@ -6338,15 +6386,6 @@ fork-ts-checker-webpack-plugin@^8.0.0: semver "^7.3.5" tapable "^2.2.1" -form-data@^3.0.0: - version "3.0.1" - resolved "https://registry.yarnpkg.com/form-data/-/form-data-3.0.1.tgz#ebd53791b78356a99af9a300d4282c4d5eb9755f" - integrity sha512-RHkBKtLWUVwd7SqRIvCZMEvAMoGUp0XU+seQiZejj0COz3RI3hWP4sCv3gZWWLjJTd7rGwcsF5eKZGii0r/hbg== - dependencies: - asynckit "^0.4.0" - combined-stream "^1.0.8" - mime-types "^2.1.12" - form-data@^4.0.0: version "4.0.0" resolved "https://registry.yarnpkg.com/form-data/-/form-data-4.0.0.tgz#93919daeaf361ee529584b9b31664dc12c9fa452" @@ -6397,9 +6436,9 @@ fs-minipass@^2.0.0: minipass "^3.0.0" fs-monkey@^1.0.4: - version "1.0.4" - resolved "https://registry.yarnpkg.com/fs-monkey/-/fs-monkey-1.0.4.tgz#ee8c1b53d3fe8bb7e5d2c5c5dfc0168afdd2f747" - integrity sha512-INM/fWAxMICjttnD0DX1rBvinKskj5G1w+oy/pnm9u/tSlnBrzFonJMcalKJ30P8RRsPzKcCG7Q8l0jx5Fh9YQ== + version "1.0.5" + resolved "https://registry.yarnpkg.com/fs-monkey/-/fs-monkey-1.0.5.tgz#fe450175f0db0d7ea758102e1d84096acb925788" + integrity sha512-8uMbBjrhzW76TYgEV27Y5E//W2f/lTFmx78P2w19FZSxarhI/798APGQyuGCwmkNxgwGRhrLfvWyLBvNtuOmew== fs-readdir-recursive@^1.1.0: version "1.1.0" @@ -6421,7 +6460,7 @@ function-bind@^1.1.1: resolved "https://registry.yarnpkg.com/function-bind/-/function-bind-1.1.1.tgz#a56899d3ea3c9bab874bb9773b7c5ede92f4895d" integrity sha512-yIovAzMX49sF8Yl58fSCWJ5svSLuaibPxXQJFLmBObTuCr0Mf1KiPopGM9NiFjiYBCbfaa2Fh6breQ6ANVTI0A== -function.prototype.name@^1.1.5: +function.prototype.name@^1.1.5, function.prototype.name@^1.1.6: version "1.1.6" resolved "https://registry.yarnpkg.com/function.prototype.name/-/function.prototype.name-1.1.6.tgz#cdf315b7d90ee77a4c6ee216c3c3362da07533fd" integrity sha512-Z5kx79swU5P27WEayXM1tBi5Ze/lbIyiNgU3qyXUOf9b2rgXYyF9Dy9Cx+IQv/Lc8WCG6L82zwUPpSS9hGehIg== @@ -6490,9 +6529,9 @@ get-symbol-description@^1.0.0: get-intrinsic "^1.1.1" get-tsconfig@^4.5.0: - version "4.7.0" - resolved "https://registry.yarnpkg.com/get-tsconfig/-/get-tsconfig-4.7.0.tgz#06ce112a1463e93196aa90320c35df5039147e34" - integrity sha512-pmjiZ7xtB8URYm74PlGJozDNyhvsVLUcpBa8DZBG3bWHwaHa9bPiRpiSfovw+fjhwONSCWKRyk+JQHEGZmMrzw== + version "4.7.2" + resolved "https://registry.yarnpkg.com/get-tsconfig/-/get-tsconfig-4.7.2.tgz#0dcd6fb330391d46332f4c6c1bf89a6514c2ddce" + integrity sha512-wuMsz4leaj5hbGgg4IvDU0bqJagpftG5l5cXIAvo8uZrqn0NJqwtfupTN00VnkQJPcIRrxYrm1Ue24btpCha2A== dependencies: resolve-pkg-maps "^1.0.0" @@ -6534,12 +6573,12 @@ glob-to-regexp@^0.4.1: integrity sha512-lkX1HJXwyMcprw/5YUZc2s7DrpAiHB21/V+E1rHUrVNokkvB6bqMzT0VfV6/86ZNabt1k14YOIaT7nDvOX3Iiw== glob@^10.0.0: - version "10.3.4" - resolved "https://registry.yarnpkg.com/glob/-/glob-10.3.4.tgz#c85c9c7ab98669102b6defda76d35c5b1ef9766f" - integrity sha512-6LFElP3A+i/Q8XQKEvZjkEWEOTgAIALR9AO2rwT8bgPhDd1anmqDJDZ6lLddI4ehxxxR1S5RIqKe1uapMQfYaQ== + version "10.3.10" + resolved "https://registry.yarnpkg.com/glob/-/glob-10.3.10.tgz#0351ebb809fd187fe421ab96af83d3a70715df4b" + integrity sha512-fa46+tv1Ak0UPK1TOy/pZrIybNNt4HCv7SDzwyfiOZkvZLEbjsZkJBPtDHVshZjbecAoAGSC20MjLDG/qr679g== dependencies: foreground-child "^3.1.0" - jackspeak "^2.0.3" + jackspeak "^2.3.5" minimatch "^9.0.1" minipass "^5.0.0 || ^6.0.2 || ^7.0.0" path-scurry "^1.10.1" @@ -6578,9 +6617,9 @@ globals@^11.1.0: integrity sha512-WOBp/EEGUiIsJSp7wcv/y6MO+lV9UoncWqxuFfm8eBwzWNgyfBd6Gz+IeKQ9jCmyhoH99g15M3T+QaVHFjizVA== globals@^13.19.0: - version "13.21.0" - resolved "https://registry.yarnpkg.com/globals/-/globals-13.21.0.tgz#163aae12f34ef502f5153cfbdd3600f36c63c571" - integrity sha512-ybyme3s4yy/t/3s35bewwXKOf7cvzfreG2lH0lZl0JB7I4GxRP2ghxOK/Nb9EkRXdbBXZLfq/p/0W2JUONB/Gg== + version "13.22.0" + resolved "https://registry.yarnpkg.com/globals/-/globals-13.22.0.tgz#0c9fcb9c48a2494fbb5edbfee644285543eba9d8" + integrity sha512-H1Ddc/PbZHTDVJSnj8kWptIRSD6AM3pK+mKytuIVF4uoBV7rshFlhhvA58ceJ5wp3Er58w6zj7bykMpYXt3ETw== dependencies: type-fest "^0.20.2" @@ -6636,10 +6675,10 @@ graphemer@^1.4.0: resolved "https://registry.yarnpkg.com/graphemer/-/graphemer-1.4.0.tgz#fb2f1d55e0e3a1849aeffc90c4fa0dd53a0e66c6" integrity sha512-EtKwoO6kxCL9WO5xipiHTZlSzBm7WLT627TqC/uVRd0HKmq8NXyebnNYxDoBi7wt8eTWrUrKXCOVaFq9x1kgag== -"graphql@^15.0.0 || ^16.0.0": - version "16.8.0" - resolved "https://registry.yarnpkg.com/graphql/-/graphql-16.8.0.tgz#374478b7f27b2dc6153c8f42c1b80157f79d79d4" - integrity sha512-0oKGaR+y3qcS5mCu1vb7KG+a89vjn06C7Ihq/dDl3jA+A8B3TKomvi3CiEcVLJQGalbu8F52LxkOym7U5sSfbg== +graphql@^16.8.1: + version "16.8.1" + resolved "https://registry.yarnpkg.com/graphql/-/graphql-16.8.1.tgz#1930a965bef1170603702acdb68aedd3f3cf6f07" + integrity sha512-59LZHPdGZVh695Ud9lRzPBVTtlX9ZCV150Er2W43ro37wVof0ctenSaskPPjN7lVTIN8mSZt8PHUNKZuNQUuxw== gunzip-maybe@^1.4.2: version "1.4.2" @@ -6715,21 +6754,19 @@ has-tostringtag@^1.0.0: has-symbols "^1.0.2" has@^1.0.3: - version "1.0.3" - resolved "https://registry.yarnpkg.com/has/-/has-1.0.3.tgz#722d7cbfc1f6aa8241f16dd814e011e1f41e8796" - integrity sha512-f2dvO0VU6Oej7RkWJGrehjbzMAjFp5/VKPp5tTpWIV4JHHZK1/BxbFRtf/siA2SWTe09caDmVtYYzWEIbBS4zw== - dependencies: - function-bind "^1.1.1" + version "1.0.4" + resolved "https://registry.yarnpkg.com/has/-/has-1.0.4.tgz#2eb2860e000011dae4f1406a86fe80e530fb2ec6" + integrity sha512-qdSAmqLF6209RFj4VVItywPMbm3vWylknmB3nvNiUIs72xAimcM8nVYxYr7ncvZq5qzk9MKIZR8ijqD/1QuYjQ== he@^1.2.0: version "1.2.0" resolved "https://registry.yarnpkg.com/he/-/he-1.2.0.tgz#84ae65fa7eafb165fddb61566ae14baf05664f0f" integrity sha512-F/1DnUGPopORZi0ni+CvrCgHQ5FyEAHRLSApuYWMmrbSwoN2Mn/7k+Gl38gJnR7yyDZk6WLXwiGod1JOWNDKGw== -headers-polyfill@^3.1.0, headers-polyfill@^3.2.0: - version "3.2.3" - resolved "https://registry.yarnpkg.com/headers-polyfill/-/headers-polyfill-3.2.3.tgz#ac656b4415b83f989fea3595931399fe9f055c7e" - integrity sha512-oj6MO8sdFQ9gQQedSVdMGh96suxTNp91vPQu7C4qx/57FqYsA5TiNr92nhIZwVQq8zygn4nu3xS1aEqpakGqdw== +headers-polyfill@3.2.5: + version "3.2.5" + resolved "https://registry.yarnpkg.com/headers-polyfill/-/headers-polyfill-3.2.5.tgz#6e67d392c9d113d37448fe45014e0afdd168faed" + integrity sha512-tUCGvt191vNSQgttSyJoibR+VO+I6+iCHIUdhzEMJKE+EAL8BwCN7fUOZlY4ofOelNHsK+gEjxB/B+9N3EWtdA== hoist-non-react-statics@^3.3.0: version "3.3.2" @@ -7289,7 +7326,7 @@ is-symbol@^1.0.2, is-symbol@^1.0.3: dependencies: has-symbols "^1.0.2" -is-typed-array@^1.1.10, is-typed-array@^1.1.3, is-typed-array@^1.1.9: +is-typed-array@^1.1.10, is-typed-array@^1.1.12, is-typed-array@^1.1.3, is-typed-array@^1.1.9: version "1.1.12" resolved "https://registry.yarnpkg.com/is-typed-array/-/is-typed-array-1.1.12.tgz#d0bab5686ef4a76f7a73097b95470ab199c57d4a" integrity sha512-Z14TF2JNG8Lss5/HMqt0//T9JeHXttXy5pH/DBU4vi98ozO2btxzq9MwYDZYnKwU8nRsz/+GVFVRDq3DkVuSPg== @@ -7370,9 +7407,9 @@ istanbul-lib-instrument@^5.0.4: semver "^6.3.0" istanbul-lib-instrument@^6.0.0: - version "6.0.0" - resolved "https://registry.yarnpkg.com/istanbul-lib-instrument/-/istanbul-lib-instrument-6.0.0.tgz#7a8af094cbfff1d5bb280f62ce043695ae8dd5b8" - integrity sha512-x58orMzEVfzPUKqlbLd1hXCnySCxKdDKa6Rjg97CwuLLRI4g3FHTdnExu1OqffVFay6zeMW+T6/DowFLndWnIw== + version "6.0.1" + resolved "https://registry.yarnpkg.com/istanbul-lib-instrument/-/istanbul-lib-instrument-6.0.1.tgz#71e87707e8041428732518c6fb5211761753fbdf" + integrity sha512-EAMEJBsYuyyztxMxW3g7ugGPkrZsV57v0Hmv3mm1uQsmB+QnZuepg731CRaIgeUVSdmsTngOkSnauNF8p7FIhA== dependencies: "@babel/core" "^7.12.3" "@babel/parser" "^7.14.7" @@ -7406,17 +7443,18 @@ istanbul-reports@^3.1.3, istanbul-reports@^3.1.4: html-escaper "^2.0.0" istanbul-lib-report "^3.0.0" -iterator.prototype@^1.1.0: - version "1.1.1" - resolved "https://registry.yarnpkg.com/iterator.prototype/-/iterator.prototype-1.1.1.tgz#ab5b790e23ec00658f5974e032a2b05188bd3a5c" - integrity sha512-9E+nePc8C9cnQldmNl6bgpTY6zI4OPRZd97fhJ/iVZ1GifIUDVV5F6x1nEDqpe8KaMEZGT4xgrwKQDxXnjOIZQ== +iterator.prototype@^1.1.2: + version "1.1.2" + resolved "https://registry.yarnpkg.com/iterator.prototype/-/iterator.prototype-1.1.2.tgz#5e29c8924f01916cb9335f1ff80619dcff22b0c0" + integrity sha512-DR33HMMr8EzwuRL8Y9D3u2BMj8+RqSE850jfGu59kS7tbmPLzGkZmVSfyCFSDxuZiEY6Rzt3T2NA/qU+NwVj1w== dependencies: - define-properties "^1.2.0" + define-properties "^1.2.1" get-intrinsic "^1.2.1" has-symbols "^1.0.3" - reflect.getprototypeof "^1.0.3" + reflect.getprototypeof "^1.0.4" + set-function-name "^2.0.1" -jackspeak@2.1.1, jackspeak@^2.0.3: +jackspeak@2.1.1, jackspeak@^2.3.5: version "2.1.1" resolved "https://registry.yarnpkg.com/jackspeak/-/jackspeak-2.1.1.tgz#2a42db4cfbb7e55433c28b6f75d8b796af9669cd" integrity sha512-juf9stUEwUaILepraGOWIJTLwg48bUnBmRqd2ln2Os1sW987zeoj/hzhbvRB95oMuS2ZTpjULmdwHNX4rzZIZw== @@ -7435,150 +7473,149 @@ jake@^10.8.5: filelist "^1.0.4" minimatch "^3.1.2" -jest-changed-files@^29.6.3: - version "29.6.3" - resolved "https://registry.yarnpkg.com/jest-changed-files/-/jest-changed-files-29.6.3.tgz#97cfdc93f74fb8af2a1acb0b78f836f1fb40c449" - integrity sha512-G5wDnElqLa4/c66ma5PG9eRjE342lIbF6SUnTJi26C3J28Fv2TVY2rOyKB9YGbSA5ogwevgmxc4j4aVjrEK6Yg== +jest-changed-files@^29.7.0: + version "29.7.0" + resolved "https://registry.yarnpkg.com/jest-changed-files/-/jest-changed-files-29.7.0.tgz#1c06d07e77c78e1585d020424dedc10d6e17ac3a" + integrity sha512-fEArFiwf1BpQ+4bXSprcDc3/x4HSzL4al2tozwVpDFpsxALjLYdyiIK4e5Vz66GQJIbXJ82+35PtysofptNX2w== dependencies: execa "^5.0.0" - jest-util "^29.6.3" + jest-util "^29.7.0" p-limit "^3.1.0" -jest-circus@^29.6.4: - version "29.6.4" - resolved "https://registry.yarnpkg.com/jest-circus/-/jest-circus-29.6.4.tgz#f074c8d795e0cc0f2ebf0705086b1be6a9a8722f" - integrity sha512-YXNrRyntVUgDfZbjXWBMPslX1mQ8MrSG0oM/Y06j9EYubODIyHWP8hMUbjbZ19M3M+zamqEur7O80HODwACoJw== +jest-circus@^29.7.0: + version "29.7.0" + resolved "https://registry.yarnpkg.com/jest-circus/-/jest-circus-29.7.0.tgz#b6817a45fcc835d8b16d5962d0c026473ee3668a" + integrity sha512-3E1nCMgipcTkCocFwM90XXQab9bS+GMsjdpmPrlelaxwD93Ad8iVEjX/vvHPdLPnFf+L40u+5+iutRdA1N9myw== dependencies: - "@jest/environment" "^29.6.4" - "@jest/expect" "^29.6.4" - "@jest/test-result" "^29.6.4" + "@jest/environment" "^29.7.0" + "@jest/expect" "^29.7.0" + "@jest/test-result" "^29.7.0" "@jest/types" "^29.6.3" "@types/node" "*" chalk "^4.0.0" co "^4.6.0" dedent "^1.0.0" is-generator-fn "^2.0.0" - jest-each "^29.6.3" - jest-matcher-utils "^29.6.4" - jest-message-util "^29.6.3" - jest-runtime "^29.6.4" - jest-snapshot "^29.6.4" - jest-util "^29.6.3" + jest-each "^29.7.0" + jest-matcher-utils "^29.7.0" + jest-message-util "^29.7.0" + jest-runtime "^29.7.0" + jest-snapshot "^29.7.0" + jest-util "^29.7.0" p-limit "^3.1.0" - pretty-format "^29.6.3" + pretty-format "^29.7.0" pure-rand "^6.0.0" slash "^3.0.0" stack-utils "^2.0.3" -jest-cli@^29.6.4: - version "29.6.4" - resolved "https://registry.yarnpkg.com/jest-cli/-/jest-cli-29.6.4.tgz#ad52f2dfa1b0291de7ec7f8d7c81ac435521ede0" - integrity sha512-+uMCQ7oizMmh8ZwRfZzKIEszFY9ksjjEQnTEMTaL7fYiL3Kw4XhqT9bYh+A4DQKUb67hZn2KbtEnDuHvcgK4pQ== +jest-cli@^29.7.0: + version "29.7.0" + resolved "https://registry.yarnpkg.com/jest-cli/-/jest-cli-29.7.0.tgz#5592c940798e0cae677eec169264f2d839a37995" + integrity sha512-OVVobw2IubN/GSYsxETi+gOe7Ka59EFMR/twOU3Jb2GnKKeMGJB5SGUUrEz3SFVmJASUdZUzy83sLNNQ2gZslg== dependencies: - "@jest/core" "^29.6.4" - "@jest/test-result" "^29.6.4" + "@jest/core" "^29.7.0" + "@jest/test-result" "^29.7.0" "@jest/types" "^29.6.3" chalk "^4.0.0" + create-jest "^29.7.0" exit "^0.1.2" - graceful-fs "^4.2.9" import-local "^3.0.2" - jest-config "^29.6.4" - jest-util "^29.6.3" - jest-validate "^29.6.3" - prompts "^2.0.1" + jest-config "^29.7.0" + jest-util "^29.7.0" + jest-validate "^29.7.0" yargs "^17.3.1" -jest-config@^29.6.4: - version "29.6.4" - resolved "https://registry.yarnpkg.com/jest-config/-/jest-config-29.6.4.tgz#eff958ee41d4e1ee7a6106d02b74ad9fc427d79e" - integrity sha512-JWohr3i9m2cVpBumQFv2akMEnFEPVOh+9L2xIBJhJ0zOaci2ZXuKJj0tgMKQCBZAKA09H049IR4HVS/43Qb19A== +jest-config@^29.7.0: + version "29.7.0" + resolved "https://registry.yarnpkg.com/jest-config/-/jest-config-29.7.0.tgz#bcbda8806dbcc01b1e316a46bb74085a84b0245f" + integrity sha512-uXbpfeQ7R6TZBqI3/TxCU4q4ttk3u0PJeC+E0zbfSoSjq6bJ7buBPxzQPL0ifrkY4DNu4JUdk0ImlBUYi840eQ== dependencies: "@babel/core" "^7.11.6" - "@jest/test-sequencer" "^29.6.4" + "@jest/test-sequencer" "^29.7.0" "@jest/types" "^29.6.3" - babel-jest "^29.6.4" + babel-jest "^29.7.0" chalk "^4.0.0" ci-info "^3.2.0" deepmerge "^4.2.2" glob "^7.1.3" graceful-fs "^4.2.9" - jest-circus "^29.6.4" - jest-environment-node "^29.6.4" + jest-circus "^29.7.0" + jest-environment-node "^29.7.0" jest-get-type "^29.6.3" jest-regex-util "^29.6.3" - jest-resolve "^29.6.4" - jest-runner "^29.6.4" - jest-util "^29.6.3" - jest-validate "^29.6.3" + jest-resolve "^29.7.0" + jest-runner "^29.7.0" + jest-util "^29.7.0" + jest-validate "^29.7.0" micromatch "^4.0.4" parse-json "^5.2.0" - pretty-format "^29.6.3" + pretty-format "^29.7.0" slash "^3.0.0" strip-json-comments "^3.1.1" -jest-diff@^29.6.4: - version "29.6.4" - resolved "https://registry.yarnpkg.com/jest-diff/-/jest-diff-29.6.4.tgz#85aaa6c92a79ae8cd9a54ebae8d5b6d9a513314a" - integrity sha512-9F48UxR9e4XOEZvoUXEHSWY4qC4zERJaOfrbBg9JpbJOO43R1vN76REt/aMGZoY6GD5g84nnJiBIVlscegefpw== +jest-diff@^29.7.0: + version "29.7.0" + resolved "https://registry.yarnpkg.com/jest-diff/-/jest-diff-29.7.0.tgz#017934a66ebb7ecf6f205e84699be10afd70458a" + integrity sha512-LMIgiIrhigmPrs03JHpxUh2yISK3vLFPkAodPeo0+BuF7wA2FoQbkEg1u8gBYBThncu7e1oEDUfIXVuTqLRUjw== dependencies: chalk "^4.0.0" diff-sequences "^29.6.3" jest-get-type "^29.6.3" - pretty-format "^29.6.3" + pretty-format "^29.7.0" -jest-docblock@^29.6.3: - version "29.6.3" - resolved "https://registry.yarnpkg.com/jest-docblock/-/jest-docblock-29.6.3.tgz#293dca5188846c9f7c0c2b1bb33e5b11f21645f2" - integrity sha512-2+H+GOTQBEm2+qFSQ7Ma+BvyV+waiIFxmZF5LdpBsAEjWX8QYjSCa4FrkIYtbfXUJJJnFCYrOtt6TZ+IAiTjBQ== +jest-docblock@^29.7.0: + version "29.7.0" + resolved "https://registry.yarnpkg.com/jest-docblock/-/jest-docblock-29.7.0.tgz#8fddb6adc3cdc955c93e2a87f61cfd350d5d119a" + integrity sha512-q617Auw3A612guyaFgsbFeYpNP5t2aoUNLwBUbc/0kD1R4t9ixDbyFTHd1nok4epoVFpr7PmeWHrhvuV3XaJ4g== dependencies: detect-newline "^3.0.0" -jest-each@^29.6.3: - version "29.6.3" - resolved "https://registry.yarnpkg.com/jest-each/-/jest-each-29.6.3.tgz#1956f14f5f0cb8ae0b2e7cabc10bb03ec817c142" - integrity sha512-KoXfJ42k8cqbkfshW7sSHcdfnv5agDdHCPA87ZBdmHP+zJstTJc0ttQaJ/x7zK6noAL76hOuTIJ6ZkQRS5dcyg== +jest-each@^29.7.0: + version "29.7.0" + resolved "https://registry.yarnpkg.com/jest-each/-/jest-each-29.7.0.tgz#162a9b3f2328bdd991beaabffbb74745e56577d1" + integrity sha512-gns+Er14+ZrEoC5fhOfYCY1LOHHr0TI+rQUHZS8Ttw2l7gl+80eHc/gFf2Ktkw0+SIACDTeWvpFcv3B04VembQ== dependencies: "@jest/types" "^29.6.3" chalk "^4.0.0" jest-get-type "^29.6.3" - jest-util "^29.6.3" - pretty-format "^29.6.3" + jest-util "^29.7.0" + pretty-format "^29.7.0" jest-environment-jsdom@^29.6.2: - version "29.6.4" - resolved "https://registry.yarnpkg.com/jest-environment-jsdom/-/jest-environment-jsdom-29.6.4.tgz#0daf44454041f9e1ef7fa82eb1bd43426a82eb1c" - integrity sha512-K6wfgUJ16DoMs02JYFid9lOsqfpoVtyJxpRlnTxUHzvZWBnnh2VNGRB9EC1Cro96TQdq5TtSjb3qUjNaJP9IyA== + version "29.7.0" + resolved "https://registry.yarnpkg.com/jest-environment-jsdom/-/jest-environment-jsdom-29.7.0.tgz#d206fa3551933c3fd519e5dfdb58a0f5139a837f" + integrity sha512-k9iQbsf9OyOfdzWH8HDmrRT0gSIcX+FLNW7IQq94tFX0gynPwqDTW0Ho6iMVNjGz/nb+l/vW3dWM2bbLLpkbXA== dependencies: - "@jest/environment" "^29.6.4" - "@jest/fake-timers" "^29.6.4" + "@jest/environment" "^29.7.0" + "@jest/fake-timers" "^29.7.0" "@jest/types" "^29.6.3" "@types/jsdom" "^20.0.0" "@types/node" "*" - jest-mock "^29.6.3" - jest-util "^29.6.3" + jest-mock "^29.7.0" + jest-util "^29.7.0" jsdom "^20.0.0" -jest-environment-node@^29.6.4: - version "29.6.4" - resolved "https://registry.yarnpkg.com/jest-environment-node/-/jest-environment-node-29.6.4.tgz#4ce311549afd815d3cafb49e60a1e4b25f06d29f" - integrity sha512-i7SbpH2dEIFGNmxGCpSc2w9cA4qVD+wfvg2ZnfQ7XVrKL0NA5uDVBIiGH8SR4F0dKEv/0qI5r+aDomDf04DpEQ== +jest-environment-node@^29.7.0: + version "29.7.0" + resolved "https://registry.yarnpkg.com/jest-environment-node/-/jest-environment-node-29.7.0.tgz#0b93e111dda8ec120bc8300e6d1fb9576e164376" + integrity sha512-DOSwCRqXirTOyheM+4d5YZOrWcdu0LNZ87ewUoywbcb2XR4wKgqiG8vNeYwhjFMbEkfju7wx2GYH0P2gevGvFw== dependencies: - "@jest/environment" "^29.6.4" - "@jest/fake-timers" "^29.6.4" + "@jest/environment" "^29.7.0" + "@jest/fake-timers" "^29.7.0" "@jest/types" "^29.6.3" "@types/node" "*" - jest-mock "^29.6.3" - jest-util "^29.6.3" + jest-mock "^29.7.0" + jest-util "^29.7.0" jest-get-type@^29.6.3: version "29.6.3" resolved "https://registry.yarnpkg.com/jest-get-type/-/jest-get-type-29.6.3.tgz#36f499fdcea197c1045a127319c0481723908fd1" integrity sha512-zrteXnqYxfQh7l5FHyL38jL39di8H8rHoecLH3JNxH3BwOrBsNeabdap5e0I23lD4HHI8W5VFBZqG4Eaq5LNcw== -jest-haste-map@^29.6.4: - version "29.6.4" - resolved "https://registry.yarnpkg.com/jest-haste-map/-/jest-haste-map-29.6.4.tgz#97143ce833829157ea7025204b08f9ace609b96a" - integrity sha512-12Ad+VNTDHxKf7k+M65sviyynRoZYuL1/GTuhEVb8RYsNSNln71nANRb/faSyWvx0j+gHcivChXHIoMJrGYjog== +jest-haste-map@^29.7.0: + version "29.7.0" + resolved "https://registry.yarnpkg.com/jest-haste-map/-/jest-haste-map-29.7.0.tgz#3c2396524482f5a0506376e6c858c3bbcc17b104" + integrity sha512-fP8u2pyfqx0K1rGn1R9pyE0/KTn+G7PxktWidOBTqFPLYX0b9ksaMFkhK5vrS3DVun09pckLdlx90QthlW7AmA== dependencies: "@jest/types" "^29.6.3" "@types/graceful-fs" "^4.1.3" @@ -7587,8 +7624,8 @@ jest-haste-map@^29.6.4: fb-watchman "^2.0.0" graceful-fs "^4.2.9" jest-regex-util "^29.6.3" - jest-util "^29.6.3" - jest-worker "^29.6.4" + jest-util "^29.7.0" + jest-worker "^29.7.0" micromatch "^4.0.4" walker "^1.0.8" optionalDependencies: @@ -7604,28 +7641,28 @@ jest-junit@^16.0.0: uuid "^8.3.2" xml "^1.0.1" -jest-leak-detector@^29.6.3: - version "29.6.3" - resolved "https://registry.yarnpkg.com/jest-leak-detector/-/jest-leak-detector-29.6.3.tgz#b9661bc3aec8874e59aff361fa0c6d7cd507ea01" - integrity sha512-0kfbESIHXYdhAdpLsW7xdwmYhLf1BRu4AA118/OxFm0Ho1b2RcTmO4oF6aAMaxpxdxnJ3zve2rgwzNBD4Zbm7Q== +jest-leak-detector@^29.7.0: + version "29.7.0" + resolved "https://registry.yarnpkg.com/jest-leak-detector/-/jest-leak-detector-29.7.0.tgz#5b7ec0dadfdfec0ca383dc9aa016d36b5ea4c728" + integrity sha512-kYA8IJcSYtST2BY9I+SMC32nDpBT3J2NvWJx8+JCuCdl/CR1I4EKUJROiP8XtCcxqgTTBGJNdbB1A8XRKbTetw== dependencies: jest-get-type "^29.6.3" - pretty-format "^29.6.3" + pretty-format "^29.7.0" -jest-matcher-utils@^29.6.4: - version "29.6.4" - resolved "https://registry.yarnpkg.com/jest-matcher-utils/-/jest-matcher-utils-29.6.4.tgz#327db7ababea49455df3b23e5d6109fe0c709d24" - integrity sha512-KSzwyzGvK4HcfnserYqJHYi7sZVqdREJ9DMPAKVbS98JsIAvumihaNUbjrWw0St7p9IY7A9UskCW5MYlGmBQFQ== +jest-matcher-utils@^29.7.0: + version "29.7.0" + resolved "https://registry.yarnpkg.com/jest-matcher-utils/-/jest-matcher-utils-29.7.0.tgz#ae8fec79ff249fd592ce80e3ee474e83a6c44f12" + integrity sha512-sBkD+Xi9DtcChsI3L3u0+N0opgPYnCRPtGcQYrgXmR+hmt/fYfWAL0xRXYU8eWOdfuLgBe0YCW3AFtnRLagq/g== dependencies: chalk "^4.0.0" - jest-diff "^29.6.4" + jest-diff "^29.7.0" jest-get-type "^29.6.3" - pretty-format "^29.6.3" + pretty-format "^29.7.0" -jest-message-util@^29.6.3: - version "29.6.3" - resolved "https://registry.yarnpkg.com/jest-message-util/-/jest-message-util-29.6.3.tgz#bce16050d86801b165f20cfde34dc01d3cf85fbf" - integrity sha512-FtzaEEHzjDpQp51HX4UMkPZjy46ati4T5pEMyM6Ik48ztu4T9LQplZ6OsimHx7EuM9dfEh5HJa6D3trEftu3dA== +jest-message-util@^29.7.0: + version "29.7.0" + resolved "https://registry.yarnpkg.com/jest-message-util/-/jest-message-util-29.7.0.tgz#8bc392e204e95dfe7564abbe72a404e28e51f7f3" + integrity sha512-GBEV4GRADeP+qtB2+6u61stea8mGcOT4mCtrYISZwfu9/ISHFJ/5zOMXYbpBE9RsS5+Gb63DW4FgmnKJ79Kf6w== dependencies: "@babel/code-frame" "^7.12.13" "@jest/types" "^29.6.3" @@ -7633,7 +7670,7 @@ jest-message-util@^29.6.3: chalk "^4.0.0" graceful-fs "^4.2.9" micromatch "^4.0.4" - pretty-format "^29.6.3" + pretty-format "^29.7.0" slash "^3.0.0" stack-utils "^2.0.3" @@ -7645,14 +7682,14 @@ jest-mock@^27.0.6: "@jest/types" "^27.5.1" "@types/node" "*" -jest-mock@^29.6.3: - version "29.6.3" - resolved "https://registry.yarnpkg.com/jest-mock/-/jest-mock-29.6.3.tgz#433f3fd528c8ec5a76860177484940628bdf5e0a" - integrity sha512-Z7Gs/mOyTSR4yPsaZ72a/MtuK6RnC3JYqWONe48oLaoEcYwEDxqvbXz85G4SJrm2Z5Ar9zp6MiHF4AlFlRM4Pg== +jest-mock@^29.7.0: + version "29.7.0" + resolved "https://registry.yarnpkg.com/jest-mock/-/jest-mock-29.7.0.tgz#4e836cf60e99c6fcfabe9f99d017f3fdd50a6347" + integrity sha512-ITOMZn+UkYS4ZFh83xYAOzWStloNzJFO2s8DWrE4lhtGD+AorgnbkiKERe4wQVBydIGPx059g6riW5Btp6Llnw== dependencies: "@jest/types" "^29.6.3" "@types/node" "*" - jest-util "^29.6.3" + jest-util "^29.7.0" jest-pnp-resolver@^1.2.2: version "1.2.3" @@ -7664,67 +7701,67 @@ jest-regex-util@^29.6.3: resolved "https://registry.yarnpkg.com/jest-regex-util/-/jest-regex-util-29.6.3.tgz#4a556d9c776af68e1c5f48194f4d0327d24e8a52" integrity sha512-KJJBsRCyyLNWCNBOvZyRDnAIfUiRJ8v+hOBQYGn8gDyF3UegwiP4gwRR3/SDa42g1YbVycTidUF3rKjyLFDWbg== -jest-resolve-dependencies@^29.6.4: - version "29.6.4" - resolved "https://registry.yarnpkg.com/jest-resolve-dependencies/-/jest-resolve-dependencies-29.6.4.tgz#20156b33c7eacbb6bb77aeba4bed0eab4a3f8734" - integrity sha512-7+6eAmr1ZBF3vOAJVsfLj1QdqeXG+WYhidfLHBRZqGN24MFRIiKG20ItpLw2qRAsW/D2ZUUmCNf6irUr/v6KHA== +jest-resolve-dependencies@^29.7.0: + version "29.7.0" + resolved "https://registry.yarnpkg.com/jest-resolve-dependencies/-/jest-resolve-dependencies-29.7.0.tgz#1b04f2c095f37fc776ff40803dc92921b1e88428" + integrity sha512-un0zD/6qxJ+S0et7WxeI3H5XSe9lTBBR7bOHCHXkKR6luG5mwDDlIzVQ0V5cZCuoTgEdcdwzTghYkTWfubi+nA== dependencies: jest-regex-util "^29.6.3" - jest-snapshot "^29.6.4" + jest-snapshot "^29.7.0" -jest-resolve@^29.6.4: - version "29.6.4" - resolved "https://registry.yarnpkg.com/jest-resolve/-/jest-resolve-29.6.4.tgz#e34cb06f2178b429c38455d98d1a07572ac9faa3" - integrity sha512-fPRq+0vcxsuGlG0O3gyoqGTAxasagOxEuyoxHeyxaZbc9QNek0AmJWSkhjlMG+mTsj+8knc/mWb3fXlRNVih7Q== +jest-resolve@^29.7.0: + version "29.7.0" + resolved "https://registry.yarnpkg.com/jest-resolve/-/jest-resolve-29.7.0.tgz#64d6a8992dd26f635ab0c01e5eef4399c6bcbc30" + integrity sha512-IOVhZSrg+UvVAshDSDtHyFCCBUl/Q3AAJv8iZ6ZjnZ74xzvwuzLXid9IIIPgTnY62SJjfuupMKZsZQRsCvxEgA== dependencies: chalk "^4.0.0" graceful-fs "^4.2.9" - jest-haste-map "^29.6.4" + jest-haste-map "^29.7.0" jest-pnp-resolver "^1.2.2" - jest-util "^29.6.3" - jest-validate "^29.6.3" + jest-util "^29.7.0" + jest-validate "^29.7.0" resolve "^1.20.0" resolve.exports "^2.0.0" slash "^3.0.0" -jest-runner@^29.6.4: - version "29.6.4" - resolved "https://registry.yarnpkg.com/jest-runner/-/jest-runner-29.6.4.tgz#b3b8ccb85970fde0fae40c73ee11eb75adccfacf" - integrity sha512-SDaLrMmtVlQYDuG0iSPYLycG8P9jLI+fRm8AF/xPKhYDB2g6xDWjXBrR5M8gEWsK6KVFlebpZ4QsrxdyIX1Jaw== +jest-runner@^29.7.0: + version "29.7.0" + resolved "https://registry.yarnpkg.com/jest-runner/-/jest-runner-29.7.0.tgz#809af072d408a53dcfd2e849a4c976d3132f718e" + integrity sha512-fsc4N6cPCAahybGBfTRcq5wFR6fpLznMg47sY5aDpsoejOcVYFb07AHuSnR0liMcPTgBsA3ZJL6kFOjPdoNipQ== dependencies: - "@jest/console" "^29.6.4" - "@jest/environment" "^29.6.4" - "@jest/test-result" "^29.6.4" - "@jest/transform" "^29.6.4" + "@jest/console" "^29.7.0" + "@jest/environment" "^29.7.0" + "@jest/test-result" "^29.7.0" + "@jest/transform" "^29.7.0" "@jest/types" "^29.6.3" "@types/node" "*" chalk "^4.0.0" emittery "^0.13.1" graceful-fs "^4.2.9" - jest-docblock "^29.6.3" - jest-environment-node "^29.6.4" - jest-haste-map "^29.6.4" - jest-leak-detector "^29.6.3" - jest-message-util "^29.6.3" - jest-resolve "^29.6.4" - jest-runtime "^29.6.4" - jest-util "^29.6.3" - jest-watcher "^29.6.4" - jest-worker "^29.6.4" + jest-docblock "^29.7.0" + jest-environment-node "^29.7.0" + jest-haste-map "^29.7.0" + jest-leak-detector "^29.7.0" + jest-message-util "^29.7.0" + jest-resolve "^29.7.0" + jest-runtime "^29.7.0" + jest-util "^29.7.0" + jest-watcher "^29.7.0" + jest-worker "^29.7.0" p-limit "^3.1.0" source-map-support "0.5.13" -jest-runtime@^29.6.4: - version "29.6.4" - resolved "https://registry.yarnpkg.com/jest-runtime/-/jest-runtime-29.6.4.tgz#b0bc495c9b6b12a0a7042ac34ca9bb85f8cd0ded" - integrity sha512-s/QxMBLvmwLdchKEjcLfwzP7h+jsHvNEtxGP5P+Fl1FMaJX2jMiIqe4rJw4tFprzCwuSvVUo9bn0uj4gNRXsbA== +jest-runtime@^29.7.0: + version "29.7.0" + resolved "https://registry.yarnpkg.com/jest-runtime/-/jest-runtime-29.7.0.tgz#efecb3141cf7d3767a3a0cc8f7c9990587d3d817" + integrity sha512-gUnLjgwdGqW7B4LvOIkbKs9WGbn+QLqRQQ9juC6HndeDiezIwhDP+mhMwHWCEcfQ5RUXa6OPnFF8BJh5xegwwQ== dependencies: - "@jest/environment" "^29.6.4" - "@jest/fake-timers" "^29.6.4" - "@jest/globals" "^29.6.4" + "@jest/environment" "^29.7.0" + "@jest/fake-timers" "^29.7.0" + "@jest/globals" "^29.7.0" "@jest/source-map" "^29.6.3" - "@jest/test-result" "^29.6.4" - "@jest/transform" "^29.6.4" + "@jest/test-result" "^29.7.0" + "@jest/transform" "^29.7.0" "@jest/types" "^29.6.3" "@types/node" "*" chalk "^4.0.0" @@ -7732,46 +7769,46 @@ jest-runtime@^29.6.4: collect-v8-coverage "^1.0.0" glob "^7.1.3" graceful-fs "^4.2.9" - jest-haste-map "^29.6.4" - jest-message-util "^29.6.3" - jest-mock "^29.6.3" + jest-haste-map "^29.7.0" + jest-message-util "^29.7.0" + jest-mock "^29.7.0" jest-regex-util "^29.6.3" - jest-resolve "^29.6.4" - jest-snapshot "^29.6.4" - jest-util "^29.6.3" + jest-resolve "^29.7.0" + jest-snapshot "^29.7.0" + jest-util "^29.7.0" slash "^3.0.0" strip-bom "^4.0.0" -jest-snapshot@^29.6.4: - version "29.6.4" - resolved "https://registry.yarnpkg.com/jest-snapshot/-/jest-snapshot-29.6.4.tgz#9833eb6b66ff1541c7fd8ceaa42d541f407b4876" - integrity sha512-VC1N8ED7+4uboUKGIDsbvNAZb6LakgIPgAF4RSpF13dN6YaMokfRqO+BaqK4zIh6X3JffgwbzuGqDEjHm/MrvA== +jest-snapshot@^29.7.0: + version "29.7.0" + resolved "https://registry.yarnpkg.com/jest-snapshot/-/jest-snapshot-29.7.0.tgz#c2c574c3f51865da1bb329036778a69bf88a6be5" + integrity sha512-Rm0BMWtxBcioHr1/OX5YCP8Uov4riHvKPknOGs804Zg9JGZgmIBkbtlxJC/7Z4msKYVbIJtfU+tKb8xlYNfdkw== dependencies: "@babel/core" "^7.11.6" "@babel/generator" "^7.7.2" "@babel/plugin-syntax-jsx" "^7.7.2" "@babel/plugin-syntax-typescript" "^7.7.2" "@babel/types" "^7.3.3" - "@jest/expect-utils" "^29.6.4" - "@jest/transform" "^29.6.4" + "@jest/expect-utils" "^29.7.0" + "@jest/transform" "^29.7.0" "@jest/types" "^29.6.3" babel-preset-current-node-syntax "^1.0.0" chalk "^4.0.0" - expect "^29.6.4" + expect "^29.7.0" graceful-fs "^4.2.9" - jest-diff "^29.6.4" + jest-diff "^29.7.0" jest-get-type "^29.6.3" - jest-matcher-utils "^29.6.4" - jest-message-util "^29.6.3" - jest-util "^29.6.3" + jest-matcher-utils "^29.7.0" + jest-message-util "^29.7.0" + jest-util "^29.7.0" natural-compare "^1.4.0" - pretty-format "^29.6.3" + pretty-format "^29.7.0" semver "^7.5.3" -jest-util@^29.0.0, jest-util@^29.6.3: - version "29.6.3" - resolved "https://registry.yarnpkg.com/jest-util/-/jest-util-29.6.3.tgz#e15c3eac8716440d1ed076f09bc63ace1aebca63" - integrity sha512-QUjna/xSy4B32fzcKTSz1w7YYzgiHrjjJjevdRf61HYk998R5vVMMNmrHESYZVDS5DSWs+1srPLPKxXPkeSDOA== +jest-util@^29.0.0, jest-util@^29.7.0: + version "29.7.0" + resolved "https://registry.yarnpkg.com/jest-util/-/jest-util-29.7.0.tgz#23c2b62bfb22be82b44de98055802ff3710fc0bc" + integrity sha512-z6EbKajIpqGKU56y5KBUgy1dt1ihhQJgWzUlZHArA/+X2ad7Cb5iF+AK1EWVL/Bo7Rz9uurpqw6SiBCefUbCGA== dependencies: "@jest/types" "^29.6.3" "@types/node" "*" @@ -7780,30 +7817,30 @@ jest-util@^29.0.0, jest-util@^29.6.3: graceful-fs "^4.2.9" picomatch "^2.2.3" -jest-validate@^29.6.3: - version "29.6.3" - resolved "https://registry.yarnpkg.com/jest-validate/-/jest-validate-29.6.3.tgz#a75fca774cfb1c5758c70d035d30a1f9c2784b4d" - integrity sha512-e7KWZcAIX+2W1o3cHfnqpGajdCs1jSM3DkXjGeLSNmCazv1EeI1ggTeK5wdZhF+7N+g44JI2Od3veojoaumlfg== +jest-validate@^29.7.0: + version "29.7.0" + resolved "https://registry.yarnpkg.com/jest-validate/-/jest-validate-29.7.0.tgz#7bf705511c64da591d46b15fce41400d52147d9c" + integrity sha512-ZB7wHqaRGVw/9hST/OuFUReG7M8vKeq0/J2egIGLdvjHCmYqGARhzXmtgi+gVeZ5uXFF219aOc3Ls2yLg27tkw== dependencies: "@jest/types" "^29.6.3" camelcase "^6.2.0" chalk "^4.0.0" jest-get-type "^29.6.3" leven "^3.1.0" - pretty-format "^29.6.3" + pretty-format "^29.7.0" -jest-watcher@^29.6.4: - version "29.6.4" - resolved "https://registry.yarnpkg.com/jest-watcher/-/jest-watcher-29.6.4.tgz#633eb515ae284aa67fd6831f1c9d1b534cf0e0ba" - integrity sha512-oqUWvx6+On04ShsT00Ir9T4/FvBeEh2M9PTubgITPxDa739p4hoQweWPRGyYeaojgT0xTpZKF0Y/rSY1UgMxvQ== +jest-watcher@^29.7.0: + version "29.7.0" + resolved "https://registry.yarnpkg.com/jest-watcher/-/jest-watcher-29.7.0.tgz#7810d30d619c3a62093223ce6bb359ca1b28a2f2" + integrity sha512-49Fg7WXkU3Vl2h6LbLtMQ/HyB6rXSIX7SqvBLQmssRBGN9I0PNvPmAmCWSOY6SOvrjhI/F7/bGAv9RtnsPA03g== dependencies: - "@jest/test-result" "^29.6.4" + "@jest/test-result" "^29.7.0" "@jest/types" "^29.6.3" "@types/node" "*" ansi-escapes "^4.2.1" chalk "^4.0.0" emittery "^0.13.1" - jest-util "^29.6.3" + jest-util "^29.7.0" string-length "^4.0.1" jest-worker@^27.4.5: @@ -7815,25 +7852,25 @@ jest-worker@^27.4.5: merge-stream "^2.0.0" supports-color "^8.0.0" -jest-worker@^29.6.4: - version "29.6.4" - resolved "https://registry.yarnpkg.com/jest-worker/-/jest-worker-29.6.4.tgz#f34279f4afc33c872b470d4af21b281ac616abd3" - integrity sha512-6dpvFV4WjcWbDVGgHTWo/aupl8/LbBx2NSKfiwqf79xC/yeJjKHT1+StcKy/2KTmW16hE68ccKVOtXf+WZGz7Q== +jest-worker@^29.7.0: + version "29.7.0" + resolved "https://registry.yarnpkg.com/jest-worker/-/jest-worker-29.7.0.tgz#acad073acbbaeb7262bd5389e1bcf43e10058d4a" + integrity sha512-eIz2msL/EzL9UFTFFx7jBTkeZfku0yUAyZZZmJ93H2TYEiroIx2PQjEXcwYtYl8zXCxb+PAmA2hLIt/6ZEkPHw== dependencies: "@types/node" "*" - jest-util "^29.6.3" + jest-util "^29.7.0" merge-stream "^2.0.0" supports-color "^8.0.0" jest@^29.6.2: - version "29.6.4" - resolved "https://registry.yarnpkg.com/jest/-/jest-29.6.4.tgz#7c48e67a445ba264b778253b5d78d4ebc9d0a622" - integrity sha512-tEFhVQFF/bzoYV1YuGyzLPZ6vlPrdfvDmmAxudA1dLEuiztqg2Rkx20vkKY32xiDROcD2KXlgZ7Cu8RPeEHRKw== + version "29.7.0" + resolved "https://registry.yarnpkg.com/jest/-/jest-29.7.0.tgz#994676fc24177f088f1c5e3737f5697204ff2613" + integrity sha512-NIy3oAFp9shda19hy4HK0HRTWKtPJmGdnvywu01nOqNC2vZg+Z+fvJDxpMQA88eb2I9EcafcdjYgsDthnYTvGw== dependencies: - "@jest/core" "^29.6.4" + "@jest/core" "^29.7.0" "@jest/types" "^29.6.3" import-local "^3.0.2" - jest-cli "^29.6.4" + jest-cli "^29.7.0" js-levenshtein@^1.1.6: version "1.1.6" @@ -8390,9 +8427,9 @@ minipass@^5.0.0: integrity sha512-3FnjYuehv9k6ovOEbyOswadCDPX1piCfhV8ncmYtHOjuPwylVWsghTLo7rabjC3Rx5xD4HDx8Wm1xnMF7S5qFQ== "minipass@^5.0.0 || ^6.0.2 || ^7.0.0": - version "7.0.3" - resolved "https://registry.yarnpkg.com/minipass/-/minipass-7.0.3.tgz#05ea638da44e475037ed94d1c7efcc76a25e1974" - integrity sha512-LhbbwCfz3vsb12j/WkWQPZfKTsgqIe1Nf/ti1pKjYESGLHIVjWU96G9/ljLH4F9mWNVhlQOm0VySdAWzf05dpg== + version "7.0.4" + resolved "https://registry.yarnpkg.com/minipass/-/minipass-7.0.4.tgz#dbce03740f50a4786ba994c1fb908844d27b038c" + integrity sha512-jYofLM5Dam9279rdkWzqHozUo4ybjdZmCsDHePy5V/PbBcVMiSZR97gmAy45aqi8CK1lG2ECd356FU86avfwUQ== minizlib@^2.1.1: version "2.1.2" @@ -8429,11 +8466,6 @@ ms@2.0.0: resolved "https://registry.yarnpkg.com/ms/-/ms-2.0.0.tgz#5608aeadfc00be6c2901df5f9861788de0d597c8" integrity sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A== -ms@2.1.1: - version "2.1.1" - resolved "https://registry.yarnpkg.com/ms/-/ms-2.1.1.tgz#30a5864eb3ebb0a66f2ebe6d727af06a09d86e0a" - integrity sha512-tgp+dl5cGk28utYktBsrFqA7HKgrhgPsg6Z/EfhWI4gl1Hwq8B/GmY/0oXZ6nF8hDVesS/FpnYaD/kOWhYQvyg== - ms@2.1.2: version "2.1.2" resolved "https://registry.yarnpkg.com/ms/-/ms-2.1.2.tgz#d09d1f357b443f493382a8eb3ccd183872ae6009" @@ -8452,20 +8484,20 @@ msw-storybook-addon@^1.8.0: is-node-process "^1.0.1" msw@^1.2.3: - version "1.3.0" - resolved "https://registry.yarnpkg.com/msw/-/msw-1.3.0.tgz#dc1f80a79f4719610e45a87e77d1ec32720bbb6d" - integrity sha512-nnWAZlQyQOKeYRblCpseT1kSPt1aF5e/jHz1hn/18IxbsMFreSVV1cJriT0uV+YG6+wvwFRMHXU3zVuMvuwERQ== + version "1.3.2" + resolved "https://registry.yarnpkg.com/msw/-/msw-1.3.2.tgz#35e0271293e893fc3c55116e90aad5d955c66899" + integrity sha512-wKLhFPR+NitYTkQl5047pia0reNGgf0P6a1eTnA5aNlripmiz0sabMvvHcicE8kQ3/gZcI0YiPFWmYfowfm3lA== dependencies: "@mswjs/cookies" "^0.2.2" - "@mswjs/interceptors" "^0.17.5" + "@mswjs/interceptors" "^0.17.10" "@open-draft/until" "^1.0.3" "@types/cookie" "^0.4.1" "@types/js-levenshtein" "^1.1.1" chalk "^4.1.1" chokidar "^3.4.2" cookie "^0.4.2" - graphql "^15.0.0 || ^16.0.0" - headers-polyfill "^3.2.0" + graphql "^16.8.1" + headers-polyfill "3.2.5" inquirer "^8.2.0" is-node-process "^1.2.0" js-levenshtein "^1.1.6" @@ -9081,9 +9113,9 @@ postcss-value-parser@^4.0.2, postcss-value-parser@^4.1.0, postcss-value-parser@^ integrity sha512-1NNCs6uurfkVbeXG4S8JFT9t19m45ICnif8zWLd5oPSZ50QnwMfK+H3jv408d4jw/7Bttv5axS5IiHoLaVNHeQ== postcss@^8.4.21, postcss@^8.4.23, postcss@^8.4.27, postcss@^8.4.29: - version "8.4.29" - resolved "https://registry.yarnpkg.com/postcss/-/postcss-8.4.29.tgz#33bc121cf3b3688d4ddef50be869b2a54185a1dd" - integrity sha512-cbI+jaqIeu/VGqXEarWkRCCffhjgXc0qjBtXpqJhTBohMUjUQnbBr0xqX3vEKudc4iviTewcJo5ajcec5+wdJw== + version "8.4.31" + resolved "https://registry.yarnpkg.com/postcss/-/postcss-8.4.31.tgz#92b451050a9f914da6755af352bdc0192508656d" + integrity sha512-PS08Iboia9mts/2ygV3eLpY5ghnUcfLV/EXTOW1E2qYxJKGGBUtNjN76FYHnMs36RmARn41bC0AZmn+rR0OVpQ== dependencies: nanoid "^3.3.6" picocolors "^1.0.0" @@ -9116,10 +9148,10 @@ pretty-format@^27.0.2: ansi-styles "^5.0.0" react-is "^17.0.1" -pretty-format@^29.0.0, pretty-format@^29.6.3: - version "29.6.3" - resolved "https://registry.yarnpkg.com/pretty-format/-/pretty-format-29.6.3.tgz#d432bb4f1ca6f9463410c3fb25a0ba88e594ace7" - integrity sha512-ZsBgjVhFAj5KeK+nHfF1305/By3lechHQSMWCTl8iHSbfOm2TN5nHEtFc/+W7fAyUeCs2n5iow72gld4gW0xDw== +pretty-format@^29.0.0, pretty-format@^29.7.0: + version "29.7.0" + resolved "https://registry.yarnpkg.com/pretty-format/-/pretty-format-29.7.0.tgz#ca42c758310f365bfa71a0bda0a807160b776812" + integrity sha512-Pdlw/oPxN+aXdmM9R00JVC9WVFoCLTKJvDVLgmJ+qAffBMxsV85l/Lu7sNx4zSzPyoL2euImuEwHhOXdEgNFZQ== dependencies: "@jest/schemas" "^29.6.3" ansi-styles "^5.0.0" @@ -9232,9 +9264,9 @@ puppeteer-core@^2.1.1: ws "^6.1.0" pure-rand@^6.0.0: - version "6.0.3" - resolved "https://registry.yarnpkg.com/pure-rand/-/pure-rand-6.0.3.tgz#3c9e6b53c09e52ac3cedffc85ab7c1c7094b38cb" - integrity sha512-KddyFewCsO0j3+np81IQ+SweXLDnDQTs5s67BOnrYmYe/yNmUhttQyGsYzy8yUnoljGAQ9sl38YB4vH8ur7Y+w== + version "6.0.4" + resolved "https://registry.yarnpkg.com/pure-rand/-/pure-rand-6.0.4.tgz#50b737f6a925468679bff00ad20eade53f37d5c7" + integrity sha512-LA0Y9kxMYv47GIPJy6MI84fqTd2HmYZI83W/kM/SkKfDlajnZYfmXFTxkbY+xSBPkLJxltMa9hIkmdc29eguMA== qs@6.11.0: version "6.11.0" @@ -9397,19 +9429,19 @@ react-remove-scroll@2.5.5: use-sidecar "^1.1.2" react-router-dom@^6.14.2: - version "6.15.0" - resolved "https://registry.yarnpkg.com/react-router-dom/-/react-router-dom-6.15.0.tgz#6da7db61e56797266fbbef0d5e324d6ac443ee40" - integrity sha512-aR42t0fs7brintwBGAv2+mGlCtgtFQeOzK0BM1/OiqEzRejOZtpMZepvgkscpMUnKb8YO84G7s3LsHnnDNonbQ== + version "6.16.0" + resolved "https://registry.yarnpkg.com/react-router-dom/-/react-router-dom-6.16.0.tgz#86f24658da35eb66727e75ecbb1a029e33ee39d9" + integrity sha512-aTfBLv3mk/gaKLxgRDUPbPw+s4Y/O+ma3rEN1u8EgEpLpPe6gNjIsWt9rxushMHHMb7mSwxRGdGlGdvmFsyPIg== dependencies: - "@remix-run/router" "1.8.0" - react-router "6.15.0" + "@remix-run/router" "1.9.0" + react-router "6.16.0" -react-router@6.15.0: - version "6.15.0" - resolved "https://registry.yarnpkg.com/react-router/-/react-router-6.15.0.tgz#bf2cb5a4a7ed57f074d4ea88db0d95033f39cac8" - integrity sha512-NIytlzvzLwJkCQj2HLefmeakxxWHWAP+02EGqWEZy+DgfHHKQMUoBBjUQLOtFInBMhWtb3hiUy6MfFgwLjXhqg== +react-router@6.16.0: + version "6.16.0" + resolved "https://registry.yarnpkg.com/react-router/-/react-router-6.16.0.tgz#abbf3d5bdc9c108c9b822a18be10ee004096fb81" + integrity sha512-VT4Mmc4jj5YyjpOi5jOf0I+TYzGpvzERy4ckNSvSh2RArv8LLoCxlsZ2D+tc7zgjxcY34oTz2hZaeX5RVprKqA== dependencies: - "@remix-run/router" "1.8.0" + "@remix-run/router" "1.9.0" react-style-singleton@^2.2.1: version "2.2.1" @@ -9538,7 +9570,7 @@ redent@^4.0.0: indent-string "^5.0.0" strip-indent "^4.0.0" -reflect.getprototypeof@^1.0.3: +reflect.getprototypeof@^1.0.4: version "1.0.4" resolved "https://registry.yarnpkg.com/reflect.getprototypeof/-/reflect.getprototypeof-1.0.4.tgz#aaccbf41aca3821b87bb71d9dcbc7ad0ba50a3f3" integrity sha512-ECkTw8TmJwW60lOTR+ZkODISW6RQ8+2CL3COqtiJKLd6MmB45hN51HprHFziKLGkAuTGQhBb91V8cy+KHlaCjw== @@ -9551,9 +9583,9 @@ reflect.getprototypeof@^1.0.3: which-builtin-type "^1.1.3" regenerate-unicode-properties@^10.1.0: - version "10.1.0" - resolved "https://registry.yarnpkg.com/regenerate-unicode-properties/-/regenerate-unicode-properties-10.1.0.tgz#7c3192cab6dd24e21cb4461e5ddd7dd24fa8374c" - integrity sha512-d1VudCLoIGitcU/hEg2QqvyGZQmdC0Lf8BqdOMXGFSvJP4bNV1+XqbPQeHHLD51Jh4QJJ225dlIFvY4Ly6MXmQ== + version "10.1.1" + resolved "https://registry.yarnpkg.com/regenerate-unicode-properties/-/regenerate-unicode-properties-10.1.1.tgz#6b0e05489d9076b04c436f318d9b067bba459480" + integrity sha512-X007RyZLsCJVVrjgEFVpLUTZwyOZk3oiL75ZcuYjlIWd6rNJtOjkBwQc5AsRrpbKVkxN6sklw/k/9m2jJYOf8Q== dependencies: regenerate "^1.4.2" @@ -9574,14 +9606,14 @@ regenerator-transform@^0.15.2: dependencies: "@babel/runtime" "^7.8.4" -regexp.prototype.flags@^1.5.0: - version "1.5.0" - resolved "https://registry.yarnpkg.com/regexp.prototype.flags/-/regexp.prototype.flags-1.5.0.tgz#fe7ce25e7e4cca8db37b6634c8a2c7009199b9cb" - integrity sha512-0SutC3pNudRKgquxGoRGIz946MZVHqbNfPjBdxeOhBrdgDKlRoXmYLQN9xRbrR09ZXWeGAdPuif7egofn6v5LA== +regexp.prototype.flags@^1.5.0, regexp.prototype.flags@^1.5.1: + version "1.5.1" + resolved "https://registry.yarnpkg.com/regexp.prototype.flags/-/regexp.prototype.flags-1.5.1.tgz#90ce989138db209f81492edd734183ce99f9677e" + integrity sha512-sy6TXMN+hnP/wMy+ISxg3krXx7BAtWVO4UouuCN/ziM9UEne0euamVNafDfvC83bRNr95y0V5iijeDQFUNpvrg== dependencies: call-bind "^1.0.2" define-properties "^1.2.0" - functions-have-names "^1.2.3" + set-function-name "^2.0.0" regexpu-core@^5.3.1: version "5.3.2" @@ -9691,9 +9723,9 @@ resolve.exports@^2.0.0: integrity sha512-X2UW6Nw3n/aMgDVy+0rSqgHlv39WZAlZrXCdnbyEiKm17DSqHX4MmQMaST3FbeWR5FTuRcUwYAziZajji0Y7mg== resolve@^1.10.0, resolve@^1.14.2, resolve@^1.20.0, resolve@^1.22.4: - version "1.22.4" - resolved "https://registry.yarnpkg.com/resolve/-/resolve-1.22.4.tgz#1dc40df46554cdaf8948a486a10f6ba1e2026c34" - integrity sha512-PXNdCiPqDqeUou+w1C2eTQbNfxKSuMxqTCuvlmmMsk1NWHL5fRrhY6Pl0qEYYc6+QqGClco1Qj8XnjPego4wfg== + version "1.22.6" + resolved "https://registry.yarnpkg.com/resolve/-/resolve-1.22.6.tgz#dd209739eca3aef739c626fea1b4f3c506195362" + integrity sha512-njhxM7mV12JfufShqGy3Rz8j11RPdLy4xi15UurGJeoHLfJpVXKdh3ueuOqbYUcDZnffr6X739JBo5LzyahEsw== dependencies: is-core-module "^2.13.0" path-parse "^1.0.7" @@ -9766,7 +9798,7 @@ rxjs@^7.5.5: dependencies: tslib "^2.1.0" -safe-array-concat@^1.0.0: +safe-array-concat@^1.0.1: version "1.0.1" resolved "https://registry.yarnpkg.com/safe-array-concat/-/safe-array-concat-1.0.1.tgz#91686a63ce3adbea14d61b14c99572a8ff84754c" integrity sha512-6XbUAseYE2KtOuGueyeobCySj9L4+66Tn6KQMOPQJrAJEowYKW/YR/MGJZl7FdydUdaFu4LYyDZjxf4/Nmo23Q== @@ -9776,11 +9808,6 @@ safe-array-concat@^1.0.0: has-symbols "^1.0.3" isarray "^2.0.5" -safe-buffer@5.1.1: - version "5.1.1" - resolved "https://registry.yarnpkg.com/safe-buffer/-/safe-buffer-5.1.1.tgz#893312af69b2123def71f57889001671eeb2c853" - integrity sha512-kKvNJn6Mm93gAczWVJg7wH+wGYWNrDHdWvpUmHyEsgCtIwwo3bqPtV4tR5tuPaUhTOo/kvhVwd8XwwOllGYkbg== - safe-buffer@5.1.2, safe-buffer@~5.1.0, safe-buffer@~5.1.1: version "5.1.2" resolved "https://registry.yarnpkg.com/safe-buffer/-/safe-buffer-5.1.2.tgz#991ec69d296e0313747d59bdfd2b745c35f8828d" @@ -9893,17 +9920,6 @@ serialize-javascript@^6.0.0, serialize-javascript@^6.0.1: dependencies: randombytes "^2.1.0" -serve-favicon@^2.5.0: - version "2.5.0" - resolved "https://registry.yarnpkg.com/serve-favicon/-/serve-favicon-2.5.0.tgz#935d240cdfe0f5805307fdfe967d88942a2cbcf0" - integrity sha512-FMW2RvqNr03x+C0WxTyu6sOv21oOjkq5j8tjquWccwa6ScNyGFOGJVpuS1NmTVGBAHS07xnSKotgf2ehQmf9iA== - dependencies: - etag "~1.8.1" - fresh "0.5.2" - ms "2.1.1" - parseurl "~1.3.2" - safe-buffer "5.1.1" - serve-index@^1.9.1: version "1.9.1" resolved "https://registry.yarnpkg.com/serve-index/-/serve-index-1.9.1.tgz#d3768d69b1e7d82e5ce050fff5b453bea12a9239" @@ -9932,6 +9948,15 @@ set-cookie-parser@^2.4.6: resolved "https://registry.yarnpkg.com/set-cookie-parser/-/set-cookie-parser-2.6.0.tgz#131921e50f62ff1a66a461d7d62d7b21d5d15a51" integrity sha512-RVnVQxTXuerk653XfuliOxBP81Sf0+qfQE73LIYKcyMYHG94AuH0kgrQpRDuTZnSmjpysHmzxJXKNfa6PjFhyQ== +set-function-name@^2.0.0, set-function-name@^2.0.1: + version "2.0.1" + resolved "https://registry.yarnpkg.com/set-function-name/-/set-function-name-2.0.1.tgz#12ce38b7954310b9f61faa12701620a0c882793a" + integrity sha512-tMNCiqYVkXIZgc2Hnoy2IvC/f8ezc5koaRFkCjrpWzGpCd3qbZXPzVy9MAZzK1ch/X0jvSkojys3oqJN0qCmdA== + dependencies: + define-data-property "^1.0.1" + functions-have-names "^1.2.3" + has-property-descriptors "^1.0.0" + setprototypeof@1.1.0: version "1.1.0" resolved "https://registry.yarnpkg.com/setprototypeof/-/setprototypeof-1.1.0.tgz#d0bd85536887b6fe7c0d818cb962d9d91c54e656" @@ -10101,9 +10126,9 @@ spdx-expression-parse@^3.0.0: spdx-license-ids "^3.0.0" spdx-license-ids@^3.0.0: - version "3.0.13" - resolved "https://registry.yarnpkg.com/spdx-license-ids/-/spdx-license-ids-3.0.13.tgz#7189a474c46f8d47c7b0da4b987bb45e908bd2d5" - integrity sha512-XkD+zwiqXHikFZm4AX/7JSCXA98U5Db4AFd5XUg/+9UNtnH75+Z9KxtpYiJZx36mUDVOwH83pl7yvCer6ewM3w== + version "3.0.15" + resolved "https://registry.yarnpkg.com/spdx-license-ids/-/spdx-license-ids-3.0.15.tgz#142460aabaca062bc7cd4cc87b7d50725ed6a4ba" + integrity sha512-lpT8hSQp9jAKp9mhtBU4Xjon8LPGBvLIuBiSVhMEtmLecTh2mO0tlqrAMp47tBXzMr13NJMQ2lf7RpQGLJ3HsQ== spdy-transport@^3.0.0: version "3.0.0" @@ -10168,11 +10193,11 @@ store2@^2.14.2: integrity sha512-siT1RiqlfQnGqgT/YzXVUNsom9S0H1OX+dpdGN1xkyYATo4I6sep5NmsRD/40s3IIOvlCq6akxkqG82urIZW1w== storybook@^7.1.1: - version "7.4.0" - resolved "https://registry.yarnpkg.com/storybook/-/storybook-7.4.0.tgz#f1b64222e3d474bc6e258eb7e48c675685829873" - integrity sha512-jSwbyxHlr2dTY51Pv0mzenjrMDJNZH7DQhHu4ZezpjV+QK/rLCnD+Gt/7iDSaNlsmZJejQcmURDoEybWggMOqw== + version "7.4.6" + resolved "https://registry.yarnpkg.com/storybook/-/storybook-7.4.6.tgz#f688649af6c2cd1329dd120d8f61a930f76262d0" + integrity sha512-YkFSpnR47j5zz7yElA+2axLjXN7K7TxDGJRHHlqXmG5iQ0PXzmjrj2RxMDKFz4Ybp/QjEUoJ4rx//ESEY0Nb5A== dependencies: - "@storybook/cli" "7.4.0" + "@storybook/cli" "7.4.6" stream-shift@^1.0.0: version "1.0.1" @@ -10209,9 +10234,9 @@ string-width@^4.1.0, string-width@^4.2.0, string-width@^4.2.3: strip-ansi "^6.0.1" string.prototype.matchall@^4.0.8: - version "4.0.9" - resolved "https://registry.yarnpkg.com/string.prototype.matchall/-/string.prototype.matchall-4.0.9.tgz#148779de0f75d36b13b15885fec5cadde994520d" - integrity sha512-6i5hL3MqG/K2G43mWXWgP+qizFW/QH/7kCNN13JrJS5q48FN5IKksLDscexKP3dnmB6cdm9jlNgAsWNLpSykmA== + version "4.0.10" + resolved "https://registry.yarnpkg.com/string.prototype.matchall/-/string.prototype.matchall-4.0.10.tgz#a1553eb532221d4180c51581d6072cd65d1ee100" + integrity sha512-rGXbGmOEosIQi6Qva94HUjgPs9vKW+dkG7Y8Q5O2OYkWL6wFaTRZO8zM4mhP94uX55wgyrXzfS2aGtGzUL7EJQ== dependencies: call-bind "^1.0.2" define-properties "^1.2.0" @@ -10220,9 +10245,10 @@ string.prototype.matchall@^4.0.8: has-symbols "^1.0.3" internal-slot "^1.0.5" regexp.prototype.flags "^1.5.0" + set-function-name "^2.0.0" side-channel "^1.0.4" -string.prototype.trim@^1.2.7: +string.prototype.trim@^1.2.8: version "1.2.8" resolved "https://registry.yarnpkg.com/string.prototype.trim/-/string.prototype.trim-1.2.8.tgz#f9ac6f8af4bd55ddfa8895e6aea92a96395393bd" integrity sha512-lfjY4HcixfQXOfaqCvcBuOIapyaroTXhbkfJN3gcB1OtyupngWK4sEET9Knd0cXd28kTUqu/kHoV4HKSJdnjiQ== @@ -10231,7 +10257,7 @@ string.prototype.trim@^1.2.7: define-properties "^1.2.0" es-abstract "^1.22.1" -string.prototype.trimend@^1.0.6: +string.prototype.trimend@^1.0.7: version "1.0.7" resolved "https://registry.yarnpkg.com/string.prototype.trimend/-/string.prototype.trimend-1.0.7.tgz#1bb3afc5008661d73e2dc015cd4853732d6c471e" integrity sha512-Ni79DqeB72ZFq1uH/L6zJ+DKZTkOtPIHovb3YZHQViE+HDouuU4mBrLOLDn5Dde3RF8qw5qVETEjhu9locMLvA== @@ -10240,7 +10266,7 @@ string.prototype.trimend@^1.0.6: define-properties "^1.2.0" es-abstract "^1.22.1" -string.prototype.trimstart@^1.0.6: +string.prototype.trimstart@^1.0.7: version "1.0.7" resolved "https://registry.yarnpkg.com/string.prototype.trimstart/-/string.prototype.trimstart-1.0.7.tgz#d4cdb44b83a4737ffbac2d406e405d43d0184298" integrity sha512-NGhtDFu3jCEm7B4Fy0DpLewdJQOZcQ0rGbwQ/+stjnrp2i+rlKeCvos9hOIeCmqwratM47OBxY7uFZzjxHXmrg== @@ -10315,9 +10341,9 @@ style-search@^0.1.0: integrity sha512-Dj1Okke1C3uKKwQcetra4jSuk0DqbzbYtXipzFlFMZtowbF1x7BKJwB9AayVMyFARvU8EDrZdcax4At/452cAg== styled-components@^6.0.2: - version "6.0.7" - resolved "https://registry.yarnpkg.com/styled-components/-/styled-components-6.0.7.tgz#1cf4a5e6b6181b29f941934df54af19b7ef05ab0" - integrity sha512-xIwWuiRMYR43mskVsW9MGTRjSo7ol4bcVjT595fGUp3OLBJOlOgaiKaxsHdC4a2HqWKqKnh0CmcRbk5ogyDjTg== + version "6.0.8" + resolved "https://registry.yarnpkg.com/styled-components/-/styled-components-6.0.8.tgz#90617ad60de39772e03a81c8f3b8e66c12f51c44" + integrity sha512-AwI02MTWZwqjzfXgR5QcbmcSn5xVjY4N2TLjSuYnmuBGF3y7GicHz3ysbpUq2EMJP5M8/Nc22vcmF3V3WNZDFA== dependencies: "@babel/cli" "^7.21.0" "@babel/core" "^7.21.0" @@ -10406,9 +10432,9 @@ stylis@^4.3.0: integrity sha512-E87pIogpwUsUwXw7dNyU4QDjdgVMy52m+XEOPEKUn161cCzWjjhPSQhByfd1CcNvrOLnXQ6OnnZDwnJrz/Z4YQ== superjson@^1.10.0: - version "1.13.1" - resolved "https://registry.yarnpkg.com/superjson/-/superjson-1.13.1.tgz#a0b6ab5d22876f6207fcb9d08b0cb2acad8ee5cd" - integrity sha512-AVH2eknm9DEd3qvxM4Sq+LTCkSXE2ssfh1t11MHMXyYXFQyQ1HLgVvV+guLTsaQnJU3gnaVo34TohHPulY/wLg== + version "1.13.3" + resolved "https://registry.yarnpkg.com/superjson/-/superjson-1.13.3.tgz#3bd64046f6c0a47062850bb3180ef352a471f930" + integrity sha512-mJiVjfd2vokfDxsQPOwJ/PtanO87LhpYY88ubI5dUB1Ab58Txbyje3+jpm+/83R/fevaq/107NNhtYBLuoTrFg== dependencies: copy-anything "^3.0.2" @@ -10579,9 +10605,9 @@ terser-webpack-plugin@^5.3.1, terser-webpack-plugin@^5.3.7: terser "^5.16.8" terser@^5.10.0, terser@^5.16.8: - version "5.19.4" - resolved "https://registry.yarnpkg.com/terser/-/terser-5.19.4.tgz#941426fa482bf9b40a0308ab2b3cd0cf7c775ebd" - integrity sha512-6p1DjHeuluwxDXcuT9VR8p64klWJKo1ILiy19s6C9+0Bh2+NWTX6nD9EPppiER4ICkHDVB1RkVpin/YW2nQn/g== + version "5.21.0" + resolved "https://registry.yarnpkg.com/terser/-/terser-5.21.0.tgz#d2b27e92b5e56650bc83b6defa00a110f0b124b2" + integrity sha512-WtnFKrxu9kaoXuiZFSGrcAvvBqAdmKx0SFNmVNYdJamMu9yyN3I/QF0FbH4QcqJQ+y1CJnzxGIKH0cSj+FGYRw== dependencies: "@jridgewell/source-map" "^0.3.3" acorn "^8.8.2" @@ -10934,11 +10960,11 @@ unpipe@1.0.0, unpipe@~1.0.0: integrity sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ== unplugin@^1.3.1: - version "1.4.0" - resolved "https://registry.yarnpkg.com/unplugin/-/unplugin-1.4.0.tgz#b771373aa1bc664f50a044ee8009bd3a7aa04d85" - integrity sha512-5x4eIEL6WgbzqGtF9UV8VEC/ehKptPXDS6L2b0mv4FRMkJxRtjaJfOWDd6a8+kYbqsjklix7yWP0N3SUepjXcg== + version "1.5.0" + resolved "https://registry.yarnpkg.com/unplugin/-/unplugin-1.5.0.tgz#8938ae84defe62afc7757df9ca05d27160f6c20c" + integrity sha512-9ZdRwbh/4gcm1JTOkp9lAkIDrtOyOxgHmY7cjuwI8L/2RTikMcVG25GsZwNAgRuap3iDw2jeq7eoqtAsz5rW3A== dependencies: - acorn "^8.9.0" + acorn "^8.10.0" chokidar "^3.5.3" webpack-sources "^3.2.3" webpack-virtual-modules "^0.5.0" @@ -10948,10 +10974,10 @@ untildify@^4.0.0: resolved "https://registry.yarnpkg.com/untildify/-/untildify-4.0.0.tgz#2bc947b953652487e4600949fb091e3ae8cd919b" integrity sha512-KK8xQ1mkzZeg9inewmFVDNkg3l5LUhoq9kN6iWYB/CC9YMG8HA+c1Q8HwDe6dEX7kErrEVNVBO3fWsVq5iDgtw== -update-browserslist-db@^1.0.11: - version "1.0.11" - resolved "https://registry.yarnpkg.com/update-browserslist-db/-/update-browserslist-db-1.0.11.tgz#9a2a641ad2907ae7b3616506f4b977851db5b940" - integrity sha512-dCwEFf0/oT85M1fHBg4F0jtLwJrutGoHSQXCh7u4o2t1drG+c0a9Flnqww6XUKSfQMPpJBRjU8d4RXB09qtvaA== +update-browserslist-db@^1.0.13: + version "1.0.13" + resolved "https://registry.yarnpkg.com/update-browserslist-db/-/update-browserslist-db-1.0.13.tgz#3c5e4f5c083661bd38ef64b6328c26ed6c8248c4" + integrity sha512-xebP81SNcPuNpPP3uzeW1NYXxI3rxyJzF3pD6sH4jE7o/IX+WtSpwnVU+qIsDPyk0d3hmFQ7mjqc6AtV604hbg== dependencies: escalade "^3.1.1" picocolors "^1.0.0" @@ -10972,9 +10998,9 @@ url-parse@^1.5.3: requires-port "^1.0.0" url@^0.11.0: - version "0.11.2" - resolved "https://registry.yarnpkg.com/url/-/url-0.11.2.tgz#02f250a6e0d992b781828cd456d44f49bf2e19dd" - integrity sha512-7yIgNnrST44S7PJ5+jXbdIupfU1nWUdQJBFBeJRclPXiWgCvrSq5Frw8lr/i//n5sqDfzoKmBymMS81l4U/7cg== + version "0.11.3" + resolved "https://registry.yarnpkg.com/url/-/url-0.11.3.tgz#6f495f4b935de40ce4a0a52faee8954244f3d3ad" + integrity sha512-6hxOLGfZASQK/cijlZnZJTq8OXAkt/3YGfQX45vvMYXpZoo8NdWZcY73K108Jf759lS1Bv/8wXnHDTSz17dSRw== dependencies: punycode "^1.4.1" qs "^6.11.2" @@ -11038,18 +11064,18 @@ uuid@^8.3.2: integrity sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg== uuid@^9.0.0: - version "9.0.0" - resolved "https://registry.yarnpkg.com/uuid/-/uuid-9.0.0.tgz#592f550650024a38ceb0c562f2f6aa435761efb5" - integrity sha512-MXcSTerfPa4uqyzStbRoTgt5XIe3x5+42+q1sDuy3R5MDk66URdLMOZe5aPX/SQd+kuYAh0FdP/pO28IkQyTeg== + version "9.0.1" + resolved "https://registry.yarnpkg.com/uuid/-/uuid-9.0.1.tgz#e188d4c8853cc722220392c424cd637f32293f30" + integrity sha512-b+1eJOlsR9K8HJpow9Ok3fiWOWSIcIzXodvv0rQjVoOVNpWMpxf1wZNpt4y9h10odCNrqnYp1OBzRktckBe3sA== v8-to-istanbul@^9.0.0, v8-to-istanbul@^9.0.1: - version "9.1.0" - resolved "https://registry.yarnpkg.com/v8-to-istanbul/-/v8-to-istanbul-9.1.0.tgz#1b83ed4e397f58c85c266a570fc2558b5feb9265" - integrity sha512-6z3GW9x8G1gd+JIIgQQQxXuiJtCXeAjp6RaPEPLv62mH3iPHPxV6W3robxtCzNErRo6ZwTmzWhsbNvjyEBKzKA== + version "9.1.2" + resolved "https://registry.yarnpkg.com/v8-to-istanbul/-/v8-to-istanbul-9.1.2.tgz#51168df21c8ca01c83285f27316549b2c51a5b46" + integrity sha512-ZGBe7VAivuuoQXTeckpbYKTdtjXGcm3ZUHXC0PAk0CzFyuYvwi73a58iEKI3GkGD1c3EHc+EgfR1w5pgbfzJlQ== dependencies: "@jridgewell/trace-mapping" "^0.3.12" "@types/istanbul-lib-coverage" "^2.0.1" - convert-source-map "^1.6.0" + convert-source-map "^2.0.0" validate-npm-package-license@^3.0.1: version "3.0.4" @@ -11334,7 +11360,7 @@ which-collection@^1.0.1: is-weakmap "^2.0.1" is-weakset "^2.0.1" -which-typed-array@^1.1.10, which-typed-array@^1.1.11, which-typed-array@^1.1.2, which-typed-array@^1.1.9: +which-typed-array@^1.1.11, which-typed-array@^1.1.2, which-typed-array@^1.1.9: version "1.1.11" resolved "https://registry.yarnpkg.com/which-typed-array/-/which-typed-array-1.1.11.tgz#99d691f23c72aab6768680805a271b69761ed61a" integrity sha512-qe9UWWpkeG5yzZ0tNYxDmd7vo58HDBc39mZ0xWWpolAGADdFOzkfamWLDxkOWcvHQKVmdTyQdLD4NOfjLWTKew== @@ -11425,9 +11451,9 @@ ws@^6.1.0: async-limiter "~1.0.0" ws@^8.11.0, ws@^8.13.0, ws@^8.2.3: - version "8.14.1" - resolved "https://registry.yarnpkg.com/ws/-/ws-8.14.1.tgz#4b9586b4f70f9e6534c7bb1d3dc0baa8b8cf01e0" - integrity sha512-4OOseMUq8AzRBI/7SLMUwO+FEDnguetSk7KMb1sHwvF2w2Wv5Hoj0nlifx8vtGsftE/jWHojPy8sMMzYLJ2G/A== + version "8.14.2" + resolved "https://registry.yarnpkg.com/ws/-/ws-8.14.2.tgz#6c249a806eb2db7a20d26d51e7709eab7b2e6c7f" + integrity sha512-wEBG1ftX4jcglPxgFCMJmZ2PLtSbJ2Peg6TmpJFTbe9GZYOQCDPdMYu/Tm0/bGZkw8paZnJY45J4K2PZrLYq8g== xml-name-validator@^4.0.0: version "4.0.0" From 1800ceb294ca2446eb16668d0e77a640be25b659 Mon Sep 17 00:00:00 2001 From: Taeeun Kim Date: Mon, 9 Oct 2023 15:59:54 +0900 Subject: [PATCH 02/55] =?UTF-8?q?[FE]=20=EB=A1=9C=EA=B7=B8=EC=9D=B8=20?= =?UTF-8?q?=EC=95=88=ED=96=88=EC=9D=84=20=EB=95=8C=EB=8F=84=20=EB=A6=AC?= =?UTF-8?q?=EB=B7=B0=EB=A5=BC=201=EA=B0=9C=20=ED=99=95=EC=9D=B8=ED=95=A0?= =?UTF-8?q?=20=EC=88=98=20=EC=9E=88=EB=8A=94=20=EA=B8=B0=EB=8A=A5=20?= =?UTF-8?q?=EA=B5=AC=ED=98=84=20(#725)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat: 베스트 리뷰 컴포넌트 추가 * chore: 디자인시스템 버전업 * feat: 태그 리스트 디자인 변경 * feat: 베스트 리뷰 가져오는 쿼리 작성 * feat: index.ts에서 export문 추가 * feat: 태그 위치 변경 * feat: UI 변경 --- frontend/package.json | 2 +- .../src/components/Common/TagList/TagList.tsx | 3 +- .../ProductDetailItem/ProductDetailItem.tsx | 2 +- .../BestReviewItem/BestReviewItem.stories.tsx | 17 ++++ .../Review/BestReviewItem/BestReviewItem.tsx | 98 +++++++++++++++++++ frontend/src/components/Review/index.ts | 1 + frontend/src/hooks/queries/rank/index.ts | 1 + .../hooks/queries/rank/useBestReviewQuery.ts | 16 +++ .../src/mocks/handlers/rankingHandlers.ts | 5 + frontend/src/pages/ProductDetailPage.tsx | 5 +- frontend/yarn.lock | 8 +- 11 files changed, 150 insertions(+), 8 deletions(-) create mode 100644 frontend/src/components/Review/BestReviewItem/BestReviewItem.stories.tsx create mode 100644 frontend/src/components/Review/BestReviewItem/BestReviewItem.tsx create mode 100644 frontend/src/hooks/queries/rank/useBestReviewQuery.ts diff --git a/frontend/package.json b/frontend/package.json index ddc1de60a..51619c2d1 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -13,7 +13,7 @@ "test:coverage": "jest --watchAll --coverage" }, "dependencies": { - "@fun-eat/design-system": "^0.3.12", + "@fun-eat/design-system": "^0.3.13", "@tanstack/react-query": "^4.32.6", "@tanstack/react-query-devtools": "^4.32.6", "dayjs": "^1.11.9", diff --git a/frontend/src/components/Common/TagList/TagList.tsx b/frontend/src/components/Common/TagList/TagList.tsx index abf2817ee..b36cd70aa 100644 --- a/frontend/src/components/Common/TagList/TagList.tsx +++ b/frontend/src/components/Common/TagList/TagList.tsx @@ -15,7 +15,7 @@ const TagList = ({ tags }: TagListProps) => { const tagColor = convertTagColor(tag.tagType); return (
  • - + {tag.name}
  • @@ -29,6 +29,7 @@ export default TagList; const TagListContainer = styled.ul` display: flex; + margin: 12px 0; column-gap: 8px; `; diff --git a/frontend/src/components/Product/ProductDetailItem/ProductDetailItem.tsx b/frontend/src/components/Product/ProductDetailItem/ProductDetailItem.tsx index 5d87be4b3..52cf05ada 100644 --- a/frontend/src/components/Product/ProductDetailItem/ProductDetailItem.tsx +++ b/frontend/src/components/Product/ProductDetailItem/ProductDetailItem.tsx @@ -29,6 +29,7 @@ const ProductDetailItem = ({ category, productDetail }: ProductDetailItemProps) )} + 가격 {price.toLocaleString('ko-KR')}원 @@ -45,7 +46,6 @@ const ProductDetailItem = ({ category, productDetail }: ProductDetailItemProps) - ); }; diff --git a/frontend/src/components/Review/BestReviewItem/BestReviewItem.stories.tsx b/frontend/src/components/Review/BestReviewItem/BestReviewItem.stories.tsx new file mode 100644 index 000000000..bc8c75e77 --- /dev/null +++ b/frontend/src/components/Review/BestReviewItem/BestReviewItem.stories.tsx @@ -0,0 +1,17 @@ +import type { Meta, StoryObj } from '@storybook/react'; + +import BestReviewItem from './BestReviewItem'; + +const meta: Meta = { + title: 'review/BestReviewItem', + component: BestReviewItem, +}; + +export default meta; +type Story = StoryObj; + +export const Default: Story = { + args: { + productId: 2, + }, +}; diff --git a/frontend/src/components/Review/BestReviewItem/BestReviewItem.tsx b/frontend/src/components/Review/BestReviewItem/BestReviewItem.tsx new file mode 100644 index 000000000..8d9059dd4 --- /dev/null +++ b/frontend/src/components/Review/BestReviewItem/BestReviewItem.tsx @@ -0,0 +1,98 @@ +import { Spacing, Text, useTheme } from '@fun-eat/design-system'; +import styled from 'styled-components'; + +import { SvgIcon } from '@/components/Common'; +import { useBestReviewQuery } from '@/hooks/queries/rank'; + +interface BestReviewItemProps { + productId: number; +} + +const BestReviewItem = ({ productId }: BestReviewItemProps) => { + const { data: bestReview } = useBestReviewQuery(productId); + const { profileImage, userName, rating, favoriteCount, content } = bestReview; + + const theme = useTheme(); + + return ( + <> + + ⭐️ 베스트 리뷰 ⭐️ + + + {Object.keys(bestReview).length !== 0 && ( + + + + +
    + + {userName} 님 + + {Array.from({ length: 5 }, (_, index) => ( + + ))} +
    +
    + + + + {favoriteCount} + + +
    + + + {content} + +
    + )} + + ); +}; + +export default BestReviewItem; + +const BestReviewItemContainer = styled.div` + padding: 10px; + border: 1px solid ${({ theme }) => theme.borderColors.disabled}; + border-radius: 5px; +`; + +const ReviewRateFavoriteWrapper = styled.div` + display: flex; + justify-content: space-between; + align-items: flex-end; +`; + +const ReviewerInfoWrapper = styled.div` + display: flex; + align-items: center; + column-gap: 10px; +`; + +const ReviewerImage = styled.img` + border: 2px solid ${({ theme }) => theme.colors.primary}; + border-radius: 50%; + object-fit: cover; +`; + +const FavoriteWrapper = styled.div` + display: flex; + gap: 8px; + align-items: center; +`; + +const ReviewText = styled(Text)` + display: -webkit-inline-box; + text-overflow: ellipsis; + overflow: hidden; + -webkit-line-clamp: 2; + -webkit-box-orient: vertical; +`; diff --git a/frontend/src/components/Review/index.ts b/frontend/src/components/Review/index.ts index 73a7530d9..ac1fa9cd4 100644 --- a/frontend/src/components/Review/index.ts +++ b/frontend/src/components/Review/index.ts @@ -3,3 +3,4 @@ export { default as ReviewList } from './ReviewList/ReviewList'; export { default as ReviewTagItem } from './ReviewTagItem/ReviewTagItem'; export { default as ReviewTagList } from './ReviewTagList/ReviewTagList'; export { default as ReviewRegisterForm } from './ReviewRegisterForm/ReviewRegisterForm'; +export { default as BestReviewItem } from './BestReviewItem/BestReviewItem'; diff --git a/frontend/src/hooks/queries/rank/index.ts b/frontend/src/hooks/queries/rank/index.ts index 34eacd269..6fd3cd034 100644 --- a/frontend/src/hooks/queries/rank/index.ts +++ b/frontend/src/hooks/queries/rank/index.ts @@ -1,3 +1,4 @@ export { default as useProductRankingQuery } from './useProductRankingQuery'; export { default as useReviewRankingQuery } from './useReviewRankingQuery'; export { default as useRecipeRankingQuery } from './useRecipeRankingQuery'; +export { default as useBestReviewQuery } from './useBestReviewQuery'; diff --git a/frontend/src/hooks/queries/rank/useBestReviewQuery.ts b/frontend/src/hooks/queries/rank/useBestReviewQuery.ts new file mode 100644 index 000000000..20ca25398 --- /dev/null +++ b/frontend/src/hooks/queries/rank/useBestReviewQuery.ts @@ -0,0 +1,16 @@ +import { useSuspendedQuery } from '../useSuspendedQuery'; + +import { rankApi } from '@/apis'; +import type { Review } from '@/types/review'; + +const fetchBestReview = async (productId: number) => { + const response = await rankApi.get({ params: `/products/${productId}/reviews` }); + const data: Review = await response.json(); + return data; +}; + +const useBestReviewQuery = (productId: number) => { + return useSuspendedQuery(['bestReview', productId], () => fetchBestReview(productId)); +}; + +export default useBestReviewQuery; diff --git a/frontend/src/mocks/handlers/rankingHandlers.ts b/frontend/src/mocks/handlers/rankingHandlers.ts index 2c4ea7fd9..869f7d969 100644 --- a/frontend/src/mocks/handlers/rankingHandlers.ts +++ b/frontend/src/mocks/handlers/rankingHandlers.ts @@ -3,6 +3,7 @@ import { rest } from 'msw'; import mockProductRankingList from '../data/productRankingList.json'; import mockRecipeRankingList from '../data/recipeRankingList.json'; import mockReviewRankingList from '../data/reviewRankingList.json'; +import mockReviewList from '../data/reviews.json'; export const rankingHandlers = [ rest.get('/api/ranks/products', (req, res, ctx) => { @@ -16,4 +17,8 @@ export const rankingHandlers = [ rest.get('/api/ranks/recipes', (req, res, ctx) => { return res(ctx.status(200), ctx.json(mockRecipeRankingList), ctx.delay(1000)); }), + + rest.get('/api/ranks/products/:productId/reviews', (req, res, ctx) => { + return res(ctx.status(200), ctx.json(mockReviewList.reviews[2])); + }), ]; diff --git a/frontend/src/pages/ProductDetailPage.tsx b/frontend/src/pages/ProductDetailPage.tsx index 46cc74b18..f41985856 100644 --- a/frontend/src/pages/ProductDetailPage.tsx +++ b/frontend/src/pages/ProductDetailPage.tsx @@ -16,7 +16,7 @@ import { SectionTitle, } from '@/components/Common'; import { ProductDetailItem, ProductRecipeList } from '@/components/Product'; -import { ReviewList, ReviewRegisterForm } from '@/components/Review'; +import { BestReviewItem, ReviewList, ReviewRegisterForm } from '@/components/Review'; import { RECIPE_SORT_OPTIONS, REVIEW_SORT_OPTIONS } from '@/constants'; import { PATH } from '@/constants/path'; import ReviewFormProvider from '@/contexts/ReviewFormContext'; @@ -33,6 +33,7 @@ export const ProductDetailPage = () => { const { category, productId } = useParams(); const { data: member } = useMemberQuery(); const { data: productDetail } = useProductDetailQuery(Number(productId)); + const { reset } = useQueryErrorResetBoundary(); const { selectedTabMenu, isFirstTabMenu: isReviewTab, handleTabMenuClick, initTabMenu } = useTabMenu(); @@ -77,6 +78,8 @@ export const ProductDetailPage = () => { + + Date: Tue, 10 Oct 2023 17:00:09 +0900 Subject: [PATCH 03/55] =?UTF-8?q?[BE]=20refactor:=20=EB=A0=88=EC=8B=9C?= =?UTF-8?q?=ED=94=BC=20=EC=A2=8B=EC=95=84=EC=9A=94=20api=EC=97=90=EC=84=9C?= =?UTF-8?q?=20=EB=B6=88=ED=95=84=EC=9A=94=ED=95=98=EA=B2=8C=20=EB=82=98?= =?UTF-8?q?=EA=B0=80=EB=8A=94=20=EC=BF=BC=EB=A6=AC=20=EC=A0=9C=EA=B1=B0=20?= =?UTF-8?q?(#680)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * refactor: RecipeFavorite의 favorite 필드의 디폴트값 제거 및 create시 Recipe의 favoriteCount더하기 로직 추가 * refactor: RecipeService의 createAndSaveRecipeFavorite에서 request의 favorite정보로 recipeFavorite을 만들도록 수정 --- .../domain/favorite/RecipeFavorite.java | 14 +++++----- .../recipe/application/RecipeService.java | 6 ++--- .../domain/favorite/RecipeFavoriteTest.java | 27 +++---------------- 3 files changed, 13 insertions(+), 34 deletions(-) diff --git a/backend/src/main/java/com/funeat/member/domain/favorite/RecipeFavorite.java b/backend/src/main/java/com/funeat/member/domain/favorite/RecipeFavorite.java index ab69e7d98..161f83483 100644 --- a/backend/src/main/java/com/funeat/member/domain/favorite/RecipeFavorite.java +++ b/backend/src/main/java/com/funeat/member/domain/favorite/RecipeFavorite.java @@ -28,24 +28,22 @@ public class RecipeFavorite { @JoinColumn(name = "recipe_id") private Recipe recipe; - private Boolean favorite = false; + private Boolean favorite; protected RecipeFavorite() { } - public RecipeFavorite(final Member member, final Recipe recipe) { - this.member = member; - this.recipe = recipe; - } - public RecipeFavorite(final Member member, final Recipe recipe, final Boolean favorite) { this.member = member; this.recipe = recipe; this.favorite = favorite; } - public static RecipeFavorite create(final Member member, final Recipe recipe) { - return new RecipeFavorite(member, recipe); + public static RecipeFavorite create(final Member member, final Recipe recipe, final Boolean favorite) { + if (favorite == true) { + recipe.addFavoriteCount(); + } + return new RecipeFavorite(member, recipe, favorite); } public void updateFavorite(final Boolean favorite) { diff --git a/backend/src/main/java/com/funeat/recipe/application/RecipeService.java b/backend/src/main/java/com/funeat/recipe/application/RecipeService.java index 1fd173fce..d67fd7b79 100644 --- a/backend/src/main/java/com/funeat/recipe/application/RecipeService.java +++ b/backend/src/main/java/com/funeat/recipe/application/RecipeService.java @@ -161,14 +161,14 @@ public void likeRecipe(final Long memberId, final Long recipeId, final RecipeFav .orElseThrow(() -> new RecipeNotFoundException(RECIPE_NOT_FOUND, recipeId)); final RecipeFavorite recipeFavorite = recipeFavoriteRepository.findByMemberAndRecipe(member, recipe) - .orElseGet(() -> createAndSaveRecipeFavorite(member, recipe)); + .orElseGet(() -> createAndSaveRecipeFavorite(member, recipe, request.getFavorite())); recipeFavorite.updateFavorite(request.getFavorite()); } - private RecipeFavorite createAndSaveRecipeFavorite(final Member member, final Recipe recipe) { + private RecipeFavorite createAndSaveRecipeFavorite(final Member member, final Recipe recipe, final Boolean favorite) { try { - final RecipeFavorite recipeFavorite = RecipeFavorite.create(member, recipe); + final RecipeFavorite recipeFavorite = RecipeFavorite.create(member, recipe, favorite); return recipeFavoriteRepository.save(recipeFavorite); } catch (final DataIntegrityViolationException e) { throw new MemberDuplicateFavoriteException(MEMBER_DUPLICATE_FAVORITE, member.getId()); diff --git a/backend/src/test/java/com/funeat/member/domain/favorite/RecipeFavoriteTest.java b/backend/src/test/java/com/funeat/member/domain/favorite/RecipeFavoriteTest.java index 99f69ae56..4b2cc940b 100644 --- a/backend/src/test/java/com/funeat/member/domain/favorite/RecipeFavoriteTest.java +++ b/backend/src/test/java/com/funeat/member/domain/favorite/RecipeFavoriteTest.java @@ -10,23 +10,6 @@ class RecipeFavoriteTest { - @Nested - class create_성공_테스트 { - - @Test - void create를_통한_생성시_favorite은_false로_초기화된다() { - // given - final var member = 멤버_멤버1_생성(); - final var recipe = 레시피_생성(member); - - // when - final var actual = RecipeFavorite.create(member, recipe); - - // then - assertThat(actual.getFavorite()).isFalse(); - } - } - @Nested class updateFavorite_성공_테스트 { @@ -35,7 +18,7 @@ class updateFavorite_성공_테스트 { // given final var member = 멤버_멤버1_생성(); final var recipe = 레시피_생성(member); - final var recipeFavorite = RecipeFavorite.create(member, recipe); + final var recipeFavorite = RecipeFavorite.create(member, recipe, false); // when recipeFavorite.updateFavorite(true); @@ -55,8 +38,7 @@ class updateFavorite_성공_테스트 { final var member = 멤버_멤버1_생성(); final var recipe = 레시피_생성(member); - final var recipeFavorite = RecipeFavorite.create(member, recipe); - recipeFavorite.updateFavorite(true); + final var recipeFavorite = RecipeFavorite.create(member, recipe, true); // when recipeFavorite.updateFavorite(false); @@ -76,8 +58,7 @@ class updateFavorite_성공_테스트 { final var member = 멤버_멤버1_생성(); final var recipe = 레시피_생성(member); - final var recipeFavorite = RecipeFavorite.create(member, recipe); - recipeFavorite.updateFavorite(true); + final var recipeFavorite = RecipeFavorite.create(member, recipe, true); // when recipeFavorite.updateFavorite(true); @@ -97,7 +78,7 @@ class updateFavorite_성공_테스트 { final var member = 멤버_멤버1_생성(); final var recipe = 레시피_생성(member); - final var recipeFavorite = RecipeFavorite.create(member, recipe); + final var recipeFavorite = RecipeFavorite.create(member, recipe, false); // when recipeFavorite.updateFavorite(false); From d4a820e63c5d1801c282d1deaf14e7de3a9f7c40 Mon Sep 17 00:00:00 2001 From: Dabeen Jeong Date: Tue, 10 Oct 2023 17:24:01 +0900 Subject: [PATCH 04/55] =?UTF-8?q?[BE]=20=ED=8A=B9=EC=A0=95=20=EC=83=81?= =?UTF-8?q?=ED=92=88=EC=97=90=EC=84=9C=20=EC=A2=8B=EC=95=84=EC=9A=94?= =?UTF-8?q?=EB=A5=BC=20=EA=B0=80=EC=9E=A5=20=EB=A7=8E=EC=9D=B4=20=EB=B0=9B?= =?UTF-8?q?=EC=9D=80=20=EB=A6=AC=EB=B7=B0=EB=A5=BC=20=EC=9D=91=EB=8B=B5?= =?UTF-8?q?=ED=95=98=EB=8A=94=20API=20=EC=97=B0=EA=B2=B0=20(#724)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * test: 인수 테스트 생성 * feat: Swagger에 내용 추가 * test: 성공 테스트 보완 및 실패 테스트 추가 * feat: 좋아요를 가장 많이 받은 리뷰 응답 DTO 기능 구현 * feat: 좋아요를 가장 많이 받은 리뷰 기능 구현 * test: Repository 테스트 추가 * test: Service 테스트 추가 * refactor: 리뷰가 존재하지 않으면 빈응답을 생성 * refactor: 오타 수정 * refactor: 피드백 반영 가장 좋아요 리뷰에서 사용자의 좋아요 여부는 프론트엔드에서 필요하지 않음 (현재 dto도 false 고정) Null 따로 처리해서 빈값 보내지 않아도 자동으로 Null이면 빈 응답 반환함 DTO에 final 추가 * refactor: 컨벤션에 맞게 수정 * refactor: 테스트하는 메서드와 다른 테스트 이름을 제대로된 이름으로 변경 * refactor: null 명시 없이 Optional로 관리 * refactor: 리뷰가 존재하지 않는 경우 204 No Content를 반환 * refactor: given && when 에서 코드 컨벤션에 맞게 given & then으로 수정 --- .../review/application/ReviewService.java | 10 ++ .../dto/MostFavoriteReviewResponse.java | 106 ++++++++++++++++++ .../review/persistence/ReviewRepository.java | 2 + .../presentation/ReviewApiController.java | 13 +++ .../review/presentation/ReviewController.java | 10 ++ .../acceptance/auth/AuthAcceptanceTest.java | 4 +- .../member/MemberAcceptanceTest.java | 12 +- .../product/ProductAcceptanceTest.java | 4 +- .../recipe/RecipeAcceptanceTest.java | 4 +- .../review/ReviewAcceptanceTest.java | 71 +++++++++++- .../funeat/acceptance/review/ReviewSteps.java | 8 ++ .../review/application/ReviewServiceTest.java | 97 ++++++++++++++++ .../persistence/ReviewRepositoryTest.java | 29 ++++- 13 files changed, 356 insertions(+), 14 deletions(-) create mode 100644 backend/src/main/java/com/funeat/review/dto/MostFavoriteReviewResponse.java diff --git a/backend/src/main/java/com/funeat/review/application/ReviewService.java b/backend/src/main/java/com/funeat/review/application/ReviewService.java index ec7020cad..ee482a8c5 100644 --- a/backend/src/main/java/com/funeat/review/application/ReviewService.java +++ b/backend/src/main/java/com/funeat/review/application/ReviewService.java @@ -20,6 +20,7 @@ import com.funeat.product.persistence.ProductRepository; import com.funeat.review.domain.Review; import com.funeat.review.domain.ReviewTag; +import com.funeat.review.dto.MostFavoriteReviewResponse; import com.funeat.review.dto.RankingReviewDto; import com.funeat.review.dto.RankingReviewsResponse; import com.funeat.review.dto.ReviewCreateRequest; @@ -178,4 +179,13 @@ public MemberReviewsResponse findReviewByMember(final Long memberId, final Pagea return MemberReviewsResponse.toResponse(pageDto, dtos); } + + public Optional getMostFavoriteReview(final Long productId) { + final Product findProduct = productRepository.findById(productId) + .orElseThrow(() -> new ProductNotFoundException(PRODUCT_NOT_FOUND, productId)); + + final Optional review = reviewRepository.findTopByProductOrderByFavoriteCountDescIdDesc(findProduct); + + return MostFavoriteReviewResponse.toResponse(review); + } } diff --git a/backend/src/main/java/com/funeat/review/dto/MostFavoriteReviewResponse.java b/backend/src/main/java/com/funeat/review/dto/MostFavoriteReviewResponse.java new file mode 100644 index 000000000..0d1352f80 --- /dev/null +++ b/backend/src/main/java/com/funeat/review/dto/MostFavoriteReviewResponse.java @@ -0,0 +1,106 @@ +package com.funeat.review.dto; + +import com.funeat.review.domain.Review; +import com.funeat.review.domain.ReviewTag; +import com.funeat.tag.dto.TagDto; +import java.time.LocalDateTime; +import java.util.List; +import java.util.Optional; +import java.util.stream.Collectors; + +public class MostFavoriteReviewResponse { + + private final Long id; + private final String userName; + private final String profileImage; + private final String image; + private final Long rating; + private final List tags; + private final String content; + private final Boolean rebuy; + private final Long favoriteCount; + private final LocalDateTime createdAt; + + public MostFavoriteReviewResponse(final Long id, final String userName, final String profileImage, + final String image, final Long rating, final List tags, + final String content, final boolean rebuy, final Long favoriteCount, + final LocalDateTime createdAt) { + this.id = id; + this.userName = userName; + this.profileImage = profileImage; + this.image = image; + this.rating = rating; + this.tags = tags; + this.content = content; + this.rebuy = rebuy; + this.favoriteCount = favoriteCount; + this.createdAt = createdAt; + } + + public static Optional toResponse(final Optional nullableReview) { + if (nullableReview.isEmpty()) { + return Optional.empty(); + } + + final Review review = nullableReview.get(); + return Optional.of(new MostFavoriteReviewResponse( + review.getId(), + review.getMember().getNickname(), + review.getMember().getProfileImage(), + review.getImage(), + review.getRating(), + findTagDtos(review), + review.getContent(), + review.getReBuy(), + review.getFavoriteCount(), + review.getCreatedAt() + )); + } + + private static List findTagDtos(final Review review) { + return review.getReviewTags().stream() + .map(ReviewTag::getTag) + .map(TagDto::toDto) + .collect(Collectors.toList()); + } + + public Long getId() { + return id; + } + + public String getUserName() { + return userName; + } + + public String getProfileImage() { + return profileImage; + } + + public String getImage() { + return image; + } + + public Long getRating() { + return rating; + } + + public List getTags() { + return tags; + } + + public String getContent() { + return content; + } + + public Boolean isRebuy() { + return rebuy; + } + + public Long getFavoriteCount() { + return favoriteCount; + } + + public LocalDateTime getCreatedAt() { + return createdAt; + } +} diff --git a/backend/src/main/java/com/funeat/review/persistence/ReviewRepository.java b/backend/src/main/java/com/funeat/review/persistence/ReviewRepository.java index 0fa4e050a..f5ed0058f 100644 --- a/backend/src/main/java/com/funeat/review/persistence/ReviewRepository.java +++ b/backend/src/main/java/com/funeat/review/persistence/ReviewRepository.java @@ -34,4 +34,6 @@ public interface ReviewRepository extends JpaRepository { + "WHERE p.id = :id AND r.image != '' " + "ORDER BY r.favoriteCount DESC, r.id DESC") List findPopularReviewWithImage(@Param("id") final Long productId, final Pageable pageable); + + Optional findTopByProductOrderByFavoriteCountDescIdDesc(final Product product); } diff --git a/backend/src/main/java/com/funeat/review/presentation/ReviewApiController.java b/backend/src/main/java/com/funeat/review/presentation/ReviewApiController.java index 00c7683b6..57bf20359 100644 --- a/backend/src/main/java/com/funeat/review/presentation/ReviewApiController.java +++ b/backend/src/main/java/com/funeat/review/presentation/ReviewApiController.java @@ -4,11 +4,14 @@ import com.funeat.auth.util.AuthenticationPrincipal; import com.funeat.common.logging.Logging; import com.funeat.review.application.ReviewService; +import com.funeat.review.dto.MostFavoriteReviewResponse; import com.funeat.review.dto.RankingReviewsResponse; import com.funeat.review.dto.ReviewCreateRequest; import com.funeat.review.dto.ReviewFavoriteRequest; import com.funeat.review.dto.SortingReviewsResponse; import java.net.URI; +import java.util.Objects; +import java.util.Optional; import javax.validation.Valid; import org.springframework.data.domain.Pageable; import org.springframework.data.web.PageableDefault; @@ -70,4 +73,14 @@ public ResponseEntity getRankingReviews() { return ResponseEntity.ok(response); } + + @GetMapping("/api/ranks/products/{productId}/reviews") + public ResponseEntity> getMostFavoriteReview(@PathVariable final Long productId) { + final Optional response = reviewService.getMostFavoriteReview(productId); + + if (response.isEmpty()) { + return ResponseEntity.noContent().build(); + } + return ResponseEntity.ok(response); + } } diff --git a/backend/src/main/java/com/funeat/review/presentation/ReviewController.java b/backend/src/main/java/com/funeat/review/presentation/ReviewController.java index 574e50fb4..886ee5a15 100644 --- a/backend/src/main/java/com/funeat/review/presentation/ReviewController.java +++ b/backend/src/main/java/com/funeat/review/presentation/ReviewController.java @@ -2,6 +2,7 @@ import com.funeat.auth.dto.LoginInfo; import com.funeat.auth.util.AuthenticationPrincipal; +import com.funeat.review.dto.MostFavoriteReviewResponse; import com.funeat.review.dto.RankingReviewsResponse; import com.funeat.review.dto.ReviewCreateRequest; import com.funeat.review.dto.ReviewFavoriteRequest; @@ -9,6 +10,7 @@ import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.responses.ApiResponse; import io.swagger.v3.oas.annotations.tags.Tag; +import java.util.Optional; import org.springframework.data.domain.Pageable; import org.springframework.data.web.PageableDefault; import org.springframework.http.ResponseEntity; @@ -61,4 +63,12 @@ ResponseEntity getSortingReviews(@AuthenticationPrincipa ) @GetMapping ResponseEntity getRankingReviews(); + + @Operation(summary = "좋아요를 제일 많은 받은 리뷰 조회", description = "특정 상품에 대해 좋아요를 제일 많이 받은 리뷰를 조회한다.") + @ApiResponse( + responseCode = "200", + description = "좋아요를 제일 많이 받은 리뷰 조회 성공." + ) + @GetMapping + ResponseEntity> getMostFavoriteReview(@PathVariable final Long productId); } diff --git a/backend/src/test/java/com/funeat/acceptance/auth/AuthAcceptanceTest.java b/backend/src/test/java/com/funeat/acceptance/auth/AuthAcceptanceTest.java index 20c547dd1..77270c589 100644 --- a/backend/src/test/java/com/funeat/acceptance/auth/AuthAcceptanceTest.java +++ b/backend/src/test/java/com/funeat/acceptance/auth/AuthAcceptanceTest.java @@ -54,7 +54,7 @@ class loginAuthorizeUser_성공_테스트 { @Test void 신규_유저라면_마이페이지_경로를_헤더에_담아_응답을_보낸다() { - // given && when + // given & when final var 응답 = 로그인_시도_요청(멤버1); // then @@ -81,7 +81,7 @@ class logout_성공_테스트 { @Test void 로그아웃을_하다() { - // given && when + // given & when final var 응답 = 로그아웃_요청(로그인_쿠키_획득(멤버1)); // then diff --git a/backend/src/test/java/com/funeat/acceptance/member/MemberAcceptanceTest.java b/backend/src/test/java/com/funeat/acceptance/member/MemberAcceptanceTest.java index 9af331623..31ea4a5e4 100644 --- a/backend/src/test/java/com/funeat/acceptance/member/MemberAcceptanceTest.java +++ b/backend/src/test/java/com/funeat/acceptance/member/MemberAcceptanceTest.java @@ -68,7 +68,7 @@ class getMemberProfile_성공_테스트 { @Test void 사용자_정보를_확인하다() { - // given && when + // given & when final var 응답 = 사용자_정보_조회_요청(로그인_쿠키_획득(멤버1)); // then @@ -98,7 +98,7 @@ class putMemberProfile_성공_테스트 { @Test void 사용자_정보를_수정하다() { - // given && when + // given & when final var 응답 = 사용자_정보_수정_요청(로그인_쿠키_획득(멤버1), 사진_명세_요청(이미지1), 유저닉네임수정요청_생성("after")); // then @@ -107,7 +107,7 @@ class putMemberProfile_성공_테스트 { @Test void 사용자_닉네임을_수정하다() { - // given && when + // given & when final var 응답 = 사용자_정보_수정_요청(로그인_쿠키_획득(멤버1), 사진_명세_요청(이미지1), 유저닉네임수정요청_생성("member1")); // then @@ -116,7 +116,7 @@ class putMemberProfile_성공_테스트 { @Test void 사용자_이미지를_수정하다() { - // given && when + // given & when final var 응답 = 사용자_정보_수정_요청(로그인_쿠키_획득(멤버1), 사진_명세_요청(이미지2), 유저닉네임수정요청_생성("after")); // then @@ -130,7 +130,7 @@ class putMemberProfile_실패_테스트 { @ParameterizedTest @NullAndEmptySource void 로그인_하지않은_사용자가_사용자_정보_수정시_예외가_발생한다(final String cookie) { - // given && when + // given & when final var 응답 = 사용자_정보_수정_요청(cookie, 사진_명세_요청(이미지1), 유저닉네임수정요청_생성("after")); // then @@ -142,7 +142,7 @@ class putMemberProfile_실패_테스트 { @ParameterizedTest @NullAndEmptySource void 사용자가_사용자_정보_수정할때_닉네임_미기입시_예외가_발생한다(final String nickname) { - // given && when + // given & when final var 응답 = 사용자_정보_수정_요청(로그인_쿠키_획득(멤버1), 사진_명세_요청(이미지1), 유저닉네임수정요청_생성(nickname)); // then diff --git a/backend/src/test/java/com/funeat/acceptance/product/ProductAcceptanceTest.java b/backend/src/test/java/com/funeat/acceptance/product/ProductAcceptanceTest.java index f6469f384..03b2d9032 100644 --- a/backend/src/test/java/com/funeat/acceptance/product/ProductAcceptanceTest.java +++ b/backend/src/test/java/com/funeat/acceptance/product/ProductAcceptanceTest.java @@ -343,7 +343,7 @@ class getAllProductsInCategory_실패_테스트 { @Test void 상품을_정렬할때_카테고리가_존재하지_않으면_예외가_발생한다() { - // given && when + // given & when final var 응답 = 카테고리별_상품_목록_조회_요청(존재하지_않는_카테고리, 가격_내림차순, FIRST_PAGE); // then @@ -382,7 +382,7 @@ class getProductDetail_실패_테스트 { @Test void 존재하지_않는_상품_상세_정보를_조회할때_예외가_발생한다() { - // given && when + // given & when final var 응답 = 상품_상세_조회_요청(존재하지_않는_상품); // then diff --git a/backend/src/test/java/com/funeat/acceptance/recipe/RecipeAcceptanceTest.java b/backend/src/test/java/com/funeat/acceptance/recipe/RecipeAcceptanceTest.java index dc39065cb..af0b74801 100644 --- a/backend/src/test/java/com/funeat/acceptance/recipe/RecipeAcceptanceTest.java +++ b/backend/src/test/java/com/funeat/acceptance/recipe/RecipeAcceptanceTest.java @@ -268,7 +268,7 @@ class getRecipeDetail_실패_테스트 { @Test void 존재하지_않는_레시피_사용자가_레시피_상세_조회시_예외가_발생한다() { - // given && when + // given & when final var 응답 = 레시피_상세_정보_요청(로그인_쿠키_획득(멤버1), 존재하지_않는_레시피); // then @@ -356,7 +356,7 @@ class likeRecipe_실패_테스트 { @Test void 존재하지_않는_레시피에_사용자가_좋아요를_할때_예외가_발생한다() { - // given && when + // given & when final var 응답 = 레시피_좋아요_요청(로그인_쿠키_획득(멤버1), 존재하지_않는_레시피, 레시피좋아요요청_생성(좋아요O)); // then diff --git a/backend/src/test/java/com/funeat/acceptance/review/ReviewAcceptanceTest.java b/backend/src/test/java/com/funeat/acceptance/review/ReviewAcceptanceTest.java index 07552c34c..b1c7218db 100644 --- a/backend/src/test/java/com/funeat/acceptance/review/ReviewAcceptanceTest.java +++ b/backend/src/test/java/com/funeat/acceptance/review/ReviewAcceptanceTest.java @@ -16,6 +16,7 @@ import static com.funeat.acceptance.review.ReviewSteps.리뷰_좋아요_요청; import static com.funeat.acceptance.review.ReviewSteps.여러명이_리뷰_좋아요_요청; import static com.funeat.acceptance.review.ReviewSteps.정렬된_리뷰_목록_조회_요청; +import static com.funeat.acceptance.review.ReviewSteps.좋아요를_제일_많이_받은_리뷰_조회_요청; import static com.funeat.auth.exception.AuthErrorCode.LOGIN_MEMBER_NOT_FOUND; import static com.funeat.exception.CommonErrorCode.REQUEST_VALID_ERROR_CODE; import static com.funeat.fixture.CategoryFixture.카테고리_즉석조리_생성; @@ -38,6 +39,7 @@ import static com.funeat.fixture.PageFixture.최신순; import static com.funeat.fixture.PageFixture.평점_내림차순; import static com.funeat.fixture.PageFixture.평점_오름차순; +import static com.funeat.fixture.ProductFixture.상품1; import static com.funeat.fixture.ProductFixture.상품_삼각김밥_가격1000원_평점3점_생성; import static com.funeat.fixture.ProductFixture.상품_삼각김밥_가격2000원_평점3점_생성; import static com.funeat.fixture.ProductFixture.존재하지_않는_상품; @@ -572,7 +574,7 @@ class getSortingReviews_실패_테스트 { @Test void 존재하지_않는_상품의_리뷰_목록을_조회시_예외가_발생한다() { - // given && when + // given & when final var 응답 = 정렬된_리뷰_목록_조회_요청(로그인_쿠키_획득(멤버1), 존재하지_않는_상품, 좋아요수_내림차순, FIRST_PAGE); // then @@ -611,6 +613,59 @@ class getRankingReviews_성공_테스트 { } } + @Nested + class getMostFavoriteReview_성공_테스트 { + + @Test + void 특정_상품에서_좋아요를_가장_많이_받은_리뷰를_조회하다() { + // given + final var 카테고리 = 카테고리_즉석조리_생성(); + 단일_카테고리_저장(카테고리); + final var 상품 = 단일_상품_저장(상품_삼각김밥_가격1000원_평점3점_생성(카테고리)); + final var 태그 = 단일_태그_저장(태그_맛있어요_TASTE_생성()); + + 리뷰_작성_요청(로그인_쿠키_획득(멤버1), 상품, 사진_명세_요청(이미지1), 리뷰추가요청_재구매O_생성(점수_3점, List.of(태그))); + 리뷰_작성_요청(로그인_쿠키_획득(멤버2), 상품, 사진_명세_요청(이미지2), 리뷰추가요청_재구매O_생성(점수_4점, List.of(태그))); + 여러명이_리뷰_좋아요_요청(List.of(멤버1, 멤버2, 멤버3), 상품1, 리뷰2, 좋아요O); + + // when + final var 응답 = 좋아요를_제일_많이_받은_리뷰_조회_요청(상품); + + // then + STATUS_CODE를_검증한다(응답, 정상_처리); + 좋아요를_제일_많이_받은_리뷰_결과를_검증한다(응답, 리뷰2); + } + + @Test + void 특정_상품에서_리뷰가_없다면_빈_응답을_반환하다() { + // given + final var 카테고리 = 카테고리_즉석조리_생성(); + 단일_카테고리_저장(카테고리); + final var 상품 = 단일_상품_저장(상품_삼각김밥_가격1000원_평점3점_생성(카테고리)); + + // when + final var 응답 = 좋아요를_제일_많이_받은_리뷰_조회_요청(상품); + + // then + STATUS_CODE를_검증한다(응답, 정상_처리_NO_CONTENT); + 좋아요를_제일_많이_받은_리뷰_결과가_빈_응답인지_검증한다(응답); + } + } + + @Nested + class getMostFavoriteReview_실패_테스트 { + + @Test + void 존재하지_않는_상품의_좋아요를_가장_많이_받은_리뷰_조회시_예외가_발생한다() { + // given & when + final var 응답 = 좋아요를_제일_많이_받은_리뷰_조회_요청(존재하지_않는_상품); + + // then + STATUS_CODE를_검증한다(응답, 찾을수_없음); + RESPONSE_CODE와_MESSAGE를_검증한다(응답, PRODUCT_NOT_FOUND.getCode(), PRODUCT_NOT_FOUND.getMessage()); + } + } + private void RESPONSE_CODE와_MESSAGE를_검증한다(final ExtractableResponse response, final String expectedCode, final String expectedMessage) { assertSoftly(soft -> { @@ -636,6 +691,20 @@ class getRankingReviews_성공_테스트 { .containsExactlyElementsOf(reviewIds); } + private void 좋아요를_제일_많이_받은_리뷰_결과를_검증한다(final ExtractableResponse response, final Long reviewId) { + final var actual = response.jsonPath() + .getLong("id"); + + assertThat(actual).isEqualTo(reviewId); + } + + private void 좋아요를_제일_많이_받은_리뷰_결과가_빈_응답인지_검증한다(final ExtractableResponse response) { + final var actual = response.body() + .asString(); + + assertThat(actual).isEmpty(); + } + private void 상품_사진을_검증한다(final ExtractableResponse response, final String expected) { final var actual = response.jsonPath() .getString("image"); diff --git a/backend/src/test/java/com/funeat/acceptance/review/ReviewSteps.java b/backend/src/test/java/com/funeat/acceptance/review/ReviewSteps.java index d59d9eded..d422b94a9 100644 --- a/backend/src/test/java/com/funeat/acceptance/review/ReviewSteps.java +++ b/backend/src/test/java/com/funeat/acceptance/review/ReviewSteps.java @@ -74,4 +74,12 @@ public class ReviewSteps { .then() .extract(); } + + public static ExtractableResponse 좋아요를_제일_많이_받은_리뷰_조회_요청(final Long productId) { + return given() + .when() + .get("/api/ranks/products/{product_id}/reviews", productId) + .then().log().all() + .extract(); + } } diff --git a/backend/src/test/java/com/funeat/review/application/ReviewServiceTest.java b/backend/src/test/java/com/funeat/review/application/ReviewServiceTest.java index aa2a0b33e..00c3ed691 100644 --- a/backend/src/test/java/com/funeat/review/application/ReviewServiceTest.java +++ b/backend/src/test/java/com/funeat/review/application/ReviewServiceTest.java @@ -38,10 +38,12 @@ import com.funeat.member.exception.MemberException.MemberNotFoundException; import com.funeat.product.exception.ProductException.ProductNotFoundException; import com.funeat.review.domain.Review; +import com.funeat.review.dto.MostFavoriteReviewResponse; import com.funeat.review.dto.SortingReviewDto; import com.funeat.review.exception.ReviewException.ReviewNotFoundException; import com.funeat.tag.domain.Tag; import java.util.List; +import java.util.Optional; import java.util.stream.Collectors; import java.util.stream.Stream; import org.junit.jupiter.api.Nested; @@ -793,6 +795,101 @@ class updateProductImage_실패_테스트 { } } + @Nested + class getMostFavoriteReview_성공_테스트 { + + @Test + void 리뷰가_여러개_존재하면_좋아요를_가장_많이_받은_리뷰를_반환한다() { + // given + final var member = 멤버_멤버1_생성(); + 단일_멤버_저장(member); + + final var category = 카테고리_즉석조리_생성(); + 단일_카테고리_저장(category); + + final var product = 상품_삼각김밥_가격1000원_평점3점_생성(category); + final var productId = 단일_상품_저장(product); + + final var review1 = 리뷰_이미지test3_평점3점_재구매O_생성(member, product, 351L); + final var review2 = 리뷰_이미지test4_평점4점_재구매O_생성(member, product, 24L); + 복수_리뷰_저장(review1, review2); + + final var expected = MostFavoriteReviewResponse.toResponse(Optional.of(review1)); + + // when + final var actual = reviewService.getMostFavoriteReview(productId); + + // then + assertThat(actual).usingRecursiveComparison() + .isEqualTo(expected); + } + + @Test + void 좋아요_수가_같은_리뷰가_여러개_존재하면_가장_최근_작성된_리뷰를_반환한다() { + // given + final var member = 멤버_멤버1_생성(); + 단일_멤버_저장(member); + + final var category = 카테고리_즉석조리_생성(); + 단일_카테고리_저장(category); + + final var product = 상품_삼각김밥_가격1000원_평점3점_생성(category); + final var productId = 단일_상품_저장(product); + + final var review1 = 리뷰_이미지test3_평점3점_재구매O_생성(member, product, 351L); + final var review2 = 리뷰_이미지test4_평점4점_재구매O_생성(member, product, 351L); + 복수_리뷰_저장(review1, review2); + + final var expected = MostFavoriteReviewResponse.toResponse(Optional.of(review2)); + + // when + final var actual = reviewService.getMostFavoriteReview(productId); + + // then + assertThat(actual).usingRecursiveComparison() + .isEqualTo(expected); + } + + @Test + void 리뷰가_존재하지_않으면_Optional_empty를_반환한다() { + // given + final var member = 멤버_멤버1_생성(); + 단일_멤버_저장(member); + + final var category = 카테고리_즉석조리_생성(); + 단일_카테고리_저장(category); + + final var product = 상품_삼각김밥_가격1000원_평점3점_생성(category); + final var productId = 단일_상품_저장(product); + + final var expected = Optional.empty(); + + // when + final var actual = reviewService.getMostFavoriteReview(productId); + + // then + assertThat(actual).isEqualTo(expected); + } + } + + @Nested + class getMostFavoriteReview_실패_테스트 { + + @Test + void 존재하지_않는_상품에_가장_많은_좋아요를_받은_리뷰를_찾으면_예외가_발생한다() { + // given + final var category = 카테고리_즉석조리_생성(); + 단일_카테고리_저장(category); + + final var product = 상품_삼각김밥_가격1000원_평점2점_생성(category); + final var wrongProductId = 단일_상품_저장(product) + 1L; + + // when & then + assertThatThrownBy(() -> reviewService.getMostFavoriteReview(wrongProductId)) + .isInstanceOf(ProductNotFoundException.class); + } + } + private List 태그_아이디_변환(final Tag... tags) { return Stream.of(tags) .map(Tag::getId) diff --git a/backend/src/test/java/com/funeat/review/persistence/ReviewRepositoryTest.java b/backend/src/test/java/com/funeat/review/persistence/ReviewRepositoryTest.java index c076fe6b6..d0198db1a 100644 --- a/backend/src/test/java/com/funeat/review/persistence/ReviewRepositoryTest.java +++ b/backend/src/test/java/com/funeat/review/persistence/ReviewRepositoryTest.java @@ -138,7 +138,7 @@ class findTop3ByOrderByFavoriteCountDesc_성공_테스트 { } @Nested - class findTopByProductOrderByFavoriteCountDesc_성공_테스트 { + class findPopularReviewWithImage_성공_테스트 { @Test void 리뷰가_존재하지_않으면_빈_값을_반환하다() { @@ -208,4 +208,31 @@ class findTopByProductOrderByFavoriteCountDesc_성공_테스트 { assertThat(actual).usingRecursiveComparison().isEqualTo(review2); } } + + @Nested + class findTopByProductOrderByFavoriteCountDescIdDesc_성공_테스트 { + + @Test + void 좋아요가_가장_많은_리뷰를_반환하다() { + // given + final var category = 카테고리_즉석조리_생성(); + 단일_카테고리_저장(category); + + final var product = 상품_삼각김밥_가격1000원_평점2점_생성(category); + 단일_상품_저장(product); + + final var member = 멤버_멤버1_생성(); + 단일_멤버_저장(member); + + final var review1 = 리뷰_이미지test1_평점1점_재구매O_생성(member, product, 0L); + final var review2 = 리뷰_이미지test3_평점3점_재구매O_생성(member, product, 4L); + 복수_리뷰_저장(review1, review2); + + // when + final var actual = reviewRepository.findTopByProductOrderByFavoriteCountDescIdDesc(product); + + // then + assertThat(actual.get()).isEqualTo(review2); + } + } } From a3d1be53c0a2e2cc600095b1121112ff2aa0337e Mon Sep 17 00:00:00 2001 From: Taeeun Kim Date: Wed, 11 Oct 2023 13:58:36 +0900 Subject: [PATCH 05/55] =?UTF-8?q?[FE]=20feat:=20=ED=99=88=EC=97=90?= =?UTF-8?q?=EC=84=9C=20=EC=B9=B4=ED=85=8C=EA=B3=A0=EB=A6=AC=20=EB=88=8C?= =?UTF-8?q?=EB=A0=80=EC=9D=84=20=EB=95=8C=20context=EC=97=90=20=EC=A0=80?= =?UTF-8?q?=EC=9E=A5=ED=95=98=EB=8A=94=20=EB=B0=A9=EC=8B=9D=EC=9C=BC?= =?UTF-8?q?=EB=A1=9C=20=EB=B3=80=EA=B2=BD=20(#732)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * refactor: 불필요한 코드 제거 * refactor: 카테고리 이동 방식을 context에 저장하는 방식으로 변경 * feat: 스토리북에 CategoryProvider 추가 * feat: categoryItem에 GA 이벤트 추가 * style: 사용하지 않는 코드 제거 --- .../CategoryFoodList.stories.tsx | 9 +++++ .../CategoryFoodList/CategoryFoodList.tsx | 35 ++++-------------- .../CategoryFoodTab/CategoryFoodTab.tsx | 36 ++++++------------ .../CategoryItem/CategoryItem.stories.tsx | 11 ++++++ .../Common/CategoryItem/CategoryItem.tsx | 35 +++++++++++++----- .../CategoryStoreList.stories.tsx | 9 +++++ .../CategoryStoreList/CategoryStoreList.tsx | 35 ++++-------------- .../CategoryStoreTab/CategoryStoreTab.tsx | 37 ++++++------------- 8 files changed, 93 insertions(+), 114 deletions(-) diff --git a/frontend/src/components/Common/CategoryFoodList/CategoryFoodList.stories.tsx b/frontend/src/components/Common/CategoryFoodList/CategoryFoodList.stories.tsx index e597dc53c..25de7b811 100644 --- a/frontend/src/components/Common/CategoryFoodList/CategoryFoodList.stories.tsx +++ b/frontend/src/components/Common/CategoryFoodList/CategoryFoodList.stories.tsx @@ -2,9 +2,18 @@ import type { Meta, StoryObj } from '@storybook/react'; import CategoryFoodList from './CategoryFoodList'; +import CategoryProvider from '@/contexts/CategoryContext'; + const meta: Meta = { title: 'common/CategoryFoodList', component: CategoryFoodList, + decorators: [ + (Story) => ( + + + + ), + ], }; export default meta; diff --git a/frontend/src/components/Common/CategoryFoodList/CategoryFoodList.tsx b/frontend/src/components/Common/CategoryFoodList/CategoryFoodList.tsx index 46464b231..921584b8f 100644 --- a/frontend/src/components/Common/CategoryFoodList/CategoryFoodList.tsx +++ b/frontend/src/components/Common/CategoryFoodList/CategoryFoodList.tsx @@ -1,42 +1,21 @@ -import { Link } from '@fun-eat/design-system'; -import { Link as RouterLink } from 'react-router-dom'; import styled from 'styled-components'; import CategoryItem from '../CategoryItem/CategoryItem'; import { CATEGORY_TYPE } from '@/constants'; -import { useGA } from '@/hooks/common'; import { useCategoryFoodQuery } from '@/hooks/queries/product'; -const category = CATEGORY_TYPE.FOOD; +const categoryType = CATEGORY_TYPE.FOOD; const CategoryFoodList = () => { - const { data: categories } = useCategoryFoodQuery(category); - const { gaEvent } = useGA(); - - const handleHomeCategoryLinkClick = (categoryName: string) => { - gaEvent({ - category: 'link', - action: `${categoryName} 카테고리 링크 클릭`, - label: '카테고리', - }); - }; + const { data: categories } = useCategoryFoodQuery(categoryType); return ( -
    - - {categories.map((menu) => ( - handleHomeCategoryLinkClick(menu.name)} - > - - - ))} - -
    + + {categories.map(({ id, name, image }) => ( + + ))} + ); }; diff --git a/frontend/src/components/Common/CategoryFoodTab/CategoryFoodTab.tsx b/frontend/src/components/Common/CategoryFoodTab/CategoryFoodTab.tsx index 6b6a734bc..56817526e 100644 --- a/frontend/src/components/Common/CategoryFoodTab/CategoryFoodTab.tsx +++ b/frontend/src/components/Common/CategoryFoodTab/CategoryFoodTab.tsx @@ -1,6 +1,4 @@ import { Button, theme } from '@fun-eat/design-system'; -import { useEffect } from 'react'; -import { useLocation } from 'react-router-dom'; import styled from 'styled-components'; import { CATEGORY_TYPE } from '@/constants'; @@ -9,29 +7,20 @@ import { useCategoryActionContext, useCategoryValueContext } from '@/hooks/conte import { useCategoryFoodQuery } from '@/hooks/queries/product/useCategoryQuery'; import { getTargetCategoryName } from '@/utils/category'; -const category = CATEGORY_TYPE.FOOD; +const categoryType = CATEGORY_TYPE.FOOD; const CategoryFoodTab = () => { - const { data: categories } = useCategoryFoodQuery(category); + const { data: categories } = useCategoryFoodQuery(categoryType); const { categoryIds } = useCategoryValueContext(); const { selectCategory } = useCategoryActionContext(); - const currentCategoryId = categoryIds[category]; - const location = useLocation(); - const queryParams = new URLSearchParams(location.search); - const categoryIdFromURL = queryParams.get('category'); + const currentCategoryId = categoryIds[categoryType]; const { gaEvent } = useGA(); - useEffect(() => { - if (categoryIdFromURL) { - selectCategory(category, parseInt(categoryIdFromURL)); - } - }, [category]); - const handleCategoryButtonClick = (menuId: number) => { - selectCategory(category, menuId); + selectCategory(categoryType, menuId); gaEvent({ category: 'button', action: `${getTargetCategoryName(categories, menuId)} 카테고리 버튼 클릭`, @@ -41,10 +30,10 @@ const CategoryFoodTab = () => { return ( - {categories.map((menu) => { - const isSelected = menu.id === currentCategoryId; + {categories.map(({ id, name }) => { + const isSelected = id === currentCategoryId; return ( -
  • +
  • { weight="bold" variant={isSelected ? 'filled' : 'outlined'} isSelected={isSelected} - onClick={() => handleCategoryButtonClick(menu.id)} + onClick={() => handleCategoryButtonClick(id)} aria-pressed={isSelected} > - {menu.name} + {name}
  • ); @@ -81,10 +70,9 @@ const CategoryMenuContainer = styled.ul` const CategoryButton = styled(Button)<{ isSelected: boolean }>` padding: 6px 12px; ${({ isSelected }) => - isSelected - ? ` + isSelected && + ` background: ${theme.colors.gray5}; color: ${theme.textColors.white}; - ` - : ''} + `} `; diff --git a/frontend/src/components/Common/CategoryItem/CategoryItem.stories.tsx b/frontend/src/components/Common/CategoryItem/CategoryItem.stories.tsx index b6158f5b6..3dc17ddf1 100644 --- a/frontend/src/components/Common/CategoryItem/CategoryItem.stories.tsx +++ b/frontend/src/components/Common/CategoryItem/CategoryItem.stories.tsx @@ -2,12 +2,23 @@ import type { Meta, StoryObj } from '@storybook/react'; import CategoryItem from './CategoryItem'; +import CategoryProvider from '@/contexts/CategoryContext'; + const meta: Meta = { title: 'common/CategoryItem', component: CategoryItem, + decorators: [ + (Story) => ( + + + + ), + ], args: { + categoryId: 1, name: '즉석 식품', image: 'https://tqklhszfkvzk6518638.cdn.ntruss.com/product/8801771029052.jpg', + categoryType: 'food', }, }; diff --git a/frontend/src/components/Common/CategoryItem/CategoryItem.tsx b/frontend/src/components/Common/CategoryItem/CategoryItem.tsx index 4ce54ce72..051c97073 100644 --- a/frontend/src/components/Common/CategoryItem/CategoryItem.tsx +++ b/frontend/src/components/Common/CategoryItem/CategoryItem.tsx @@ -1,30 +1,47 @@ import { Button } from '@fun-eat/design-system'; +import { useNavigate } from 'react-router-dom'; import styled from 'styled-components'; +import { PATH } from '@/constants/path'; +import { useGA } from '@/hooks/common'; +import { useCategoryActionContext } from '@/hooks/context'; + interface CategoryItemProps { + categoryId: number; name: string; image: string; + categoryType: 'food' | 'store'; } -const CategoryItem = ({ name, image }: CategoryItemProps) => { +const CategoryItem = ({ categoryId, name, image, categoryType }: CategoryItemProps) => { + const navigate = useNavigate(); + const { selectCategory } = useCategoryActionContext(); + + const { gaEvent } = useGA(); + + const handleCategoryItemClick = (categoryId: number) => { + selectCategory(categoryType, categoryId); + navigate(PATH.PRODUCT_LIST + '/' + categoryType); + + gaEvent({ + category: 'button', + action: `${name} 카테고리 링크 클릭`, + label: '카테고리', + }); + }; + return ( - + ); }; export default CategoryItem; -const CategoryItemContainer = styled(Button)` - width: 60px; - height: 100px; - text-align: center; -`; - const ImageWrapper = styled.div` display: flex; justify-content: center; diff --git a/frontend/src/components/Common/CategoryStoreList/CategoryStoreList.stories.tsx b/frontend/src/components/Common/CategoryStoreList/CategoryStoreList.stories.tsx index 2592fe4c9..d26be6f49 100644 --- a/frontend/src/components/Common/CategoryStoreList/CategoryStoreList.stories.tsx +++ b/frontend/src/components/Common/CategoryStoreList/CategoryStoreList.stories.tsx @@ -2,9 +2,18 @@ import type { Meta, StoryObj } from '@storybook/react'; import CategoryStoreList from './CategoryStoreList'; +import CategoryProvider from '@/contexts/CategoryContext'; + const meta: Meta = { title: 'common/CategoryStoreList', component: CategoryStoreList, + decorators: [ + (Story) => ( + + + + ), + ], }; export default meta; diff --git a/frontend/src/components/Common/CategoryStoreList/CategoryStoreList.tsx b/frontend/src/components/Common/CategoryStoreList/CategoryStoreList.tsx index 9ce856fa1..6bf2c36ae 100644 --- a/frontend/src/components/Common/CategoryStoreList/CategoryStoreList.tsx +++ b/frontend/src/components/Common/CategoryStoreList/CategoryStoreList.tsx @@ -1,42 +1,21 @@ -import { Link } from '@fun-eat/design-system'; -import { Link as RouterLink } from 'react-router-dom'; import styled from 'styled-components'; import CategoryItem from '../CategoryItem/CategoryItem'; import { CATEGORY_TYPE } from '@/constants'; -import { useGA } from '@/hooks/common'; import { useCategoryStoreQuery } from '@/hooks/queries/product'; -const category = CATEGORY_TYPE.STORE; +const categoryType = CATEGORY_TYPE.STORE; const CategoryStoreList = () => { - const { data: categories } = useCategoryStoreQuery(category); - const { gaEvent } = useGA(); - - const handleHomeCategoryLinkClick = (categoryName: string) => { - gaEvent({ - category: 'link', - action: `${categoryName} 카테고리 링크 클릭`, - label: '카테고리', - }); - }; + const { data: categories } = useCategoryStoreQuery(categoryType); return ( -
    - - {categories.map((menu) => ( - handleHomeCategoryLinkClick(menu.name)} - > - - - ))} - -
    + + {categories.map(({ id, name, image }) => ( + + ))} + ); }; diff --git a/frontend/src/components/Common/CategoryStoreTab/CategoryStoreTab.tsx b/frontend/src/components/Common/CategoryStoreTab/CategoryStoreTab.tsx index a83fa501f..b75abb7b7 100644 --- a/frontend/src/components/Common/CategoryStoreTab/CategoryStoreTab.tsx +++ b/frontend/src/components/Common/CategoryStoreTab/CategoryStoreTab.tsx @@ -1,6 +1,4 @@ import { Button, theme } from '@fun-eat/design-system'; -import { useEffect } from 'react'; -import { useLocation } from 'react-router-dom'; import styled from 'styled-components'; import { CATEGORY_TYPE } from '@/constants'; @@ -9,29 +7,19 @@ import { useCategoryActionContext, useCategoryValueContext } from '@/hooks/conte import { useCategoryStoreQuery } from '@/hooks/queries/product/useCategoryQuery'; import { getTargetCategoryName } from '@/utils/category'; -const category = CATEGORY_TYPE.STORE; +const categoryType = CATEGORY_TYPE.STORE; const CategoryStoreTab = () => { - const { data: categories } = useCategoryStoreQuery(category); + const { data: categories } = useCategoryStoreQuery(categoryType); const { categoryIds } = useCategoryValueContext(); const { selectCategory } = useCategoryActionContext(); - const currentCategoryId = categoryIds[category]; - - const location = useLocation(); - const queryParams = new URLSearchParams(location.search); - const categoryIdFromURL = queryParams.get('category'); + const currentCategoryId = categoryIds[categoryType]; const { gaEvent } = useGA(); - useEffect(() => { - if (categoryIdFromURL) { - selectCategory(category, parseInt(categoryIdFromURL)); - } - }, [category]); - const handleCategoryButtonClick = (menuId: number) => { - selectCategory(category, menuId); + selectCategory(categoryType, menuId); gaEvent({ category: 'button', action: `${getTargetCategoryName(categories, menuId)} 카테고리 버튼 클릭`, @@ -41,10 +29,10 @@ const CategoryStoreTab = () => { return ( - {categories.map((menu) => { - const isSelected = menu.id === currentCategoryId; + {categories.map(({ id, name }) => { + const isSelected = id === currentCategoryId; return ( -
  • +
  • { weight="bold" variant={isSelected ? 'filled' : 'outlined'} isSelected={isSelected} - onClick={() => handleCategoryButtonClick(menu.id)} + onClick={() => handleCategoryButtonClick(id)} aria-pressed={isSelected} > - {menu.name} + {name}
  • ); @@ -81,10 +69,9 @@ const CategoryMenuContainer = styled.ul` const CategoryButton = styled(Button)<{ isSelected: boolean }>` padding: 6px 12px; ${({ isSelected }) => - isSelected - ? ` + isSelected && + ` background: ${theme.colors.primary}; color: ${theme.textColors.default}; - ` - : ''} + `} `; From cc664d4ed445cb3747e01eb16a2e942d9cc57e5a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?s=E1=B4=8F=CA=9F=CA=99=C9=AA=20=E2=98=94=EF=B8=8F?= Date: Wed, 11 Oct 2023 15:08:55 +0900 Subject: [PATCH 06/55] =?UTF-8?q?refactor:=20=EC=BA=90=EB=9F=AC=EC=85=80?= =?UTF-8?q?=20Index=20index=20=EC=88=98=EC=A0=95=20(#736)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- frontend/src/components/Common/Carousel/Carousel.tsx | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/frontend/src/components/Common/Carousel/Carousel.tsx b/frontend/src/components/Common/Carousel/Carousel.tsx index 5f7c3ad23..038b5cefa 100644 --- a/frontend/src/components/Common/Carousel/Carousel.tsx +++ b/frontend/src/components/Common/Carousel/Carousel.tsx @@ -31,8 +31,11 @@ const Carousel = ({ carouselList }: CarouselProps) => { transition: currentIndex === length - 1 ? '' : 'all 0.5s ease-in-out', }} > - {extendedCarouselList.map(({ id, children }) => ( - + {extendedCarouselList.map(({ id, children }, index) => ( + {children} ))} From 22e792d6bd337c5330db0d3b56a4af212ac1be1a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=9A=B0=EA=B0=80?= Date: Wed, 11 Oct 2023 15:36:25 +0900 Subject: [PATCH 07/55] =?UTF-8?q?[BE]=20feat:=20=EC=96=B4=EB=93=9C?= =?UTF-8?q?=EB=AF=BC=20=EA=B4=80=EB=A6=AC=EC=9E=90=20api=20=EA=B5=AC?= =?UTF-8?q?=ED=98=84=20(#731)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat: 어드민 상품 API 구현 * feat: 어드민 리뷰 조회 API 구현 * feat: 어드민 리뷰 조회 반환 데이터 추가 * refactor: 리뷰 조회 기간 Date format 수정 * feat: 어드민 로그인 구현 * refactor: 기존 정렬으로 수정 * refactor: 예외메시지 수정 * refactor: page size 상수화 * refactor: 메소드 호출 순서대로 수정 * refactor: specification 메소드 명시하도록 수정 * refactor: 리뷰 반영 * refactor: controller 메소드명 수정, 카테고리 커스텀 예외 설정 * refactor: CategoryResponse -> AdminCategoryResponse 수정 * refactor: final 컨벤션 추가 * refactor: update 메소드 순서 변경 * refactor: 컨벤션 수정, 사용하지 않는 메소드 삭제 * refactor: 바디가 없는 경우 noContent 코드 반환 * refactor: 카운트 쿼리 타입들 상수화 * refactor: noContent 응답 테스트 코드 수정 --- .../java/com/funeat/FuneatApplication.java | 3 + .../admin/application/AdminChecker.java | 29 +++++ .../admin/application/AdminService.java | 117 ++++++++++++++++++ .../funeat/admin/domain/AdminAuthInfo.java | 20 +++ .../admin/dto/AdminCategoryResponse.java | 32 +++++ .../admin/dto/AdminProductResponse.java | 48 +++++++ .../admin/dto/AdminProductSearchResponse.java | 29 +++++ .../funeat/admin/dto/AdminReviewResponse.java | 54 ++++++++ .../admin/dto/AdminReviewSearchResponse.java | 29 +++++ .../admin/dto/ProductCreateRequest.java | 32 +++++ .../admin/dto/ProductSearchCondition.java | 39 ++++++ .../admin/dto/ProductUpdateRequest.java | 32 +++++ .../admin/dto/ReviewSearchCondition.java | 53 ++++++++ .../admin/presentation/AdminController.java | 70 +++++++++++ .../presentation/AdminLoginController.java | 36 ++++++ .../repository/AdminProductRepository.java | 7 ++ .../repository/AdminReviewRepository.java | 7 ++ .../AdminProductSpecification.java | 67 ++++++++++ .../AdminReviewSpecification.java | 79 ++++++++++++ .../admin/util/AdminCheckInterceptor.java | 41 ++++++ .../funeat/auth/exception/AuthErrorCode.java | 1 + .../java/com/funeat/common/WebConfig.java | 10 +- .../common/repository/BaseRepository.java | 14 +++ .../common/repository/BaseRepositoryImpl.java | 40 ++++++ .../presentation/MemberApiController.java | 2 +- .../member/presentation/MemberController.java | 2 +- .../com/funeat/product/domain/Product.java | 13 +- .../persistence/ProductRepository.java | 11 +- .../src/main/resources/application-local.yml | 1 - backend/src/main/resources/application.yml | 4 + .../member/MemberAcceptanceTest.java | 7 +- backend/src/test/resources/application.yml | 4 + 32 files changed, 919 insertions(+), 14 deletions(-) create mode 100644 backend/src/main/java/com/funeat/admin/application/AdminChecker.java create mode 100644 backend/src/main/java/com/funeat/admin/application/AdminService.java create mode 100644 backend/src/main/java/com/funeat/admin/domain/AdminAuthInfo.java create mode 100644 backend/src/main/java/com/funeat/admin/dto/AdminCategoryResponse.java create mode 100644 backend/src/main/java/com/funeat/admin/dto/AdminProductResponse.java create mode 100644 backend/src/main/java/com/funeat/admin/dto/AdminProductSearchResponse.java create mode 100644 backend/src/main/java/com/funeat/admin/dto/AdminReviewResponse.java create mode 100644 backend/src/main/java/com/funeat/admin/dto/AdminReviewSearchResponse.java create mode 100644 backend/src/main/java/com/funeat/admin/dto/ProductCreateRequest.java create mode 100644 backend/src/main/java/com/funeat/admin/dto/ProductSearchCondition.java create mode 100644 backend/src/main/java/com/funeat/admin/dto/ProductUpdateRequest.java create mode 100644 backend/src/main/java/com/funeat/admin/dto/ReviewSearchCondition.java create mode 100644 backend/src/main/java/com/funeat/admin/presentation/AdminController.java create mode 100644 backend/src/main/java/com/funeat/admin/presentation/AdminLoginController.java create mode 100644 backend/src/main/java/com/funeat/admin/repository/AdminProductRepository.java create mode 100644 backend/src/main/java/com/funeat/admin/repository/AdminReviewRepository.java create mode 100644 backend/src/main/java/com/funeat/admin/specification/AdminProductSpecification.java create mode 100644 backend/src/main/java/com/funeat/admin/specification/AdminReviewSpecification.java create mode 100644 backend/src/main/java/com/funeat/admin/util/AdminCheckInterceptor.java create mode 100644 backend/src/main/java/com/funeat/common/repository/BaseRepository.java create mode 100644 backend/src/main/java/com/funeat/common/repository/BaseRepositoryImpl.java diff --git a/backend/src/main/java/com/funeat/FuneatApplication.java b/backend/src/main/java/com/funeat/FuneatApplication.java index 9fae64b95..53bd185c0 100644 --- a/backend/src/main/java/com/funeat/FuneatApplication.java +++ b/backend/src/main/java/com/funeat/FuneatApplication.java @@ -1,9 +1,12 @@ package com.funeat; +import com.funeat.common.repository.BaseRepositoryImpl; import org.springframework.boot.SpringApplication; import org.springframework.boot.autoconfigure.SpringBootApplication; +import org.springframework.data.jpa.repository.config.EnableJpaRepositories; @SpringBootApplication +@EnableJpaRepositories(repositoryBaseClass = BaseRepositoryImpl.class) public class FuneatApplication { public static void main(String[] args) { diff --git a/backend/src/main/java/com/funeat/admin/application/AdminChecker.java b/backend/src/main/java/com/funeat/admin/application/AdminChecker.java new file mode 100644 index 000000000..39045ce22 --- /dev/null +++ b/backend/src/main/java/com/funeat/admin/application/AdminChecker.java @@ -0,0 +1,29 @@ +package com.funeat.admin.application; + +import com.funeat.admin.domain.AdminAuthInfo; +import com.funeat.auth.exception.AuthErrorCode; +import com.funeat.auth.exception.AuthException.NotLoggedInException; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Component; + +@Component +public class AdminChecker { + + @Value("${back-office.id}") + private String id; + + @Value("${back-office.key}") + private String key; + + public boolean check(final AdminAuthInfo adminAuthInfo) { + if (!id.equals(adminAuthInfo.getId())) { + throw new NotLoggedInException(AuthErrorCode.LOGIN_ADMIN_NOT_FOUND); + } + + if (!key.equals(adminAuthInfo.getKey())) { + throw new NotLoggedInException(AuthErrorCode.LOGIN_ADMIN_NOT_FOUND); + } + + return true; + } +} diff --git a/backend/src/main/java/com/funeat/admin/application/AdminService.java b/backend/src/main/java/com/funeat/admin/application/AdminService.java new file mode 100644 index 000000000..3c97c3c1b --- /dev/null +++ b/backend/src/main/java/com/funeat/admin/application/AdminService.java @@ -0,0 +1,117 @@ +package com.funeat.admin.application; + +import static com.funeat.product.exception.CategoryErrorCode.CATEGORY_NOT_FOUND; +import static com.funeat.product.exception.ProductErrorCode.PRODUCT_NOT_FOUND; + +import com.funeat.admin.dto.AdminProductResponse; +import com.funeat.admin.dto.AdminProductSearchResponse; +import com.funeat.admin.dto.AdminReviewResponse; +import com.funeat.admin.dto.AdminReviewSearchResponse; +import com.funeat.admin.dto.ProductCreateRequest; +import com.funeat.admin.dto.ProductSearchCondition; +import com.funeat.admin.dto.ProductUpdateRequest; +import com.funeat.admin.dto.ReviewSearchCondition; +import com.funeat.admin.repository.AdminProductRepository; +import com.funeat.admin.repository.AdminReviewRepository; +import com.funeat.admin.specification.AdminProductSpecification; +import com.funeat.admin.specification.AdminReviewSpecification; +import com.funeat.product.domain.Category; +import com.funeat.product.domain.Product; +import com.funeat.product.dto.CategoryResponse; +import com.funeat.product.exception.CategoryException.CategoryNotFoundException; +import com.funeat.product.exception.ProductException.ProductNotFoundException; +import com.funeat.product.persistence.CategoryRepository; +import com.funeat.product.persistence.ProductRepository; +import com.funeat.review.domain.Review; +import java.util.List; +import java.util.stream.Collectors; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; +import org.springframework.data.jpa.domain.Specification; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@Service +@Transactional(readOnly = true) +public class AdminService { + + private static final int DEFAULT_PAGE_SIZE = 10; + + private final ProductRepository productRepository; + private final AdminProductRepository adminProductRepository; + private final CategoryRepository categoryRepository; + private final AdminReviewRepository adminReviewRepository; + + public AdminService(final ProductRepository productRepository, final AdminProductRepository adminProductRepository, + final CategoryRepository categoryRepository, final AdminReviewRepository adminReviewRepository) { + this.productRepository = productRepository; + this.adminProductRepository = adminProductRepository; + this.categoryRepository = categoryRepository; + this.adminReviewRepository = adminReviewRepository; + } + + @Transactional + public Long addProduct(final ProductCreateRequest request) { + final Category findCategory = categoryRepository.findById(request.getCategoryId()) + .orElseThrow(() -> new CategoryNotFoundException(CATEGORY_NOT_FOUND, request.getCategoryId())); + + final Product product = Product.create(request.getName(), request.getPrice(), request.getContent(), + findCategory); + + return productRepository.save(product).getId(); + } + + public List getAllCategories() { + final List findCategories = categoryRepository.findAll(); + + return findCategories.stream() + .map(CategoryResponse::toResponse) + .collect(Collectors.toList()); + } + + public AdminProductSearchResponse getSearchProducts(final ProductSearchCondition condition, + final Pageable pageable) { + final Specification specification = AdminProductSpecification.searchBy(condition); + + final Page findProducts = adminProductRepository.findAllForPagination(specification, pageable, + condition.getTotalElements()); + + final List productResponses = findProducts.stream() + .map(AdminProductResponse::toResponse) + .collect(Collectors.toList()); + + final Boolean isLastPage = isLastPage(findProducts, condition.getPrePage()); + + return new AdminProductSearchResponse(productResponses, findProducts.getTotalElements(), isLastPage); + } + + private boolean isLastPage(final Page findProducts, Long prePage) { + return prePage * DEFAULT_PAGE_SIZE + findProducts.getContent().size() == findProducts.getTotalElements(); + } + + @Transactional + public void updateProduct(final Long productId, final ProductUpdateRequest request) { + final Product findProduct = productRepository.findById(productId) + .orElseThrow(() -> new ProductNotFoundException(PRODUCT_NOT_FOUND, productId)); + + final Category findCategory = categoryRepository.findById(request.getCategoryId()) + .orElseThrow(() -> new CategoryNotFoundException(CATEGORY_NOT_FOUND, request.getCategoryId())); + + findProduct.update(request.getName(), request.getContent(), request.getPrice(), findCategory); + } + + public AdminReviewSearchResponse getSearchReviews(final ReviewSearchCondition condition, final Pageable pageable) { + final Specification specification = AdminReviewSpecification.searchBy(condition); + + final Page findReviews = adminReviewRepository.findAllForPagination(specification, pageable, + condition.getTotalElements()); + + final List reviewResponses = findReviews.stream() + .map(AdminReviewResponse::toResponse) + .collect(Collectors.toList()); + + final Boolean isLastPage = isLastPage(findReviews, condition.getPrePage()); + + return new AdminReviewSearchResponse(reviewResponses, findReviews.getTotalElements(), isLastPage); + } +} diff --git a/backend/src/main/java/com/funeat/admin/domain/AdminAuthInfo.java b/backend/src/main/java/com/funeat/admin/domain/AdminAuthInfo.java new file mode 100644 index 000000000..1a1fb23b8 --- /dev/null +++ b/backend/src/main/java/com/funeat/admin/domain/AdminAuthInfo.java @@ -0,0 +1,20 @@ +package com.funeat.admin.domain; + +public class AdminAuthInfo { + + private final String id; + private final String key; + + public AdminAuthInfo(final String id, final String key) { + this.id = id; + this.key = key; + } + + public String getId() { + return id; + } + + public String getKey() { + return key; + } +} diff --git a/backend/src/main/java/com/funeat/admin/dto/AdminCategoryResponse.java b/backend/src/main/java/com/funeat/admin/dto/AdminCategoryResponse.java new file mode 100644 index 000000000..86c47a715 --- /dev/null +++ b/backend/src/main/java/com/funeat/admin/dto/AdminCategoryResponse.java @@ -0,0 +1,32 @@ +package com.funeat.admin.dto; + +import com.funeat.product.domain.Category; + +public class AdminCategoryResponse { + + private final Long id; + private final String name; + private final String image; + + public AdminCategoryResponse(final Long id, final String name, final String image) { + this.id = id; + this.name = name; + this.image = image; + } + + public static AdminCategoryResponse toResponse(final Category category) { + return new AdminCategoryResponse(category.getId(), category.getName(), category.getImage()); + } + + public Long getId() { + return id; + } + + public String getName() { + return name; + } + + public String getImage() { + return image; + } +} diff --git a/backend/src/main/java/com/funeat/admin/dto/AdminProductResponse.java b/backend/src/main/java/com/funeat/admin/dto/AdminProductResponse.java new file mode 100644 index 000000000..0f5d2dac5 --- /dev/null +++ b/backend/src/main/java/com/funeat/admin/dto/AdminProductResponse.java @@ -0,0 +1,48 @@ +package com.funeat.admin.dto; + +import com.funeat.product.domain.Product; + +public class AdminProductResponse { + + private final Long id; + private final String name; + private final String content; + private final Long price; + private final AdminCategoryResponse categoryResponse; + + private AdminProductResponse(final Long id, final String name, final String content, + final Long price, final AdminCategoryResponse categoryResponse) { + this.id = id; + this.name = name; + this.content = content; + this.price = price; + this.categoryResponse = categoryResponse; + } + + public static AdminProductResponse toResponse(final Product product) { + final AdminCategoryResponse categoryResponse = AdminCategoryResponse.toResponse(product.getCategory()); + + return new AdminProductResponse(product.getId(), product.getName(), product.getContent(), product.getPrice(), + categoryResponse); + } + + public Long getId() { + return id; + } + + public String getName() { + return name; + } + + public String getContent() { + return content; + } + + public Long getPrice() { + return price; + } + + public AdminCategoryResponse getCategoryResponse() { + return categoryResponse; + } +} diff --git a/backend/src/main/java/com/funeat/admin/dto/AdminProductSearchResponse.java b/backend/src/main/java/com/funeat/admin/dto/AdminProductSearchResponse.java new file mode 100644 index 000000000..0f543344e --- /dev/null +++ b/backend/src/main/java/com/funeat/admin/dto/AdminProductSearchResponse.java @@ -0,0 +1,29 @@ +package com.funeat.admin.dto; + +import java.util.List; + +public class AdminProductSearchResponse { + + private final List productResponses; + private final Long totalElements; + private final Boolean isLastPage; + + public AdminProductSearchResponse(final List productResponses, final Long totalElements, + final Boolean isLastPage) { + this.productResponses = productResponses; + this.totalElements = totalElements; + this.isLastPage = isLastPage; + } + + public List getProductResponses() { + return productResponses; + } + + public Long getTotalElements() { + return totalElements; + } + + public Boolean isLastPage() { + return isLastPage; + } +} diff --git a/backend/src/main/java/com/funeat/admin/dto/AdminReviewResponse.java b/backend/src/main/java/com/funeat/admin/dto/AdminReviewResponse.java new file mode 100644 index 000000000..d9f4bddcf --- /dev/null +++ b/backend/src/main/java/com/funeat/admin/dto/AdminReviewResponse.java @@ -0,0 +1,54 @@ +package com.funeat.admin.dto; + +import com.funeat.review.domain.Review; +import java.time.LocalDateTime; + +public class AdminReviewResponse { + + private final Long id; + private final String userName; + private final String content; + private final String image; + private final String productName; + private final LocalDateTime createdAt; + + private AdminReviewResponse(final Long id, final String userName, final String content, + final String image, final String productName, + final LocalDateTime createdAt) { + this.id = id; + this.userName = userName; + this.content = content; + this.image = image; + this.productName = productName; + this.createdAt = createdAt; + } + + public static AdminReviewResponse toResponse(final Review review) { + return new AdminReviewResponse(review.getId(), review.getMember().getNickname(), review.getContent(), + review.getImage(), review.getProduct().getName(), review.getCreatedAt()); + } + + public Long getId() { + return id; + } + + public String getUserName() { + return userName; + } + + public String getContent() { + return content; + } + + public String getImage() { + return image; + } + + public String getProductName() { + return productName; + } + + public LocalDateTime getCreatedAt() { + return createdAt; + } +} diff --git a/backend/src/main/java/com/funeat/admin/dto/AdminReviewSearchResponse.java b/backend/src/main/java/com/funeat/admin/dto/AdminReviewSearchResponse.java new file mode 100644 index 000000000..1b5c0c12b --- /dev/null +++ b/backend/src/main/java/com/funeat/admin/dto/AdminReviewSearchResponse.java @@ -0,0 +1,29 @@ +package com.funeat.admin.dto; + +import java.util.List; + +public class AdminReviewSearchResponse { + + private final List reviewResponses; + private final Long totalElements; + private final Boolean isLastPage; + + public AdminReviewSearchResponse(final List reviewResponses, final Long totalElements, + final Boolean isLastPage) { + this.reviewResponses = reviewResponses; + this.totalElements = totalElements; + this.isLastPage = isLastPage; + } + + public List getReviewResponses() { + return reviewResponses; + } + + public Long getTotalElements() { + return totalElements; + } + + public Boolean getLastPage() { + return isLastPage; + } +} diff --git a/backend/src/main/java/com/funeat/admin/dto/ProductCreateRequest.java b/backend/src/main/java/com/funeat/admin/dto/ProductCreateRequest.java new file mode 100644 index 000000000..dd835efa3 --- /dev/null +++ b/backend/src/main/java/com/funeat/admin/dto/ProductCreateRequest.java @@ -0,0 +1,32 @@ +package com.funeat.admin.dto; + +public class ProductCreateRequest { + + private final String name; + private final Long price; + private final String content; + private final Long categoryId; + + public ProductCreateRequest(final String name, final Long price, final String content, final Long categoryId) { + this.name = name; + this.price = price; + this.content = content; + this.categoryId = categoryId; + } + + public String getName() { + return name; + } + + public Long getPrice() { + return price; + } + + public String getContent() { + return content; + } + + public Long getCategoryId() { + return categoryId; + } +} diff --git a/backend/src/main/java/com/funeat/admin/dto/ProductSearchCondition.java b/backend/src/main/java/com/funeat/admin/dto/ProductSearchCondition.java new file mode 100644 index 000000000..6114176c2 --- /dev/null +++ b/backend/src/main/java/com/funeat/admin/dto/ProductSearchCondition.java @@ -0,0 +1,39 @@ +package com.funeat.admin.dto; + +public class ProductSearchCondition { + + private final String name; + private final Long id; + private final Long categoryId; + private final Long totalElements; + private final Long prePage; + + public ProductSearchCondition(final String name, final Long id, final Long categoryId, + final Long totalElements, final Long prePage) { + this.name = name; + this.id = id; + this.categoryId = categoryId; + this.totalElements = totalElements; + this.prePage = prePage; + } + + public String getName() { + return name; + } + + public Long getId() { + return id; + } + + public Long getCategoryId() { + return categoryId; + } + + public Long getTotalElements() { + return totalElements; + } + + public Long getPrePage() { + return prePage; + } +} diff --git a/backend/src/main/java/com/funeat/admin/dto/ProductUpdateRequest.java b/backend/src/main/java/com/funeat/admin/dto/ProductUpdateRequest.java new file mode 100644 index 000000000..31d639be4 --- /dev/null +++ b/backend/src/main/java/com/funeat/admin/dto/ProductUpdateRequest.java @@ -0,0 +1,32 @@ +package com.funeat.admin.dto; + +public class ProductUpdateRequest { + + private final String name; + private final Long price; + private final String content; + private final Long categoryId; + + public ProductUpdateRequest(final String name, final Long price, final String content, final Long categoryId) { + this.name = name; + this.price = price; + this.content = content; + this.categoryId = categoryId; + } + + public String getName() { + return name; + } + + public Long getPrice() { + return price; + } + + public String getContent() { + return content; + } + + public Long getCategoryId() { + return categoryId; + } +} diff --git a/backend/src/main/java/com/funeat/admin/dto/ReviewSearchCondition.java b/backend/src/main/java/com/funeat/admin/dto/ReviewSearchCondition.java new file mode 100644 index 000000000..02ce7e902 --- /dev/null +++ b/backend/src/main/java/com/funeat/admin/dto/ReviewSearchCondition.java @@ -0,0 +1,53 @@ +package com.funeat.admin.dto; + +import java.time.LocalDateTime; +import org.springframework.format.annotation.DateTimeFormat; + +public class ReviewSearchCondition { + + private final Long productId; + + @DateTimeFormat(pattern = "yyyy-MM-dd HH:mm:ss") + private final LocalDateTime from; + + @DateTimeFormat(pattern = "yyyy-MM-dd HH:mm:ss") + private final LocalDateTime to; + + private final Long id; + private final Long totalElements; + private final Long prePage; + + public ReviewSearchCondition(final Long productId, final LocalDateTime from, final LocalDateTime to, + final Long id, final Long totalElements, final Long prePage) { + this.productId = productId; + this.from = from; + this.to = to; + this.id = id; + this.totalElements = totalElements; + this.prePage = prePage; + } + + public Long getProductId() { + return productId; + } + + public LocalDateTime getFrom() { + return from; + } + + public LocalDateTime getTo() { + return to; + } + + public Long getId() { + return id; + } + + public Long getTotalElements() { + return totalElements; + } + + public Long getPrePage() { + return prePage; + } +} diff --git a/backend/src/main/java/com/funeat/admin/presentation/AdminController.java b/backend/src/main/java/com/funeat/admin/presentation/AdminController.java new file mode 100644 index 000000000..6b22890b3 --- /dev/null +++ b/backend/src/main/java/com/funeat/admin/presentation/AdminController.java @@ -0,0 +1,70 @@ +package com.funeat.admin.presentation; + +import com.funeat.admin.application.AdminService; +import com.funeat.admin.dto.AdminProductSearchResponse; +import com.funeat.admin.dto.AdminReviewSearchResponse; +import com.funeat.admin.dto.ProductCreateRequest; +import com.funeat.admin.dto.ProductSearchCondition; +import com.funeat.admin.dto.ProductUpdateRequest; +import com.funeat.admin.dto.ReviewSearchCondition; +import com.funeat.product.dto.CategoryResponse; +import java.net.URI; +import java.util.List; +import org.springframework.data.domain.Pageable; +import org.springframework.data.web.PageableDefault; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.ModelAttribute; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.PutMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +@RestController +@RequestMapping("/api/admin") +public class AdminController { + + private final AdminService adminService; + + public AdminController(final AdminService adminService) { + this.adminService = adminService; + } + + @PostMapping("/products") + public ResponseEntity addProduct(@RequestBody final ProductCreateRequest request) { + final Long productId = adminService.addProduct(request); + + return ResponseEntity.created(URI.create("/api/products/" + productId)).build(); + } + + @GetMapping("/products") + public ResponseEntity getSearchProducts( + @ModelAttribute final ProductSearchCondition condition, + @PageableDefault final Pageable pageable) { + final AdminProductSearchResponse response = adminService.getSearchProducts(condition, pageable); + return ResponseEntity.ok(response); + } + + @PutMapping("/products/{productId}") + public ResponseEntity updateProduct(@PathVariable final Long productId, + @RequestBody final ProductUpdateRequest request) { + adminService.updateProduct(productId, request); + return ResponseEntity.noContent().build(); + } + + @GetMapping("/categories") + public ResponseEntity> getAllCategories() { + final List responses = adminService.getAllCategories(); + return ResponseEntity.ok(responses); + } + + @GetMapping("/reviews") + public ResponseEntity getSearchReviews( + @ModelAttribute final ReviewSearchCondition condition, + @PageableDefault final Pageable pageable) { + final AdminReviewSearchResponse responses = adminService.getSearchReviews(condition, pageable); + return ResponseEntity.ok(responses); + } +} diff --git a/backend/src/main/java/com/funeat/admin/presentation/AdminLoginController.java b/backend/src/main/java/com/funeat/admin/presentation/AdminLoginController.java new file mode 100644 index 000000000..6d8e55761 --- /dev/null +++ b/backend/src/main/java/com/funeat/admin/presentation/AdminLoginController.java @@ -0,0 +1,36 @@ +package com.funeat.admin.presentation; + +import com.funeat.admin.application.AdminChecker; +import com.funeat.admin.domain.AdminAuthInfo; +import javax.servlet.http.HttpServletRequest; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +@RestController +@RequestMapping("/api/admin") +public class AdminLoginController { + + private final AdminChecker adminChecker; + + public AdminLoginController(final AdminChecker adminChecker) { + this.adminChecker = adminChecker; + } + + @PostMapping("/login") + public ResponseEntity login(@RequestBody final AdminAuthInfo adminAuthInfo, final HttpServletRequest request) { + adminChecker.check(adminAuthInfo); + + request.getSession().setAttribute("authInfo", adminAuthInfo); + + return ResponseEntity.noContent().build(); + } + + @GetMapping("/logged-check") + public ResponseEntity validLoggedInAdmin() { + return ResponseEntity.ok(true); + } +} diff --git a/backend/src/main/java/com/funeat/admin/repository/AdminProductRepository.java b/backend/src/main/java/com/funeat/admin/repository/AdminProductRepository.java new file mode 100644 index 000000000..6dddc02a2 --- /dev/null +++ b/backend/src/main/java/com/funeat/admin/repository/AdminProductRepository.java @@ -0,0 +1,7 @@ +package com.funeat.admin.repository; + +import com.funeat.common.repository.BaseRepository; +import com.funeat.product.domain.Product; + +public interface AdminProductRepository extends BaseRepository { +} diff --git a/backend/src/main/java/com/funeat/admin/repository/AdminReviewRepository.java b/backend/src/main/java/com/funeat/admin/repository/AdminReviewRepository.java new file mode 100644 index 000000000..5da483f2e --- /dev/null +++ b/backend/src/main/java/com/funeat/admin/repository/AdminReviewRepository.java @@ -0,0 +1,7 @@ +package com.funeat.admin.repository; + +import com.funeat.common.repository.BaseRepository; +import com.funeat.review.domain.Review; + +public interface AdminReviewRepository extends BaseRepository { +} diff --git a/backend/src/main/java/com/funeat/admin/specification/AdminProductSpecification.java b/backend/src/main/java/com/funeat/admin/specification/AdminProductSpecification.java new file mode 100644 index 000000000..a8e63b748 --- /dev/null +++ b/backend/src/main/java/com/funeat/admin/specification/AdminProductSpecification.java @@ -0,0 +1,67 @@ +package com.funeat.admin.specification; + +import com.funeat.admin.dto.ProductSearchCondition; +import com.funeat.product.domain.Category; +import com.funeat.product.domain.Product; +import java.util.List; +import java.util.Objects; +import javax.persistence.criteria.JoinType; +import javax.persistence.criteria.Path; +import org.springframework.data.jpa.domain.Specification; + +public class AdminProductSpecification { + + private static final List> COUNT_RESULT_TYPES = List.of(Long.class, long.class); + + public static Specification searchBy(final ProductSearchCondition condition) { + return (root, query, criteriaBuilder) -> { + if (!COUNT_RESULT_TYPES.contains(query.getResultType())) { + root.fetch("category", JoinType.LEFT); + } + + criteriaBuilder.desc(root.get("id")); + + return Specification + .where(like(condition.getName())) + .and(lessThan(condition.getId())) + .and(sameCategory(condition.getCategoryId())) + .toPredicate(root, query, criteriaBuilder); + }; + } + + private static Specification like(final String productName) { + return (root, query, criteriaBuilder) -> { + if (Objects.isNull(productName)) { + return null; + } + + final Path namePath = root.get("name"); + + return criteriaBuilder.like(namePath, "%" + productName + "%"); + }; + } + + private static Specification lessThan(final Long productId) { + return (root, query, criteriaBuilder) -> { + if (Objects.isNull(productId)) { + return null; + } + + final Path productIdPath = root.get("id"); + + return criteriaBuilder.lessThan(productIdPath, productId); + }; + } + + private static Specification sameCategory(final Long categoryId) { + return (root, query, criteriaBuilder) -> { + if (Objects.isNull(categoryId)) { + return null; + } + + final Path categoryPath = root.get("category"); + + return criteriaBuilder.equal(categoryPath, categoryId); + }; + } +} diff --git a/backend/src/main/java/com/funeat/admin/specification/AdminReviewSpecification.java b/backend/src/main/java/com/funeat/admin/specification/AdminReviewSpecification.java new file mode 100644 index 000000000..b7c345f14 --- /dev/null +++ b/backend/src/main/java/com/funeat/admin/specification/AdminReviewSpecification.java @@ -0,0 +1,79 @@ +package com.funeat.admin.specification; + +import com.funeat.admin.dto.ReviewSearchCondition; +import com.funeat.product.domain.Product; +import com.funeat.review.domain.Review; +import java.time.LocalDateTime; +import java.util.Objects; +import javax.persistence.criteria.JoinType; +import javax.persistence.criteria.Path; +import org.springframework.data.jpa.domain.Specification; + +public class AdminReviewSpecification { + + public static Specification searchBy(final ReviewSearchCondition condition) { + return (root, query, criteriaBuilder) -> { + if (query.getResultType() != Long.class && query.getResultType() != long.class) { + root.fetch("member", JoinType.LEFT); + root.fetch("product", JoinType.LEFT); + } + + criteriaBuilder.desc(root.get("id")); + + return Specification + .where(to(condition.getTo())) + .and(from(condition.getFrom())) + .and(sameProduct(condition.getProductId())) + .and(lessThan(condition.getId())) + .toPredicate(root, query, criteriaBuilder); + }; + } + + private static Specification to(final LocalDateTime to) { + return (root, query, criteriaBuilder) -> { + if (Objects.isNull(to)) { + return null; + } + + final Path toPath = root.get("createdAt"); + + return criteriaBuilder.lessThanOrEqualTo(toPath, to); + }; + } + + private static Specification from(final LocalDateTime from) { + return (root, query, criteriaBuilder) -> { + if (Objects.isNull(from)) { + return null; + } + + final Path fromPath = root.get("createdAt"); + + return criteriaBuilder.greaterThanOrEqualTo(fromPath, from); + }; + } + + private static Specification sameProduct(final Long productId) { + return (root, query, criteriaBuilder) -> { + if (Objects.isNull(productId)) { + return null; + } + + final Path productPath = root.get("product"); + + return criteriaBuilder.equal(productPath, productId); + }; + } + + private static Specification lessThan(final Long reviewId) { + return (root, query, criteriaBuilder) -> { + if (Objects.isNull(reviewId)) { + return null; + } + + final Path reviewIdPath = root.get("id"); + + return criteriaBuilder.lessThan(reviewIdPath, reviewId); + }; + } +} diff --git a/backend/src/main/java/com/funeat/admin/util/AdminCheckInterceptor.java b/backend/src/main/java/com/funeat/admin/util/AdminCheckInterceptor.java new file mode 100644 index 000000000..8245a849b --- /dev/null +++ b/backend/src/main/java/com/funeat/admin/util/AdminCheckInterceptor.java @@ -0,0 +1,41 @@ +package com.funeat.admin.util; + +import static com.funeat.auth.exception.AuthErrorCode.LOGIN_ADMIN_NOT_FOUND; + +import com.funeat.admin.application.AdminChecker; +import com.funeat.admin.domain.AdminAuthInfo; +import com.funeat.auth.exception.AuthException.NotLoggedInException; +import java.util.Objects; +import javax.servlet.http.HttpServletRequest; +import javax.servlet.http.HttpServletResponse; +import javax.servlet.http.HttpSession; +import org.springframework.stereotype.Component; +import org.springframework.web.servlet.HandlerInterceptor; + +@Component +public class AdminCheckInterceptor implements HandlerInterceptor { + + private final AdminChecker adminChecker; + + public AdminCheckInterceptor(final AdminChecker adminChecker) { + this.adminChecker = adminChecker; + } + + @Override + public boolean preHandle(final HttpServletRequest request, final HttpServletResponse response, + final Object handler) { + final HttpSession session = request.getSession(false); + + if (Objects.isNull(session)) { + throw new NotLoggedInException(LOGIN_ADMIN_NOT_FOUND); + } + + final AdminAuthInfo adminAuthInfo = (AdminAuthInfo) session.getAttribute("authInfo"); + + if (Objects.isNull(adminAuthInfo)) { + throw new NotLoggedInException(LOGIN_ADMIN_NOT_FOUND); + } + + return adminChecker.check(adminAuthInfo); + } +} diff --git a/backend/src/main/java/com/funeat/auth/exception/AuthErrorCode.java b/backend/src/main/java/com/funeat/auth/exception/AuthErrorCode.java index 3508ff142..e47350078 100644 --- a/backend/src/main/java/com/funeat/auth/exception/AuthErrorCode.java +++ b/backend/src/main/java/com/funeat/auth/exception/AuthErrorCode.java @@ -5,6 +5,7 @@ public enum AuthErrorCode { LOGIN_MEMBER_NOT_FOUND(HttpStatus.UNAUTHORIZED, "로그인 하지 않은 회원입니다. 로그인을 해주세요.", "6001"), + LOGIN_ADMIN_NOT_FOUND(HttpStatus.UNAUTHORIZED, "로그인 하지 않은 관리자입니다. 로그인을 해주세요.", "6002"), ; private final HttpStatus status; diff --git a/backend/src/main/java/com/funeat/common/WebConfig.java b/backend/src/main/java/com/funeat/common/WebConfig.java index 638dffe05..c6a0b0e7a 100644 --- a/backend/src/main/java/com/funeat/common/WebConfig.java +++ b/backend/src/main/java/com/funeat/common/WebConfig.java @@ -1,5 +1,6 @@ package com.funeat.common; +import com.funeat.admin.util.AdminCheckInterceptor; import com.funeat.auth.util.AuthArgumentResolver; import com.funeat.auth.util.AuthHandlerInterceptor; import com.funeat.recipe.util.RecipeDetailHandlerInterceptor; @@ -20,16 +21,20 @@ public class WebConfig implements WebMvcConfigurer { private final RecipeHandlerInterceptor recipeHandlerInterceptor; private final RecipeDetailHandlerInterceptor recipeDetailHandlerInterceptor; + private final AdminCheckInterceptor adminCheckInterceptor; + public WebConfig(final CustomPageableHandlerMethodArgumentResolver customPageableHandlerMethodArgumentResolver, final AuthArgumentResolver authArgumentResolver, final AuthHandlerInterceptor authHandlerInterceptor, final RecipeHandlerInterceptor recipeHandlerInterceptor, - final RecipeDetailHandlerInterceptor recipeDetailHandlerInterceptor) { + final RecipeDetailHandlerInterceptor recipeDetailHandlerInterceptor, + final AdminCheckInterceptor adminCheckInterceptor) { this.customPageableHandlerMethodArgumentResolver = customPageableHandlerMethodArgumentResolver; this.authArgumentResolver = authArgumentResolver; this.authHandlerInterceptor = authHandlerInterceptor; this.recipeHandlerInterceptor = recipeHandlerInterceptor; this.recipeDetailHandlerInterceptor = recipeDetailHandlerInterceptor; + this.adminCheckInterceptor = adminCheckInterceptor; } @Override @@ -43,6 +48,9 @@ public void addInterceptors(final InterceptorRegistry registry) { registry.addInterceptor(recipeDetailHandlerInterceptor) .addPathPatterns("/api/recipes/**") .excludePathPatterns("/api/recipes"); + registry.addInterceptor(adminCheckInterceptor) + .excludePathPatterns("/api/admin/login") + .addPathPatterns("/api/admin/**"); } @Override diff --git a/backend/src/main/java/com/funeat/common/repository/BaseRepository.java b/backend/src/main/java/com/funeat/common/repository/BaseRepository.java new file mode 100644 index 000000000..9c7197243 --- /dev/null +++ b/backend/src/main/java/com/funeat/common/repository/BaseRepository.java @@ -0,0 +1,14 @@ +package com.funeat.common.repository; + +import java.io.Serializable; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; +import org.springframework.data.jpa.domain.Specification; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.repository.NoRepositoryBean; + +@NoRepositoryBean +public interface BaseRepository extends JpaRepository { + + Page findAllForPagination(final Specification spec, final Pageable pageable, final Long totalElements); +} diff --git a/backend/src/main/java/com/funeat/common/repository/BaseRepositoryImpl.java b/backend/src/main/java/com/funeat/common/repository/BaseRepositoryImpl.java new file mode 100644 index 000000000..773b95269 --- /dev/null +++ b/backend/src/main/java/com/funeat/common/repository/BaseRepositoryImpl.java @@ -0,0 +1,40 @@ +package com.funeat.common.repository; + +import java.io.Serializable; +import javax.persistence.EntityManager; +import javax.persistence.TypedQuery; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.PageImpl; +import org.springframework.data.domain.PageRequest; +import org.springframework.data.domain.Pageable; +import org.springframework.data.jpa.domain.Specification; +import org.springframework.data.jpa.repository.support.JpaEntityInformation; +import org.springframework.data.jpa.repository.support.SimpleJpaRepository; + +public class BaseRepositoryImpl extends SimpleJpaRepository + implements BaseRepository { + + public BaseRepositoryImpl(final JpaEntityInformation entityInformation, final EntityManager entityManager) { + super(entityInformation, entityManager); + } + + @Override + public Page findAllForPagination(final Specification spec, final Pageable pageable, + final Long totalElements) { + final TypedQuery query = getQuery(spec, pageable.getSort()); + + final int pageSize = pageable.getPageSize(); + + if (totalElements == null) { + return findAll(spec, pageable); + } + + if (pageSize < 1) { + throw new IllegalArgumentException("페이지는 1미만이 될 수 없습니다."); + } + + query.setMaxResults(pageable.getPageSize()); + + return new PageImpl<>(query.getResultList(), PageRequest.of(0, pageSize), totalElements); + } +} diff --git a/backend/src/main/java/com/funeat/member/presentation/MemberApiController.java b/backend/src/main/java/com/funeat/member/presentation/MemberApiController.java index 6e28cfac6..6ee963d54 100644 --- a/backend/src/main/java/com/funeat/member/presentation/MemberApiController.java +++ b/backend/src/main/java/com/funeat/member/presentation/MemberApiController.java @@ -51,7 +51,7 @@ public ResponseEntity putMemberProfile(@AuthenticationPrincipal final Logi @RequestPart @Valid final MemberRequest memberRequest) { memberService.modify(loginInfo.getId(), image, memberRequest); - return ResponseEntity.ok().build(); + return ResponseEntity.noContent().build(); } @GetMapping("/reviews") diff --git a/backend/src/main/java/com/funeat/member/presentation/MemberController.java b/backend/src/main/java/com/funeat/member/presentation/MemberController.java index 9a4ede8da..5d5748fd7 100644 --- a/backend/src/main/java/com/funeat/member/presentation/MemberController.java +++ b/backend/src/main/java/com/funeat/member/presentation/MemberController.java @@ -30,7 +30,7 @@ public interface MemberController { @Operation(summary = "사용자 정보 수정", description = "사용자 닉네임과 프로필 사진을 수정한다.") @ApiResponse( - responseCode = "200", + responseCode = "204", description = "사용자 정보 수정 성공." ) @PutMapping diff --git a/backend/src/main/java/com/funeat/product/domain/Product.java b/backend/src/main/java/com/funeat/product/domain/Product.java index eca71a02d..a485eaf55 100644 --- a/backend/src/main/java/com/funeat/product/domain/Product.java +++ b/backend/src/main/java/com/funeat/product/domain/Product.java @@ -3,8 +3,6 @@ import com.funeat.member.domain.bookmark.ProductBookmark; import com.funeat.review.domain.Review; import java.util.List; -import java.util.Objects; -import java.util.concurrent.atomic.AtomicLong; import javax.persistence.Entity; import javax.persistence.FetchType; import javax.persistence.GeneratedValue; @@ -68,6 +66,10 @@ public Product(final String name, final Long price, final String image, final St this.category = category; } + public static Product create(final String name, final Long price, final String content, final Category category) { + return new Product(name, price, null, content, category); + } + public void updateAverageRating(final Long rating, final Long count) { final double calculatedRating = ((count - 1) * averageRating + rating) / count; this.averageRating = Math.round(calculatedRating * 10.0) / 10.0; @@ -83,6 +85,13 @@ public void updateImage(final String topFavoriteImage) { this.image = topFavoriteImage; } + public void update(final String name, final String content, final Long price, final Category category) { + this.name = name; + this.content = content; + this.price = price; + this.category = category; + } + public Long getId() { return id; } diff --git a/backend/src/main/java/com/funeat/product/persistence/ProductRepository.java b/backend/src/main/java/com/funeat/product/persistence/ProductRepository.java index ad4cdab0e..c3adc5c4b 100644 --- a/backend/src/main/java/com/funeat/product/persistence/ProductRepository.java +++ b/backend/src/main/java/com/funeat/product/persistence/ProductRepository.java @@ -8,16 +8,17 @@ import org.springframework.data.domain.Page; import org.springframework.data.domain.Pageable; import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.JpaSpecificationExecutor; import org.springframework.data.jpa.repository.Query; import org.springframework.data.repository.query.Param; -public interface ProductRepository extends JpaRepository { +public interface ProductRepository extends JpaRepository, JpaSpecificationExecutor { @Query(value = "SELECT new com.funeat.product.dto.ProductInCategoryDto(p.id, p.name, p.price, p.image, p.averageRating, COUNT(r)) " - + "FROM Product p " - + "LEFT JOIN p.reviews r " - + "WHERE p.category = :category " - + "GROUP BY p ", + + "FROM Product p " + + "LEFT JOIN p.reviews r " + + "WHERE p.category = :category " + + "GROUP BY p ", countQuery = "SELECT COUNT(p) FROM Product p WHERE p.category = :category") Page findAllByCategory(@Param("category") final Category category, final Pageable pageable); diff --git a/backend/src/main/resources/application-local.yml b/backend/src/main/resources/application-local.yml index e9e6a6acf..dd4ef8afb 100644 --- a/backend/src/main/resources/application-local.yml +++ b/backend/src/main/resources/application-local.yml @@ -12,7 +12,6 @@ spring: hibernate: format_sql: true show_sql: true - logging: level: org.hibernate.type.descriptor.sql: trace diff --git a/backend/src/main/resources/application.yml b/backend/src/main/resources/application.yml index d77ffa4b2..df666acad 100644 --- a/backend/src/main/resources/application.yml +++ b/backend/src/main/resources/application.yml @@ -27,3 +27,7 @@ server: session: cookie: name: FUNEAT + +back-office: + id: { BACK_OFFICE_ID } + key: { BACK_OFFICE_KEY } diff --git a/backend/src/test/java/com/funeat/acceptance/member/MemberAcceptanceTest.java b/backend/src/test/java/com/funeat/acceptance/member/MemberAcceptanceTest.java index 31ea4a5e4..cb3b8e629 100644 --- a/backend/src/test/java/com/funeat/acceptance/member/MemberAcceptanceTest.java +++ b/backend/src/test/java/com/funeat/acceptance/member/MemberAcceptanceTest.java @@ -7,6 +7,7 @@ import static com.funeat.acceptance.common.CommonSteps.인증되지_않음; import static com.funeat.acceptance.common.CommonSteps.잘못된_요청; import static com.funeat.acceptance.common.CommonSteps.정상_처리; +import static com.funeat.acceptance.common.CommonSteps.정상_처리_NO_CONTENT; import static com.funeat.acceptance.common.CommonSteps.페이지를_검증한다; import static com.funeat.acceptance.member.MemberSteps.사용자_꿀조합_조회_요청; import static com.funeat.acceptance.member.MemberSteps.사용자_리뷰_조회_요청; @@ -102,7 +103,7 @@ class putMemberProfile_성공_테스트 { final var 응답 = 사용자_정보_수정_요청(로그인_쿠키_획득(멤버1), 사진_명세_요청(이미지1), 유저닉네임수정요청_생성("after")); // then - STATUS_CODE를_검증한다(응답, 정상_처리); + STATUS_CODE를_검증한다(응답, 정상_처리_NO_CONTENT); } @Test @@ -111,7 +112,7 @@ class putMemberProfile_성공_테스트 { final var 응답 = 사용자_정보_수정_요청(로그인_쿠키_획득(멤버1), 사진_명세_요청(이미지1), 유저닉네임수정요청_생성("member1")); // then - STATUS_CODE를_검증한다(응답, 정상_처리); + STATUS_CODE를_검증한다(응답, 정상_처리_NO_CONTENT); } @Test @@ -120,7 +121,7 @@ class putMemberProfile_성공_테스트 { final var 응답 = 사용자_정보_수정_요청(로그인_쿠키_획득(멤버1), 사진_명세_요청(이미지2), 유저닉네임수정요청_생성("after")); // then - STATUS_CODE를_검증한다(응답, 정상_처리); + STATUS_CODE를_검증한다(응답, 정상_처리_NO_CONTENT); } } diff --git a/backend/src/test/resources/application.yml b/backend/src/test/resources/application.yml index b4dda6f4f..74ac869e1 100644 --- a/backend/src/test/resources/application.yml +++ b/backend/src/test/resources/application.yml @@ -33,3 +33,7 @@ cloud: bucket: testBucket folder: testFolder cloudfrontPath: testCloudfrontPath + +back-office: + id: test + key: test From 9f3232c5ad9d86040ce944fc04ef87700f6e03de Mon Sep 17 00:00:00 2001 From: Leejin Yang Date: Thu, 12 Oct 2023 10:01:30 +0900 Subject: [PATCH 08/55] =?UTF-8?q?[FE]=20fix:=20=EB=B2=A0=EC=8A=A4=ED=8A=B8?= =?UTF-8?q?=EB=A6=AC=EB=B7=B0=20=EC=9A=94=EC=B2=AD=20204=20=EC=B2=98?= =?UTF-8?q?=EB=A6=AC=20(#742)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../src/components/Review/BestReviewItem/BestReviewItem.tsx | 5 +++++ frontend/src/hooks/queries/rank/useBestReviewQuery.ts | 5 +++++ 2 files changed, 10 insertions(+) diff --git a/frontend/src/components/Review/BestReviewItem/BestReviewItem.tsx b/frontend/src/components/Review/BestReviewItem/BestReviewItem.tsx index 8d9059dd4..5e66dcbbb 100644 --- a/frontend/src/components/Review/BestReviewItem/BestReviewItem.tsx +++ b/frontend/src/components/Review/BestReviewItem/BestReviewItem.tsx @@ -10,6 +10,11 @@ interface BestReviewItemProps { const BestReviewItem = ({ productId }: BestReviewItemProps) => { const { data: bestReview } = useBestReviewQuery(productId); + + if (!bestReview) { + return null; + } + const { profileImage, userName, rating, favoriteCount, content } = bestReview; const theme = useTheme(); diff --git a/frontend/src/hooks/queries/rank/useBestReviewQuery.ts b/frontend/src/hooks/queries/rank/useBestReviewQuery.ts index 20ca25398..5dbd0e902 100644 --- a/frontend/src/hooks/queries/rank/useBestReviewQuery.ts +++ b/frontend/src/hooks/queries/rank/useBestReviewQuery.ts @@ -5,6 +5,11 @@ import type { Review } from '@/types/review'; const fetchBestReview = async (productId: number) => { const response = await rankApi.get({ params: `/products/${productId}/reviews` }); + + if (response.status === 204) { + return null; + } + const data: Review = await response.json(); return data; }; From 1b770e36cdfa9027f86c4ca7e910fea2144b9fa8 Mon Sep 17 00:00:00 2001 From: Dabeen Jeong Date: Thu, 12 Oct 2023 17:51:28 +0900 Subject: [PATCH 09/55] =?UTF-8?q?[BE]=20refactor:=20=EB=A1=9C=EA=B7=B8?= =?UTF-8?q?=EC=9D=B8=20=EC=84=B8=EC=85=98=20=EC=A0=95=EB=B3=B4=EB=A5=BC=20?= =?UTF-8?q?DB=EC=97=90=20=EC=A0=80=EC=9E=A5=ED=95=98=EB=8A=94=20=EA=B8=B0?= =?UTF-8?q?=EB=8A=A5=20=EC=B6=94=EA=B0=80=20(#730)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * chore: spring session jdbc 의존성 추가 * feat: 스프링 세션 설정 store-type은 JDBC를 사용한다는 뜻 (ex. jdbc, redis, ...) initialize-schema는 직접 개발, 운영 서버에 DDL을 실행할 것이므로 never 사용 (ex. never, always, embedded) * refactor: 테스트는 spring session jdbc를 사용하지 않게 수정 * refactor: yaml 파일에 쿠키 이름 FUNEAT 제거 * refactor: 모든 Steps의 쿠키 이름을 FUNEAT 대신 JSESSIONID로 변경 --- backend/build.gradle | 2 ++ .../java/com/funeat/auth/application/AuthService.java | 2 +- backend/src/main/resources/application.yml | 8 ++++---- .../test/java/com/funeat/acceptance/auth/LoginSteps.java | 4 ++-- .../java/com/funeat/acceptance/member/MemberSteps.java | 8 ++++---- .../java/com/funeat/acceptance/recipe/RecipeSteps.java | 6 +++--- .../java/com/funeat/acceptance/review/ReviewSteps.java | 6 +++--- backend/src/test/resources/application.yml | 9 +++------ 8 files changed, 22 insertions(+), 23 deletions(-) diff --git a/backend/build.gradle b/backend/build.gradle index 8a0ad47dd..3d46c806e 100644 --- a/backend/build.gradle +++ b/backend/build.gradle @@ -31,6 +31,8 @@ dependencies { runtimeOnly 'io.micrometer:micrometer-registry-prometheus' implementation 'com.amazonaws:aws-java-sdk-s3:1.12.547' + + implementation 'org.springframework.session:spring-session-jdbc' } tasks.named('test') { diff --git a/backend/src/main/java/com/funeat/auth/application/AuthService.java b/backend/src/main/java/com/funeat/auth/application/AuthService.java index dd0a43d6c..bd69c7dc4 100644 --- a/backend/src/main/java/com/funeat/auth/application/AuthService.java +++ b/backend/src/main/java/com/funeat/auth/application/AuthService.java @@ -12,7 +12,7 @@ @Transactional(readOnly = true) public class AuthService { - private static final String COOKIE_NAME = "FUNEAT"; + private static final String COOKIE_NAME = "JSESSIONID"; private final MemberService memberService; private final PlatformUserProvider platformUserProvider; diff --git a/backend/src/main/resources/application.yml b/backend/src/main/resources/application.yml index df666acad..8235adae4 100644 --- a/backend/src/main/resources/application.yml +++ b/backend/src/main/resources/application.yml @@ -6,6 +6,10 @@ spring: enabled: true maxFileSize: 10MB maxRequestSize: 15MB + session: + store-type: jdbc + jdbc: + initialize-schema: never springdoc: swagger-ui: @@ -23,10 +27,6 @@ server: max: { MAX_THREADS } max-connections: { MAX_CONNECTIONS } accept-count: { ACCEPT_COUNT } - servlet: - session: - cookie: - name: FUNEAT back-office: id: { BACK_OFFICE_ID } diff --git a/backend/src/test/java/com/funeat/acceptance/auth/LoginSteps.java b/backend/src/test/java/com/funeat/acceptance/auth/LoginSteps.java index dd75b30ab..21dd1bf77 100644 --- a/backend/src/test/java/com/funeat/acceptance/auth/LoginSteps.java +++ b/backend/src/test/java/com/funeat/acceptance/auth/LoginSteps.java @@ -29,7 +29,7 @@ public class LoginSteps { public static ExtractableResponse 로그아웃_요청(final String loginCookie) { return given() - .cookie("FUNEAT", loginCookie) + .cookie("JSESSIONID", loginCookie) .when() .post("/api/logout") .then() @@ -44,6 +44,6 @@ public class LoginSteps { .then() .extract() .response() - .getCookie("FUNEAT"); + .getCookie("JSESSIONID"); } } diff --git a/backend/src/test/java/com/funeat/acceptance/member/MemberSteps.java b/backend/src/test/java/com/funeat/acceptance/member/MemberSteps.java index 32ad97500..ca5600fdc 100644 --- a/backend/src/test/java/com/funeat/acceptance/member/MemberSteps.java +++ b/backend/src/test/java/com/funeat/acceptance/member/MemberSteps.java @@ -13,7 +13,7 @@ public class MemberSteps { public static ExtractableResponse 사용자_정보_조회_요청(final String loginCookie) { return given() - .cookie("FUNEAT", loginCookie) + .cookie("JSESSIONID", loginCookie) .when() .get("/api/members") .then() @@ -24,7 +24,7 @@ public class MemberSteps { final MultiPartSpecification image, final MemberRequest request) { final var requestSpec = given() - .cookie("FUNEAT", loginCookie); + .cookie("JSESSIONID", loginCookie); if (Objects.nonNull(image)) { requestSpec.multiPart(image); @@ -43,7 +43,7 @@ public class MemberSteps { final Long page) { return given() .when() - .cookie("FUNEAT", loginCookie) + .cookie("JSESSIONID", loginCookie) .queryParam("sort", sort) .queryParam("page", page) .get("/api/members/reviews") @@ -55,7 +55,7 @@ public class MemberSteps { final Long page) { return given() .when() - .cookie("FUNEAT", loginCookie) + .cookie("JSESSIONID", loginCookie) .queryParam("sort", sort) .queryParam("page", page) .get("/api/members/recipes") diff --git a/backend/src/test/java/com/funeat/acceptance/recipe/RecipeSteps.java b/backend/src/test/java/com/funeat/acceptance/recipe/RecipeSteps.java index f82c84f93..4ff18dc2e 100644 --- a/backend/src/test/java/com/funeat/acceptance/recipe/RecipeSteps.java +++ b/backend/src/test/java/com/funeat/acceptance/recipe/RecipeSteps.java @@ -19,7 +19,7 @@ public class RecipeSteps { final List images, final RecipeCreateRequest recipeRequest) { final var requestSpec = given() - .cookie("FUNEAT", loginCookie); + .cookie("JSESSIONID", loginCookie); if (Objects.nonNull(images) && !images.isEmpty()) { images.forEach(requestSpec::multiPart); @@ -35,7 +35,7 @@ public class RecipeSteps { public static ExtractableResponse 레시피_상세_정보_요청(final String loginCookie, final Long recipeId) { return given() - .cookie("FUNEAT", loginCookie) + .cookie("JSESSIONID", loginCookie) .when() .get("/api/recipes/{recipeId}", recipeId) .then() @@ -55,7 +55,7 @@ public class RecipeSteps { public static ExtractableResponse 레시피_좋아요_요청(final String loginCookie, final Long recipeId, final RecipeFavoriteRequest request) { return given() - .cookie("FUNEAT", loginCookie) + .cookie("JSESSIONID", loginCookie) .contentType("application/json") .body(request) .when() diff --git a/backend/src/test/java/com/funeat/acceptance/review/ReviewSteps.java b/backend/src/test/java/com/funeat/acceptance/review/ReviewSteps.java index d422b94a9..0d8ce8fd7 100644 --- a/backend/src/test/java/com/funeat/acceptance/review/ReviewSteps.java +++ b/backend/src/test/java/com/funeat/acceptance/review/ReviewSteps.java @@ -20,7 +20,7 @@ public class ReviewSteps { final MultiPartSpecification image, final ReviewCreateRequest request) { final var requestSpec = given() - .cookie("FUNEAT", loginCookie); + .cookie("JSESSIONID", loginCookie); if (Objects.nonNull(image)) { requestSpec.multiPart(image); @@ -37,7 +37,7 @@ public class ReviewSteps { public static ExtractableResponse 리뷰_좋아요_요청(final String loginCookie, final Long productId, final Long reviewId, final ReviewFavoriteRequest request) { return given() - .cookie("FUNEAT", loginCookie) + .cookie("JSESSIONID", loginCookie) .contentType("application/json") .body(request) .when() @@ -58,7 +58,7 @@ public class ReviewSteps { public static ExtractableResponse 정렬된_리뷰_목록_조회_요청(final String loginCookie, final Long productId, final String sort, final Long page) { return given() - .cookie("FUNEAT", loginCookie) + .cookie("JSESSIONID", loginCookie) .queryParam("sort", sort) .queryParam("page", page) .when() diff --git a/backend/src/test/resources/application.yml b/backend/src/test/resources/application.yml index 74ac869e1..44ef68eb7 100644 --- a/backend/src/test/resources/application.yml +++ b/backend/src/test/resources/application.yml @@ -15,16 +15,13 @@ spring: format_sql: true show_sql: true + session: + store-type: none + logging: level: org.hibernate.type.descriptor.sql: trace -server: - servlet: - session: - cookie: - name: FUNEAT - cloud: aws: region: From 1c3fa0e9c5c6b0637c0e43316676f199c1231d96 Mon Sep 17 00:00:00 2001 From: Dabeen Jeong Date: Thu, 12 Oct 2023 18:07:51 +0900 Subject: [PATCH 10/55] =?UTF-8?q?[BE]=20fix:=20=EB=A1=9C=EA=B7=B8=EC=95=84?= =?UTF-8?q?=EC=9B=83=EC=8B=9C=20=EC=BF=A0=ED=82=A4=EA=B0=80=20=EC=82=AD?= =?UTF-8?q?=EC=A0=9C=EB=90=98=EB=8F=84=EB=A1=9D=20=EC=88=98=EC=A0=95=20(#7?= =?UTF-8?q?49)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../src/main/java/com/funeat/auth/application/AuthService.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/backend/src/main/java/com/funeat/auth/application/AuthService.java b/backend/src/main/java/com/funeat/auth/application/AuthService.java index bd69c7dc4..20a24345c 100644 --- a/backend/src/main/java/com/funeat/auth/application/AuthService.java +++ b/backend/src/main/java/com/funeat/auth/application/AuthService.java @@ -12,7 +12,7 @@ @Transactional(readOnly = true) public class AuthService { - private static final String COOKIE_NAME = "JSESSIONID"; + private static final String COOKIE_NAME = "SESSION"; private final MemberService memberService; private final PlatformUserProvider platformUserProvider; From dc20956c74bca1c6c7dd1e1665b04e3115107011 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=9A=B0=EA=B0=80?= Date: Thu, 12 Oct 2023 22:11:30 +0900 Subject: [PATCH 11/55] =?UTF-8?q?[BE]=20hotfix:=20id,=20key=20Session=20?= =?UTF-8?q?=EC=97=90=20=EB=94=B0=EB=A1=9C=20=EC=A0=80=EC=9E=A5=20(#751)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../funeat/admin/presentation/AdminLoginController.java | 6 ++++-- .../java/com/funeat/admin/util/AdminCheckInterceptor.java | 7 ++++--- 2 files changed, 8 insertions(+), 5 deletions(-) diff --git a/backend/src/main/java/com/funeat/admin/presentation/AdminLoginController.java b/backend/src/main/java/com/funeat/admin/presentation/AdminLoginController.java index 6d8e55761..d23125a85 100644 --- a/backend/src/main/java/com/funeat/admin/presentation/AdminLoginController.java +++ b/backend/src/main/java/com/funeat/admin/presentation/AdminLoginController.java @@ -21,10 +21,12 @@ public AdminLoginController(final AdminChecker adminChecker) { } @PostMapping("/login") - public ResponseEntity login(@RequestBody final AdminAuthInfo adminAuthInfo, final HttpServletRequest request) { + public ResponseEntity login(@RequestBody final AdminAuthInfo adminAuthInfo, + final HttpServletRequest request) { adminChecker.check(adminAuthInfo); - request.getSession().setAttribute("authInfo", adminAuthInfo); + request.getSession().setAttribute("authId", adminAuthInfo.getId()); + request.getSession().setAttribute("authKey", adminAuthInfo.getKey()); return ResponseEntity.noContent().build(); } diff --git a/backend/src/main/java/com/funeat/admin/util/AdminCheckInterceptor.java b/backend/src/main/java/com/funeat/admin/util/AdminCheckInterceptor.java index 8245a849b..59ef45b49 100644 --- a/backend/src/main/java/com/funeat/admin/util/AdminCheckInterceptor.java +++ b/backend/src/main/java/com/funeat/admin/util/AdminCheckInterceptor.java @@ -30,12 +30,13 @@ public boolean preHandle(final HttpServletRequest request, final HttpServletResp throw new NotLoggedInException(LOGIN_ADMIN_NOT_FOUND); } - final AdminAuthInfo adminAuthInfo = (AdminAuthInfo) session.getAttribute("authInfo"); + final String authId = String.valueOf(session.getAttribute("authId")); + final String authKey = String.valueOf(session.getAttribute("authKey")); - if (Objects.isNull(adminAuthInfo)) { + if (Objects.isNull(authId) || Objects.isNull(authKey)) { throw new NotLoggedInException(LOGIN_ADMIN_NOT_FOUND); } - return adminChecker.check(adminAuthInfo); + return adminChecker.check(new AdminAuthInfo(authId, authKey)); } } From 3a950c204070dc74a059239768acd5f087f23adb Mon Sep 17 00:00:00 2001 From: Taeeun Kim Date: Fri, 13 Oct 2023 14:59:24 +0900 Subject: [PATCH 12/55] =?UTF-8?q?[FE]=20feat:=20=EA=BF=80=EC=A1=B0?= =?UTF-8?q?=ED=95=A9=20=EB=8C=93=EA=B8=80=20=EA=B8=B0=EB=8A=A5=20=EA=B5=AC?= =?UTF-8?q?=ED=98=84=20(#744)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat: CommentItem 컴포넌트 추가 * feat: 댓글 목록 가져오는 쿼리 추가 * feat: 상품 상세 페이지에 댓글 컴포넌트 추가 * feat: Input 컴포넌트 속성에 minWidth값 추가 * feat: CommentInput 컴포넌트 추가 * feat: 댓글 등록 기능 구현 * feat: 사용자가 입력한 글자수 UI 추가 * feat: 리뷰 반영 * feat: text area 텍스트 크기 수정 * feat: CommentList 컴포넌트 추가 * feat: 디자인 수정 * feat: api 변경 적용 * refactor: CommentInput -> CommentForm으로 네이밍 수정 * feat: data fetching 로직을 CommentList내부로 이동 * feat: 댓글 무한 스크롤로 변경 * fix: 토스트 컴포넌트가 가운데 정렬되지 않는 문제 해결 * feat: 전송 아이콘 추가 * feat: 댓글 컴포넌트를 fixed로 변경 * feat: 댓글 컴포넌트 사이 공백 추가 * feat: Response 객체에 totalElements 값 추가 * feat: pageParam의 기본값 추가 * feat: index.ts에서 export문 추가 --- frontend/.storybook/preview-body.html | 5 + .../src/components/Common/Input/Input.tsx | 11 +- .../src/components/Common/Svg/SvgIcon.tsx | 1 + .../src/components/Common/Svg/SvgSprite.tsx | 3 + .../src/components/Common/Toast/Toast.tsx | 2 +- .../CommentForm/CommentForm.stories.tsx | 13 +++ .../Recipe/CommentForm/CommentForm.tsx | 101 ++++++++++++++++++ .../CommentItem/CommentItem.stories.tsx | 18 ++++ .../Recipe/CommentItem/CommentItem.tsx | 50 +++++++++ .../CommentList/CommentList.stories.tsx | 13 +++ .../Recipe/CommentList/CommentList.tsx | 35 ++++++ frontend/src/components/Recipe/index.ts | 3 + frontend/src/contexts/ToastContext.tsx | 2 +- frontend/src/hooks/queries/recipe/index.ts | 2 + .../recipe/useInfiniteRecipeCommentQuery.ts | 36 +++++++ .../recipe/useRecipeCommentMutation.ts | 24 +++++ frontend/src/mocks/data/comments.json | 32 ++++++ frontend/src/mocks/handlers/recipeHandlers.ts | 9 ++ frontend/src/pages/RecipeDetailPage.tsx | 31 ++++-- frontend/src/router/index.tsx | 18 ++-- frontend/src/types/recipe.ts | 7 ++ frontend/src/types/response.ts | 8 +- 22 files changed, 399 insertions(+), 25 deletions(-) create mode 100644 frontend/src/components/Recipe/CommentForm/CommentForm.stories.tsx create mode 100644 frontend/src/components/Recipe/CommentForm/CommentForm.tsx create mode 100644 frontend/src/components/Recipe/CommentItem/CommentItem.stories.tsx create mode 100644 frontend/src/components/Recipe/CommentItem/CommentItem.tsx create mode 100644 frontend/src/components/Recipe/CommentList/CommentList.stories.tsx create mode 100644 frontend/src/components/Recipe/CommentList/CommentList.tsx create mode 100644 frontend/src/hooks/queries/recipe/useInfiniteRecipeCommentQuery.ts create mode 100644 frontend/src/hooks/queries/recipe/useRecipeCommentMutation.ts create mode 100644 frontend/src/mocks/data/comments.json diff --git a/frontend/.storybook/preview-body.html b/frontend/.storybook/preview-body.html index c74febeca..4e44004eb 100644 --- a/frontend/.storybook/preview-body.html +++ b/frontend/.storybook/preview-body.html @@ -100,6 +100,11 @@ d="M3 4V1h2v3h3v2H5v3H3V6H0V4m6 6V7h3V4h7l1.8 2H21c1.1 0 2 .9 2 2v12c0 1.1-.9 2-2 2H5c-1.1 0-2-.9-2-2V10m10 9c4.45 0 6.69-5.38 3.54-8.54C13.39 7.31 8 9.55 8 14c0 2.76 2.24 5 5 5m-3.2-5c0 2.85 3.45 4.28 5.46 2.26c2.02-2.01.59-5.46-2.26-5.46A3.21 3.21 0 0 0 9.8 14Z" /> + + +
    diff --git a/frontend/src/components/Common/Input/Input.tsx b/frontend/src/components/Common/Input/Input.tsx index 59f86743e..c3b3b40f1 100644 --- a/frontend/src/components/Common/Input/Input.tsx +++ b/frontend/src/components/Common/Input/Input.tsx @@ -8,6 +8,10 @@ interface InputProps extends ComponentPropsWithRef<'input'> { * Input 컴포넌트의 너비값입니다. */ customWidth?: string; + /** + * Input 컴포넌트의 최소 너비값입니다. + */ + minWidth?: string; /** * Input value에 에러가 있는지 여부입니다. */ @@ -24,12 +28,12 @@ interface InputProps extends ComponentPropsWithRef<'input'> { const Input = forwardRef( ( - { customWidth = '300px', isError = false, rightIcon, errorMessage, ...props }: InputProps, + { customWidth = '300px', minWidth, isError = false, rightIcon, errorMessage, ...props }: InputProps, ref: ForwardedRef ) => { return ( <> - + {rightIcon && {rightIcon}} @@ -43,11 +47,12 @@ Input.displayName = 'Input'; export default Input; -type InputContainerStyleProps = Pick; +type InputContainerStyleProps = Pick; type CustomInputStyleProps = Pick; const InputContainer = styled.div` position: relative; + min-width: ${({ minWidth }) => minWidth ?? 0}; max-width: ${({ customWidth }) => customWidth}; text-align: center; `; diff --git a/frontend/src/components/Common/Svg/SvgIcon.tsx b/frontend/src/components/Common/Svg/SvgIcon.tsx index 7287cbd5e..d8d11d326 100644 --- a/frontend/src/components/Common/Svg/SvgIcon.tsx +++ b/frontend/src/components/Common/Svg/SvgIcon.tsx @@ -21,6 +21,7 @@ export const SVG_ICON_VARIANTS = [ 'plus', 'pencil', 'camera', + 'plane', ] as const; export type SvgIconVariant = (typeof SVG_ICON_VARIANTS)[number]; diff --git a/frontend/src/components/Common/Svg/SvgSprite.tsx b/frontend/src/components/Common/Svg/SvgSprite.tsx index f4d7a1937..b4811ca73 100644 --- a/frontend/src/components/Common/Svg/SvgSprite.tsx +++ b/frontend/src/components/Common/Svg/SvgSprite.tsx @@ -74,6 +74,9 @@ const SvgSprite = () => { + + + ); }; diff --git a/frontend/src/components/Common/Toast/Toast.tsx b/frontend/src/components/Common/Toast/Toast.tsx index 28571c6af..c9d9dc46f 100644 --- a/frontend/src/components/Common/Toast/Toast.tsx +++ b/frontend/src/components/Common/Toast/Toast.tsx @@ -27,7 +27,7 @@ type ToastStyleProps = Pick & { isAnimating?: boolean }; const ToastWrapper = styled.div` position: relative; - width: 100%; + width: calc(100% - 20px); height: 55px; max-width: 560px; border-radius: 10px; diff --git a/frontend/src/components/Recipe/CommentForm/CommentForm.stories.tsx b/frontend/src/components/Recipe/CommentForm/CommentForm.stories.tsx new file mode 100644 index 000000000..e65b87225 --- /dev/null +++ b/frontend/src/components/Recipe/CommentForm/CommentForm.stories.tsx @@ -0,0 +1,13 @@ +import type { Meta, StoryObj } from '@storybook/react'; + +import CommentForm from './CommentForm'; + +const meta: Meta = { + title: 'recipe/CommentForm', + component: CommentForm, +}; + +export default meta; +type Story = StoryObj; + +export const Default: Story = {}; diff --git a/frontend/src/components/Recipe/CommentForm/CommentForm.tsx b/frontend/src/components/Recipe/CommentForm/CommentForm.tsx new file mode 100644 index 000000000..33c03d1e2 --- /dev/null +++ b/frontend/src/components/Recipe/CommentForm/CommentForm.tsx @@ -0,0 +1,101 @@ +import { Button, Spacing, Text, Textarea, useTheme } from '@fun-eat/design-system'; +import type { ChangeEventHandler, FormEventHandler } from 'react'; +import { useState } from 'react'; +import styled from 'styled-components'; + +import { SvgIcon } from '@/components/Common'; +import { useToastActionContext } from '@/hooks/context'; +import { useRecipeCommentMutation } from '@/hooks/queries/recipe'; + +interface CommentFormProps { + recipeId: number; +} + +const MAX_COMMENT_LENGTH = 200; + +const CommentForm = ({ recipeId }: CommentFormProps) => { + const [commentValue, setCommentValue] = useState(''); + const { mutate } = useRecipeCommentMutation(recipeId); + + const theme = useTheme(); + const { toast } = useToastActionContext(); + + const handleCommentInput: ChangeEventHandler = (e) => { + setCommentValue(e.target.value); + }; + + const handleSubmitComment: FormEventHandler = (e) => { + e.preventDefault(); + + mutate( + { comment: commentValue }, + { + onSuccess: () => { + setCommentValue(''); + toast.success('댓글이 등록되었습니다.'); + }, + onError: (error) => { + if (error instanceof Error) { + toast.error(error.message); + return; + } + + toast.error('댓글을 등록하는데 오류가 발생했습니다.'); + }, + } + ); + }; + + return ( + +
    + + + + + + + + {commentValue.length}자 / {MAX_COMMENT_LENGTH}자 + +
    + ); +}; + +export default CommentForm; + +const CommentFormContainer = styled.div` + position: fixed; + bottom: 0; + width: calc(100% - 40px); + max-width: 540px; + padding: 16px 0; + background: ${({ theme }) => theme.backgroundColors.default}; +`; + +const Form = styled.form` + display: flex; + gap: 4px; + justify-content: space-around; + align-items: center; +`; + +const CommentTextarea = styled(Textarea)` + height: 50px; + padding: 8px; + font-size: 1.4rem; +`; + +const SubmitButton = styled(Button)` + cursor: ${({ disabled }) => (disabled ? 'not-allowed' : 'pointer')}; +`; diff --git a/frontend/src/components/Recipe/CommentItem/CommentItem.stories.tsx b/frontend/src/components/Recipe/CommentItem/CommentItem.stories.tsx new file mode 100644 index 000000000..70bf1f9a6 --- /dev/null +++ b/frontend/src/components/Recipe/CommentItem/CommentItem.stories.tsx @@ -0,0 +1,18 @@ +import type { Meta, StoryObj } from '@storybook/react'; + +import CommentItem from './CommentItem'; + +import comments from '@/mocks/data/comments.json'; + +const meta: Meta = { + title: 'recipe/CommentItem', + component: CommentItem, + args: { + recipeComment: comments.comments[0], + }, +}; + +export default meta; +type Story = StoryObj; + +export const Default: Story = {}; diff --git a/frontend/src/components/Recipe/CommentItem/CommentItem.tsx b/frontend/src/components/Recipe/CommentItem/CommentItem.tsx new file mode 100644 index 000000000..847194b75 --- /dev/null +++ b/frontend/src/components/Recipe/CommentItem/CommentItem.tsx @@ -0,0 +1,50 @@ +import { Divider, Spacing, Text, useTheme } from '@fun-eat/design-system'; +import styled from 'styled-components'; + +import type { Comment } from '@/types/recipe'; +import { getFormattedDate } from '@/utils/date'; + +interface CommentItemProps { + recipeComment: Comment; +} + +const CommentItem = ({ recipeComment }: CommentItemProps) => { + const theme = useTheme(); + const { author, comment, createdAt } = recipeComment; + + return ( + <> + + +
    + + {author.nickname} 님 + + + {getFormattedDate(createdAt)} + +
    +
    + {comment} + + + + ); +}; + +export default CommentItem; + +const AuthorWrapper = styled.div` + display: flex; + gap: 12px; + align-items: center; +`; + +const AuthorProfileImage = styled.img` + border: 1px solid ${({ theme }) => theme.colors.primary}; + border-radius: 50%; +`; + +const CommentContent = styled(Text)` + margin: 16px 0; +`; diff --git a/frontend/src/components/Recipe/CommentList/CommentList.stories.tsx b/frontend/src/components/Recipe/CommentList/CommentList.stories.tsx new file mode 100644 index 000000000..ebad218de --- /dev/null +++ b/frontend/src/components/Recipe/CommentList/CommentList.stories.tsx @@ -0,0 +1,13 @@ +import type { Meta, StoryObj } from '@storybook/react'; + +import CommentList from './CommentList'; + +const meta: Meta = { + title: 'recipe/CommentList', + component: CommentList, +}; + +export default meta; +type Story = StoryObj; + +export const Default: Story = {}; diff --git a/frontend/src/components/Recipe/CommentList/CommentList.tsx b/frontend/src/components/Recipe/CommentList/CommentList.tsx new file mode 100644 index 000000000..5a34feb95 --- /dev/null +++ b/frontend/src/components/Recipe/CommentList/CommentList.tsx @@ -0,0 +1,35 @@ +import { Heading, Spacing } from '@fun-eat/design-system'; +import { useRef } from 'react'; + +import CommentItem from '../CommentItem/CommentItem'; + +import { useIntersectionObserver } from '@/hooks/common'; +import { useInfiniteRecipeCommentQuery } from '@/hooks/queries/recipe'; + +interface CommentListProps { + recipeId: number; +} + +const CommentList = ({ recipeId }: CommentListProps) => { + const scrollRef = useRef(null); + + const { fetchNextPage, hasNextPage, data } = useInfiniteRecipeCommentQuery(Number(recipeId)); + useIntersectionObserver(fetchNextPage, scrollRef, hasNextPage); + + const comments = data.pages.flatMap((page) => page.comments); + + return ( +
    + + 댓글 ({comments.length}개) + + + {comments.map((comment) => ( + + ))} +
    +
    + ); +}; + +export default CommentList; diff --git a/frontend/src/components/Recipe/index.ts b/frontend/src/components/Recipe/index.ts index f0ecdf5f6..b79761203 100644 --- a/frontend/src/components/Recipe/index.ts +++ b/frontend/src/components/Recipe/index.ts @@ -5,3 +5,6 @@ export { default as RecipeItem } from './RecipeItem/RecipeItem'; export { default as RecipeList } from './RecipeList/RecipeList'; export { default as RecipeRegisterForm } from './RecipeRegisterForm/RecipeRegisterForm'; export { default as RecipeFavorite } from './RecipeFavorite/RecipeFavorite'; +export { default as CommentItem } from './CommentItem/CommentItem'; +export { default as CommentForm } from './CommentForm/CommentForm'; +export { default as CommentList } from './CommentList/CommentList'; diff --git a/frontend/src/contexts/ToastContext.tsx b/frontend/src/contexts/ToastContext.tsx index 0bfdb0837..f14148646 100644 --- a/frontend/src/contexts/ToastContext.tsx +++ b/frontend/src/contexts/ToastContext.tsx @@ -75,6 +75,6 @@ const ToastContainer = styled.div` display: flex; flex-direction: column; align-items: center; - width: calc(100% - 20px); + width: 100%; transform: translate(0, -10px); `; diff --git a/frontend/src/hooks/queries/recipe/index.ts b/frontend/src/hooks/queries/recipe/index.ts index ef871dadd..0cd5db9f6 100644 --- a/frontend/src/hooks/queries/recipe/index.ts +++ b/frontend/src/hooks/queries/recipe/index.ts @@ -2,3 +2,5 @@ export { default as useRecipeDetailQuery } from './useRecipeDetailQuery'; export { default as useRecipeRegisterFormMutation } from './useRecipeRegisterFormMutation'; export { default as useRecipeFavoriteMutation } from './useRecipeFavoriteMutation'; export { default as useInfiniteRecipesQuery } from './useInfiniteRecipesQuery'; +export { default as useInfiniteRecipeCommentQuery } from './useInfiniteRecipeCommentQuery'; +export { default as useRecipeCommentMutation } from './useRecipeCommentMutation'; diff --git a/frontend/src/hooks/queries/recipe/useInfiniteRecipeCommentQuery.ts b/frontend/src/hooks/queries/recipe/useInfiniteRecipeCommentQuery.ts new file mode 100644 index 000000000..460b11e92 --- /dev/null +++ b/frontend/src/hooks/queries/recipe/useInfiniteRecipeCommentQuery.ts @@ -0,0 +1,36 @@ +import { useSuspendedInfiniteQuery } from '../useSuspendedInfiniteQuery'; + +import { recipeApi } from '@/apis'; +import type { CommentResponse } from '@/types/response'; + +interface PageParam { + lastId: number; + totalElements: number | null; +} + +const fetchRecipeComments = async (pageParam: PageParam, recipeId: number) => { + const { lastId, totalElements } = pageParam; + const response = await recipeApi.get({ + params: `/${recipeId}/comments`, + queries: `?lastId=${lastId}&totalElements=${totalElements}`, + }); + const data: CommentResponse = await response.json(); + return data; +}; + +const useInfiniteRecipeCommentQuery = (recipeId: number) => { + return useSuspendedInfiniteQuery( + ['recipeComment', recipeId], + ({ pageParam = { lastId: 0, totalElements: null } }) => fetchRecipeComments(pageParam, recipeId), + { + getNextPageParam: (prevResponse: CommentResponse) => { + const lastId = prevResponse.comments[prevResponse.comments.length - 1].id; + const totalElements = prevResponse.totalElements; + const lastCursor = { lastId: lastId, totalElements: totalElements }; + return prevResponse.hasNext ? lastCursor : undefined; + }, + } + ); +}; + +export default useInfiniteRecipeCommentQuery; diff --git a/frontend/src/hooks/queries/recipe/useRecipeCommentMutation.ts b/frontend/src/hooks/queries/recipe/useRecipeCommentMutation.ts new file mode 100644 index 000000000..fc599b15e --- /dev/null +++ b/frontend/src/hooks/queries/recipe/useRecipeCommentMutation.ts @@ -0,0 +1,24 @@ +import { useMutation, useQueryClient } from '@tanstack/react-query'; + +import { recipeApi } from '@/apis'; + +interface RecipeCommentRequestBody { + comment: string; +} + +const headers = { 'Content-Type': 'application/json' }; + +const postRecipeComment = (recipeId: number, body: RecipeCommentRequestBody) => { + return recipeApi.post({ params: `/${recipeId}/comments`, credentials: true }, headers, body); +}; + +const useRecipeCommentMutation = (recipeId: number) => { + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: (body: RecipeCommentRequestBody) => postRecipeComment(recipeId, body), + onSuccess: () => queryClient.invalidateQueries({ queryKey: ['recipeComment', recipeId] }), + }); +}; + +export default useRecipeCommentMutation; diff --git a/frontend/src/mocks/data/comments.json b/frontend/src/mocks/data/comments.json new file mode 100644 index 000000000..acf2f9b08 --- /dev/null +++ b/frontend/src/mocks/data/comments.json @@ -0,0 +1,32 @@ +{ + "hasNext": false, + "comments": [ + { + "author": { + "nickname": "펀잇", + "profileImage": "https://github.com/woowacourse-teams/2023-fun-eat/assets/78616893/1f0fd418-131c-4cf8-b540-112d762b7c34" + }, + "comment": "저도 먹어봤는데 맛있었어요. 저도 먹어봤는데 맛있었어요. 저도 먹어봤는데 맛있었어요. 저도 먹어봤는데 맛있었어요. 저도 먹어봤는데 맛있었어요. 저도 먹어봤는데 맛있었어요. ", + "createdAt": "2023-08-09T10:10:10", + "id": 1 + }, + { + "author": { + "nickname": "펀잇", + "profileImage": "https://github.com/woowacourse-teams/2023-fun-eat/assets/78616893/1f0fd418-131c-4cf8-b540-112d762b7c34" + }, + "comment": "string", + "createdAt": "2023-08-09T10:10:10", + "id": 1 + }, + { + "author": { + "nickname": "펀잇", + "profileImage": "https://github.com/woowacourse-teams/2023-fun-eat/assets/78616893/1f0fd418-131c-4cf8-b540-112d762b7c34" + }, + "comment": "string", + "createdAt": "2023-08-09T10:10:10", + "id": 1 + } + ] +} diff --git a/frontend/src/mocks/handlers/recipeHandlers.ts b/frontend/src/mocks/handlers/recipeHandlers.ts index 0a37b22f0..b213e5926 100644 --- a/frontend/src/mocks/handlers/recipeHandlers.ts +++ b/frontend/src/mocks/handlers/recipeHandlers.ts @@ -1,6 +1,7 @@ import { rest } from 'msw'; import { isRecipeSortOption, isSortOrder } from './utils'; +import comments from '../data/comments.json'; import recipeDetail from '../data/recipeDetail.json'; import mockRecipes from '../data/recipes.json'; @@ -88,4 +89,12 @@ export const recipeHandlers = [ ctx.json({ ...sortedRecipes, recipes: sortedRecipes.recipes.slice(page * 5, (page + 1) * 5) }) ); }), + + rest.get('/api/recipes/:recipeId/comments', (req, res, ctx) => { + return res(ctx.status(200), ctx.json(comments)); + }), + + rest.post('/api/recipes/:recipeId/comments', (req, res, ctx) => { + return res(ctx.status(201)); + }), ]; diff --git a/frontend/src/pages/RecipeDetailPage.tsx b/frontend/src/pages/RecipeDetailPage.tsx index 61b1ab585..ed68acb7b 100644 --- a/frontend/src/pages/RecipeDetailPage.tsx +++ b/frontend/src/pages/RecipeDetailPage.tsx @@ -1,10 +1,12 @@ -import { Heading, Spacing, Text, theme } from '@fun-eat/design-system'; +import { Divider, Heading, Spacing, Text, theme } from '@fun-eat/design-system'; +import { useQueryErrorResetBoundary } from '@tanstack/react-query'; +import { Suspense } from 'react'; import { useParams } from 'react-router-dom'; import styled from 'styled-components'; import RecipePreviewImage from '@/assets/plate.svg'; -import { SectionTitle } from '@/components/Common'; -import { RecipeFavorite } from '@/components/Recipe'; +import { ErrorBoundary, ErrorComponent, Loading, SectionTitle } from '@/components/Common'; +import { CommentForm, CommentList, RecipeFavorite } from '@/components/Recipe'; import { useRecipeDetailQuery } from '@/hooks/queries/recipe'; import { getFormattedDate } from '@/utils/date'; @@ -12,10 +14,13 @@ export const RecipeDetailPage = () => { const { recipeId } = useParams(); const { data: recipeDetail } = useRecipeDetailQuery(Number(recipeId)); + + const { reset } = useQueryErrorResetBoundary(); + const { id, images, title, content, author, products, totalPrice, favoriteCount, favorite, createdAt } = recipeDetail; return ( - + <> {images.length > 0 ? ( @@ -65,15 +70,21 @@ export const RecipeDetailPage = () => { {content} - - + + + + + }> + + + + + + + ); }; -const RecipeDetailPageContainer = styled.div` - padding: 20px 20px 0; -`; - const RecipeImageContainer = styled.ul` display: flex; flex-direction: column; diff --git a/frontend/src/router/index.tsx b/frontend/src/router/index.tsx index 0103d0fc7..1f0ee78a9 100644 --- a/frontend/src/router/index.tsx +++ b/frontend/src/router/index.tsx @@ -17,15 +17,6 @@ const router = createBrowserRouter([ ), errorElement: , children: [ - { - path: `${PATH.RECIPE}/:recipeId`, - async lazy() { - const { RecipeDetailPage } = await import( - /* webpackChunkName: "RecipeDetailPage" */ '@/pages/RecipeDetailPage' - ); - return { Component: RecipeDetailPage }; - }, - }, { path: PATH.MEMBER, async lazy() { @@ -119,6 +110,15 @@ const router = createBrowserRouter([ return { Component: ProductDetailPage }; }, }, + { + path: `${PATH.RECIPE}/:recipeId`, + async lazy() { + const { RecipeDetailPage } = await import( + /* webpackChunkName: "RecipeDetailPage" */ '@/pages/RecipeDetailPage' + ); + return { Component: RecipeDetailPage }; + }, + }, ], }, { diff --git a/frontend/src/types/recipe.ts b/frontend/src/types/recipe.ts index 3fec2ba91..da336d7b5 100644 --- a/frontend/src/types/recipe.ts +++ b/frontend/src/types/recipe.ts @@ -39,3 +39,10 @@ export interface RecipeFavoriteRequestBody { type RecipeProductWithPrice = Pick; export type RecipeProduct = Omit; + +export interface Comment { + id: number; + author: Member; + comment: string; + createdAt: string; +} diff --git a/frontend/src/types/response.ts b/frontend/src/types/response.ts index 7ab144bc0..fa17b469a 100644 --- a/frontend/src/types/response.ts +++ b/frontend/src/types/response.ts @@ -1,6 +1,6 @@ import type { Product } from './product'; import type { ProductRanking, RecipeRanking, ReviewRanking } from './ranking'; -import type { MemberRecipe, Recipe } from './recipe'; +import type { Comment, MemberRecipe, Recipe } from './recipe'; import type { Review } from './review'; import type { ProductSearchResult, ProductSearchAutocomplete } from './search'; @@ -63,3 +63,9 @@ export interface MemberRecipeResponse { page: Page; recipes: MemberRecipe[]; } + +export interface CommentResponse { + hasNext: boolean; + totalElements: number | null; + comments: Comment[]; +} From 679ee07de7b1d3c7faaaf3515c85f752b70658db Mon Sep 17 00:00:00 2001 From: Leejin Yang Date: Fri, 13 Oct 2023 16:30:16 +0900 Subject: [PATCH 13/55] =?UTF-8?q?[FE]=20feat:=20=EB=9D=BC=EC=9A=B0?= =?UTF-8?q?=ED=8C=85=20=EA=B4=80=EB=A0=A8=20=EC=B2=98=EB=A6=AC=20(#752)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat: 최근 상품 상세 페이지 로컬스토리지에 저장 * fix: 로그아웃 후 마이페이지 방문 시 에러 처리 * refactor: 로컬스토리지 로직 유틸로 이동 --- frontend/src/constants/index.ts | 8 +++++- frontend/src/hooks/common/index.ts | 1 - frontend/src/pages/AuthPage.tsx | 16 ++++++++---- frontend/src/pages/ProductDetailPage.tsx | 31 +++++++++++++++++------- frontend/src/router/index.tsx | 4 +-- frontend/src/utils/localStorage.ts | 26 ++++++++++++++++++++ 6 files changed, 68 insertions(+), 18 deletions(-) create mode 100644 frontend/src/utils/localStorage.ts diff --git a/frontend/src/constants/index.ts b/frontend/src/constants/index.ts index 81b964c6c..22c296123 100644 --- a/frontend/src/constants/index.ts +++ b/frontend/src/constants/index.ts @@ -63,7 +63,13 @@ export const CATEGORY_TYPE = { export const IMAGE_MAX_SIZE = 5 * 1024 * 1024; -export const ENVIRONMENT = window.location.href.includes('dev') ? 'dev' : 'prod'; +export const ENVIRONMENT = window.location.href.includes('dev') + ? 'dev' + : process.env.NODE_ENV === 'production' + ? 'prod' + : 'local'; export const IMAGE_URL = ENVIRONMENT === 'dev' ? process.env.S3_DEV_CLOUDFRONT_PATH : process.env.S3_PROD_CLOUDFRONT_PATH; + +export const PRODUCT_PATH_LOCAL_STORAGE_KEY = `funeat-last-product-path-${ENVIRONMENT}`; diff --git a/frontend/src/hooks/common/index.ts b/frontend/src/hooks/common/index.ts index 61c985f67..199ae608a 100644 --- a/frontend/src/hooks/common/index.ts +++ b/frontend/src/hooks/common/index.ts @@ -12,4 +12,3 @@ export { default as useTabMenu } from './useTabMenu'; export { default as useScrollRestoration } from './useScrollRestoration'; export { default as useToast } from './useToast'; export { default as useGA } from './useGA'; - diff --git a/frontend/src/pages/AuthPage.tsx b/frontend/src/pages/AuthPage.tsx index a840cb978..e5f60c954 100644 --- a/frontend/src/pages/AuthPage.tsx +++ b/frontend/src/pages/AuthPage.tsx @@ -2,8 +2,10 @@ import { useEffect, useState } from 'react'; import { Navigate, useNavigate, useParams, useSearchParams } from 'react-router-dom'; import { loginApi } from '@/apis'; +import { PRODUCT_PATH_LOCAL_STORAGE_KEY } from '@/constants'; import { PATH } from '@/constants/path'; import { useMemberQuery } from '@/hooks/queries/members'; +import { getLocalStorage, removeLocalStorage } from '@/utils/localstorage'; export const AuthPage = () => { const { authProvider } = useParams(); @@ -14,10 +16,6 @@ export const AuthPage = () => { const [location, setLocation] = useState(''); const navigate = useNavigate(); - if (member) { - return ; - } - const getSessionId = async () => { const response = await loginApi.get({ params: `/oauth2/code/${authProvider}`, @@ -51,9 +49,17 @@ export const AuthPage = () => { return; } + const productPath = getLocalStorage(PRODUCT_PATH_LOCAL_STORAGE_KEY); + const redirectLocation = productPath ? productPath : location; + + navigate(redirectLocation, { replace: true }); + removeLocalStorage(PRODUCT_PATH_LOCAL_STORAGE_KEY); refetchMember(); - navigate(location, { replace: true }); }, [location]); + if (member) { + return ; + } + return <>; }; diff --git a/frontend/src/pages/ProductDetailPage.tsx b/frontend/src/pages/ProductDetailPage.tsx index f41985856..82c8ce4e0 100644 --- a/frontend/src/pages/ProductDetailPage.tsx +++ b/frontend/src/pages/ProductDetailPage.tsx @@ -1,7 +1,7 @@ -import { BottomSheet, Spacing, useBottomSheet, Text, Link } from '@fun-eat/design-system'; +import { BottomSheet, Spacing, useBottomSheet, Text, Button } from '@fun-eat/design-system'; import { useQueryErrorResetBoundary } from '@tanstack/react-query'; import { useState, useRef, Suspense } from 'react'; -import { useParams, Link as RouterLink } from 'react-router-dom'; +import { useParams, useLocation, useNavigate } from 'react-router-dom'; import styled from 'styled-components'; import { @@ -17,12 +17,13 @@ import { } from '@/components/Common'; import { ProductDetailItem, ProductRecipeList } from '@/components/Product'; import { BestReviewItem, ReviewList, ReviewRegisterForm } from '@/components/Review'; -import { RECIPE_SORT_OPTIONS, REVIEW_SORT_OPTIONS } from '@/constants'; +import { PRODUCT_PATH_LOCAL_STORAGE_KEY, RECIPE_SORT_OPTIONS, REVIEW_SORT_OPTIONS } from '@/constants'; import { PATH } from '@/constants/path'; import ReviewFormProvider from '@/contexts/ReviewFormContext'; import { useGA, useSortOption, useTabMenu } from '@/hooks/common'; import { useMemberQuery } from '@/hooks/queries/members'; import { useProductDetailQuery } from '@/hooks/queries/product'; +import { setLocalStorage } from '@/utils/localstorage'; const LOGIN_ERROR_MESSAGE_REVIEW = '로그인 후 상품 리뷰를 볼 수 있어요.\n펀잇에 가입하고 편의점 상품 리뷰를 확인해보세요 😊'; @@ -31,6 +32,9 @@ const LOGIN_ERROR_MESSAGE_RECIPE = export const ProductDetailPage = () => { const { category, productId } = useParams(); + const { pathname } = useLocation(); + const navigate = useNavigate(); + const { data: member } = useMemberQuery(); const { data: productDetail } = useProductDetailQuery(Number(productId)); @@ -46,7 +50,7 @@ export const ProductDetailPage = () => { const productDetailPageRef = useRef(null); - if (!category) { + if (!category || !productId) { return null; } @@ -73,6 +77,11 @@ export const ProductDetailPage = () => { selectSortOption(currentSortOption); }; + const handleLoginButtonClick = () => { + setLocalStorage(PRODUCT_PATH_LOCAL_STORAGE_KEY, pathname); + navigate(PATH.LOGIN); + }; + return ( @@ -107,9 +116,15 @@ export const ProductDetailPage = () => { {isReviewTab ? LOGIN_ERROR_MESSAGE_REVIEW : LOGIN_ERROR_MESSAGE_RECIPE} - + 로그인하러 가기 - + )} @@ -171,10 +186,8 @@ const ErrorDescription = styled(Text)` white-space: pre-wrap; `; -const LoginLink = styled(Link)` - padding: 16px 24px; +const LoginButton = styled(Button)` border: 1px solid ${({ theme }) => theme.colors.gray4}; - border-radius: 8px; `; const ReviewRegisterButtonWrapper = styled.div` diff --git a/frontend/src/router/index.tsx b/frontend/src/router/index.tsx index 1f0ee78a9..739785556 100644 --- a/frontend/src/router/index.tsx +++ b/frontend/src/router/index.tsx @@ -1,4 +1,4 @@ -import { createBrowserRouter } from 'react-router-dom'; +import { Navigate, createBrowserRouter } from 'react-router-dom'; import App from './App'; @@ -15,7 +15,7 @@ const router = createBrowserRouter([ ), - errorElement: , + errorElement: , children: [ { path: PATH.MEMBER, diff --git a/frontend/src/utils/localStorage.ts b/frontend/src/utils/localStorage.ts new file mode 100644 index 000000000..91ce0c762 --- /dev/null +++ b/frontend/src/utils/localStorage.ts @@ -0,0 +1,26 @@ +export const getLocalStorage = (key: string) => { + const item = localStorage.getItem(key); + + if (item) { + try { + return JSON.parse(item); + } catch (error) { + return item; + } + } + + return null; +}; + +export const setLocalStorage = (key: string, newValue: unknown) => { + if (typeof newValue === 'string') { + localStorage.setItem(key, newValue); + return; + } + + localStorage.setItem(key, JSON.stringify(newValue)); +}; + +export const removeLocalStorage = (key: string) => { + localStorage.removeItem(key); +}; From 43cf12a3130a921ebf6b917d2d275c461ca369be Mon Sep 17 00:00:00 2001 From: Leejin Yang Date: Fri, 13 Oct 2023 16:39:43 +0900 Subject: [PATCH 14/55] =?UTF-8?q?[FE]=20fix:=20=EB=A1=9C=EC=BB=AC=EC=8A=A4?= =?UTF-8?q?=ED=86=A0=EB=A6=AC=EC=A7=80=20=EC=9C=A0=ED=8B=B8=20import=20?= =?UTF-8?q?=EB=8C=80=EC=86=8C=EB=AC=B8=EC=9E=90=20=EC=88=98=EC=A0=95=20(#7?= =?UTF-8?q?60)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- frontend/src/pages/AuthPage.tsx | 2 +- frontend/src/pages/ProductDetailPage.tsx | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/frontend/src/pages/AuthPage.tsx b/frontend/src/pages/AuthPage.tsx index e5f60c954..579bca327 100644 --- a/frontend/src/pages/AuthPage.tsx +++ b/frontend/src/pages/AuthPage.tsx @@ -5,7 +5,7 @@ import { loginApi } from '@/apis'; import { PRODUCT_PATH_LOCAL_STORAGE_KEY } from '@/constants'; import { PATH } from '@/constants/path'; import { useMemberQuery } from '@/hooks/queries/members'; -import { getLocalStorage, removeLocalStorage } from '@/utils/localstorage'; +import { getLocalStorage, removeLocalStorage } from '@/utils/localStorage'; export const AuthPage = () => { const { authProvider } = useParams(); diff --git a/frontend/src/pages/ProductDetailPage.tsx b/frontend/src/pages/ProductDetailPage.tsx index 82c8ce4e0..12df226e0 100644 --- a/frontend/src/pages/ProductDetailPage.tsx +++ b/frontend/src/pages/ProductDetailPage.tsx @@ -23,7 +23,7 @@ import ReviewFormProvider from '@/contexts/ReviewFormContext'; import { useGA, useSortOption, useTabMenu } from '@/hooks/common'; import { useMemberQuery } from '@/hooks/queries/members'; import { useProductDetailQuery } from '@/hooks/queries/product'; -import { setLocalStorage } from '@/utils/localstorage'; +import { setLocalStorage } from '@/utils/localStorage'; const LOGIN_ERROR_MESSAGE_REVIEW = '로그인 후 상품 리뷰를 볼 수 있어요.\n펀잇에 가입하고 편의점 상품 리뷰를 확인해보세요 😊'; From 215003f9a28f33ce48c3c0f6ff9342a0a59578e9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?s=E1=B4=8F=CA=9F=CA=99=C9=AA=20=E2=98=94=EF=B8=8F?= Date: Mon, 16 Oct 2023 12:46:55 +0900 Subject: [PATCH 15/55] =?UTF-8?q?[FE]=20feat:=20=EB=A6=AC=EB=B7=B0=20?= =?UTF-8?q?=EC=83=81=EC=84=B8=20=ED=8E=98=EC=9D=B4=EC=A7=80=20=EA=B5=AC?= =?UTF-8?q?=ED=98=84=20(#762)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * refactor: 마이페이지 리뷰 디자인 수정 * refactor: border bottom 무조건 있도록 수정 * refactor: bookmark 로직 삭제 * refactor: 로딩 rotate 방향 수정 * feat: link 아이콘 추가 * feat: 리뷰 상세 페이지 구현 * refactor: categotyType string을 CategoryVaraint type으로 변경 * chore: yarn 재설치 * refactor: 필요없는 css 값 삭제 * refactor: params review id 받도록 수정 * refactor: query key 추가 --- frontend/.storybook/preview-body.html | 9 ++ frontend/package.json | 3 +- frontend/src/apis/index.ts | 1 + .../src/components/Common/Loading/Loading.tsx | 2 +- .../SectionTitle/SectionTitle.stories.tsx | 2 - .../Common/SectionTitle/SectionTitle.tsx | 31 ++-- .../src/components/Common/Svg/SvgIcon.tsx | 1 + .../src/components/Common/Svg/SvgSprite.tsx | 6 + .../MemberReviewList/MemberReviewList.tsx | 8 +- .../ReviewRankingItem/ReviewRankingItem.tsx | 12 +- .../ReviewRankingList/ReviewRankingList.tsx | 2 +- frontend/src/constants/path.ts | 1 + frontend/src/hooks/queries/review/index.ts | 1 + .../queries/review/useReviewDetailQuery.ts | 16 +++ frontend/src/mocks/data/productDetail.json | 1 - frontend/src/mocks/data/productDetails.json | 2 - frontend/src/mocks/data/reviewDetail.json | 29 ++++ frontend/src/mocks/handlers/reviewHandlers.ts | 5 + frontend/src/pages/ProductDetailPage.tsx | 4 +- frontend/src/pages/ReviewDetailPage.tsx | 132 ++++++++++++++++++ frontend/src/router/index.tsx | 9 ++ frontend/src/types/product.ts | 1 - frontend/src/types/ranking.ts | 3 +- frontend/src/types/response.ts | 6 +- frontend/src/types/review.ts | 8 +- frontend/src/types/search.ts | 3 +- frontend/yarn.lock | 20 ++- 27 files changed, 272 insertions(+), 46 deletions(-) create mode 100644 frontend/src/hooks/queries/review/useReviewDetailQuery.ts create mode 100644 frontend/src/mocks/data/reviewDetail.json create mode 100644 frontend/src/pages/ReviewDetailPage.tsx diff --git a/frontend/.storybook/preview-body.html b/frontend/.storybook/preview-body.html index 4e44004eb..9e1c31c57 100644 --- a/frontend/.storybook/preview-body.html +++ b/frontend/.storybook/preview-body.html @@ -100,6 +100,15 @@ d="M3 4V1h2v3h3v2H5v3H3V6H0V4m6 6V7h3V4h7l1.8 2H21c1.1 0 2 .9 2 2v12c0 1.1-.9 2-2 2H5c-1.1 0-2-.9-2-2V10m10 9c4.45 0 6.69-5.38 3.54-8.54C13.39 7.31 8 9.55 8 14c0 2.76 2.24 5 5 5m-3.2-5c0 2.85 3.45 4.28 5.46 2.26c2.02-2.01.59-5.46-2.26-5.46A3.21 3.21 0 0 0 9.8 14Z" /> + + + + + ; export const Default: Story = { args: { name: '사이다', - bookmark: false, }, }; export const Bookmarked: Story = { args: { name: '사이다', - bookmark: true, }, }; diff --git a/frontend/src/components/Common/SectionTitle/SectionTitle.tsx b/frontend/src/components/Common/SectionTitle/SectionTitle.tsx index 106c60759..c9d649ddb 100644 --- a/frontend/src/components/Common/SectionTitle/SectionTitle.tsx +++ b/frontend/src/components/Common/SectionTitle/SectionTitle.tsx @@ -1,4 +1,5 @@ -import { Button, Heading, theme } from '@fun-eat/design-system'; +import { Button, Heading, Link, theme } from '@fun-eat/design-system'; +import { Link as RouterLink } from 'react-router-dom'; import styled from 'styled-components'; import { SvgIcon } from '@/components/Common'; @@ -6,10 +7,10 @@ import { useRoutePage } from '@/hooks/common'; interface SectionTitleProps { name: string; - bookmark?: boolean; + link?: string; } -const SectionTitle = ({ name, bookmark = false }: SectionTitleProps) => { +const SectionTitle = ({ name, link }: SectionTitleProps) => { const { routeBack } = useRoutePage(); return ( @@ -18,18 +19,15 @@ const SectionTitle = ({ name, bookmark = false }: SectionTitleProps) => { - - {name} - + {link ? ( + + {name} + + ) : ( + {name} + )} + {link && } - {bookmark && ( - - )} ); }; @@ -45,9 +43,12 @@ const SectionTitleContainer = styled.div` const SectionTitleWrapper = styled.div` display: flex; align-items: center; - column-gap: 16px; svg { padding-top: 2px; } `; + +const ProductName = styled(Heading)` + margin: 0 5px 0 16px; +`; diff --git a/frontend/src/components/Common/Svg/SvgIcon.tsx b/frontend/src/components/Common/Svg/SvgIcon.tsx index d8d11d326..b8be9a974 100644 --- a/frontend/src/components/Common/Svg/SvgIcon.tsx +++ b/frontend/src/components/Common/Svg/SvgIcon.tsx @@ -21,6 +21,7 @@ export const SVG_ICON_VARIANTS = [ 'plus', 'pencil', 'camera', + 'link', 'plane', ] as const; export type SvgIconVariant = (typeof SVG_ICON_VARIANTS)[number]; diff --git a/frontend/src/components/Common/Svg/SvgSprite.tsx b/frontend/src/components/Common/Svg/SvgSprite.tsx index b4811ca73..65cf49d3d 100644 --- a/frontend/src/components/Common/Svg/SvgSprite.tsx +++ b/frontend/src/components/Common/Svg/SvgSprite.tsx @@ -74,6 +74,12 @@ const SvgSprite = () => { + + + + + + diff --git a/frontend/src/components/Members/MemberReviewList/MemberReviewList.tsx b/frontend/src/components/Members/MemberReviewList/MemberReviewList.tsx index 50398fedf..fde211413 100644 --- a/frontend/src/components/Members/MemberReviewList/MemberReviewList.tsx +++ b/frontend/src/components/Members/MemberReviewList/MemberReviewList.tsx @@ -48,12 +48,8 @@ const MemberReviewList = ({ isMemberPage = false }: MemberReviewListProps) => { {reviewsToDisplay.map((reviewRanking) => (
  • - - + +
  • ))} diff --git a/frontend/src/components/Rank/ReviewRankingItem/ReviewRankingItem.tsx b/frontend/src/components/Rank/ReviewRankingItem/ReviewRankingItem.tsx index 70fd89c32..c2004c20e 100644 --- a/frontend/src/components/Rank/ReviewRankingItem/ReviewRankingItem.tsx +++ b/frontend/src/components/Rank/ReviewRankingItem/ReviewRankingItem.tsx @@ -7,13 +7,14 @@ import type { ReviewRanking } from '@/types/ranking'; interface ReviewRankingItemProps { reviewRanking: ReviewRanking; + isMemberPage?: boolean; } -const ReviewRankingItem = ({ reviewRanking }: ReviewRankingItemProps) => { +const ReviewRankingItem = ({ reviewRanking, isMemberPage = false }: ReviewRankingItemProps) => { const { productName, content, rating, favoriteCount } = reviewRanking; return ( - + {productName} @@ -41,13 +42,14 @@ const ReviewRankingItem = ({ reviewRanking }: ReviewRankingItemProps) => { export default memo(ReviewRankingItem); -const ReviewRankingItemContainer = styled.div` +const ReviewRankingItemContainer = styled.div<{ isMemberPage: boolean }>` display: flex; flex-direction: column; gap: 4px; padding: 12px; - border: 1px solid ${({ theme }) => theme.borderColors.disabled}; - border-radius: ${({ theme }) => theme.borderRadius.sm}; + border: ${({ isMemberPage, theme }) => (isMemberPage ? 'none' : `1px solid ${theme.borderColors.disabled}`)}; + border-bottom: ${({ theme }) => `1px solid ${theme.borderColors.disabled}`}; + border-radius: ${({ isMemberPage, theme }) => (isMemberPage ? 0 : theme.borderRadius.sm)}; `; const ReviewText = styled(Text)` diff --git a/frontend/src/components/Rank/ReviewRankingList/ReviewRankingList.tsx b/frontend/src/components/Rank/ReviewRankingList/ReviewRankingList.tsx index 7b6c8272c..99f10d5f3 100644 --- a/frontend/src/components/Rank/ReviewRankingList/ReviewRankingList.tsx +++ b/frontend/src/components/Rank/ReviewRankingList/ReviewRankingList.tsx @@ -28,7 +28,7 @@ const ReviewRankingList = ({ isHomePage = false }: ReviewRankingListProps) => {
  • diff --git a/frontend/src/constants/path.ts b/frontend/src/constants/path.ts index 6fd8735c8..f729a74b2 100644 --- a/frontend/src/constants/path.ts +++ b/frontend/src/constants/path.ts @@ -4,5 +4,6 @@ export const PATH = { PRODUCT_LIST: '/products', MEMBER: '/members', RECIPE: '/recipes', + REVIEW: '/reviews', LOGIN: '/login', } as const; diff --git a/frontend/src/hooks/queries/review/index.ts b/frontend/src/hooks/queries/review/index.ts index e8bb44d4a..78fd628d2 100644 --- a/frontend/src/hooks/queries/review/index.ts +++ b/frontend/src/hooks/queries/review/index.ts @@ -1,3 +1,4 @@ export { default as useReviewTagsQuery } from './useReviewTagsQuery'; export { default as useReviewFavoriteMutation } from './useReviewFavoriteMutation'; export { default as useReviewRegisterFormMutation } from './useReviewRegisterFormMutation'; +export { default as useReviewDetailQuery } from './useReviewDetailQuery'; diff --git a/frontend/src/hooks/queries/review/useReviewDetailQuery.ts b/frontend/src/hooks/queries/review/useReviewDetailQuery.ts new file mode 100644 index 000000000..a80914053 --- /dev/null +++ b/frontend/src/hooks/queries/review/useReviewDetailQuery.ts @@ -0,0 +1,16 @@ +import { useSuspendedQuery } from '../useSuspendedQuery'; + +import { reviewApi } from '@/apis'; +import type { ReviewDetailResponse } from '@/types/response'; + +const fetchReviewDetail = async (reviewId: number) => { + const response = await reviewApi.get({ params: `/${reviewId}` }); + const data: ReviewDetailResponse = await response.json(); + return data; +}; + +const useReviewDetailQuery = (reviewId: number) => { + return useSuspendedQuery(['review', reviewId, 'detail'], () => fetchReviewDetail(reviewId)); +}; + +export default useReviewDetailQuery; diff --git a/frontend/src/mocks/data/productDetail.json b/frontend/src/mocks/data/productDetail.json index 386c7ae71..2695b51b1 100644 --- a/frontend/src/mocks/data/productDetail.json +++ b/frontend/src/mocks/data/productDetail.json @@ -5,7 +5,6 @@ "image": "https://i.namu.wiki/i/9wnvUaEa1EkDqG-M0Pbwfdf19FJQQXV_-bnlU2SYaNcG05y2wbabiIrfrGES1M4xSgDjY39RwOvLNggDd3Huuw.webp", "content": "할머니가 먹을 거 같은 맛입니다.\n1960년 전쟁 때 맛 보고 싶었는데 그때는 너무 가난해서 먹을 수 없었는데, 맛있어요.", "averageRating": 4.5, - "bookmark": false, "reviewCount": 100, "tags": [ { diff --git a/frontend/src/mocks/data/productDetails.json b/frontend/src/mocks/data/productDetails.json index e3c68dab0..c386ce680 100644 --- a/frontend/src/mocks/data/productDetails.json +++ b/frontend/src/mocks/data/productDetails.json @@ -6,7 +6,6 @@ "image": "https://github.com/woowacourse-teams/2023-fun-eat/assets/78616893/1f0fd418-131c-4cf8-b540-112d762b7c34", "content": "할머니가 먹을 거 같은 맛입니다.\n1960년 전쟁 때 맛 보고 싶었는데 그때는 너무 가난해서 먹을 수 없었는데, 맛있어요.", "averageRating": 4.5, - "bookmark": false, "reviewCount": 100, "tags": [ { @@ -33,7 +32,6 @@ "image": "https://github.com/woowacourse-teams/2023-fun-eat/assets/78616893/1f0fd418-131c-4cf8-b540-112d762b7c34", "content": "할머니가 먹을 거 같은 맛입니다. 1960년 전쟁 때 맛 보고 싶었는데 그때는 너무 가난해서 먹을 수 없었는데, 맛있어요.", "averageRating": 4.0, - "bookmark": true, "reviewCount": 55, "tags": [ { diff --git a/frontend/src/mocks/data/reviewDetail.json b/frontend/src/mocks/data/reviewDetail.json new file mode 100644 index 000000000..4439f7d43 --- /dev/null +++ b/frontend/src/mocks/data/reviewDetail.json @@ -0,0 +1,29 @@ +{ + "reviews": { + "id": 1, + "userName": "펀잇", + "profileImage": "https://github.com/woowacourse-teams/2023-fun-eat/assets/78616893/1f0fd418-131c-4cf8-b540-112d762b7c34", + "image": "https://i.namu.wiki/i/9wnvUaEa1EkDqG-M0Pbwfdf19FJQQXV_-bnlU2SYaNcG05y2wbabiIrfrGES1M4xSgDjY39RwOvLNggDd3Huuw.webp", + "rating": 4.5, + "tags": [ + { + "id": 5, + "name": "단짠단짠", + "tagType": "TASTE" + }, + { + "id": 1, + "name": "망고망고", + "tagType": "TASTE" + } + ], + "content": "맛있어용~!~!", + "rebuy": true, + "favoriteCount": 1320, + "favorite": true, + "createdAt": "2023-10-13T00:00:00", + "categoryType": "food", + "productId": 1, + "productName": "칠성 사이다" + } +} diff --git a/frontend/src/mocks/handlers/reviewHandlers.ts b/frontend/src/mocks/handlers/reviewHandlers.ts index 455714f52..5c007c605 100644 --- a/frontend/src/mocks/handlers/reviewHandlers.ts +++ b/frontend/src/mocks/handlers/reviewHandlers.ts @@ -1,6 +1,7 @@ import { rest } from 'msw'; import { isReviewSortOption, isSortOrder } from './utils'; +import mockReviewDetail from '../data/reviewDetail.json'; import mockReviewRanking from '../data/reviewRankingList.json'; import mockReviews from '../data/reviews.json'; import mockReviewTags from '../data/reviewTagList.json'; @@ -73,4 +74,8 @@ export const reviewHandlers = [ rest.get('/api/tags', (_, res, ctx) => { return res(ctx.status(200), ctx.json(mockReviewTags)); }), + + rest.get('/api/reviews/:reviewId', (_, res, ctx) => { + return res(ctx.status(200), ctx.json(mockReviewDetail)); + }), ]; diff --git a/frontend/src/pages/ProductDetailPage.tsx b/frontend/src/pages/ProductDetailPage.tsx index 12df226e0..f53b61282 100644 --- a/frontend/src/pages/ProductDetailPage.tsx +++ b/frontend/src/pages/ProductDetailPage.tsx @@ -54,7 +54,7 @@ export const ProductDetailPage = () => { return null; } - const { name, bookmark, reviewCount } = productDetail; + const { name, reviewCount } = productDetail; const tabMenus = [`리뷰 ${reviewCount}`, '꿀조합']; const sortOptions = isReviewTab ? REVIEW_SORT_OPTIONS : RECIPE_SORT_OPTIONS; @@ -84,7 +84,7 @@ export const ProductDetailPage = () => { return ( - + diff --git a/frontend/src/pages/ReviewDetailPage.tsx b/frontend/src/pages/ReviewDetailPage.tsx new file mode 100644 index 000000000..7447e88e2 --- /dev/null +++ b/frontend/src/pages/ReviewDetailPage.tsx @@ -0,0 +1,132 @@ +import { Badge, Spacing, Text, useTheme } from '@fun-eat/design-system'; +import { useParams } from 'react-router-dom'; +import styled from 'styled-components'; + +import { SectionTitle, SvgIcon, TagList } from '@/components/Common'; +import { PATH } from '@/constants/path'; +import { useReviewDetailQuery } from '@/hooks/queries/review'; +import { getRelativeDate } from '@/utils/date'; + +export const ReviewDetailPage = () => { + const { reviewId } = useParams(); + const { data: reviewDetail } = useReviewDetailQuery(Number(reviewId)); + + const { + productName, + categoryType, + productId, + profileImage, + userName, + rating, + createdAt, + rebuy, + image, + tags, + content, + favoriteCount, + } = reviewDetail.reviews; + + const theme = useTheme(); + + return ( + + + + + + + +
    + {userName} + + {Array.from({ length: 5 }, (_, index) => ( + + ))} + + {getRelativeDate(createdAt)} + + +
    +
    + {rebuy && ( + + 😝 또 살래요 + + )} +
    + {image && } + + {content} + + + + {favoriteCount} + + +
    +
    + ); +}; + +const ReviewDetailPageContainer = styled.div` + padding: 20px 20px 0; +`; + +const ReviewItemContainer = styled.div` + display: flex; + flex-direction: column; + row-gap: 20px; +`; + +const ReviewerWrapper = styled.div` + display: flex; + justify-content: space-between; + align-items: center; +`; + +const ReviewerInfoWrapper = styled.div` + display: flex; + align-items: center; + column-gap: 10px; +`; + +const RebuyBadge = styled(Badge)` + font-weight: ${({ theme }) => theme.fontWeights.bold}; +`; + +const ReviewerImage = styled.img` + border: 2px solid ${({ theme }) => theme.colors.primary}; + border-radius: 50%; + object-fit: cover; +`; + +const RatingIconWrapper = styled.div` + display: flex; + align-items: center; + margin-left: -2px; + + & > span { + margin-left: 12px; + } +`; + +const ReviewImage = styled.img` + align-self: center; +`; + +const ReviewContent = styled(Text)` + white-space: pre-wrap; +`; + +const FavoriteWrapper = styled.div` + display: flex; + align-items: center; + padding: 0; + column-gap: 8px; +`; diff --git a/frontend/src/router/index.tsx b/frontend/src/router/index.tsx index 739785556..d045e8dc3 100644 --- a/frontend/src/router/index.tsx +++ b/frontend/src/router/index.tsx @@ -51,6 +51,15 @@ const router = createBrowserRouter([ return { Component: MemberRecipePage }; }, }, + { + path: `${PATH.REVIEW}/:reviewId`, + async lazy() { + const { ReviewDetailPage } = await import( + /* webpackChunkName: "ReviewDetailPage" */ '@/pages/ReviewDetailPage' + ); + return { Component: ReviewDetailPage }; + }, + }, ], }, { diff --git a/frontend/src/types/product.ts b/frontend/src/types/product.ts index 391cb103d..b3389a32f 100644 --- a/frontend/src/types/product.ts +++ b/frontend/src/types/product.ts @@ -17,7 +17,6 @@ export interface ProductDetail { content: string; averageRating: number; reviewCount: number; - bookmark: boolean; tags: Tag[]; } diff --git a/frontend/src/types/ranking.ts b/frontend/src/types/ranking.ts index 9f0707f68..01c669e0f 100644 --- a/frontend/src/types/ranking.ts +++ b/frontend/src/types/ranking.ts @@ -1,3 +1,4 @@ +import type { CategoryVariant } from './common'; import type { Member } from './member'; import type { Product } from './product'; @@ -10,7 +11,7 @@ export interface ReviewRanking { content: string; rating: number; favoriteCount: number; - categoryType: string; + categoryType: CategoryVariant; } export interface RecipeRanking { diff --git a/frontend/src/types/response.ts b/frontend/src/types/response.ts index fa17b469a..42c21a24d 100644 --- a/frontend/src/types/response.ts +++ b/frontend/src/types/response.ts @@ -1,7 +1,7 @@ import type { Product } from './product'; import type { ProductRanking, RecipeRanking, ReviewRanking } from './ranking'; import type { Comment, MemberRecipe, Recipe } from './recipe'; -import type { Review } from './review'; +import type { Review, ReviewDetail } from './review'; import type { ProductSearchResult, ProductSearchAutocomplete } from './search'; export interface Page { @@ -63,7 +63,9 @@ export interface MemberRecipeResponse { page: Page; recipes: MemberRecipe[]; } - +export interface ReviewDetailResponse { + reviews: ReviewDetail; +} export interface CommentResponse { hasNext: boolean; totalElements: number | null; diff --git a/frontend/src/types/review.ts b/frontend/src/types/review.ts index debf6f65e..ced1d2f58 100644 --- a/frontend/src/types/review.ts +++ b/frontend/src/types/review.ts @@ -1,4 +1,4 @@ -import type { Tag, TagVariants } from './common'; +import type { CategoryVariant, Tag, TagVariants } from './common'; export interface Review { id: number; @@ -14,6 +14,12 @@ export interface Review { favorite: boolean; } +export interface ReviewDetail extends Review { + categoryType: CategoryVariant; + productId: number; + productName: string; +} + export interface ReviewTag { tagType: TagVariants; tags: Tag[]; diff --git a/frontend/src/types/search.ts b/frontend/src/types/search.ts index 75d5d816d..78f293f7f 100644 --- a/frontend/src/types/search.ts +++ b/frontend/src/types/search.ts @@ -1,7 +1,8 @@ +import type { CategoryVariant } from './common'; import type { Product } from './product'; export interface ProductSearchResult extends Product { - categoryType: string; + categoryType: CategoryVariant; } export type ProductSearchAutocomplete = Pick; diff --git a/frontend/yarn.lock b/frontend/yarn.lock index da508d2b9..3cdae7d06 100644 --- a/frontend/yarn.lock +++ b/frontend/yarn.lock @@ -1375,10 +1375,10 @@ resolved "https://registry.yarnpkg.com/@floating-ui/utils/-/utils-0.1.6.tgz#22958c042e10b67463997bd6ea7115fe28cbcaf9" integrity sha512-OfX7E2oUDYxtBvsuS4e/jSn4Q9Qb6DzgeYtsAdkPZ47znpoNsMgZw0+tVijiv3uGNR6dgNlty6r9rzIzHjtd/A== -"@fun-eat/design-system@^0.3.13": - version "0.3.13" - resolved "https://registry.yarnpkg.com/@fun-eat/design-system/-/design-system-0.3.13.tgz#fbb48efff05c95883889dff280e118204de6d459" - integrity sha512-+wlTfWAJ3Z0ZmnJ2GyxX+HSQB8eB3g9PY8Blemv8nAk5ppuWbB9UKjnhebNgdtbtq+AN4HezKmbNl1Y+prxcWA== +"@fun-eat/design-system@^0.3.15": + version "0.3.15" + resolved "https://registry.yarnpkg.com/@fun-eat/design-system/-/design-system-0.3.15.tgz#61a9a01a82f84fa5627c49bd646cb72ca9e648c8" + integrity sha512-uhn5UZWfvQhNz/2sOoMwDr7Hj7SSx94bN35jifuYpm7ju0A8LHfivmu0mAbrMojuQ6XKYf0ZUME8FMMHwpw9Fg== "@humanwhocodes/config-array@^0.11.11": version "0.11.11" @@ -4497,6 +4497,13 @@ browser-assert@^1.2.1: resolved "https://registry.yarnpkg.com/browser-assert/-/browser-assert-1.2.1.tgz#9aaa5a2a8c74685c2ae05bfe46efd606f068c200" integrity sha512-nfulgvOR6S4gt9UKCeGJOuSGBPGiFT6oQ/2UBnvTY/5aQ1PnksW72fhZkM30DzoRRv2WpwZf1vHHEr3mtuXIWQ== +browser-image-compression@^2.0.2: + version "2.0.2" + resolved "https://registry.yarnpkg.com/browser-image-compression/-/browser-image-compression-2.0.2.tgz#4d5ef8882e9e471d6d923715ceb9034499d14eaa" + integrity sha512-pBLlQyUf6yB8SmmngrcOw3EoS4RpQ1BcylI3T9Yqn7+4nrQTXJD4sJDe5ODnJdrvNMaio5OicFo75rDyJD2Ucw== + dependencies: + uzip "0.20201231.0" + browserify-zlib@^0.1.4: version "0.1.4" resolved "https://registry.yarnpkg.com/browserify-zlib/-/browserify-zlib-0.1.4.tgz#bb35f8a519f600e0fa6b8485241c979d0141fb2d" @@ -11068,6 +11075,11 @@ uuid@^9.0.0: resolved "https://registry.yarnpkg.com/uuid/-/uuid-9.0.1.tgz#e188d4c8853cc722220392c424cd637f32293f30" integrity sha512-b+1eJOlsR9K8HJpow9Ok3fiWOWSIcIzXodvv0rQjVoOVNpWMpxf1wZNpt4y9h10odCNrqnYp1OBzRktckBe3sA== +uzip@0.20201231.0: + version "0.20201231.0" + resolved "https://registry.yarnpkg.com/uzip/-/uzip-0.20201231.0.tgz#9e64b065b9a8ebf26eb7583fe8e77e1d9a15ed14" + integrity sha512-OZeJfZP+R0z9D6TmBgLq2LHzSSptGMGDGigGiEe0pr8UBe/7fdflgHlHBNDASTXB5jnFuxHpNaJywSg8YFeGng== + v8-to-istanbul@^9.0.0, v8-to-istanbul@^9.0.1: version "9.1.2" resolved "https://registry.yarnpkg.com/v8-to-istanbul/-/v8-to-istanbul-9.1.2.tgz#51168df21c8ca01c83285f27316549b2c51a5b46" From 62124a6139fc53fd65b03b18097f0c9597b323dc Mon Sep 17 00:00:00 2001 From: Leejin Yang Date: Mon, 16 Oct 2023 12:58:36 +0900 Subject: [PATCH 16/55] =?UTF-8?q?[FE]=20feat:=20=EB=9E=AD=ED=82=B9=20?= =?UTF-8?q?=EA=B8=B0=EC=A4=80=20=ED=85=8D=EC=8A=A4=ED=8A=B8=20=EC=B6=94?= =?UTF-8?q?=EA=B0=80=20(#765)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat: 상품 리뷰 소수 한자리까지로 수정 * feat: 랭킹 업데이트 텍스트 추가 * feat: 랭킹 기준에 info 아이콘 추가 * feat: 스토리북 프리뷰에 info svg 추가 --- frontend/.storybook/preview-body.html | 5 ++ .../src/components/Common/Svg/SvgIcon.tsx | 1 + .../src/components/Common/Svg/SvgSprite.tsx | 3 ++ .../ProductDetailItem/ProductDetailItem.tsx | 2 +- .../Product/ProductItem/ProductItem.tsx | 2 +- frontend/src/pages/HomePage.tsx | 46 ++++++++++++++++--- 6 files changed, 51 insertions(+), 8 deletions(-) diff --git a/frontend/.storybook/preview-body.html b/frontend/.storybook/preview-body.html index 9e1c31c57..1461bde42 100644 --- a/frontend/.storybook/preview-body.html +++ b/frontend/.storybook/preview-body.html @@ -114,6 +114,11 @@ d="M232 127.89a16 16 0 0 1-8.18 14L55.91 237.9A16.14 16.14 0 0 1 48 240a16 16 0 0 1-15.05-21.34l27.35-79.95a4 4 0 0 1 3.79-2.71H136a8 8 0 0 0 8-8.53a8.19 8.19 0 0 0-8.26-7.47H64.16a4 4 0 0 1-3.79-2.7l-27.44-80a16 16 0 0 1 22.92-19.23l168 95.89a16 16 0 0 1 8.15 13.93Z" /> + + +
    diff --git a/frontend/src/components/Common/Svg/SvgIcon.tsx b/frontend/src/components/Common/Svg/SvgIcon.tsx index b8be9a974..0705d543a 100644 --- a/frontend/src/components/Common/Svg/SvgIcon.tsx +++ b/frontend/src/components/Common/Svg/SvgIcon.tsx @@ -23,6 +23,7 @@ export const SVG_ICON_VARIANTS = [ 'camera', 'link', 'plane', + 'info' ] as const; export type SvgIconVariant = (typeof SVG_ICON_VARIANTS)[number]; diff --git a/frontend/src/components/Common/Svg/SvgSprite.tsx b/frontend/src/components/Common/Svg/SvgSprite.tsx index 65cf49d3d..450c870be 100644 --- a/frontend/src/components/Common/Svg/SvgSprite.tsx +++ b/frontend/src/components/Common/Svg/SvgSprite.tsx @@ -83,6 +83,9 @@ const SvgSprite = () => { + + + ); }; diff --git a/frontend/src/components/Product/ProductDetailItem/ProductDetailItem.tsx b/frontend/src/components/Product/ProductDetailItem/ProductDetailItem.tsx index 52cf05ada..ab7437f17 100644 --- a/frontend/src/components/Product/ProductDetailItem/ProductDetailItem.tsx +++ b/frontend/src/components/Product/ProductDetailItem/ProductDetailItem.tsx @@ -42,7 +42,7 @@ const ProductDetailItem = ({ category, productDetail }: ProductDetailItemProps) 평균 평점 - {averageRating} + {averageRating.toFixed(1)} diff --git a/frontend/src/components/Product/ProductItem/ProductItem.tsx b/frontend/src/components/Product/ProductItem/ProductItem.tsx index 74fe7accb..32300d2bc 100644 --- a/frontend/src/components/Product/ProductItem/ProductItem.tsx +++ b/frontend/src/components/Product/ProductItem/ProductItem.tsx @@ -43,7 +43,7 @@ const ProductItem = ({ product }: ProductItemProps) => { - {averageRating} + {averageRating.toFixed(1)} diff --git a/frontend/src/pages/HomePage.tsx b/frontend/src/pages/HomePage.tsx index 436b893ec..bb42cecba 100644 --- a/frontend/src/pages/HomePage.tsx +++ b/frontend/src/pages/HomePage.tsx @@ -1,15 +1,23 @@ -import { Heading, Spacing } from '@fun-eat/design-system'; +import { Heading, Spacing, Text, useTheme } from '@fun-eat/design-system'; import { useQueryErrorResetBoundary } from '@tanstack/react-query'; import { Suspense } from 'react'; import styled from 'styled-components'; -import { Loading, ErrorBoundary, ErrorComponent, CategoryFoodList, CategoryStoreList } from '@/components/Common'; +import { + Loading, + ErrorBoundary, + ErrorComponent, + CategoryFoodList, + CategoryStoreList, + SvgIcon, +} from '@/components/Common'; import { ProductRankingList, ReviewRankingList, RecipeRankingList } from '@/components/Rank'; import { IMAGE_URL } from '@/constants'; import channelTalk from '@/service/channelTalk'; export const HomePage = () => { const { reset } = useQueryErrorResetBoundary(); + const theme = useTheme(); channelTalk.loadScript(); @@ -41,7 +49,12 @@ export const HomePage = () => { 🍯 꿀조합 랭킹 - + + + + 꿀조합 랭킹은 자체 알고리즘 기반으로 업데이트됩니다. + + }> @@ -51,9 +64,14 @@ export const HomePage = () => { - 👑 상품 랭킹 + 🍙 상품 랭킹 - + + + + 상품 랭킹은 2주 단위로 업데이트됩니다. + + }> @@ -65,7 +83,12 @@ export const HomePage = () => { 📝 리뷰 랭킹 - + + + + 리뷰 랭킹은 자체 알고리즘 기반으로 업데이트됩니다. + + }> @@ -100,3 +123,14 @@ const CategoryListWrapper = styled.div` display: none; } `; + +const RankingInfoWrapper = styled.div` + display: flex; + align-items: center; + gap: 2px; + margin: 8px 0 16px; + + & > svg { + padding-bottom: 2px; + } +`; From 5d2e717eba11319c699a923e95e10d8d64fdb12e Mon Sep 17 00:00:00 2001 From: Hanuel Lee <91522259+hanueleee@users.noreply.github.com> Date: Mon, 16 Oct 2023 13:34:50 +0900 Subject: [PATCH 17/55] =?UTF-8?q?[BE]=20feat:=20=EB=A6=AC=EB=B7=B0=20?= =?UTF-8?q?=EC=82=AD=EC=A0=9C=20=EA=B8=B0=EB=8A=A5=20=EA=B5=AC=ED=98=84=20?= =?UTF-8?q?(#735)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat: 해당 review에 달린 tag를 삭제하는 기능 추가 * feat: 해당 review에 달린 favorite을 삭제하는 기능 추가 * feat: NotAuthorOfReviewException 추가 * feat: 리뷰 삭제 기능 구현 * feat: s3 이미지 삭제 기능 구현 * test: 리뷰 삭제 기능에 대한 인수테스트 작성 * refactor: 리뷰 반영 * refactor: deleteAllByIdInBatch적용 * test: 리뷰 삭제 실패 케이스 추가 * refactor: updateProductImage 메서드 중복 제거 * feat: s3 파일 경로 지정 로직 추가 * refactor: 리뷰에 이미지가 존재할 때에만 s3 delete 로직 실행하도록 수정 * refactor: 리뷰 삭제 성공시 상태코드 204 반환 * test: 리뷰 삭제 성공시 상태코드 204 반환하도록 인수테스트 수정 * feat: s3 이미지 삭제 로직 이벤트 처리 * refactor: 이미지 있을 때만 이벤트 발행하던 로직을 이미지 유무 상관없이 이벤트 발행하도록 수정 (이미지 유무 처리를 이벤트리스너에서 하도록) * test: 리뷰 삭제 이벤트 관련 테스트 추가 * test: 리뷰 삭제 이벤트 관련 테스트 보완 * refactor: ReviewTagRepositoryTest의 deleteByReview 테스트 간소화 * feat: application.yml에 스레드 풀 설정 추가 * refactor: member를 equals로 비교하도록 수정 * chore: 컨벤션 적용 * refactor: 세션 이름 복구 * refactor: 리뷰 반영 * refactor: reviewId 대신 review로 delete하도록 수정 * refactor: s3 이미지 삭제 실패 로그 문구 수정 * refactor: 리뷰 삭제시 deleteById 대신 delete로 수정 * feat: 리뷰 삭제 api 수정 사항 적용 * style: EventTest 메소드 줄바꿈 --- .../java/com/funeat/FuneatApplication.java | 3 +- .../java/com/funeat/common/ImageUploader.java | 2 + .../common/exception/CommonException.java | 6 + .../java/com/funeat/common/s3/S3Uploader.java | 19 ++ .../persistence/ReviewFavoriteRepository.java | 5 + .../presentation/MemberApiController.java | 11 + .../member/presentation/MemberController.java | 11 + .../review/application/ReviewDeleteEvent.java | 14 ++ .../ReviewDeleteEventListener.java | 27 +++ .../review/application/ReviewService.java | 57 ++++- .../java/com/funeat/review/domain/Review.java | 4 + .../review/exception/ReviewErrorCode.java | 1 + .../review/exception/ReviewException.java | 6 + .../persistence/ReviewTagRepository.java | 5 + .../presentation/ReviewApiController.java | 6 +- .../review/presentation/ReviewController.java | 4 +- backend/src/main/resources/application.yml | 5 + .../member/MemberAcceptanceTest.java | 82 +++++++ .../funeat/acceptance/member/MemberSteps.java | 9 + .../java/com/funeat/common/EventTest.java | 66 ++++++ .../com/funeat/common/TestImageUploader.java | 4 + .../ReviewFavoriteRepositoryTest.java | 75 ++++++ .../ReviewDeleteEventListenerTest.java | 217 ++++++++++++++++++ .../review/application/ReviewServiceTest.java | 169 ++++++++++++-- .../persistence/ReviewTagRepositoryTest.java | 74 ++++++ 25 files changed, 858 insertions(+), 24 deletions(-) create mode 100644 backend/src/main/java/com/funeat/review/application/ReviewDeleteEvent.java create mode 100644 backend/src/main/java/com/funeat/review/application/ReviewDeleteEventListener.java create mode 100644 backend/src/test/java/com/funeat/common/EventTest.java create mode 100644 backend/src/test/java/com/funeat/review/application/ReviewDeleteEventListenerTest.java diff --git a/backend/src/main/java/com/funeat/FuneatApplication.java b/backend/src/main/java/com/funeat/FuneatApplication.java index 53bd185c0..34909202c 100644 --- a/backend/src/main/java/com/funeat/FuneatApplication.java +++ b/backend/src/main/java/com/funeat/FuneatApplication.java @@ -3,8 +3,10 @@ import com.funeat.common.repository.BaseRepositoryImpl; import org.springframework.boot.SpringApplication; import org.springframework.boot.autoconfigure.SpringBootApplication; +import org.springframework.scheduling.annotation.EnableAsync; import org.springframework.data.jpa.repository.config.EnableJpaRepositories; +@EnableAsync @SpringBootApplication @EnableJpaRepositories(repositoryBaseClass = BaseRepositoryImpl.class) public class FuneatApplication { @@ -12,5 +14,4 @@ public class FuneatApplication { public static void main(String[] args) { SpringApplication.run(FuneatApplication.class, args); } - } diff --git a/backend/src/main/java/com/funeat/common/ImageUploader.java b/backend/src/main/java/com/funeat/common/ImageUploader.java index 754b1affd..afd4b5c10 100644 --- a/backend/src/main/java/com/funeat/common/ImageUploader.java +++ b/backend/src/main/java/com/funeat/common/ImageUploader.java @@ -5,4 +5,6 @@ public interface ImageUploader { String upload(final MultipartFile image); + + void delete(final String fileName); } diff --git a/backend/src/main/java/com/funeat/common/exception/CommonException.java b/backend/src/main/java/com/funeat/common/exception/CommonException.java index e2e822c68..55be12d5d 100644 --- a/backend/src/main/java/com/funeat/common/exception/CommonException.java +++ b/backend/src/main/java/com/funeat/common/exception/CommonException.java @@ -22,4 +22,10 @@ public S3UploadFailException(final CommonErrorCode errorCode) { super(errorCode.getStatus(), new ErrorCode<>(errorCode.getCode(), errorCode.getMessage())); } } + + public static class S3DeleteFailException extends CommonException { + public S3DeleteFailException(final CommonErrorCode errorCode) { + super(errorCode.getStatus(), new ErrorCode<>(errorCode.getCode(), errorCode.getMessage())); + } + } } diff --git a/backend/src/main/java/com/funeat/common/s3/S3Uploader.java b/backend/src/main/java/com/funeat/common/s3/S3Uploader.java index 3f9c86caa..97e6241b7 100644 --- a/backend/src/main/java/com/funeat/common/s3/S3Uploader.java +++ b/backend/src/main/java/com/funeat/common/s3/S3Uploader.java @@ -3,15 +3,19 @@ import static com.funeat.exception.CommonErrorCode.IMAGE_EXTENSION_ERROR_CODE; import static com.funeat.exception.CommonErrorCode.UNKNOWN_SERVER_ERROR_CODE; +import com.amazonaws.AmazonServiceException; import com.amazonaws.services.s3.AmazonS3; import com.amazonaws.services.s3.model.ObjectMetadata; import com.amazonaws.services.s3.model.PutObjectRequest; import com.funeat.common.ImageUploader; import com.funeat.common.exception.CommonException.NotAllowedFileExtensionException; +import com.funeat.common.exception.CommonException.S3DeleteFailException; import com.funeat.common.exception.CommonException.S3UploadFailException; import java.io.IOException; import java.util.List; import java.util.UUID; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; import org.springframework.beans.factory.annotation.Value; import org.springframework.context.annotation.Profile; import org.springframework.stereotype.Component; @@ -21,8 +25,11 @@ @Profile("!test") public class S3Uploader implements ImageUploader { + private static final int BEGIN_FILE_NAME_INDEX_WITHOUT_CLOUDFRONT_PATH = 31; private static final List INCLUDE_EXTENSIONS = List.of("image/jpeg", "image/png", "image/webp"); + private final Logger log = LoggerFactory.getLogger(this.getClass()); + @Value("${cloud.aws.s3.bucket}") private String bucket; @@ -53,6 +60,18 @@ public String upload(final MultipartFile image) { } } + @Override + public void delete(final String image) { + final String imageName = image.substring(BEGIN_FILE_NAME_INDEX_WITHOUT_CLOUDFRONT_PATH); + try { + final String key = folder + imageName; + amazonS3.deleteObject(bucket, key); + } catch (final AmazonServiceException e) { + log.error("S3 이미지 삭제에 실패했습니다. 이미지 경로 : {}", image); + throw new S3DeleteFailException(UNKNOWN_SERVER_ERROR_CODE); + } + } + private void validateExtension(final MultipartFile image) { final String contentType = image.getContentType(); if (!INCLUDE_EXTENSIONS.contains(contentType)) { diff --git a/backend/src/main/java/com/funeat/member/persistence/ReviewFavoriteRepository.java b/backend/src/main/java/com/funeat/member/persistence/ReviewFavoriteRepository.java index 2e96e623a..f1ae40e5d 100644 --- a/backend/src/main/java/com/funeat/member/persistence/ReviewFavoriteRepository.java +++ b/backend/src/main/java/com/funeat/member/persistence/ReviewFavoriteRepository.java @@ -3,10 +3,15 @@ import com.funeat.member.domain.Member; import com.funeat.member.domain.favorite.ReviewFavorite; import com.funeat.review.domain.Review; +import java.util.List; import java.util.Optional; import org.springframework.data.jpa.repository.JpaRepository; public interface ReviewFavoriteRepository extends JpaRepository { Optional findByMemberAndReview(final Member member, final Review review); + + void deleteByReview(final Review review); + + List findByReview(final Review review); } diff --git a/backend/src/main/java/com/funeat/member/presentation/MemberApiController.java b/backend/src/main/java/com/funeat/member/presentation/MemberApiController.java index 6ee963d54..af00932f7 100644 --- a/backend/src/main/java/com/funeat/member/presentation/MemberApiController.java +++ b/backend/src/main/java/com/funeat/member/presentation/MemberApiController.java @@ -15,7 +15,9 @@ import org.springframework.data.web.PageableDefault; import org.springframework.http.MediaType; import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.DeleteMapping; import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; import org.springframework.web.bind.annotation.PutMapping; import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RequestPart; @@ -69,4 +71,13 @@ public ResponseEntity getMemberRecipe(@AuthenticationPrin return ResponseEntity.ok().body(response); } + + @Logging + @DeleteMapping("/reviews/{reviewId}") + public ResponseEntity deleteReview(@PathVariable final Long reviewId, + @AuthenticationPrincipal final LoginInfo loginInfo) { + reviewService.deleteReview(reviewId, loginInfo.getId()); + + return ResponseEntity.noContent().build(); + } } diff --git a/backend/src/main/java/com/funeat/member/presentation/MemberController.java b/backend/src/main/java/com/funeat/member/presentation/MemberController.java index 5d5748fd7..9c5e60763 100644 --- a/backend/src/main/java/com/funeat/member/presentation/MemberController.java +++ b/backend/src/main/java/com/funeat/member/presentation/MemberController.java @@ -12,7 +12,9 @@ import org.springframework.data.domain.Pageable; import org.springframework.data.web.PageableDefault; import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.DeleteMapping; import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; import org.springframework.web.bind.annotation.PutMapping; import org.springframework.web.bind.annotation.RequestPart; import org.springframework.web.multipart.MultipartFile; @@ -55,4 +57,13 @@ ResponseEntity getMemberReview(@AuthenticationPrincipal f @GetMapping ResponseEntity getMemberRecipe(@AuthenticationPrincipal final LoginInfo loginInfo, @PageableDefault final Pageable pageable); + + @Operation(summary = "리뷰 삭제", description = "자신이 작성한 리뷰를 삭제한다.") + @ApiResponse( + responseCode = "204", + description = "리뷰 삭제 성공." + ) + @DeleteMapping + ResponseEntity deleteReview(@PathVariable final Long reviewId, + @AuthenticationPrincipal final LoginInfo loginInfo); } diff --git a/backend/src/main/java/com/funeat/review/application/ReviewDeleteEvent.java b/backend/src/main/java/com/funeat/review/application/ReviewDeleteEvent.java new file mode 100644 index 000000000..7c69eee3c --- /dev/null +++ b/backend/src/main/java/com/funeat/review/application/ReviewDeleteEvent.java @@ -0,0 +1,14 @@ +package com.funeat.review.application; + +public class ReviewDeleteEvent { + + private final String image; + + public ReviewDeleteEvent(final String image) { + this.image = image; + } + + public String getImage() { + return image; + } +} diff --git a/backend/src/main/java/com/funeat/review/application/ReviewDeleteEventListener.java b/backend/src/main/java/com/funeat/review/application/ReviewDeleteEventListener.java new file mode 100644 index 000000000..2009e3936 --- /dev/null +++ b/backend/src/main/java/com/funeat/review/application/ReviewDeleteEventListener.java @@ -0,0 +1,27 @@ +package com.funeat.review.application; + +import com.funeat.common.ImageUploader; +import io.micrometer.core.instrument.util.StringUtils; +import org.springframework.scheduling.annotation.Async; +import org.springframework.stereotype.Component; +import org.springframework.transaction.event.TransactionPhase; +import org.springframework.transaction.event.TransactionalEventListener; + +@Component +public class ReviewDeleteEventListener { + + private final ImageUploader imageUploader; + + public ReviewDeleteEventListener(final ImageUploader imageUploader) { + this.imageUploader = imageUploader; + } + + @Async + @TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT) + public void deleteReviewImageInS3(final ReviewDeleteEvent event) { + final String image = event.getImage(); + if (StringUtils.isBlank(image)) { + imageUploader.delete(image); + } + } +} diff --git a/backend/src/main/java/com/funeat/review/application/ReviewService.java b/backend/src/main/java/com/funeat/review/application/ReviewService.java index ee482a8c5..027f8d5ae 100644 --- a/backend/src/main/java/com/funeat/review/application/ReviewService.java +++ b/backend/src/main/java/com/funeat/review/application/ReviewService.java @@ -3,6 +3,7 @@ import static com.funeat.member.exception.MemberErrorCode.MEMBER_DUPLICATE_FAVORITE; import static com.funeat.member.exception.MemberErrorCode.MEMBER_NOT_FOUND; import static com.funeat.product.exception.ProductErrorCode.PRODUCT_NOT_FOUND; +import static com.funeat.review.exception.ReviewErrorCode.NOT_AUTHOR_OF_REVIEW; import static com.funeat.review.exception.ReviewErrorCode.REVIEW_NOT_FOUND; import com.funeat.common.ImageUploader; @@ -27,6 +28,7 @@ import com.funeat.review.dto.ReviewFavoriteRequest; import com.funeat.review.dto.SortingReviewDto; import com.funeat.review.dto.SortingReviewsResponse; +import com.funeat.review.exception.ReviewException.NotAuthorOfReviewException; import com.funeat.review.exception.ReviewException.ReviewNotFoundException; import com.funeat.review.persistence.ReviewRepository; import com.funeat.review.persistence.ReviewTagRepository; @@ -35,6 +37,7 @@ import java.util.List; import java.util.Optional; import java.util.stream.Collectors; +import org.springframework.context.ApplicationEventPublisher; import org.springframework.dao.DataIntegrityViolationException; import org.springframework.data.domain.Page; import org.springframework.data.domain.PageRequest; @@ -58,11 +61,13 @@ public class ReviewService { private final ProductRepository productRepository; private final ReviewFavoriteRepository reviewFavoriteRepository; private final ImageUploader imageUploader; + private final ApplicationEventPublisher eventPublisher; public ReviewService(final ReviewRepository reviewRepository, final TagRepository tagRepository, final ReviewTagRepository reviewTagRepository, final MemberRepository memberRepository, final ProductRepository productRepository, - final ReviewFavoriteRepository reviewFavoriteRepository, final ImageUploader imageUploader) { + final ReviewFavoriteRepository reviewFavoriteRepository, + final ImageUploader imageUploader, final ApplicationEventPublisher eventPublisher) { this.reviewRepository = reviewRepository; this.tagRepository = tagRepository; this.reviewTagRepository = reviewTagRepository; @@ -70,6 +75,7 @@ public ReviewService(final ReviewRepository reviewRepository, final TagRepositor this.productRepository = productRepository; this.reviewFavoriteRepository = reviewFavoriteRepository; this.imageUploader = imageUploader; + this.eventPublisher = eventPublisher; } @Transactional @@ -124,14 +130,11 @@ private ReviewFavorite saveReviewFavorite(final Member member, final Review revi } @Transactional - public void updateProductImage(final Long reviewId) { - final Review review = reviewRepository.findById(reviewId) - .orElseThrow(() -> new ReviewNotFoundException(REVIEW_NOT_FOUND, reviewId)); + public void updateProductImage(final Long productId) { + final Product product = productRepository.findById(productId) + .orElseThrow(() -> new ProductNotFoundException(PRODUCT_NOT_FOUND, productId)); - final Product product = review.getProduct(); - final Long productId = product.getId(); final PageRequest pageRequest = PageRequest.of(TOP, ONE); - final List topFavoriteReview = reviewRepository.findPopularReviewWithImage(productId, pageRequest); if (!topFavoriteReview.isEmpty()) { final String topFavoriteReviewImage = topFavoriteReview.get(TOP).getImage(); @@ -180,6 +183,46 @@ public MemberReviewsResponse findReviewByMember(final Long memberId, final Pagea return MemberReviewsResponse.toResponse(pageDto, dtos); } + @Transactional + public void deleteReview(final Long reviewId, final Long memberId) { + final Member member = memberRepository.findById(memberId) + .orElseThrow(() -> new MemberNotFoundException(MEMBER_NOT_FOUND, memberId)); + final Review review = reviewRepository.findById(reviewId) + .orElseThrow(() -> new ReviewNotFoundException(REVIEW_NOT_FOUND, reviewId)); + final Product product = review.getProduct(); + final String image = review.getImage(); + + if (review.checkAuthor(member)) { + eventPublisher.publishEvent(new ReviewDeleteEvent(image)); + deleteThingsRelatedToReview(review); + updateProductImage(product.getId()); + return; + } + throw new NotAuthorOfReviewException(NOT_AUTHOR_OF_REVIEW, memberId); + } + + private void deleteThingsRelatedToReview(final Review review) { + deleteReviewTags(review); + deleteReviewFavorites(review); + reviewRepository.delete(review); + } + + private void deleteReviewTags(final Review review) { + final List reviewTags = reviewTagRepository.findByReview(review); + final List ids = reviewTags.stream() + .map(ReviewTag::getId) + .collect(Collectors.toList()); + reviewTagRepository.deleteAllByIdInBatch(ids); + } + + private void deleteReviewFavorites(final Review review) { + final List reviewFavorites = reviewFavoriteRepository.findByReview(review); + final List ids = reviewFavorites.stream() + .map(ReviewFavorite::getId) + .collect(Collectors.toList()); + reviewFavoriteRepository.deleteAllByIdInBatch(ids); + } + public Optional getMostFavoriteReview(final Long productId) { final Product findProduct = productRepository.findById(productId) .orElseThrow(() -> new ProductNotFoundException(PRODUCT_NOT_FOUND, productId)); diff --git a/backend/src/main/java/com/funeat/review/domain/Review.java b/backend/src/main/java/com/funeat/review/domain/Review.java index 3545371e3..d990666d3 100644 --- a/backend/src/main/java/com/funeat/review/domain/Review.java +++ b/backend/src/main/java/com/funeat/review/domain/Review.java @@ -88,6 +88,10 @@ public void minusFavoriteCount() { this.favoriteCount--; } + public boolean checkAuthor(final Member member) { + return Objects.equals(this.member, member); + } + public Long getId() { return id; } diff --git a/backend/src/main/java/com/funeat/review/exception/ReviewErrorCode.java b/backend/src/main/java/com/funeat/review/exception/ReviewErrorCode.java index d91c0c8c3..05331dac9 100644 --- a/backend/src/main/java/com/funeat/review/exception/ReviewErrorCode.java +++ b/backend/src/main/java/com/funeat/review/exception/ReviewErrorCode.java @@ -5,6 +5,7 @@ public enum ReviewErrorCode { REVIEW_NOT_FOUND(HttpStatus.NOT_FOUND, "존재하지 않는 리뷰입니다. 리뷰 id를 확인하세요.", "3001"), + NOT_AUTHOR_OF_REVIEW(HttpStatus.BAD_REQUEST, "해당 리뷰를 작성한 회원이 아닙니다", "3002") ; private final HttpStatus status; diff --git a/backend/src/main/java/com/funeat/review/exception/ReviewException.java b/backend/src/main/java/com/funeat/review/exception/ReviewException.java index 4699f3af6..a961f3301 100644 --- a/backend/src/main/java/com/funeat/review/exception/ReviewException.java +++ b/backend/src/main/java/com/funeat/review/exception/ReviewException.java @@ -15,4 +15,10 @@ public ReviewNotFoundException(final ReviewErrorCode errorCode, final Long revie super(errorCode.getStatus(), new ErrorCode<>(errorCode.getCode(), errorCode.getMessage(), reviewId)); } } + + public static class NotAuthorOfReviewException extends ReviewException { + public NotAuthorOfReviewException(final ReviewErrorCode errorCode, final Long memberId) { + super(errorCode.getStatus(), new ErrorCode<>(errorCode.getCode(), errorCode.getMessage(), memberId)); + } + } } diff --git a/backend/src/main/java/com/funeat/review/persistence/ReviewTagRepository.java b/backend/src/main/java/com/funeat/review/persistence/ReviewTagRepository.java index 7129a711c..cbdf3c3bf 100644 --- a/backend/src/main/java/com/funeat/review/persistence/ReviewTagRepository.java +++ b/backend/src/main/java/com/funeat/review/persistence/ReviewTagRepository.java @@ -1,5 +1,6 @@ package com.funeat.review.persistence; +import com.funeat.review.domain.Review; import com.funeat.review.domain.ReviewTag; import com.funeat.tag.domain.Tag; import java.util.List; @@ -16,4 +17,8 @@ public interface ReviewTagRepository extends JpaRepository { + "GROUP BY rt.tag " + "ORDER BY cnt DESC") List findTop3TagsByReviewIn(final Long productId, final Pageable pageable); + + void deleteByReview(final Review review); + + List findByReview(final Review review); } diff --git a/backend/src/main/java/com/funeat/review/presentation/ReviewApiController.java b/backend/src/main/java/com/funeat/review/presentation/ReviewApiController.java index 57bf20359..ba094cb14 100644 --- a/backend/src/main/java/com/funeat/review/presentation/ReviewApiController.java +++ b/backend/src/main/java/com/funeat/review/presentation/ReviewApiController.java @@ -17,6 +17,7 @@ import org.springframework.data.web.PageableDefault; import org.springframework.http.MediaType; import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.DeleteMapping; import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.PatchMapping; import org.springframework.web.bind.annotation.PathVariable; @@ -49,11 +50,12 @@ public ResponseEntity writeReview(@PathVariable final Long productId, @Logging @PatchMapping("/api/products/{productId}/reviews/{reviewId}") - public ResponseEntity toggleLikeReview(@PathVariable final Long reviewId, + public ResponseEntity toggleLikeReview(@PathVariable final Long productId, + @PathVariable final Long reviewId, @AuthenticationPrincipal final LoginInfo loginInfo, @RequestBody @Valid final ReviewFavoriteRequest request) { reviewService.likeReview(reviewId, loginInfo.getId(), request); - reviewService.updateProductImage(reviewId); + reviewService.updateProductImage(productId); return ResponseEntity.noContent().build(); } diff --git a/backend/src/main/java/com/funeat/review/presentation/ReviewController.java b/backend/src/main/java/com/funeat/review/presentation/ReviewController.java index 886ee5a15..2e3d52459 100644 --- a/backend/src/main/java/com/funeat/review/presentation/ReviewController.java +++ b/backend/src/main/java/com/funeat/review/presentation/ReviewController.java @@ -14,6 +14,7 @@ import org.springframework.data.domain.Pageable; import org.springframework.data.web.PageableDefault; import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.DeleteMapping; import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.PatchMapping; import org.springframework.web.bind.annotation.PathVariable; @@ -42,7 +43,8 @@ ResponseEntity writeReview(@PathVariable final Long productId, description = "리뷰 좋아요(취소) 성공." ) @PatchMapping - ResponseEntity toggleLikeReview(@PathVariable final Long reviewId, + ResponseEntity toggleLikeReview(@PathVariable final Long productId, + @PathVariable final Long reviewId, @AuthenticationPrincipal final LoginInfo loginInfo, @RequestBody final ReviewFavoriteRequest request); diff --git a/backend/src/main/resources/application.yml b/backend/src/main/resources/application.yml index 8235adae4..b15ff14fe 100644 --- a/backend/src/main/resources/application.yml +++ b/backend/src/main/resources/application.yml @@ -6,6 +6,11 @@ spring: enabled: true maxFileSize: 10MB maxRequestSize: 15MB + task: + execution: + pool: + core-size: { THREAD_CORE_SIZE } + max-size: { THREAD_MAX_SIZE } session: store-type: jdbc jdbc: diff --git a/backend/src/test/java/com/funeat/acceptance/member/MemberAcceptanceTest.java b/backend/src/test/java/com/funeat/acceptance/member/MemberAcceptanceTest.java index cb3b8e629..b2a94b2c0 100644 --- a/backend/src/test/java/com/funeat/acceptance/member/MemberAcceptanceTest.java +++ b/backend/src/test/java/com/funeat/acceptance/member/MemberAcceptanceTest.java @@ -8,7 +8,9 @@ import static com.funeat.acceptance.common.CommonSteps.잘못된_요청; import static com.funeat.acceptance.common.CommonSteps.정상_처리; import static com.funeat.acceptance.common.CommonSteps.정상_처리_NO_CONTENT; +import static com.funeat.acceptance.common.CommonSteps.찾을수_없음; import static com.funeat.acceptance.common.CommonSteps.페이지를_검증한다; +import static com.funeat.acceptance.member.MemberSteps.리뷰_삭제_요청; import static com.funeat.acceptance.member.MemberSteps.사용자_꿀조합_조회_요청; import static com.funeat.acceptance.member.MemberSteps.사용자_리뷰_조회_요청; import static com.funeat.acceptance.member.MemberSteps.사용자_정보_수정_요청; @@ -33,17 +35,22 @@ import static com.funeat.fixture.PageFixture.총_데이터_개수; import static com.funeat.fixture.PageFixture.총_페이지; import static com.funeat.fixture.PageFixture.최신순; +import static com.funeat.fixture.ProductFixture.상품_삼각김밥_가격1000원_평점3점_생성; import static com.funeat.fixture.ProductFixture.상품_삼각김밥_가격1000원_평점5점_생성; import static com.funeat.fixture.RecipeFixture.레시피; import static com.funeat.fixture.RecipeFixture.레시피1; import static com.funeat.fixture.RecipeFixture.레시피2; import static com.funeat.fixture.RecipeFixture.레시피추가요청_생성; +import static com.funeat.fixture.ReviewFixture.리뷰1; +import static com.funeat.fixture.ReviewFixture.리뷰2; import static com.funeat.fixture.ReviewFixture.리뷰추가요청_재구매O_생성; import static com.funeat.fixture.ReviewFixture.리뷰추가요청_재구매X_생성; import static com.funeat.fixture.ScoreFixture.점수_1점; import static com.funeat.fixture.ScoreFixture.점수_2점; import static com.funeat.fixture.ScoreFixture.점수_3점; +import static com.funeat.fixture.ScoreFixture.점수_4점; import static com.funeat.fixture.TagFixture.태그_맛있어요_TASTE_생성; +import static com.funeat.review.exception.ReviewErrorCode.REVIEW_NOT_FOUND; import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.SoftAssertions.assertSoftly; @@ -300,6 +307,81 @@ class getMemberRecipes_실패_테스트 { } } + @Nested + class deleteReview_성공_테스트 { + + @Test + void 자신이_작성한_리뷰를_삭제할_수_있다() { + // given + final var 카테고리 = 카테고리_즉석조리_생성(); + 단일_카테고리_저장(카테고리); + final var 상품 = 단일_상품_저장(상품_삼각김밥_가격1000원_평점3점_생성(카테고리)); + final var 태그 = 단일_태그_저장(태그_맛있어요_TASTE_생성()); + 리뷰_작성_요청(로그인_쿠키_획득(멤버1), 상품, 사진_명세_요청(이미지1), 리뷰추가요청_재구매O_생성(점수_4점, List.of(태그))); + + // when + final var 응답 = 리뷰_삭제_요청(로그인_쿠키_획득(멤버1), 리뷰1); + + // then + STATUS_CODE를_검증한다(응답, 정상_처리_NO_CONTENT); + } + } + + @Nested + class deleteReview_실패_테스트 { + + @ParameterizedTest + @NullAndEmptySource + void 로그인하지_않는_사용자가_리뷰_삭제시_예외가_발생한다(final String cookie) { + // given + final var 카테고리 = 카테고리_즉석조리_생성(); + 단일_카테고리_저장(카테고리); + final var 상품 = 단일_상품_저장(상품_삼각김밥_가격1000원_평점3점_생성(카테고리)); + final var 태그 = 단일_태그_저장(태그_맛있어요_TASTE_생성()); + 리뷰_작성_요청(로그인_쿠키_획득(멤버1), 상품, 사진_명세_요청(이미지1), 리뷰추가요청_재구매O_생성(점수_4점, List.of(태그))); + + // when + final var 응답 = 리뷰_삭제_요청(cookie, 리뷰1); + + // then + STATUS_CODE를_검증한다(응답, 인증되지_않음); + RESPONSE_CODE와_MESSAGE를_검증한다(응답, LOGIN_MEMBER_NOT_FOUND.getCode(), LOGIN_MEMBER_NOT_FOUND.getMessage()); + } + + @Test + void 존재하지_않는_리뷰를_삭제할_수_없다() { + // given + final var 카테고리 = 카테고리_즉석조리_생성(); + 단일_카테고리_저장(카테고리); + final var 상품 = 단일_상품_저장(상품_삼각김밥_가격1000원_평점3점_생성(카테고리)); + final var 태그 = 단일_태그_저장(태그_맛있어요_TASTE_생성()); + 리뷰_작성_요청(로그인_쿠키_획득(멤버1), 상품, 사진_명세_요청(이미지1), 리뷰추가요청_재구매O_생성(점수_4점, List.of(태그))); + + // when + final var 응답 = 리뷰_삭제_요청(로그인_쿠키_획득(멤버1), 리뷰2); + + // then + STATUS_CODE를_검증한다(응답, 찾을수_없음); + RESPONSE_CODE와_MESSAGE를_검증한다(응답, REVIEW_NOT_FOUND.getCode(), REVIEW_NOT_FOUND.getMessage()); + } + + @Test + void 자신이_작성하지_않은_리뷰는_삭제할_수_없다() { + // given + final var 카테고리 = 카테고리_즉석조리_생성(); + 단일_카테고리_저장(카테고리); + final var 상품 = 단일_상품_저장(상품_삼각김밥_가격1000원_평점3점_생성(카테고리)); + final var 태그 = 단일_태그_저장(태그_맛있어요_TASTE_생성()); + 리뷰_작성_요청(로그인_쿠키_획득(멤버1), 상품, 사진_명세_요청(이미지1), 리뷰추가요청_재구매O_생성(점수_4점, List.of(태그))); + + // when + final var 응답 = 리뷰_삭제_요청(로그인_쿠키_획득(멤버2), 리뷰1); + + // then + STATUS_CODE를_검증한다(응답, 잘못된_요청); + } + } + private void 사용자_리뷰_조회_결과를_검증한다(final ExtractableResponse response, final int expectedReviewSize) { final var actual = response.jsonPath().getList("reviews", MemberReviewDto.class); diff --git a/backend/src/test/java/com/funeat/acceptance/member/MemberSteps.java b/backend/src/test/java/com/funeat/acceptance/member/MemberSteps.java index ca5600fdc..681efb26a 100644 --- a/backend/src/test/java/com/funeat/acceptance/member/MemberSteps.java +++ b/backend/src/test/java/com/funeat/acceptance/member/MemberSteps.java @@ -62,4 +62,13 @@ public class MemberSteps { .then() .extract(); } + + public static ExtractableResponse 리뷰_삭제_요청(final String loginCookie, final Long reviewId) { + return given() + .cookie("JSESSIONID", loginCookie) + .when() + .delete("/api/members/reviews/{reviewId}", reviewId) + .then() + .extract(); + } } diff --git a/backend/src/test/java/com/funeat/common/EventTest.java b/backend/src/test/java/com/funeat/common/EventTest.java new file mode 100644 index 000000000..dec401bec --- /dev/null +++ b/backend/src/test/java/com/funeat/common/EventTest.java @@ -0,0 +1,66 @@ +package com.funeat.common; + +import com.funeat.member.domain.Member; +import com.funeat.member.persistence.MemberRepository; +import com.funeat.product.domain.Category; +import com.funeat.product.domain.Product; +import com.funeat.product.persistence.CategoryRepository; +import com.funeat.product.persistence.ProductRepository; +import com.funeat.review.application.ReviewService; +import com.funeat.review.persistence.ReviewRepository; +import com.funeat.tag.domain.Tag; +import com.funeat.tag.persistence.TagRepository; +import java.util.List; +import org.junit.jupiter.api.DisplayNameGeneration; +import org.junit.jupiter.api.DisplayNameGenerator.ReplaceUnderscores; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.test.context.event.ApplicationEvents; +import org.springframework.test.context.event.RecordApplicationEvents; + +@SpringBootTest +@RecordApplicationEvents +@ExtendWith(MockitoExtension.class) +@DisplayNameGeneration(ReplaceUnderscores.class) +public class EventTest { + + @Autowired + protected ApplicationEvents events; + + @Autowired + protected ProductRepository productRepository; + + @Autowired + protected CategoryRepository categoryRepository; + + @Autowired + protected TagRepository tagRepository; + + @Autowired + protected MemberRepository memberRepository; + + @Autowired + protected ReviewRepository reviewRepository; + + @Autowired + protected ReviewService reviewService; + + protected Long 단일_상품_저장(final Product product) { + return productRepository.save(product).getId(); + } + + protected Long 단일_카테고리_저장(final Category category) { + return categoryRepository.save(category).getId(); + } + + protected void 복수_태그_저장(final Tag... tagsToSave) { + final var tags = List.of(tagsToSave); + tagRepository.saveAll(tags); + } + + protected Long 단일_멤버_저장(final Member member) { + return memberRepository.save(member).getId(); + } +} diff --git a/backend/src/test/java/com/funeat/common/TestImageUploader.java b/backend/src/test/java/com/funeat/common/TestImageUploader.java index 58d4ab6f8..642da2176 100644 --- a/backend/src/test/java/com/funeat/common/TestImageUploader.java +++ b/backend/src/test/java/com/funeat/common/TestImageUploader.java @@ -30,6 +30,10 @@ public String upload(final MultipartFile image) { } } + @Override + public void delete(final String fileName) { + } + private void deleteDirectory(Path directory) throws IOException { // 디렉토리 내부 파일 및 디렉토리 삭제 try (Stream pathStream = Files.walk(directory)) { diff --git a/backend/src/test/java/com/funeat/member/persistence/ReviewFavoriteRepositoryTest.java b/backend/src/test/java/com/funeat/member/persistence/ReviewFavoriteRepositoryTest.java index 73ed00553..fcb190d28 100644 --- a/backend/src/test/java/com/funeat/member/persistence/ReviewFavoriteRepositoryTest.java +++ b/backend/src/test/java/com/funeat/member/persistence/ReviewFavoriteRepositoryTest.java @@ -3,6 +3,7 @@ import static com.funeat.fixture.CategoryFixture.카테고리_즉석조리_생성; import static com.funeat.fixture.MemberFixture.멤버_멤버1_생성; import static com.funeat.fixture.MemberFixture.멤버_멤버2_생성; +import static com.funeat.fixture.MemberFixture.멤버_멤버3_생성; import static com.funeat.fixture.ProductFixture.상품_삼각김밥_가격1000원_평점3점_생성; import static com.funeat.fixture.ReviewFixture.리뷰_이미지test4_평점4점_재구매O_생성; import static com.funeat.fixture.ReviewFixture.리뷰_이미지test5_평점5점_재구매O_생성; @@ -11,6 +12,7 @@ import com.funeat.common.RepositoryTest; import com.funeat.member.domain.favorite.ReviewFavorite; +import java.util.List; import java.util.NoSuchElementException; import org.junit.jupiter.api.Nested; import org.junit.jupiter.api.Test; @@ -106,4 +108,77 @@ class findByMemberAndReview_실패_테스트 { .isInstanceOf(NoSuchElementException.class); } } + + @Nested + class deleteByReview_성공_테스트 { + + @Test + void 해당_리뷰에_달린_좋아요를_삭제할_수_있다() { + // given + final var member1 = 멤버_멤버1_생성(); + final var member2 = 멤버_멤버2_생성(); + final var member3 = 멤버_멤버3_생성(); + 복수_멤버_저장(member1, member2, member3); + + final var category = 카테고리_즉석조리_생성(); + 단일_카테고리_저장(category); + + final var product = 상품_삼각김밥_가격1000원_평점3점_생성(category); + 단일_상품_저장(product); + + final var review1 = 리뷰_이미지test4_평점4점_재구매O_생성(member1, product, 0L); + final var review2 = 리뷰_이미지test5_평점5점_재구매O_생성(member1, product, 0L); + 복수_리뷰_저장(review1, review2); + + final var reviewFavorite1_1 = ReviewFavorite.create(member1, review1, true); + final var reviewFavorite1_2 = ReviewFavorite.create(member2, review1, true); + final var reviewFavorite1_3 = ReviewFavorite.create(member3, review1, true); + final var reviewFavorite2_1 = ReviewFavorite.create(member1, review2, true); + final var reviewFavorite2_2 = ReviewFavorite.create(member2, review2, true); + 복수_리뷰_좋아요_저장(reviewFavorite1_1, reviewFavorite1_2, reviewFavorite1_3, reviewFavorite2_1, reviewFavorite2_2); + + final var expected = List.of(reviewFavorite2_1, reviewFavorite2_2); + + // when + reviewFavoriteRepository.deleteByReview(review1); + + // then + final var remainings = reviewFavoriteRepository.findAll(); + assertThat(remainings).usingRecursiveComparison() + .isEqualTo(expected); + } + } + + @Nested + class findByReview_성공_테스트 { + + @Test + void 리뷰로_해당_리뷰에_달린_좋아요를_조회할_수_있다() { + // given + final var member = 멤버_멤버1_생성(); + 단일_멤버_저장(member); + + final var category = 카테고리_즉석조리_생성(); + 단일_카테고리_저장(category); + + final var product = 상품_삼각김밥_가격1000원_평점3점_생성(category); + 단일_상품_저장(product); + + final var review = 리뷰_이미지test4_평점4점_재구매O_생성(member, product, 0L); + 단일_리뷰_저장(review); + + final var reviewFavorite = ReviewFavorite.create(member, review, true); + 단일_리뷰_좋아요_저장(reviewFavorite); + + final var expected = List.of(reviewFavorite); + + // when + final var actual = reviewFavoriteRepository.findByReview(review); + + // then + assertThat(actual).usingRecursiveComparison() + .ignoringExpectedNullFields() + .isEqualTo(expected); + } + } } diff --git a/backend/src/test/java/com/funeat/review/application/ReviewDeleteEventListenerTest.java b/backend/src/test/java/com/funeat/review/application/ReviewDeleteEventListenerTest.java new file mode 100644 index 000000000..5edf33f36 --- /dev/null +++ b/backend/src/test/java/com/funeat/review/application/ReviewDeleteEventListenerTest.java @@ -0,0 +1,217 @@ +package com.funeat.review.application; + +import static com.funeat.fixture.CategoryFixture.카테고리_즉석조리_생성; +import static com.funeat.fixture.ImageFixture.이미지_생성; +import static com.funeat.fixture.MemberFixture.멤버_멤버1_생성; +import static com.funeat.fixture.MemberFixture.멤버_멤버2_생성; +import static com.funeat.fixture.ProductFixture.상품_삼각김밥_가격1000원_평점2점_생성; +import static com.funeat.fixture.ReviewFixture.리뷰추가요청_재구매O_생성; +import static com.funeat.fixture.TagFixture.태그_맛있어요_TASTE_생성; +import static com.funeat.fixture.TagFixture.태그_아침식사_ETC_생성; +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.doThrow; +import static org.mockito.Mockito.timeout; +import static org.mockito.Mockito.verify; + +import com.funeat.common.EventTest; +import com.funeat.common.ImageUploader; +import com.funeat.common.exception.CommonException.S3DeleteFailException; +import com.funeat.exception.CommonErrorCode; +import com.funeat.tag.domain.Tag; +import java.util.List; +import java.util.stream.Collectors; +import java.util.stream.Stream; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.springframework.boot.test.mock.mockito.MockBean; + +class ReviewDeleteEventListenerTest extends EventTest { + + @MockBean + private ImageUploader uploader; + + @Nested + class 리뷰_삭제_이벤트_발행 { + + @Test + void 리뷰_작성자가_리뷰_삭제_시도시_리뷰_삭제_이벤트가_발행된다() { + // given + final var member = 멤버_멤버1_생성(); + final var memberId = 단일_멤버_저장(member); + + final var category = 카테고리_즉석조리_생성(); + 단일_카테고리_저장(category); + + final var product = 상품_삼각김밥_가격1000원_평점2점_생성(category); + final var productId = 단일_상품_저장(product); + + final var tag1 = 태그_맛있어요_TASTE_생성(); + final var tag2 = 태그_아침식사_ETC_생성(); + 복수_태그_저장(tag1, tag2); + + final var tagIds = 태그_아이디_변환(tag1, tag2); + final var image = 이미지_생성(); + + final var request = 리뷰추가요청_재구매O_생성(4L, tagIds); + reviewService.create(productId, memberId, image, request); + + final var review = reviewRepository.findAll().get(0); + final var reviewId = review.getId(); + + // when + reviewService.deleteReview(reviewId, memberId); + + // then + final var count = events.stream(ReviewDeleteEvent.class).count(); + assertThat(count).isEqualTo(1); + } + + @Test + void 리뷰_작성자가_아닌_사람이_리뷰_삭제_시도시_리뷰_삭제_이벤트가_발행되지_않는다() { + // given + final var author = 멤버_멤버2_생성(); + final var authorId = 단일_멤버_저장(author); + final var member = 멤버_멤버1_생성(); + final var memberId = 단일_멤버_저장(member); + + final var category = 카테고리_즉석조리_생성(); + 단일_카테고리_저장(category); + + final var product = 상품_삼각김밥_가격1000원_평점2점_생성(category); + final var productId = 단일_상품_저장(product); + + final var tag1 = 태그_맛있어요_TASTE_생성(); + final var tag2 = 태그_아침식사_ETC_생성(); + 복수_태그_저장(tag1, tag2); + + final var tagIds = 태그_아이디_변환(tag1, tag2); + final var image = 이미지_생성(); + + final var request = 리뷰추가요청_재구매O_생성(4L, tagIds); + reviewService.create(productId, authorId, image, request); + + final var review = reviewRepository.findAll().get(0); + final var reviewId = review.getId(); + + // when + try { + reviewService.deleteReview(reviewId, memberId); + } catch (Exception ignored) { + } + + // then + final var count = events.stream(ReviewDeleteEvent.class).count(); + assertThat(count).isEqualTo(0); + } + } + + @Nested + class 이미지_삭제_로직_작동 { + + @Test + void 리뷰_삭제가_정상적으로_커밋되고_이미지가_존재하면_이미지_삭제_로직이_작동한다() { + // given + final var member = 멤버_멤버1_생성(); + final var memberId = 단일_멤버_저장(member); + + final var category = 카테고리_즉석조리_생성(); + 단일_카테고리_저장(category); + + final var product = 상품_삼각김밥_가격1000원_평점2점_생성(category); + final var productId = 단일_상품_저장(product); + + final var tag1 = 태그_맛있어요_TASTE_생성(); + final var tag2 = 태그_아침식사_ETC_생성(); + 복수_태그_저장(tag1, tag2); + + final var tagIds = 태그_아이디_변환(tag1, tag2); + final var image = 이미지_생성(); + + final var request = 리뷰추가요청_재구매O_생성(4L, tagIds); + reviewService.create(productId, memberId, image, request); + + final var review = reviewRepository.findAll().get(0); + final var reviewId = review.getId(); + + // when + reviewService.deleteReview(reviewId, memberId); + + // then + verify(uploader, timeout(100).times(1)).delete(any()); + } + + @Test + void 리뷰_삭제가_정상적으로_커밋되었지만_이미지가_존재하지_않으면_이미지_삭제_로직이_작동하지않는다() { + // given + final var member = 멤버_멤버1_생성(); + final var memberId = 단일_멤버_저장(member); + + final var category = 카테고리_즉석조리_생성(); + 단일_카테고리_저장(category); + + final var product = 상품_삼각김밥_가격1000원_평점2점_생성(category); + final var productId = 단일_상품_저장(product); + + final var tag1 = 태그_맛있어요_TASTE_생성(); + final var tag2 = 태그_아침식사_ETC_생성(); + 복수_태그_저장(tag1, tag2); + + final var tagIds = 태그_아이디_변환(tag1, tag2); + + final var request = 리뷰추가요청_재구매O_생성(4L, tagIds); + reviewService.create(productId, memberId, null, request); + + final var review = reviewRepository.findAll().get(0); + final var reviewId = review.getId(); + + // when + reviewService.deleteReview(reviewId, memberId); + + // then + verify(uploader, timeout(100).times(0)).delete(any()); + } + + @Test + void 이미지_삭제_로직이_실패해도_메인로직까지_롤백되어서는_안된다() { + // given + final var member = 멤버_멤버1_생성(); + final var memberId = 단일_멤버_저장(member); + + final var category = 카테고리_즉석조리_생성(); + 단일_카테고리_저장(category); + + final var product = 상품_삼각김밥_가격1000원_평점2점_생성(category); + final var productId = 단일_상품_저장(product); + + final var tag1 = 태그_맛있어요_TASTE_생성(); + final var tag2 = 태그_아침식사_ETC_생성(); + 복수_태그_저장(tag1, tag2); + + final var tagIds = 태그_아이디_변환(tag1, tag2); + final var image = 이미지_생성(); + + final var request = 리뷰추가요청_재구매O_생성(4L, tagIds); + reviewService.create(productId, memberId, image, request); + + final var review = reviewRepository.findAll().get(0); + final var reviewId = review.getId(); + + doThrow(new S3DeleteFailException(CommonErrorCode.UNKNOWN_SERVER_ERROR_CODE)) + .when(uploader) + .delete(any()); + + // when + reviewService.deleteReview(reviewId, memberId); + + // then + assertThat(reviewRepository.findById(reviewId)).isEmpty(); + } + } + + private List 태그_아이디_변환(final Tag... tags) { + return Stream.of(tags) + .map(Tag::getId) + .collect(Collectors.toList()); + } +} diff --git a/backend/src/test/java/com/funeat/review/application/ReviewServiceTest.java b/backend/src/test/java/com/funeat/review/application/ReviewServiceTest.java index 00c3ed691..e6ca43b22 100644 --- a/backend/src/test/java/com/funeat/review/application/ReviewServiceTest.java +++ b/backend/src/test/java/com/funeat/review/application/ReviewServiceTest.java @@ -40,6 +40,7 @@ import com.funeat.review.domain.Review; import com.funeat.review.dto.MostFavoriteReviewResponse; import com.funeat.review.dto.SortingReviewDto; +import com.funeat.review.exception.ReviewException.NotAuthorOfReviewException; import com.funeat.review.exception.ReviewException.ReviewNotFoundException; import com.funeat.tag.domain.Tag; import java.util.List; @@ -614,7 +615,7 @@ class updateProductImage_성공_테스트 { final var expected = review.getImage(); // when - reviewService.updateProductImage(reviewId); + reviewService.updateProductImage(product.getId()); final var actual = product.getImage(); // then @@ -643,7 +644,7 @@ class updateProductImage_성공_테스트 { final var expected = secondReview.getImage(); // when - reviewService.updateProductImage(secondReviewId); + reviewService.updateProductImage(product.getId()); final var actual = product.getImage(); // then @@ -672,7 +673,7 @@ class updateProductImage_성공_테스트 { final var expected = firstReview.getImage(); // when - reviewService.updateProductImage(secondReviewId); + reviewService.updateProductImage(product.getId()); final var actual = product.getImage(); // then @@ -701,7 +702,7 @@ class updateProductImage_성공_테스트 { final var expected = secondReview.getImage(); // when - reviewService.updateProductImage(secondReviewId); + reviewService.updateProductImage(product.getId()); final var actual = product.getImage(); // then @@ -722,11 +723,11 @@ class updateProductImage_성공_테스트 { final var firstReview = 리뷰_이미지없음_평점1점_재구매O_생성(member, product, 3L); final var firstReviewId = 단일_리뷰_저장(firstReview); - reviewService.updateProductImage(firstReviewId); + reviewService.updateProductImage(product.getId()); final var secondReview = 리뷰_이미지없음_평점1점_재구매O_생성(member, product, 2L); final var secondReviewId = 단일_리뷰_저장(secondReview); - reviewService.updateProductImage(secondReviewId); + reviewService.updateProductImage(product.getId()); final var thirdReview = 리뷰_이미지test3_평점3점_재구매O_생성(member, product, 1L); final var thirdReviewId = 단일_리뷰_저장(thirdReview); @@ -734,7 +735,7 @@ class updateProductImage_성공_테스트 { final var expected = thirdReview.getImage(); // when - reviewService.updateProductImage(thirdReviewId); + reviewService.updateProductImage(product.getId()); final var actual = product.getImage(); // then @@ -755,7 +756,7 @@ class updateProductImage_성공_테스트 { final var firstReview = 리뷰_이미지없음_평점1점_재구매O_생성(member, product, 3L); final var firstReviewId = 단일_리뷰_저장(firstReview); - reviewService.updateProductImage(firstReviewId); + reviewService.updateProductImage(product.getId()); final var secondReview = 리뷰_이미지없음_평점1점_재구매O_생성(member, product, 2L); final var secondReviewId = 단일_리뷰_저장(secondReview); @@ -763,7 +764,7 @@ class updateProductImage_성공_테스트 { final var expected = secondReview.getImage(); // when - reviewService.updateProductImage(secondReviewId); + reviewService.updateProductImage(product.getId()); final var actual = product.getImage(); // then @@ -775,7 +776,7 @@ class updateProductImage_성공_테스트 { class updateProductImage_실패_테스트 { @Test - void 존재하지_않는_리뷰로_상품_업데이트를_시도하면_예외가_발생한다() { + void 존재하지_않는_상품으로_상품_업데이트를_시도하면_예외가_발생한다() { // given final var member = 멤버_멤버1_생성(); 단일_멤버_저장(member); @@ -786,13 +787,155 @@ class updateProductImage_실패_테스트 { final var product = 상품_삼각김밥_가격1000원_평점2점_생성(category); 단일_상품_저장(product); - final var review = 리뷰_이미지test1_평점1점_재구매O_생성(member, product, 0L); - final var wrongReviewId = 단일_리뷰_저장(review) + 1L; + final var wrongProductId = 999L; + + // when & then + assertThatThrownBy(() -> reviewService.updateProductImage(wrongProductId)) + .isInstanceOf(ProductNotFoundException.class); + } + } + + @Nested + class deleteReview_성공_테스트 { + + @Test + void 자신이_작성한_리뷰를_삭제할_수_있다() { + // given + final var author = 멤버_멤버1_생성(); + final var authorId = 단일_멤버_저장(author); + final var member = 멤버_멤버2_생성(); + final var memberId = 단일_멤버_저장(member); + + final var category = 카테고리_즉석조리_생성(); + 단일_카테고리_저장(category); + + final var product = 상품_삼각김밥_가격1000원_평점2점_생성(category); + final var productId = 단일_상품_저장(product); + + final var tag1 = 태그_맛있어요_TASTE_생성(); + final var tag2 = 태그_아침식사_ETC_생성(); + 복수_태그_저장(tag1, tag2); + + final var tagIds = 태그_아이디_변환(tag1, tag2); + final var image = 이미지_생성(); + final var reviewCreateRequest = 리뷰추가요청_재구매O_생성(4L, tagIds); + reviewService.create(productId, authorId, image, reviewCreateRequest); + + final var review = reviewRepository.findAll().get(0); + final var reviewId = review.getId(); + + final var favoriteRequest = 리뷰좋아요요청_생성(true); + reviewService.likeReview(reviewId, authorId, favoriteRequest); + reviewService.likeReview(reviewId, memberId, favoriteRequest); + + // when + reviewService.deleteReview(reviewId, authorId); + + // then + final var tags = reviewTagRepository.findAll(); + final var favorites = reviewFavoriteRepository.findAll(); + final var findReview = reviewRepository.findById(reviewId); + + assertSoftly(soft -> { + soft.assertThat(tags).isEmpty(); + soft.assertThat(favorites).isEmpty(); + soft.assertThat(findReview).isEmpty(); + }); + } + } + + @Nested + class deleteReview_실패_테스트 { + + @Test + void 존재하지_않는_사용자가_리뷰를_삭제하려하면_에러가_발생한다() { + // given + final var author = 멤버_멤버1_생성(); + final var authorId = 단일_멤버_저장(author); + + final var category = 카테고리_즉석조리_생성(); + 단일_카테고리_저장(category); + + final var product = 상품_삼각김밥_가격1000원_평점2점_생성(category); + final var productId = 단일_상품_저장(product); + + final var tag1 = 태그_맛있어요_TASTE_생성(); + final var tag2 = 태그_아침식사_ETC_생성(); + 복수_태그_저장(tag1, tag2); + + final var tagIds = 태그_아이디_변환(tag1, tag2); + final var image = 이미지_생성(); + final var reviewCreateRequest = 리뷰추가요청_재구매O_생성(4L, tagIds); + reviewService.create(productId, authorId, image, reviewCreateRequest); + + final var review = reviewRepository.findAll().get(0); + final var reviewId = review.getId(); + + final var wrongMemberId = 999L; + + // when & then + assertThatThrownBy(() -> reviewService.deleteReview(reviewId, wrongMemberId)) + .isInstanceOf(MemberNotFoundException.class); + } + + @Test + void 존재하지_않는_리뷰를_삭제하려하면_에러가_발생한다() { + // given + final var author = 멤버_멤버1_생성(); + final var authorId = 단일_멤버_저장(author); + + final var category = 카테고리_즉석조리_생성(); + 단일_카테고리_저장(category); + + final var product = 상품_삼각김밥_가격1000원_평점2점_생성(category); + final var productId = 단일_상품_저장(product); + + final var tag1 = 태그_맛있어요_TASTE_생성(); + final var tag2 = 태그_아침식사_ETC_생성(); + 복수_태그_저장(tag1, tag2); + + final var tagIds = 태그_아이디_변환(tag1, tag2); + final var image = 이미지_생성(); + final var reviewCreateRequest = 리뷰추가요청_재구매O_생성(4L, tagIds); + reviewService.create(productId, authorId, image, reviewCreateRequest); + + final var wrongReviewId = 999L; // when & then - assertThatThrownBy(() -> reviewService.updateProductImage(wrongReviewId)) + assertThatThrownBy(() -> reviewService.deleteReview(wrongReviewId, authorId)) .isInstanceOf(ReviewNotFoundException.class); } + + @Test + void 자신이_작성하지_않은_리뷰를_삭제하려하면_에러가_발생한다() { + // given + final var author = 멤버_멤버1_생성(); + final var authorId = 단일_멤버_저장(author); + final var member = 멤버_멤버2_생성(); + final var memberId = 단일_멤버_저장(member); + + final var category = 카테고리_즉석조리_생성(); + 단일_카테고리_저장(category); + + final var product = 상품_삼각김밥_가격1000원_평점2점_생성(category); + final var productId = 단일_상품_저장(product); + + final var tag1 = 태그_맛있어요_TASTE_생성(); + final var tag2 = 태그_아침식사_ETC_생성(); + 복수_태그_저장(tag1, tag2); + + final var tagIds = 태그_아이디_변환(tag1, tag2); + final var image = 이미지_생성(); + final var reviewCreateRequest = 리뷰추가요청_재구매O_생성(4L, tagIds); + reviewService.create(productId, authorId, image, reviewCreateRequest); + + final var review = reviewRepository.findAll().get(0); + final var reviewId = review.getId(); + + // when & then + assertThatThrownBy(() -> reviewService.deleteReview(reviewId, memberId)) + .isInstanceOf(NotAuthorOfReviewException.class); + } } @Nested diff --git a/backend/src/test/java/com/funeat/review/persistence/ReviewTagRepositoryTest.java b/backend/src/test/java/com/funeat/review/persistence/ReviewTagRepositoryTest.java index baab6abdb..4f5bbf152 100644 --- a/backend/src/test/java/com/funeat/review/persistence/ReviewTagRepositoryTest.java +++ b/backend/src/test/java/com/funeat/review/persistence/ReviewTagRepositoryTest.java @@ -72,6 +72,80 @@ class findTop3TagsByReviewIn_성공_테스트 { } } + @Nested + class deleteByReview_성공_테스트 { + + @Test + void 해당_리뷰에_달린_태그를_삭제할_수_있다() { + // given + final var member = 멤버_멤버1_생성(); + 단일_멤버_저장(member); + + final var category = 카테고리_즉석조리_생성(); + 단일_카테고리_저장(category); + + final var product = 상품_삼각김밥_가격3000원_평점2점_생성(category); + 단일_상품_저장(product); + + final var tag1 = 태그_맛있어요_TASTE_생성(); + 단일_태그_저장(tag1); + + final var review1 = 리뷰_이미지test5_평점5점_재구매O_생성(member, product, 0L); + final var review2 = 리뷰_이미지test3_평점3점_재구매X_생성(member, product, 0L); + 복수_리뷰_저장(review1, review2); + + final var reviewTag1_1 = 리뷰_태그_생성(review1, tag1); + final var reviewTag2_1 = 리뷰_태그_생성(review2, tag1); + 복수_리뷰_태그_저장(reviewTag1_1, reviewTag2_1); + + final var expected = List.of(reviewTag2_1); + + // when + reviewTagRepository.deleteByReview(review1); + + // then + final var remainings = reviewTagRepository.findAll(); + assertThat(remainings).usingRecursiveComparison() + .isEqualTo(expected); + } + } + + @Nested + class findByReview_성공_테스트 { + + @Test + void 해당_리뷰에_달린_태그를_확인할_수_있다() { + // given + final var member = 멤버_멤버1_생성(); + 단일_멤버_저장(member); + + final var category = 카테고리_즉석조리_생성(); + 단일_카테고리_저장(category); + + final var product = 상품_삼각김밥_가격3000원_평점2점_생성(category); + 단일_상품_저장(product); + + final var tag1 = 태그_맛있어요_TASTE_생성(); + 단일_태그_저장(tag1); + + final var review = 리뷰_이미지test5_평점5점_재구매O_생성(member, product, 0L); + 단일_리뷰_저장(review); + + final var reviewTag = 리뷰_태그_생성(review, tag1); + 단일_리뷰_태그_저장(reviewTag); + + final var expected = List.of(reviewTag); + + // when + final var actual = reviewTagRepository.findByReview(review); + + // then + assertThat(actual).usingRecursiveComparison() + .ignoringExpectedNullFields() + .isEqualTo(expected); + } + } + private ReviewTag 리뷰_태그_생성(final Review review, final Tag tag) { return ReviewTag.createReviewTag(review, tag); } From dab066eb4d0336baba88db38073807c8bfa5a8c8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=9A=B0=EA=B0=80?= Date: Mon, 16 Oct 2023 13:56:28 +0900 Subject: [PATCH 18/55] =?UTF-8?q?[BE]=20feat:=20=EA=BF=80=EC=A1=B0?= =?UTF-8?q?=ED=95=A9=20=EB=8C=93=EA=B8=80=20=EC=9E=91=EC=84=B1=20=EA=B8=B0?= =?UTF-8?q?=EB=8A=A5=20=EA=B5=AC=ED=98=84=20(#747)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * remove: 북마크 관련 삭제 * feat: 꿀조합 댓글 작성 구현 * refactor: Comments 단방향으로 수정 * feat: 꿀조합 댓글 조회 기능 추가 * refactor: Specification private 기본생성자 추가 * refactor: 적용된 코드 SESSION ID 수정 * refactor: 생성자 정렬 수정 * refactor: 세션 쿠키 이름 SESSION 으로 수정 * refactor: 변수명 상세하게 specification 로 수정 * refactor: repeat 사용과 디버깅 출력 코드 삭제 * remove: 디버깅 출력 코드 삭제 * refactor: subList() 와 for each 사용으로 수정 * test: 꿀조합 댓글 관련 서비스 테스트 추가 * refactor: 응답 변수명 상세하게 수정 * refactor: toResponse 맞춰서 수정 * refactor: 메소드 순서에 맞게 수정 * refactor: 리뷰 반영 * refactor: 테스트 실패 수정 --- .../AdminProductSpecification.java | 3 + .../AdminReviewSpecification.java | 3 + .../com/funeat/comment/domain/Comment.java | 59 +++++ .../persistence/CommentRepository.java | 8 + .../specification/CommentSpecification.java | 56 +++++ .../domain/bookmark/ProductBookmark.java | 28 --- .../domain/bookmark/RecipeBookmark.java | 28 --- .../ProductBookmarkRepository.java | 7 - .../persistence/RecipeBookMarkRepository.java | 7 - .../com/funeat/product/domain/Product.java | 4 - .../recipe/application/RecipeService.java | 79 ++++++- .../recipe/dto/RecipeCommentCondition.java | 20 ++ .../dto/RecipeCommentCreateRequest.java | 20 ++ .../dto/RecipeCommentMemberResponse.java | 26 +++ .../recipe/dto/RecipeCommentResponse.java | 42 ++++ .../recipe/dto/RecipeCommentsResponse.java | 34 +++ .../presentation/RecipeApiController.java | 22 ++ .../recipe/presentation/RecipeController.java | 24 ++ .../acceptance/common/AcceptanceTest.java | 4 + .../recipe/RecipeAcceptanceTest.java | 215 +++++++++++++++++- .../funeat/acceptance/recipe/RecipeSteps.java | 28 +++ .../funeat/acceptance/review/ReviewSteps.java | 1 - .../com/funeat/common/RepositoryTest.java | 8 - .../java/com/funeat/common/ServiceTest.java | 12 +- .../recipe/application/RecipeServiceTest.java | 198 +++++++++++++++- .../persistence/RecipeRepositoryTest.java | 4 +- .../review/application/ReviewServiceTest.java | 4 +- 27 files changed, 844 insertions(+), 100 deletions(-) create mode 100644 backend/src/main/java/com/funeat/comment/domain/Comment.java create mode 100644 backend/src/main/java/com/funeat/comment/persistence/CommentRepository.java create mode 100644 backend/src/main/java/com/funeat/comment/specification/CommentSpecification.java delete mode 100644 backend/src/main/java/com/funeat/member/domain/bookmark/ProductBookmark.java delete mode 100644 backend/src/main/java/com/funeat/member/domain/bookmark/RecipeBookmark.java delete mode 100644 backend/src/main/java/com/funeat/member/persistence/ProductBookmarkRepository.java delete mode 100644 backend/src/main/java/com/funeat/member/persistence/RecipeBookMarkRepository.java create mode 100644 backend/src/main/java/com/funeat/recipe/dto/RecipeCommentCondition.java create mode 100644 backend/src/main/java/com/funeat/recipe/dto/RecipeCommentCreateRequest.java create mode 100644 backend/src/main/java/com/funeat/recipe/dto/RecipeCommentMemberResponse.java create mode 100644 backend/src/main/java/com/funeat/recipe/dto/RecipeCommentResponse.java create mode 100644 backend/src/main/java/com/funeat/recipe/dto/RecipeCommentsResponse.java diff --git a/backend/src/main/java/com/funeat/admin/specification/AdminProductSpecification.java b/backend/src/main/java/com/funeat/admin/specification/AdminProductSpecification.java index a8e63b748..c48ea0305 100644 --- a/backend/src/main/java/com/funeat/admin/specification/AdminProductSpecification.java +++ b/backend/src/main/java/com/funeat/admin/specification/AdminProductSpecification.java @@ -13,6 +13,9 @@ public class AdminProductSpecification { private static final List> COUNT_RESULT_TYPES = List.of(Long.class, long.class); + private AdminProductSpecification() { + } + public static Specification searchBy(final ProductSearchCondition condition) { return (root, query, criteriaBuilder) -> { if (!COUNT_RESULT_TYPES.contains(query.getResultType())) { diff --git a/backend/src/main/java/com/funeat/admin/specification/AdminReviewSpecification.java b/backend/src/main/java/com/funeat/admin/specification/AdminReviewSpecification.java index b7c345f14..045147de5 100644 --- a/backend/src/main/java/com/funeat/admin/specification/AdminReviewSpecification.java +++ b/backend/src/main/java/com/funeat/admin/specification/AdminReviewSpecification.java @@ -11,6 +11,9 @@ public class AdminReviewSpecification { + private AdminReviewSpecification() { + } + public static Specification searchBy(final ReviewSearchCondition condition) { return (root, query, criteriaBuilder) -> { if (query.getResultType() != Long.class && query.getResultType() != long.class) { diff --git a/backend/src/main/java/com/funeat/comment/domain/Comment.java b/backend/src/main/java/com/funeat/comment/domain/Comment.java new file mode 100644 index 000000000..4e6798b9d --- /dev/null +++ b/backend/src/main/java/com/funeat/comment/domain/Comment.java @@ -0,0 +1,59 @@ +package com.funeat.comment.domain; + +import com.funeat.member.domain.Member; +import com.funeat.recipe.domain.Recipe; +import java.time.LocalDateTime; +import javax.persistence.Column; +import javax.persistence.Entity; +import javax.persistence.FetchType; +import javax.persistence.GeneratedValue; +import javax.persistence.GenerationType; +import javax.persistence.Id; +import javax.persistence.JoinColumn; +import javax.persistence.ManyToOne; + +@Entity +public class Comment { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + private String comment; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "recipe_id") + private Recipe recipe; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "member_id") + private Member member; + + @Column(nullable = false) + private LocalDateTime createdAt = LocalDateTime.now(); + + protected Comment() { + } + + public Comment(final Recipe recipe, final Member member, final String comment) { + this.recipe = recipe; + this.member = member; + this.comment = comment; + } + + public Long getId() { + return id; + } + + public String getComment() { + return comment; + } + + public Member getMember() { + return member; + } + + public LocalDateTime getCreatedAt() { + return createdAt; + } +} diff --git a/backend/src/main/java/com/funeat/comment/persistence/CommentRepository.java b/backend/src/main/java/com/funeat/comment/persistence/CommentRepository.java new file mode 100644 index 000000000..e40a47f67 --- /dev/null +++ b/backend/src/main/java/com/funeat/comment/persistence/CommentRepository.java @@ -0,0 +1,8 @@ +package com.funeat.comment.persistence; + +import com.funeat.comment.domain.Comment; +import com.funeat.common.repository.BaseRepository; +import org.springframework.data.jpa.repository.JpaRepository; + +public interface CommentRepository extends JpaRepository, BaseRepository { +} diff --git a/backend/src/main/java/com/funeat/comment/specification/CommentSpecification.java b/backend/src/main/java/com/funeat/comment/specification/CommentSpecification.java new file mode 100644 index 000000000..db6c734bb --- /dev/null +++ b/backend/src/main/java/com/funeat/comment/specification/CommentSpecification.java @@ -0,0 +1,56 @@ +package com.funeat.comment.specification; + +import com.funeat.comment.domain.Comment; +import com.funeat.recipe.domain.Recipe; +import java.util.List; +import java.util.Objects; +import javax.persistence.criteria.JoinType; +import javax.persistence.criteria.Path; +import org.springframework.data.jpa.domain.Specification; + +public class CommentSpecification { + + private CommentSpecification() { + } + + private static final List> COUNT_RESULT_TYPES = List.of(Long.class, long.class); + + public static Specification findAllByRecipe(final Recipe recipe, final Long lastCommentId) { + return (root, query, criteriaBuilder) -> { + if (!COUNT_RESULT_TYPES.contains(query.getResultType())) { + root.fetch("member", JoinType.LEFT); + } + + criteriaBuilder.desc(root.get("id")); + + return Specification + .where(lessThan(lastCommentId)) + .and(equalToRecipe(recipe)) + .toPredicate(root, query, criteriaBuilder); + }; + } + + private static Specification lessThan(final Long commentId) { + return (root, query, criteriaBuilder) -> { + if (Objects.isNull(commentId)) { + return null; + } + + final Path commentIdPath = root.get("id"); + + return criteriaBuilder.lessThan(commentIdPath, commentId); + }; + } + + private static Specification equalToRecipe(final Recipe recipe) { + return (root, query, criteriaBuilder) -> { + if (Objects.isNull(recipe)) { + return null; + } + + final Path recipePath = root.get("recipe"); + + return criteriaBuilder.equal(recipePath, recipe); + }; + } +} diff --git a/backend/src/main/java/com/funeat/member/domain/bookmark/ProductBookmark.java b/backend/src/main/java/com/funeat/member/domain/bookmark/ProductBookmark.java deleted file mode 100644 index c18c84b59..000000000 --- a/backend/src/main/java/com/funeat/member/domain/bookmark/ProductBookmark.java +++ /dev/null @@ -1,28 +0,0 @@ -package com.funeat.member.domain.bookmark; - -import com.funeat.member.domain.Member; -import com.funeat.product.domain.Product; -import javax.persistence.Entity; -import javax.persistence.GeneratedValue; -import javax.persistence.GenerationType; -import javax.persistence.Id; -import javax.persistence.JoinColumn; -import javax.persistence.ManyToOne; - -@Entity -public class ProductBookmark { - - @Id - @GeneratedValue(strategy = GenerationType.IDENTITY) - private Long id; - - @ManyToOne - @JoinColumn(name = "member_id") - private Member member; - - @ManyToOne - @JoinColumn(name = "product_id") - private Product product; - - private Boolean checked; -} diff --git a/backend/src/main/java/com/funeat/member/domain/bookmark/RecipeBookmark.java b/backend/src/main/java/com/funeat/member/domain/bookmark/RecipeBookmark.java deleted file mode 100644 index 9dc0b75ad..000000000 --- a/backend/src/main/java/com/funeat/member/domain/bookmark/RecipeBookmark.java +++ /dev/null @@ -1,28 +0,0 @@ -package com.funeat.member.domain.bookmark; - -import com.funeat.member.domain.Member; -import com.funeat.recipe.domain.Recipe; -import javax.persistence.Entity; -import javax.persistence.GeneratedValue; -import javax.persistence.GenerationType; -import javax.persistence.Id; -import javax.persistence.JoinColumn; -import javax.persistence.ManyToOne; - -@Entity -public class RecipeBookmark { - - @Id - @GeneratedValue(strategy = GenerationType.IDENTITY) - private Long id; - - @ManyToOne - @JoinColumn(name = "member_id") - private Member member; - - @ManyToOne - @JoinColumn(name = "recipe_id") - private Recipe recipe; - - private Boolean checked; -} diff --git a/backend/src/main/java/com/funeat/member/persistence/ProductBookmarkRepository.java b/backend/src/main/java/com/funeat/member/persistence/ProductBookmarkRepository.java deleted file mode 100644 index c7651b592..000000000 --- a/backend/src/main/java/com/funeat/member/persistence/ProductBookmarkRepository.java +++ /dev/null @@ -1,7 +0,0 @@ -package com.funeat.member.persistence; - -import com.funeat.member.domain.bookmark.ProductBookmark; -import org.springframework.data.jpa.repository.JpaRepository; - -public interface ProductBookmarkRepository extends JpaRepository { -} diff --git a/backend/src/main/java/com/funeat/member/persistence/RecipeBookMarkRepository.java b/backend/src/main/java/com/funeat/member/persistence/RecipeBookMarkRepository.java deleted file mode 100644 index 4ed5cce46..000000000 --- a/backend/src/main/java/com/funeat/member/persistence/RecipeBookMarkRepository.java +++ /dev/null @@ -1,7 +0,0 @@ -package com.funeat.member.persistence; - -import com.funeat.member.domain.bookmark.RecipeBookmark; -import org.springframework.data.jpa.repository.JpaRepository; - -public interface RecipeBookMarkRepository extends JpaRepository { -} diff --git a/backend/src/main/java/com/funeat/product/domain/Product.java b/backend/src/main/java/com/funeat/product/domain/Product.java index a485eaf55..512f77f8f 100644 --- a/backend/src/main/java/com/funeat/product/domain/Product.java +++ b/backend/src/main/java/com/funeat/product/domain/Product.java @@ -1,6 +1,5 @@ package com.funeat.product.domain; -import com.funeat.member.domain.bookmark.ProductBookmark; import com.funeat.review.domain.Review; import java.util.List; import javax.persistence.Entity; @@ -39,9 +38,6 @@ public class Product { @OneToMany(mappedBy = "product") private List productRecipes; - @OneToMany(mappedBy = "product") - private List productBookmarks; - private Long reviewCount = 0L; protected Product() { diff --git a/backend/src/main/java/com/funeat/recipe/application/RecipeService.java b/backend/src/main/java/com/funeat/recipe/application/RecipeService.java index d67fd7b79..d9ede67c3 100644 --- a/backend/src/main/java/com/funeat/recipe/application/RecipeService.java +++ b/backend/src/main/java/com/funeat/recipe/application/RecipeService.java @@ -5,6 +5,9 @@ import static com.funeat.product.exception.ProductErrorCode.PRODUCT_NOT_FOUND; import static com.funeat.recipe.exception.RecipeErrorCode.RECIPE_NOT_FOUND; +import com.funeat.comment.domain.Comment; +import com.funeat.comment.persistence.CommentRepository; +import com.funeat.comment.specification.CommentSpecification; import com.funeat.common.ImageUploader; import com.funeat.common.dto.PageDto; import com.funeat.member.domain.Member; @@ -26,6 +29,10 @@ import com.funeat.recipe.dto.RankingRecipeDto; import com.funeat.recipe.dto.RankingRecipesResponse; import com.funeat.recipe.dto.RecipeAuthorDto; +import com.funeat.recipe.dto.RecipeCommentCondition; +import com.funeat.recipe.dto.RecipeCommentCreateRequest; +import com.funeat.recipe.dto.RecipeCommentResponse; +import com.funeat.recipe.dto.RecipeCommentsResponse; import com.funeat.recipe.dto.RecipeCreateRequest; import com.funeat.recipe.dto.RecipeDetailResponse; import com.funeat.recipe.dto.RecipeDto; @@ -36,6 +43,7 @@ import com.funeat.recipe.exception.RecipeException.RecipeNotFoundException; import com.funeat.recipe.persistence.RecipeImageRepository; import com.funeat.recipe.persistence.RecipeRepository; +import java.util.ArrayList; import java.util.List; import java.util.Objects; import java.util.stream.Collectors; @@ -43,6 +51,8 @@ import org.springframework.data.domain.Page; import org.springframework.data.domain.PageRequest; import org.springframework.data.domain.Pageable; +import org.springframework.data.domain.Sort; +import org.springframework.data.jpa.domain.Specification; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; import org.springframework.web.multipart.MultipartFile; @@ -53,6 +63,8 @@ public class RecipeService { private static final int THREE = 3; private static final int TOP = 0; + private static final int RECIPE_COMMENT_PAGE_SIZE = 10; + private static final int DEFAULT_CURSOR_PAGINATION_SIZE = 11; private final MemberRepository memberRepository; private final ProductRepository productRepository; @@ -60,18 +72,21 @@ public class RecipeService { private final RecipeRepository recipeRepository; private final RecipeImageRepository recipeImageRepository; private final RecipeFavoriteRepository recipeFavoriteRepository; + private final CommentRepository commentRepository; private final ImageUploader imageUploader; public RecipeService(final MemberRepository memberRepository, final ProductRepository productRepository, final ProductRecipeRepository productRecipeRepository, final RecipeRepository recipeRepository, final RecipeImageRepository recipeImageRepository, - final RecipeFavoriteRepository recipeFavoriteRepository, final ImageUploader imageUploader) { + final RecipeFavoriteRepository recipeFavoriteRepository, + final CommentRepository commentRepository, final ImageUploader imageUploader) { this.memberRepository = memberRepository; this.productRepository = productRepository; this.productRecipeRepository = productRecipeRepository; this.recipeRepository = recipeRepository; this.recipeImageRepository = recipeImageRepository; this.recipeFavoriteRepository = recipeFavoriteRepository; + this.commentRepository = commentRepository; this.imageUploader = imageUploader; } @@ -166,7 +181,8 @@ public void likeRecipe(final Long memberId, final Long recipeId, final RecipeFav recipeFavorite.updateFavorite(request.getFavorite()); } - private RecipeFavorite createAndSaveRecipeFavorite(final Member member, final Recipe recipe, final Boolean favorite) { + private RecipeFavorite createAndSaveRecipeFavorite(final Member member, final Recipe recipe, + final Boolean favorite) { try { final RecipeFavorite recipeFavorite = RecipeFavorite.create(member, recipe, favorite); return recipeFavoriteRepository.save(recipeFavorite); @@ -201,4 +217,63 @@ public RankingRecipesResponse getTop3Recipes() { .collect(Collectors.toList()); return RankingRecipesResponse.toResponse(dtos); } + + @Transactional + public Long writeCommentOfRecipe(final Long memberId, final Long recipeId, + final RecipeCommentCreateRequest request) { + final Member findMember = memberRepository.findById(memberId) + .orElseThrow(() -> new MemberNotFoundException(MEMBER_NOT_FOUND, memberId)); + + final Recipe findRecipe = recipeRepository.findById(recipeId) + .orElseThrow(() -> new RecipeNotFoundException(RECIPE_NOT_FOUND, recipeId)); + + final Comment comment = new Comment(findRecipe, findMember, request.getComment()); + + final Comment savedComment = commentRepository.save(comment); + return savedComment.getId(); + } + + public RecipeCommentsResponse getCommentsOfRecipe(final Long recipeId, final RecipeCommentCondition condition) { + final Recipe findRecipe = recipeRepository.findById(recipeId) + .orElseThrow(() -> new RecipeNotFoundException(RECIPE_NOT_FOUND, recipeId)); + + final Specification specification = CommentSpecification.findAllByRecipe(findRecipe, + condition.getLastId()); + + final PageRequest pageable = PageRequest.of(0, DEFAULT_CURSOR_PAGINATION_SIZE, Sort.by("id").descending()); + + final Page commentPaginationResult = commentRepository.findAllForPagination(specification, pageable, + condition.getTotalElements()); + + final List recipeCommentResponses = getRecipeCommentResponses( + commentPaginationResult.getContent()); + + final Boolean hasNext = hasNextPage(commentPaginationResult); + + return RecipeCommentsResponse.toResponse(recipeCommentResponses, hasNext, + commentPaginationResult.getTotalElements()); + } + + private List getRecipeCommentResponses(final List findComments) { + final List recipeCommentResponses = new ArrayList<>(); + final int resultSize = getResultSize(findComments); + final List comments = findComments.subList(0, resultSize); + + for (final Comment comment : comments) { + final RecipeCommentResponse recipeCommentResponse = RecipeCommentResponse.toResponse(comment); + recipeCommentResponses.add(recipeCommentResponse); + } + return recipeCommentResponses; + } + + private int getResultSize(final List findComments) { + if (findComments.size() < DEFAULT_CURSOR_PAGINATION_SIZE) { + return findComments.size(); + } + return RECIPE_COMMENT_PAGE_SIZE; + } + + private Boolean hasNextPage(final Page findComments) { + return findComments.getContent().size() > RECIPE_COMMENT_PAGE_SIZE; + } } diff --git a/backend/src/main/java/com/funeat/recipe/dto/RecipeCommentCondition.java b/backend/src/main/java/com/funeat/recipe/dto/RecipeCommentCondition.java new file mode 100644 index 000000000..dcb3cf2d1 --- /dev/null +++ b/backend/src/main/java/com/funeat/recipe/dto/RecipeCommentCondition.java @@ -0,0 +1,20 @@ +package com.funeat.recipe.dto; + +public class RecipeCommentCondition { + + private final Long lastId; + private final Long totalElements; + + public RecipeCommentCondition(final Long lastId, final Long totalElements) { + this.lastId = lastId; + this.totalElements = totalElements; + } + + public Long getLastId() { + return lastId; + } + + public Long getTotalElements() { + return totalElements; + } +} diff --git a/backend/src/main/java/com/funeat/recipe/dto/RecipeCommentCreateRequest.java b/backend/src/main/java/com/funeat/recipe/dto/RecipeCommentCreateRequest.java new file mode 100644 index 000000000..2b24e9207 --- /dev/null +++ b/backend/src/main/java/com/funeat/recipe/dto/RecipeCommentCreateRequest.java @@ -0,0 +1,20 @@ +package com.funeat.recipe.dto; + +import com.fasterxml.jackson.annotation.JsonProperty; +import javax.validation.constraints.NotBlank; +import javax.validation.constraints.Size; + +public class RecipeCommentCreateRequest { + + @NotBlank(message = "꿀조합 댓글을 확인해 주세요") + @Size(max = 200, message = "꿀조합 댓글은 최대 200자까지 입력 가능합니다") + private final String comment; + + public RecipeCommentCreateRequest(@JsonProperty("comment") final String comment) { + this.comment = comment; + } + + public String getComment() { + return comment; + } +} diff --git a/backend/src/main/java/com/funeat/recipe/dto/RecipeCommentMemberResponse.java b/backend/src/main/java/com/funeat/recipe/dto/RecipeCommentMemberResponse.java new file mode 100644 index 000000000..ad66d7811 --- /dev/null +++ b/backend/src/main/java/com/funeat/recipe/dto/RecipeCommentMemberResponse.java @@ -0,0 +1,26 @@ +package com.funeat.recipe.dto; + +import com.funeat.member.domain.Member; + +public class RecipeCommentMemberResponse { + + private final String nickname; + private final String profileImage; + + private RecipeCommentMemberResponse(final String nickname, final String profileImage) { + this.nickname = nickname; + this.profileImage = profileImage; + } + + public static RecipeCommentMemberResponse toResponse(final Member member) { + return new RecipeCommentMemberResponse(member.getNickname(), member.getProfileImage()); + } + + public String getNickname() { + return nickname; + } + + public String getProfileImage() { + return profileImage; + } +} diff --git a/backend/src/main/java/com/funeat/recipe/dto/RecipeCommentResponse.java b/backend/src/main/java/com/funeat/recipe/dto/RecipeCommentResponse.java new file mode 100644 index 000000000..989e52bd5 --- /dev/null +++ b/backend/src/main/java/com/funeat/recipe/dto/RecipeCommentResponse.java @@ -0,0 +1,42 @@ +package com.funeat.recipe.dto; + +import com.funeat.comment.domain.Comment; +import java.time.LocalDateTime; + +public class RecipeCommentResponse { + + private final Long id; + private final String comment; + private final LocalDateTime createdAt; + private final RecipeCommentMemberResponse author; + + private RecipeCommentResponse(final Long id, final String comment, final LocalDateTime createdAt, + final RecipeCommentMemberResponse author) { + this.id = id; + this.comment = comment; + this.createdAt = createdAt; + this.author = author; + } + + public static RecipeCommentResponse toResponse(final Comment comment) { + final RecipeCommentMemberResponse author = RecipeCommentMemberResponse.toResponse(comment.getMember()); + + return new RecipeCommentResponse(comment.getId(), comment.getComment(), comment.getCreatedAt(), author); + } + + public Long getId() { + return id; + } + + public String getComment() { + return comment; + } + + public LocalDateTime getCreatedAt() { + return createdAt; + } + + public RecipeCommentMemberResponse getAuthor() { + return author; + } +} diff --git a/backend/src/main/java/com/funeat/recipe/dto/RecipeCommentsResponse.java b/backend/src/main/java/com/funeat/recipe/dto/RecipeCommentsResponse.java new file mode 100644 index 000000000..7e7d6dc19 --- /dev/null +++ b/backend/src/main/java/com/funeat/recipe/dto/RecipeCommentsResponse.java @@ -0,0 +1,34 @@ +package com.funeat.recipe.dto; + +import java.util.List; + +public class RecipeCommentsResponse { + + private final List comments; + private final boolean hasNext; + private final Long totalElements; + + private RecipeCommentsResponse(final List comments, final boolean hasNext, + final Long totalElements) { + this.comments = comments; + this.hasNext = hasNext; + this.totalElements = totalElements; + } + + public static RecipeCommentsResponse toResponse(final List comments, final boolean hasNext, + final Long totalElements) { + return new RecipeCommentsResponse(comments, hasNext, totalElements); + } + + public List getComments() { + return comments; + } + + public boolean getHasNext() { + return hasNext; + } + + public Long getTotalElements() { + return totalElements; + } +} diff --git a/backend/src/main/java/com/funeat/recipe/presentation/RecipeApiController.java b/backend/src/main/java/com/funeat/recipe/presentation/RecipeApiController.java index 8406c1645..17eb1f1d6 100644 --- a/backend/src/main/java/com/funeat/recipe/presentation/RecipeApiController.java +++ b/backend/src/main/java/com/funeat/recipe/presentation/RecipeApiController.java @@ -5,6 +5,9 @@ import com.funeat.common.logging.Logging; import com.funeat.recipe.application.RecipeService; import com.funeat.recipe.dto.RankingRecipesResponse; +import com.funeat.recipe.dto.RecipeCommentCondition; +import com.funeat.recipe.dto.RecipeCommentCreateRequest; +import com.funeat.recipe.dto.RecipeCommentsResponse; import com.funeat.recipe.dto.RecipeCreateRequest; import com.funeat.recipe.dto.RecipeDetailResponse; import com.funeat.recipe.dto.RecipeFavoriteRequest; @@ -19,6 +22,7 @@ import org.springframework.http.MediaType; import org.springframework.http.ResponseEntity; import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.ModelAttribute; import org.springframework.web.bind.annotation.PatchMapping; import org.springframework.web.bind.annotation.PathVariable; import org.springframework.web.bind.annotation.PostMapping; @@ -88,4 +92,22 @@ public ResponseEntity getSearchResults(@RequestPara return ResponseEntity.ok(response); } + + @PostMapping("/api/recipes/{recipeId}/comments") + public ResponseEntity writeComment(@AuthenticationPrincipal final LoginInfo loginInfo, + @PathVariable final Long recipeId, + @RequestBody @Valid final RecipeCommentCreateRequest request) { + final Long savedCommentId = recipeService.writeCommentOfRecipe(loginInfo.getId(), recipeId, request); + + return ResponseEntity.created(URI.create("/api/recipes/" + recipeId + "/" + savedCommentId)).build(); + } + + @GetMapping("/api/recipes/{recipeId}/comments") + public ResponseEntity getCommentsOfRecipe( + @AuthenticationPrincipal final LoginInfo loginInfo, @PathVariable final Long recipeId, + @ModelAttribute final RecipeCommentCondition condition) { + final RecipeCommentsResponse response = recipeService.getCommentsOfRecipe(recipeId, condition); + + return ResponseEntity.ok(response); + } } diff --git a/backend/src/main/java/com/funeat/recipe/presentation/RecipeController.java b/backend/src/main/java/com/funeat/recipe/presentation/RecipeController.java index 013c559cd..05602cc7f 100644 --- a/backend/src/main/java/com/funeat/recipe/presentation/RecipeController.java +++ b/backend/src/main/java/com/funeat/recipe/presentation/RecipeController.java @@ -3,6 +3,9 @@ import com.funeat.auth.dto.LoginInfo; import com.funeat.auth.util.AuthenticationPrincipal; import com.funeat.recipe.dto.RankingRecipesResponse; +import com.funeat.recipe.dto.RecipeCommentCondition; +import com.funeat.recipe.dto.RecipeCommentCreateRequest; +import com.funeat.recipe.dto.RecipeCommentsResponse; import com.funeat.recipe.dto.RecipeCreateRequest; import com.funeat.recipe.dto.RecipeDetailResponse; import com.funeat.recipe.dto.RecipeFavoriteRequest; @@ -16,6 +19,7 @@ import org.springframework.data.web.PageableDefault; import org.springframework.http.ResponseEntity; import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.ModelAttribute; import org.springframework.web.bind.annotation.PatchMapping; import org.springframework.web.bind.annotation.PathVariable; import org.springframework.web.bind.annotation.PostMapping; @@ -80,4 +84,24 @@ ResponseEntity likeRecipe(@AuthenticationPrincipal final LoginInfo loginIn @GetMapping ResponseEntity getSearchResults(@RequestParam final String query, @PageableDefault final Pageable pageable); + + @Operation(summary = "꿀조합 댓글 작성", description = "꿀조합 상세에서 댓글을 작성한다.") + @ApiResponse( + responseCode = "201", + description = "꿀조합 댓글 작성 성공." + ) + @PostMapping("/api/recipes/{recipeId}/comments") + ResponseEntity writeComment(@AuthenticationPrincipal final LoginInfo loginInfo, + @PathVariable final Long recipeId, + @RequestBody final RecipeCommentCreateRequest request); + + @Operation(summary = "꿀조합 댓글 조회", description = "꿀조합 상세에서 댓글을 조회한다.") + @ApiResponse( + responseCode = "200", + description = "꿀조합 댓글 조회 성공." + ) + @GetMapping("/api/recipes/{recipeId}/comments") + ResponseEntity getCommentsOfRecipe(@AuthenticationPrincipal final LoginInfo loginInfo, + @PathVariable final Long recipeId, + @ModelAttribute final RecipeCommentCondition condition); } diff --git a/backend/src/test/java/com/funeat/acceptance/common/AcceptanceTest.java b/backend/src/test/java/com/funeat/acceptance/common/AcceptanceTest.java index 17c6d725b..128490663 100644 --- a/backend/src/test/java/com/funeat/acceptance/common/AcceptanceTest.java +++ b/backend/src/test/java/com/funeat/acceptance/common/AcceptanceTest.java @@ -1,5 +1,6 @@ package com.funeat.acceptance.common; +import com.funeat.comment.persistence.CommentRepository; import com.funeat.common.DataClearExtension; import com.funeat.member.domain.Member; import com.funeat.member.persistence.MemberRepository; @@ -68,6 +69,9 @@ public abstract class AcceptanceTest { @Autowired public RecipeFavoriteRepository recipeFavoriteRepository; + @Autowired + protected CommentRepository commentRepository; + @BeforeEach void setUp() { RestAssured.port = port; diff --git a/backend/src/test/java/com/funeat/acceptance/recipe/RecipeAcceptanceTest.java b/backend/src/test/java/com/funeat/acceptance/recipe/RecipeAcceptanceTest.java index af0b74801..bb388ea78 100644 --- a/backend/src/test/java/com/funeat/acceptance/recipe/RecipeAcceptanceTest.java +++ b/backend/src/test/java/com/funeat/acceptance/recipe/RecipeAcceptanceTest.java @@ -11,6 +11,8 @@ import static com.funeat.acceptance.common.CommonSteps.찾을수_없음; import static com.funeat.acceptance.common.CommonSteps.페이지를_검증한다; import static com.funeat.acceptance.recipe.RecipeSteps.레시피_검색_결과_조회_요청; +import static com.funeat.acceptance.recipe.RecipeSteps.레시피_댓글_작성_요청; +import static com.funeat.acceptance.recipe.RecipeSteps.레시피_댓글_조회_요청; import static com.funeat.acceptance.recipe.RecipeSteps.레시피_랭킹_조회_요청; import static com.funeat.acceptance.recipe.RecipeSteps.레시피_목록_요청; import static com.funeat.acceptance.recipe.RecipeSteps.레시피_상세_정보_요청; @@ -59,12 +61,14 @@ import static org.assertj.core.api.SoftAssertions.assertSoftly; import com.funeat.acceptance.common.AcceptanceTest; -import com.funeat.common.dto.PageDto; import com.funeat.member.domain.Member; import com.funeat.recipe.domain.Recipe; import com.funeat.recipe.dto.ProductRecipeDto; import com.funeat.recipe.dto.RankingRecipeDto; import com.funeat.recipe.dto.RecipeAuthorDto; +import com.funeat.recipe.dto.RecipeCommentCondition; +import com.funeat.recipe.dto.RecipeCommentCreateRequest; +import com.funeat.recipe.dto.RecipeCommentResponse; import com.funeat.recipe.dto.RecipeCreateRequest; import com.funeat.recipe.dto.RecipeDetailResponse; import com.funeat.recipe.dto.RecipeDto; @@ -532,6 +536,189 @@ class getRankingRecipes_성공_테스트 { } } + @Nested + class writeRecipeComment_성공_테스트 { + + @Test + void 꿀조합에_댓글을_작성할_수_있다() { + // given + final var 카테고리 = 카테고리_간편식사_생성(); + 단일_카테고리_저장(카테고리); + final var 상품 = 단일_상품_저장(상품_삼각김밥_가격1000원_평점5점_생성(카테고리)); + final var 꿀조합_작성_응답 = 레시피_작성_요청(로그인_쿠키_획득(멤버1), 여러개_사진_명세_요청(이미지1), 레시피추가요청_생성(상품)); + + // when + final var 작성된_꿀조합_아이디 = 작성된_꿀조합_아이디_추출(꿀조합_작성_응답); + final var 댓글작성자_로그인_쿠키_획득 = 로그인_쿠키_획득(멤버2); + final var 꿀조합_댓글 = new RecipeCommentCreateRequest("테스트 코멘트 1"); + + final var 응답 = 레시피_댓글_작성_요청(댓글작성자_로그인_쿠키_획득, 작성된_꿀조합_아이디, 꿀조합_댓글); + + // then + STATUS_CODE를_검증한다(응답, 정상_생성); + 꿀조합_댓글_작성_결과를_검증한다(응답, 멤버2, 꿀조합_댓글); + } + } + + @Nested + class writeRecipeComment_실패_테스트 { + + @ParameterizedTest + @NullAndEmptySource + void 꿀조합에_댓글을_작성할때_댓글이_비어있을시_예외가_발생한다(final String comment) { + // given + final var 카테고리 = 카테고리_간편식사_생성(); + 단일_카테고리_저장(카테고리); + final var 상품 = 단일_상품_저장(상품_삼각김밥_가격1000원_평점5점_생성(카테고리)); + final var 꿀조합_작성_응답 = 레시피_작성_요청(로그인_쿠키_획득(멤버1), 여러개_사진_명세_요청(이미지1), 레시피추가요청_생성(상품)); + + // when + final var 작성된_꿀조합_아이디 = 작성된_꿀조합_아이디_추출(꿀조합_작성_응답); + final var 댓글작성자_로그인_쿠키_획득 = 로그인_쿠키_획득(멤버2); + final var 꿀조합_댓글 = new RecipeCommentCreateRequest(comment); + + final var 레시피_댓글_작성_요청 = 레시피_댓글_작성_요청(댓글작성자_로그인_쿠키_획득, 작성된_꿀조합_아이디, 꿀조합_댓글); + + // then + STATUS_CODE를_검증한다(레시피_댓글_작성_요청, 잘못된_요청); + RESPONSE_CODE와_MESSAGE를_검증한다(레시피_댓글_작성_요청, REQUEST_VALID_ERROR_CODE.getCode(), + "꿀조합 댓글을 확인해 주세요. " + REQUEST_VALID_ERROR_CODE.getMessage()); + } + + @Test + void 꿀조합에_댓글을_작성할때_댓글이_200자_초과시_예외가_발생한다() { + // given + final var 카테고리 = 카테고리_간편식사_생성(); + 단일_카테고리_저장(카테고리); + final var 상품 = 단일_상품_저장(상품_삼각김밥_가격1000원_평점5점_생성(카테고리)); + final var 꿀조합_작성_응답 = 레시피_작성_요청(로그인_쿠키_획득(멤버1), 여러개_사진_명세_요청(이미지1), 레시피추가요청_생성(상품)); + + // when + final var 작성된_꿀조합_아이디 = 작성된_꿀조합_아이디_추출(꿀조합_작성_응답); + final var 댓글작성자_로그인_쿠키_획득 = 로그인_쿠키_획득(멤버2); + final var 꿀조합_댓글 = new RecipeCommentCreateRequest("1" + "댓글입니다".repeat(40)); + + final var 응답 = 레시피_댓글_작성_요청(댓글작성자_로그인_쿠키_획득, 작성된_꿀조합_아이디, 꿀조합_댓글); + + // then + STATUS_CODE를_검증한다(응답, 잘못된_요청); + RESPONSE_CODE와_MESSAGE를_검증한다(응답, REQUEST_VALID_ERROR_CODE.getCode(), + "꿀조합 댓글은 최대 200자까지 입력 가능합니다. " + REQUEST_VALID_ERROR_CODE.getMessage()); + } + + @ParameterizedTest + @NullAndEmptySource + void 로그인_하지않은_사용자가_꿀조합_댓글_작성시_예외가_발생한다(final String cookie) { + // given + final var 카테고리 = 카테고리_간편식사_생성(); + 단일_카테고리_저장(카테고리); + final var 상품 = 단일_상품_저장(상품_삼각김밥_가격1000원_평점5점_생성(카테고리)); + final var 꿀조합_작성_응답 = 레시피_작성_요청(로그인_쿠키_획득(멤버1), 여러개_사진_명세_요청(이미지1), 레시피추가요청_생성(상품)); + + // when + final var 작성된_꿀조합_아이디 = 작성된_꿀조합_아이디_추출(꿀조합_작성_응답); + final var 꿀조합_댓글 = new RecipeCommentCreateRequest("테스트 코멘트 1"); + + final var 응답 = 레시피_댓글_작성_요청(cookie, 작성된_꿀조합_아이디, 꿀조합_댓글); + + // then + STATUS_CODE를_검증한다(응답, 인증되지_않음); + RESPONSE_CODE와_MESSAGE를_검증한다(응답, LOGIN_MEMBER_NOT_FOUND.getCode(), + LOGIN_MEMBER_NOT_FOUND.getMessage()); + } + } + + @Nested + class getRecipeComment_성공_테스트 { + + @Test + void 꿀조합에_댓글을_조회할_수_있다() { + // given + final var 카테고리 = 카테고리_간편식사_생성(); + 단일_카테고리_저장(카테고리); + final var 상품 = 단일_상품_저장(상품_삼각김밥_가격1000원_평점5점_생성(카테고리)); + final var 꿀조합_작성_응답 = 레시피_작성_요청(로그인_쿠키_획득(멤버1), 여러개_사진_명세_요청(이미지1), 레시피추가요청_생성(상품)); + + final var 작성된_꿀조합_아이디 = 작성된_꿀조합_아이디_추출(꿀조합_작성_응답); + final var 댓글작성자_로그인_쿠키_획득 = 로그인_쿠키_획득(멤버2); + + for (int i = 1; i <= 15; i++) { + 레시피_댓글_작성_요청(댓글작성자_로그인_쿠키_획득, 작성된_꿀조합_아이디, + new RecipeCommentCreateRequest("테스트 코멘트" + i)); + } + + // when + final var 응답 = 레시피_댓글_조회_요청(로그인_쿠키_획득(멤버1), 작성된_꿀조합_아이디, + new RecipeCommentCondition(null, null)); + + // then + final var expectedSize = 10; + final var expectedHasNext = true; + + STATUS_CODE를_검증한다(응답, 정상_처리); + 레시피_댓글_조회_결과를_검증한다(응답, expectedSize, expectedHasNext); + } + + @Test + void 꿀조합에_댓글을_마지막_페이지를_조회할_수_있다() { + // given + final var 카테고리 = 카테고리_간편식사_생성(); + 단일_카테고리_저장(카테고리); + final var 상품 = 단일_상품_저장(상품_삼각김밥_가격1000원_평점5점_생성(카테고리)); + final var 꿀조합_작성_응답 = 레시피_작성_요청(로그인_쿠키_획득(멤버1), 여러개_사진_명세_요청(이미지1), 레시피추가요청_생성(상품)); + + final var 작성된_꿀조합_아이디 = 작성된_꿀조합_아이디_추출(꿀조합_작성_응답); + final var 댓글작성자_로그인_쿠키_획득 = 로그인_쿠키_획득(멤버2); + + final var totalElements = 15L; + final var lastId = 6L; + + for (int i = 1; i <= totalElements; i++) { + 레시피_댓글_작성_요청(댓글작성자_로그인_쿠키_획득, 작성된_꿀조합_아이디, + new RecipeCommentCreateRequest("테스트 코멘트" + i)); + } + + // when + final var 응답 = 레시피_댓글_조회_요청(로그인_쿠키_획득(멤버1), 작성된_꿀조합_아이디, new RecipeCommentCondition(lastId, totalElements)); + + // then + final var expectedSize = 5; + final var expectedHasNext = false; + + STATUS_CODE를_검증한다(응답, 정상_처리); + 레시피_댓글_조회_결과를_검증한다(응답, expectedSize, expectedHasNext); + } + } + + @Nested + class getRecipeComment_실패_테스트 { + + @ParameterizedTest + @NullAndEmptySource + void 로그인_하지않은_사용자가_꿀조합_댓글_조회시_예외가_발생한다(final String cookie) { + // given + final var 카테고리 = 카테고리_간편식사_생성(); + 단일_카테고리_저장(카테고리); + final var 상품 = 단일_상품_저장(상품_삼각김밥_가격1000원_평점5점_생성(카테고리)); + final var 꿀조합_작성_응답 = 레시피_작성_요청(로그인_쿠키_획득(멤버1), 여러개_사진_명세_요청(이미지1), 레시피추가요청_생성(상품)); + + final var 작성된_꿀조합_아이디 = 작성된_꿀조합_아이디_추출(꿀조합_작성_응답); + final var 댓글작성자_로그인_쿠키_획득 = 로그인_쿠키_획득(멤버2); + final var 꿀조합_댓글 = new RecipeCommentCreateRequest("테스트 코멘트"); + + 레시피_댓글_작성_요청(댓글작성자_로그인_쿠키_획득, 작성된_꿀조합_아이디, 꿀조합_댓글); + + // when + final var 응답 = 레시피_댓글_조회_요청(cookie, 작성된_꿀조합_아이디, + new RecipeCommentCondition(6L, 15L)); + + // then + STATUS_CODE를_검증한다(응답, 인증되지_않음); + RESPONSE_CODE와_MESSAGE를_검증한다(응답, LOGIN_MEMBER_NOT_FOUND.getCode(), + LOGIN_MEMBER_NOT_FOUND.getMessage()); + } + } + private void 레시피_목록_조회_결과를_검증한다(final ExtractableResponse response, final List recipeIds) { final var actual = response.jsonPath().getList("recipes", RecipeDto.class); @@ -590,4 +777,30 @@ class getRankingRecipes_성공_테스트 { assertThat(actual).extracting(SearchRecipeResultDto::getId) .containsExactlyElementsOf(recipeIds); } + + private Long 작성된_꿀조합_아이디_추출(final ExtractableResponse response) { + return Long.parseLong(response.header("Location").split("/")[3]); + } + + private void 꿀조합_댓글_작성_결과를_검증한다(final ExtractableResponse response, final Long memberId, + final RecipeCommentCreateRequest request) { + final var savedCommentId = Long.parseLong(response.header("Location").split("/")[4]); + + final var findComments = commentRepository.findAll(); + + assertSoftly(soft -> { + soft.assertThat(savedCommentId).isEqualTo(findComments.get(0).getId()); + soft.assertThat(memberId).isEqualTo(findComments.get(0).getMember().getId()); + soft.assertThat(request.getComment()).isEqualTo(findComments.get(0).getComment()); + }); + } + + private void 레시피_댓글_조회_결과를_검증한다(final ExtractableResponse response, final int expectedSize, + final boolean expectedHasNext) { + final var actualComments = response.jsonPath().getList("comments", RecipeCommentResponse.class); + final var actualHasNext = response.jsonPath().getBoolean("hasNext"); + + assertThat(actualComments).hasSize(expectedSize); + assertThat(actualHasNext).isEqualTo(expectedHasNext); + } } diff --git a/backend/src/test/java/com/funeat/acceptance/recipe/RecipeSteps.java b/backend/src/test/java/com/funeat/acceptance/recipe/RecipeSteps.java index 4ff18dc2e..6ebc08ba5 100644 --- a/backend/src/test/java/com/funeat/acceptance/recipe/RecipeSteps.java +++ b/backend/src/test/java/com/funeat/acceptance/recipe/RecipeSteps.java @@ -4,6 +4,8 @@ import static com.funeat.fixture.RecipeFixture.레시피좋아요요청_생성; import static io.restassured.RestAssured.given; +import com.funeat.recipe.dto.RecipeCommentCondition; +import com.funeat.recipe.dto.RecipeCommentCreateRequest; import com.funeat.recipe.dto.RecipeCreateRequest; import com.funeat.recipe.dto.RecipeFavoriteRequest; import io.restassured.response.ExtractableResponse; @@ -90,4 +92,30 @@ public class RecipeSteps { .then() .extract(); } + + public static ExtractableResponse 레시피_댓글_작성_요청(final String loginCookie, + final Long recipeId, + final RecipeCommentCreateRequest request) { + return given() + .cookie("JSESSIONID", loginCookie) + .contentType("application/json") + .body(request) + .when() + .post("/api/recipes/" + recipeId + "/comments") + .then() + .extract(); + } + + public static ExtractableResponse 레시피_댓글_조회_요청(final String loginCookie, final Long recipeId, + final RecipeCommentCondition condition) { + return given() + .cookie("JSESSIONID", loginCookie) + .contentType("application/json") + .param("lastId", condition.getLastId()) + .param("totalElements", condition.getTotalElements()) + .when() + .get("/api/recipes/" + recipeId + "/comments") + .then() + .extract(); + } } diff --git a/backend/src/test/java/com/funeat/acceptance/review/ReviewSteps.java b/backend/src/test/java/com/funeat/acceptance/review/ReviewSteps.java index 0d8ce8fd7..93dfd6292 100644 --- a/backend/src/test/java/com/funeat/acceptance/review/ReviewSteps.java +++ b/backend/src/test/java/com/funeat/acceptance/review/ReviewSteps.java @@ -1,7 +1,6 @@ package com.funeat.acceptance.review; import static com.funeat.acceptance.auth.LoginSteps.로그인_쿠키_획득; -import static com.funeat.acceptance.common.CommonSteps.LOCATION_헤더에서_ID_추출; import static com.funeat.fixture.ReviewFixture.리뷰좋아요요청_생성; import static io.restassured.RestAssured.given; diff --git a/backend/src/test/java/com/funeat/common/RepositoryTest.java b/backend/src/test/java/com/funeat/common/RepositoryTest.java index b438ca41b..6fdb2307b 100644 --- a/backend/src/test/java/com/funeat/common/RepositoryTest.java +++ b/backend/src/test/java/com/funeat/common/RepositoryTest.java @@ -4,8 +4,6 @@ import com.funeat.member.domain.favorite.RecipeFavorite; import com.funeat.member.domain.favorite.ReviewFavorite; import com.funeat.member.persistence.MemberRepository; -import com.funeat.member.persistence.ProductBookmarkRepository; -import com.funeat.member.persistence.RecipeBookMarkRepository; import com.funeat.member.persistence.RecipeFavoriteRepository; import com.funeat.member.persistence.ReviewFavoriteRepository; import com.funeat.product.domain.Category; @@ -42,12 +40,6 @@ public abstract class RepositoryTest { @Autowired protected MemberRepository memberRepository; - @Autowired - protected ProductBookmarkRepository productBookmarkRepository; - - @Autowired - protected RecipeBookMarkRepository recipeBookMarkRepository; - @Autowired protected RecipeFavoriteRepository recipeFavoriteRepository; diff --git a/backend/src/test/java/com/funeat/common/ServiceTest.java b/backend/src/test/java/com/funeat/common/ServiceTest.java index f8b58e5a2..6612223aa 100644 --- a/backend/src/test/java/com/funeat/common/ServiceTest.java +++ b/backend/src/test/java/com/funeat/common/ServiceTest.java @@ -1,12 +1,11 @@ package com.funeat.common; import com.funeat.auth.application.AuthService; +import com.funeat.comment.persistence.CommentRepository; import com.funeat.member.application.TestMemberService; import com.funeat.member.domain.Member; import com.funeat.member.domain.favorite.ReviewFavorite; import com.funeat.member.persistence.MemberRepository; -import com.funeat.member.persistence.ProductBookmarkRepository; -import com.funeat.member.persistence.RecipeBookMarkRepository; import com.funeat.member.persistence.RecipeFavoriteRepository; import com.funeat.member.persistence.ReviewFavoriteRepository; import com.funeat.product.application.CategoryService; @@ -48,12 +47,6 @@ public abstract class ServiceTest { @Autowired protected MemberRepository memberRepository; - @Autowired - protected ProductBookmarkRepository productBookmarkRepository; - - @Autowired - protected RecipeBookMarkRepository recipeBookMarkRepository; - @Autowired protected RecipeFavoriteRepository recipeFavoriteRepository; @@ -84,6 +77,9 @@ public abstract class ServiceTest { @Autowired protected TagRepository tagRepository; + @Autowired + protected CommentRepository commentRepository; + @Autowired protected AuthService authService; diff --git a/backend/src/test/java/com/funeat/recipe/application/RecipeServiceTest.java b/backend/src/test/java/com/funeat/recipe/application/RecipeServiceTest.java index fcd4e2f40..3fecf28bb 100644 --- a/backend/src/test/java/com/funeat/recipe/application/RecipeServiceTest.java +++ b/backend/src/test/java/com/funeat/recipe/application/RecipeServiceTest.java @@ -24,6 +24,7 @@ import static org.assertj.core.api.Assertions.assertThatThrownBy; import static org.assertj.core.api.SoftAssertions.assertSoftly; +import com.funeat.comment.domain.Comment; import com.funeat.common.ServiceTest; import com.funeat.common.dto.PageDto; import com.funeat.member.domain.Member; @@ -35,17 +36,20 @@ import com.funeat.product.domain.CategoryType; import com.funeat.product.domain.Product; import com.funeat.product.exception.ProductException.ProductNotFoundException; +import com.funeat.recipe.dto.RecipeCommentCondition; +import com.funeat.recipe.dto.RecipeCommentCreateRequest; +import com.funeat.recipe.dto.RecipeCommentResponse; import com.funeat.recipe.dto.RecipeCreateRequest; import com.funeat.recipe.dto.RecipeDetailResponse; import com.funeat.recipe.dto.RecipeDto; import com.funeat.recipe.exception.RecipeException.RecipeNotFoundException; -import org.junit.jupiter.api.Nested; -import org.junit.jupiter.api.Test; - +import java.util.ArrayList; import java.util.Collections; import java.util.List; import java.util.stream.Collectors; import java.util.stream.Stream; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; @SuppressWarnings("NonAsciiCharacters") class RecipeServiceTest extends ServiceTest { @@ -317,7 +321,7 @@ class getSortingRecipes_성공_테스트 { } @Test - void 꿀조합을_최신순으로_정렬할_수_있다() { + void 꿀조합을_최신순으로_정렬할_수_있다() throws InterruptedException { // given final var member1 = 멤버_멤버1_생성(); final var member2 = 멤버_멤버2_생성(); @@ -333,7 +337,9 @@ class getSortingRecipes_성공_테스트 { 복수_상품_저장(product1, product2, product3); final var recipe1_1 = 레시피_생성(member1, 1L); + Thread.sleep(1000); final var recipe1_2 = 레시피_생성(member1, 3L); + Thread.sleep(1000); final var recipe1_3 = 레시피_생성(member1, 2L); 복수_꿀조합_저장(recipe1_1, recipe1_2, recipe1_3); @@ -545,6 +551,190 @@ class likeRecipe_실패_테스트 { } } + @Nested + class writeCommentOfRecipe_성공_테스트 { + + @Test + void 꿀조합에_댓글을_작성할_수_있다() { + // given + final var category = 카테고리_간편식사_생성(); + final var product1 = 상품_삼각김밥_가격1000원_평점2점_생성(category); + final var product2 = 상품_삼각김밥_가격3000원_평점4점_생성(category); + final var product3 = 상품_삼각김밥_가격2000원_평점3점_생성(category); + 복수_상품_저장(product1, product2, product3); + final var author = 멤버_멤버1_생성(); + 단일_멤버_저장(author); + final var authorId = author.getId(); + + final var images = 여러_이미지_생성(3); + + final var productIds = List.of(product1.getId(), product2.getId(), product3.getId()); + final var recipeCreateRequest = new RecipeCreateRequest("제일로 맛있는 레시피", productIds, + "우선 밥을 넣어요. 그리고 밥을 또 넣어요. 그리고 밥을 또 넣으면.. 끝!!"); + + final var savedMemberId = 단일_멤버_저장(멤버_멤버1_생성()); + final var savedRecipeId = recipeService.create(authorId, images, recipeCreateRequest); + + // when + final var request = new RecipeCommentCreateRequest("꿀조합 댓글이에요"); + final var savedCommentId = recipeService.writeCommentOfRecipe(savedMemberId, savedRecipeId, request); + + // then + final var result = commentRepository.findById(savedCommentId).get(); + final var savedRecipe = recipeRepository.findById(savedRecipeId).get(); + final var savedMember = memberRepository.findById(savedMemberId).get(); + + assertThat(result).usingRecursiveComparison() + .ignoringFields("id", "createdAt") + .isEqualTo(new Comment(savedRecipe, savedMember, request.getComment())); + } + } + + @Nested + class writeCommentOfRecipe_실패_테스트 { + + @Test + void 존재하지_않은_멤버가_꿀조합에_댓글을_작성하면_예외가_발생한다() { + // given + final var category = 카테고리_간편식사_생성(); + final var product1 = 상품_삼각김밥_가격1000원_평점2점_생성(category); + final var product2 = 상품_삼각김밥_가격3000원_평점4점_생성(category); + final var product3 = 상품_삼각김밥_가격2000원_평점3점_생성(category); + 복수_상품_저장(product1, product2, product3); + final var author = new Member("author", "image.png", "1"); + 단일_멤버_저장(author); + final var authorId = author.getId(); + + final var images = 여러_이미지_생성(3); + + final var productIds = List.of(product1.getId(), product2.getId(), product3.getId()); + final var recipeCreateRequest = new RecipeCreateRequest("제일로 맛있는 레시피", productIds, + "우선 밥을 넣어요. 그리고 밥을 또 넣어요. 그리고 밥을 또 넣으면.. 끝!!"); + + final var notExistMemberId = 999999999L; + final var savedRecipeId = recipeService.create(authorId, images, recipeCreateRequest); + final var request = new RecipeCommentCreateRequest("꿀조합 댓글이에요"); + + // when then + assertThatThrownBy(() -> recipeService.writeCommentOfRecipe(notExistMemberId, savedRecipeId, request)) + .isInstanceOf(MemberNotFoundException.class); + } + + @Test + void 존재하지_않은_꿀조합에_댓글을_작성하면_예외가_발생한다() { + // given + final var memberId = 단일_멤버_저장(멤버_멤버1_생성()); + final var request = new RecipeCommentCreateRequest("꿀조합 댓글이에요"); + final var notExistRecipeId = 999999999L; + + // when then + assertThatThrownBy(() -> recipeService.writeCommentOfRecipe(memberId, notExistRecipeId, request)) + .isInstanceOf(RecipeNotFoundException.class); + } + } + + @Nested + class getCommentsOfRecipe_성공_테스트 { + + @Test + void 꿀조합에_달린_댓글들을_커서페이징을_통해_조회할_수_있다_총_댓글_15개_중_첫페이지_댓글_10개조회() { + // given + final var category = 카테고리_간편식사_생성(); + 단일_카테고리_저장(category); + final var product1 = 상품_삼각김밥_가격1000원_평점2점_생성(category); + final var product2 = 상품_삼각김밥_가격3000원_평점4점_생성(category); + final var product3 = 상품_삼각김밥_가격2000원_평점3점_생성(category); + 복수_상품_저장(product1, product2, product3); + final var author = 멤버_멤버1_생성(); + 단일_멤버_저장(author); + final var authorId = author.getId(); + + final var images = 여러_이미지_생성(3); + + final var productIds = List.of(product1.getId(), product2.getId(), product3.getId()); + final var recipeCreateRequest = new RecipeCreateRequest("제일로 맛있는 레시피", productIds, + "우선 밥을 넣어요. 그리고 밥을 또 넣어요. 그리고 밥을 또 넣으면.. 끝!!"); + + final var savedMemberId = 단일_멤버_저장(멤버_멤버1_생성()); + final var savedRecipeId = recipeService.create(authorId, images, recipeCreateRequest); + + for (int i = 1; i <= 15; i++) { + final var request = new RecipeCommentCreateRequest("꿀조합 댓글이에요" + i); + recipeService.writeCommentOfRecipe(savedMemberId, savedRecipeId, request); + } + + // when + final var result = recipeService.getCommentsOfRecipe(savedRecipeId, + new RecipeCommentCondition(null, null)); + + // + final var savedRecipe = recipeRepository.findById(savedRecipeId).get(); + final var savedMember = memberRepository.findById(savedMemberId).get(); + + final var expectedCommentResponses = new ArrayList<>(); + for (int i = 0; i < result.getComments().size(); i++) { + expectedCommentResponses.add(RecipeCommentResponse.toResponse( + new Comment(savedRecipe, savedMember, "꿀조합 댓글이에요" + (15 - i)))); + } + + assertThat(result.getHasNext()).isTrue(); + assertThat(result.getTotalElements()).isEqualTo(15); + assertThat(result.getComments()).hasSize(10); + assertThat(result.getComments()).usingRecursiveComparison() + .ignoringFields("id", "createdAt") + .isEqualTo(expectedCommentResponses); + } + + @Test + void 꿀조합에_달린_댓글들을_커서페이징을_통해_조회할_수_있다_총_댓글_15개_중_마지막페이지_댓글_5개조회() { + // given + final var category = 카테고리_간편식사_생성(); + 단일_카테고리_저장(category); + final var product1 = 상품_삼각김밥_가격1000원_평점2점_생성(category); + final var product2 = 상품_삼각김밥_가격3000원_평점4점_생성(category); + final var product3 = 상품_삼각김밥_가격2000원_평점3점_생성(category); + 복수_상품_저장(product1, product2, product3); + final var author = 멤버_멤버1_생성(); + 단일_멤버_저장(author); + final var authorId = author.getId(); + + final var images = 여러_이미지_생성(3); + + final var productIds = List.of(product1.getId(), product2.getId(), product3.getId()); + final var recipeCreateRequest = new RecipeCreateRequest("제일로 맛있는 레시피", productIds, + "우선 밥을 넣어요. 그리고 밥을 또 넣어요. 그리고 밥을 또 넣으면.. 끝!!"); + + final var savedMemberId = 단일_멤버_저장(멤버_멤버1_생성()); + final var savedRecipeId = recipeService.create(authorId, images, recipeCreateRequest); + + for (int i = 1; i <= 15; i++) { + final var request = new RecipeCommentCreateRequest("꿀조합 댓글이에요" + i); + recipeService.writeCommentOfRecipe(savedMemberId, savedRecipeId, request); + } + + // when + final var result = recipeService.getCommentsOfRecipe(savedRecipeId, + new RecipeCommentCondition(6L, 15L)); + + // + final var savedRecipe = recipeRepository.findById(savedRecipeId).get(); + final var savedMember = memberRepository.findById(savedMemberId).get(); + + final var expectedCommentResponses = new ArrayList<>(); + for (int i = 0; i < result.getComments().size(); i++) { + expectedCommentResponses.add(RecipeCommentResponse.toResponse( + new Comment(savedRecipe, savedMember, "꿀조합 댓글이에요" + (5 - i)))); + } + + assertThat(result.getHasNext()).isFalse(); + assertThat(result.getTotalElements()).isEqualTo(15); + assertThat(result.getComments()).hasSize(5); + assertThat(result.getComments()).usingRecursiveComparison() + .ignoringFields("id", "createdAt") + .isEqualTo(expectedCommentResponses); + } + } + private void 해당멤버의_꿀조합과_페이징_결과를_검증한다(final MemberRecipesResponse actual, final List expectedRecipesDtos, final PageDto expectedPage) { assertSoftly(soft -> { diff --git a/backend/src/test/java/com/funeat/recipe/persistence/RecipeRepositoryTest.java b/backend/src/test/java/com/funeat/recipe/persistence/RecipeRepositoryTest.java index c7130d2ce..f52eae169 100644 --- a/backend/src/test/java/com/funeat/recipe/persistence/RecipeRepositoryTest.java +++ b/backend/src/test/java/com/funeat/recipe/persistence/RecipeRepositoryTest.java @@ -119,7 +119,7 @@ class findAllRecipes_성공_테스트 { } @Test - void 꿀조합을_최신순으로_정렬한다() { + void 꿀조합을_최신순으로_정렬한다() throws InterruptedException { // given final var member1 = 멤버_멤버1_생성(); final var member2 = 멤버_멤버2_생성(); @@ -135,7 +135,9 @@ class findAllRecipes_성공_테스트 { 복수_상품_저장(product1, product2, product3); final var recipe1_1 = 레시피_생성(member1, 1L); + Thread.sleep(1000); final var recipe1_2 = 레시피_생성(member1, 3L); + Thread.sleep(1000); final var recipe1_3 = 레시피_생성(member1, 2L); 복수_꿀조합_저장(recipe1_1, recipe1_2, recipe1_3); diff --git a/backend/src/test/java/com/funeat/review/application/ReviewServiceTest.java b/backend/src/test/java/com/funeat/review/application/ReviewServiceTest.java index e6ca43b22..597033063 100644 --- a/backend/src/test/java/com/funeat/review/application/ReviewServiceTest.java +++ b/backend/src/test/java/com/funeat/review/application/ReviewServiceTest.java @@ -441,7 +441,7 @@ class sortingReviews_성공_테스트 { } @Test - void 최신순으로_정렬을_할_수_있다() { + void 최신순으로_정렬을_할_수_있다() throws InterruptedException { // given final var member1 = 멤버_멤버1_생성(); final var member2 = 멤버_멤버2_생성(); @@ -455,7 +455,9 @@ class sortingReviews_성공_테스트 { final var productId = 단일_상품_저장(product); final var review1 = 리뷰_이미지test3_평점3점_재구매O_생성(member1, product, 351L); + Thread.sleep(1000); final var review2 = 리뷰_이미지test4_평점4점_재구매O_생성(member2, product, 24L); + Thread.sleep(1000); final var review3 = 리뷰_이미지test3_평점3점_재구매X_생성(member3, product, 130L); 복수_리뷰_저장(review1, review2, review3); From f614edac8f80463212a87c0e18345a15a82b1b98 Mon Sep 17 00:00:00 2001 From: Taeeun Kim Date: Mon, 16 Oct 2023 14:19:21 +0900 Subject: [PATCH 19/55] =?UTF-8?q?[FE]=20feat:=20=EA=BF=80=EC=A1=B0?= =?UTF-8?q?=ED=95=A9=20=EB=8C=93=EA=B8=80=20=EC=A1=B0=ED=9A=8C=EC=97=90=20?= =?UTF-8?q?credentials=20=EC=B6=94=EA=B0=80=20(#768)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../src/hooks/queries/recipe/useInfiniteRecipeCommentQuery.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/frontend/src/hooks/queries/recipe/useInfiniteRecipeCommentQuery.ts b/frontend/src/hooks/queries/recipe/useInfiniteRecipeCommentQuery.ts index 460b11e92..7bcbe60f5 100644 --- a/frontend/src/hooks/queries/recipe/useInfiniteRecipeCommentQuery.ts +++ b/frontend/src/hooks/queries/recipe/useInfiniteRecipeCommentQuery.ts @@ -13,6 +13,7 @@ const fetchRecipeComments = async (pageParam: PageParam, recipeId: number) => { const response = await recipeApi.get({ params: `/${recipeId}/comments`, queries: `?lastId=${lastId}&totalElements=${totalElements}`, + credentials: true, }); const data: CommentResponse = await response.json(); return data; From ff62d0ef6491c9025f8ef18305bceee1016a8af7 Mon Sep 17 00:00:00 2001 From: Taeeun Kim Date: Mon, 16 Oct 2023 14:45:31 +0900 Subject: [PATCH 20/55] =?UTF-8?q?[FE]=20fix:=20totalElements=EA=B0=80=20?= =?UTF-8?q?=EC=97=86=EB=8A=94=20=EA=B2=BD=EC=9A=B0=20query=20string?= =?UTF-8?q?=EC=97=90=EC=84=9C=20=EC=A0=9C=EC=99=B8=20(#770)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix: totalElements가 없는 경우 query string에서 제외 * feat: credentials true 추가 --- .../src/hooks/queries/recipe/useInfiniteRecipeCommentQuery.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/frontend/src/hooks/queries/recipe/useInfiniteRecipeCommentQuery.ts b/frontend/src/hooks/queries/recipe/useInfiniteRecipeCommentQuery.ts index 7bcbe60f5..ec251f9b5 100644 --- a/frontend/src/hooks/queries/recipe/useInfiniteRecipeCommentQuery.ts +++ b/frontend/src/hooks/queries/recipe/useInfiniteRecipeCommentQuery.ts @@ -10,9 +10,11 @@ interface PageParam { const fetchRecipeComments = async (pageParam: PageParam, recipeId: number) => { const { lastId, totalElements } = pageParam; + const queries = `?lastId=${lastId}${totalElements !== null ? `&totalElements=${totalElements}` : ''}`; + const response = await recipeApi.get({ params: `/${recipeId}/comments`, - queries: `?lastId=${lastId}&totalElements=${totalElements}`, + queries: queries, credentials: true, }); const data: CommentResponse = await response.json(); From be83c6b0bf8d6d3c7b93689b9b375f0c105853ed Mon Sep 17 00:00:00 2001 From: Taeeun Kim Date: Mon, 16 Oct 2023 15:16:31 +0900 Subject: [PATCH 21/55] =?UTF-8?q?[FE]=20feat:=20=EA=BF=80=EC=A1=B0?= =?UTF-8?q?=ED=95=A9=20=EB=8C=93=EA=B8=80=EC=9D=B4=20=EC=97=86=EB=8A=94=20?= =?UTF-8?q?=EA=B2=BD=EC=9A=B0=20=EC=98=88=EC=99=B8=EC=B2=98=EB=A6=AC=20(#7?= =?UTF-8?q?72)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat: 댓글이 없는 경우 ui 처리 * feat: 댓글이 없는 경우 id를 읽어올 수 없는 문제 해결 --- frontend/src/components/Recipe/CommentList/CommentList.tsx | 3 ++- .../src/hooks/queries/recipe/useInfiniteRecipeCommentQuery.ts | 2 +- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/frontend/src/components/Recipe/CommentList/CommentList.tsx b/frontend/src/components/Recipe/CommentList/CommentList.tsx index 5a34feb95..093d5d93e 100644 --- a/frontend/src/components/Recipe/CommentList/CommentList.tsx +++ b/frontend/src/components/Recipe/CommentList/CommentList.tsx @@ -1,4 +1,4 @@ -import { Heading, Spacing } from '@fun-eat/design-system'; +import { Heading, Spacing, Text, theme } from '@fun-eat/design-system'; import { useRef } from 'react'; import CommentItem from '../CommentItem/CommentItem'; @@ -24,6 +24,7 @@ const CommentList = ({ recipeId }: CommentListProps) => { 댓글 ({comments.length}개) + {comments.length === 0 && 꿀조합의 첫번째 댓글을 달아보세요!} {comments.map((comment) => ( ))} diff --git a/frontend/src/hooks/queries/recipe/useInfiniteRecipeCommentQuery.ts b/frontend/src/hooks/queries/recipe/useInfiniteRecipeCommentQuery.ts index ec251f9b5..dd870941b 100644 --- a/frontend/src/hooks/queries/recipe/useInfiniteRecipeCommentQuery.ts +++ b/frontend/src/hooks/queries/recipe/useInfiniteRecipeCommentQuery.ts @@ -27,7 +27,7 @@ const useInfiniteRecipeCommentQuery = (recipeId: number) => { ({ pageParam = { lastId: 0, totalElements: null } }) => fetchRecipeComments(pageParam, recipeId), { getNextPageParam: (prevResponse: CommentResponse) => { - const lastId = prevResponse.comments[prevResponse.comments.length - 1].id; + const lastId = prevResponse.comments.length ? prevResponse.comments[prevResponse.comments.length - 1].id : 0; const totalElements = prevResponse.totalElements; const lastCursor = { lastId: lastId, totalElements: totalElements }; return prevResponse.hasNext ? lastCursor : undefined; From 4cd0eaf3a986bc634f6ec4a21d9c768c3642f8af Mon Sep 17 00:00:00 2001 From: Taeeun Kim Date: Mon, 16 Oct 2023 15:44:29 +0900 Subject: [PATCH 22/55] =?UTF-8?q?[FE]=20feat:=20totalElements=EA=B0=80=20n?= =?UTF-8?q?ull=EC=9D=BC=20=EB=95=8C=20=EC=95=84=EC=98=88=20=EC=BF=BC?= =?UTF-8?q?=EB=A6=AC=EB=A5=BC=20=EB=B3=B4=EB=82=B4=EC=A7=80=20=EC=95=8A?= =?UTF-8?q?=EB=8F=84=EB=A1=9D=20=EC=88=98=EC=A0=95=20(#774)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../src/hooks/queries/recipe/useInfiniteRecipeCommentQuery.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/frontend/src/hooks/queries/recipe/useInfiniteRecipeCommentQuery.ts b/frontend/src/hooks/queries/recipe/useInfiniteRecipeCommentQuery.ts index dd870941b..068520c8a 100644 --- a/frontend/src/hooks/queries/recipe/useInfiniteRecipeCommentQuery.ts +++ b/frontend/src/hooks/queries/recipe/useInfiniteRecipeCommentQuery.ts @@ -10,7 +10,7 @@ interface PageParam { const fetchRecipeComments = async (pageParam: PageParam, recipeId: number) => { const { lastId, totalElements } = pageParam; - const queries = `?lastId=${lastId}${totalElements !== null ? `&totalElements=${totalElements}` : ''}`; + const queries = totalElements === null ? '' : `?lastId=${lastId}&totalElements=${totalElements}`; const response = await recipeApi.get({ params: `/${recipeId}/comments`, From 41a1d75fbe2e392f082a27738605d0008916122c Mon Sep 17 00:00:00 2001 From: Dabeen Jeong Date: Mon, 16 Oct 2023 17:55:14 +0900 Subject: [PATCH 23/55] =?UTF-8?q?[BE]=20feat:=20=EC=83=81=ED=92=88?= =?UTF-8?q?=EC=97=90=20=EB=8C=80=ED=95=9C=20=EB=A6=AC=EB=B7=B0=20=EB=AA=A9?= =?UTF-8?q?=EB=A1=9D=20=EC=A1=B0=ED=9A=8C=20=EB=8F=99=EC=A0=81=20=EC=BF=BC?= =?UTF-8?q?=EB=A6=AC=20=EA=B5=AC=ED=98=84=20=EB=B0=8F=20API=20=EC=84=B1?= =?UTF-8?q?=EB=8A=A5=20=EA=B0=9C=EC=84=A0=20(#607)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat: 좋아요 기준 내림차순 리뷰 목록 조회 쿼리 개선 * test: 좋아요 기준 내림차순 테스트 재생성 * feat: 최신순으로 리뷰 목록 조회 쿼리 개선 * test: 최신순 리뷰 목록 테스트 재생성 * feat: 평점순 정렬 리뷰 목록 조회 쿼리 개선 * test: 평점순 정렬 리뷰 목록 테스트 재생성 * feat: 정렬 조건에 따라 리뷰 목록을 반환하는 기능 추가 * feat: 정렬 기능 추가 * refactor: 테스트 추가 및 conflict 해결 * fix: 생성자가 여러개라 jackson이 json으로 변환하지 못하는 현상 수정 * fix: 2차 정렬 기준이 ID 기준 내림차순으로 수정 * fix: 좋아요를 누른 사람이 여러명이면 그 개수만큼 같은 리뷰를 반환하던 쿼리문 수정 * test: 프로덕션 코드 수정으로 인한 테스트 코드 수정 * refactor: 정렬 조건에 맞게 리뷰 목록 생성 * refactor: 주석 삭제 * fix: 데이터를 11개가 아니라 10개를 반환하도록 수정 * refactor: 리뷰 랭킹에서 좋아요가 같으면 최신 리뷰순으로 정렬하기 추가 * temp: Criteria API + Specification으로 동적 쿼리 기능 구현 리팩터링 진행중 * refactor: SortSpec -> ReviewSortSpec 네이밍 변경 * refactor: 다른 곳에서 객체를 생성할 수 없도록 수정 * refactor: SortingReviewDto Wrapper 타입으로 변경, 유저 좋아요 데이터 기본값 변경 * refactor: static 삭제 * refactor: SortingReviewDto의 멤버 변수에 final 추가 * refactor: 동적 쿼리 이전의 리뷰 목록 정렬 코드 삭제 * refactor: 정렬 조건에 없는 필드 이름이 아니면 예외를 반환하도록 수정 * refactor: 클래스 네이밍 변경 LongTypeSortSpec -> LongTypeReviewSortSpec * refactor: 정렬만 하는 서비스 클래스 분리, Tag까지 가져올 수 있도록 수정 * fix: 충돌 해결 * test: Thread.sleep()을 1초가 아닌 0.1초로 수정 * refactor: Wrapper -> Primitive 타입으로 변경 * refactor: is@@@ -> get@@@로 변경 * refactor: 다음 페이지가 존재하는지는 hasNext로 통일 * refactor: 리뷰 정렬 클래스 삭제, 상수 이름을 목적에 따라 분리 * refactor: 에러 코드 네이밍 수정 * refactor: exception 이름도 같이 수정 --- .../review/application/ReviewService.java | 78 +++++++-- .../funeat/review/dto/SortingReviewDto.java | 51 +++--- .../dto/SortingReviewDtoWithoutTag.java | 84 +++++++++ .../review/dto/SortingReviewRequest.java | 27 +++ .../review/dto/SortingReviewsResponse.java | 19 +-- .../review/exception/ReviewErrorCode.java | 3 +- .../review/exception/ReviewException.java | 6 + .../persistence/ReviewCustomRepository.java | 14 ++ .../review/persistence/ReviewRepository.java | 8 +- .../persistence/ReviewRepositoryImpl.java | 91 ++++++++++ .../presentation/ReviewApiController.java | 6 +- .../review/presentation/ReviewController.java | 4 +- .../specification/LongTypeReviewSortSpec.java | 27 +++ .../SortingReviewSpecification.java | 161 ++++++++++++++++++ .../funeat/tag/persistence/TagRepository.java | 8 + .../funeat/acceptance/common/CommonSteps.java | 6 + .../review/ReviewAcceptanceTest.java | 58 +++---- .../funeat/acceptance/review/ReviewSteps.java | 4 +- .../java/com/funeat/fixture/PageFixture.java | 26 +++ .../com/funeat/fixture/ReviewFixture.java | 30 ++++ .../recipe/application/RecipeServiceTest.java | 4 +- .../persistence/RecipeRepositoryTest.java | 4 +- .../review/application/ReviewServiceTest.java | 154 +++++++---------- .../persistence/ReviewRepositoryTest.java | 39 +---- 24 files changed, 677 insertions(+), 235 deletions(-) create mode 100644 backend/src/main/java/com/funeat/review/dto/SortingReviewDtoWithoutTag.java create mode 100644 backend/src/main/java/com/funeat/review/dto/SortingReviewRequest.java create mode 100644 backend/src/main/java/com/funeat/review/persistence/ReviewCustomRepository.java create mode 100644 backend/src/main/java/com/funeat/review/persistence/ReviewRepositoryImpl.java create mode 100644 backend/src/main/java/com/funeat/review/specification/LongTypeReviewSortSpec.java create mode 100644 backend/src/main/java/com/funeat/review/specification/SortingReviewSpecification.java diff --git a/backend/src/main/java/com/funeat/review/application/ReviewService.java b/backend/src/main/java/com/funeat/review/application/ReviewService.java index 027f8d5ae..320bc13eb 100644 --- a/backend/src/main/java/com/funeat/review/application/ReviewService.java +++ b/backend/src/main/java/com/funeat/review/application/ReviewService.java @@ -27,11 +27,14 @@ import com.funeat.review.dto.ReviewCreateRequest; import com.funeat.review.dto.ReviewFavoriteRequest; import com.funeat.review.dto.SortingReviewDto; +import com.funeat.review.dto.SortingReviewDtoWithoutTag; +import com.funeat.review.dto.SortingReviewRequest; import com.funeat.review.dto.SortingReviewsResponse; import com.funeat.review.exception.ReviewException.NotAuthorOfReviewException; import com.funeat.review.exception.ReviewException.ReviewNotFoundException; import com.funeat.review.persistence.ReviewRepository; import com.funeat.review.persistence.ReviewTagRepository; +import com.funeat.review.specification.SortingReviewSpecification; import com.funeat.tag.domain.Tag; import com.funeat.tag.persistence.TagRepository; import java.util.List; @@ -42,6 +45,7 @@ import org.springframework.data.domain.Page; import org.springframework.data.domain.PageRequest; import org.springframework.data.domain.Pageable; +import org.springframework.data.jpa.domain.Specification; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; import org.springframework.web.multipart.MultipartFile; @@ -50,9 +54,11 @@ @Transactional(readOnly = true) public class ReviewService { - private static final int TOP = 0; + private static final int FIRST_PAGE = 0; + private static final int START_INDEX = 0; private static final int ONE = 1; private static final String EMPTY_URL = ""; + private static final int REVIEW_PAGE_SIZE = 10; private final ReviewRepository reviewRepository; private final TagRepository tagRepository; @@ -121,8 +127,7 @@ public void likeReview(final Long reviewId, final Long memberId, final ReviewFav private ReviewFavorite saveReviewFavorite(final Member member, final Review review, final Boolean favorite) { try { - final ReviewFavorite reviewFavorite = ReviewFavorite.create(member, review, - favorite); + final ReviewFavorite reviewFavorite = ReviewFavorite.create(member, review, favorite); return reviewFavoriteRepository.save(reviewFavorite); } catch (final DataIntegrityViolationException e) { throw new MemberDuplicateFavoriteException(MEMBER_DUPLICATE_FAVORITE, member.getId()); @@ -134,33 +139,76 @@ public void updateProductImage(final Long productId) { final Product product = productRepository.findById(productId) .orElseThrow(() -> new ProductNotFoundException(PRODUCT_NOT_FOUND, productId)); - final PageRequest pageRequest = PageRequest.of(TOP, ONE); + final PageRequest pageRequest = PageRequest.of(FIRST_PAGE, ONE); + final List topFavoriteReview = reviewRepository.findPopularReviewWithImage(productId, pageRequest); if (!topFavoriteReview.isEmpty()) { - final String topFavoriteReviewImage = topFavoriteReview.get(TOP).getImage(); + final String topFavoriteReviewImage = topFavoriteReview.get(START_INDEX).getImage(); product.updateImage(topFavoriteReviewImage); } } - public SortingReviewsResponse sortingReviews(final Long productId, final Pageable pageable, final Long memberId) { - final Member member = memberRepository.findById(memberId) + public SortingReviewsResponse sortingReviews(final Long productId, final Long memberId, + final SortingReviewRequest request) { + final Member findMember = memberRepository.findById(memberId) .orElseThrow(() -> new MemberNotFoundException(MEMBER_NOT_FOUND, memberId)); - - final Product product = productRepository.findById(productId) + final Product findProduct = productRepository.findById(productId) .orElseThrow(() -> new ProductNotFoundException(PRODUCT_NOT_FOUND, productId)); - final Page reviewPage = reviewRepository.findReviewsByProduct(pageable, product); + final List sortingReviews = getSortingReviews(findMember, findProduct, request); + final int resultSize = getResultSize(sortingReviews); + + final List resizeSortingReviews = sortingReviews.subList(START_INDEX, resultSize); + final Boolean hasNext = hasNextPage(sortingReviews); + + return SortingReviewsResponse.toResponse(resizeSortingReviews, hasNext); + } + + private List getSortingReviews(final Member member, final Product product, + final SortingReviewRequest request) { + final Long lastReviewId = request.getLastReviewId(); + final String sortOption = request.getSort(); + + final Specification specification = getSortingSpecification(product, sortOption, lastReviewId); + final List sortingReviewDtoWithoutTags = reviewRepository.getSortingReview(member, + specification, sortOption); - final PageDto pageDto = PageDto.toDto(reviewPage); - final List reviewDtos = reviewPage.stream() - .map(review -> SortingReviewDto.toDto(review, member)) + return addTagsToSortingReviews(sortingReviewDtoWithoutTags); + } + + private List addTagsToSortingReviews( + final List sortingReviewDtoWithoutTags) { + return sortingReviewDtoWithoutTags.stream() + .map(reviewDto -> SortingReviewDto.toDto(reviewDto, + tagRepository.findTagsByReviewId(reviewDto.getId()))) .collect(Collectors.toList()); + } + + private Specification getSortingSpecification(final Product product, final String sortOption, + final Long lastReviewId) { + if (lastReviewId == FIRST_PAGE) { + return SortingReviewSpecification.sortingFirstPageBy(product); + } + + final Review lastReview = reviewRepository.findById(lastReviewId) + .orElseThrow(() -> new ReviewNotFoundException(REVIEW_NOT_FOUND, lastReviewId)); + + return SortingReviewSpecification.sortingBy(product, sortOption, lastReview); + } + + private int getResultSize(final List sortingReviews) { + if (sortingReviews.size() <= REVIEW_PAGE_SIZE) { + return sortingReviews.size(); + } + return REVIEW_PAGE_SIZE; + } - return SortingReviewsResponse.toResponse(pageDto, reviewDtos); + private Boolean hasNextPage(final List sortingReviews) { + return sortingReviews.size() > REVIEW_PAGE_SIZE; } public RankingReviewsResponse getTopReviews() { - final List rankingReviews = reviewRepository.findTop3ByOrderByFavoriteCountDesc(); + final List rankingReviews = reviewRepository.findTop3ByOrderByFavoriteCountDescIdDesc(); final List dtos = rankingReviews.stream() .map(RankingReviewDto::toDto) diff --git a/backend/src/main/java/com/funeat/review/dto/SortingReviewDto.java b/backend/src/main/java/com/funeat/review/dto/SortingReviewDto.java index 7254dd6c1..1231d0058 100644 --- a/backend/src/main/java/com/funeat/review/dto/SortingReviewDto.java +++ b/backend/src/main/java/com/funeat/review/dto/SortingReviewDto.java @@ -1,12 +1,16 @@ package com.funeat.review.dto; +import com.fasterxml.jackson.annotation.JsonCreator; import com.funeat.member.domain.Member; import com.funeat.member.domain.favorite.ReviewFavorite; import com.funeat.review.domain.Review; import com.funeat.review.domain.ReviewTag; +import com.funeat.tag.domain.Tag; import com.funeat.tag.dto.TagDto; import java.time.LocalDateTime; +import java.util.Collections; import java.util.List; +import java.util.Objects; import java.util.stream.Collectors; public class SortingReviewDto { @@ -23,6 +27,7 @@ public class SortingReviewDto { private final boolean favorite; private final LocalDateTime createdAt; + @JsonCreator public SortingReviewDto(final Long id, final String userName, final String profileImage, final String image, final Long rating, final List tags, final String content, final boolean rebuy, final Long favoriteCount, final boolean favorite, @@ -40,37 +45,23 @@ public SortingReviewDto(final Long id, final String userName, final String profi this.createdAt = createdAt; } - public static SortingReviewDto toDto(final Review review, final Member member) { - return new SortingReviewDto( - review.getId(), - review.getMember().getNickname(), - review.getMember().getProfileImage(), - review.getImage(), - review.getRating(), - findTagDtos(review), - review.getContent(), - review.getReBuy(), - review.getFavoriteCount(), - findReviewFavoriteChecked(review, member), - review.getCreatedAt() - ); - } - - private static List findTagDtos(final Review review) { - return review.getReviewTags().stream() - .map(ReviewTag::getTag) + public static SortingReviewDto toDto(final SortingReviewDtoWithoutTag sortingReviewDto, final List tags) { + final List tagDtos = tags.stream() .map(TagDto::toDto) .collect(Collectors.toList()); - } - private static boolean findReviewFavoriteChecked(final Review review, final Member member) { - return review.getReviewFavorites() - .stream() - .filter(reviewFavorite -> reviewFavorite.getReview().equals(review)) - .filter(reviewFavorite -> reviewFavorite.getMember().equals(member)) - .findFirst() - .map(ReviewFavorite::getFavorite) - .orElse(false); + return new SortingReviewDto( + sortingReviewDto.getId(), + sortingReviewDto.getUserName(), + sortingReviewDto.getProfileImage(), + sortingReviewDto.getImage(), + sortingReviewDto.getRating(), + tagDtos, + sortingReviewDto.getContent(), + sortingReviewDto.getRebuy(), + sortingReviewDto.getFavoriteCount(), + sortingReviewDto.getFavorite(), + sortingReviewDto.getCreatedAt()); } public Long getId() { @@ -101,7 +92,7 @@ public String getContent() { return content; } - public boolean isRebuy() { + public boolean getRebuy() { return rebuy; } @@ -109,7 +100,7 @@ public Long getFavoriteCount() { return favoriteCount; } - public boolean isFavorite() { + public boolean getFavorite() { return favorite; } diff --git a/backend/src/main/java/com/funeat/review/dto/SortingReviewDtoWithoutTag.java b/backend/src/main/java/com/funeat/review/dto/SortingReviewDtoWithoutTag.java new file mode 100644 index 000000000..287750e7f --- /dev/null +++ b/backend/src/main/java/com/funeat/review/dto/SortingReviewDtoWithoutTag.java @@ -0,0 +1,84 @@ +package com.funeat.review.dto; + +import java.time.LocalDateTime; +import java.util.Objects; + +public class SortingReviewDtoWithoutTag { + + private final Long id; + private final String userName; + private final String profileImage; + private final String image; + private final Long rating; + private final String content; + private final boolean rebuy; + private final Long favoriteCount; + private final boolean favorite; + private final LocalDateTime createdAt; + + public SortingReviewDtoWithoutTag(final Long id, final String userName, final String profileImage, + final String image, final Long rating, + final String content, final boolean rebuy, final Long favoriteCount, + final Boolean favorite, + final LocalDateTime createdAt) { + final Boolean isFavorite = checkingFavorite(favorite); + + this.id = id; + this.userName = userName; + this.profileImage = profileImage; + this.image = image; + this.rating = rating; + this.content = content; + this.rebuy = rebuy; + this.favoriteCount = favoriteCount; + this.favorite = isFavorite; + this.createdAt = createdAt; + } + + private static Boolean checkingFavorite(final Boolean favorite) { + if (Objects.isNull(favorite)) { + return Boolean.FALSE; + } + return Boolean.TRUE; + } + + public Long getId() { + return id; + } + + public String getUserName() { + return userName; + } + + public String getProfileImage() { + return profileImage; + } + + public String getImage() { + return image; + } + + public Long getRating() { + return rating; + } + + public String getContent() { + return content; + } + + public boolean getRebuy() { + return rebuy; + } + + public Long getFavoriteCount() { + return favoriteCount; + } + + public boolean getFavorite() { + return favorite; + } + + public LocalDateTime getCreatedAt() { + return createdAt; + } +} diff --git a/backend/src/main/java/com/funeat/review/dto/SortingReviewRequest.java b/backend/src/main/java/com/funeat/review/dto/SortingReviewRequest.java new file mode 100644 index 000000000..b6bdeb1eb --- /dev/null +++ b/backend/src/main/java/com/funeat/review/dto/SortingReviewRequest.java @@ -0,0 +1,27 @@ +package com.funeat.review.dto; + +import javax.validation.constraints.NotNull; +import javax.validation.constraints.PositiveOrZero; + +public class SortingReviewRequest { + + @NotNull(message = "정렬 조건을 확인해주세요") + private String sort; + + @NotNull(message = "마지막으로 조회한 리뷰 ID를 확인해주세요") + @PositiveOrZero(message = "마지막으로 조회한 ID는 0 이상이어야 합니다. (처음 조회하면 0)") + private Long lastReviewId; + + public SortingReviewRequest(final String sort, final Long lastReviewId) { + this.sort = sort; + this.lastReviewId = lastReviewId; + } + + public String getSort() { + return sort; + } + + public Long getLastReviewId() { + return lastReviewId; + } +} diff --git a/backend/src/main/java/com/funeat/review/dto/SortingReviewsResponse.java b/backend/src/main/java/com/funeat/review/dto/SortingReviewsResponse.java index caf1ea155..1dc082fe0 100644 --- a/backend/src/main/java/com/funeat/review/dto/SortingReviewsResponse.java +++ b/backend/src/main/java/com/funeat/review/dto/SortingReviewsResponse.java @@ -1,27 +1,26 @@ package com.funeat.review.dto; -import com.funeat.common.dto.PageDto; import java.util.List; public class SortingReviewsResponse { - private final PageDto page; private final List reviews; + private final Boolean hasNext; - public SortingReviewsResponse(final PageDto page, final List reviews) { - this.page = page; + public SortingReviewsResponse(final List reviews, final Boolean hasNext) { this.reviews = reviews; + this.hasNext = hasNext; } - public static SortingReviewsResponse toResponse(final PageDto page, final List reviews) { - return new SortingReviewsResponse(page, reviews); - } - - public PageDto getPage() { - return page; + public static SortingReviewsResponse toResponse(final List reviews, final Boolean hasNextReview) { + return new SortingReviewsResponse(reviews, hasNextReview); } public List getReviews() { return reviews; } + + public Boolean getHasNext() { + return hasNext; + } } diff --git a/backend/src/main/java/com/funeat/review/exception/ReviewErrorCode.java b/backend/src/main/java/com/funeat/review/exception/ReviewErrorCode.java index 05331dac9..2f5fb5c64 100644 --- a/backend/src/main/java/com/funeat/review/exception/ReviewErrorCode.java +++ b/backend/src/main/java/com/funeat/review/exception/ReviewErrorCode.java @@ -5,7 +5,8 @@ public enum ReviewErrorCode { REVIEW_NOT_FOUND(HttpStatus.NOT_FOUND, "존재하지 않는 리뷰입니다. 리뷰 id를 확인하세요.", "3001"), - NOT_AUTHOR_OF_REVIEW(HttpStatus.BAD_REQUEST, "해당 리뷰를 작성한 회원이 아닙니다", "3002") + NOT_SUPPORTED_REVIEW_SORTING_CONDITION(HttpStatus.BAD_REQUEST, "존재하지 않는 정렬 옵션입니다. 정렬 옵션을 확인하세요.", "3002"), + NOT_AUTHOR_OF_REVIEW(HttpStatus.BAD_REQUEST, "해당 리뷰를 작성한 회원이 아닙니다", "3003") ; private final HttpStatus status; diff --git a/backend/src/main/java/com/funeat/review/exception/ReviewException.java b/backend/src/main/java/com/funeat/review/exception/ReviewException.java index a961f3301..85fd3f666 100644 --- a/backend/src/main/java/com/funeat/review/exception/ReviewException.java +++ b/backend/src/main/java/com/funeat/review/exception/ReviewException.java @@ -16,6 +16,12 @@ public ReviewNotFoundException(final ReviewErrorCode errorCode, final Long revie } } + public static class NotSupportedReviewSortingConditionException extends ReviewException { + public NotSupportedReviewSortingConditionException(final ReviewErrorCode errorCode, final String sortFieldName) { + super(errorCode.getStatus(), new ErrorCode<>(errorCode.getCode(), errorCode.getMessage(), sortFieldName)); + } + } + public static class NotAuthorOfReviewException extends ReviewException { public NotAuthorOfReviewException(final ReviewErrorCode errorCode, final Long memberId) { super(errorCode.getStatus(), new ErrorCode<>(errorCode.getCode(), errorCode.getMessage(), memberId)); diff --git a/backend/src/main/java/com/funeat/review/persistence/ReviewCustomRepository.java b/backend/src/main/java/com/funeat/review/persistence/ReviewCustomRepository.java new file mode 100644 index 000000000..e2dd79992 --- /dev/null +++ b/backend/src/main/java/com/funeat/review/persistence/ReviewCustomRepository.java @@ -0,0 +1,14 @@ +package com.funeat.review.persistence; + +import com.funeat.member.domain.Member; +import com.funeat.review.domain.Review; +import com.funeat.review.dto.SortingReviewDtoWithoutTag; +import java.util.List; +import org.springframework.data.jpa.domain.Specification; + +public interface ReviewCustomRepository { + + List getSortingReview(final Member loginMember, + final Specification specification, + final String sortField); +} diff --git a/backend/src/main/java/com/funeat/review/persistence/ReviewRepository.java b/backend/src/main/java/com/funeat/review/persistence/ReviewRepository.java index f5ed0058f..1cf889b0b 100644 --- a/backend/src/main/java/com/funeat/review/persistence/ReviewRepository.java +++ b/backend/src/main/java/com/funeat/review/persistence/ReviewRepository.java @@ -5,6 +5,8 @@ import com.funeat.member.domain.Member; import com.funeat.product.domain.Product; import com.funeat.review.domain.Review; +import com.funeat.review.dto.SortingReviewDto; +import java.time.LocalDateTime; import java.util.List; import java.util.Optional; import org.springframework.data.domain.Page; @@ -14,11 +16,9 @@ import org.springframework.data.jpa.repository.Query; import org.springframework.data.repository.query.Param; -public interface ReviewRepository extends JpaRepository { +public interface ReviewRepository extends JpaRepository, ReviewCustomRepository { - Page findReviewsByProduct(final Pageable pageable, final Product product); - - List findTop3ByOrderByFavoriteCountDesc(); + List findTop3ByOrderByFavoriteCountDescIdDesc(); Long countByProduct(final Product product); diff --git a/backend/src/main/java/com/funeat/review/persistence/ReviewRepositoryImpl.java b/backend/src/main/java/com/funeat/review/persistence/ReviewRepositoryImpl.java new file mode 100644 index 000000000..ae47f7127 --- /dev/null +++ b/backend/src/main/java/com/funeat/review/persistence/ReviewRepositoryImpl.java @@ -0,0 +1,91 @@ +package com.funeat.review.persistence; + +import com.funeat.member.domain.Member; +import com.funeat.member.domain.favorite.ReviewFavorite; +import com.funeat.review.domain.Review; +import com.funeat.review.dto.SortingReviewDtoWithoutTag; +import java.util.List; +import javax.persistence.EntityManager; +import javax.persistence.PersistenceContext; +import javax.persistence.TypedQuery; +import javax.persistence.criteria.CompoundSelection; +import javax.persistence.criteria.CriteriaBuilder; +import javax.persistence.criteria.CriteriaQuery; +import javax.persistence.criteria.Join; +import javax.persistence.criteria.JoinType; +import javax.persistence.criteria.Order; +import javax.persistence.criteria.Predicate; +import javax.persistence.criteria.Root; +import org.springframework.data.jpa.domain.Specification; +import org.springframework.stereotype.Repository; + +@Repository +public class ReviewRepositoryImpl implements ReviewCustomRepository { + + @PersistenceContext + private EntityManager em; + + @Override + public List getSortingReview(final Member loginMember, + final Specification specification, + final String sortOption) { + final CriteriaBuilder cb = em.getCriteriaBuilder(); + final CriteriaQuery cq = cb.createQuery(SortingReviewDtoWithoutTag.class); + final Root root = cq.from(Review.class); + + // sortField, sortOrder + final String[] sortOptionSplit = sortOption.split(","); + final String sortField = sortOptionSplit[0]; + final String sortOrder = sortOptionSplit[1]; + + // join + final Join joinMember = root.join("member", JoinType.INNER); + + // left join + final Join leftJoinReviewFavorite = root.join("reviewFavorites", JoinType.LEFT); + final Predicate condition = cb.equal(leftJoinReviewFavorite.get("member"), loginMember); + leftJoinReviewFavorite.on(condition); + + // select - from - where - order by + cq.select(getConstruct(root, cb, joinMember, leftJoinReviewFavorite)) + .where(specification.toPredicate(root, cq, cb)) + .orderBy(getOrderBy(root, cb, sortField, sortOrder)); + + // limit + final TypedQuery query = em.createQuery(cq); + query.setMaxResults(11); + + // result + return query.getResultList(); + } + + private CompoundSelection getConstruct(final Root root, + final CriteriaBuilder cb, + final Join joinMember, + final Join leftJoinReviewFavorite) { + + return cb.construct(SortingReviewDtoWithoutTag.class, + root.get("id"), + joinMember.get("nickname"), + joinMember.get("profileImage"), + root.get("image"), + root.get("rating"), + root.get("content"), + root.get("reBuy"), + root.get("favoriteCount"), + leftJoinReviewFavorite.get("favorite"), + root.get("createdAt")); + } + + private List getOrderBy(final Root root, + final CriteriaBuilder cb, + final String fieldName, + final String sortOption) { + if ("ASC".equalsIgnoreCase(sortOption)) { + final Order order = cb.asc(root.get(fieldName)); + return List.of(order, cb.desc(root.get("id"))); + } + final Order order = cb.desc(root.get(fieldName)); + return List.of(order, cb.desc(root.get("id"))); + } +} diff --git a/backend/src/main/java/com/funeat/review/presentation/ReviewApiController.java b/backend/src/main/java/com/funeat/review/presentation/ReviewApiController.java index ba094cb14..cb68d6e6b 100644 --- a/backend/src/main/java/com/funeat/review/presentation/ReviewApiController.java +++ b/backend/src/main/java/com/funeat/review/presentation/ReviewApiController.java @@ -8,6 +8,7 @@ import com.funeat.review.dto.RankingReviewsResponse; import com.funeat.review.dto.ReviewCreateRequest; import com.funeat.review.dto.ReviewFavoriteRequest; +import com.funeat.review.dto.SortingReviewRequest; import com.funeat.review.dto.SortingReviewsResponse; import java.net.URI; import java.util.Objects; @@ -19,6 +20,7 @@ import org.springframework.http.ResponseEntity; import org.springframework.web.bind.annotation.DeleteMapping; import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.ModelAttribute; import org.springframework.web.bind.annotation.PatchMapping; import org.springframework.web.bind.annotation.PathVariable; import org.springframework.web.bind.annotation.PostMapping; @@ -63,8 +65,8 @@ public ResponseEntity toggleLikeReview(@PathVariable final Long productId, @GetMapping("/api/products/{productId}/reviews") public ResponseEntity getSortingReviews(@AuthenticationPrincipal final LoginInfo loginInfo, @PathVariable final Long productId, - @PageableDefault final Pageable pageable) { - final SortingReviewsResponse response = reviewService.sortingReviews(productId, pageable, loginInfo.getId()); + @ModelAttribute final SortingReviewRequest request) { + final SortingReviewsResponse response = reviewService.sortingReviews(productId, loginInfo.getId(), request); return ResponseEntity.ok(response); } diff --git a/backend/src/main/java/com/funeat/review/presentation/ReviewController.java b/backend/src/main/java/com/funeat/review/presentation/ReviewController.java index 2e3d52459..0584134b5 100644 --- a/backend/src/main/java/com/funeat/review/presentation/ReviewController.java +++ b/backend/src/main/java/com/funeat/review/presentation/ReviewController.java @@ -6,6 +6,7 @@ import com.funeat.review.dto.RankingReviewsResponse; import com.funeat.review.dto.ReviewCreateRequest; import com.funeat.review.dto.ReviewFavoriteRequest; +import com.funeat.review.dto.SortingReviewRequest; import com.funeat.review.dto.SortingReviewsResponse; import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.responses.ApiResponse; @@ -16,6 +17,7 @@ import org.springframework.http.ResponseEntity; import org.springframework.web.bind.annotation.DeleteMapping; import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.ModelAttribute; import org.springframework.web.bind.annotation.PatchMapping; import org.springframework.web.bind.annotation.PathVariable; import org.springframework.web.bind.annotation.PostMapping; @@ -56,7 +58,7 @@ ResponseEntity toggleLikeReview(@PathVariable final Long productId, @GetMapping ResponseEntity getSortingReviews(@AuthenticationPrincipal final LoginInfo loginInfo, @PathVariable final Long productId, - @PageableDefault final Pageable pageable); + @ModelAttribute final SortingReviewRequest request); @Operation(summary = "리뷰 랭킹 Top3 조회", description = "리뷰 랭킹 Top3 조회한다.") @ApiResponse( diff --git a/backend/src/main/java/com/funeat/review/specification/LongTypeReviewSortSpec.java b/backend/src/main/java/com/funeat/review/specification/LongTypeReviewSortSpec.java new file mode 100644 index 000000000..23914e003 --- /dev/null +++ b/backend/src/main/java/com/funeat/review/specification/LongTypeReviewSortSpec.java @@ -0,0 +1,27 @@ +package com.funeat.review.specification; + +import com.funeat.review.domain.Review; +import java.util.Arrays; +import java.util.function.Function; + +public enum LongTypeReviewSortSpec { + + FAVORITE_COUNT("favoriteCount", Review::getFavoriteCount), + RATING("rating", Review::getRating); + + private final String fieldName; + private final Function function; + + LongTypeReviewSortSpec(final String fieldName, final Function function) { + this.fieldName = fieldName; + this.function = function; + } + + public static Long find(final String fieldName, final Review lastReview) { + return Arrays.stream(LongTypeReviewSortSpec.values()) + .filter(reviewSortSpec -> reviewSortSpec.fieldName.equals(fieldName)) + .findFirst() + .orElseThrow(IllegalArgumentException::new) + .function.apply(lastReview); + } +} diff --git a/backend/src/main/java/com/funeat/review/specification/SortingReviewSpecification.java b/backend/src/main/java/com/funeat/review/specification/SortingReviewSpecification.java new file mode 100644 index 000000000..781032017 --- /dev/null +++ b/backend/src/main/java/com/funeat/review/specification/SortingReviewSpecification.java @@ -0,0 +1,161 @@ +package com.funeat.review.specification; + +import static com.funeat.review.exception.ReviewErrorCode.NOT_SUPPORTED_REVIEW_SORTING_CONDITION; + +import com.funeat.product.domain.Product; +import com.funeat.review.domain.Review; +import com.funeat.review.exception.ReviewException.NotSupportedReviewSortingConditionException; +import java.time.LocalDateTime; +import java.util.List; +import java.util.Objects; +import javax.persistence.criteria.CriteriaBuilder; +import javax.persistence.criteria.Path; +import javax.persistence.criteria.Predicate; +import javax.persistence.criteria.Root; +import org.springframework.data.jpa.domain.Specification; + +public final class SortingReviewSpecification { + + private static final List LOCALDATETIME_TYPE_INCLUDE = List.of("createdAt"); + private static final List LONG_TYPE_INCLUDE = List.of("favoriteCount", "rating"); + private static final String DELIMITER = ","; + private static final String PRODUCT = "product"; + private static final String ID = "id"; + private static final String ASC = "ASC"; + + private SortingReviewSpecification() { + } + + public static Specification sortingFirstPageBy(final Product product) { + return (root, query, criteriaBuilder) -> Specification + .where(equalsProduct(product)) + .toPredicate(root, query, criteriaBuilder); + } + + public static Specification sortingBy(final Product product, final String sortOption, + final Review lastReview) { + return (root, query, criteriaBuilder) -> { + final String[] sortFieldSplit = sortOption.split(DELIMITER); + final String field = sortFieldSplit[0]; + final String sort = sortFieldSplit[1]; + + return Specification + .where((equalsProduct(product).and(equals(field, lastReview)).and(lessThanLastReviewId(lastReview))) + .or(equalsProduct(product).and(lessOrGreaterThan(field, sort, lastReview)))) + .toPredicate(root, query, criteriaBuilder); + }; + } + + private static Specification equalsProduct(final Product product) { + return (root, query, criteriaBuilder) -> { + if (Objects.isNull(product)) { + return null; + } + + final Path productPath = root.get(PRODUCT); + + return criteriaBuilder.equal(productPath, product); + }; + } + + private static Specification lessThanLastReviewId(final Review lastReview) { + return (root, query, criteriaBuilder) -> { + if (Objects.isNull(lastReview)) { + return null; + } + + final Path reviewPath = root.get(ID); + + return criteriaBuilder.lessThan(reviewPath, lastReview.getId()); + }; + } + + private static Specification equals(final String fieldName, final Review lastReview) { + return (root, query, criteriaBuilder) -> { + if (validateNull(fieldName, lastReview)) { + return null; + } + + return checkEquals(fieldName, lastReview, root, criteriaBuilder); + }; + } + + private static Predicate checkEquals(final String fieldName, + final Review lastReview, + final Root root, + final CriteriaBuilder criteriaBuilder) { + if (LOCALDATETIME_TYPE_INCLUDE.contains(fieldName)) { + final Path createdAtPath = root.get(fieldName); + final LocalDateTime lastReviewCreatedAt = lastReview.getCreatedAt(); + return criteriaBuilder.equal(createdAtPath, lastReviewCreatedAt); + } + if (LONG_TYPE_INCLUDE.contains(fieldName)) { + final Path reviewPath = root.get(fieldName); + final Long lastReviewField = LongTypeReviewSortSpec.find(fieldName, lastReview); + return criteriaBuilder.equal(reviewPath, lastReviewField); + } + throw new NotSupportedReviewSortingConditionException(NOT_SUPPORTED_REVIEW_SORTING_CONDITION, fieldName); + } + + private static Specification lessOrGreaterThan(final String field, final String sort, + final Review lastReview) { + if (ASC.equalsIgnoreCase(sort)) { + return greaterThan(field, lastReview); + } + return lessThan(field, lastReview); + } + + private static Specification greaterThan(final String fieldName, final Review lastReview) { + return (root, query, criteriaBuilder) -> { + if (validateNull(fieldName, lastReview)) { + return null; + } + + return checkGreaterThan(fieldName, lastReview, root, criteriaBuilder); + }; + } + + private static Predicate checkGreaterThan(final String fieldName, final Review lastReview, final Root root, + final CriteriaBuilder criteriaBuilder) { + if (LOCALDATETIME_TYPE_INCLUDE.contains(fieldName)) { + final Path createdAtPath = root.get(fieldName); + final LocalDateTime lastReviewCreatedAt = lastReview.getCreatedAt(); + return criteriaBuilder.greaterThan(createdAtPath, lastReviewCreatedAt); + } + if (LONG_TYPE_INCLUDE.contains(fieldName)) { + final Path reviewPath = root.get(fieldName); + final Long lastReviewField = LongTypeReviewSortSpec.find(fieldName, lastReview); + return criteriaBuilder.greaterThan(reviewPath, lastReviewField); + } + throw new NotSupportedReviewSortingConditionException(NOT_SUPPORTED_REVIEW_SORTING_CONDITION, fieldName); + } + + private static Specification lessThan(final String fieldName, final Review lastReview) { + return (root, query, criteriaBuilder) -> { + if (validateNull(fieldName, lastReview)) { + return null; + } + + return checkLessThan(fieldName, lastReview, root, criteriaBuilder); + }; + } + + private static boolean validateNull(final String fieldName, final Review lastReview) { + return Objects.isNull(fieldName) || Objects.isNull(lastReview); + } + + private static Predicate checkLessThan(final String fieldName, final Review lastReview, final Root root, + final CriteriaBuilder criteriaBuilder) { + if (LOCALDATETIME_TYPE_INCLUDE.contains(fieldName)) { + final Path createdAtPath = root.get(fieldName); + final LocalDateTime lastReviewCreatedAt = lastReview.getCreatedAt(); + return criteriaBuilder.lessThan(createdAtPath, lastReviewCreatedAt); + } + if (LONG_TYPE_INCLUDE.contains(fieldName)) { + final Path reviewPath = root.get(fieldName); + final Long lastReviewField = LongTypeReviewSortSpec.find(fieldName, lastReview); + return criteriaBuilder.lessThan(reviewPath, lastReviewField); + } + throw new NotSupportedReviewSortingConditionException(NOT_SUPPORTED_REVIEW_SORTING_CONDITION, fieldName); + } +} diff --git a/backend/src/main/java/com/funeat/tag/persistence/TagRepository.java b/backend/src/main/java/com/funeat/tag/persistence/TagRepository.java index b74e0197c..9ad319f7a 100644 --- a/backend/src/main/java/com/funeat/tag/persistence/TagRepository.java +++ b/backend/src/main/java/com/funeat/tag/persistence/TagRepository.java @@ -4,10 +4,18 @@ import com.funeat.tag.domain.TagType; import java.util.List; import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; public interface TagRepository extends JpaRepository { List findTagsByIdIn(final List tagIds); List findTagsByTagType(final TagType tagType); + + @Query("SELECT t " + + "FROM ReviewTag rt " + + "JOIN rt.tag t " + + "WHERE rt.review.id = :reviewId") + List findTagsByReviewId(@Param("reviewId") final Long reviewId); } diff --git a/backend/src/test/java/com/funeat/acceptance/common/CommonSteps.java b/backend/src/test/java/com/funeat/acceptance/common/CommonSteps.java index 32dcb85e2..af33aa14f 100644 --- a/backend/src/test/java/com/funeat/acceptance/common/CommonSteps.java +++ b/backend/src/test/java/com/funeat/acceptance/common/CommonSteps.java @@ -74,4 +74,10 @@ public class CommonSteps { assertThat(actual).usingRecursiveComparison() .isEqualTo(expected); } + + public static void 다음_데이터가_있는지_검증한다(final ExtractableResponse response, final boolean expected) { + final var actual = response.jsonPath().getBoolean("hasNext"); + + assertThat(actual).isEqualTo(expected); + } } diff --git a/backend/src/test/java/com/funeat/acceptance/review/ReviewAcceptanceTest.java b/backend/src/test/java/com/funeat/acceptance/review/ReviewAcceptanceTest.java index b1c7218db..76e4f48a5 100644 --- a/backend/src/test/java/com/funeat/acceptance/review/ReviewAcceptanceTest.java +++ b/backend/src/test/java/com/funeat/acceptance/review/ReviewAcceptanceTest.java @@ -2,6 +2,7 @@ import static com.funeat.acceptance.auth.LoginSteps.로그인_쿠키_획득; import static com.funeat.acceptance.common.CommonSteps.STATUS_CODE를_검증한다; +import static com.funeat.acceptance.common.CommonSteps.다음_데이터가_있는지_검증한다; import static com.funeat.acceptance.common.CommonSteps.사진_명세_요청; import static com.funeat.acceptance.common.CommonSteps.인증되지_않음; import static com.funeat.acceptance.common.CommonSteps.잘못된_요청; @@ -9,7 +10,6 @@ import static com.funeat.acceptance.common.CommonSteps.정상_처리; import static com.funeat.acceptance.common.CommonSteps.정상_처리_NO_CONTENT; import static com.funeat.acceptance.common.CommonSteps.찾을수_없음; -import static com.funeat.acceptance.common.CommonSteps.페이지를_검증한다; import static com.funeat.acceptance.product.ProductSteps.상품_상세_조회_요청; import static com.funeat.acceptance.review.ReviewSteps.리뷰_랭킹_조회_요청; import static com.funeat.acceptance.review.ReviewSteps.리뷰_작성_요청; @@ -29,13 +29,7 @@ import static com.funeat.fixture.MemberFixture.멤버2; import static com.funeat.fixture.MemberFixture.멤버3; import static com.funeat.fixture.PageFixture.FIRST_PAGE; -import static com.funeat.fixture.PageFixture.PAGE_SIZE; -import static com.funeat.fixture.PageFixture.마지막페이지O; -import static com.funeat.fixture.PageFixture.응답_페이지_생성; import static com.funeat.fixture.PageFixture.좋아요수_내림차순; -import static com.funeat.fixture.PageFixture.첫페이지O; -import static com.funeat.fixture.PageFixture.총_데이터_개수; -import static com.funeat.fixture.PageFixture.총_페이지; import static com.funeat.fixture.PageFixture.최신순; import static com.funeat.fixture.PageFixture.평점_내림차순; import static com.funeat.fixture.PageFixture.평점_오름차순; @@ -43,6 +37,7 @@ import static com.funeat.fixture.ProductFixture.상품_삼각김밥_가격1000원_평점3점_생성; import static com.funeat.fixture.ProductFixture.상품_삼각김밥_가격2000원_평점3점_생성; import static com.funeat.fixture.ProductFixture.존재하지_않는_상품; +import static com.funeat.fixture.ReviewFixture.다음_데이터_존재X; import static com.funeat.fixture.ReviewFixture.리뷰; import static com.funeat.fixture.ReviewFixture.리뷰1; import static com.funeat.fixture.ReviewFixture.리뷰2; @@ -56,6 +51,7 @@ import static com.funeat.fixture.ReviewFixture.존재하지_않는_리뷰; import static com.funeat.fixture.ReviewFixture.좋아요O; import static com.funeat.fixture.ReviewFixture.좋아요X; +import static com.funeat.fixture.ReviewFixture.첫_목록을_가져옴; import static com.funeat.fixture.ScoreFixture.점수_1점; import static com.funeat.fixture.ScoreFixture.점수_2점; import static com.funeat.fixture.ScoreFixture.점수_3점; @@ -386,14 +382,12 @@ class 좋아요_기준_내림차순으로_리뷰_목록_조회 { 여러명이_리뷰_좋아요_요청(List.of(멤버1), 상품, 리뷰3, 좋아요O); 여러명이_리뷰_좋아요_요청(List.of(멤버2, 멤버3), 상품, 리뷰2, 좋아요O); - final var 예상_응답_페이지 = 응답_페이지_생성(총_데이터_개수(3L), 총_페이지(1L), 첫페이지O, 마지막페이지O, FIRST_PAGE, PAGE_SIZE); - // when - final var 응답 = 정렬된_리뷰_목록_조회_요청(로그인_쿠키_획득(멤버1), 상품, 좋아요수_내림차순, FIRST_PAGE); + final var 응답 = 정렬된_리뷰_목록_조회_요청(로그인_쿠키_획득(멤버1), 상품, 첫_목록을_가져옴, 좋아요수_내림차순, FIRST_PAGE); // then STATUS_CODE를_검증한다(응답, 정상_처리); - 페이지를_검증한다(응답, 예상_응답_페이지); + 다음_데이터가_있는지_검증한다(응답, 다음_데이터_존재X); 정렬된_리뷰_목록_조회_결과를_검증한다(응답, List.of(리뷰2, 리뷰3, 리뷰1)); } @@ -409,14 +403,12 @@ class 좋아요_기준_내림차순으로_리뷰_목록_조회 { 리뷰_작성_요청(로그인_쿠키_획득(멤버2), 상품, 사진_명세_요청(이미지2), 리뷰추가요청_재구매O_생성(점수_4점, List.of(태그))); 리뷰_작성_요청(로그인_쿠키_획득(멤버3), 상품, 사진_명세_요청(이미지3), 리뷰추가요청_재구매X_생성(점수_3점, List.of(태그))); - final var 예상_응답_페이지 = 응답_페이지_생성(총_데이터_개수(3L), 총_페이지(1L), 첫페이지O, 마지막페이지O, FIRST_PAGE, PAGE_SIZE); - // when - final var 응답 = 정렬된_리뷰_목록_조회_요청(로그인_쿠키_획득(멤버1), 상품, 좋아요수_내림차순, FIRST_PAGE); + final var 응답 = 정렬된_리뷰_목록_조회_요청(로그인_쿠키_획득(멤버1), 상품, 첫_목록을_가져옴, 좋아요수_내림차순, FIRST_PAGE); // then STATUS_CODE를_검증한다(응답, 정상_처리); - 페이지를_검증한다(응답, 예상_응답_페이지); + 다음_데이터가_있는지_검증한다(응답, 다음_데이터_존재X); 정렬된_리뷰_목록_조회_결과를_검증한다(응답, List.of(리뷰3, 리뷰2, 리뷰1)); } } @@ -436,14 +428,12 @@ class 평점_기준_오름차순으로_리뷰_목록을_조회 { 리뷰_작성_요청(로그인_쿠키_획득(멤버2), 상품, 사진_명세_요청(이미지2), 리뷰추가요청_재구매O_생성(점수_4점, List.of(태그))); 리뷰_작성_요청(로그인_쿠키_획득(멤버3), 상품, 사진_명세_요청(이미지3), 리뷰추가요청_재구매X_생성(점수_3점, List.of(태그))); - final var 예상_응답_페이지 = 응답_페이지_생성(총_데이터_개수(3L), 총_페이지(1L), 첫페이지O, 마지막페이지O, FIRST_PAGE, PAGE_SIZE); - // when - final var 응답 = 정렬된_리뷰_목록_조회_요청(로그인_쿠키_획득(멤버1), 상품, 평점_오름차순, FIRST_PAGE); + final var 응답 = 정렬된_리뷰_목록_조회_요청(로그인_쿠키_획득(멤버1), 상품, 첫_목록을_가져옴, 평점_오름차순, FIRST_PAGE); // then STATUS_CODE를_검증한다(응답, 정상_처리); - 페이지를_검증한다(응답, 예상_응답_페이지); + 다음_데이터가_있는지_검증한다(응답, 다음_데이터_존재X); 정렬된_리뷰_목록_조회_결과를_검증한다(응답, List.of(리뷰1, 리뷰3, 리뷰2)); } @@ -459,14 +449,12 @@ class 평점_기준_오름차순으로_리뷰_목록을_조회 { 리뷰_작성_요청(로그인_쿠키_획득(멤버2), 상품, 사진_명세_요청(이미지2), 리뷰추가요청_재구매O_생성(점수_3점, List.of(태그))); 리뷰_작성_요청(로그인_쿠키_획득(멤버3), 상품, 사진_명세_요청(이미지3), 리뷰추가요청_재구매X_생성(점수_3점, List.of(태그))); - final var 예상_응답_페이지 = 응답_페이지_생성(총_데이터_개수(3L), 총_페이지(1L), 첫페이지O, 마지막페이지O, FIRST_PAGE, PAGE_SIZE); - // when - final var 응답 = 정렬된_리뷰_목록_조회_요청(로그인_쿠키_획득(멤버1), 상품, 평점_오름차순, FIRST_PAGE); + final var 응답 = 정렬된_리뷰_목록_조회_요청(로그인_쿠키_획득(멤버1), 상품, 첫_목록을_가져옴, 평점_오름차순, FIRST_PAGE); // then STATUS_CODE를_검증한다(응답, 정상_처리); - 페이지를_검증한다(응답, 예상_응답_페이지); + 다음_데이터가_있는지_검증한다(응답, 다음_데이터_존재X); 정렬된_리뷰_목록_조회_결과를_검증한다(응답, List.of(리뷰3, 리뷰2, 리뷰1)); } } @@ -486,14 +474,12 @@ class 평점_기준_내림차순으로_리뷰_목록_조회 { 리뷰_작성_요청(로그인_쿠키_획득(멤버2), 상품, 사진_명세_요청(이미지2), 리뷰추가요청_재구매O_생성(점수_4점, List.of(태그))); 리뷰_작성_요청(로그인_쿠키_획득(멤버3), 상품, 사진_명세_요청(이미지3), 리뷰추가요청_재구매X_생성(점수_3점, List.of(태그))); - final var 예상_응답_페이지 = 응답_페이지_생성(총_데이터_개수(3L), 총_페이지(1L), 첫페이지O, 마지막페이지O, FIRST_PAGE, PAGE_SIZE); - // when - final var 응답 = 정렬된_리뷰_목록_조회_요청(로그인_쿠키_획득(멤버1), 상품, 평점_내림차순, FIRST_PAGE); + final var 응답 = 정렬된_리뷰_목록_조회_요청(로그인_쿠키_획득(멤버1), 상품, 첫_목록을_가져옴, 평점_내림차순, FIRST_PAGE); // then STATUS_CODE를_검증한다(응답, 정상_처리); - 페이지를_검증한다(응답, 예상_응답_페이지); + 다음_데이터가_있는지_검증한다(응답, 다음_데이터_존재X); 정렬된_리뷰_목록_조회_결과를_검증한다(응답, List.of(리뷰2, 리뷰3, 리뷰1)); } @@ -509,14 +495,12 @@ class 평점_기준_내림차순으로_리뷰_목록_조회 { 리뷰_작성_요청(로그인_쿠키_획득(멤버2), 상품, 사진_명세_요청(이미지2), 리뷰추가요청_재구매O_생성(점수_3점, List.of(태그))); 리뷰_작성_요청(로그인_쿠키_획득(멤버3), 상품, 사진_명세_요청(이미지3), 리뷰추가요청_재구매X_생성(점수_3점, List.of(태그))); - final var 예상_응답_페이지 = 응답_페이지_생성(총_데이터_개수(3L), 총_페이지(1L), 첫페이지O, 마지막페이지O, FIRST_PAGE, PAGE_SIZE); - // when - final var 응답 = 정렬된_리뷰_목록_조회_요청(로그인_쿠키_획득(멤버1), 상품, 평점_내림차순, FIRST_PAGE); + final var 응답 = 정렬된_리뷰_목록_조회_요청(로그인_쿠키_획득(멤버1), 상품, 첫_목록을_가져옴, 평점_내림차순, FIRST_PAGE); // then STATUS_CODE를_검증한다(응답, 정상_처리); - 페이지를_검증한다(응답, 예상_응답_페이지); + 다음_데이터가_있는지_검증한다(응답, 다음_데이터_존재X); 정렬된_리뷰_목록_조회_결과를_검증한다(응답, List.of(리뷰3, 리뷰2, 리뷰1)); } } @@ -536,14 +520,12 @@ class 최신순으로_리뷰_목록을_조회 { 리뷰_작성_요청(로그인_쿠키_획득(멤버2), 상품, 사진_명세_요청(이미지2), 리뷰추가요청_재구매O_생성(점수_4점, List.of(태그))); 리뷰_작성_요청(로그인_쿠키_획득(멤버3), 상품, 사진_명세_요청(이미지3), 리뷰추가요청_재구매X_생성(점수_3점, List.of(태그))); - final var 예상_응답_페이지 = 응답_페이지_생성(총_데이터_개수(3L), 총_페이지(1L), 첫페이지O, 마지막페이지O, FIRST_PAGE, PAGE_SIZE); - // when - final var 응답 = 정렬된_리뷰_목록_조회_요청(로그인_쿠키_획득(멤버1), 상품, 최신순, FIRST_PAGE); + final var 응답 = 정렬된_리뷰_목록_조회_요청(로그인_쿠키_획득(멤버1), 상품, 첫_목록을_가져옴, 최신순, FIRST_PAGE); // then STATUS_CODE를_검증한다(응답, 정상_처리); - 페이지를_검증한다(응답, 예상_응답_페이지); + 다음_데이터가_있는지_검증한다(응답, 다음_데이터_존재X); 정렬된_리뷰_목록_조회_결과를_검증한다(응답, List.of(리뷰3, 리뷰2, 리뷰1)); } } @@ -564,7 +546,7 @@ class getSortingReviews_실패_테스트 { 리뷰_작성_요청(로그인_쿠키_획득(멤버1), 상품, 사진_명세_요청(이미지1), 리뷰추가요청_재구매O_생성(점수_2점, List.of(태그))); // when - final var 응답 = 정렬된_리뷰_목록_조회_요청(cookie, 상품, 좋아요수_내림차순, FIRST_PAGE); + final var 응답 = 정렬된_리뷰_목록_조회_요청(cookie, 상품, 첫_목록을_가져옴, 좋아요수_내림차순, FIRST_PAGE); // then STATUS_CODE를_검증한다(응답, 인증되지_않음); @@ -574,8 +556,8 @@ class getSortingReviews_실패_테스트 { @Test void 존재하지_않는_상품의_리뷰_목록을_조회시_예외가_발생한다() { - // given & when - final var 응답 = 정렬된_리뷰_목록_조회_요청(로그인_쿠키_획득(멤버1), 존재하지_않는_상품, 좋아요수_내림차순, FIRST_PAGE); + // given && when + final var 응답 = 정렬된_리뷰_목록_조회_요청(로그인_쿠키_획득(멤버1), 존재하지_않는_상품, 첫_목록을_가져옴, 좋아요수_내림차순, FIRST_PAGE); // then STATUS_CODE를_검증한다(응답, 찾을수_없음); diff --git a/backend/src/test/java/com/funeat/acceptance/review/ReviewSteps.java b/backend/src/test/java/com/funeat/acceptance/review/ReviewSteps.java index 93dfd6292..ae4f81c16 100644 --- a/backend/src/test/java/com/funeat/acceptance/review/ReviewSteps.java +++ b/backend/src/test/java/com/funeat/acceptance/review/ReviewSteps.java @@ -55,14 +55,16 @@ public class ReviewSteps { } public static ExtractableResponse 정렬된_리뷰_목록_조회_요청(final String loginCookie, final Long productId, + final Long lastReviewId, final String sort, final Long page) { return given() .cookie("JSESSIONID", loginCookie) .queryParam("sort", sort) .queryParam("page", page) + .queryParam("lastReviewId", lastReviewId).log().all() .when() .get("/api/products/{product_id}/reviews", productId) - .then() + .then().log().all() .extract(); } diff --git a/backend/src/test/java/com/funeat/fixture/PageFixture.java b/backend/src/test/java/com/funeat/fixture/PageFixture.java index afae2d14a..773658f48 100644 --- a/backend/src/test/java/com/funeat/fixture/PageFixture.java +++ b/backend/src/test/java/com/funeat/fixture/PageFixture.java @@ -2,6 +2,7 @@ import com.funeat.common.dto.PageDto; import org.springframework.data.domain.PageRequest; +import org.springframework.data.domain.Pageable; import org.springframework.data.domain.Sort; import org.springframework.data.domain.Sort.Direction; @@ -18,6 +19,7 @@ public class PageFixture { public static final String 평점_내림차순 = "rating,desc"; public static final String 과거순 = "createdAt,asc"; public static final String 최신순 = "createdAt,desc"; + public static final String 아이디_내림차순 = "id,desc"; public static final Long PAGE_SIZE = 10L; public static final Long FIRST_PAGE = 0L; @@ -45,6 +47,30 @@ public class PageFixture { return new PageDto(totalDataCount, totalPages, firstPage, lastPage, requestPage, requestSize); } + public static Pageable 페이지요청_좋아요_내림차순_생성(final int page, final int size) { + final var sort = Sort.by(Direction.DESC, "favoriteCount"); + + return PageRequest.of(page, size, sort); + } + + public static Pageable 페이지요청_최신순_생성(final int page, final int size) { + final var sort = Sort.by(Direction.DESC, "createdAt"); + + return PageRequest.of(page, size, sort); + } + + public static Pageable 페이지요청_평점_오름차순_생성(final int page, final int size) { + final var sort = Sort.by(Direction.ASC, "rating"); + + return PageRequest.of(page, size, sort); + } + + public static Pageable 페이지요청_평점_내림차순_생성(final int page, final int size) { + final var sort = Sort.by(Direction.DESC, "rating"); + + return PageRequest.of(page, size, sort); + } + public static Long 총_데이터_개수(final Long count) { return count; } diff --git a/backend/src/test/java/com/funeat/fixture/ReviewFixture.java b/backend/src/test/java/com/funeat/fixture/ReviewFixture.java index 3ae53a3c7..d68a09b6b 100644 --- a/backend/src/test/java/com/funeat/fixture/ReviewFixture.java +++ b/backend/src/test/java/com/funeat/fixture/ReviewFixture.java @@ -1,10 +1,16 @@ package com.funeat.fixture; +import static com.funeat.fixture.PageFixture.좋아요수_내림차순; +import static com.funeat.fixture.PageFixture.최신순; +import static com.funeat.fixture.PageFixture.평점_내림차순; +import static com.funeat.fixture.PageFixture.평점_오름차순; + import com.funeat.member.domain.Member; import com.funeat.product.domain.Product; import com.funeat.review.domain.Review; import com.funeat.review.dto.ReviewCreateRequest; import com.funeat.review.dto.ReviewFavoriteRequest; +import com.funeat.review.dto.SortingReviewRequest; import java.util.List; @SuppressWarnings("NonAsciiCharacters") @@ -21,6 +27,10 @@ public class ReviewFixture { public static final boolean 재구매O = true; public static final boolean 재구매X = false; + public static final Long 첫_목록을_가져옴 = 0L; + public static final boolean 다음_데이터_존재O = true; + public static final boolean 다음_데이터_존재X = false; + public static Review 리뷰_이미지test1_평점1점_재구매O_생성(final Member member, final Product product, final Long count) { return new Review(member, product, "test1", 1L, "test", true, count); } @@ -81,4 +91,24 @@ public class ReviewFixture { public static ReviewFavoriteRequest 리뷰좋아요요청_생성(final Boolean favorite) { return new ReviewFavoriteRequest(favorite); } + + public static SortingReviewRequest 리뷰정렬요청_좋아요수_내림차순_생성(final Long lastReviewId) { + return new SortingReviewRequest(좋아요수_내림차순, lastReviewId); + } + + public static SortingReviewRequest 리뷰정렬요청_최신순_생성(final Long lastReviewId) { + return new SortingReviewRequest(최신순, lastReviewId); + } + + public static SortingReviewRequest 리뷰정렬요청_평점_오름차순_생성(final Long lastReviewId) { + return new SortingReviewRequest(평점_오름차순, lastReviewId); + } + + public static SortingReviewRequest 리뷰정렬요청_평점_내림차순_생성(final Long lastReviewId) { + return new SortingReviewRequest(평점_내림차순, lastReviewId); + } + + public static SortingReviewRequest 리뷰정렬요청_존재하지않는정렬_생성() { + return new SortingReviewRequest("test,test", 1L); + } } diff --git a/backend/src/test/java/com/funeat/recipe/application/RecipeServiceTest.java b/backend/src/test/java/com/funeat/recipe/application/RecipeServiceTest.java index 3fecf28bb..c0f68e789 100644 --- a/backend/src/test/java/com/funeat/recipe/application/RecipeServiceTest.java +++ b/backend/src/test/java/com/funeat/recipe/application/RecipeServiceTest.java @@ -337,9 +337,9 @@ class getSortingRecipes_성공_테스트 { 복수_상품_저장(product1, product2, product3); final var recipe1_1 = 레시피_생성(member1, 1L); - Thread.sleep(1000); + Thread.sleep(100); final var recipe1_2 = 레시피_생성(member1, 3L); - Thread.sleep(1000); + Thread.sleep(100); final var recipe1_3 = 레시피_생성(member1, 2L); 복수_꿀조합_저장(recipe1_1, recipe1_2, recipe1_3); diff --git a/backend/src/test/java/com/funeat/recipe/persistence/RecipeRepositoryTest.java b/backend/src/test/java/com/funeat/recipe/persistence/RecipeRepositoryTest.java index f52eae169..494f7b215 100644 --- a/backend/src/test/java/com/funeat/recipe/persistence/RecipeRepositoryTest.java +++ b/backend/src/test/java/com/funeat/recipe/persistence/RecipeRepositoryTest.java @@ -135,9 +135,9 @@ class findAllRecipes_성공_테스트 { 복수_상품_저장(product1, product2, product3); final var recipe1_1 = 레시피_생성(member1, 1L); - Thread.sleep(1000); + Thread.sleep(100); final var recipe1_2 = 레시피_생성(member1, 3L); - Thread.sleep(1000); + Thread.sleep(100); final var recipe1_3 = 레시피_생성(member1, 2L); 복수_꿀조합_저장(recipe1_1, recipe1_2, recipe1_3); diff --git a/backend/src/test/java/com/funeat/review/application/ReviewServiceTest.java b/backend/src/test/java/com/funeat/review/application/ReviewServiceTest.java index 597033063..2e7c82b43 100644 --- a/backend/src/test/java/com/funeat/review/application/ReviewServiceTest.java +++ b/backend/src/test/java/com/funeat/review/application/ReviewServiceTest.java @@ -4,13 +4,8 @@ import static com.funeat.fixture.ImageFixture.이미지_생성; import static com.funeat.fixture.MemberFixture.멤버_멤버1_생성; import static com.funeat.fixture.MemberFixture.멤버_멤버2_생성; -import static com.funeat.fixture.MemberFixture.멤버_멤버3_생성; -import static com.funeat.fixture.PageFixture.좋아요수_내림차순; import static com.funeat.fixture.PageFixture.최신순; -import static com.funeat.fixture.PageFixture.페이지요청_기본_생성; import static com.funeat.fixture.PageFixture.페이지요청_생성; -import static com.funeat.fixture.PageFixture.평점_내림차순; -import static com.funeat.fixture.PageFixture.평점_오름차순; import static com.funeat.fixture.ProductFixture.상품_삼각김밥_가격1000원_평점2점_생성; import static com.funeat.fixture.ProductFixture.상품_삼각김밥_가격1000원_평점3점_생성; import static com.funeat.fixture.ProductFixture.상품_삼각김밥_가격1000원_평점5점_생성; @@ -24,6 +19,10 @@ import static com.funeat.fixture.ReviewFixture.리뷰_이미지test3_평점3점_재구매X_생성; import static com.funeat.fixture.ReviewFixture.리뷰_이미지test4_평점4점_재구매O_생성; import static com.funeat.fixture.ReviewFixture.리뷰_이미지없음_평점1점_재구매O_생성; +import static com.funeat.fixture.ReviewFixture.리뷰정렬요청_좋아요수_내림차순_생성; +import static com.funeat.fixture.ReviewFixture.리뷰정렬요청_최신순_생성; +import static com.funeat.fixture.ReviewFixture.리뷰정렬요청_평점_내림차순_생성; +import static com.funeat.fixture.ReviewFixture.리뷰정렬요청_평점_오름차순_생성; import static com.funeat.fixture.ReviewFixture.리뷰좋아요요청_생성; import static com.funeat.fixture.ReviewFixture.리뷰추가요청_재구매O_생성; import static com.funeat.fixture.TagFixture.태그_맛있어요_TASTE_생성; @@ -341,44 +340,36 @@ class sortingReviews_성공_테스트 { @Test void 좋아요_기준으로_내림차순_정렬을_할_수_있다() { // given - final var member1 = 멤버_멤버1_생성(); - final var member2 = 멤버_멤버2_생성(); - final var member3 = 멤버_멤버3_생성(); - 복수_멤버_저장(member1, member2, member3); + final var member = 멤버_멤버1_생성(); + final var memberId = 단일_멤버_저장(member); final var category = 카테고리_즉석조리_생성(); 단일_카테고리_저장(category); - final var product = 상품_삼각김밥_가격1000원_평점3점_생성(category); final var productId = 단일_상품_저장(product); - final var review1 = 리뷰_이미지test3_평점3점_재구매O_생성(member1, product, 351L); - final var review2 = 리뷰_이미지test4_평점4점_재구매O_생성(member2, product, 24L); - final var review3 = 리뷰_이미지test3_평점3점_재구매X_생성(member3, product, 130L); + final var review1 = 리뷰_이미지test3_평점3점_재구매O_생성(member, product, 351L); + final var review2 = 리뷰_이미지test4_평점4점_재구매O_생성(member, product, 24L); + final var review3 = 리뷰_이미지test3_평점3점_재구매X_생성(member, product, 130L); 복수_리뷰_저장(review1, review2, review3); - final var page = 페이지요청_생성(0, 2, 좋아요수_내림차순); - final var member1Id = member1.getId(); + final var request = 리뷰정렬요청_좋아요수_내림차순_생성(0L); - final var expected = Stream.of(review1, review3) - .map(review -> SortingReviewDto.toDto(review, member1)) - .collect(Collectors.toList()); + final var expected = List.of(review1.getId(), review3.getId(), review2.getId()); // when - final var actual = reviewService.sortingReviews(productId, page, member1Id).getReviews(); + final var actual = reviewService.sortingReviews(productId, memberId, request).getReviews(); // then - assertThat(actual).usingRecursiveComparison() - .isEqualTo(expected); + assertThat(actual).extracting(SortingReviewDto::getId) + .containsExactlyElementsOf(expected); } @Test - void 평점_기준으로_오름차순_정렬을_할_수_있다() { + void 최신순으로_정렬을_할_수_있다() throws InterruptedException { // given - final var member1 = 멤버_멤버1_생성(); - final var member2 = 멤버_멤버2_생성(); - final var member3 = 멤버_멤버3_생성(); - 복수_멤버_저장(member1, member2, member3); + final var member = 멤버_멤버1_생성(); + final var memberId = 단일_멤버_저장(member); final var category = 카테고리_즉석조리_생성(); 단일_카테고리_저장(category); @@ -386,33 +377,30 @@ class sortingReviews_성공_테스트 { final var product = 상품_삼각김밥_가격1000원_평점3점_생성(category); final var productId = 단일_상품_저장(product); - final var review1 = 리뷰_이미지test2_평점2점_재구매O_생성(member1, product, 351L); - final var review2 = 리뷰_이미지test4_평점4점_재구매O_생성(member2, product, 24L); - final var review3 = 리뷰_이미지test3_평점3점_재구매X_생성(member3, product, 130L); + final var review1 = 리뷰_이미지test3_평점3점_재구매O_생성(member, product, 351L); + Thread.sleep(100); + final var review2 = 리뷰_이미지test4_평점4점_재구매O_생성(member, product, 24L); + Thread.sleep(100); + final var review3 = 리뷰_이미지test3_평점3점_재구매X_생성(member, product, 130L); 복수_리뷰_저장(review1, review2, review3); - final var page = 페이지요청_생성(0, 2, 평점_오름차순); - final var member1Id = member1.getId(); + final var request = 리뷰정렬요청_최신순_생성(3L); - final var expected = Stream.of(review1, review3) - .map(review -> SortingReviewDto.toDto(review, member1)) - .collect(Collectors.toList()); + final var expected = List.of(review2.getId(), review1.getId()); // when - final var actual = reviewService.sortingReviews(productId, page, member1Id).getReviews(); + final var actual = reviewService.sortingReviews(productId, memberId, request).getReviews(); // then - assertThat(actual).usingRecursiveComparison() - .isEqualTo(expected); + assertThat(actual).extracting(SortingReviewDto::getId) + .containsExactlyElementsOf(expected); } @Test - void 평점_기준으로_내림차순_정렬을_할_수_있다() { + void 평점_기준으로_오름차순_정렬을_할_수_있다() { // given - final var member1 = 멤버_멤버1_생성(); - final var member2 = 멤버_멤버2_생성(); - final var member3 = 멤버_멤버3_생성(); - 복수_멤버_저장(member1, member2, member3); + final var member = 멤버_멤버1_생성(); + final var memberId = 단일_멤버_저장(member); final var category = 카테고리_즉석조리_생성(); 단일_카테고리_저장(category); @@ -420,33 +408,28 @@ class sortingReviews_성공_테스트 { final var product = 상품_삼각김밥_가격1000원_평점3점_생성(category); final var productId = 단일_상품_저장(product); - final var review1 = 리뷰_이미지test3_평점3점_재구매O_생성(member1, product, 351L); - final var review2 = 리뷰_이미지test4_평점4점_재구매O_생성(member2, product, 24L); - final var review3 = 리뷰_이미지test3_평점3점_재구매X_생성(member3, product, 130L); + final var review1 = 리뷰_이미지test2_평점2점_재구매O_생성(member, product, 351L); + final var review2 = 리뷰_이미지test4_평점4점_재구매O_생성(member, product, 24L); + final var review3 = 리뷰_이미지test3_평점3점_재구매X_생성(member, product, 130L); 복수_리뷰_저장(review1, review2, review3); - final var page = 페이지요청_생성(0, 2, 평점_내림차순); - final var member1Id = member1.getId(); + final var request = 리뷰정렬요청_평점_오름차순_생성(0L); - final var expected = Stream.of(review2, review3) - .map(review -> SortingReviewDto.toDto(review, member1)) - .collect(Collectors.toList()); + final var expected = List.of(review1.getId(), review3.getId(), review2.getId()); // when - final var actual = reviewService.sortingReviews(productId, page, member1Id).getReviews(); + final var actual = reviewService.sortingReviews(productId, memberId, request).getReviews(); // then - assertThat(actual).usingRecursiveComparison() - .isEqualTo(expected); + assertThat(actual).extracting(SortingReviewDto::getId) + .containsExactlyElementsOf(expected); } @Test - void 최신순으로_정렬을_할_수_있다() throws InterruptedException { + void 평점_기준으로_내림차순_정렬을_할_수_있다() { // given - final var member1 = 멤버_멤버1_생성(); - final var member2 = 멤버_멤버2_생성(); - final var member3 = 멤버_멤버3_생성(); - 복수_멤버_저장(member1, member2, member3); + final var member = 멤버_멤버1_생성(); + final var memberId = 단일_멤버_저장(member); final var category = 카테고리_즉석조리_생성(); 단일_카테고리_저장(category); @@ -454,26 +437,21 @@ class sortingReviews_성공_테스트 { final var product = 상품_삼각김밥_가격1000원_평점3점_생성(category); final var productId = 단일_상품_저장(product); - final var review1 = 리뷰_이미지test3_평점3점_재구매O_생성(member1, product, 351L); - Thread.sleep(1000); - final var review2 = 리뷰_이미지test4_평점4점_재구매O_생성(member2, product, 24L); - Thread.sleep(1000); - final var review3 = 리뷰_이미지test3_평점3점_재구매X_생성(member3, product, 130L); + final var review1 = 리뷰_이미지test4_평점4점_재구매O_생성(member, product, 5L); + final var review2 = 리뷰_이미지test2_평점2점_재구매O_생성(member, product, 24L); + final var review3 = 리뷰_이미지test3_평점3점_재구매X_생성(member, product, 13L); 복수_리뷰_저장(review1, review2, review3); - final var page = 페이지요청_생성(0, 2, 최신순); - final var member1Id = member1.getId(); + final var request = 리뷰정렬요청_평점_내림차순_생성(1L); - final var expected = Stream.of(review3, review2) - .map(review -> SortingReviewDto.toDto(review, member1)) - .collect(Collectors.toList()); + final var expected = List.of(review3.getId(), review2.getId()); // when - final var actual = reviewService.sortingReviews(productId, page, member1Id).getReviews(); + final var actual = reviewService.sortingReviews(productId, memberId, request).getReviews(); // then - assertThat(actual).usingRecursiveComparison() - .isEqualTo(expected); + assertThat(actual).extracting(SortingReviewDto::getId) + .containsExactlyElementsOf(expected); } } @@ -483,10 +461,8 @@ class sortingReviews_실패_테스트 { @Test void 존재하지_않는_멤버가_상품에_있는_리뷰들을_정렬하면_예외가_발생한다() { // given - final var member1 = 멤버_멤버1_생성(); - final var member2 = 멤버_멤버2_생성(); - final var member3 = 멤버_멤버3_생성(); - 복수_멤버_저장(member1, member2, member3); + final var member = 멤버_멤버1_생성(); + final var wrongMemberId = 단일_멤버_저장(member) + 3L; final var category = 카테고리_즉석조리_생성(); 단일_카테고리_저장(category); @@ -494,26 +470,23 @@ class sortingReviews_실패_테스트 { final var product = 상품_삼각김밥_가격1000원_평점3점_생성(category); final var productId = 단일_상품_저장(product); - final var review1 = 리뷰_이미지test3_평점3점_재구매O_생성(member1, product, 351L); - final var review2 = 리뷰_이미지test4_평점4점_재구매O_생성(member2, product, 24L); - final var review3 = 리뷰_이미지test3_평점3점_재구매X_생성(member3, product, 130L); + final var review1 = 리뷰_이미지test3_평점3점_재구매O_생성(member, product, 351L); + final var review2 = 리뷰_이미지test4_평점4점_재구매O_생성(member, product, 24L); + final var review3 = 리뷰_이미지test3_평점3점_재구매X_생성(member, product, 130L); 복수_리뷰_저장(review1, review2, review3); - final var page = 페이지요청_기본_생성(0, 2); - final var wrongMemberId = member1.getId() + 3L; + final var request = 리뷰정렬요청_평점_내림차순_생성(1L); // when & then - assertThatThrownBy(() -> reviewService.sortingReviews(productId, page, wrongMemberId)) + assertThatThrownBy(() -> reviewService.sortingReviews(productId, wrongMemberId, request)) .isInstanceOf(MemberNotFoundException.class); } @Test void 멤버가_존재하지_않는_상품에_있는_리뷰들을_정렬하면_예외가_발생한다() { // given - final var member1 = 멤버_멤버1_생성(); - final var member2 = 멤버_멤버2_생성(); - final var member3 = 멤버_멤버3_생성(); - 복수_멤버_저장(member1, member2, member3); + final var member = 멤버_멤버1_생성(); + final var memberId = 단일_멤버_저장(member); final var category = 카테고리_즉석조리_생성(); 단일_카테고리_저장(category); @@ -521,16 +494,15 @@ class sortingReviews_실패_테스트 { final var product = 상품_삼각김밥_가격1000원_평점3점_생성(category); final var wrongProductId = 단일_상품_저장(product) + 1L; - final var review1 = 리뷰_이미지test3_평점3점_재구매O_생성(member1, product, 351L); - final var review2 = 리뷰_이미지test4_평점4점_재구매O_생성(member2, product, 24L); - final var review3 = 리뷰_이미지test3_평점3점_재구매X_생성(member3, product, 130L); + final var review1 = 리뷰_이미지test3_평점3점_재구매O_생성(member, product, 351L); + final var review2 = 리뷰_이미지test4_평점4점_재구매O_생성(member, product, 24L); + final var review3 = 리뷰_이미지test3_평점3점_재구매X_생성(member, product, 130L); 복수_리뷰_저장(review1, review2, review3); - final var page = 페이지요청_기본_생성(0, 2); - final var member1Id = member1.getId(); + final var request = 리뷰정렬요청_평점_내림차순_생성(1L); // when & then - assertThatThrownBy(() -> reviewService.sortingReviews(wrongProductId, page, member1Id)) + assertThatThrownBy(() -> reviewService.sortingReviews(wrongProductId, memberId, request)) .isInstanceOf(ProductNotFoundException.class); } } diff --git a/backend/src/test/java/com/funeat/review/persistence/ReviewRepositoryTest.java b/backend/src/test/java/com/funeat/review/persistence/ReviewRepositoryTest.java index d0198db1a..ebac6adac 100644 --- a/backend/src/test/java/com/funeat/review/persistence/ReviewRepositoryTest.java +++ b/backend/src/test/java/com/funeat/review/persistence/ReviewRepositoryTest.java @@ -5,8 +5,6 @@ import static com.funeat.fixture.MemberFixture.멤버_멤버1_생성; import static com.funeat.fixture.MemberFixture.멤버_멤버2_생성; import static com.funeat.fixture.MemberFixture.멤버_멤버3_생성; -import static com.funeat.fixture.PageFixture.좋아요수_내림차순; -import static com.funeat.fixture.PageFixture.페이지요청_생성; import static com.funeat.fixture.ProductFixture.상품_삼각김밥_가격1000원_평점1점_생성; import static com.funeat.fixture.ProductFixture.상품_삼각김밥_가격1000원_평점2점_생성; import static com.funeat.fixture.ProductFixture.상품_삼각김밥_가격2000원_평점3점_생성; @@ -66,41 +64,6 @@ class countByProduct_성공_테스트 { } } - @Nested - class findReviewsByProduct_성공_테스트 { - - @Test - void 특정_상품에_대한_좋아요_기준_내림차순으로_정렬한다() { - // given - final var member1 = 멤버_멤버1_생성(); - final var member2 = 멤버_멤버2_생성(); - final var member3 = 멤버_멤버3_생성(); - 복수_멤버_저장(member1, member2, member3); - - final var category = 카테고리_간편식사_생성(); - 단일_카테고리_저장(category); - - final var product = 상품_삼각김밥_가격1000원_평점2점_생성(category); - 단일_상품_저장(product); - - final var review1 = 리뷰_이미지test3_평점3점_재구매O_생성(member1, product, 351L); - final var review2 = 리뷰_이미지test4_평점4점_재구매O_생성(member2, product, 24L); - final var review3 = 리뷰_이미지test3_평점3점_재구매X_생성(member3, product, 130L); - 복수_리뷰_저장(review1, review2, review3); - - final var page = 페이지요청_생성(0, 2, 좋아요수_내림차순); - - final var expected = List.of(review1, review3); - - // when - final var actual = reviewRepository.findReviewsByProduct(page, product).getContent(); - - // then - assertThat(actual).usingRecursiveComparison() - .isEqualTo(expected); - } - } - @Nested class findTop3ByOrderByFavoriteCountDesc_성공_테스트 { @@ -129,7 +92,7 @@ class findTop3ByOrderByFavoriteCountDesc_성공_테스트 { final var expected = List.of(review1_2, review2_2, review1_3); // when - final var actual = reviewRepository.findTop3ByOrderByFavoriteCountDesc(); + final var actual = reviewRepository.findTop3ByOrderByFavoriteCountDescIdDesc(); // then assertThat(actual).usingRecursiveComparison() From 0c55b8ab173ea0ecd30e0ccaa0f519356f329412 Mon Sep 17 00:00:00 2001 From: Taeeun Kim Date: Tue, 17 Oct 2023 10:26:39 +0900 Subject: [PATCH 24/55] =?UTF-8?q?[FE]=20feat:=20=EA=BF=80=EC=A1=B0?= =?UTF-8?q?=ED=95=A9=20=EB=8C=93=EA=B8=80=20=EB=B2=84=EA=B7=B8=20=EC=88=98?= =?UTF-8?q?=EC=A0=95=20=EB=B0=8F=20=EA=B0=9C=EC=84=A0=20(#779)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix: 댓글 갯수를 totalElements로 수정 * feat: 댓글 입력 후 가장 위로 올라가는 기능 추가 --- .../src/components/Recipe/CommentForm/CommentForm.tsx | 9 +++++++-- .../src/components/Recipe/CommentList/CommentList.tsx | 10 +++++----- frontend/src/mocks/data/comments.json | 1 + frontend/src/pages/RecipeDetailPage.tsx | 11 +++++++---- 4 files changed, 20 insertions(+), 11 deletions(-) diff --git a/frontend/src/components/Recipe/CommentForm/CommentForm.tsx b/frontend/src/components/Recipe/CommentForm/CommentForm.tsx index 33c03d1e2..104657552 100644 --- a/frontend/src/components/Recipe/CommentForm/CommentForm.tsx +++ b/frontend/src/components/Recipe/CommentForm/CommentForm.tsx @@ -1,25 +1,29 @@ import { Button, Spacing, Text, Textarea, useTheme } from '@fun-eat/design-system'; -import type { ChangeEventHandler, FormEventHandler } from 'react'; +import type { ChangeEventHandler, FormEventHandler, RefObject } from 'react'; import { useState } from 'react'; import styled from 'styled-components'; import { SvgIcon } from '@/components/Common'; +import { useScroll } from '@/hooks/common'; import { useToastActionContext } from '@/hooks/context'; import { useRecipeCommentMutation } from '@/hooks/queries/recipe'; interface CommentFormProps { recipeId: number; + scrollTargetRef: RefObject; } const MAX_COMMENT_LENGTH = 200; -const CommentForm = ({ recipeId }: CommentFormProps) => { +const CommentForm = ({ recipeId, scrollTargetRef }: CommentFormProps) => { const [commentValue, setCommentValue] = useState(''); const { mutate } = useRecipeCommentMutation(recipeId); const theme = useTheme(); const { toast } = useToastActionContext(); + const { scrollToPosition } = useScroll(); + const handleCommentInput: ChangeEventHandler = (e) => { setCommentValue(e.target.value); }; @@ -32,6 +36,7 @@ const CommentForm = ({ recipeId }: CommentFormProps) => { { onSuccess: () => { setCommentValue(''); + scrollToPosition(scrollTargetRef); toast.success('댓글이 등록되었습니다.'); }, onError: (error) => { diff --git a/frontend/src/components/Recipe/CommentList/CommentList.tsx b/frontend/src/components/Recipe/CommentList/CommentList.tsx index 093d5d93e..0330c7059 100644 --- a/frontend/src/components/Recipe/CommentList/CommentList.tsx +++ b/frontend/src/components/Recipe/CommentList/CommentList.tsx @@ -16,20 +16,20 @@ const CommentList = ({ recipeId }: CommentListProps) => { const { fetchNextPage, hasNextPage, data } = useInfiniteRecipeCommentQuery(Number(recipeId)); useIntersectionObserver(fetchNextPage, scrollRef, hasNextPage); - const comments = data.pages.flatMap((page) => page.comments); + const [{ totalElements, comments }] = data.pages.flatMap((page) => page); return ( -
    + <> - 댓글 ({comments.length}개) + 댓글 ({totalElements}개) - {comments.length === 0 && 꿀조합의 첫번째 댓글을 달아보세요!} + {totalElements === 0 && 꿀조합의 첫번째 댓글을 달아보세요!} {comments.map((comment) => ( ))}
    -
    + ); }; diff --git a/frontend/src/mocks/data/comments.json b/frontend/src/mocks/data/comments.json index acf2f9b08..d2f029c98 100644 --- a/frontend/src/mocks/data/comments.json +++ b/frontend/src/mocks/data/comments.json @@ -1,5 +1,6 @@ { "hasNext": false, + "totalElements": 3, "comments": [ { "author": { diff --git a/frontend/src/pages/RecipeDetailPage.tsx b/frontend/src/pages/RecipeDetailPage.tsx index ed68acb7b..73f430c51 100644 --- a/frontend/src/pages/RecipeDetailPage.tsx +++ b/frontend/src/pages/RecipeDetailPage.tsx @@ -1,6 +1,6 @@ import { Divider, Heading, Spacing, Text, theme } from '@fun-eat/design-system'; import { useQueryErrorResetBoundary } from '@tanstack/react-query'; -import { Suspense } from 'react'; +import { Suspense, useRef } from 'react'; import { useParams } from 'react-router-dom'; import styled from 'styled-components'; @@ -13,8 +13,9 @@ import { getFormattedDate } from '@/utils/date'; export const RecipeDetailPage = () => { const { recipeId } = useParams(); - const { data: recipeDetail } = useRecipeDetailQuery(Number(recipeId)); + const scrollTargetRef = useRef(null); + const { data: recipeDetail } = useRecipeDetailQuery(Number(recipeId)); const { reset } = useQueryErrorResetBoundary(); const { id, images, title, content, author, products, totalPrice, favoriteCount, favorite, createdAt } = recipeDetail; @@ -75,11 +76,13 @@ export const RecipeDetailPage = () => { }> - +
    + +
    - + ); From 0cddbdf92585cdb949e061f9112289ca463c277d Mon Sep 17 00:00:00 2001 From: Hanuel Lee <91522259+hanueleee@users.noreply.github.com> Date: Tue, 17 Oct 2023 19:10:18 +0900 Subject: [PATCH 25/55] =?UTF-8?q?[BE]=20refactor:=20=EC=83=81=ED=92=88=20?= =?UTF-8?q?=EB=AA=A9=EB=A1=9D=20=EC=A1=B0=ED=9A=8C=20api=20=EC=84=B1?= =?UTF-8?q?=EB=8A=A5=20=EA=B0=9C=EC=84=A0=20(#685)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * refactor: ProductsInCategoryResponse에서 PageDto(페이징에 대한 자세한 정보)를 제거하고 hasNext값만 갖도록 수정 * refactor: ProductRepository의 findAllByCategory와 findAllByCategoryByReviewCountDesc 메소드를 findAllByCategory메소드로 통합 및 반환타입 수정 - ReviewCount 반정규화로 인해 메소드 분리 필요성 없어짐 - ReviewCount를 위한 join 쿼리 제거 - 페이징에 대한 자세한 정보(ex. 전체 페이지 수등) 필요없기 때문에 반환값을 Page에서 Slice로 수정 * refactor: ProductService의 reviewCount로 인한 분기 처리 부분 제거 및 PageDto대신 hasNext값으로 response 만들도록 수정 * test: 상품 목록 조회 인수테스트에서 페이지 검증 대신 다음 페이지 유무를 검증하도록 수정 * test: findAllByCategoryOrderByReviewCountDesc테스트를 findAllByCategory 테스트에 통합 * feat: 상품목록조회 api 수정사항 반영 (기존) sort=price,asc&page=1 (수정) sort=price,asc&id=5 * feat: 정렬 조건별 메소드 생성 * feat: ProductService에서 정렬 조건별 분기처리 * test: 상품목록조회api 변경사항 인수테스트에 반영 * chore: toString 제거 * fix: findProductByReviewCountDesc 메소드 오류 수정 * feat: specification을 이용한 동적 쿼리 적용 * feat: count 쿼리 안나가도록 수정 * refactor: 정렬조건(sortBy, sortOrder)용 dto인 ProductSortCondition 추가 * refactor: ProductSpecification의 메소드명 변경 * refactor: 리뷰 반영 * chore: 충돌 해결 * fix: 테스트 fail 해결 --- .../common/repository/BaseRepository.java | 3 + .../common/repository/BaseRepositoryImpl.java | 11 ++ .../product/application/ProductService.java | 46 +++-- .../com/funeat/product/domain/Product.java | 10 ++ .../product/dto/ProductInCategoryDto.java | 5 + .../product/dto/ProductSortCondition.java | 25 +++ .../dto/ProductsInCategoryResponse.java | 16 +- .../product/exception/ProductErrorCode.java | 1 + .../product/exception/ProductException.java | 6 + .../persistence/ProductRepository.java | 25 +-- .../persistence/ProductSpecification.java | 108 +++++++++++ .../presentation/ProductApiController.java | 7 +- .../presentation/ProductController.java | 4 +- .../product/ProductAcceptanceTest.java | 61 +++---- .../acceptance/product/ProductSteps.java | 4 +- .../com/funeat/fixture/ProductFixture.java | 16 ++ .../persistence/ProductRepositoryTest.java | 168 +----------------- 17 files changed, 263 insertions(+), 253 deletions(-) create mode 100644 backend/src/main/java/com/funeat/product/dto/ProductSortCondition.java create mode 100644 backend/src/main/java/com/funeat/product/persistence/ProductSpecification.java diff --git a/backend/src/main/java/com/funeat/common/repository/BaseRepository.java b/backend/src/main/java/com/funeat/common/repository/BaseRepository.java index 9c7197243..448db7766 100644 --- a/backend/src/main/java/com/funeat/common/repository/BaseRepository.java +++ b/backend/src/main/java/com/funeat/common/repository/BaseRepository.java @@ -1,6 +1,7 @@ package com.funeat.common.repository; import java.io.Serializable; +import java.util.List; import org.springframework.data.domain.Page; import org.springframework.data.domain.Pageable; import org.springframework.data.jpa.domain.Specification; @@ -11,4 +12,6 @@ public interface BaseRepository extends JpaRepository { Page findAllForPagination(final Specification spec, final Pageable pageable, final Long totalElements); + + List findAllWithSpecification(final Specification spec, final int pageSize); } diff --git a/backend/src/main/java/com/funeat/common/repository/BaseRepositoryImpl.java b/backend/src/main/java/com/funeat/common/repository/BaseRepositoryImpl.java index 773b95269..64cd508f6 100644 --- a/backend/src/main/java/com/funeat/common/repository/BaseRepositoryImpl.java +++ b/backend/src/main/java/com/funeat/common/repository/BaseRepositoryImpl.java @@ -1,12 +1,15 @@ package com.funeat.common.repository; import java.io.Serializable; +import java.util.List; import javax.persistence.EntityManager; import javax.persistence.TypedQuery; import org.springframework.data.domain.Page; import org.springframework.data.domain.PageImpl; import org.springframework.data.domain.PageRequest; import org.springframework.data.domain.Pageable; +import org.springframework.data.domain.Sort; + import org.springframework.data.jpa.domain.Specification; import org.springframework.data.jpa.repository.support.JpaEntityInformation; import org.springframework.data.jpa.repository.support.SimpleJpaRepository; @@ -37,4 +40,12 @@ public Page findAllForPagination(final Specification spec, final Pageable return new PageImpl<>(query.getResultList(), PageRequest.of(0, pageSize), totalElements); } + + @Override + public List findAllWithSpecification(final Specification spec, final int pageSize) { + final TypedQuery query = getQuery(spec, Sort.unsorted()); + query.setMaxResults(pageSize); + + return query.getResultList(); + } } diff --git a/backend/src/main/java/com/funeat/product/application/ProductService.java b/backend/src/main/java/com/funeat/product/application/ProductService.java index 0301db183..921d07d7a 100644 --- a/backend/src/main/java/com/funeat/product/application/ProductService.java +++ b/backend/src/main/java/com/funeat/product/application/ProductService.java @@ -9,6 +9,7 @@ import com.funeat.product.dto.ProductInCategoryDto; import com.funeat.product.dto.ProductResponse; import com.funeat.product.dto.ProductReviewCountDto; +import com.funeat.product.dto.ProductSortCondition; import com.funeat.product.dto.ProductsInCategoryResponse; import com.funeat.product.dto.RankingProductDto; import com.funeat.product.dto.RankingProductsResponse; @@ -21,6 +22,7 @@ import com.funeat.product.persistence.CategoryRepository; import com.funeat.product.persistence.ProductRecipeRepository; import com.funeat.product.persistence.ProductRepository; +import com.funeat.product.persistence.ProductSpecification; import com.funeat.recipe.domain.Recipe; import com.funeat.recipe.domain.RecipeImage; import com.funeat.recipe.dto.RecipeDto; @@ -32,11 +34,11 @@ import com.funeat.tag.domain.Tag; import java.util.Comparator; import java.util.List; -import java.util.Objects; import java.util.stream.Collectors; import org.springframework.data.domain.Page; import org.springframework.data.domain.PageRequest; import org.springframework.data.domain.Pageable; +import org.springframework.data.jpa.domain.Specification; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; @@ -46,8 +48,9 @@ public class ProductService { private static final int THREE = 3; private static final int TOP = 0; - public static final String REVIEW_COUNT = "reviewCount"; private static final int RANKING_SIZE = 3; + private static final int DEFAULT_PAGE_SIZE = 10; + private static final int DEFAULT_CURSOR_PAGINATION_SIZE = 11; private final CategoryRepository categoryRepository; private final ProductRepository productRepository; @@ -60,7 +63,8 @@ public class ProductService { public ProductService(final CategoryRepository categoryRepository, final ProductRepository productRepository, final ReviewTagRepository reviewTagRepository, final ReviewRepository reviewRepository, final ProductRecipeRepository productRecipeRepository, - final RecipeImageRepository recipeImageRepository, final RecipeRepository recipeRepository) { + final RecipeImageRepository recipeImageRepository, + final RecipeRepository recipeRepository) { this.categoryRepository = categoryRepository; this.productRepository = productRepository; this.reviewTagRepository = reviewTagRepository; @@ -70,25 +74,39 @@ public ProductService(final CategoryRepository categoryRepository, final Product this.recipeRepository = recipeRepository; } - public ProductsInCategoryResponse getAllProductsInCategory(final Long categoryId, - final Pageable pageable) { + public ProductsInCategoryResponse getAllProductsInCategory(final Long categoryId, final Long lastProductId, + final ProductSortCondition sortCondition) { final Category category = categoryRepository.findById(categoryId) .orElseThrow(() -> new CategoryNotFoundException(CATEGORY_NOT_FOUND, categoryId)); + final Product lastProduct = productRepository.findById(lastProductId).orElse(null); - final Page pages = getAllProductsInCategory(pageable, category); + final Specification specification = ProductSpecification.searchBy(category, lastProduct, sortCondition); + final List findResults = productRepository.findAllWithSpecification(specification, DEFAULT_CURSOR_PAGINATION_SIZE); - final PageDto pageDto = PageDto.toDto(pages); - final List productDtos = pages.getContent(); + final List productDtos = getProductInCategoryDtos(findResults); + final boolean hasNext = hasNextPage(findResults); - return ProductsInCategoryResponse.toResponse(pageDto, productDtos); + return ProductsInCategoryResponse.toResponse(hasNext, productDtos); } - private Page getAllProductsInCategory(final Pageable pageable, final Category category) { - if (Objects.nonNull(pageable.getSort().getOrderFor(REVIEW_COUNT))) { - final PageRequest pageRequest = PageRequest.of(pageable.getPageNumber(), pageable.getPageSize()); - return productRepository.findAllByCategoryOrderByReviewCountDesc(category, pageRequest); + private List getProductInCategoryDtos(final List findProducts) { + final int resultSize = getResultSize(findProducts); + final List products = findProducts.subList(0, resultSize); + + return products.stream() + .map(ProductInCategoryDto::toDto) + .collect(Collectors.toList()); + } + + private int getResultSize(final List findProducts) { + if (findProducts.size() < DEFAULT_CURSOR_PAGINATION_SIZE) { + return findProducts.size(); } - return productRepository.findAllByCategory(category, pageable); + return DEFAULT_PAGE_SIZE; + } + + private boolean hasNextPage(final List findProducts) { + return findProducts.size() > DEFAULT_PAGE_SIZE; } public ProductResponse findProductDetail(final Long productId) { diff --git a/backend/src/main/java/com/funeat/product/domain/Product.java b/backend/src/main/java/com/funeat/product/domain/Product.java index 512f77f8f..b3dfa60a1 100644 --- a/backend/src/main/java/com/funeat/product/domain/Product.java +++ b/backend/src/main/java/com/funeat/product/domain/Product.java @@ -62,6 +62,16 @@ public Product(final String name, final Long price, final String image, final St this.category = category; } + public Product(final String name, final Long price, final String image, final String content, + final Category category, final Long reviewCount) { + this.name = name; + this.price = price; + this.image = image; + this.content = content; + this.category = category; + this.reviewCount = reviewCount; + } + public static Product create(final String name, final Long price, final String content, final Category category) { return new Product(name, price, null, content, category); } diff --git a/backend/src/main/java/com/funeat/product/dto/ProductInCategoryDto.java b/backend/src/main/java/com/funeat/product/dto/ProductInCategoryDto.java index 7ab4bf467..e4c73b606 100644 --- a/backend/src/main/java/com/funeat/product/dto/ProductInCategoryDto.java +++ b/backend/src/main/java/com/funeat/product/dto/ProductInCategoryDto.java @@ -21,6 +21,11 @@ public ProductInCategoryDto(final Long id, final String name, final Long price, this.reviewCount = reviewCount; } + public static ProductInCategoryDto toDto(final Product product) { + return new ProductInCategoryDto(product.getId(), product.getName(), product.getPrice(), product.getImage(), + product.getAverageRating(), product.getReviewCount()); + } + public static ProductInCategoryDto toDto(final Product product, final Long reviewCount) { return new ProductInCategoryDto(product.getId(), product.getName(), product.getPrice(), product.getImage(), product.getAverageRating(), reviewCount); diff --git a/backend/src/main/java/com/funeat/product/dto/ProductSortCondition.java b/backend/src/main/java/com/funeat/product/dto/ProductSortCondition.java new file mode 100644 index 000000000..8a929f99c --- /dev/null +++ b/backend/src/main/java/com/funeat/product/dto/ProductSortCondition.java @@ -0,0 +1,25 @@ +package com.funeat.product.dto; + +public class ProductSortCondition { + + private final String by; + private final String order; + + private ProductSortCondition(final String by, final String order) { + this.by = by; + this.order = order; + } + + public static ProductSortCondition toDto(final String sort) { + final String[] split = sort.split(","); + return new ProductSortCondition(split[0], split[1]); + } + + public String getBy() { + return by; + } + + public String getOrder() { + return order; + } +} diff --git a/backend/src/main/java/com/funeat/product/dto/ProductsInCategoryResponse.java b/backend/src/main/java/com/funeat/product/dto/ProductsInCategoryResponse.java index 4712e90fb..39c685268 100644 --- a/backend/src/main/java/com/funeat/product/dto/ProductsInCategoryResponse.java +++ b/backend/src/main/java/com/funeat/product/dto/ProductsInCategoryResponse.java @@ -1,24 +1,24 @@ package com.funeat.product.dto; -import com.funeat.common.dto.PageDto; import java.util.List; public class ProductsInCategoryResponse { - private final PageDto page; + private final boolean hasNext; private final List products; - public ProductsInCategoryResponse(final PageDto page, final List products) { - this.page = page; + public ProductsInCategoryResponse(final boolean hasNext, final List products) { + this.hasNext = hasNext; this.products = products; } - public static ProductsInCategoryResponse toResponse(final PageDto page, final List products) { - return new ProductsInCategoryResponse(page, products); + public static ProductsInCategoryResponse toResponse(final boolean hasNext, + final List products) { + return new ProductsInCategoryResponse(hasNext, products); } - public PageDto getPage() { - return page; + public boolean isHasNext() { + return hasNext; } public List getProducts() { diff --git a/backend/src/main/java/com/funeat/product/exception/ProductErrorCode.java b/backend/src/main/java/com/funeat/product/exception/ProductErrorCode.java index 933f91098..e3b4d3ccc 100644 --- a/backend/src/main/java/com/funeat/product/exception/ProductErrorCode.java +++ b/backend/src/main/java/com/funeat/product/exception/ProductErrorCode.java @@ -5,6 +5,7 @@ public enum ProductErrorCode { PRODUCT_NOT_FOUND(HttpStatus.NOT_FOUND, "존재하지 않는 상품입니다. 상품 id를 확인하세요.", "1001"), + NOT_SUPPORTED_PRODUCT_SORTING_CONDITION(HttpStatus.BAD_REQUEST, "정렬 조건이 올바르지 않습니다. 정렬 조건을 확인하세요", "1002"); ; private final HttpStatus status; diff --git a/backend/src/main/java/com/funeat/product/exception/ProductException.java b/backend/src/main/java/com/funeat/product/exception/ProductException.java index c9b1f1720..bdbb4782b 100644 --- a/backend/src/main/java/com/funeat/product/exception/ProductException.java +++ b/backend/src/main/java/com/funeat/product/exception/ProductException.java @@ -15,4 +15,10 @@ public ProductNotFoundException(final ProductErrorCode errorCode, final Long pro super(errorCode.getStatus(), new ErrorCode<>(errorCode.getCode(), errorCode.getMessage(), productId)); } } + + public static class NotSupportedProductSortingConditionException extends ProductException { + public NotSupportedProductSortingConditionException(final ProductErrorCode errorCode, final String sortBy) { + super(errorCode.getStatus(), new ErrorCode<>(errorCode.getCode(), errorCode.getMessage(), sortBy)); + } + } } diff --git a/backend/src/main/java/com/funeat/product/persistence/ProductRepository.java b/backend/src/main/java/com/funeat/product/persistence/ProductRepository.java index c3adc5c4b..9b4036361 100644 --- a/backend/src/main/java/com/funeat/product/persistence/ProductRepository.java +++ b/backend/src/main/java/com/funeat/product/persistence/ProductRepository.java @@ -1,36 +1,15 @@ package com.funeat.product.persistence; -import com.funeat.product.domain.Category; +import com.funeat.common.repository.BaseRepository; import com.funeat.product.domain.Product; -import com.funeat.product.dto.ProductInCategoryDto; import com.funeat.product.dto.ProductReviewCountDto; import java.util.List; import org.springframework.data.domain.Page; import org.springframework.data.domain.Pageable; -import org.springframework.data.jpa.repository.JpaRepository; -import org.springframework.data.jpa.repository.JpaSpecificationExecutor; import org.springframework.data.jpa.repository.Query; import org.springframework.data.repository.query.Param; -public interface ProductRepository extends JpaRepository, JpaSpecificationExecutor { - - @Query(value = "SELECT new com.funeat.product.dto.ProductInCategoryDto(p.id, p.name, p.price, p.image, p.averageRating, COUNT(r)) " - + "FROM Product p " - + "LEFT JOIN p.reviews r " - + "WHERE p.category = :category " - + "GROUP BY p ", - countQuery = "SELECT COUNT(p) FROM Product p WHERE p.category = :category") - Page findAllByCategory(@Param("category") final Category category, final Pageable pageable); - - @Query(value = "SELECT new com.funeat.product.dto.ProductInCategoryDto(p.id, p.name, p.price, p.image, p.averageRating, COUNT(r)) " - + "FROM Product p " - + "LEFT JOIN p.reviews r " - + "WHERE p.category = :category " - + "GROUP BY p " - + "ORDER BY COUNT(r) DESC, p.id DESC ", - countQuery = "SELECT COUNT(p) FROM Product p WHERE p.category = :category") - Page findAllByCategoryOrderByReviewCountDesc(@Param("category") final Category category, - final Pageable pageable); +public interface ProductRepository extends BaseRepository { @Query("SELECT new com.funeat.product.dto.ProductReviewCountDto(p, COUNT(r.id)) " + "FROM Product p " diff --git a/backend/src/main/java/com/funeat/product/persistence/ProductSpecification.java b/backend/src/main/java/com/funeat/product/persistence/ProductSpecification.java new file mode 100644 index 000000000..f2dbd2ff2 --- /dev/null +++ b/backend/src/main/java/com/funeat/product/persistence/ProductSpecification.java @@ -0,0 +1,108 @@ +package com.funeat.product.persistence; + +import static com.funeat.product.exception.ProductErrorCode.NOT_SUPPORTED_PRODUCT_SORTING_CONDITION; + +import com.funeat.product.domain.Category; +import com.funeat.product.domain.Product; +import com.funeat.product.dto.ProductSortCondition; +import com.funeat.product.exception.ProductException.NotSupportedProductSortingConditionException; +import java.util.Objects; +import javax.persistence.criteria.CriteriaBuilder; +import javax.persistence.criteria.CriteriaQuery; +import javax.persistence.criteria.Path; +import javax.persistence.criteria.Root; +import org.springframework.data.jpa.domain.Specification; + +public class ProductSpecification { + + private ProductSpecification() { + } + + private static final String DESC = "desc"; + private static final String CATEGORY = "category"; + private static final String ID = "id"; + private static final String REVIEW_COUNT = "reviewCount"; + private static final String AVERAGE_RATING = "averageRating"; + private static final String PRICE = "price"; + + public static Specification searchBy(final Category category, final Product lastProduct, + final ProductSortCondition sortCondition) { + return (root, query, builder) -> { + setOrderBy(sortCondition, root, query, builder); + + return Specification + .where(sameCategory(category)) + .and(nextCursor(lastProduct, sortCondition)) + .toPredicate(root, query, builder); + }; + } + + private static void setOrderBy(final ProductSortCondition sortCondition, final Root root, + final CriteriaQuery query, final CriteriaBuilder builder) { + final String sortBy = sortCondition.getBy(); + final String sortOrder = sortCondition.getOrder(); + + if (DESC.equals(sortOrder)) { + query.orderBy(builder.desc(root.get(sortBy)), builder.desc(root.get(ID))); + } else { + query.orderBy(builder.asc(root.get(sortBy)), builder.desc(root.get(ID))); + } + } + + private static Specification sameCategory(final Category category) { + return (root, query, builder) -> { + final Path categoryPath = root.get(CATEGORY); + + return builder.equal(categoryPath, category); + }; + } + + private static Specification nextCursor(final Product lastProduct, final ProductSortCondition sortCondition) { + final String sortBy = sortCondition.getBy(); + final String sortOrder = sortCondition.getOrder(); + + return (root, query, builder) -> { + if (Objects.isNull(lastProduct)) { + return null; + } + + final Comparable comparisonValue = (Comparable) getComparisonValue(lastProduct, sortBy); + + return builder.or( + sameValueAndSmallerId(sortBy, lastProduct.getId(), comparisonValue).toPredicate(root, query, builder), + nextValue(sortBy, sortOrder, comparisonValue).toPredicate(root, query, builder) + ); + }; + } + + private static Object getComparisonValue(final Product lastProduct, final String sortBy) { + if (PRICE.equals(sortBy)) { + return lastProduct.getPrice(); + } + if (AVERAGE_RATING.equals(sortBy)) { + return lastProduct.getAverageRating(); + } + if (REVIEW_COUNT.equals(sortBy)) { + return lastProduct.getReviewCount(); + } + throw new NotSupportedProductSortingConditionException(NOT_SUPPORTED_PRODUCT_SORTING_CONDITION, sortBy); + } + + private static Specification sameValueAndSmallerId(final String sortBy, final Long lastProductId, + final Comparable comparisonValue) { + return (root, query, builder) -> builder.and( + builder.equal(root.get(sortBy), comparisonValue), + builder.lessThan(root.get(ID), lastProductId)); + } + + private static Specification nextValue(final String sortBy, final String sortOrder, + final Comparable comparisonValue) { + return (root, query, builder) -> { + if (DESC.equals(sortOrder)) { + return builder.lessThan(root.get(sortBy), comparisonValue); + } else { + return builder.greaterThan(root.get(sortBy), comparisonValue); + } + }; + } +} diff --git a/backend/src/main/java/com/funeat/product/presentation/ProductApiController.java b/backend/src/main/java/com/funeat/product/presentation/ProductApiController.java index f71a1a706..46435aee7 100644 --- a/backend/src/main/java/com/funeat/product/presentation/ProductApiController.java +++ b/backend/src/main/java/com/funeat/product/presentation/ProductApiController.java @@ -2,6 +2,7 @@ import com.funeat.product.application.ProductService; import com.funeat.product.dto.ProductResponse; +import com.funeat.product.dto.ProductSortCondition; import com.funeat.product.dto.ProductsInCategoryResponse; import com.funeat.product.dto.RankingProductsResponse; import com.funeat.product.dto.SearchProductResultsResponse; @@ -29,8 +30,10 @@ public ProductApiController(final ProductService productService) { @GetMapping("/categories/{categoryId}/products") public ResponseEntity getAllProductsInCategory(@PathVariable final Long categoryId, - @PageableDefault final Pageable pageable) { - final ProductsInCategoryResponse response = productService.getAllProductsInCategory(categoryId, pageable); + @RequestParam final Long lastProductId, + @RequestParam final String sort) { + final ProductSortCondition sortCondition = ProductSortCondition.toDto(sort); + final ProductsInCategoryResponse response = productService.getAllProductsInCategory(categoryId, lastProductId, sortCondition); return ResponseEntity.ok(response); } diff --git a/backend/src/main/java/com/funeat/product/presentation/ProductController.java b/backend/src/main/java/com/funeat/product/presentation/ProductController.java index d7f9e653f..3fa704eb7 100644 --- a/backend/src/main/java/com/funeat/product/presentation/ProductController.java +++ b/backend/src/main/java/com/funeat/product/presentation/ProductController.java @@ -26,7 +26,9 @@ public interface ProductController { ) @GetMapping ResponseEntity getAllProductsInCategory( - @PathVariable(name = "category_id") final Long categoryId, @PageableDefault final Pageable pageable + @PathVariable final Long categoryId, + @RequestParam final Long lastProductId, + @RequestParam final String sort ); @Operation(summary = "해당 상품 상세 조회", description = "해당 상품 상세정보를 조회한다.") diff --git a/backend/src/test/java/com/funeat/acceptance/product/ProductAcceptanceTest.java b/backend/src/test/java/com/funeat/acceptance/product/ProductAcceptanceTest.java index 03b2d9032..5a73e6554 100644 --- a/backend/src/test/java/com/funeat/acceptance/product/ProductAcceptanceTest.java +++ b/backend/src/test/java/com/funeat/acceptance/product/ProductAcceptanceTest.java @@ -2,6 +2,7 @@ import static com.funeat.acceptance.auth.LoginSteps.로그인_쿠키_획득; import static com.funeat.acceptance.common.CommonSteps.STATUS_CODE를_검증한다; +import static com.funeat.acceptance.common.CommonSteps.다음_데이터가_있는지_검증한다; import static com.funeat.acceptance.common.CommonSteps.사진_명세_요청; import static com.funeat.acceptance.common.CommonSteps.여러개_사진_명세_요청; import static com.funeat.acceptance.common.CommonSteps.정상_처리; @@ -124,14 +125,12 @@ class 가격_기준_내림차순으로_카테고리별_상품_목록_조회 { final var 상품2 = 단일_상품_저장(상품_삼각김밥_가격2000원_평점3점_생성(카테고리)); final var 상품3 = 단일_상품_저장(상품_삼각김밥_가격4000원_평점4점_생성(카테고리)); - final var 예상_응답_페이지 = 응답_페이지_생성(총_데이터_개수(3L), 총_페이지(1L), 첫페이지O, 마지막페이지O, FIRST_PAGE, PAGE_SIZE); - // when - final var 응답 = 카테고리별_상품_목록_조회_요청(1L, 가격_내림차순, FIRST_PAGE); + final var 응답 = 카테고리별_상품_목록_조회_요청(1L, 가격_내림차순, 0L); // then STATUS_CODE를_검증한다(응답, 정상_처리); - 페이지를_검증한다(응답, 예상_응답_페이지); + 다음_데이터가_있는지_검증한다(응답, 마지막페이지X); 카테고리별_상품_목록_조회_결과를_검증한다(응답, List.of(상품3, 상품1, 상품2)); } @@ -144,14 +143,12 @@ class 가격_기준_내림차순으로_카테고리별_상품_목록_조회 { final var 상품2 = 단일_상품_저장(상품_삼각김밥_가격1000원_평점3점_생성(카테고리)); final var 상품3 = 단일_상품_저장(상품_삼각김밥_가격1000원_평점1점_생성(카테고리)); - final var 예상_응답_페이지 = 응답_페이지_생성(총_데이터_개수(3L), 총_페이지(1L), 첫페이지O, 마지막페이지O, FIRST_PAGE, PAGE_SIZE); - // when - final var 응답 = 카테고리별_상품_목록_조회_요청(카테고리_아이디, 가격_내림차순, FIRST_PAGE); + final var 응답 = 카테고리별_상품_목록_조회_요청(카테고리_아이디, 가격_내림차순, 0L); // then STATUS_CODE를_검증한다(응답, 정상_처리); - 페이지를_검증한다(응답, 예상_응답_페이지); + 다음_데이터가_있는지_검증한다(응답, 마지막페이지X); 카테고리별_상품_목록_조회_결과를_검증한다(응답, List.of(상품3, 상품2, 상품1)); } } @@ -168,14 +165,12 @@ class 가격_기준_오름차순으로_카테고리별_상품_목록_조회 { final var 상품2 = 단일_상품_저장(상품_삼각김밥_가격4000원_평점4점_생성(카테고리)); final var 상품3 = 단일_상품_저장(상품_삼각김밥_가격2000원_평점3점_생성(카테고리)); - final var 예상_응답_페이지 = 응답_페이지_생성(총_데이터_개수(3L), 총_페이지(1L), 첫페이지O, 마지막페이지O, FIRST_PAGE, PAGE_SIZE); - // when - final var 응답 = 카테고리별_상품_목록_조회_요청(카테고리_아이디, 가격_오름차순, FIRST_PAGE); + final var 응답 = 카테고리별_상품_목록_조회_요청(카테고리_아이디, 가격_오름차순, 0L); // then STATUS_CODE를_검증한다(응답, 정상_처리); - 페이지를_검증한다(응답, 예상_응답_페이지); + 다음_데이터가_있는지_검증한다(응답, 마지막페이지X); 카테고리별_상품_목록_조회_결과를_검증한다(응답, List.of(상품1, 상품3, 상품2)); } @@ -188,14 +183,12 @@ class 가격_기준_오름차순으로_카테고리별_상품_목록_조회 { 단일_상품_저장(상품_삼각김밥_가격1000원_평점3점_생성(카테고리)); 단일_상품_저장(상품_삼각김밥_가격1000원_평점1점_생성(카테고리)); - final var 예상_응답_페이지 = 응답_페이지_생성(3L, 1L, true, true, FIRST_PAGE, PAGE_SIZE); - // when - final var 응답 = 카테고리별_상품_목록_조회_요청(1L, 가격_오름차순, FIRST_PAGE); + final var 응답 = 카테고리별_상품_목록_조회_요청(1L, 가격_오름차순, 0L); // then STATUS_CODE를_검증한다(응답, 정상_처리); - 페이지를_검증한다(응답, 예상_응답_페이지); + 다음_데이터가_있는지_검증한다(응답, 마지막페이지X); 카테고리별_상품_목록_조회_결과를_검증한다(응답, List.of(3L, 2L, 1L)); } } @@ -212,14 +205,12 @@ class 평점_기준_내림차순으로_카테고리별_상품_목록_조회 { 단일_상품_저장(상품_삼각김밥_가격2000원_평점5점_생성(카테고리)); 단일_상품_저장(상품_삼각김밥_가격2000원_평점1점_생성(카테고리)); - final var 예상_응답_페이지 = 응답_페이지_생성(3L, 1L, true, true, FIRST_PAGE, PAGE_SIZE); - // when - final var 응답 = 카테고리별_상품_목록_조회_요청(1L, 평균_평점_내림차순, FIRST_PAGE); + final var 응답 = 카테고리별_상품_목록_조회_요청(1L, 평균_평점_내림차순, 0L); // then STATUS_CODE를_검증한다(응답, 정상_처리); - 페이지를_검증한다(응답, 예상_응답_페이지); + 다음_데이터가_있는지_검증한다(응답, 마지막페이지X); 카테고리별_상품_목록_조회_결과를_검증한다(응답, List.of(2L, 1L, 3L)); } @@ -232,14 +223,12 @@ class 평점_기준_내림차순으로_카테고리별_상품_목록_조회 { 단일_상품_저장(상품_삼각김밥_가격2000원_평점1점_생성(카테고리)); 단일_상품_저장(상품_삼각김밥_가격2000원_평점1점_생성(카테고리)); - final var 예상_응답_페이지 = 응답_페이지_생성(3L, 1L, true, true, FIRST_PAGE, PAGE_SIZE); - // when - final var 응답 = 카테고리별_상품_목록_조회_요청(1L, 평균_평점_내림차순, FIRST_PAGE); + final var 응답 = 카테고리별_상품_목록_조회_요청(1L, 평균_평점_내림차순, 0L); // then STATUS_CODE를_검증한다(응답, 정상_처리); - 페이지를_검증한다(응답, 예상_응답_페이지); + 다음_데이터가_있는지_검증한다(응답, 마지막페이지X); 카테고리별_상품_목록_조회_결과를_검증한다(응답, List.of(3L, 2L, 1L)); } } @@ -256,14 +245,12 @@ class 평점_기준_오름차순으로_카테고리별_상품_목록_조회 { 단일_상품_저장(상품_삼각김밥_가격1000원_평점5점_생성(카테고리)); 단일_상품_저장(상품_삼각김밥_가격2000원_평점3점_생성(카테고리)); - final var 예상_응답_페이지 = 응답_페이지_생성(3L, 1L, true, true, FIRST_PAGE, PAGE_SIZE); - // when - final var 응답 = 카테고리별_상품_목록_조회_요청(1L, 평균_평점_오름차순, FIRST_PAGE); + final var 응답 = 카테고리별_상품_목록_조회_요청(1L, 평균_평점_오름차순, 0L); // then STATUS_CODE를_검증한다(응답, 정상_처리); - 페이지를_검증한다(응답, 예상_응답_페이지); + 다음_데이터가_있는지_검증한다(응답, 마지막페이지X); 카테고리별_상품_목록_조회_결과를_검증한다(응답, List.of(1L, 3L, 2L)); } @@ -276,14 +263,12 @@ class 평점_기준_오름차순으로_카테고리별_상품_목록_조회 { 단일_상품_저장(상품_삼각김밥_가격2000원_평점1점_생성(카테고리)); 단일_상품_저장(상품_삼각김밥_가격2000원_평점1점_생성(카테고리)); - final var 예상_응답_페이지 = 응답_페이지_생성(3L, 1L, true, true, FIRST_PAGE, PAGE_SIZE); - // when - final var 응답 = 카테고리별_상품_목록_조회_요청(1L, 평균_평점_오름차순, FIRST_PAGE); + final var 응답 = 카테고리별_상품_목록_조회_요청(1L, 평균_평점_오름차순, 0L); // then STATUS_CODE를_검증한다(응답, 정상_처리); - 페이지를_검증한다(응답, 예상_응답_페이지); + 다음_데이터가_있는지_검증한다(응답, 마지막페이지X); 카테고리별_상품_목록_조회_결과를_검증한다(응답, List.of(3L, 2L, 1L)); } } @@ -305,14 +290,12 @@ class 리뷰수_기준_내림차순으로_카테고리별_상품_목록_조회 { 리뷰_작성_요청(로그인_쿠키_획득(멤버1), 상품2, 사진_명세_요청(이미지2), 리뷰추가요청_재구매X_생성(점수_3점, List.of(태그))); 리뷰_작성_요청(로그인_쿠키_획득(멤버2), 상품2, 사진_명세_요청(이미지3), 리뷰추가요청_재구매O_생성(점수_2점, List.of(태그))); - final var 예상_응답_페이지 = 응답_페이지_생성(총_데이터_개수(3L), 총_페이지(1L), 첫페이지O, 마지막페이지O, FIRST_PAGE, PAGE_SIZE); - // when - final var 응답 = 카테고리별_상품_목록_조회_요청(카테고리_아이디, 리뷰수_내림차순, FIRST_PAGE); + final var 응답 = 카테고리별_상품_목록_조회_요청(카테고리_아이디, 리뷰수_내림차순, 0L); // then STATUS_CODE를_검증한다(응답, 정상_처리); - 페이지를_검증한다(응답, 예상_응답_페이지); + 다음_데이터가_있는지_검증한다(응답, 마지막페이지X); 카테고리별_상품_목록_조회_결과를_검증한다(응답, List.of(상품2, 상품1, 상품3)); } @@ -325,14 +308,12 @@ class 리뷰수_기준_내림차순으로_카테고리별_상품_목록_조회 { final var 상품2 = 단일_상품_저장(상품_삼각김밥_가격5000원_평점3점_생성(카테고리)); final var 상품3 = 단일_상품_저장(상품_삼각김밥_가격3000원_평점1점_생성(카테고리)); - final var 예상_응답_페이지 = 응답_페이지_생성(총_데이터_개수(3L), 총_페이지(1L), 첫페이지O, 마지막페이지O, FIRST_PAGE, PAGE_SIZE); - // when - final var 응답 = 카테고리별_상품_목록_조회_요청(카테고리_아이디, 리뷰수_내림차순, FIRST_PAGE); + final var 응답 = 카테고리별_상품_목록_조회_요청(카테고리_아이디, 리뷰수_내림차순, 0L); // then STATUS_CODE를_검증한다(응답, 정상_처리); - 페이지를_검증한다(응답, 예상_응답_페이지); + 다음_데이터가_있는지_검증한다(응답, 마지막페이지X); 카테고리별_상품_목록_조회_결과를_검증한다(응답, List.of(상품3, 상품2, 상품1)); } } diff --git a/backend/src/test/java/com/funeat/acceptance/product/ProductSteps.java b/backend/src/test/java/com/funeat/acceptance/product/ProductSteps.java index 8092bc187..d41bd5be1 100644 --- a/backend/src/test/java/com/funeat/acceptance/product/ProductSteps.java +++ b/backend/src/test/java/com/funeat/acceptance/product/ProductSteps.java @@ -10,10 +10,10 @@ public class ProductSteps { public static ExtractableResponse 카테고리별_상품_목록_조회_요청(final Long categoryId, final String sort, - final Long page) { + final Long lastProductId) { return given() .queryParam("sort", sort) - .queryParam("page", page) + .queryParam("lastProductId", lastProductId) .when() .get("/api/categories/{category_id}/products", categoryId) .then() diff --git a/backend/src/test/java/com/funeat/fixture/ProductFixture.java b/backend/src/test/java/com/funeat/fixture/ProductFixture.java index 15b2fa27e..6bb7da0d9 100644 --- a/backend/src/test/java/com/funeat/fixture/ProductFixture.java +++ b/backend/src/test/java/com/funeat/fixture/ProductFixture.java @@ -130,6 +130,22 @@ public class ProductFixture { return new Product("애플망고", 3000L, "image.png", "맛있는 애플망고", 5.0, category); } + public static Product 상품_삼각김밥_가격5000원_리뷰0개_생성(final Category category) { + return new Product("삼각김밥", 5000L, "image.png", "맛있는 삼각김밥", category, 0L); + } + + public static Product 상품_삼각김밥_가격2000원_리뷰1개_생성(final Category category) { + return new Product("삼각김밥", 2000L, "image.png", "맛있는 삼각김밥", category, 1L); + } + + public static Product 상품_삼각김밥_가격1000원_리뷰3개_생성(final Category category) { + return new Product("삼각김밥", 1000L, "image.png", "맛있는 삼각김밥", category, 3L); + } + + public static Product 상품_삼각김밥_가격3000원_리뷰5개_생성(final Category category) { + return new Product("삼각김밥", 3000L, "image.png", "맛있는 삼각김밥", category, 5L); + } + public static ProductRecipe 레시피_안에_들어가는_상품_생성(final Product product, final Recipe recipe) { return new ProductRecipe(product, recipe); } diff --git a/backend/src/test/java/com/funeat/product/persistence/ProductRepositoryTest.java b/backend/src/test/java/com/funeat/product/persistence/ProductRepositoryTest.java index 887958516..e1eff6623 100644 --- a/backend/src/test/java/com/funeat/product/persistence/ProductRepositoryTest.java +++ b/backend/src/test/java/com/funeat/product/persistence/ProductRepositoryTest.java @@ -6,28 +6,31 @@ import static com.funeat.fixture.MemberFixture.멤버_멤버3_생성; import static com.funeat.fixture.PageFixture.가격_내림차순; import static com.funeat.fixture.PageFixture.가격_오름차순; +import static com.funeat.fixture.PageFixture.리뷰수_내림차순; import static com.funeat.fixture.PageFixture.페이지요청_기본_생성; import static com.funeat.fixture.PageFixture.페이지요청_생성; import static com.funeat.fixture.PageFixture.평균_평점_내림차순; import static com.funeat.fixture.PageFixture.평균_평점_오름차순; import static com.funeat.fixture.ProductFixture.상품_망고빙수_가격5000원_평점4점_생성; +import static com.funeat.fixture.ProductFixture.상품_삼각김밥_가격1000원_리뷰3개_생성; import static com.funeat.fixture.ProductFixture.상품_삼각김밥_가격1000원_평점1점_생성; import static com.funeat.fixture.ProductFixture.상품_삼각김밥_가격1000원_평점2점_생성; import static com.funeat.fixture.ProductFixture.상품_삼각김밥_가격1000원_평점3점_생성; import static com.funeat.fixture.ProductFixture.상품_삼각김밥_가격1000원_평점4점_생성; import static com.funeat.fixture.ProductFixture.상품_삼각김밥_가격1000원_평점5점_생성; +import static com.funeat.fixture.ProductFixture.상품_삼각김밥_가격2000원_리뷰1개_생성; import static com.funeat.fixture.ProductFixture.상품_삼각김밥_가격2000원_평점1점_생성; import static com.funeat.fixture.ProductFixture.상품_삼각김밥_가격2000원_평점4점_생성; +import static com.funeat.fixture.ProductFixture.상품_삼각김밥_가격3000원_리뷰5개_생성; import static com.funeat.fixture.ProductFixture.상품_삼각김밥_가격3000원_평점1점_생성; import static com.funeat.fixture.ProductFixture.상품_삼각김밥_가격3000원_평점5점_생성; import static com.funeat.fixture.ProductFixture.상품_삼각김밥_가격4000원_평점1점_생성; import static com.funeat.fixture.ProductFixture.상품_삼각김밥_가격4000원_평점2점_생성; +import static com.funeat.fixture.ProductFixture.상품_삼각김밥_가격5000원_리뷰0개_생성; import static com.funeat.fixture.ProductFixture.상품_삼각김밥_가격5000원_평점1점_생성; import static com.funeat.fixture.ProductFixture.상품_애플망고_가격3000원_평점5점_생성; import static com.funeat.fixture.ReviewFixture.리뷰_이미지test1_평점1점_재구매X_생성; -import static com.funeat.fixture.ReviewFixture.리뷰_이미지test2_평점2점_재구매X_생성; import static com.funeat.fixture.ReviewFixture.리뷰_이미지test3_평점3점_재구매O_생성; -import static com.funeat.fixture.ReviewFixture.리뷰_이미지test4_평점4점_재구매O_생성; import static com.funeat.fixture.ReviewFixture.리뷰_이미지test4_평점4점_재구매X_생성; import static com.funeat.fixture.ReviewFixture.리뷰_이미지test5_평점5점_재구매O_생성; import static org.assertj.core.api.Assertions.assertThat; @@ -42,167 +45,6 @@ @SuppressWarnings("NonAsciiCharacters") class ProductRepositoryTest extends RepositoryTest { - @Nested - class findByAllCategory_성공_테스트 { - - @Test - void 카테고리별_상품을_평점이_높은_순으로_정렬한다() { - // given - final var category = 카테고리_간편식사_생성(); - 단일_카테고리_저장(category); - - final var product1 = 상품_삼각김밥_가격1000원_평점1점_생성(category); - final var product2 = 상품_삼각김밥_가격1000원_평점2점_생성(category); - final var product3 = 상품_삼각김밥_가격1000원_평점3점_생성(category); - final var product4 = 상품_삼각김밥_가격1000원_평점4점_생성(category); - final var product5 = 상품_삼각김밥_가격1000원_평점5점_생성(category); - 복수_상품_저장(product1, product2, product3, product4, product5); - - final var page = 페이지요청_생성(0, 3, 평균_평점_내림차순); - - final var productInCategoryDto1 = ProductInCategoryDto.toDto(product5, 0L); - final var productInCategoryDto2 = ProductInCategoryDto.toDto(product4, 0L); - final var productInCategoryDto3 = ProductInCategoryDto.toDto(product3, 0L); - final var expected = List.of(productInCategoryDto1, productInCategoryDto2, productInCategoryDto3); - - // when - final var actual = productRepository.findAllByCategory(category, page).getContent(); - - // then - assertThat(actual).usingRecursiveComparison() - .isEqualTo(expected); - } - - @Test - void 카테고리별_상품을_평점이_낮은_순으로_정렬한다() { - // given - final var category = 카테고리_간편식사_생성(); - 단일_카테고리_저장(category); - - final var product1 = 상품_삼각김밥_가격1000원_평점1점_생성(category); - final var product2 = 상품_삼각김밥_가격1000원_평점2점_생성(category); - final var product3 = 상품_삼각김밥_가격1000원_평점3점_생성(category); - final var product4 = 상품_삼각김밥_가격1000원_평점4점_생성(category); - final var product5 = 상품_삼각김밥_가격1000원_평점5점_생성(category); - 복수_상품_저장(product1, product2, product3, product4, product5); - - final var page = 페이지요청_생성(0, 3, 평균_평점_오름차순); - - final var productInCategoryDto1 = ProductInCategoryDto.toDto(product1, 0L); - final var productInCategoryDto2 = ProductInCategoryDto.toDto(product2, 0L); - final var productInCategoryDto3 = ProductInCategoryDto.toDto(product3, 0L); - final var expected = List.of(productInCategoryDto1, productInCategoryDto2, productInCategoryDto3); - - // when - final var actual = productRepository.findAllByCategory(category, page).getContent(); - - // then - assertThat(actual).usingRecursiveComparison() - .isEqualTo(expected); - } - - @Test - void 카테고리별_상품을_가격이_높은_순으로_정렬한다() { - // given - final var category = 카테고리_간편식사_생성(); - 단일_카테고리_저장(category); - - final var product1 = 상품_삼각김밥_가격1000원_평점1점_생성(category); - final var product2 = 상품_삼각김밥_가격2000원_평점1점_생성(category); - final var product3 = 상품_삼각김밥_가격3000원_평점1점_생성(category); - final var product4 = 상품_삼각김밥_가격4000원_평점1점_생성(category); - final var product5 = 상품_삼각김밥_가격5000원_평점1점_생성(category); - 복수_상품_저장(product1, product2, product3, product4, product5); - - final var page = 페이지요청_생성(0, 3, 가격_내림차순); - - final var productInCategoryDto1 = ProductInCategoryDto.toDto(product5, 0L); - final var productInCategoryDto2 = ProductInCategoryDto.toDto(product4, 0L); - final var productInCategoryDto3 = ProductInCategoryDto.toDto(product3, 0L); - final var expected = List.of(productInCategoryDto1, productInCategoryDto2, productInCategoryDto3); - - // when - final var actual = productRepository.findAllByCategory(category, page).getContent(); - - // then - assertThat(actual).usingRecursiveComparison() - .isEqualTo(expected); - } - - @Test - void 카테고리별_상품을_가격이_낮은_순으로_정렬한다() { - // given - final var category = 카테고리_간편식사_생성(); - 단일_카테고리_저장(category); - - final var product1 = 상품_삼각김밥_가격1000원_평점1점_생성(category); - final var product2 = 상품_삼각김밥_가격2000원_평점1점_생성(category); - final var product3 = 상품_삼각김밥_가격3000원_평점1점_생성(category); - final var product4 = 상품_삼각김밥_가격4000원_평점1점_생성(category); - final var product5 = 상품_삼각김밥_가격5000원_평점1점_생성(category); - 복수_상품_저장(product1, product2, product3, product4, product5); - - final var page = 페이지요청_생성(0, 3, 가격_오름차순); - - final var productInCategoryDto1 = ProductInCategoryDto.toDto(product1, 0L); - final var productInCategoryDto2 = ProductInCategoryDto.toDto(product2, 0L); - final var productInCategoryDto3 = ProductInCategoryDto.toDto(product3, 0L); - final var expected = List.of(productInCategoryDto1, productInCategoryDto2, productInCategoryDto3); - - // when - final var actual = productRepository.findAllByCategory(category, page).getContent(); - - // then - assertThat(actual).usingRecursiveComparison() - .isEqualTo(expected); - } - } - - @Nested - class findAllByCategoryOrderByReviewCountDesc_성공_테스트 { - - @Test - void 카테고리별_상품을_리뷰수가_많은_순으로_정렬한다() { - // given - final var category = 카테고리_간편식사_생성(); - 단일_카테고리_저장(category); - - final var product1 = 상품_삼각김밥_가격1000원_평점1점_생성(category); - final var product2 = 상품_삼각김밥_가격2000원_평점1점_생성(category); - final var product3 = 상품_삼각김밥_가격3000원_평점1점_생성(category); - final var product4 = 상품_삼각김밥_가격4000원_평점1점_생성(category); - 복수_상품_저장(product1, product2, product3, product4); - - final var member1 = 멤버_멤버1_생성(); - final var member2 = 멤버_멤버2_생성(); - final var member3 = 멤버_멤버3_생성(); - 복수_멤버_저장(member1, member2, member3); - - final var review1_1 = 리뷰_이미지test1_평점1점_재구매X_생성(member1, product1, 0L); - final var review1_2 = 리뷰_이미지test3_평점3점_재구매O_생성(member2, product1, 0L); - final var review2_1 = 리뷰_이미지test4_평점4점_재구매O_생성(member3, product2, 0L); - final var review2_2 = 리뷰_이미지test2_평점2점_재구매X_생성(member1, product2, 0L); - final var review2_3 = 리뷰_이미지test3_평점3점_재구매O_생성(member2, product2, 0L); - final var review3_1 = 리뷰_이미지test3_평점3점_재구매O_생성(member1, product3, 0L); - 복수_리뷰_저장(review1_1, review1_2, review2_1, review2_2, review2_3, review3_1); - - final var page = 페이지요청_기본_생성(0, 3); - - final var productInCategoryDto1 = ProductInCategoryDto.toDto(product2, 3L); - final var productInCategoryDto2 = ProductInCategoryDto.toDto(product1, 2L); - final var productInCategoryDto3 = ProductInCategoryDto.toDto(product3, 1L); - final var expected = List.of(productInCategoryDto1, productInCategoryDto2, productInCategoryDto3); - - // when - final var actual = productRepository.findAllByCategoryOrderByReviewCountDesc(category, page) - .getContent(); - - // then - assertThat(actual).usingRecursiveComparison() - .isEqualTo(expected); - } - } - @Nested class findAllByAverageRatingGreaterThan3_성공_테스트 { From 16de45ce64f79df3fa4071da95068b22aee91db7 Mon Sep 17 00:00:00 2001 From: Hanuel Lee <91522259+hanueleee@users.noreply.github.com> Date: Wed, 18 Oct 2023 10:14:03 +0900 Subject: [PATCH 26/55] =?UTF-8?q?[BE]=20fix:=20ReviewDeleteEventListenerTe?= =?UTF-8?q?st=20=ED=86=B5=EA=B3=BC=ED=95=98=EB=8F=84=EB=A1=9D=20=EC=88=98?= =?UTF-8?q?=EC=A0=95=20(#778)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix: image가 있을 경우에만 delete로직 작동하도록 수정 * test: ReviewDeleteEventListenerTest 통과하도록 수정 * test: EventTest에 DataClearExtension 추가 * test: 테스트에 사용되는 fixture 변경 * test: event 초기화 로직 추가 * refactor: events.clear()를 @AfterEach로 추출 * test: @SuppressWarnings("NonAsciiCharacters") 추가 * test: 저장 방식 롤백 * test: 변수명 수정 (member -> author) --- .../ReviewDeleteEventListener.java | 2 +- .../java/com/funeat/common/EventTest.java | 20 +-- .../com/funeat/fixture/ReviewFixture.java | 4 + .../ReviewDeleteEventListenerTest.java | 131 +++++------------- 4 files changed, 50 insertions(+), 107 deletions(-) diff --git a/backend/src/main/java/com/funeat/review/application/ReviewDeleteEventListener.java b/backend/src/main/java/com/funeat/review/application/ReviewDeleteEventListener.java index 2009e3936..a92c4f943 100644 --- a/backend/src/main/java/com/funeat/review/application/ReviewDeleteEventListener.java +++ b/backend/src/main/java/com/funeat/review/application/ReviewDeleteEventListener.java @@ -20,7 +20,7 @@ public ReviewDeleteEventListener(final ImageUploader imageUploader) { @TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT) public void deleteReviewImageInS3(final ReviewDeleteEvent event) { final String image = event.getImage(); - if (StringUtils.isBlank(image)) { + if (!StringUtils.isBlank(image)) { imageUploader.delete(image); } } diff --git a/backend/src/test/java/com/funeat/common/EventTest.java b/backend/src/test/java/com/funeat/common/EventTest.java index dec401bec..aeb72d418 100644 --- a/backend/src/test/java/com/funeat/common/EventTest.java +++ b/backend/src/test/java/com/funeat/common/EventTest.java @@ -8,9 +8,8 @@ import com.funeat.product.persistence.ProductRepository; import com.funeat.review.application.ReviewService; import com.funeat.review.persistence.ReviewRepository; -import com.funeat.tag.domain.Tag; import com.funeat.tag.persistence.TagRepository; -import java.util.List; +import org.junit.jupiter.api.AfterEach; import org.junit.jupiter.api.DisplayNameGeneration; import org.junit.jupiter.api.DisplayNameGenerator.ReplaceUnderscores; import org.junit.jupiter.api.extension.ExtendWith; @@ -22,13 +21,17 @@ @SpringBootTest @RecordApplicationEvents -@ExtendWith(MockitoExtension.class) +@SuppressWarnings("NonAsciiCharacters") @DisplayNameGeneration(ReplaceUnderscores.class) +@ExtendWith({MockitoExtension.class, DataClearExtension.class}) public class EventTest { @Autowired protected ApplicationEvents events; + @Autowired + protected ReviewService reviewService; + @Autowired protected ProductRepository productRepository; @@ -44,8 +47,10 @@ public class EventTest { @Autowired protected ReviewRepository reviewRepository; - @Autowired - protected ReviewService reviewService; + @AfterEach + void tearDown() { + events.clear(); + } protected Long 단일_상품_저장(final Product product) { return productRepository.save(product).getId(); @@ -55,11 +60,6 @@ public class EventTest { return categoryRepository.save(category).getId(); } - protected void 복수_태그_저장(final Tag... tagsToSave) { - final var tags = List.of(tagsToSave); - tagRepository.saveAll(tags); - } - protected Long 단일_멤버_저장(final Member member) { return memberRepository.save(member).getId(); } diff --git a/backend/src/test/java/com/funeat/fixture/ReviewFixture.java b/backend/src/test/java/com/funeat/fixture/ReviewFixture.java index d68a09b6b..2538458ee 100644 --- a/backend/src/test/java/com/funeat/fixture/ReviewFixture.java +++ b/backend/src/test/java/com/funeat/fixture/ReviewFixture.java @@ -75,6 +75,10 @@ public class ReviewFixture { return new Review(member, product, "test5", 5L, "test", false, count); } + public static Review 리뷰_이미지없음_평점1점_재구매X_생성(final Member member, final Product product, final Long count) { + return new Review(member, product, "", 1L, "test", false, count); + } + public static ReviewCreateRequest 리뷰추가요청_생성(final Long rating, final List tagIds, final String content, final Boolean rebuy) { return new ReviewCreateRequest(rating, tagIds, content, rebuy); diff --git a/backend/src/test/java/com/funeat/review/application/ReviewDeleteEventListenerTest.java b/backend/src/test/java/com/funeat/review/application/ReviewDeleteEventListenerTest.java index 5edf33f36..9f5dfb73b 100644 --- a/backend/src/test/java/com/funeat/review/application/ReviewDeleteEventListenerTest.java +++ b/backend/src/test/java/com/funeat/review/application/ReviewDeleteEventListenerTest.java @@ -1,13 +1,14 @@ package com.funeat.review.application; import static com.funeat.fixture.CategoryFixture.카테고리_즉석조리_생성; -import static com.funeat.fixture.ImageFixture.이미지_생성; import static com.funeat.fixture.MemberFixture.멤버_멤버1_생성; import static com.funeat.fixture.MemberFixture.멤버_멤버2_생성; import static com.funeat.fixture.ProductFixture.상품_삼각김밥_가격1000원_평점2점_생성; -import static com.funeat.fixture.ReviewFixture.리뷰추가요청_재구매O_생성; -import static com.funeat.fixture.TagFixture.태그_맛있어요_TASTE_생성; -import static com.funeat.fixture.TagFixture.태그_아침식사_ETC_생성; +import static com.funeat.fixture.ReviewFixture.리뷰_이미지test1_평점1점_재구매O_생성; +import static com.funeat.fixture.ReviewFixture.리뷰_이미지test2_평점2점_재구매O_생성; +import static com.funeat.fixture.ReviewFixture.리뷰_이미지test3_평점3점_재구매O_생성; +import static com.funeat.fixture.ReviewFixture.리뷰_이미지test4_평점4점_재구매O_생성; +import static com.funeat.fixture.ReviewFixture.리뷰_이미지없음_평점1점_재구매X_생성; import static org.assertj.core.api.Assertions.assertThat; import static org.mockito.ArgumentMatchers.any; import static org.mockito.Mockito.doThrow; @@ -18,14 +19,11 @@ import com.funeat.common.ImageUploader; import com.funeat.common.exception.CommonException.S3DeleteFailException; import com.funeat.exception.CommonErrorCode; -import com.funeat.tag.domain.Tag; -import java.util.List; -import java.util.stream.Collectors; -import java.util.stream.Stream; import org.junit.jupiter.api.Nested; import org.junit.jupiter.api.Test; import org.springframework.boot.test.mock.mockito.MockBean; +@SuppressWarnings("NonAsciiCharacters") class ReviewDeleteEventListenerTest extends EventTest { @MockBean @@ -37,30 +35,19 @@ class 리뷰_삭제_이벤트_발행 { @Test void 리뷰_작성자가_리뷰_삭제_시도시_리뷰_삭제_이벤트가_발행된다() { // given - final var member = 멤버_멤버1_생성(); - final var memberId = 단일_멤버_저장(member); + final var author = 멤버_멤버1_생성(); + final var authorId = 단일_멤버_저장(author); final var category = 카테고리_즉석조리_생성(); 단일_카테고리_저장(category); final var product = 상품_삼각김밥_가격1000원_평점2점_생성(category); - final var productId = 단일_상품_저장(product); - - final var tag1 = 태그_맛있어요_TASTE_생성(); - final var tag2 = 태그_아침식사_ETC_생성(); - 복수_태그_저장(tag1, tag2); - - final var tagIds = 태그_아이디_변환(tag1, tag2); - final var image = 이미지_생성(); + 단일_상품_저장(product); - final var request = 리뷰추가요청_재구매O_생성(4L, tagIds); - reviewService.create(productId, memberId, image, request); - - final var review = reviewRepository.findAll().get(0); - final var reviewId = review.getId(); + final var review = reviewRepository.save(리뷰_이미지test1_평점1점_재구매O_생성(author, product, 0L)); // when - reviewService.deleteReview(reviewId, memberId); + reviewService.deleteReview(review.getId(), authorId); // then final var count = events.stream(ReviewDeleteEvent.class).count(); @@ -70,33 +57,23 @@ class 리뷰_삭제_이벤트_발행 { @Test void 리뷰_작성자가_아닌_사람이_리뷰_삭제_시도시_리뷰_삭제_이벤트가_발행되지_않는다() { // given - final var author = 멤버_멤버2_생성(); + final var author = 멤버_멤버1_생성(); final var authorId = 단일_멤버_저장(author); - final var member = 멤버_멤버1_생성(); + + final var member = 멤버_멤버2_생성(); final var memberId = 단일_멤버_저장(member); final var category = 카테고리_즉석조리_생성(); 단일_카테고리_저장(category); final var product = 상품_삼각김밥_가격1000원_평점2점_생성(category); - final var productId = 단일_상품_저장(product); - - final var tag1 = 태그_맛있어요_TASTE_생성(); - final var tag2 = 태그_아침식사_ETC_생성(); - 복수_태그_저장(tag1, tag2); + 단일_상품_저장(product); - final var tagIds = 태그_아이디_변환(tag1, tag2); - final var image = 이미지_생성(); - - final var request = 리뷰추가요청_재구매O_생성(4L, tagIds); - reviewService.create(productId, authorId, image, request); - - final var review = reviewRepository.findAll().get(0); - final var reviewId = review.getId(); + final var review = reviewRepository.save(리뷰_이미지test2_평점2점_재구매O_생성(author, product, 0L)); // when try { - reviewService.deleteReview(reviewId, memberId); + reviewService.deleteReview(review.getId(), memberId); } catch (Exception ignored) { } @@ -112,106 +89,68 @@ class 이미지_삭제_로직_작동 { @Test void 리뷰_삭제가_정상적으로_커밋되고_이미지가_존재하면_이미지_삭제_로직이_작동한다() { // given - final var member = 멤버_멤버1_생성(); - final var memberId = 단일_멤버_저장(member); + final var author = 멤버_멤버1_생성(); + final var authorId = 단일_멤버_저장(author); final var category = 카테고리_즉석조리_생성(); 단일_카테고리_저장(category); final var product = 상품_삼각김밥_가격1000원_평점2점_생성(category); - final var productId = 단일_상품_저장(product); - - final var tag1 = 태그_맛있어요_TASTE_생성(); - final var tag2 = 태그_아침식사_ETC_생성(); - 복수_태그_저장(tag1, tag2); - - final var tagIds = 태그_아이디_변환(tag1, tag2); - final var image = 이미지_생성(); + 단일_상품_저장(product); - final var request = 리뷰추가요청_재구매O_생성(4L, tagIds); - reviewService.create(productId, memberId, image, request); - - final var review = reviewRepository.findAll().get(0); - final var reviewId = review.getId(); + final var review = reviewRepository.save(리뷰_이미지test3_평점3점_재구매O_생성(author, product, 0L)); // when - reviewService.deleteReview(reviewId, memberId); + reviewService.deleteReview(review.getId(), authorId); // then - verify(uploader, timeout(100).times(1)).delete(any()); + verify(uploader, timeout(1000).times(1)).delete(any()); } @Test void 리뷰_삭제가_정상적으로_커밋되었지만_이미지가_존재하지_않으면_이미지_삭제_로직이_작동하지않는다() { // given - final var member = 멤버_멤버1_생성(); - final var memberId = 단일_멤버_저장(member); + final var author = 멤버_멤버1_생성(); + final var authorId = 단일_멤버_저장(author); final var category = 카테고리_즉석조리_생성(); 단일_카테고리_저장(category); final var product = 상품_삼각김밥_가격1000원_평점2점_생성(category); - final var productId = 단일_상품_저장(product); - - final var tag1 = 태그_맛있어요_TASTE_생성(); - final var tag2 = 태그_아침식사_ETC_생성(); - 복수_태그_저장(tag1, tag2); + 단일_상품_저장(product); - final var tagIds = 태그_아이디_변환(tag1, tag2); - - final var request = 리뷰추가요청_재구매O_생성(4L, tagIds); - reviewService.create(productId, memberId, null, request); - - final var review = reviewRepository.findAll().get(0); - final var reviewId = review.getId(); + final var review = reviewRepository.save(리뷰_이미지없음_평점1점_재구매X_생성(author, product, 0L)); // when - reviewService.deleteReview(reviewId, memberId); + reviewService.deleteReview(review.getId(), authorId); // then - verify(uploader, timeout(100).times(0)).delete(any()); + verify(uploader, timeout(1000).times(0)).delete(any()); } @Test void 이미지_삭제_로직이_실패해도_메인로직까지_롤백되어서는_안된다() { // given - final var member = 멤버_멤버1_생성(); - final var memberId = 단일_멤버_저장(member); + final var author = 멤버_멤버1_생성(); + final var authorId = 단일_멤버_저장(author); final var category = 카테고리_즉석조리_생성(); 단일_카테고리_저장(category); final var product = 상품_삼각김밥_가격1000원_평점2점_생성(category); - final var productId = 단일_상품_저장(product); - - final var tag1 = 태그_맛있어요_TASTE_생성(); - final var tag2 = 태그_아침식사_ETC_생성(); - 복수_태그_저장(tag1, tag2); - - final var tagIds = 태그_아이디_변환(tag1, tag2); - final var image = 이미지_생성(); + 단일_상품_저장(product); - final var request = 리뷰추가요청_재구매O_생성(4L, tagIds); - reviewService.create(productId, memberId, image, request); - - final var review = reviewRepository.findAll().get(0); - final var reviewId = review.getId(); + final var review = reviewRepository.save(리뷰_이미지test4_평점4점_재구매O_생성(author, product, 0L)); doThrow(new S3DeleteFailException(CommonErrorCode.UNKNOWN_SERVER_ERROR_CODE)) .when(uploader) .delete(any()); // when - reviewService.deleteReview(reviewId, memberId); + reviewService.deleteReview(review.getId(), authorId); // then - assertThat(reviewRepository.findById(reviewId)).isEmpty(); + assertThat(reviewRepository.findById(review.getId())).isEmpty(); } } - - private List 태그_아이디_변환(final Tag... tags) { - return Stream.of(tags) - .map(Tag::getId) - .collect(Collectors.toList()); - } } From 9467d94f98f710aa5b7ce48ae97fd9bb0f3a85a8 Mon Sep 17 00:00:00 2001 From: JFe <33208246+Go-Jaecheol@users.noreply.github.com> Date: Wed, 18 Oct 2023 11:58:47 +0900 Subject: [PATCH 27/55] =?UTF-8?q?[BE]=20refactor:=20=EB=A6=AC=EB=B7=B0=20?= =?UTF-8?q?=EB=9E=AD=ED=82=B9=20=EC=95=8C=EA=B3=A0=EB=A6=AC=EC=A6=98=20?= =?UTF-8?q?=EA=B0=9C=EC=84=A0=20(#737)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat: 리뷰의 랭킹 점수 계산 로직 추가 * test: 리뷰 랭킹 점수 계산 관련 테스트 추가 * refactor: 리뷰 랭킹 기능 수정 * test: 리뷰 랭킹 서비스 테스트 추가 * style: import 와일드카드 제거 * refactor: 좋아요 1개 이상인 리뷰만 랭킹에 들어갈 수 있도록 수정 * refactor: 사용하지 않는 메서드 및 테스트 삭제 * test: findReviewsByFavoriteCountGreaterThanEqual 테스트 추가 * style: ReviewServiceTest 와일드카드 제거 * style: import 정렬 순서 변경 * fix: 충돌 해결 --- .../review/application/ReviewService.java | 10 +- .../java/com/funeat/review/domain/Review.java | 21 +++ .../review/persistence/ReviewRepository.java | 6 +- .../com/funeat/fixture/ReviewFixture.java | 6 + .../review/application/ReviewServiceTest.java | 156 ++++++++++++++++++ .../com/funeat/review/domain/ReviewTest.java | 40 +++++ .../persistence/ReviewRepositoryTest.java | 96 ++++++----- 7 files changed, 290 insertions(+), 45 deletions(-) create mode 100644 backend/src/test/java/com/funeat/review/domain/ReviewTest.java diff --git a/backend/src/main/java/com/funeat/review/application/ReviewService.java b/backend/src/main/java/com/funeat/review/application/ReviewService.java index 320bc13eb..5719e8dd0 100644 --- a/backend/src/main/java/com/funeat/review/application/ReviewService.java +++ b/backend/src/main/java/com/funeat/review/application/ReviewService.java @@ -37,6 +37,7 @@ import com.funeat.review.specification.SortingReviewSpecification; import com.funeat.tag.domain.Tag; import com.funeat.tag.persistence.TagRepository; +import java.util.Comparator; import java.util.List; import java.util.Optional; import java.util.stream.Collectors; @@ -58,6 +59,8 @@ public class ReviewService { private static final int START_INDEX = 0; private static final int ONE = 1; private static final String EMPTY_URL = ""; + private static final int RANKING_SIZE = 3; + private static final long RANKING_MINIMUM_FAVORITE_COUNT = 1L; private static final int REVIEW_PAGE_SIZE = 10; private final ReviewRepository reviewRepository; @@ -208,9 +211,10 @@ private Boolean hasNextPage(final List sortingReviews) { } public RankingReviewsResponse getTopReviews() { - final List rankingReviews = reviewRepository.findTop3ByOrderByFavoriteCountDescIdDesc(); - - final List dtos = rankingReviews.stream() + final List reviews = reviewRepository.findReviewsByFavoriteCountGreaterThanEqual(RANKING_MINIMUM_FAVORITE_COUNT); + final List dtos = reviews.stream() + .sorted(Comparator.comparing(Review::calculateRankingScore).reversed()) + .limit(RANKING_SIZE) .map(RankingReviewDto::toDto) .collect(Collectors.toList()); diff --git a/backend/src/main/java/com/funeat/review/domain/Review.java b/backend/src/main/java/com/funeat/review/domain/Review.java index d990666d3..9b1a458d3 100644 --- a/backend/src/main/java/com/funeat/review/domain/Review.java +++ b/backend/src/main/java/com/funeat/review/domain/Review.java @@ -4,6 +4,7 @@ import com.funeat.member.domain.favorite.ReviewFavorite; import com.funeat.product.domain.Product; import java.time.LocalDateTime; +import java.time.temporal.ChronoUnit; import java.util.ArrayList; import java.util.List; import java.util.Objects; @@ -20,6 +21,8 @@ @Entity public class Review { + private static final double RANKING_GRAVITY = 0.5; + @Id @GeneratedValue(strategy = GenerationType.IDENTITY) private Long id; @@ -80,6 +83,18 @@ public Review(final Member member, final Product findProduct, final String image this.favoriteCount = favoriteCount; } + public Review(final Member member, final Product findProduct, final String image, final Long rating, + final String content, final Boolean reBuy, final Long favoriteCount, final LocalDateTime createdAt) { + this.member = member; + this.product = findProduct; + this.image = image; + this.rating = rating; + this.content = content; + this.reBuy = reBuy; + this.favoriteCount = favoriteCount; + this.createdAt = createdAt; + } + public void addFavoriteCount() { this.favoriteCount++; } @@ -88,6 +103,12 @@ public void minusFavoriteCount() { this.favoriteCount--; } + public Double calculateRankingScore() { + final long age = ChronoUnit.DAYS.between(createdAt, LocalDateTime.now()); + final double denominator = Math.pow(age + 1.0, RANKING_GRAVITY); + return favoriteCount / denominator; + } + public boolean checkAuthor(final Member member) { return Objects.equals(this.member, member); } diff --git a/backend/src/main/java/com/funeat/review/persistence/ReviewRepository.java b/backend/src/main/java/com/funeat/review/persistence/ReviewRepository.java index 1cf889b0b..69d35018d 100644 --- a/backend/src/main/java/com/funeat/review/persistence/ReviewRepository.java +++ b/backend/src/main/java/com/funeat/review/persistence/ReviewRepository.java @@ -5,8 +5,6 @@ import com.funeat.member.domain.Member; import com.funeat.product.domain.Product; import com.funeat.review.domain.Review; -import com.funeat.review.dto.SortingReviewDto; -import java.time.LocalDateTime; import java.util.List; import java.util.Optional; import org.springframework.data.domain.Page; @@ -18,8 +16,6 @@ public interface ReviewRepository extends JpaRepository, ReviewCustomRepository { - List findTop3ByOrderByFavoriteCountDescIdDesc(); - Long countByProduct(final Product product); Page findReviewsByMember(final Member findMember, final Pageable pageable); @@ -36,4 +32,6 @@ public interface ReviewRepository extends JpaRepository, ReviewCus List findPopularReviewWithImage(@Param("id") final Long productId, final Pageable pageable); Optional findTopByProductOrderByFavoriteCountDescIdDesc(final Product product); + + List findReviewsByFavoriteCountGreaterThanEqual(final Long favoriteCount); } diff --git a/backend/src/test/java/com/funeat/fixture/ReviewFixture.java b/backend/src/test/java/com/funeat/fixture/ReviewFixture.java index 2538458ee..fee2b0b7b 100644 --- a/backend/src/test/java/com/funeat/fixture/ReviewFixture.java +++ b/backend/src/test/java/com/funeat/fixture/ReviewFixture.java @@ -11,6 +11,7 @@ import com.funeat.review.dto.ReviewCreateRequest; import com.funeat.review.dto.ReviewFavoriteRequest; import com.funeat.review.dto.SortingReviewRequest; +import java.time.LocalDateTime; import java.util.List; @SuppressWarnings("NonAsciiCharacters") @@ -75,6 +76,11 @@ public class ReviewFixture { return new Review(member, product, "test5", 5L, "test", false, count); } + public static Review 리뷰_이미지test5_평점5점_재구매X_생성(final Member member, final Product product, final Long count, + final LocalDateTime createdAt) { + return new Review(member, product, "test5", 5L, "test", false, count, createdAt); + } + public static Review 리뷰_이미지없음_평점1점_재구매X_생성(final Member member, final Product product, final Long count) { return new Review(member, product, "", 1L, "test", false, count); } diff --git a/backend/src/test/java/com/funeat/review/application/ReviewServiceTest.java b/backend/src/test/java/com/funeat/review/application/ReviewServiceTest.java index 2e7c82b43..87a10756c 100644 --- a/backend/src/test/java/com/funeat/review/application/ReviewServiceTest.java +++ b/backend/src/test/java/com/funeat/review/application/ReviewServiceTest.java @@ -1,5 +1,6 @@ package com.funeat.review.application; +import static com.funeat.fixture.CategoryFixture.카테고리_간편식사_생성; import static com.funeat.fixture.CategoryFixture.카테고리_즉석조리_생성; import static com.funeat.fixture.ImageFixture.이미지_생성; import static com.funeat.fixture.MemberFixture.멤버_멤버1_생성; @@ -8,6 +9,7 @@ import static com.funeat.fixture.PageFixture.페이지요청_생성; import static com.funeat.fixture.ProductFixture.상품_삼각김밥_가격1000원_평점2점_생성; import static com.funeat.fixture.ProductFixture.상품_삼각김밥_가격1000원_평점3점_생성; +import static com.funeat.fixture.ProductFixture.상품_삼각김밥_가격1000원_평점4점_생성; import static com.funeat.fixture.ProductFixture.상품_삼각김밥_가격1000원_평점5점_생성; import static com.funeat.fixture.ProductFixture.상품_삼각김밥_가격2000원_평점1점_생성; import static com.funeat.fixture.ProductFixture.상품_삼각김밥_가격2000원_평점3점_생성; @@ -18,6 +20,7 @@ import static com.funeat.fixture.ReviewFixture.리뷰_이미지test3_평점3점_재구매O_생성; import static com.funeat.fixture.ReviewFixture.리뷰_이미지test3_평점3점_재구매X_생성; import static com.funeat.fixture.ReviewFixture.리뷰_이미지test4_평점4점_재구매O_생성; +import static com.funeat.fixture.ReviewFixture.리뷰_이미지test5_평점5점_재구매X_생성; import static com.funeat.fixture.ReviewFixture.리뷰_이미지없음_평점1점_재구매O_생성; import static com.funeat.fixture.ReviewFixture.리뷰정렬요청_좋아요수_내림차순_생성; import static com.funeat.fixture.ReviewFixture.리뷰정렬요청_최신순_생성; @@ -37,11 +40,15 @@ import com.funeat.member.exception.MemberException.MemberNotFoundException; import com.funeat.product.exception.ProductException.ProductNotFoundException; import com.funeat.review.domain.Review; +import com.funeat.review.dto.RankingReviewDto; +import com.funeat.review.dto.RankingReviewsResponse; import com.funeat.review.dto.MostFavoriteReviewResponse; import com.funeat.review.dto.SortingReviewDto; import com.funeat.review.exception.ReviewException.NotAuthorOfReviewException; import com.funeat.review.exception.ReviewException.ReviewNotFoundException; import com.funeat.tag.domain.Tag; +import java.time.LocalDateTime; +import java.util.Collections; import java.util.List; import java.util.Optional; import java.util.stream.Collectors; @@ -1007,6 +1014,155 @@ class getMostFavoriteReview_실패_테스트 { } } + @Nested + class getTopReviews_성공_테스트 { + + @Nested + class 리뷰_개수에_대한_테스트 { + + @Test + void 전체_리뷰가_하나도_없어도_반환값은_있어야한다() { + // given + final var expected = RankingReviewsResponse.toResponse(Collections.emptyList()); + + // when + final var actual = reviewService.getTopReviews(); + + // then + assertThat(actual).usingRecursiveComparison() + .isEqualTo(expected); + } + + @Test + void 전체_리뷰가_1개_이상_3개_미만이라도_리뷰가_나와야한다() { + // given + final var category = 카테고리_간편식사_생성(); + 단일_카테고리_저장(category); + + final var product = 상품_삼각김밥_가격1000원_평점4점_생성(category); + 단일_상품_저장(product); + + final var member = 멤버_멤버1_생성(); + 단일_멤버_저장(member); + + final var now = LocalDateTime.now(); + final var review1 = 리뷰_이미지test5_평점5점_재구매X_생성(member, product, 2L, now.minusDays(1L)); + final var review2 = 리뷰_이미지test5_평점5점_재구매X_생성(member, product, 2L, now); + 복수_리뷰_저장(review1, review2); + + final var rankingReviewDto1 = RankingReviewDto.toDto(review1); + final var rankingReviewDto2 = RankingReviewDto.toDto(review2); + final var rankingReviewDtos = List.of(rankingReviewDto2, rankingReviewDto1); + final var expected = RankingReviewsResponse.toResponse(rankingReviewDtos); + + // when + final var actual = reviewService.getTopReviews(); + + // then + assertThat(actual).usingRecursiveComparison() + .isEqualTo(expected); + } + + @Test + void 전체_리뷰_중_랭킹이_높은_상위_3개_리뷰를_구할_수_있다() { + // given + final var category = 카테고리_간편식사_생성(); + 단일_카테고리_저장(category); + + final var product = 상품_삼각김밥_가격1000원_평점4점_생성(category); + 단일_상품_저장(product); + + final var member = 멤버_멤버1_생성(); + 단일_멤버_저장(member); + + final var now = LocalDateTime.now(); + final var review1 = 리뷰_이미지test5_평점5점_재구매X_생성(member, product, 4L, now.minusDays(3L)); + final var review2 = 리뷰_이미지test5_평점5점_재구매X_생성(member, product, 6L, now.minusDays(2L)); + final var review3 = 리뷰_이미지test5_평점5점_재구매X_생성(member, product, 4L, now); + final var review4 = 리뷰_이미지test5_평점5점_재구매X_생성(member, product, 5L, now); + 복수_리뷰_저장(review1, review2, review3, review4); + + final var rankingReviewDto1 = RankingReviewDto.toDto(review1); + final var rankingReviewDto2 = RankingReviewDto.toDto(review2); + final var rankingReviewDto3 = RankingReviewDto.toDto(review3); + final var rankingReviewDto4 = RankingReviewDto.toDto(review4); + final var rankingReviewDtos = List.of(rankingReviewDto4, rankingReviewDto3, rankingReviewDto2); + final var expected = RankingReviewsResponse.toResponse(rankingReviewDtos); + + // when + final var actual = reviewService.getTopReviews(); + + // then + assertThat(actual).usingRecursiveComparison() + .isEqualTo(expected); + } + } + + @Nested + class 리뷰_랭킹_점수에_대한_테스트 { + + @Test + void 리뷰_좋아요_수가_같으면_최근_생성된_리뷰의_랭킹을_더_높게_반환한다() { + // given + final var category = 카테고리_간편식사_생성(); + 단일_카테고리_저장(category); + + final var product = 상품_삼각김밥_가격1000원_평점4점_생성(category); + 단일_상품_저장(product); + + final var member = 멤버_멤버1_생성(); + 단일_멤버_저장(member); + + final var now = LocalDateTime.now(); + final var review1 = 리뷰_이미지test5_평점5점_재구매X_생성(member, product, 10L, now.minusDays(9L)); + final var review2 = 리뷰_이미지test5_평점5점_재구매X_생성(member, product, 10L, now.minusDays(4L)); + 복수_리뷰_저장(review1, review2); + + final var rankingReviewDto1 = RankingReviewDto.toDto(review1); + final var rankingReviewDto2 = RankingReviewDto.toDto(review2); + final var rankingReviewDtos = List.of(rankingReviewDto2, rankingReviewDto1); + final var expected = RankingReviewsResponse.toResponse(rankingReviewDtos); + + // when + final var actual = reviewService.getTopReviews(); + + // then + assertThat(actual).usingRecursiveComparison() + .isEqualTo(expected); + } + + @Test + void 리뷰_생성_일자가_같으면_좋아요_수가_많은_리뷰의_랭킹을_더_높게_반환한다() { + // given + final var category = 카테고리_간편식사_생성(); + 단일_카테고리_저장(category); + + final var product = 상품_삼각김밥_가격1000원_평점4점_생성(category); + 단일_상품_저장(product); + + final var member = 멤버_멤버1_생성(); + 단일_멤버_저장(member); + + final var now = LocalDateTime.now(); + final var review1 = 리뷰_이미지test5_평점5점_재구매X_생성(member, product, 2L, now.minusDays(1L)); + final var review2 = 리뷰_이미지test5_평점5점_재구매X_생성(member, product, 4L, now.minusDays(1L)); + 복수_리뷰_저장(review1, review2); + + final var rankingReviewDto1 = RankingReviewDto.toDto(review1); + final var rankingReviewDto2 = RankingReviewDto.toDto(review2); + final var rankingReviewDtos = List.of(rankingReviewDto2, rankingReviewDto1); + final var expected = RankingReviewsResponse.toResponse(rankingReviewDtos); + + // when + final var actual = reviewService.getTopReviews(); + + // then + assertThat(actual).usingRecursiveComparison() + .isEqualTo(expected); + } + } + } + private List 태그_아이디_변환(final Tag... tags) { return Stream.of(tags) .map(Tag::getId) diff --git a/backend/src/test/java/com/funeat/review/domain/ReviewTest.java b/backend/src/test/java/com/funeat/review/domain/ReviewTest.java new file mode 100644 index 000000000..a9b02876b --- /dev/null +++ b/backend/src/test/java/com/funeat/review/domain/ReviewTest.java @@ -0,0 +1,40 @@ +package com.funeat.review.domain; + +import static com.funeat.fixture.CategoryFixture.카테고리_간편식사_생성; +import static com.funeat.fixture.MemberFixture.멤버_멤버1_생성; +import static com.funeat.fixture.ProductFixture.상품_삼각김밥_가격1000원_평점1점_생성; +import static com.funeat.fixture.ReviewFixture.리뷰_이미지test5_평점5점_재구매X_생성; +import static org.assertj.core.api.Assertions.assertThat; + +import java.time.LocalDateTime; +import org.junit.jupiter.api.DisplayNameGeneration; +import org.junit.jupiter.api.DisplayNameGenerator; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; + +@SuppressWarnings("NonAsciiCharacters") +@DisplayNameGeneration(DisplayNameGenerator.ReplaceUnderscores.class) +class ReviewTest { + + @Nested + class calculateRankingScore_성공_테스트 { + + @Test + void 리뷰_좋아요_수와_리뷰_생성_시간으로_해당_리뷰의_랭킹_점수를_구할_수_있다() { + // given + final var member = 멤버_멤버1_생성(); + final var category = 카테고리_간편식사_생성(); + final var product = 상품_삼각김밥_가격1000원_평점1점_생성(category); + final var favoriteCount = 4L; + final var review = 리뷰_이미지test5_평점5점_재구매X_생성(member, product, favoriteCount, LocalDateTime.now().minusDays(1L)); + + final var expected = favoriteCount / Math.pow(2.0, 0.5); + + // when + final var actual = review.calculateRankingScore(); + + // then + assertThat(actual).isEqualTo(expected); + } + } +} diff --git a/backend/src/test/java/com/funeat/review/persistence/ReviewRepositoryTest.java b/backend/src/test/java/com/funeat/review/persistence/ReviewRepositoryTest.java index ebac6adac..46e4645f4 100644 --- a/backend/src/test/java/com/funeat/review/persistence/ReviewRepositoryTest.java +++ b/backend/src/test/java/com/funeat/review/persistence/ReviewRepositoryTest.java @@ -9,15 +9,14 @@ import static com.funeat.fixture.ProductFixture.상품_삼각김밥_가격1000원_평점2점_생성; import static com.funeat.fixture.ProductFixture.상품_삼각김밥_가격2000원_평점3점_생성; import static com.funeat.fixture.ReviewFixture.리뷰_이미지test1_평점1점_재구매O_생성; -import static com.funeat.fixture.ReviewFixture.리뷰_이미지test1_평점1점_재구매X_생성; import static com.funeat.fixture.ReviewFixture.리뷰_이미지test3_평점3점_재구매O_생성; import static com.funeat.fixture.ReviewFixture.리뷰_이미지test3_평점3점_재구매X_생성; import static com.funeat.fixture.ReviewFixture.리뷰_이미지test4_평점4점_재구매O_생성; -import static com.funeat.fixture.ReviewFixture.리뷰_이미지test5_평점5점_재구매O_생성; import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.SoftAssertions.assertSoftly; import com.funeat.common.RepositoryTest; +import java.util.Collections; import java.util.List; import org.junit.jupiter.api.Nested; import org.junit.jupiter.api.Test; @@ -64,42 +63,6 @@ class countByProduct_성공_테스트 { } } - @Nested - class findTop3ByOrderByFavoriteCountDesc_성공_테스트 { - - @Test - void 전체_리뷰_목록에서_가장_좋아요가_높은_상위_3개의_리뷰를_가져온다() { - // given - final var member1 = 멤버_멤버1_생성(); - final var member2 = 멤버_멤버2_생성(); - final var member3 = 멤버_멤버3_생성(); - 복수_멤버_저장(member1, member2, member3); - - final var category = 카테고리_간편식사_생성(); - 단일_카테고리_저장(category); - - final var product1 = 상품_삼각김밥_가격1000원_평점2점_생성(category); - final var product2 = 상품_삼각김밥_가격2000원_평점3점_생성(category); - 복수_상품_저장(product1, product2); - - final var review1_1 = 리뷰_이미지test3_평점3점_재구매O_생성(member1, product1, 5L); - final var review1_2 = 리뷰_이미지test4_평점4점_재구매O_생성(member2, product1, 351L); - final var review1_3 = 리뷰_이미지test3_평점3점_재구매X_생성(member3, product1, 130L); - final var review2_2 = 리뷰_이미지test5_평점5점_재구매O_생성(member2, product2, 247L); - final var review3_2 = 리뷰_이미지test1_평점1점_재구매X_생성(member3, product2, 83L); - 복수_리뷰_저장(review1_1, review1_2, review1_3, review2_2, review3_2); - - final var expected = List.of(review1_2, review2_2, review1_3); - - // when - final var actual = reviewRepository.findTop3ByOrderByFavoriteCountDescIdDesc(); - - // then - assertThat(actual).usingRecursiveComparison() - .isEqualTo(expected); - } - } - @Nested class findPopularReviewWithImage_성공_테스트 { @@ -198,4 +161,61 @@ class findTopByProductOrderByFavoriteCountDescIdDesc_성공_테스트 { assertThat(actual.get()).isEqualTo(review2); } } + + @Nested + class findReviewsByFavoriteCountGreaterThanEqual_성공_테스트 { + + @Test + void 특정_좋아요_수_이상인_모든_리뷰들을_조회한다() { + // given + final var member = 멤버_멤버1_생성(); + 단일_멤버_저장(member); + + final var category = 카테고리_간편식사_생성(); + 단일_카테고리_저장(category); + + final var product = 상품_삼각김밥_가격1000원_평점2점_생성(category); + 단일_상품_저장(product); + + final var review1 = 리뷰_이미지test3_평점3점_재구매O_생성(member, product, 1L); + final var review2 = 리뷰_이미지test4_평점4점_재구매O_생성(member, product, 0L); + final var review3 = 리뷰_이미지test3_평점3점_재구매X_생성(member, product, 100L); + 복수_리뷰_저장(review1, review2, review3); + + final var expected = List.of(review1, review3); + + // when + final var actual = reviewRepository.findReviewsByFavoriteCountGreaterThanEqual(1L); + + // then + assertThat(actual).usingRecursiveComparison() + .isEqualTo(expected); + } + + @Test + void 특정_좋아요_수_이상인_리뷰가_없으면_빈_리스트를_반환한다() { + // given + final var member = 멤버_멤버1_생성(); + 단일_멤버_저장(member); + + final var category = 카테고리_간편식사_생성(); + 단일_카테고리_저장(category); + + final var product = 상품_삼각김밥_가격1000원_평점2점_생성(category); + 단일_상품_저장(product); + + final var review1 = 리뷰_이미지test3_평점3점_재구매O_생성(member, product, 0L); + final var review2 = 리뷰_이미지test4_평점4점_재구매O_생성(member, product, 0L); + 복수_리뷰_저장(review1, review2); + + final var expected = Collections.emptyList(); + + // when + final var actual = reviewRepository.findReviewsByFavoriteCountGreaterThanEqual(1L); + + // then + assertThat(actual).usingRecursiveComparison() + .isEqualTo(expected); + } + } } From 0532bbe4466a23df72d5f078251bebd1574ec051 Mon Sep 17 00:00:00 2001 From: JFe <33208246+Go-Jaecheol@users.noreply.github.com> Date: Wed, 18 Oct 2023 13:10:18 +0900 Subject: [PATCH 28/55] =?UTF-8?q?[BE]=20refactor:=20=EA=BF=80=EC=A1=B0?= =?UTF-8?q?=ED=95=A9=20=EB=9E=AD=ED=82=B9=20=EC=95=8C=EA=B3=A0=EB=A6=AC?= =?UTF-8?q?=EC=A6=98=20=EA=B0=9C=EC=84=A0=20(#755)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat: 꿀조합 랭킹 점수 계산 로직 추가 * refactor: 꿀조합 랭킹 기능 수정 * test: 꿀조합 랭킹 관련 테스트 추가 * refactor: import 정렬 * test: 상황에 따른 꿀조합 랭킹 서비스 테스트 추가 * refactor: Objects import 추가 * fix: 충돌 해결 --- .../com/funeat/product/domain/Category.java | 7 +- .../recipe/application/RecipeService.java | 9 +- .../java/com/funeat/recipe/domain/Recipe.java | 18 ++ .../recipe/persistence/RecipeRepository.java | 4 +- .../presentation/ReviewApiController.java | 4 - .../com/funeat/fixture/RecipeFixture.java | 6 + .../recipe/application/RecipeServiceTest.java | 156 ++++++++++++++++++ .../com/funeat/recipe/domain/RecipeTest.java | 36 ++++ .../persistence/RecipeRepositoryTest.java | 38 ++++- 9 files changed, 259 insertions(+), 19 deletions(-) create mode 100644 backend/src/test/java/com/funeat/recipe/domain/RecipeTest.java diff --git a/backend/src/main/java/com/funeat/product/domain/Category.java b/backend/src/main/java/com/funeat/product/domain/Category.java index 5d6c62a08..7702a087e 100644 --- a/backend/src/main/java/com/funeat/product/domain/Category.java +++ b/backend/src/main/java/com/funeat/product/domain/Category.java @@ -1,6 +1,11 @@ package com.funeat.product.domain; -import javax.persistence.*; +import javax.persistence.Entity; +import javax.persistence.EnumType; +import javax.persistence.Enumerated; +import javax.persistence.GeneratedValue; +import javax.persistence.GenerationType; +import javax.persistence.Id; @Entity public class Category { diff --git a/backend/src/main/java/com/funeat/recipe/application/RecipeService.java b/backend/src/main/java/com/funeat/recipe/application/RecipeService.java index d9ede67c3..19545b20b 100644 --- a/backend/src/main/java/com/funeat/recipe/application/RecipeService.java +++ b/backend/src/main/java/com/funeat/recipe/application/RecipeService.java @@ -44,6 +44,7 @@ import com.funeat.recipe.persistence.RecipeImageRepository; import com.funeat.recipe.persistence.RecipeRepository; import java.util.ArrayList; +import java.util.Comparator; import java.util.List; import java.util.Objects; import java.util.stream.Collectors; @@ -61,8 +62,8 @@ @Transactional(readOnly = true) public class RecipeService { - private static final int THREE = 3; - private static final int TOP = 0; + private static final long RANKING_MINIMUM_FAVORITE_COUNT = 1L; + private static final int RANKING_SIZE = 3; private static final int RECIPE_COMMENT_PAGE_SIZE = 10; private static final int DEFAULT_CURSOR_PAGINATION_SIZE = 11; @@ -206,9 +207,11 @@ public SearchRecipeResultsResponse getSearchResults(final String query, final Pa } public RankingRecipesResponse getTop3Recipes() { - final List recipes = recipeRepository.findRecipesByOrderByFavoriteCountDesc(PageRequest.of(TOP, THREE)); + final List recipes = recipeRepository.findRecipesByFavoriteCountGreaterThanEqual(RANKING_MINIMUM_FAVORITE_COUNT); final List dtos = recipes.stream() + .sorted(Comparator.comparing(Recipe::calculateRankingScore).reversed()) + .limit(RANKING_SIZE) .map(recipe -> { final List findRecipeImages = recipeImageRepository.findByRecipe(recipe); final RecipeAuthorDto author = RecipeAuthorDto.toDto(recipe.getMember()); diff --git a/backend/src/main/java/com/funeat/recipe/domain/Recipe.java b/backend/src/main/java/com/funeat/recipe/domain/Recipe.java index dcb607148..5ffb0438b 100644 --- a/backend/src/main/java/com/funeat/recipe/domain/Recipe.java +++ b/backend/src/main/java/com/funeat/recipe/domain/Recipe.java @@ -2,6 +2,7 @@ import com.funeat.member.domain.Member; import java.time.LocalDateTime; +import java.time.temporal.ChronoUnit; import javax.persistence.Column; import javax.persistence.Entity; import javax.persistence.FetchType; @@ -14,6 +15,8 @@ @Entity public class Recipe { + private static final double RANKING_GRAVITY = 0.1; + @Id @GeneratedValue(strategy = GenerationType.IDENTITY) private Long id; @@ -48,6 +51,21 @@ public Recipe(final String title, final String content, final Member member, this.favoriteCount = favoriteCount; } + public Recipe(final String title, final String content, final Member member, final Long favoriteCount, + final LocalDateTime createdAt) { + this.title = title; + this.content = content; + this.member = member; + this.favoriteCount = favoriteCount; + this.createdAt = createdAt; + } + + public Double calculateRankingScore() { + final long age = ChronoUnit.DAYS.between(createdAt, LocalDateTime.now()); + final double denominator = Math.pow(age + 1.0, RANKING_GRAVITY); + return favoriteCount / denominator; + } + public void addFavoriteCount() { this.favoriteCount++; } diff --git a/backend/src/main/java/com/funeat/recipe/persistence/RecipeRepository.java b/backend/src/main/java/com/funeat/recipe/persistence/RecipeRepository.java index 4d1a3a306..ce5ef3c31 100644 --- a/backend/src/main/java/com/funeat/recipe/persistence/RecipeRepository.java +++ b/backend/src/main/java/com/funeat/recipe/persistence/RecipeRepository.java @@ -32,9 +32,9 @@ public interface RecipeRepository extends JpaRepository { @Query("SELECT r FROM Recipe r LEFT JOIN ProductRecipe pr ON pr.product = :product WHERE pr.recipe.id = r.id") Page findRecipesByProduct(final Product product, final Pageable pageable); - List findRecipesByOrderByFavoriteCountDesc(final Pageable pageable); - @Lock(PESSIMISTIC_WRITE) @Query("SELECT r FROM Recipe r WHERE r.id=:id") Optional findByIdForUpdate(final Long id); + + List findRecipesByFavoriteCountGreaterThanEqual(final Long favoriteCount); } diff --git a/backend/src/main/java/com/funeat/review/presentation/ReviewApiController.java b/backend/src/main/java/com/funeat/review/presentation/ReviewApiController.java index cb68d6e6b..0249b6967 100644 --- a/backend/src/main/java/com/funeat/review/presentation/ReviewApiController.java +++ b/backend/src/main/java/com/funeat/review/presentation/ReviewApiController.java @@ -11,14 +11,10 @@ import com.funeat.review.dto.SortingReviewRequest; import com.funeat.review.dto.SortingReviewsResponse; import java.net.URI; -import java.util.Objects; import java.util.Optional; import javax.validation.Valid; -import org.springframework.data.domain.Pageable; -import org.springframework.data.web.PageableDefault; import org.springframework.http.MediaType; import org.springframework.http.ResponseEntity; -import org.springframework.web.bind.annotation.DeleteMapping; import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.ModelAttribute; import org.springframework.web.bind.annotation.PatchMapping; diff --git a/backend/src/test/java/com/funeat/fixture/RecipeFixture.java b/backend/src/test/java/com/funeat/fixture/RecipeFixture.java index 2050727f3..2d3bb3deb 100644 --- a/backend/src/test/java/com/funeat/fixture/RecipeFixture.java +++ b/backend/src/test/java/com/funeat/fixture/RecipeFixture.java @@ -6,6 +6,7 @@ import com.funeat.recipe.domain.RecipeImage; import com.funeat.recipe.dto.RecipeCreateRequest; import com.funeat.recipe.dto.RecipeFavoriteRequest; +import java.time.LocalDateTime; import java.util.List; @SuppressWarnings("NonAsciiCharacters") @@ -33,6 +34,11 @@ public class RecipeFixture { return new Recipe("The most delicious recipes", "More rice, more rice, more rice.. Done!!", member, favoriteCount); } + public static Recipe 레시피_생성(final Member member, final Long favoriteCount, final LocalDateTime createdAt) { + return new Recipe("The most delicious recipes", "More rice, more rice, more rice.. Done!!", + member, favoriteCount, createdAt); + } + public static RecipeFavorite 레시피_좋아요_생성(final Member member, final Recipe recipe, final Boolean favorite) { return new RecipeFavorite(member, recipe, favorite); } diff --git a/backend/src/test/java/com/funeat/recipe/application/RecipeServiceTest.java b/backend/src/test/java/com/funeat/recipe/application/RecipeServiceTest.java index c0f68e789..81badf4f6 100644 --- a/backend/src/test/java/com/funeat/recipe/application/RecipeServiceTest.java +++ b/backend/src/test/java/com/funeat/recipe/application/RecipeServiceTest.java @@ -36,6 +36,9 @@ import com.funeat.product.domain.CategoryType; import com.funeat.product.domain.Product; import com.funeat.product.exception.ProductException.ProductNotFoundException; +import com.funeat.recipe.dto.RankingRecipeDto; +import com.funeat.recipe.dto.RankingRecipesResponse; +import com.funeat.recipe.dto.RecipeAuthorDto; import com.funeat.recipe.dto.RecipeCommentCondition; import com.funeat.recipe.dto.RecipeCommentCreateRequest; import com.funeat.recipe.dto.RecipeCommentResponse; @@ -43,6 +46,7 @@ import com.funeat.recipe.dto.RecipeDetailResponse; import com.funeat.recipe.dto.RecipeDto; import com.funeat.recipe.exception.RecipeException.RecipeNotFoundException; +import java.time.LocalDateTime; import java.util.ArrayList; import java.util.Collections; import java.util.List; @@ -551,6 +555,158 @@ class likeRecipe_실패_테스트 { } } + @Nested + class getTop3Recipes_성공_테스트 { + + @Nested + class 꿀조합_개수에_대한_테스트 { + + @Test + void 전체_꿀조합이_하나도_없어도_반환값은_있어야한다() { + // given + final var expected = RankingRecipesResponse.toResponse(Collections.emptyList()); + + // when + final var actual = recipeService.getTop3Recipes(); + + // then + assertThat(actual).usingRecursiveComparison() + .isEqualTo(expected); + } + + @Test + void 랭킹_조건에_부합하는_꿀조합이_1개면_꿀조합이_1개_반환된다() { + // given + final var member = 멤버_멤버1_생성(); + 단일_멤버_저장(member); + + final var now = LocalDateTime.now(); + final var recipe = 레시피_생성(member, 2L, now); + 단일_꿀조합_저장(recipe); + + final var author = RecipeAuthorDto.toDto(member); + final var rankingRecipeDto = RankingRecipeDto.toDto(recipe, Collections.emptyList(), author); + final var rankingRecipesDtos = Collections.singletonList(rankingRecipeDto); + final var expected = RankingRecipesResponse.toResponse(rankingRecipesDtos); + + // when + final var actual = recipeService.getTop3Recipes(); + + // then + assertThat(actual).usingRecursiveComparison() + .isEqualTo(expected); + } + + @Test + void 랭킹_조건에_부합하는_꿀조합이_2개면_꿀조합이_2개_반환된다() { + // given + final var member = 멤버_멤버1_생성(); + 단일_멤버_저장(member); + + final var now = LocalDateTime.now(); + final var recipe1 = 레시피_생성(member, 2L, now.minusDays(1L)); + final var recipe2 = 레시피_생성(member, 2L, now); + 복수_꿀조합_저장(recipe1, recipe2); + + final var author = RecipeAuthorDto.toDto(member); + final var rankingRecipeDto1 = RankingRecipeDto.toDto(recipe1, Collections.emptyList(), author); + final var rankingRecipeDto2 = RankingRecipeDto.toDto(recipe2, Collections.emptyList(), author); + final var rankingRecipesDtos = List.of(rankingRecipeDto2, rankingRecipeDto1); + final var expected = RankingRecipesResponse.toResponse(rankingRecipesDtos); + + // when + final var actual = recipeService.getTop3Recipes(); + + // then + assertThat(actual).usingRecursiveComparison() + .isEqualTo(expected); + } + + @Test + void 전체_꿀조합_중_랭킹이_높은_상위_3개_꿀조합을_구할_수_있다() { + // given + final var member = 멤버_멤버1_생성(); + 단일_멤버_저장(member); + + final var now = LocalDateTime.now(); + final var recipe1 = 레시피_생성(member, 4L, now.minusDays(10L)); + final var recipe2 = 레시피_생성(member, 6L, now.minusDays(10L)); + final var recipe3 = 레시피_생성(member, 5L, now); + final var recipe4 = 레시피_생성(member, 6L, now); + 복수_꿀조합_저장(recipe1, recipe2, recipe3, recipe4); + + final var author = RecipeAuthorDto.toDto(member); + final var rankingRecipeDto1 = RankingRecipeDto.toDto(recipe1, Collections.emptyList(), author); + final var rankingRecipeDto2 = RankingRecipeDto.toDto(recipe2, Collections.emptyList(), author); + final var rankingRecipeDto3 = RankingRecipeDto.toDto(recipe3, Collections.emptyList(), author); + final var rankingRecipeDto4 = RankingRecipeDto.toDto(recipe4, Collections.emptyList(), author); + final var rankingRecipesDtos = List.of(rankingRecipeDto4, rankingRecipeDto3, rankingRecipeDto2); + final var expected = RankingRecipesResponse.toResponse(rankingRecipesDtos); + + // when + final var actual = recipeService.getTop3Recipes(); + + // then + assertThat(actual).usingRecursiveComparison() + .isEqualTo(expected); + } + } + + @Nested + class 꿀조합_랭킹_점수에_대한_테스트 { + + @Test + void 꿀조합_좋아요_수가_같으면_최근_생성된_꿀조합의_랭킹을_더_높게_반환한다() { + // given + final var member = 멤버_멤버1_생성(); + 단일_멤버_저장(member); + + final var now = LocalDateTime.now(); + final var recipe1 = 레시피_생성(member, 10L, now.minusDays(9L)); + final var recipe2 = 레시피_생성(member, 10L, now.minusDays(4L)); + 복수_꿀조합_저장(recipe1, recipe2); + + final var author = RecipeAuthorDto.toDto(member); + final var rankingRecipeDto1 = RankingRecipeDto.toDto(recipe1, Collections.emptyList(), author); + final var rankingRecipeDto2 = RankingRecipeDto.toDto(recipe2, Collections.emptyList(), author); + final var rankingRecipesDtos = List.of(rankingRecipeDto2, rankingRecipeDto1); + final var expected = RankingRecipesResponse.toResponse(rankingRecipesDtos); + + // when + final var actual = recipeService.getTop3Recipes(); + + // then + assertThat(actual).usingRecursiveComparison() + .isEqualTo(expected); + } + + @Test + void 꿀조합_생성_일자가_같으면_좋아요_수가_많은_꿀조합의_랭킹을_더_높게_반환한다() { + // given + final var member = 멤버_멤버1_생성(); + 단일_멤버_저장(member); + + final var now = LocalDateTime.now(); + final var recipe1 = 레시피_생성(member, 2L, now.minusDays(1L)); + final var recipe2 = 레시피_생성(member, 4L, now.minusDays(1L)); + 복수_꿀조합_저장(recipe1, recipe2); + + final var author = RecipeAuthorDto.toDto(member); + final var rankingRecipeDto1 = RankingRecipeDto.toDto(recipe1, Collections.emptyList(), author); + final var rankingRecipeDto2 = RankingRecipeDto.toDto(recipe2, Collections.emptyList(), author); + final var rankingRecipesDtos = List.of(rankingRecipeDto2, rankingRecipeDto1); + final var expected = RankingRecipesResponse.toResponse(rankingRecipesDtos); + + // when + final var actual = recipeService.getTop3Recipes(); + + // then + assertThat(actual).usingRecursiveComparison() + .isEqualTo(expected); + } + } + } + @Nested class writeCommentOfRecipe_성공_테스트 { diff --git a/backend/src/test/java/com/funeat/recipe/domain/RecipeTest.java b/backend/src/test/java/com/funeat/recipe/domain/RecipeTest.java new file mode 100644 index 000000000..7a0d28030 --- /dev/null +++ b/backend/src/test/java/com/funeat/recipe/domain/RecipeTest.java @@ -0,0 +1,36 @@ +package com.funeat.recipe.domain; + +import static com.funeat.fixture.MemberFixture.멤버_멤버1_생성; +import static com.funeat.fixture.RecipeFixture.레시피_생성; +import static org.assertj.core.api.Assertions.assertThat; + +import java.time.LocalDateTime; +import org.junit.jupiter.api.DisplayNameGeneration; +import org.junit.jupiter.api.DisplayNameGenerator; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; + +@SuppressWarnings("NonAsciiCharacters") +@DisplayNameGeneration(DisplayNameGenerator.ReplaceUnderscores.class) +class RecipeTest { + + @Nested + class calculateRankingScore_성공_테스트 { + + @Test + void 꿀조합_좋아요_수와_꿀조합_생성_시간으로_해당_꿀조합의_랭킹_점수를_구할_수_있다() { + // given + final var member = 멤버_멤버1_생성(); + final var favoriteCount = 4L; + final var recipe = 레시피_생성(member, favoriteCount, LocalDateTime.now().minusDays(1L)); + + final var expected = favoriteCount / Math.pow(2.0, 0.1); + + // when + final var actual = recipe.calculateRankingScore(); + + // then + assertThat(actual).isEqualTo(expected); + } + } +} diff --git a/backend/src/test/java/com/funeat/recipe/persistence/RecipeRepositoryTest.java b/backend/src/test/java/com/funeat/recipe/persistence/RecipeRepositoryTest.java index 494f7b215..9c53177db 100644 --- a/backend/src/test/java/com/funeat/recipe/persistence/RecipeRepositoryTest.java +++ b/backend/src/test/java/com/funeat/recipe/persistence/RecipeRepositoryTest.java @@ -21,6 +21,7 @@ import static org.assertj.core.api.Assertions.assertThat; import com.funeat.common.RepositoryTest; +import java.util.Collections; import java.util.List; import org.junit.jupiter.api.Nested; import org.junit.jupiter.api.Test; @@ -259,25 +260,44 @@ class findRecipesByProduct_성공_테스트 { } @Nested - class findRecipesByOrderByFavoriteCountDesc_성공_테스트 { + class findRecipesByFavoriteCountGreaterThanEqual_성공_테스트 { @Test - void 좋아요순으로_상위_3개의_레시피들을_조회한다() { + void 특정_좋아요_수_이상인_모든_꿀조합들을_조회한다() { // given final var member = 멤버_멤버1_생성(); 단일_멤버_저장(member); - final var recipe1 = 레시피_생성(member, 1L); - final var recipe2 = 레시피_생성(member, 2L); - final var recipe3 = 레시피_생성(member, 3L); - final var recipe4 = 레시피_생성(member, 4L); + final var recipe1 = 레시피_생성(member, 0L); + final var recipe2 = 레시피_생성(member, 1L); + final var recipe3 = 레시피_생성(member, 10L); + final var recipe4 = 레시피_생성(member, 100L); 복수_꿀조합_저장(recipe1, recipe2, recipe3, recipe4); - final var page = 페이지요청_기본_생성(0, 3); - final var expected = List.of(recipe4, recipe3, recipe2); + final var expected = List.of(recipe2, recipe3, recipe4); + + // when + final var actual = recipeRepository.findRecipesByFavoriteCountGreaterThanEqual(1L); + + // then + assertThat(actual).usingRecursiveComparison() + .isEqualTo(expected); + } + + @Test + void 특정_좋아요_수_이상인_꿀조합이_없으면_빈_리스트를_반환한다() { + // given + final var member = 멤버_멤버1_생성(); + 단일_멤버_저장(member); + + final var recipe1 = 레시피_생성(member, 0L); + final var recipe2 = 레시피_생성(member, 0L); + 복수_꿀조합_저장(recipe1, recipe2); + + final var expected = Collections.emptyList(); // when - final var actual = recipeRepository.findRecipesByOrderByFavoriteCountDesc(page); + final var actual = recipeRepository.findRecipesByFavoriteCountGreaterThanEqual(1L); // then assertThat(actual).usingRecursiveComparison() From 111d7eae20dbebc0f4fc0a0c8e4d47753a2ec07b Mon Sep 17 00:00:00 2001 From: Taeeun Kim Date: Wed, 18 Oct 2023 13:11:13 +0900 Subject: [PATCH 29/55] =?UTF-8?q?[FE]=20=EB=A6=AC=EB=B7=B0=20=EC=82=AD?= =?UTF-8?q?=EC=A0=9C=20=EA=B8=B0=EB=8A=A5=20=EA=B5=AC=ED=98=84=20(#780)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * refactor: MemberReviewItem과 ReviewRankingItem 분리 * feat: 휴지통 모양 svg icon 추가 * feat: 리뷰 삭제 구현 * feat: 스토리북에 args 추가 * refactor: isMemberPage -> isPreview로 네이밍 수정 --- frontend/.storybook/preview-body.html | 3 + .../src/components/Common/Svg/SvgIcon.tsx | 3 +- .../src/components/Common/Svg/SvgSprite.tsx | 3 + .../MemberRecipeList/MemberRecipeList.tsx | 10 +- .../MemberReviewItem.stories.tsx | 35 +++++ .../MemberReviewItem/MemberReviewItem.tsx | 123 ++++++++++++++++++ .../MemberReviewList/MemberReviewList.tsx | 19 +-- frontend/src/components/Members/index.ts | 1 + .../ReviewRankingItem/ReviewRankingItem.tsx | 16 +-- frontend/src/hooks/queries/members/index.ts | 1 + .../hooks/queries/members/useDeleteReview.ts | 20 +++ frontend/src/mocks/handlers/memberHandlers.ts | 4 + frontend/src/pages/MemberPage.tsx | 4 +- frontend/src/types/review.ts | 10 ++ 14 files changed, 227 insertions(+), 25 deletions(-) create mode 100644 frontend/src/components/Members/MemberReviewItem/MemberReviewItem.stories.tsx create mode 100644 frontend/src/components/Members/MemberReviewItem/MemberReviewItem.tsx create mode 100644 frontend/src/hooks/queries/members/useDeleteReview.ts diff --git a/frontend/.storybook/preview-body.html b/frontend/.storybook/preview-body.html index 1461bde42..46ce41c44 100644 --- a/frontend/.storybook/preview-body.html +++ b/frontend/.storybook/preview-body.html @@ -119,6 +119,9 @@ d="M8 7a.5.5 0 0 1 .5.5v3a.5.5 0 0 1-1 0v-3A.5.5 0 0 1 8 7zm0-.75a.749.749 0 1 0 0-1.5.749.749 0 0 0 0 1.498zM2 8a6 6 0 1 1 12 0A6 6 0 0 1 2 8zm6-5a5 5 0 1 0 0 10A5 5 0 0 0 8 3z" /> + + +
    diff --git a/frontend/src/components/Common/Svg/SvgIcon.tsx b/frontend/src/components/Common/Svg/SvgIcon.tsx index 0705d543a..e31256ae6 100644 --- a/frontend/src/components/Common/Svg/SvgIcon.tsx +++ b/frontend/src/components/Common/Svg/SvgIcon.tsx @@ -23,7 +23,8 @@ export const SVG_ICON_VARIANTS = [ 'camera', 'link', 'plane', - 'info' + 'info', + 'trashcan', ] as const; export type SvgIconVariant = (typeof SVG_ICON_VARIANTS)[number]; diff --git a/frontend/src/components/Common/Svg/SvgSprite.tsx b/frontend/src/components/Common/Svg/SvgSprite.tsx index 450c870be..e0fa06dd5 100644 --- a/frontend/src/components/Common/Svg/SvgSprite.tsx +++ b/frontend/src/components/Common/Svg/SvgSprite.tsx @@ -86,6 +86,9 @@ const SvgSprite = () => { + + + ); }; diff --git a/frontend/src/components/Members/MemberRecipeList/MemberRecipeList.tsx b/frontend/src/components/Members/MemberRecipeList/MemberRecipeList.tsx index 14558353f..740daddff 100644 --- a/frontend/src/components/Members/MemberRecipeList/MemberRecipeList.tsx +++ b/frontend/src/components/Members/MemberRecipeList/MemberRecipeList.tsx @@ -10,15 +10,15 @@ import { useInfiniteMemberRecipeQuery } from '@/hooks/queries/members'; import useDisplaySlice from '@/utils/displaySlice'; interface MemberRecipeListProps { - isMemberPage?: boolean; + isPreview?: boolean; } -const MemberRecipeList = ({ isMemberPage = false }: MemberRecipeListProps) => { +const MemberRecipeList = ({ isPreview = false }: MemberRecipeListProps) => { const scrollRef = useRef(null); const { fetchNextPage, hasNextPage, data } = useInfiniteMemberRecipeQuery(); const memberRecipes = data?.pages.flatMap((page) => page.recipes); - const recipeToDisplay = useDisplaySlice(isMemberPage, memberRecipes); + const recipeToDisplay = useDisplaySlice(isPreview, memberRecipes); useIntersectionObserver(fetchNextPage, scrollRef, hasNextPage); @@ -40,7 +40,7 @@ const MemberRecipeList = ({ isMemberPage = false }: MemberRecipeListProps) => { return ( - {!isMemberPage && ( + {!isPreview && ( {totalRecipeCount}개의 꿀조합을 남겼어요! @@ -50,7 +50,7 @@ const MemberRecipeList = ({ isMemberPage = false }: MemberRecipeListProps) => { {recipeToDisplay?.map((recipe) => (
  • - +
  • ))} diff --git a/frontend/src/components/Members/MemberReviewItem/MemberReviewItem.stories.tsx b/frontend/src/components/Members/MemberReviewItem/MemberReviewItem.stories.tsx new file mode 100644 index 000000000..89827a62c --- /dev/null +++ b/frontend/src/components/Members/MemberReviewItem/MemberReviewItem.stories.tsx @@ -0,0 +1,35 @@ +import type { Meta, StoryObj } from '@storybook/react'; + +import MemberReviewItem from './MemberReviewItem'; + +import ToastProvider from '@/contexts/ToastContext'; + +const meta: Meta = { + title: 'members/MemberReviewItem', + component: MemberReviewItem, + decorators: [ + (Story) => ( + + + + ), + ], + args: { + review: { + reviewId: 1, + productId: 5, + productName: '구운감자슬림명란마요', + content: + '할머니가 먹을 거 같은 맛입니다. 1960년 전쟁 때 맛 보고 싶었는데 그때는 너무 가난해서 먹을 수 없었는데요 이것보다 긴 리뷰도 잘려 보인답니다', + rating: 4.0, + favoriteCount: 1256, + categoryType: 'food', + }, + isMemberPage: true, + }, +}; + +export default meta; +type Story = StoryObj; + +export const Default: Story = {}; diff --git a/frontend/src/components/Members/MemberReviewItem/MemberReviewItem.tsx b/frontend/src/components/Members/MemberReviewItem/MemberReviewItem.tsx new file mode 100644 index 000000000..1d4503853 --- /dev/null +++ b/frontend/src/components/Members/MemberReviewItem/MemberReviewItem.tsx @@ -0,0 +1,123 @@ +import { useTheme, Spacing, Text, Button } from '@fun-eat/design-system'; +import type { MouseEventHandler } from 'react'; +import styled from 'styled-components'; + +import { SvgIcon } from '@/components/Common'; +import { useToastActionContext } from '@/hooks/context'; +import { useDeleteReview } from '@/hooks/queries/members'; +import type { MemberReview } from '@/types/review'; + +interface MemberReviewItemProps { + review: MemberReview; + isPreview: boolean; +} + +const MemberReviewItem = ({ review, isPreview }: MemberReviewItemProps) => { + const theme = useTheme(); + + const { mutate } = useDeleteReview(); + + const { toast } = useToastActionContext(); + + const { reviewId, productName, content, rating, favoriteCount } = review; + + const handleReviewDelete: MouseEventHandler = (e) => { + e.preventDefault(); + + const result = window.confirm('리뷰를 삭제하시겠습니까?'); + if (!result) { + return; + } + + mutate(reviewId, { + onSuccess: () => { + toast.success('리뷰를 삭제했습니다.'); + }, + onError: (error) => { + if (error instanceof Error) { + toast.error(error.message); + return; + } + + toast.error('리뷰 좋아요를 다시 시도해주세요.'); + }, + }); + }; + + return ( + + + + {productName} + + {!isPreview && ( + + )} + + + {content} + + + + + + + {favoriteCount} + + + + + + {rating.toFixed(1)} + + + + + ); +}; + +export default MemberReviewItem; + +const ReviewRankingItemContainer = styled.div` + display: flex; + flex-direction: column; + gap: 4px; + padding: 12px 0; + border-bottom: ${({ theme }) => `1px solid ${theme.borderColors.disabled}`}; +`; + +const ProductNameIconWrapper = styled.div` + display: flex; + justify-content: space-between; +`; + +const ReviewText = styled(Text)` + display: -webkit-inline-box; + text-overflow: ellipsis; + overflow: hidden; + -webkit-line-clamp: 2; + -webkit-box-orient: vertical; +`; + +const FavoriteStarWrapper = styled.div` + display: flex; + gap: 4px; +`; + +const FavoriteIconWrapper = styled.div` + display: flex; + gap: 4px; + align-items: center; +`; + +const RatingIconWrapper = styled.div` + display: flex; + gap: 2px; + align-items: center; + + & > svg { + padding-bottom: 2px; + } +`; diff --git a/frontend/src/components/Members/MemberReviewList/MemberReviewList.tsx b/frontend/src/components/Members/MemberReviewList/MemberReviewList.tsx index fde211413..b622d9f65 100644 --- a/frontend/src/components/Members/MemberReviewList/MemberReviewList.tsx +++ b/frontend/src/components/Members/MemberReviewList/MemberReviewList.tsx @@ -3,21 +3,22 @@ import { useRef } from 'react'; import { Link as RouterLink } from 'react-router-dom'; import styled from 'styled-components'; -import { ReviewRankingItem } from '@/components/Rank'; +import MemberReviewItem from '../MemberReviewItem/MemberReviewItem'; + import { PATH } from '@/constants/path'; import { useIntersectionObserver } from '@/hooks/common'; import { useInfiniteMemberReviewQuery } from '@/hooks/queries/members'; import useDisplaySlice from '@/utils/displaySlice'; interface MemberReviewListProps { - isMemberPage?: boolean; + isPreview?: boolean; } -const MemberReviewList = ({ isMemberPage = false }: MemberReviewListProps) => { +const MemberReviewList = ({ isPreview = false }: MemberReviewListProps) => { const scrollRef = useRef(null); const { fetchNextPage, hasNextPage, data } = useInfiniteMemberReviewQuery(); const memberReviews = data.pages.flatMap((page) => page.reviews); - const reviewsToDisplay = useDisplaySlice(isMemberPage, memberReviews); + const reviewsToDisplay = useDisplaySlice(isPreview, memberReviews); useIntersectionObserver(fetchNextPage, scrollRef, hasNextPage); @@ -39,17 +40,17 @@ const MemberReviewList = ({ isMemberPage = false }: MemberReviewListProps) => { return ( - {!isMemberPage && ( + {!isPreview && ( {totalReviewCount}개의 리뷰를 남겼어요! )} - {reviewsToDisplay.map((reviewRanking) => ( -
  • - - + {reviewsToDisplay.map((review) => ( +
  • + +
  • ))} diff --git a/frontend/src/components/Members/index.ts b/frontend/src/components/Members/index.ts index a295e2728..4e31460ee 100644 --- a/frontend/src/components/Members/index.ts +++ b/frontend/src/components/Members/index.ts @@ -2,3 +2,4 @@ export { default as MembersInfo } from './MembersInfo/MembersInfo'; export { default as MemberReviewList } from './MemberReviewList/MemberReviewList'; export { default as MemberRecipeList } from './MemberRecipeList/MemberRecipeList'; export { default as MemberModifyInput } from './MemberModifyInput/MemberModifyInput'; +export { default as MemberReviewItem } from './MemberReviewItem/MemberReviewItem'; diff --git a/frontend/src/components/Rank/ReviewRankingItem/ReviewRankingItem.tsx b/frontend/src/components/Rank/ReviewRankingItem/ReviewRankingItem.tsx index c2004c20e..ca2aed3dc 100644 --- a/frontend/src/components/Rank/ReviewRankingItem/ReviewRankingItem.tsx +++ b/frontend/src/components/Rank/ReviewRankingItem/ReviewRankingItem.tsx @@ -1,4 +1,4 @@ -import { Spacing, Text, theme } from '@fun-eat/design-system'; +import { Spacing, Text, useTheme } from '@fun-eat/design-system'; import { memo } from 'react'; import styled from 'styled-components'; @@ -7,14 +7,15 @@ import type { ReviewRanking } from '@/types/ranking'; interface ReviewRankingItemProps { reviewRanking: ReviewRanking; - isMemberPage?: boolean; } -const ReviewRankingItem = ({ reviewRanking, isMemberPage = false }: ReviewRankingItemProps) => { +const ReviewRankingItem = ({ reviewRanking }: ReviewRankingItemProps) => { + const theme = useTheme(); + const { productName, content, rating, favoriteCount } = reviewRanking; return ( - + {productName} @@ -42,14 +43,13 @@ const ReviewRankingItem = ({ reviewRanking, isMemberPage = false }: ReviewRankin export default memo(ReviewRankingItem); -const ReviewRankingItemContainer = styled.div<{ isMemberPage: boolean }>` +const ReviewRankingItemContainer = styled.div` display: flex; flex-direction: column; gap: 4px; padding: 12px; - border: ${({ isMemberPage, theme }) => (isMemberPage ? 'none' : `1px solid ${theme.borderColors.disabled}`)}; - border-bottom: ${({ theme }) => `1px solid ${theme.borderColors.disabled}`}; - border-radius: ${({ isMemberPage, theme }) => (isMemberPage ? 0 : theme.borderRadius.sm)}; + border: ${({ theme }) => `1px solid ${theme.borderColors.disabled}`}; + border-radius: ${({ theme }) => theme.borderRadius.sm}; `; const ReviewText = styled(Text)` diff --git a/frontend/src/hooks/queries/members/index.ts b/frontend/src/hooks/queries/members/index.ts index 9e45e5239..cbd6e7468 100644 --- a/frontend/src/hooks/queries/members/index.ts +++ b/frontend/src/hooks/queries/members/index.ts @@ -3,3 +3,4 @@ export { default as useMemberQuery } from './useMemberQuery'; export { default as useInfiniteMemberRecipeQuery } from './useInfiniteMemberRecipeQuery'; export { default as useMemberModifyMutation } from './useMemberModifyMutation'; export { default as useLogoutMutation } from './useLogoutMutation'; +export { default as useDeleteReview } from './useDeleteReview'; diff --git a/frontend/src/hooks/queries/members/useDeleteReview.ts b/frontend/src/hooks/queries/members/useDeleteReview.ts new file mode 100644 index 000000000..a88169bce --- /dev/null +++ b/frontend/src/hooks/queries/members/useDeleteReview.ts @@ -0,0 +1,20 @@ +import { useMutation, useQueryClient } from '@tanstack/react-query'; + +import { memberApi } from '@/apis'; + +const headers = { 'Content-Type': 'application/json' }; + +const deleteReview = async (reviewId: number) => { + return memberApi.delete({ params: `/reviews/${reviewId}`, credentials: true }, headers); +}; + +const useDeleteReview = () => { + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: (reviewId: number) => deleteReview(reviewId), + onSuccess: () => queryClient.invalidateQueries({ queryKey: ['member', 'review'] }), + }); +}; + +export default useDeleteReview; diff --git a/frontend/src/mocks/handlers/memberHandlers.ts b/frontend/src/mocks/handlers/memberHandlers.ts index 2abb3d7fb..31ee93536 100644 --- a/frontend/src/mocks/handlers/memberHandlers.ts +++ b/frontend/src/mocks/handlers/memberHandlers.ts @@ -55,4 +55,8 @@ export const memberHandlers = [ return res(ctx.status(200), ctx.json(mockMemberRecipes)); }), + + rest.delete('/api/members/reviews/:reviewId', (req, res, ctx) => { + return res(ctx.status(204)); + }), ]; diff --git a/frontend/src/pages/MemberPage.tsx b/frontend/src/pages/MemberPage.tsx index 7026e4768..58aa6d8e4 100644 --- a/frontend/src/pages/MemberPage.tsx +++ b/frontend/src/pages/MemberPage.tsx @@ -20,14 +20,14 @@ export const MemberPage = () => { }> - + }> - + diff --git a/frontend/src/types/review.ts b/frontend/src/types/review.ts index ced1d2f58..dd0273ddf 100644 --- a/frontend/src/types/review.ts +++ b/frontend/src/types/review.ts @@ -20,6 +20,16 @@ export interface ReviewDetail extends Review { productName: string; } +export interface MemberReview { + reviewId: number; + productId: number; + productName: string; + content: string; + rating: number; + favoriteCount: number; + categoryType: CategoryVariant; +} + export interface ReviewTag { tagType: TagVariants; tags: Tag[]; From e94c576344a2c710e404451d4ce25a1b1f30193b Mon Sep 17 00:00:00 2001 From: Taeeun Kim Date: Wed, 18 Oct 2023 13:19:27 +0900 Subject: [PATCH 30/55] =?UTF-8?q?[FE]=20fix:=20storybook=EC=97=90=EC=84=9C?= =?UTF-8?q?=20=EB=B0=94=EB=80=90=20=EB=B3=80=EC=88=98=EB=AA=85=20=EC=88=98?= =?UTF-8?q?=EC=A0=95=20(#783)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Members/MemberReviewItem/MemberReviewItem.stories.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/frontend/src/components/Members/MemberReviewItem/MemberReviewItem.stories.tsx b/frontend/src/components/Members/MemberReviewItem/MemberReviewItem.stories.tsx index 89827a62c..a631341d4 100644 --- a/frontend/src/components/Members/MemberReviewItem/MemberReviewItem.stories.tsx +++ b/frontend/src/components/Members/MemberReviewItem/MemberReviewItem.stories.tsx @@ -25,7 +25,7 @@ const meta: Meta = { favoriteCount: 1256, categoryType: 'food', }, - isMemberPage: true, + isPreview: true, }, }; From 9342c2243887ad53de754fd171c82ef64439d5ba Mon Sep 17 00:00:00 2001 From: Taeeun Kim Date: Wed, 18 Oct 2023 13:25:15 +0900 Subject: [PATCH 31/55] =?UTF-8?q?[FE]=20refactor:=20=EB=AC=B4=ED=95=9C?= =?UTF-8?q?=EC=8A=A4=ED=81=AC=EB=A1=A4=20=ED=8E=98=EC=9D=B4=EC=A7=95=20?= =?UTF-8?q?=EA=B4=80=EB=A0=A8=20api=20=EB=B3=80=EA=B2=BD=20=EC=A0=81?= =?UTF-8?q?=EC=9A=A9=20(#781)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat: 상품 목록 페이징 api 변경 * refactor: 리뷰, 상품 페이징 변경 적용 --- .../queries/product/useInfiniteProductReviewsQuery.ts | 7 +++---- .../hooks/queries/product/useInfiniteProductsQuery.ts | 9 +++++---- frontend/src/mocks/data/pbProducts.json | 9 +-------- frontend/src/mocks/data/products.json | 9 +-------- frontend/src/mocks/data/reviews.json | 9 +-------- frontend/src/mocks/handlers/productHandlers.ts | 9 ++------- frontend/src/mocks/handlers/reviewHandlers.ts | 2 +- frontend/src/types/response.ts | 4 ++-- 8 files changed, 16 insertions(+), 42 deletions(-) diff --git a/frontend/src/hooks/queries/product/useInfiniteProductReviewsQuery.ts b/frontend/src/hooks/queries/product/useInfiniteProductReviewsQuery.ts index 0fb4d604b..8142c77e0 100644 --- a/frontend/src/hooks/queries/product/useInfiniteProductReviewsQuery.ts +++ b/frontend/src/hooks/queries/product/useInfiniteProductReviewsQuery.ts @@ -6,7 +6,7 @@ import type { ProductReviewResponse } from '@/types/response'; const fetchProductReviews = async (pageParam: number, productId: number, sort: string) => { const res = await productApi.get({ params: `/${productId}/reviews`, - queries: `?sort=${sort}&page=${pageParam}`, + queries: `?sort=${sort}&lastReviewId=${pageParam}`, credentials: true, }); @@ -20,9 +20,8 @@ const useInfiniteProductReviewsQuery = (productId: number, sort: string) => { ({ pageParam = 0 }) => fetchProductReviews(pageParam, productId, sort), { getNextPageParam: (prevResponse: ProductReviewResponse) => { - const isLast = prevResponse.page.lastPage; - const nextPage = prevResponse.page.requestPage + 1; - return isLast ? undefined : nextPage; + const lastCursor = prevResponse.reviews.length ? prevResponse.reviews[prevResponse.reviews.length - 1].id : 0; + return prevResponse.hasNext ? lastCursor : undefined; }, } ); diff --git a/frontend/src/hooks/queries/product/useInfiniteProductsQuery.ts b/frontend/src/hooks/queries/product/useInfiniteProductsQuery.ts index d8b008126..1b9fdd57f 100644 --- a/frontend/src/hooks/queries/product/useInfiniteProductsQuery.ts +++ b/frontend/src/hooks/queries/product/useInfiniteProductsQuery.ts @@ -6,7 +6,7 @@ import type { CategoryProductResponse } from '@/types/response'; const fetchProducts = async (pageParam: number, categoryId: number, sort = 'reviewCount,desc') => { const res = await categoryApi.get({ params: `/${categoryId}/products`, - queries: `?page=${pageParam}&sort=${sort}`, + queries: `?lastProductId=${pageParam}&sort=${sort}`, }); const data: CategoryProductResponse = await res.json(); @@ -19,9 +19,10 @@ const useInfiniteProductsQuery = (categoryId: number, sort = 'reviewCount,desc') ({ pageParam = 0 }) => fetchProducts(pageParam, categoryId, sort), { getNextPageParam: (prevResponse: CategoryProductResponse) => { - const isLast = prevResponse.page.lastPage; - const nextPage = prevResponse.page.requestPage + 1; - return isLast ? undefined : nextPage; + const lastCursor = prevResponse.products.length + ? prevResponse.products[prevResponse.products.length - 1].id + : 0; + return prevResponse.hasNext ? lastCursor : undefined; }, } ); diff --git a/frontend/src/mocks/data/pbProducts.json b/frontend/src/mocks/data/pbProducts.json index 12bad382c..e25f1dc48 100644 --- a/frontend/src/mocks/data/pbProducts.json +++ b/frontend/src/mocks/data/pbProducts.json @@ -1,12 +1,5 @@ { - "page": { - "totalDataCount": 99, - "totalPages": 10, - "firstPage": true, - "lastPage": false, - "requestPage": 1, - "requestSize": 10 - }, + "hasNext": false, "products": [ { "id": 11, diff --git a/frontend/src/mocks/data/products.json b/frontend/src/mocks/data/products.json index 8d78d4a97..e68a13fbf 100644 --- a/frontend/src/mocks/data/products.json +++ b/frontend/src/mocks/data/products.json @@ -1,12 +1,5 @@ { - "page": { - "totalDataCount": 99, - "totalPages": 10, - "firstPage": true, - "lastPage": false, - "requestPage": 1, - "requestSize": 10 - }, + "hasNext": false, "products": [ { "id": 1, diff --git a/frontend/src/mocks/data/reviews.json b/frontend/src/mocks/data/reviews.json index 54b9719ce..5f9d129ee 100644 --- a/frontend/src/mocks/data/reviews.json +++ b/frontend/src/mocks/data/reviews.json @@ -1,12 +1,5 @@ { - "page": { - "totalDataCount": 99, - "totalPages": 10, - "firstPage": true, - "lastPage": false, - "requestPage": 1, - "requestSize": 10 - }, + "hasNext": false, "reviews": [ { "id": 1, diff --git a/frontend/src/mocks/handlers/productHandlers.ts b/frontend/src/mocks/handlers/productHandlers.ts index bc230c1fa..32f3feadc 100644 --- a/frontend/src/mocks/handlers/productHandlers.ts +++ b/frontend/src/mocks/handlers/productHandlers.ts @@ -25,7 +25,6 @@ export const productHandlers = [ rest.get('/api/categories/:categoryId/products', (req, res, ctx) => { const sortOptions = req.url.searchParams.get('sort'); const categoryId = req.params.categoryId; - const page = Number(req.url.searchParams.get('page')); if (sortOptions === null) { return res(ctx.status(400)); @@ -37,7 +36,7 @@ export const productHandlers = [ let products = commonProducts; - if (Number(categoryId) >= 7 && Number(categoryId) <= 9) { + if (Number(categoryId) >= 6 && Number(categoryId) <= 9) { products = pbProducts; } @@ -53,11 +52,7 @@ export const productHandlers = [ sortOrder === 'asc' ? cur[key] - next[key] : next[key] - cur[key] ), }; - return res( - ctx.status(200), - ctx.json({ page: sortedProducts.page, products: products.products.slice(page * 10, (page + 1) * 10) }), - ctx.delay(500) - ); + return res(ctx.status(200), ctx.json(sortedProducts), ctx.delay(500)); }), rest.get('/api/products/:productId', (req, res, ctx) => { diff --git a/frontend/src/mocks/handlers/reviewHandlers.ts b/frontend/src/mocks/handlers/reviewHandlers.ts index 5c007c605..656891e55 100644 --- a/frontend/src/mocks/handlers/reviewHandlers.ts +++ b/frontend/src/mocks/handlers/reviewHandlers.ts @@ -42,7 +42,7 @@ export const reviewHandlers = [ return res( ctx.status(200), - ctx.json({ page: sortedReviews.page, reviews: sortedReviews.reviews }), + ctx.json({ hasNext: sortedReviews.hasNext, reviews: sortedReviews.reviews }), ctx.delay(1000) ); }), diff --git a/frontend/src/types/response.ts b/frontend/src/types/response.ts index 42c21a24d..bac466ebf 100644 --- a/frontend/src/types/response.ts +++ b/frontend/src/types/response.ts @@ -14,11 +14,11 @@ export interface Page { } export interface CategoryProductResponse { - page: Page; + hasNext: boolean; products: Product[]; } export interface ProductReviewResponse { - page: Page; + hasNext: boolean; reviews: Review[]; } From 87c5ac8057f8391954d4bf1fe173d04d668c5915 Mon Sep 17 00:00:00 2001 From: JFe <33208246+Go-Jaecheol@users.noreply.github.com> Date: Wed, 18 Oct 2023 13:30:42 +0900 Subject: [PATCH 32/55] =?UTF-8?q?[BE]=20refactor:=20=EC=83=81=ED=92=88=20?= =?UTF-8?q?=EB=9E=AD=ED=82=B9=20=EC=95=8C=EA=B3=A0=EB=A6=AC=EC=A6=98=20?= =?UTF-8?q?=EA=B0=9C=EC=84=A0=20(#756)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * refactor: 상품 랭킹 조회 시 기간 설정 * test: 상품 랭킹 관련 Repository 테스트 추가 및 수정 * test: 상품 랭킹 관련 서비스 테스트 수정 * style: import 정렬 순서 변경 * fix: 충돌 해결 --- .../product/application/ProductService.java | 5 +- .../persistence/ProductRepository.java | 5 +- .../application/ProductServiceTest.java | 14 ++-- .../persistence/ProductRepositoryTest.java | 69 +++++++++++-------- 4 files changed, 57 insertions(+), 36 deletions(-) diff --git a/backend/src/main/java/com/funeat/product/application/ProductService.java b/backend/src/main/java/com/funeat/product/application/ProductService.java index 921d07d7a..b094d49e8 100644 --- a/backend/src/main/java/com/funeat/product/application/ProductService.java +++ b/backend/src/main/java/com/funeat/product/application/ProductService.java @@ -32,6 +32,7 @@ import com.funeat.review.persistence.ReviewRepository; import com.funeat.review.persistence.ReviewTagRepository; import com.funeat.tag.domain.Tag; +import java.time.LocalDateTime; import java.util.Comparator; import java.util.List; import java.util.stream.Collectors; @@ -119,7 +120,9 @@ public ProductResponse findProductDetail(final Long productId) { } public RankingProductsResponse getTop3Products() { - final List productsAndReviewCounts = productRepository.findAllByAverageRatingGreaterThan3(); + final LocalDateTime endDateTime = LocalDateTime.now(); + final LocalDateTime startDateTime = endDateTime.minusWeeks(2L); + final List productsAndReviewCounts = productRepository.findAllByAverageRatingGreaterThan3(startDateTime, endDateTime); final Comparator rankingScoreComparator = Comparator.comparing( (ProductReviewCountDto it) -> it.getProduct().calculateRankingScore(it.getReviewCount()) ).reversed(); diff --git a/backend/src/main/java/com/funeat/product/persistence/ProductRepository.java b/backend/src/main/java/com/funeat/product/persistence/ProductRepository.java index 9b4036361..27208f254 100644 --- a/backend/src/main/java/com/funeat/product/persistence/ProductRepository.java +++ b/backend/src/main/java/com/funeat/product/persistence/ProductRepository.java @@ -3,6 +3,7 @@ import com.funeat.common.repository.BaseRepository; import com.funeat.product.domain.Product; import com.funeat.product.dto.ProductReviewCountDto; +import java.time.LocalDateTime; import java.util.List; import org.springframework.data.domain.Page; import org.springframework.data.domain.Pageable; @@ -15,8 +16,10 @@ public interface ProductRepository extends BaseRepository { + "FROM Product p " + "LEFT JOIN Review r ON r.product.id = p.id " + "WHERE p.averageRating > 3.0 " + + "AND r.createdAt BETWEEN :startDateTime AND :endDateTime " + "GROUP BY p.id") - List findAllByAverageRatingGreaterThan3(); + List findAllByAverageRatingGreaterThan3(final LocalDateTime startDateTime, + final LocalDateTime endDateTime); @Query("SELECT p FROM Product p " + "WHERE p.name LIKE CONCAT('%', :name, '%') " diff --git a/backend/src/test/java/com/funeat/product/application/ProductServiceTest.java b/backend/src/test/java/com/funeat/product/application/ProductServiceTest.java index 5aa812dc6..9f5b88c5b 100644 --- a/backend/src/test/java/com/funeat/product/application/ProductServiceTest.java +++ b/backend/src/test/java/com/funeat/product/application/ProductServiceTest.java @@ -109,16 +109,16 @@ class 상품_개수에_대한_테스트 { final var review1_4 = 리뷰_이미지test3_평점3점_재구매O_생성(member1, product1, 0L); final var review2_1 = 리뷰_이미지test4_평점4점_재구매O_생성(member1, product2, 0L); final var review2_2 = 리뷰_이미지test4_평점4점_재구매O_생성(member1, product2, 0L); - final var review3_1 = 리뷰_이미지test5_평점5점_재구매X_생성(member1, product2, 0L); - final var review4_1 = 리뷰_이미지test4_평점4점_재구매X_생성(member1, product2, 0L); - final var review4_2 = 리뷰_이미지test3_평점3점_재구매X_생성(member1, product2, 0L); - final var review4_3 = 리뷰_이미지test5_평점5점_재구매X_생성(member1, product2, 0L); + final var review3_1 = 리뷰_이미지test5_평점5점_재구매X_생성(member1, product3, 0L); + final var review4_1 = 리뷰_이미지test4_평점4점_재구매X_생성(member1, product4, 0L); + final var review4_2 = 리뷰_이미지test3_평점3점_재구매X_생성(member1, product4, 0L); + final var review4_3 = 리뷰_이미지test5_평점5점_재구매X_생성(member1, product4, 0L); 복수_리뷰_저장(review1_1, review1_2, review1_3, review1_4, review2_1, review2_2, review3_1, review4_1, review4_2, review4_3); - final var rankingProductDto1 = RankingProductDto.toDto(product2); - final var rankingProductDto2 = RankingProductDto.toDto(product3); - final var rankingProductDto3 = RankingProductDto.toDto(product4); + final var rankingProductDto1 = RankingProductDto.toDto(product3); + final var rankingProductDto2 = RankingProductDto.toDto(product4); + final var rankingProductDto3 = RankingProductDto.toDto(product2); final var rankingProductDtos = List.of(rankingProductDto1, rankingProductDto2, rankingProductDto3); final var expected = RankingProductsResponse.toResponse(rankingProductDtos); diff --git a/backend/src/test/java/com/funeat/product/persistence/ProductRepositoryTest.java b/backend/src/test/java/com/funeat/product/persistence/ProductRepositoryTest.java index e1eff6623..107585475 100644 --- a/backend/src/test/java/com/funeat/product/persistence/ProductRepositoryTest.java +++ b/backend/src/test/java/com/funeat/product/persistence/ProductRepositoryTest.java @@ -4,40 +4,23 @@ import static com.funeat.fixture.MemberFixture.멤버_멤버1_생성; import static com.funeat.fixture.MemberFixture.멤버_멤버2_생성; import static com.funeat.fixture.MemberFixture.멤버_멤버3_생성; -import static com.funeat.fixture.PageFixture.가격_내림차순; -import static com.funeat.fixture.PageFixture.가격_오름차순; -import static com.funeat.fixture.PageFixture.리뷰수_내림차순; import static com.funeat.fixture.PageFixture.페이지요청_기본_생성; -import static com.funeat.fixture.PageFixture.페이지요청_생성; -import static com.funeat.fixture.PageFixture.평균_평점_내림차순; -import static com.funeat.fixture.PageFixture.평균_평점_오름차순; import static com.funeat.fixture.ProductFixture.상품_망고빙수_가격5000원_평점4점_생성; -import static com.funeat.fixture.ProductFixture.상품_삼각김밥_가격1000원_리뷰3개_생성; -import static com.funeat.fixture.ProductFixture.상품_삼각김밥_가격1000원_평점1점_생성; -import static com.funeat.fixture.ProductFixture.상품_삼각김밥_가격1000원_평점2점_생성; import static com.funeat.fixture.ProductFixture.상품_삼각김밥_가격1000원_평점3점_생성; -import static com.funeat.fixture.ProductFixture.상품_삼각김밥_가격1000원_평점4점_생성; -import static com.funeat.fixture.ProductFixture.상품_삼각김밥_가격1000원_평점5점_생성; -import static com.funeat.fixture.ProductFixture.상품_삼각김밥_가격2000원_리뷰1개_생성; -import static com.funeat.fixture.ProductFixture.상품_삼각김밥_가격2000원_평점1점_생성; import static com.funeat.fixture.ProductFixture.상품_삼각김밥_가격2000원_평점4점_생성; -import static com.funeat.fixture.ProductFixture.상품_삼각김밥_가격3000원_리뷰5개_생성; -import static com.funeat.fixture.ProductFixture.상품_삼각김밥_가격3000원_평점1점_생성; import static com.funeat.fixture.ProductFixture.상품_삼각김밥_가격3000원_평점5점_생성; -import static com.funeat.fixture.ProductFixture.상품_삼각김밥_가격4000원_평점1점_생성; import static com.funeat.fixture.ProductFixture.상품_삼각김밥_가격4000원_평점2점_생성; -import static com.funeat.fixture.ProductFixture.상품_삼각김밥_가격5000원_리뷰0개_생성; -import static com.funeat.fixture.ProductFixture.상품_삼각김밥_가격5000원_평점1점_생성; import static com.funeat.fixture.ProductFixture.상품_애플망고_가격3000원_평점5점_생성; import static com.funeat.fixture.ReviewFixture.리뷰_이미지test1_평점1점_재구매X_생성; import static com.funeat.fixture.ReviewFixture.리뷰_이미지test3_평점3점_재구매O_생성; -import static com.funeat.fixture.ReviewFixture.리뷰_이미지test4_평점4점_재구매X_생성; import static com.funeat.fixture.ReviewFixture.리뷰_이미지test5_평점5점_재구매O_생성; +import static com.funeat.fixture.ReviewFixture.리뷰_이미지test5_평점5점_재구매X_생성; import static org.assertj.core.api.Assertions.assertThat; import com.funeat.common.RepositoryTest; -import com.funeat.product.dto.ProductInCategoryDto; import com.funeat.product.dto.ProductReviewCountDto; +import java.time.LocalDateTime; +import java.util.Collections; import java.util.List; import org.junit.jupiter.api.Nested; import org.junit.jupiter.api.Test; @@ -65,12 +48,12 @@ class findAllByAverageRatingGreaterThan3_성공_테스트 { final var member3 = 멤버_멤버3_생성(); 복수_멤버_저장(member1, member2, member3); - final var review1_1 = 리뷰_이미지test1_평점1점_재구매X_생성(member1, product1, 0L); - final var review1_2 = 리뷰_이미지test5_평점5점_재구매O_생성(member2, product1, 0L); - final var review2_1 = 리뷰_이미지test3_평점3점_재구매O_생성(member3, product2, 0L); - final var review2_2 = 리뷰_이미지test4_평점4점_재구매X_생성(member1, product2, 0L); - final var review2_3 = 리뷰_이미지test5_평점5점_재구매O_생성(member2, product2, 0L); - final var review3_1 = 리뷰_이미지test5_평점5점_재구매O_생성(member1, product3, 0L); + final var review1_1 = 리뷰_이미지test5_평점5점_재구매X_생성(member1, product1, 0L, LocalDateTime.now().minusDays(2L)); + final var review1_2 = 리뷰_이미지test5_평점5점_재구매X_생성(member2, product1, 0L, LocalDateTime.now().minusDays(3L)); + final var review2_1 = 리뷰_이미지test5_평점5점_재구매X_생성(member3, product2, 0L, LocalDateTime.now().minusDays(10L)); + final var review2_2 = 리뷰_이미지test5_평점5점_재구매X_생성(member1, product2, 0L, LocalDateTime.now().minusDays(1L)); + final var review2_3 = 리뷰_이미지test5_평점5점_재구매X_생성(member2, product2, 0L, LocalDateTime.now().minusDays(9L)); + final var review3_1 = 리뷰_이미지test5_평점5점_재구매X_생성(member1, product3, 0L, LocalDateTime.now().minusDays(8L)); 복수_리뷰_저장(review1_1, review1_2, review2_1, review2_2, review2_3, review3_1); final var productReviewCountDto1 = new ProductReviewCountDto(product2, 3L); @@ -78,7 +61,39 @@ class findAllByAverageRatingGreaterThan3_성공_테스트 { final var expected = List.of(productReviewCountDto1, productReviewCountDto2); // when - final var actual = productRepository.findAllByAverageRatingGreaterThan3(); + final var startDateTime = LocalDateTime.now().minusWeeks(2L); + final var endDateTime = LocalDateTime.now(); + final var actual = productRepository.findAllByAverageRatingGreaterThan3(startDateTime, endDateTime); + + // then + assertThat(actual).usingRecursiveComparison() + .isEqualTo(expected); + } + + @Test + void 기간_안에_리뷰가_존재하는_상품이_없으면_빈_리스트를_반환한다() { + // given + final var category = 카테고리_간편식사_생성(); + 단일_카테고리_저장(category); + + final var product1 = 상품_삼각김밥_가격1000원_평점3점_생성(category); + final var product2 = 상품_삼각김밥_가격2000원_평점4점_생성(category); + 복수_상품_저장(product1, product2); + + final var member1 = 멤버_멤버1_생성(); + final var member2 = 멤버_멤버2_생성(); + 복수_멤버_저장(member1, member2); + + final var review1 = 리뷰_이미지test5_평점5점_재구매X_생성(member1, product1, 0L, LocalDateTime.now().minusDays(15L)); + final var review2 = 리뷰_이미지test5_평점5점_재구매X_생성(member2, product2, 0L, LocalDateTime.now().minusWeeks(3L)); + 복수_리뷰_저장(review1, review2); + + final var expected = Collections.emptyList(); + + // when + final var startDateTime = LocalDateTime.now().minusWeeks(2L); + final var endDateTime = LocalDateTime.now(); + final var actual = productRepository.findAllByAverageRatingGreaterThan3(startDateTime, endDateTime); // then assertThat(actual).usingRecursiveComparison() From 6fc930b12a34814bab1f56ea4b4b39d6e8235c2f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?s=E1=B4=8F=CA=9F=CA=99=C9=AA=20=E2=98=94=EF=B8=8F?= Date: Wed, 18 Oct 2023 17:10:17 +0900 Subject: [PATCH 33/55] =?UTF-8?q?[FE]=20refactor:=20=EC=9D=B4=EB=AF=B8?= =?UTF-8?q?=EC=A7=80=20=EC=95=95=EC=B6=95=20=EB=9D=BC=EC=9D=B4=EB=B8=8C?= =?UTF-8?q?=EB=9F=AC=EB=A6=AC=20=EB=8F=84=EC=9E=85=20(#786)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * refactor: alert를 toast로 수정 * refactor: dialog 컨테이너 div 추가 * refactor: dialog 컨테이너 div 추가 * chore: 디자인 시스템 버전 업 * refactor: dialog 업데이트 * refactor: 이미지 압축 구현 * refactor: 이미지 압축 구현 --- frontend/.storybook/preview-body.html | 2 + frontend/package.json | 4 +- frontend/public/index.html | 1 + .../Common/ImageUploader/ImageUploader.tsx | 4 +- .../SortOptionList/SortOptionList.stories.tsx | 4 +- .../ReviewRegisterForm/ReviewRegisterForm.tsx | 13 ++++--- frontend/src/hooks/common/useImageUploader.ts | 38 +++++++++++++++++-- frontend/src/pages/MemberModifyPage.tsx | 4 +- frontend/src/pages/ProductDetailPage.tsx | 4 +- frontend/src/pages/ProductListPage.tsx | 4 +- frontend/src/pages/RecipePage.tsx | 4 +- frontend/yarn.lock | 8 ++-- 12 files changed, 66 insertions(+), 24 deletions(-) diff --git a/frontend/.storybook/preview-body.html b/frontend/.storybook/preview-body.html index 46ce41c44..a37b26cbd 100644 --- a/frontend/.storybook/preview-body.html +++ b/frontend/.storybook/preview-body.html @@ -124,4 +124,6 @@ +
    + diff --git a/frontend/package.json b/frontend/package.json index ef994ca5e..358f1fe4b 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -13,9 +13,10 @@ "test:coverage": "jest --watchAll --coverage" }, "dependencies": { - "@fun-eat/design-system": "^0.3.15", + "@fun-eat/design-system": "^0.3.18", "@tanstack/react-query": "^4.32.6", "@tanstack/react-query-devtools": "^4.32.6", + "browser-image-compression": "^2.0.2", "dayjs": "^1.11.9", "react": "^18.2.0", "react-dom": "^18.2.0", @@ -46,7 +47,6 @@ "@typescript-eslint/eslint-plugin": "^5.60.1", "@typescript-eslint/parser": "^5.60.1", "babel-plugin-styled-components": "^2.1.4", - "browser-image-compression": "^2.0.2", "copy-webpack-plugin": "^11.0.0", "dotenv-webpack": "^8.0.1", "eslint": "^8.44.0", diff --git a/frontend/public/index.html b/frontend/public/index.html index 0359ca16d..36782ea87 100644 --- a/frontend/public/index.html +++ b/frontend/public/index.html @@ -27,6 +27,7 @@
    +
    diff --git a/frontend/src/components/Common/ImageUploader/ImageUploader.tsx b/frontend/src/components/Common/ImageUploader/ImageUploader.tsx index bd85c20e3..9c915081e 100644 --- a/frontend/src/components/Common/ImageUploader/ImageUploader.tsx +++ b/frontend/src/components/Common/ImageUploader/ImageUploader.tsx @@ -4,6 +4,7 @@ import styled from 'styled-components'; import { IMAGE_MAX_SIZE } from '@/constants'; import { useEnterKeyDown } from '@/hooks/common'; +import { useToastActionContext } from '@/hooks/context'; interface ReviewImageUploaderProps { previewImage: string; @@ -13,6 +14,7 @@ interface ReviewImageUploaderProps { const ImageUploader = ({ previewImage, uploadImage, deleteImage }: ReviewImageUploaderProps) => { const { inputRef, handleKeydown } = useEnterKeyDown(); + const { toast } = useToastActionContext(); const handleImageUpload: ChangeEventHandler = (event) => { if (!event.target.files) { @@ -22,7 +24,7 @@ const ImageUploader = ({ previewImage, uploadImage, deleteImage }: ReviewImageUp const imageFile = event.target.files[0]; if (imageFile.size > IMAGE_MAX_SIZE) { - alert('이미지 크기가 너무 커요. 5MB 이하의 이미지를 골라주세요.'); + toast.error('이미지 크기가 너무 커요. 5MB 이하의 이미지를 골라주세요.'); event.target.value = ''; return; } diff --git a/frontend/src/components/Common/SortOptionList/SortOptionList.stories.tsx b/frontend/src/components/Common/SortOptionList/SortOptionList.stories.tsx index 779e68942..5e7c2f935 100644 --- a/frontend/src/components/Common/SortOptionList/SortOptionList.stories.tsx +++ b/frontend/src/components/Common/SortOptionList/SortOptionList.stories.tsx @@ -17,7 +17,7 @@ type Story = StoryObj; export const Default: Story = { render: () => { - const { ref, isClosing, handleOpenBottomSheet, handleCloseBottomSheet } = useBottomSheet(); + const { isOpen, isClosing, handleOpenBottomSheet, handleCloseBottomSheet } = useBottomSheet(); const { selectedOption, selectSortOption } = useSortOption(PRODUCT_SORT_OPTIONS[0]); useEffect(() => { @@ -25,7 +25,7 @@ export const Default: Story = { }, []); return ( - + { const { scrollToPosition } = useScroll(); - const { previewImage, imageFile, uploadImage, deleteImage } = useImageUploader(); + const { isImageUploading, previewImage, imageFile, uploadImage, deleteImage } = useImageUploader(); const reviewFormValue = useReviewFormValueContext(); const { resetReviewFormValue } = useReviewFormActionContext(); + const { toast } = useToastActionContext(); const { data: productDetail } = useProductDetailQuery(productId); const { mutate, isLoading } = useReviewRegisterFormMutation(productId); @@ -41,7 +42,8 @@ const ReviewRegisterForm = ({ productId, targetRef, closeReviewDialog, initTabMe reviewFormValue.rating > MIN_RATING_SCORE && reviewFormValue.tagIds.length >= MIN_SELECTED_TAGS_COUNT && reviewFormValue.tagIds.length <= MIN_DISPLAYED_TAGS_LENGTH && - reviewFormValue.content.length > MIN_CONTENT_LENGTH; + reviewFormValue.content.length > MIN_CONTENT_LENGTH && + !isImageUploading; const formData = useFormData({ imageKey: 'image', @@ -64,15 +66,16 @@ const ReviewRegisterForm = ({ productId, targetRef, closeReviewDialog, initTabMe resetAndCloseForm(); initTabMenu(); scrollToPosition(targetRef); + toast.success('📝 리뷰가 등록 됐어요'); }, onError: (error) => { resetAndCloseForm(); if (error instanceof Error) { - alert(error.message); + toast.error(error.message); return; } - alert('리뷰 등록을 다시 시도해주세요'); + toast.error('리뷰 등록을 다시 시도해주세요'); }, }); }; diff --git a/frontend/src/hooks/common/useImageUploader.ts b/frontend/src/hooks/common/useImageUploader.ts index c5783ad59..cc093517d 100644 --- a/frontend/src/hooks/common/useImageUploader.ts +++ b/frontend/src/hooks/common/useImageUploader.ts @@ -1,19 +1,50 @@ +import imageCompression from 'browser-image-compression'; import { useState } from 'react'; +import { useToastActionContext } from '../context'; + const isImageFile = (file: File) => file.type !== 'image/png' && file.type !== 'image/jpeg'; +const options = { + maxSizeMB: 1, + maxWidthOrHeight: 1920, + useWebWorker: true, +}; + const useImageUploader = () => { + const { toast } = useToastActionContext(); + const [imageFile, setImageFile] = useState(null); + const [isImageUploading, setIsImageUploading] = useState(false); const [previewImage, setPreviewImage] = useState(''); - const uploadImage = (imageFile: File) => { + const uploadImage = async (imageFile: File) => { if (isImageFile(imageFile)) { - alert('이미지 파일만 업로드 가능합니다.'); + toast.error('이미지 파일만 업로드 가능합니다.'); return; } setPreviewImage(URL.createObjectURL(imageFile)); - setImageFile(imageFile); + + try { + setIsImageUploading(true); + + const compressedFile = await imageCompression(imageFile, options); + const compressedImageFilePromise = imageCompression.getFilefromDataUrl( + await imageCompression.getDataUrlFromFile(compressedFile), + compressedFile.name + ); + compressedImageFilePromise + .then((result) => { + setImageFile(result); + }) + .then(() => { + setIsImageUploading(false); + toast.success('이미지가 성공적으로 등록 됐습니다'); + }); + } catch (error) { + console.log(error); + } }; const deleteImage = () => { @@ -23,6 +54,7 @@ const useImageUploader = () => { }; return { + isImageUploading, previewImage, imageFile, uploadImage, diff --git a/frontend/src/pages/MemberModifyPage.tsx b/frontend/src/pages/MemberModifyPage.tsx index c6c43a8c3..a5c21490b 100644 --- a/frontend/src/pages/MemberModifyPage.tsx +++ b/frontend/src/pages/MemberModifyPage.tsx @@ -8,6 +8,7 @@ import { SectionTitle, SvgIcon } from '@/components/Common'; import { MemberModifyInput } from '@/components/Members'; import { IMAGE_MAX_SIZE } from '@/constants'; import { useFormData, useImageUploader } from '@/hooks/common'; +import { useToastActionContext } from '@/hooks/context'; import { useMemberModifyMutation, useMemberQuery } from '@/hooks/queries/members'; import type { MemberRequest } from '@/types/member'; @@ -16,6 +17,7 @@ export const MemberModifyPage = () => { const { mutate } = useMemberModifyMutation(); const { previewImage, imageFile, uploadImage } = useImageUploader(); + const { toast } = useToastActionContext(); const [nickname, setNickname] = useState(member?.nickname ?? ''); const navigate = useNavigate(); @@ -43,7 +45,7 @@ export const MemberModifyPage = () => { const imageFile = event.target.files[0]; if (imageFile.size > IMAGE_MAX_SIZE) { - alert('이미지 크기가 너무 커요. 5MB 이하의 이미지를 골라주세요.'); + toast.error('이미지 크기가 너무 커요. 5MB 이하의 이미지를 골라주세요.'); event.target.value = ''; return; } diff --git a/frontend/src/pages/ProductDetailPage.tsx b/frontend/src/pages/ProductDetailPage.tsx index f53b61282..6471af213 100644 --- a/frontend/src/pages/ProductDetailPage.tsx +++ b/frontend/src/pages/ProductDetailPage.tsx @@ -44,7 +44,7 @@ export const ProductDetailPage = () => { const tabRef = useRef(null); const { selectedOption, selectSortOption } = useSortOption(REVIEW_SORT_OPTIONS[0]); - const { ref, isClosing, handleOpenBottomSheet, handleCloseBottomSheet } = useBottomSheet(); + const { isOpen, isClosing, handleOpenBottomSheet, handleCloseBottomSheet } = useBottomSheet(); const [activeSheet, setActiveSheet] = useState<'registerReview' | 'sortOption'>('sortOption'); const { gaEvent } = useGA(); @@ -136,7 +136,7 @@ export const ProductDetailPage = () => { /> - + {activeSheet === 'registerReview' ? ( { const { category } = useParams(); const productListRef = useRef(null); - const { ref, isClosing, handleOpenBottomSheet, handleCloseBottomSheet } = useBottomSheet(); + const { isOpen, isClosing, handleOpenBottomSheet, handleCloseBottomSheet } = useBottomSheet(); const { selectedOption, selectSortOption } = useSortOption(PRODUCT_SORT_OPTIONS[0]); const { reset } = useQueryErrorResetBoundary(); const { gaEvent } = useGA(); @@ -68,7 +68,7 @@ export const ProductListPage = () => { - + ('sortOption'); const { selectedOption, selectSortOption } = useSortOption(RECIPE_SORT_OPTIONS[0]); - const { ref, isClosing, handleOpenBottomSheet, handleCloseBottomSheet } = useBottomSheet(); + const { isOpen, isClosing, handleOpenBottomSheet, handleCloseBottomSheet } = useBottomSheet(); const { reset } = useQueryErrorResetBoundary(); const { gaEvent } = useGA(); @@ -72,7 +72,7 @@ export const RecipePage = () => { /> - + {activeSheet === 'sortOption' ? ( Date: Wed, 18 Oct 2023 20:17:17 +0900 Subject: [PATCH 34/55] =?UTF-8?q?refactor:=20alert=EB=A5=BC=20toast?= =?UTF-8?q?=EB=A1=9C=20=EB=B3=80=EA=B2=BD=20(#789)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../components/Recipe/RecipeFavorite/RecipeFavorite.tsx | 6 +++++- .../Recipe/RecipeRegisterForm/RecipeRegisterForm.tsx | 7 ++++--- frontend/src/components/Review/ReviewItem/ReviewItem.tsx | 7 +++++-- frontend/src/hooks/queries/members/useLogoutMutation.ts | 7 +++++-- frontend/src/hooks/search/useSearch.ts | 5 ++++- frontend/src/pages/MemberModifyPage.tsx | 4 ++-- 6 files changed, 25 insertions(+), 11 deletions(-) diff --git a/frontend/src/components/Recipe/RecipeFavorite/RecipeFavorite.tsx b/frontend/src/components/Recipe/RecipeFavorite/RecipeFavorite.tsx index 882ca0eed..8df4e9e13 100644 --- a/frontend/src/components/Recipe/RecipeFavorite/RecipeFavorite.tsx +++ b/frontend/src/components/Recipe/RecipeFavorite/RecipeFavorite.tsx @@ -4,6 +4,7 @@ import styled from 'styled-components'; import { SvgIcon } from '@/components/Common'; import { useTimeout } from '@/hooks/common'; +import { useToastActionContext } from '@/hooks/context'; import { useRecipeFavoriteMutation } from '@/hooks/queries/recipe'; interface RecipeFavoriteProps { @@ -15,6 +16,8 @@ interface RecipeFavoriteProps { const RecipeFavorite = ({ recipeId, favorite, favoriteCount }: RecipeFavoriteProps) => { const [isFavorite, setIsFavorite] = useState(favorite); const [currentFavoriteCount, setCurrentFavoriteCount] = useState(favoriteCount); + const { toast } = useToastActionContext(); + const { mutate } = useRecipeFavoriteMutation(Number(recipeId)); const handleToggleFavorite = async () => { @@ -24,9 +27,10 @@ const RecipeFavorite = ({ recipeId, favorite, favoriteCount }: RecipeFavoritePro onSuccess: () => { setIsFavorite((prev) => !prev); setCurrentFavoriteCount((prev) => (isFavorite ? prev - 1 : prev + 1)); + toast.success('🍯 꿀조합이 등록 됐어요'); }, onError: () => { - alert('꿀조합 좋아요를 다시 시도해주세요.'); + toast.error('꿀조합 좋아요를 다시 시도해주세요.'); }, } ); diff --git a/frontend/src/components/Recipe/RecipeRegisterForm/RecipeRegisterForm.tsx b/frontend/src/components/Recipe/RecipeRegisterForm/RecipeRegisterForm.tsx index 238de3574..074d8f4f6 100644 --- a/frontend/src/components/Recipe/RecipeRegisterForm/RecipeRegisterForm.tsx +++ b/frontend/src/components/Recipe/RecipeRegisterForm/RecipeRegisterForm.tsx @@ -8,7 +8,7 @@ import RecipeUsedProducts from '../RecipeUsedProducts/RecipeUsedProducts'; import { ImageUploader, SvgIcon } from '@/components/Common'; import { useImageUploader, useFormData } from '@/hooks/common'; -import { useRecipeFormValueContext, useRecipeFormActionContext } from '@/hooks/context'; +import { useRecipeFormValueContext, useRecipeFormActionContext, useToastActionContext } from '@/hooks/context'; import { useRecipeRegisterFormMutation } from '@/hooks/queries/recipe'; import type { RecipeRequest } from '@/types/recipe'; @@ -23,6 +23,7 @@ const RecipeRegisterForm = ({ closeRecipeDialog }: RecipeRegisterFormProps) => { const recipeFormValue = useRecipeFormValueContext(); const { resetRecipeFormValue } = useRecipeFormActionContext(); + const { toast } = useToastActionContext(); const formData = useFormData({ imageKey: 'images', @@ -52,11 +53,11 @@ const RecipeRegisterForm = ({ closeRecipeDialog }: RecipeRegisterFormProps) => { onError: (error) => { resetAndCloseForm(); if (error instanceof Error) { - alert(error.message); + toast.error(error.message); return; } - alert('꿀조합 등록을 다시 시도해주세요'); + toast.error('꿀조합 등록을 다시 시도해주세요'); }, }); }; diff --git a/frontend/src/components/Review/ReviewItem/ReviewItem.tsx b/frontend/src/components/Review/ReviewItem/ReviewItem.tsx index 112824ada..14cd83d18 100644 --- a/frontend/src/components/Review/ReviewItem/ReviewItem.tsx +++ b/frontend/src/components/Review/ReviewItem/ReviewItem.tsx @@ -4,6 +4,7 @@ import styled from 'styled-components'; import { SvgIcon, TagList } from '@/components/Common'; import { useTimeout } from '@/hooks/common'; +import { useToastActionContext } from '@/hooks/context'; import { useReviewFavoriteMutation } from '@/hooks/queries/review'; import type { Review } from '@/types/review'; import { getRelativeDate } from '@/utils/date'; @@ -18,6 +19,8 @@ const ReviewItem = ({ productId, review }: ReviewItemProps) => { review; const [isFavorite, setIsFavorite] = useState(favorite); const [currentFavoriteCount, setCurrentFavoriteCount] = useState(favoriteCount); + + const { toast } = useToastActionContext(); const { mutate } = useReviewFavoriteMutation(productId, id); const theme = useTheme(); @@ -32,11 +35,11 @@ const ReviewItem = ({ productId, review }: ReviewItemProps) => { }, onError: (error) => { if (error instanceof Error) { - alert(error.message); + toast.error(error.message); return; } - alert('리뷰 좋아요를 다시 시도해주세요.'); + toast.error('리뷰 좋아요를 다시 시도해주세요.'); }, } ); diff --git a/frontend/src/hooks/queries/members/useLogoutMutation.ts b/frontend/src/hooks/queries/members/useLogoutMutation.ts index 3e586adc7..afc27e2bc 100644 --- a/frontend/src/hooks/queries/members/useLogoutMutation.ts +++ b/frontend/src/hooks/queries/members/useLogoutMutation.ts @@ -3,11 +3,14 @@ import { useNavigate } from 'react-router-dom'; import { logoutApi } from '@/apis'; import { PATH } from '@/constants/path'; +import { useToastActionContext } from '@/hooks/context'; const useLogoutMutation = () => { const navigate = useNavigate(); const queryClient = useQueryClient(); + const { toast } = useToastActionContext(); + return useMutation({ mutationFn: () => logoutApi.post({ credentials: true }), onSuccess: () => { @@ -16,10 +19,10 @@ const useLogoutMutation = () => { }, onError: (error) => { if (error instanceof Error) { - alert(error.message); + toast.error(error.message); return; } - alert('로그아웃을 다시 시도해주세요.'); + toast.error('로그아웃을 다시 시도해주세요.'); }, }); }; diff --git a/frontend/src/hooks/search/useSearch.ts b/frontend/src/hooks/search/useSearch.ts index 4c438d4ec..ba9853940 100644 --- a/frontend/src/hooks/search/useSearch.ts +++ b/frontend/src/hooks/search/useSearch.ts @@ -3,6 +3,7 @@ import { useRef, useState } from 'react'; import { useSearchParams } from 'react-router-dom'; import { useGA } from '../common'; +import { useToastActionContext } from '../context'; const useSearch = () => { const inputRef = useRef(null); @@ -14,6 +15,8 @@ const useSearch = () => { const [isSubmitted, setIsSubmitted] = useState(!!currentSearchQuery); const [isAutocompleteOpen, setIsAutocompleteOpen] = useState(searchQuery.length > 0); + const { toast } = useToastActionContext(); + const { gaEvent } = useGA(); const focusInput = () => { @@ -35,7 +38,7 @@ const useSearch = () => { const trimmedSearchQuery = searchQuery.trim(); if (!trimmedSearchQuery) { - alert('검색어를 입력해주세요'); + toast.error('검색어를 입력해주세요'); focusInput(); resetSearchQuery(); return; diff --git a/frontend/src/pages/MemberModifyPage.tsx b/frontend/src/pages/MemberModifyPage.tsx index a5c21490b..6b3031a9e 100644 --- a/frontend/src/pages/MemberModifyPage.tsx +++ b/frontend/src/pages/MemberModifyPage.tsx @@ -62,11 +62,11 @@ export const MemberModifyPage = () => { }, onError: (error) => { if (error instanceof Error) { - alert(error.message); + toast.error(error.message); return; } - alert('회원정보 수정을 다시 시도해주세요.'); + toast.error('회원정보 수정을 다시 시도해주세요.'); }, }); }; From 3f1dd1be1e17a80d5b361ed45568de13a30c6200 Mon Sep 17 00:00:00 2001 From: Hanuel Lee <91522259+hanueleee@users.noreply.github.com> Date: Wed, 18 Oct 2023 20:50:48 +0900 Subject: [PATCH 35/55] =?UTF-8?q?[BE=20]test:=20=EB=B9=84=EB=8F=99?= =?UTF-8?q?=EA=B8=B0=20=EB=A1=9C=EC=A7=81=20=EA=B2=80=EC=A6=9D=20=ED=85=8C?= =?UTF-8?q?=EC=8A=A4=ED=8A=B8=20=EC=A0=9C=EA=B1=B0=20(#788)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../ReviewDeleteEventListenerTest.java | 46 ------------------- 1 file changed, 46 deletions(-) diff --git a/backend/src/test/java/com/funeat/review/application/ReviewDeleteEventListenerTest.java b/backend/src/test/java/com/funeat/review/application/ReviewDeleteEventListenerTest.java index 9f5dfb73b..f62537aca 100644 --- a/backend/src/test/java/com/funeat/review/application/ReviewDeleteEventListenerTest.java +++ b/backend/src/test/java/com/funeat/review/application/ReviewDeleteEventListenerTest.java @@ -6,14 +6,10 @@ import static com.funeat.fixture.ProductFixture.상품_삼각김밥_가격1000원_평점2점_생성; import static com.funeat.fixture.ReviewFixture.리뷰_이미지test1_평점1점_재구매O_생성; import static com.funeat.fixture.ReviewFixture.리뷰_이미지test2_평점2점_재구매O_생성; -import static com.funeat.fixture.ReviewFixture.리뷰_이미지test3_평점3점_재구매O_생성; import static com.funeat.fixture.ReviewFixture.리뷰_이미지test4_평점4점_재구매O_생성; -import static com.funeat.fixture.ReviewFixture.리뷰_이미지없음_평점1점_재구매X_생성; import static org.assertj.core.api.Assertions.assertThat; import static org.mockito.ArgumentMatchers.any; import static org.mockito.Mockito.doThrow; -import static org.mockito.Mockito.timeout; -import static org.mockito.Mockito.verify; import com.funeat.common.EventTest; import com.funeat.common.ImageUploader; @@ -86,48 +82,6 @@ class 리뷰_삭제_이벤트_발행 { @Nested class 이미지_삭제_로직_작동 { - @Test - void 리뷰_삭제가_정상적으로_커밋되고_이미지가_존재하면_이미지_삭제_로직이_작동한다() { - // given - final var author = 멤버_멤버1_생성(); - final var authorId = 단일_멤버_저장(author); - - final var category = 카테고리_즉석조리_생성(); - 단일_카테고리_저장(category); - - final var product = 상품_삼각김밥_가격1000원_평점2점_생성(category); - 단일_상품_저장(product); - - final var review = reviewRepository.save(리뷰_이미지test3_평점3점_재구매O_생성(author, product, 0L)); - - // when - reviewService.deleteReview(review.getId(), authorId); - - // then - verify(uploader, timeout(1000).times(1)).delete(any()); - } - - @Test - void 리뷰_삭제가_정상적으로_커밋되었지만_이미지가_존재하지_않으면_이미지_삭제_로직이_작동하지않는다() { - // given - final var author = 멤버_멤버1_생성(); - final var authorId = 단일_멤버_저장(author); - - final var category = 카테고리_즉석조리_생성(); - 단일_카테고리_저장(category); - - final var product = 상품_삼각김밥_가격1000원_평점2점_생성(category); - 단일_상품_저장(product); - - final var review = reviewRepository.save(리뷰_이미지없음_평점1점_재구매X_생성(author, product, 0L)); - - // when - reviewService.deleteReview(review.getId(), authorId); - - // then - verify(uploader, timeout(1000).times(0)).delete(any()); - } - @Test void 이미지_삭제_로직이_실패해도_메인로직까지_롤백되어서는_안된다() { // given From 4d299d790e51277a5e66955275b786b7b6263d16 Mon Sep 17 00:00:00 2001 From: JFe <33208246+Go-Jaecheol@users.noreply.github.com> Date: Wed, 18 Oct 2023 22:10:10 +0900 Subject: [PATCH 36/55] =?UTF-8?q?[BE]=20feat:=20=EB=A6=AC=EB=B7=B0=20?= =?UTF-8?q?=EC=83=81=EC=84=B8=20=EC=A1=B0=ED=9A=8C=20API=20=EA=B5=AC?= =?UTF-8?q?=ED=98=84=20(#758)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat: 리뷰 상세 조회 기능 구현 * test: 리뷰 상세 조회 테스트 추가 * refactor: ReviewDetailResponse에 상품 정보 추가 * style: import 정렬 순서 변경 * style: 불필요한 import 제거 --- .../review/application/ReviewService.java | 7 + .../review/dto/ReviewDetailResponse.java | 121 ++++++++++++++++++ .../presentation/ReviewApiController.java | 8 ++ .../review/presentation/ReviewController.java | 12 +- .../review/ReviewAcceptanceTest.java | 56 ++++++++ .../funeat/acceptance/review/ReviewSteps.java | 8 ++ .../review/application/ReviewServiceTest.java | 46 ++++++- 7 files changed, 254 insertions(+), 4 deletions(-) create mode 100644 backend/src/main/java/com/funeat/review/dto/ReviewDetailResponse.java diff --git a/backend/src/main/java/com/funeat/review/application/ReviewService.java b/backend/src/main/java/com/funeat/review/application/ReviewService.java index 5719e8dd0..6bc8c4f42 100644 --- a/backend/src/main/java/com/funeat/review/application/ReviewService.java +++ b/backend/src/main/java/com/funeat/review/application/ReviewService.java @@ -25,6 +25,7 @@ import com.funeat.review.dto.RankingReviewDto; import com.funeat.review.dto.RankingReviewsResponse; import com.funeat.review.dto.ReviewCreateRequest; +import com.funeat.review.dto.ReviewDetailResponse; import com.funeat.review.dto.ReviewFavoriteRequest; import com.funeat.review.dto.SortingReviewDto; import com.funeat.review.dto.SortingReviewDtoWithoutTag; @@ -283,4 +284,10 @@ public Optional getMostFavoriteReview(final Long pro return MostFavoriteReviewResponse.toResponse(review); } + + public ReviewDetailResponse getReviewDetail(final Long reviewId) { + final Review review = reviewRepository.findById(reviewId) + .orElseThrow(() -> new ReviewNotFoundException(REVIEW_NOT_FOUND, reviewId)); + return ReviewDetailResponse.toResponse(review); + } } diff --git a/backend/src/main/java/com/funeat/review/dto/ReviewDetailResponse.java b/backend/src/main/java/com/funeat/review/dto/ReviewDetailResponse.java new file mode 100644 index 000000000..7e0c9858b --- /dev/null +++ b/backend/src/main/java/com/funeat/review/dto/ReviewDetailResponse.java @@ -0,0 +1,121 @@ +package com.funeat.review.dto; + +import com.funeat.review.domain.Review; +import com.funeat.review.domain.ReviewTag; +import com.funeat.tag.dto.TagDto; +import java.time.LocalDateTime; +import java.util.List; +import java.util.stream.Collectors; + +public class ReviewDetailResponse { + + private final Long id; + private final String userName; + private final String profileImage; + private final String image; + private final Long rating; + private final List tags; + private final String content; + private final boolean rebuy; + private final Long favoriteCount; + private final LocalDateTime createdAt; + private final String categoryType; + private final Long productId; + private final String productName; + + public ReviewDetailResponse(final Long id, final String userName, final String profileImage, final String image, + final Long rating, final List tags, final String content, final boolean rebuy, + final Long favoriteCount, final LocalDateTime createdAt, final String categoryType, + final Long productId, final String productName) { + this.id = id; + this.userName = userName; + this.profileImage = profileImage; + this.image = image; + this.rating = rating; + this.tags = tags; + this.content = content; + this.rebuy = rebuy; + this.favoriteCount = favoriteCount; + this.createdAt = createdAt; + this.categoryType = categoryType; + this.productId = productId; + this.productName = productName; + } + + public static ReviewDetailResponse toResponse(final Review review) { + return new ReviewDetailResponse( + review.getId(), + review.getMember().getNickname(), + review.getMember().getProfileImage(), + review.getImage(), + review.getRating(), + findTagDtos(review), + review.getContent(), + review.getReBuy(), + review.getFavoriteCount(), + review.getCreatedAt(), + review.getProduct().getCategory().getType().getName(), + review.getProduct().getId(), + review.getProduct().getName() + ); + } + + private static List findTagDtos(final Review review) { + return review.getReviewTags().stream() + .map(ReviewTag::getTag) + .map(TagDto::toDto) + .collect(Collectors.toList()); + } + + public Long getId() { + return id; + } + + public String getUserName() { + return userName; + } + + public String getProfileImage() { + return profileImage; + } + + public String getImage() { + return image; + } + + public Long getRating() { + return rating; + } + + public List getTags() { + return tags; + } + + public String getContent() { + return content; + } + + public boolean isRebuy() { + return rebuy; + } + + public Long getFavoriteCount() { + return favoriteCount; + } + + public LocalDateTime getCreatedAt() { + return createdAt; + } + + public String getCategoryType() { + return categoryType; + } + + public Long getProductId() { + return productId; + } + + public String getProductName() { + return productName; + } +} diff --git a/backend/src/main/java/com/funeat/review/presentation/ReviewApiController.java b/backend/src/main/java/com/funeat/review/presentation/ReviewApiController.java index 0249b6967..ff82ff499 100644 --- a/backend/src/main/java/com/funeat/review/presentation/ReviewApiController.java +++ b/backend/src/main/java/com/funeat/review/presentation/ReviewApiController.java @@ -7,6 +7,7 @@ import com.funeat.review.dto.MostFavoriteReviewResponse; import com.funeat.review.dto.RankingReviewsResponse; import com.funeat.review.dto.ReviewCreateRequest; +import com.funeat.review.dto.ReviewDetailResponse; import com.funeat.review.dto.ReviewFavoriteRequest; import com.funeat.review.dto.SortingReviewRequest; import com.funeat.review.dto.SortingReviewsResponse; @@ -83,4 +84,11 @@ public ResponseEntity> getMostFavoriteRevie } return ResponseEntity.ok(response); } + + @GetMapping("/api/reviews/{reviewId}") + public ResponseEntity getReviewDetail(@PathVariable final Long reviewId) { + final ReviewDetailResponse response = reviewService.getReviewDetail(reviewId); + + return ResponseEntity.ok(response); + } } diff --git a/backend/src/main/java/com/funeat/review/presentation/ReviewController.java b/backend/src/main/java/com/funeat/review/presentation/ReviewController.java index 0584134b5..13fb85e94 100644 --- a/backend/src/main/java/com/funeat/review/presentation/ReviewController.java +++ b/backend/src/main/java/com/funeat/review/presentation/ReviewController.java @@ -5,6 +5,7 @@ import com.funeat.review.dto.MostFavoriteReviewResponse; import com.funeat.review.dto.RankingReviewsResponse; import com.funeat.review.dto.ReviewCreateRequest; +import com.funeat.review.dto.ReviewDetailResponse; import com.funeat.review.dto.ReviewFavoriteRequest; import com.funeat.review.dto.SortingReviewRequest; import com.funeat.review.dto.SortingReviewsResponse; @@ -12,10 +13,7 @@ import io.swagger.v3.oas.annotations.responses.ApiResponse; import io.swagger.v3.oas.annotations.tags.Tag; import java.util.Optional; -import org.springframework.data.domain.Pageable; -import org.springframework.data.web.PageableDefault; import org.springframework.http.ResponseEntity; -import org.springframework.web.bind.annotation.DeleteMapping; import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.ModelAttribute; import org.springframework.web.bind.annotation.PatchMapping; @@ -75,4 +73,12 @@ ResponseEntity getSortingReviews(@AuthenticationPrincipa ) @GetMapping ResponseEntity> getMostFavoriteReview(@PathVariable final Long productId); + + @Operation(summary = "리뷰 상세 조회", description = "리뷰의 상세 정보를 조회한다.") + @ApiResponse( + responseCode = "200", + description = "리뷰 상세 조회 성공." + ) + @GetMapping + ResponseEntity getReviewDetail(@PathVariable final Long reviewId); } diff --git a/backend/src/test/java/com/funeat/acceptance/review/ReviewAcceptanceTest.java b/backend/src/test/java/com/funeat/acceptance/review/ReviewAcceptanceTest.java index 76e4f48a5..233cc4ca5 100644 --- a/backend/src/test/java/com/funeat/acceptance/review/ReviewAcceptanceTest.java +++ b/backend/src/test/java/com/funeat/acceptance/review/ReviewAcceptanceTest.java @@ -12,6 +12,7 @@ import static com.funeat.acceptance.common.CommonSteps.찾을수_없음; import static com.funeat.acceptance.product.ProductSteps.상품_상세_조회_요청; import static com.funeat.acceptance.review.ReviewSteps.리뷰_랭킹_조회_요청; +import static com.funeat.acceptance.review.ReviewSteps.리뷰_상세_조회_요청; import static com.funeat.acceptance.review.ReviewSteps.리뷰_작성_요청; import static com.funeat.acceptance.review.ReviewSteps.리뷰_좋아요_요청; import static com.funeat.acceptance.review.ReviewSteps.여러명이_리뷰_좋아요_요청; @@ -66,7 +67,9 @@ import com.funeat.acceptance.common.AcceptanceTest; import com.funeat.review.dto.RankingReviewDto; import com.funeat.review.dto.ReviewCreateRequest; +import com.funeat.review.dto.ReviewDetailResponse; import com.funeat.review.dto.SortingReviewDto; +import com.funeat.tag.dto.TagDto; import io.restassured.response.ExtractableResponse; import io.restassured.response.Response; import java.util.Collections; @@ -648,6 +651,43 @@ class getMostFavoriteReview_실패_테스트 { } } + @Nested + class getReviewDetail_성공_테스트 { + + @Test + void 리뷰_상세_정보를_조회한다() { + // given + final var 카테고리 = 카테고리_즉석조리_생성(); + 단일_카테고리_저장(카테고리); + final var 상품 = 단일_상품_저장(상품_삼각김밥_가격1000원_평점3점_생성(카테고리)); + final var 태그 = 단일_태그_저장(태그_맛있어요_TASTE_생성()); + + final var 요청 = 리뷰추가요청_재구매O_생성(점수_3점, List.of(태그)); + 리뷰_작성_요청(로그인_쿠키_획득(멤버1), 상품, 사진_명세_요청(이미지1), 요청); + + // when + final var 응답 = 리뷰_상세_조회_요청(리뷰); + + // then + STATUS_CODE를_검증한다(응답, 정상_처리); + 리뷰_상세_정보_조회_결과를_검증한다(응답, 요청); + } + } + + @Nested + class getReviewDetail_실패_테스트 { + + @Test + void 존재하지_않는_리뷰_조회시_예외가_발생한다() { + // given & when + final var 응답 = 리뷰_상세_조회_요청(존재하지_않는_리뷰); + + // then + STATUS_CODE를_검증한다(응답, 찾을수_없음); + RESPONSE_CODE와_MESSAGE를_검증한다(응답, REVIEW_NOT_FOUND.getCode(), REVIEW_NOT_FOUND.getMessage()); + } + } + private void RESPONSE_CODE와_MESSAGE를_검증한다(final ExtractableResponse response, final String expectedCode, final String expectedMessage) { assertSoftly(soft -> { @@ -693,4 +733,20 @@ class getMostFavoriteReview_실패_테스트 { assertThat(actual).isEqualTo(expected); } + + private void 리뷰_상세_정보_조회_결과를_검증한다(final ExtractableResponse response, final ReviewCreateRequest request) { + final var actual = response.as(ReviewDetailResponse.class); + final var actualTags = response.jsonPath().getList("tags", TagDto.class); + + assertSoftly(soft -> { + soft.assertThat(actual.getId()).isEqualTo(리뷰); + soft.assertThat(actual.getImage()).isEqualTo("1.png"); + soft.assertThat(actual.getRating()).isEqualTo(request.getRating()); + soft.assertThat(actual.getContent()).isEqualTo(request.getContent()); + soft.assertThat(actual.isRebuy()).isEqualTo(request.getRebuy()); + soft.assertThat(actual.getFavoriteCount()).isEqualTo(0L); + soft.assertThat(actualTags).extracting(TagDto::getId) + .containsExactlyElementsOf(request.getTagIds()); + }); + } } diff --git a/backend/src/test/java/com/funeat/acceptance/review/ReviewSteps.java b/backend/src/test/java/com/funeat/acceptance/review/ReviewSteps.java index ae4f81c16..e7f828134 100644 --- a/backend/src/test/java/com/funeat/acceptance/review/ReviewSteps.java +++ b/backend/src/test/java/com/funeat/acceptance/review/ReviewSteps.java @@ -83,4 +83,12 @@ public class ReviewSteps { .then().log().all() .extract(); } + + public static ExtractableResponse 리뷰_상세_조회_요청(final Long reviewId) { + return given() + .when() + .get("/api/reviews/{reviewId}", reviewId) + .then() + .extract(); + } } diff --git a/backend/src/test/java/com/funeat/review/application/ReviewServiceTest.java b/backend/src/test/java/com/funeat/review/application/ReviewServiceTest.java index 87a10756c..7d35ab166 100644 --- a/backend/src/test/java/com/funeat/review/application/ReviewServiceTest.java +++ b/backend/src/test/java/com/funeat/review/application/ReviewServiceTest.java @@ -40,9 +40,10 @@ import com.funeat.member.exception.MemberException.MemberNotFoundException; import com.funeat.product.exception.ProductException.ProductNotFoundException; import com.funeat.review.domain.Review; +import com.funeat.review.dto.MostFavoriteReviewResponse; import com.funeat.review.dto.RankingReviewDto; import com.funeat.review.dto.RankingReviewsResponse; -import com.funeat.review.dto.MostFavoriteReviewResponse; +import com.funeat.review.dto.ReviewDetailResponse; import com.funeat.review.dto.SortingReviewDto; import com.funeat.review.exception.ReviewException.NotAuthorOfReviewException; import com.funeat.review.exception.ReviewException.ReviewNotFoundException; @@ -1014,6 +1015,49 @@ class getMostFavoriteReview_실패_테스트 { } } + @Nested + class getReviewDetail_성공_테스트 { + + @Test + void 리뷰_상세_정보를_조회한다() { + // given + final var member = 멤버_멤버1_생성(); + 단일_멤버_저장(member); + + final var category = 카테고리_즉석조리_생성(); + 단일_카테고리_저장(category); + + final var product = 상품_삼각김밥_가격1000원_평점5점_생성(category); + 단일_상품_저장(product); + + final var review = 리뷰_이미지test2_평점2점_재구매X_생성(member, product, 0L); + 단일_리뷰_저장(review); + + // when + final var actual = reviewService.getReviewDetail(review.getId()); + + // then + final var expected = ReviewDetailResponse.toResponse(review); + + assertThat(actual).usingRecursiveComparison() + .isEqualTo(expected); + } + } + + @Nested + class getReviewDetail_실패_테스트 { + + @Test + void 존재하지_않는_리뷰를_조회할때_예외가_발생한다() { + // given + final var notExistReviewId = 999999L; + + // when & then + assertThatThrownBy(() -> reviewService.getReviewDetail(notExistReviewId)) + .isInstanceOf(ReviewNotFoundException.class); + } + } + @Nested class getTopReviews_성공_테스트 { From 826e7138363b7809e8d324f33c5f86539a06186e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=9A=B0=EA=B0=80?= Date: Wed, 18 Oct 2023 22:19:58 +0900 Subject: [PATCH 37/55] =?UTF-8?q?[BE]=20feat:=20Banner=20entity=20?= =?UTF-8?q?=EA=B5=AC=ED=98=84,=20Banner=20=EC=A0=84=EC=B2=B4=20=EC=A1=B0?= =?UTF-8?q?=ED=9A=8C=20=EA=B5=AC=ED=98=84=20(#791)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat: Banner entity 구현, Banner 전체 조회 구현 * remove: 리뷰 비동기 테스트 삭제 * refactor: 리뷰 반영 --- .../banner/application/BannerService.java | 28 +++++++++ .../java/com/funeat/banner/domain/Banner.java | 38 ++++++++++++ .../com/funeat/banner/dto/BannerResponse.java | 32 ++++++++++ .../banner/persistence/BannerRepository.java | 10 +++ .../presentation/BannerApiController.java | 21 +++++++ .../banner/presentation/BannerController.java | 25 ++++++++ .../java/com/funeat/common/OpenApiConfig.java | 1 + .../banner/BannerAcceptanceTest.java | 62 +++++++++++++++++++ .../funeat/acceptance/banner/BannerSteps.java | 17 +++++ .../acceptance/common/AcceptanceTest.java | 10 ++- .../banner/application/BannerServiceTest.java | 47 ++++++++++++++ .../persistence/BannerRepositoryTest.java | 39 ++++++++++++ .../com/funeat/common/RepositoryTest.java | 11 ++++ .../java/com/funeat/common/ServiceTest.java | 15 +++++ .../com/funeat/fixture/BannerFixture.java | 26 ++++++++ .../ReviewDeleteEventListenerTest.java | 4 +- 16 files changed, 381 insertions(+), 5 deletions(-) create mode 100644 backend/src/main/java/com/funeat/banner/application/BannerService.java create mode 100644 backend/src/main/java/com/funeat/banner/domain/Banner.java create mode 100644 backend/src/main/java/com/funeat/banner/dto/BannerResponse.java create mode 100644 backend/src/main/java/com/funeat/banner/persistence/BannerRepository.java create mode 100644 backend/src/main/java/com/funeat/banner/presentation/BannerApiController.java create mode 100644 backend/src/main/java/com/funeat/banner/presentation/BannerController.java create mode 100644 backend/src/test/java/com/funeat/acceptance/banner/BannerAcceptanceTest.java create mode 100644 backend/src/test/java/com/funeat/acceptance/banner/BannerSteps.java create mode 100644 backend/src/test/java/com/funeat/banner/application/BannerServiceTest.java create mode 100644 backend/src/test/java/com/funeat/banner/persistence/BannerRepositoryTest.java create mode 100644 backend/src/test/java/com/funeat/fixture/BannerFixture.java diff --git a/backend/src/main/java/com/funeat/banner/application/BannerService.java b/backend/src/main/java/com/funeat/banner/application/BannerService.java new file mode 100644 index 000000000..48864670a --- /dev/null +++ b/backend/src/main/java/com/funeat/banner/application/BannerService.java @@ -0,0 +1,28 @@ +package com.funeat.banner.application; + +import com.funeat.banner.domain.Banner; +import com.funeat.banner.dto.BannerResponse; +import com.funeat.banner.persistence.BannerRepository; +import java.util.List; +import java.util.stream.Collectors; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@Service +@Transactional(readOnly = true) +public class BannerService { + + private final BannerRepository bannerRepository; + + public BannerService(final BannerRepository bannerRepository) { + this.bannerRepository = bannerRepository; + } + + public List getAllBanners() { + final List findBanners = bannerRepository.findAllByOrderByIdDesc(); + + return findBanners.stream() + .map(BannerResponse::toResponse) + .collect(Collectors.toList()); + } +} diff --git a/backend/src/main/java/com/funeat/banner/domain/Banner.java b/backend/src/main/java/com/funeat/banner/domain/Banner.java new file mode 100644 index 000000000..9ea8eabd2 --- /dev/null +++ b/backend/src/main/java/com/funeat/banner/domain/Banner.java @@ -0,0 +1,38 @@ +package com.funeat.banner.domain; + +import javax.persistence.Entity; +import javax.persistence.GeneratedValue; +import javax.persistence.GenerationType; +import javax.persistence.Id; + +@Entity +public class Banner { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + private String link; + + private String image; + + protected Banner() { + } + + public Banner(final String link, final String image) { + this.link = link; + this.image = image; + } + + public Long getId() { + return id; + } + + public String getLink() { + return link; + } + + public String getImage() { + return image; + } +} diff --git a/backend/src/main/java/com/funeat/banner/dto/BannerResponse.java b/backend/src/main/java/com/funeat/banner/dto/BannerResponse.java new file mode 100644 index 000000000..fcff62c2f --- /dev/null +++ b/backend/src/main/java/com/funeat/banner/dto/BannerResponse.java @@ -0,0 +1,32 @@ +package com.funeat.banner.dto; + +import com.funeat.banner.domain.Banner; + +public class BannerResponse { + + private final Long id; + private final String link; + private final String image; + + private BannerResponse(final Long id, final String link, final String image) { + this.id = id; + this.link = link; + this.image = image; + } + + public static BannerResponse toResponse(final Banner banner) { + return new BannerResponse(banner.getId(), banner.getLink(), banner.getImage()); + } + + public Long getId() { + return id; + } + + public String getLink() { + return link; + } + + public String getImage() { + return image; + } +} diff --git a/backend/src/main/java/com/funeat/banner/persistence/BannerRepository.java b/backend/src/main/java/com/funeat/banner/persistence/BannerRepository.java new file mode 100644 index 000000000..30f598755 --- /dev/null +++ b/backend/src/main/java/com/funeat/banner/persistence/BannerRepository.java @@ -0,0 +1,10 @@ +package com.funeat.banner.persistence; + +import com.funeat.banner.domain.Banner; +import java.util.List; +import org.springframework.data.jpa.repository.JpaRepository; + +public interface BannerRepository extends JpaRepository { + + List findAllByOrderByIdDesc(); +} diff --git a/backend/src/main/java/com/funeat/banner/presentation/BannerApiController.java b/backend/src/main/java/com/funeat/banner/presentation/BannerApiController.java new file mode 100644 index 000000000..7465189ab --- /dev/null +++ b/backend/src/main/java/com/funeat/banner/presentation/BannerApiController.java @@ -0,0 +1,21 @@ +package com.funeat.banner.presentation; + +import com.funeat.banner.dto.BannerResponse; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.responses.ApiResponse; +import io.swagger.v3.oas.annotations.tags.Tag; +import java.util.List; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.GetMapping; + +@Tag(name = "08.Banner", description = "배너 관련 API 입니다.") +public interface BannerApiController { + + @Operation(summary = "배너 전체 조회", description = "배너 전체를 조회한다.") + @ApiResponse( + responseCode = "200", + description = "배너 전체 조회 성공." + ) + @GetMapping + ResponseEntity> getBanners(); +} diff --git a/backend/src/main/java/com/funeat/banner/presentation/BannerController.java b/backend/src/main/java/com/funeat/banner/presentation/BannerController.java new file mode 100644 index 000000000..164df97cd --- /dev/null +++ b/backend/src/main/java/com/funeat/banner/presentation/BannerController.java @@ -0,0 +1,25 @@ +package com.funeat.banner.presentation; + +import com.funeat.banner.application.BannerService; +import com.funeat.banner.dto.BannerResponse; +import java.util.List; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RestController; + +@RestController +public class BannerController implements BannerApiController { + + private final BannerService bannerService; + + public BannerController(final BannerService bannerService) { + this.bannerService = bannerService; + } + + @GetMapping("/api/banners") + public ResponseEntity> getBanners() { + final List responses = bannerService.getAllBanners(); + + return ResponseEntity.ok(responses); + } +} diff --git a/backend/src/main/java/com/funeat/common/OpenApiConfig.java b/backend/src/main/java/com/funeat/common/OpenApiConfig.java index 47c4df2bb..51c42429d 100644 --- a/backend/src/main/java/com/funeat/common/OpenApiConfig.java +++ b/backend/src/main/java/com/funeat/common/OpenApiConfig.java @@ -19,6 +19,7 @@ @Tag(name = "05.Member", description = "사용자 기능"), @Tag(name = "06.Login", description = "로그인 기능"), @Tag(name = "07.Recipe", description = "꿀조합 기능"), + @Tag(name = "08.Banner", description = "배너 기능"), } ) @Configuration diff --git a/backend/src/test/java/com/funeat/acceptance/banner/BannerAcceptanceTest.java b/backend/src/test/java/com/funeat/acceptance/banner/BannerAcceptanceTest.java new file mode 100644 index 000000000..e5b3a39fd --- /dev/null +++ b/backend/src/test/java/com/funeat/acceptance/banner/BannerAcceptanceTest.java @@ -0,0 +1,62 @@ +package com.funeat.acceptance.banner; + +import static com.funeat.acceptance.banner.BannerSteps.배너_목록_조회_요청; +import static com.funeat.acceptance.common.CommonSteps.STATUS_CODE를_검증한다; +import static com.funeat.acceptance.common.CommonSteps.정상_처리; +import static com.funeat.fixture.BannerFixture.배너1_생성; +import static com.funeat.fixture.BannerFixture.배너2_생성; +import static com.funeat.fixture.BannerFixture.배너3_생성; +import static com.funeat.fixture.BannerFixture.배너4_생성; +import static com.funeat.fixture.BannerFixture.배너5_생성; +import static org.assertj.core.api.Assertions.assertThat; + +import com.funeat.acceptance.common.AcceptanceTest; +import com.funeat.banner.domain.Banner; +import com.funeat.banner.dto.BannerResponse; +import io.restassured.response.ExtractableResponse; +import io.restassured.response.Response; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.List; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; + +@SuppressWarnings("NonAsciiCharacters") +public class BannerAcceptanceTest extends AcceptanceTest { + + @Nested + class getBanners_성공_테스트 { + + @Test + void 배너를_아이디_내림차순으로_전체_조회한다() { + // given + final var 배너1 = 배너1_생성(); + final var 배너2 = 배너2_생성(); + final var 배너3 = 배너3_생성(); + final var 배너4 = 배너4_생성(); + final var 배너5 = 배너5_생성(); + final var 생성할_배너_리스트 = Arrays.asList(배너1, 배너2, 배너3, 배너4, 배너5); + bannerRepository.saveAll(생성할_배너_리스트); + + // when + final var 응답 = 배너_목록_조회_요청(); + + // then + STATUS_CODE를_검증한다(응답, 정상_처리); + 배너_조회_결과를_검증한다(응답, 생성할_배너_리스트); + } + } + + private void 배너_조회_결과를_검증한다(final ExtractableResponse response, + final List expected) { + List expectedResponse = new ArrayList<>(); + for (int i = expected.size() - 1; i >= 0; i--) { + expectedResponse.add(BannerResponse.toResponse(expected.get(i))); + } + + final List result = response.jsonPath().getList("$", BannerResponse.class); + assertThat(result).usingRecursiveComparison() + .ignoringFields("id") + .isEqualTo(expectedResponse); + } +} diff --git a/backend/src/test/java/com/funeat/acceptance/banner/BannerSteps.java b/backend/src/test/java/com/funeat/acceptance/banner/BannerSteps.java new file mode 100644 index 000000000..6fbc155f9 --- /dev/null +++ b/backend/src/test/java/com/funeat/acceptance/banner/BannerSteps.java @@ -0,0 +1,17 @@ +package com.funeat.acceptance.banner; + +import io.restassured.RestAssured; +import io.restassured.response.ExtractableResponse; +import io.restassured.response.Response; + +@SuppressWarnings("NonAsciiCharacters") +public class BannerSteps { + + public static ExtractableResponse 배너_목록_조회_요청() { + return RestAssured.given() + .when() + .get("/api/banners") + .then() + .extract(); + } +} diff --git a/backend/src/test/java/com/funeat/acceptance/common/AcceptanceTest.java b/backend/src/test/java/com/funeat/acceptance/common/AcceptanceTest.java index 128490663..2e5be6b6d 100644 --- a/backend/src/test/java/com/funeat/acceptance/common/AcceptanceTest.java +++ b/backend/src/test/java/com/funeat/acceptance/common/AcceptanceTest.java @@ -1,5 +1,6 @@ package com.funeat.acceptance.common; +import com.funeat.banner.persistence.BannerRepository; import com.funeat.comment.persistence.CommentRepository; import com.funeat.common.DataClearExtension; import com.funeat.member.domain.Member; @@ -58,16 +59,19 @@ public abstract class AcceptanceTest { protected ReviewFavoriteRepository reviewFavoriteRepository; @Autowired - public RecipeRepository recipeRepository; + protected RecipeRepository recipeRepository; @Autowired - public RecipeImageRepository recipeImageRepository; + protected RecipeImageRepository recipeImageRepository; @Autowired protected ProductRecipeRepository productRecipeRepository; @Autowired - public RecipeFavoriteRepository recipeFavoriteRepository; + protected RecipeFavoriteRepository recipeFavoriteRepository; + + @Autowired + protected BannerRepository bannerRepository; @Autowired protected CommentRepository commentRepository; diff --git a/backend/src/test/java/com/funeat/banner/application/BannerServiceTest.java b/backend/src/test/java/com/funeat/banner/application/BannerServiceTest.java new file mode 100644 index 000000000..dad7292f4 --- /dev/null +++ b/backend/src/test/java/com/funeat/banner/application/BannerServiceTest.java @@ -0,0 +1,47 @@ +package com.funeat.banner.application; + +import static com.funeat.fixture.BannerFixture.배너1_생성; +import static com.funeat.fixture.BannerFixture.배너2_생성; +import static com.funeat.fixture.BannerFixture.배너3_생성; +import static com.funeat.fixture.BannerFixture.배너4_생성; +import static com.funeat.fixture.BannerFixture.배너5_생성; +import static org.assertj.core.api.Assertions.assertThat; + +import com.funeat.banner.dto.BannerResponse; +import com.funeat.common.ServiceTest; +import java.util.List; +import java.util.stream.Collectors; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; + +@SuppressWarnings("NonAsciiCharacters") +public class BannerServiceTest extends ServiceTest { + + @Nested + class getBanners_성공_테스트 { + + @Test + void 배너를_아이디_내림차순으로_전체_조회한다() { + // given + final var 배너1 = 배너1_생성(); + final var 배너2 = 배너2_생성(); + final var 배너3 = 배너3_생성(); + final var 배너4 = 배너4_생성(); + final var 배너5 = 배너5_생성(); + 복수_배너_저장(배너1, 배너2, 배너3, 배너4, 배너5); + + // when + final var result = bannerService.getAllBanners(); + + // then + final var 배너들 = List.of(배너5, 배너4, 배너3, 배너2, 배너1); + final var bannerResponses = 배너들.stream() + .map(BannerResponse::toResponse) + .collect(Collectors.toList()); + + assertThat(result).usingRecursiveComparison() + .ignoringFields("id") + .isEqualTo(bannerResponses); + } + } +} diff --git a/backend/src/test/java/com/funeat/banner/persistence/BannerRepositoryTest.java b/backend/src/test/java/com/funeat/banner/persistence/BannerRepositoryTest.java new file mode 100644 index 000000000..34ef6e6b9 --- /dev/null +++ b/backend/src/test/java/com/funeat/banner/persistence/BannerRepositoryTest.java @@ -0,0 +1,39 @@ +package com.funeat.banner.persistence; + +import static com.funeat.fixture.BannerFixture.배너1_생성; +import static com.funeat.fixture.BannerFixture.배너2_생성; +import static com.funeat.fixture.BannerFixture.배너3_생성; +import static com.funeat.fixture.BannerFixture.배너4_생성; +import static com.funeat.fixture.BannerFixture.배너5_생성; +import static org.assertj.core.api.Assertions.assertThat; + +import com.funeat.common.RepositoryTest; +import java.util.List; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; + +@SuppressWarnings("NonAsciiCharacters") +public class BannerRepositoryTest extends RepositoryTest { + + @Nested + class findAllByOrderByIdDesc_성공_테스트 { + + @Test + void 배너는_아이디가_내림차순으로_조회된다() { + // given + final var 배너1 = 배너1_생성(); + final var 배너2 = 배너2_생성(); + final var 배너3 = 배너3_생성(); + final var 배너4 = 배너4_생성(); + final var 배너5 = 배너5_생성(); + 복수_배너_저장(배너1, 배너2, 배너3, 배너4, 배너5); + + // when + final var bannersOrderByIdDesc = bannerRepository.findAllByOrderByIdDesc(); + + // then + assertThat(bannersOrderByIdDesc).usingRecursiveComparison() + .isEqualTo(List.of(배너5, 배너4, 배너3, 배너2, 배너1)); + } + } +} diff --git a/backend/src/test/java/com/funeat/common/RepositoryTest.java b/backend/src/test/java/com/funeat/common/RepositoryTest.java index 6fdb2307b..1ae1fff62 100644 --- a/backend/src/test/java/com/funeat/common/RepositoryTest.java +++ b/backend/src/test/java/com/funeat/common/RepositoryTest.java @@ -1,5 +1,7 @@ package com.funeat.common; +import com.funeat.banner.domain.Banner; +import com.funeat.banner.persistence.BannerRepository; import com.funeat.member.domain.Member; import com.funeat.member.domain.favorite.RecipeFavorite; import com.funeat.member.domain.favorite.ReviewFavorite; @@ -70,6 +72,9 @@ public abstract class RepositoryTest { @Autowired protected TagRepository tagRepository; + @Autowired + protected BannerRepository bannerRepository; + protected Long 단일_상품_저장(final Product product) { return productRepository.save(product).getId(); } @@ -187,4 +192,10 @@ public abstract class RepositoryTest { protected void 레시피_좋아요_저장(final RecipeFavorite recipeFavorite) { recipeFavoriteRepository.save(recipeFavorite); } + + protected void 복수_배너_저장(final Banner... bannerToSave) { + final List banners = List.of(bannerToSave); + + bannerRepository.saveAll(banners); + } } diff --git a/backend/src/test/java/com/funeat/common/ServiceTest.java b/backend/src/test/java/com/funeat/common/ServiceTest.java index 6612223aa..23ee634f5 100644 --- a/backend/src/test/java/com/funeat/common/ServiceTest.java +++ b/backend/src/test/java/com/funeat/common/ServiceTest.java @@ -1,6 +1,9 @@ package com.funeat.common; import com.funeat.auth.application.AuthService; +import com.funeat.banner.application.BannerService; +import com.funeat.banner.domain.Banner; +import com.funeat.banner.persistence.BannerRepository; import com.funeat.comment.persistence.CommentRepository; import com.funeat.member.application.TestMemberService; import com.funeat.member.domain.Member; @@ -77,6 +80,9 @@ public abstract class ServiceTest { @Autowired protected TagRepository tagRepository; + @Autowired + protected BannerRepository bannerRepository; + @Autowired protected CommentRepository commentRepository; @@ -101,6 +107,9 @@ public abstract class ServiceTest { @Autowired protected TagService tagService; + @Autowired + protected BannerService bannerService; + protected Long 단일_상품_저장(final Product product) { return productRepository.save(product).getId(); } @@ -196,4 +205,10 @@ public abstract class ServiceTest { productRecipeRepository.saveAll(productRecipes); } + + protected void 복수_배너_저장(final Banner... bannerToSave) { + final List banners = List.of(bannerToSave); + + bannerRepository.saveAll(banners); + } } diff --git a/backend/src/test/java/com/funeat/fixture/BannerFixture.java b/backend/src/test/java/com/funeat/fixture/BannerFixture.java new file mode 100644 index 000000000..9ab4443f5 --- /dev/null +++ b/backend/src/test/java/com/funeat/fixture/BannerFixture.java @@ -0,0 +1,26 @@ +package com.funeat.fixture; + +import com.funeat.banner.domain.Banner; + +public class BannerFixture { + + public static Banner 배너1_생성() { + return new Banner("배너1링크", "배너1.jpeg"); + } + + public static Banner 배너2_생성() { + return new Banner("배너2링크", "배너2.jpeg"); + } + + public static Banner 배너3_생성() { + return new Banner("배너3링크", "배너3.jpeg"); + } + + public static Banner 배너4_생성() { + return new Banner("배너4링크", "배너4.jpeg"); + } + + public static Banner 배너5_생성() { + return new Banner("배너5링크", "배너5.jpeg"); + } +} diff --git a/backend/src/test/java/com/funeat/review/application/ReviewDeleteEventListenerTest.java b/backend/src/test/java/com/funeat/review/application/ReviewDeleteEventListenerTest.java index f62537aca..1349eaa37 100644 --- a/backend/src/test/java/com/funeat/review/application/ReviewDeleteEventListenerTest.java +++ b/backend/src/test/java/com/funeat/review/application/ReviewDeleteEventListenerTest.java @@ -97,8 +97,8 @@ class 이미지_삭제_로직_작동 { final var review = reviewRepository.save(리뷰_이미지test4_평점4점_재구매O_생성(author, product, 0L)); doThrow(new S3DeleteFailException(CommonErrorCode.UNKNOWN_SERVER_ERROR_CODE)) - .when(uploader) - .delete(any()); + .when(uploader) + .delete(any()); // when reviewService.deleteReview(review.getId(), authorId); From 3f77bf1d39cb7066c7ba2008db80535d8a64041e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?s=E1=B4=8F=CA=9F=CA=99=C9=AA=20=E2=98=94=EF=B8=8F?= Date: Thu, 19 Oct 2023 10:50:44 +0900 Subject: [PATCH 38/55] =?UTF-8?q?fix:=20=EB=A6=AC=EB=B7=B0=20=EC=83=81?= =?UTF-8?q?=EC=84=B8=20api=20=EC=88=98=EC=A0=95=20(#796)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../queries/review/useReviewDetailQuery.ts | 4 +- frontend/src/mocks/data/reviewDetail.json | 52 +++++++++---------- frontend/src/pages/ReviewDetailPage.tsx | 2 +- frontend/src/router/index.tsx | 18 +++---- frontend/src/types/response.ts | 4 +- 5 files changed, 38 insertions(+), 42 deletions(-) diff --git a/frontend/src/hooks/queries/review/useReviewDetailQuery.ts b/frontend/src/hooks/queries/review/useReviewDetailQuery.ts index a80914053..4b043a93b 100644 --- a/frontend/src/hooks/queries/review/useReviewDetailQuery.ts +++ b/frontend/src/hooks/queries/review/useReviewDetailQuery.ts @@ -1,11 +1,11 @@ import { useSuspendedQuery } from '../useSuspendedQuery'; import { reviewApi } from '@/apis'; -import type { ReviewDetailResponse } from '@/types/response'; +import type { ReviewDetail } from '@/types/review'; const fetchReviewDetail = async (reviewId: number) => { const response = await reviewApi.get({ params: `/${reviewId}` }); - const data: ReviewDetailResponse = await response.json(); + const data: ReviewDetail = await response.json(); return data; }; diff --git a/frontend/src/mocks/data/reviewDetail.json b/frontend/src/mocks/data/reviewDetail.json index 4439f7d43..9d0bcc097 100644 --- a/frontend/src/mocks/data/reviewDetail.json +++ b/frontend/src/mocks/data/reviewDetail.json @@ -1,29 +1,27 @@ { - "reviews": { - "id": 1, - "userName": "펀잇", - "profileImage": "https://github.com/woowacourse-teams/2023-fun-eat/assets/78616893/1f0fd418-131c-4cf8-b540-112d762b7c34", - "image": "https://i.namu.wiki/i/9wnvUaEa1EkDqG-M0Pbwfdf19FJQQXV_-bnlU2SYaNcG05y2wbabiIrfrGES1M4xSgDjY39RwOvLNggDd3Huuw.webp", - "rating": 4.5, - "tags": [ - { - "id": 5, - "name": "단짠단짠", - "tagType": "TASTE" - }, - { - "id": 1, - "name": "망고망고", - "tagType": "TASTE" - } - ], - "content": "맛있어용~!~!", - "rebuy": true, - "favoriteCount": 1320, - "favorite": true, - "createdAt": "2023-10-13T00:00:00", - "categoryType": "food", - "productId": 1, - "productName": "칠성 사이다" - } + "id": 1, + "userName": "펀잇", + "profileImage": "https://github.com/woowacourse-teams/2023-fun-eat/assets/78616893/1f0fd418-131c-4cf8-b540-112d762b7c34", + "image": "https://i.namu.wiki/i/9wnvUaEa1EkDqG-M0Pbwfdf19FJQQXV_-bnlU2SYaNcG05y2wbabiIrfrGES1M4xSgDjY39RwOvLNggDd3Huuw.webp", + "rating": 4.5, + "tags": [ + { + "id": 5, + "name": "단짠단짠", + "tagType": "TASTE" + }, + { + "id": 1, + "name": "망고망고", + "tagType": "TASTE" + } + ], + "content": "맛있어용~!~!", + "rebuy": true, + "favoriteCount": 1320, + "favorite": true, + "createdAt": "2023-10-13T00:00:00", + "categoryType": "food", + "productId": 1, + "productName": "칠성 사이다" } diff --git a/frontend/src/pages/ReviewDetailPage.tsx b/frontend/src/pages/ReviewDetailPage.tsx index 7447e88e2..9cc209bf5 100644 --- a/frontend/src/pages/ReviewDetailPage.tsx +++ b/frontend/src/pages/ReviewDetailPage.tsx @@ -24,7 +24,7 @@ export const ReviewDetailPage = () => { tags, content, favoriteCount, - } = reviewDetail.reviews; + } = reviewDetail; const theme = useTheme(); diff --git a/frontend/src/router/index.tsx b/frontend/src/router/index.tsx index d045e8dc3..d24db3150 100644 --- a/frontend/src/router/index.tsx +++ b/frontend/src/router/index.tsx @@ -51,15 +51,6 @@ const router = createBrowserRouter([ return { Component: MemberRecipePage }; }, }, - { - path: `${PATH.REVIEW}/:reviewId`, - async lazy() { - const { ReviewDetailPage } = await import( - /* webpackChunkName: "ReviewDetailPage" */ '@/pages/ReviewDetailPage' - ); - return { Component: ReviewDetailPage }; - }, - }, ], }, { @@ -78,6 +69,15 @@ const router = createBrowserRouter([ return { Component: HomePage }; }, }, + { + path: `${PATH.REVIEW}/:reviewId`, + async lazy() { + const { ReviewDetailPage } = await import( + /* webpackChunkName: "ReviewDetailPage" */ '@/pages/ReviewDetailPage' + ); + return { Component: ReviewDetailPage }; + }, + }, ], }, { diff --git a/frontend/src/types/response.ts b/frontend/src/types/response.ts index bac466ebf..b2935328a 100644 --- a/frontend/src/types/response.ts +++ b/frontend/src/types/response.ts @@ -63,9 +63,7 @@ export interface MemberRecipeResponse { page: Page; recipes: MemberRecipe[]; } -export interface ReviewDetailResponse { - reviews: ReviewDetail; -} + export interface CommentResponse { hasNext: boolean; totalElements: number | null; From b91e7751236b7b9eacdd9378be99f26a37ec8c64 Mon Sep 17 00:00:00 2001 From: Dabeen Jeong Date: Thu, 19 Oct 2023 10:54:47 +0900 Subject: [PATCH 39/55] =?UTF-8?q?[BE]=20feat:=20HikariCP=20=EC=98=B5?= =?UTF-8?q?=EC=85=98=20=EC=84=A4=EC=A0=95=20(#795)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend/src/main/resources/application.yml | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/backend/src/main/resources/application.yml b/backend/src/main/resources/application.yml index b15ff14fe..941adb9cf 100644 --- a/backend/src/main/resources/application.yml +++ b/backend/src/main/resources/application.yml @@ -15,6 +15,10 @@ spring: store-type: jdbc jdbc: initialize-schema: never + datasource: + hikari: + connection-timeout: { CONNECTION_TIMEOUT } + maximum-pool-size: { MAXIMUM_POOL_SIZE } springdoc: swagger-ui: From 52cf2ee7f7cc6b9625bc9dcc9b1292748adadba8 Mon Sep 17 00:00:00 2001 From: Leejin Yang Date: Thu, 19 Oct 2023 13:25:27 +0900 Subject: [PATCH 40/55] =?UTF-8?q?[FE]=20feat:=20=EB=B0=B0=EB=84=88=20?= =?UTF-8?q?=EC=A1=B0=ED=9A=8C=20API=20=EC=97=B0=EA=B2=B0=20=EB=B0=8F=20?= =?UTF-8?q?=EB=B0=B0=EB=84=88=20=EC=BB=B4=ED=8F=AC=EB=84=8C=ED=8A=B8=20?= =?UTF-8?q?=EC=9E=91=EC=84=B1=20(#790)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat: 배너 조회 API 연결 쿼리 작성 * feat: 배너 컴포넌트 작성 --- frontend/src/apis/index.ts | 1 + .../src/components/Common/Banner/Banner.tsx | 26 +++++++++++++++++++ frontend/src/components/Common/index.ts | 1 + frontend/src/hooks/queries/banner/index.ts | 1 + .../hooks/queries/banner/useBannerQuery.ts | 16 ++++++++++++ frontend/src/mocks/browser.ts | 4 ++- frontend/src/mocks/data/banners.json | 17 ++++++++++++ frontend/src/mocks/handlers/bannerHandlers.ts | 9 +++++++ frontend/src/mocks/handlers/index.ts | 1 + frontend/src/pages/HomePage.tsx | 9 ++----- frontend/src/types/banner.ts | 5 ++++ 11 files changed, 82 insertions(+), 8 deletions(-) create mode 100644 frontend/src/components/Common/Banner/Banner.tsx create mode 100644 frontend/src/hooks/queries/banner/index.ts create mode 100644 frontend/src/hooks/queries/banner/useBannerQuery.ts create mode 100644 frontend/src/mocks/data/banners.json create mode 100644 frontend/src/mocks/handlers/bannerHandlers.ts create mode 100644 frontend/src/types/banner.ts diff --git a/frontend/src/apis/index.ts b/frontend/src/apis/index.ts index 4df8ec8b4..c258c1615 100644 --- a/frontend/src/apis/index.ts +++ b/frontend/src/apis/index.ts @@ -10,3 +10,4 @@ export const recipeApi = new ApiClient('/recipes'); export const searchApi = new ApiClient('/search'); export const logoutApi = new ApiClient('/logout'); export const reviewApi = new ApiClient('/reviews'); +export const bannerApi = new ApiClient('/banners'); diff --git a/frontend/src/components/Common/Banner/Banner.tsx b/frontend/src/components/Common/Banner/Banner.tsx new file mode 100644 index 000000000..89af08c6c --- /dev/null +++ b/frontend/src/components/Common/Banner/Banner.tsx @@ -0,0 +1,26 @@ +import { Link } from '@fun-eat/design-system'; +import styled from 'styled-components'; + +import { useBannerQuery } from '@/hooks/queries/banner'; + +const Banner = () => { + const { data: banners } = useBannerQuery(); + const { link, image } = banners[Math.floor(Math.random() * banners.length)]; + + if (!link) { + return ; + } + + return ( + + + + ); +}; + +export default Banner; + +const BannerImage = styled.img` + width: 100%; + height: auto; +`; diff --git a/frontend/src/components/Common/index.ts b/frontend/src/components/Common/index.ts index 7d9a7c747..070263f54 100644 --- a/frontend/src/components/Common/index.ts +++ b/frontend/src/components/Common/index.ts @@ -24,3 +24,4 @@ export { default as CategoryItem } from './CategoryItem/CategoryItem'; export { default as CategoryFoodList } from './CategoryFoodList/CategoryFoodList'; export { default as CategoryStoreList } from './CategoryStoreList/CategoryStoreList'; export { default as Skeleton } from './Skeleton/Skeleton'; +export { default as Banner } from './Banner/Banner'; diff --git a/frontend/src/hooks/queries/banner/index.ts b/frontend/src/hooks/queries/banner/index.ts new file mode 100644 index 000000000..92fc0eb88 --- /dev/null +++ b/frontend/src/hooks/queries/banner/index.ts @@ -0,0 +1 @@ +export { default as useBannerQuery } from './useBannerQuery'; diff --git a/frontend/src/hooks/queries/banner/useBannerQuery.ts b/frontend/src/hooks/queries/banner/useBannerQuery.ts new file mode 100644 index 000000000..00fc09c42 --- /dev/null +++ b/frontend/src/hooks/queries/banner/useBannerQuery.ts @@ -0,0 +1,16 @@ +import { useSuspendedQuery } from '../useSuspendedQuery'; + +import { bannerApi } from '@/apis'; +import type { Banner } from '@/types/banner'; + +const fetchBanner = async () => { + const response = await bannerApi.get({}); + const data: Banner[] = await response.json(); + return data; +}; + +const useBannerQuery = () => { + return useSuspendedQuery(['banner'], () => fetchBanner()); +}; + +export default useBannerQuery; diff --git a/frontend/src/mocks/browser.ts b/frontend/src/mocks/browser.ts index f7bb4bf35..b0b74505b 100644 --- a/frontend/src/mocks/browser.ts +++ b/frontend/src/mocks/browser.ts @@ -9,6 +9,7 @@ import { recipeHandlers, searchHandlers, logoutHandlers, + bannerHandlers, } from './handlers'; export const worker = setupWorker( @@ -19,5 +20,6 @@ export const worker = setupWorker( ...memberHandlers, ...recipeHandlers, ...searchHandlers, - ...logoutHandlers + ...logoutHandlers, + ...bannerHandlers ); diff --git a/frontend/src/mocks/data/banners.json b/frontend/src/mocks/data/banners.json new file mode 100644 index 000000000..0883f4c22 --- /dev/null +++ b/frontend/src/mocks/data/banners.json @@ -0,0 +1,17 @@ +[ + { + "id": 3, + "link": "https://www.youtube.com/embed/3QlYJ0VY7zg", + "image": "https://i.ytimg.com/vi/3QlYJ0VY7zg/maxresdefault.jpg" + }, + { + "id": 2, + "link": "https://www.youtube.com/embed/3QlYJ0VY7zg", + "image": "https://i.ytimg.com/vi/3QlYJ0VY7zg/maxresdefault.jpg" + }, + { + "id": 1, + "link": "https://www.youtube.com/embed/3QlYJ0VY7zg", + "image": "https://i.ytimg.com/vi/3QlYJ0VY7zg/maxresdefault.jpg" + } +] diff --git a/frontend/src/mocks/handlers/bannerHandlers.ts b/frontend/src/mocks/handlers/bannerHandlers.ts new file mode 100644 index 000000000..20bf92f77 --- /dev/null +++ b/frontend/src/mocks/handlers/bannerHandlers.ts @@ -0,0 +1,9 @@ +import { rest } from 'msw'; + +import banners from '../data/banners.json'; + +export const bannerHandlers = [ + rest.get('/api/banners', (req, res, ctx) => { + return res(ctx.status(200), ctx.json(banners)); + }), +]; diff --git a/frontend/src/mocks/handlers/index.ts b/frontend/src/mocks/handlers/index.ts index c255590ef..b8c5b8b38 100644 --- a/frontend/src/mocks/handlers/index.ts +++ b/frontend/src/mocks/handlers/index.ts @@ -6,3 +6,4 @@ export * from './memberHandlers'; export * from './recipeHandlers'; export * from './searchHandlers'; export * from './logoutHandlers'; +export * from './bannerHandlers'; diff --git a/frontend/src/pages/HomePage.tsx b/frontend/src/pages/HomePage.tsx index bb42cecba..d48e4a255 100644 --- a/frontend/src/pages/HomePage.tsx +++ b/frontend/src/pages/HomePage.tsx @@ -10,9 +10,9 @@ import { CategoryFoodList, CategoryStoreList, SvgIcon, + Banner, } from '@/components/Common'; import { ProductRankingList, ReviewRankingList, RecipeRankingList } from '@/components/Rank'; -import { IMAGE_URL } from '@/constants'; import channelTalk from '@/service/channelTalk'; export const HomePage = () => { @@ -28,7 +28,7 @@ export const HomePage = () => { return ( <>
    - +
    @@ -100,11 +100,6 @@ export const HomePage = () => { ); }; -const Banner = styled.img` - width: 100%; - height: auto; -`; - const SectionWrapper = styled.section` padding: 0 20px; `; diff --git a/frontend/src/types/banner.ts b/frontend/src/types/banner.ts new file mode 100644 index 000000000..ae3ebad74 --- /dev/null +++ b/frontend/src/types/banner.ts @@ -0,0 +1,5 @@ +export interface Banner { + id: number; + link: string; + image: string; +} From c832bb43569a2cde2e2a9408064cd7da19147368 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?s=E1=B4=8F=CA=9F=CA=99=C9=AA=20=E2=98=94=EF=B8=8F?= Date: Thu, 19 Oct 2023 14:35:57 +0900 Subject: [PATCH 41/55] =?UTF-8?q?fix:=20=EA=BF=80=EC=A1=B0=ED=95=A9=20?= =?UTF-8?q?=EB=93=B1=EB=A1=9D=20=ED=86=A0=EC=8A=A4=ED=8A=B8=20=EC=9C=84?= =?UTF-8?q?=EC=B9=98=20=EC=9D=B4=EB=8F=99=20(#800)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- frontend/src/components/Recipe/RecipeFavorite/RecipeFavorite.tsx | 1 - .../components/Recipe/RecipeRegisterForm/RecipeRegisterForm.tsx | 1 + 2 files changed, 1 insertion(+), 1 deletion(-) diff --git a/frontend/src/components/Recipe/RecipeFavorite/RecipeFavorite.tsx b/frontend/src/components/Recipe/RecipeFavorite/RecipeFavorite.tsx index 8df4e9e13..703e209c6 100644 --- a/frontend/src/components/Recipe/RecipeFavorite/RecipeFavorite.tsx +++ b/frontend/src/components/Recipe/RecipeFavorite/RecipeFavorite.tsx @@ -27,7 +27,6 @@ const RecipeFavorite = ({ recipeId, favorite, favoriteCount }: RecipeFavoritePro onSuccess: () => { setIsFavorite((prev) => !prev); setCurrentFavoriteCount((prev) => (isFavorite ? prev - 1 : prev + 1)); - toast.success('🍯 꿀조합이 등록 됐어요'); }, onError: () => { toast.error('꿀조합 좋아요를 다시 시도해주세요.'); diff --git a/frontend/src/components/Recipe/RecipeRegisterForm/RecipeRegisterForm.tsx b/frontend/src/components/Recipe/RecipeRegisterForm/RecipeRegisterForm.tsx index 074d8f4f6..e92d894c5 100644 --- a/frontend/src/components/Recipe/RecipeRegisterForm/RecipeRegisterForm.tsx +++ b/frontend/src/components/Recipe/RecipeRegisterForm/RecipeRegisterForm.tsx @@ -49,6 +49,7 @@ const RecipeRegisterForm = ({ closeRecipeDialog }: RecipeRegisterFormProps) => { mutate(formData, { onSuccess: () => { resetAndCloseForm(); + toast.success('🍯 꿀조합이 등록 됐어요'); }, onError: (error) => { resetAndCloseForm(); From 08aeb2ff2b5f9d907e4e82af0ea106ff18298499 Mon Sep 17 00:00:00 2001 From: Taeeun Kim Date: Thu, 19 Oct 2023 14:38:56 +0900 Subject: [PATCH 42/55] =?UTF-8?q?[FE]=20fix:=20totalElements=EC=99=80=20co?= =?UTF-8?q?mments=20=EB=B3=80=EC=88=98=EB=A5=BC=20destructing=20=ED=95=98?= =?UTF-8?q?=EB=8A=94=20=EB=B0=A9=EB=B2=95=20=EC=88=98=EC=A0=95=20(#801)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- frontend/src/components/Recipe/CommentList/CommentList.tsx | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/frontend/src/components/Recipe/CommentList/CommentList.tsx b/frontend/src/components/Recipe/CommentList/CommentList.tsx index 0330c7059..d44f33c34 100644 --- a/frontend/src/components/Recipe/CommentList/CommentList.tsx +++ b/frontend/src/components/Recipe/CommentList/CommentList.tsx @@ -16,7 +16,8 @@ const CommentList = ({ recipeId }: CommentListProps) => { const { fetchNextPage, hasNextPage, data } = useInfiniteRecipeCommentQuery(Number(recipeId)); useIntersectionObserver(fetchNextPage, scrollRef, hasNextPage); - const [{ totalElements, comments }] = data.pages.flatMap((page) => page); + const [{ totalElements }] = data.pages.flatMap((page) => page); + const comments = data.pages.flatMap((page) => page.comments); return ( <> From 69c744c49b22e8b3c8042b3033437827c5d7f017 Mon Sep 17 00:00:00 2001 From: Leejin Yang Date: Thu, 19 Oct 2023 15:03:53 +0900 Subject: [PATCH 43/55] =?UTF-8?q?[FE]=20feat:=20=EA=BF=80=EC=A1=B0?= =?UTF-8?q?=ED=95=A9=20=EC=83=81=EC=84=B8=20=EC=9D=B4=EB=AF=B8=EC=A7=80=20?= =?UTF-8?q?=ED=81=AC=EA=B8=B0=20=EC=A1=B0=EC=A0=95=20(#805)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- frontend/src/mocks/data/recipeDetail.json | 2 +- frontend/src/pages/RecipeDetailPage.tsx | 7 +++++++ 2 files changed, 8 insertions(+), 1 deletion(-) diff --git a/frontend/src/mocks/data/recipeDetail.json b/frontend/src/mocks/data/recipeDetail.json index 41c5c1fce..a8ba54c3d 100644 --- a/frontend/src/mocks/data/recipeDetail.json +++ b/frontend/src/mocks/data/recipeDetail.json @@ -1,6 +1,6 @@ { "id": 3, - "images": [], + "images": ["https://github.com/woowacourse-teams/2023-fun-eat/assets/78616893/1f0fd418-131c-4cf8-b540-112d762b7c34"], "title": "초특급불닭콘치즈", "content": "맛있는 불닭콘치즈덮밥을 만들기 위해서는 1번. 어쩌고저쩌고 2번. 어쩌고저쩌고 3번 맛있는 불닭콘치즈덮밥을 만들기 위해서는 1번. 어쩌고저쩌고 2번. 어쩌고저쩌고\n\n 3번맛있는 불닭콘치즈덮밥을 만들기 위해서는 1번. 어쩌고저쩌고 2번. 어쩌고저쩌고 3번맛있는 불닭콘치즈덮밥을 만들기 위해서는 1번. 어쩌고저쩌고 2번. 어쩌고저쩌고 3번맛있는 불닭콘치즈덮밥을 만들기 위해서는 1번. 어쩌고저쩌고 2번. 어쩌고저쩌고 3번맛있는 불닭콘치즈덮밥을 만들기 위해서는 1번. 어쩌고저쩌고 2번. 어쩌고저쩌고 3번맛있는 불닭콘치즈덮밥을 만들기 위해서는 1번. 어쩌고저쩌고 2번. 어쩌고저쩌고 3번", "author": { diff --git a/frontend/src/pages/RecipeDetailPage.tsx b/frontend/src/pages/RecipeDetailPage.tsx index 73f430c51..1f98f5d9f 100644 --- a/frontend/src/pages/RecipeDetailPage.tsx +++ b/frontend/src/pages/RecipeDetailPage.tsx @@ -93,9 +93,16 @@ const RecipeImageContainer = styled.ul` flex-direction: column; gap: 20px; align-items: center; + + & > li { + width: 312px; + margin: 0 auto; + } `; const RecipeImage = styled.img` + width: 100%; + height: auto; border-radius: 10px; object-fit: cover; `; From 9e928b3d50b2a5ebd0c351fb665c4656a4a1290c Mon Sep 17 00:00:00 2001 From: Taeeun Kim Date: Thu, 19 Oct 2023 15:17:46 +0900 Subject: [PATCH 44/55] =?UTF-8?q?[FE]=20router=20=EB=A6=AC=ED=8C=A9?= =?UTF-8?q?=ED=84=B0=EB=A7=81=20(#806)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix: RecipeDetail 페이지를 AuthLayout아래로 이동 * style: 각각 Router 아래에 주석 작성 --- frontend/src/router/index.tsx | 35 ++++++++++++++++++++++++++--------- 1 file changed, 26 insertions(+), 9 deletions(-) diff --git a/frontend/src/router/index.tsx b/frontend/src/router/index.tsx index d24db3150..9f39193e0 100644 --- a/frontend/src/router/index.tsx +++ b/frontend/src/router/index.tsx @@ -8,6 +8,7 @@ import CategoryProvider from '@/contexts/CategoryContext'; import NotFoundPage from '@/pages/NotFoundPage'; const router = createBrowserRouter([ + /** 로그인이 안되었다면 로그인 페이지로 리다이렉트 */ { path: '/', element: ( @@ -53,6 +54,28 @@ const router = createBrowserRouter([ }, ], }, + /** 로그인이 안되었다면 로그인 페이지로 리다이렉트하면서 헤더만 있는 레이아웃 */ + { + path: '/', + element: ( + + + + ), + errorElement: , + children: [ + { + path: `${PATH.RECIPE}/:recipeId`, + async lazy() { + const { RecipeDetailPage } = await import( + /* webpackChunkName: "RecipeDetailPage" */ '@/pages/RecipeDetailPage' + ); + return { Component: RecipeDetailPage }; + }, + }, + ], + }, + /** 헤더와 네비게이션 바가 있는 기본 레이아웃 */ { path: '/', element: ( @@ -80,6 +103,7 @@ const router = createBrowserRouter([ }, ], }, + /** 헤더, 네비게이션 모두 없는 레이아웃 */ { path: '/', element: , @@ -101,6 +125,7 @@ const router = createBrowserRouter([ }, ], }, + /** 네비게이션 바 없이 헤더만 있는 레이아웃 */ { path: '/', element: ( @@ -119,17 +144,9 @@ const router = createBrowserRouter([ return { Component: ProductDetailPage }; }, }, - { - path: `${PATH.RECIPE}/:recipeId`, - async lazy() { - const { RecipeDetailPage } = await import( - /* webpackChunkName: "RecipeDetailPage" */ '@/pages/RecipeDetailPage' - ); - return { Component: RecipeDetailPage }; - }, - }, ], }, + /** 네비게이션과 헤더(검색 아이콘이 없는)가 있는 레이아웃 */ { path: '/', element: ( From c4bb04fefbfe5da1b3f9ba2cc26db208838172bc Mon Sep 17 00:00:00 2001 From: Hanuel Lee <91522259+hanueleee@users.noreply.github.com> Date: Thu, 19 Oct 2023 15:20:59 +0900 Subject: [PATCH 45/55] =?UTF-8?q?[BE]=20feat:=20=EB=A6=AC=EB=B7=B0=20?= =?UTF-8?q?=EC=82=AD=EC=A0=9C=EC=8B=9C=20=EC=83=81=ED=92=88=20=EC=A0=95?= =?UTF-8?q?=EB=B3=B4=20=EC=97=85=EB=8D=B0=EC=9D=B4=ED=8A=B8=20=EB=A1=9C?= =?UTF-8?q?=EC=A7=81=20=EC=B6=94=EA=B0=80=20(#803)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * refactor: 상품 상세 조회시 리뷰 데이터 조회 대신 상품 속 reviewCount 사용하도록 개선 * feat: 리뷰 삭제시 상품 정보 갱신 로직 추가 --- .../product/application/ProductService.java | 3 +- .../com/funeat/product/domain/Product.java | 28 +++++++++- .../funeat/product/dto/ProductResponse.java | 4 +- .../review/application/ReviewService.java | 10 +++- .../funeat/product/domain/ProductTest.java | 52 ++++++++----------- .../review/application/ReviewServiceTest.java | 29 +++++++---- 6 files changed, 79 insertions(+), 47 deletions(-) diff --git a/backend/src/main/java/com/funeat/product/application/ProductService.java b/backend/src/main/java/com/funeat/product/application/ProductService.java index b094d49e8..021ffe126 100644 --- a/backend/src/main/java/com/funeat/product/application/ProductService.java +++ b/backend/src/main/java/com/funeat/product/application/ProductService.java @@ -113,10 +113,9 @@ private boolean hasNextPage(final List findProducts) { public ProductResponse findProductDetail(final Long productId) { final Product product = productRepository.findById(productId) .orElseThrow(() -> new ProductNotFoundException(PRODUCT_NOT_FOUND, productId)); - final Long reviewCount = reviewRepository.countByProduct(product); final List tags = reviewTagRepository.findTop3TagsByReviewIn(productId, PageRequest.of(TOP, THREE)); - return ProductResponse.toResponse(product, reviewCount, tags); + return ProductResponse.toResponse(product, tags); } public RankingProductsResponse getTop3Products() { diff --git a/backend/src/main/java/com/funeat/product/domain/Product.java b/backend/src/main/java/com/funeat/product/domain/Product.java index b3dfa60a1..d5c3607c1 100644 --- a/backend/src/main/java/com/funeat/product/domain/Product.java +++ b/backend/src/main/java/com/funeat/product/domain/Product.java @@ -71,16 +71,36 @@ public Product(final String name, final Long price, final String image, final St this.category = category; this.reviewCount = reviewCount; } - + + public Product(final String name, final Long price, final String image, final String content, + final Double averageRating, final Category category, final Long reviewCount) { + this.name = name; + this.price = price; + this.image = image; + this.content = content; + this.averageRating = averageRating; + this.category = category; + this.reviewCount = reviewCount; + } + public static Product create(final String name, final Long price, final String content, final Category category) { return new Product(name, price, null, content, category); } - public void updateAverageRating(final Long rating, final Long count) { + public void updateAverageRatingForInsert(final Long count, final Long rating) { final double calculatedRating = ((count - 1) * averageRating + rating) / count; this.averageRating = Math.round(calculatedRating * 10.0) / 10.0; } + public void updateAverageRatingForDelete(final Long deletedRating) { + if (reviewCount == 1) { + this.averageRating = 0.0; + return; + } + final double calculatedRating = (reviewCount * averageRating - deletedRating) / (reviewCount - 1); + this.averageRating = Math.round(calculatedRating * 10.0) / 10.0; + } + public Double calculateRankingScore(final Long reviewCount) { final double exponent = -Math.log10(reviewCount + 1); final double factor = Math.pow(2, exponent); @@ -133,4 +153,8 @@ public Long getReviewCount() { public void addReviewCount() { reviewCount++; } + + public void minusReviewCount() { + reviewCount--; + } } diff --git a/backend/src/main/java/com/funeat/product/dto/ProductResponse.java b/backend/src/main/java/com/funeat/product/dto/ProductResponse.java index 49c5bba53..d3c0ed264 100644 --- a/backend/src/main/java/com/funeat/product/dto/ProductResponse.java +++ b/backend/src/main/java/com/funeat/product/dto/ProductResponse.java @@ -29,13 +29,13 @@ public ProductResponse(final Long id, final String name, final Long price, final this.tags = tags; } - public static ProductResponse toResponse(final Product product, final Long reviewCount, final List tags) { + public static ProductResponse toResponse(final Product product, final List tags) { List tagDtos = new ArrayList<>(); for (Tag tag : tags) { tagDtos.add(TagDto.toDto(tag)); } return new ProductResponse(product.getId(), product.getName(), product.getPrice(), product.getImage(), - product.getContent(), product.getAverageRating(), reviewCount, tagDtos); + product.getContent(), product.getAverageRating(), product.getReviewCount(), tagDtos); } public Long getId() { diff --git a/backend/src/main/java/com/funeat/review/application/ReviewService.java b/backend/src/main/java/com/funeat/review/application/ReviewService.java index 6bc8c4f42..142a8e6b5 100644 --- a/backend/src/main/java/com/funeat/review/application/ReviewService.java +++ b/backend/src/main/java/com/funeat/review/application/ReviewService.java @@ -111,7 +111,7 @@ public void create(final Long productId, final Long memberId, final MultipartFil final Long countByProduct = reviewRepository.countByProduct(findProduct); - findProduct.updateAverageRating(savedReview.getRating(), countByProduct); + findProduct.updateAverageRatingForInsert(countByProduct, savedReview.getRating()); findProduct.addReviewCount(); reviewTagRepository.saveAll(reviewTags); } @@ -247,13 +247,19 @@ public void deleteReview(final Long reviewId, final Long memberId) { if (review.checkAuthor(member)) { eventPublisher.publishEvent(new ReviewDeleteEvent(image)); + updateProduct(product, review.getRating()); deleteThingsRelatedToReview(review); - updateProductImage(product.getId()); return; } throw new NotAuthorOfReviewException(NOT_AUTHOR_OF_REVIEW, memberId); } + private void updateProduct(final Product product, final Long deletedRating) { + updateProductImage(product.getId()); + product.updateAverageRatingForDelete(deletedRating); + product.minusReviewCount(); + } + private void deleteThingsRelatedToReview(final Review review) { deleteReviewTags(review); deleteReviewFavorites(review); diff --git a/backend/src/test/java/com/funeat/product/domain/ProductTest.java b/backend/src/test/java/com/funeat/product/domain/ProductTest.java index ddfeaa2c7..7e012dd22 100644 --- a/backend/src/test/java/com/funeat/product/domain/ProductTest.java +++ b/backend/src/test/java/com/funeat/product/domain/ProductTest.java @@ -1,7 +1,6 @@ package com.funeat.product.domain; import static org.assertj.core.api.Assertions.assertThat; -import static org.assertj.core.api.Assertions.assertThatThrownBy; import static org.assertj.core.api.SoftAssertions.assertSoftly; import org.junit.jupiter.api.DisplayNameGeneration; @@ -14,17 +13,17 @@ class ProductTest { @Nested - class updateAverageRating_성공_테스트 { + class updateAverageRatingForInsert_성공_테스트 { @Test void 평균_평점을_업데이트_할_수_있다() { // given final var product = new Product("testName", 1000L, "testImage", "testContent", null); - final var reviewRating = 4L; final var reviewCount = 1L; + final var reviewRating = 4L; // when - product.updateAverageRating(reviewRating, reviewCount); + product.updateAverageRatingForInsert(reviewCount, reviewRating); final var actual = product.getAverageRating(); // then @@ -35,16 +34,16 @@ class updateAverageRating_성공_테스트 { void 평균_평점을_여러번_업데이트_할_수_있다() { // given final var product = new Product("testName", 1000L, "testImage", "testContent", null); - final var reviewRating1 = 4L; - final var reviewRating2 = 2L; final var reviewCount1 = 1L; final var reviewCount2 = 2L; + final var reviewRating1 = 4L; + final var reviewRating2 = 2L; // when - product.updateAverageRating(reviewRating1, reviewCount1); + product.updateAverageRatingForInsert(reviewCount1, reviewRating1); final var actual1 = product.getAverageRating(); - product.updateAverageRating(reviewRating2, reviewCount2); + product.updateAverageRatingForInsert(reviewCount2, reviewRating2); final var actual2 = product.getAverageRating(); // then @@ -58,39 +57,34 @@ class updateAverageRating_성공_테스트 { } @Nested - class updateAverageRating_실패_테스트 { + class updateAverageRatingForDelete_성공_테스트 { @Test - void 리뷰_평점에_null_값이_들어오면_예외가_발생한다() { + void 리뷰가_하나인_상품의_리뷰를_삭제하면_평균평점은_0점이_된다() { // given - final var product = new Product("testName", 1000L, "testImage", "testContent", null); - final var reviewCount = 1L; + final var product = new Product("testName", 1000L, "testImage", "testContent", 4.0, null, 1L); + final var reviewRating = 4L; // when - assertThatThrownBy(() -> product.updateAverageRating(null, reviewCount)) - .isInstanceOf(NullPointerException.class); - } - - @Test - void 리뷰_평점이_0점이라면_예외가_발생해야하는데_관련_로직이_없어_통과하고_있다() { - // given - final var product = new Product("testName", 1000L, "testImage", "testContent", null); - final var reviewRating = 0L; - final var reviewCount = 1L; + product.updateAverageRatingForDelete(reviewRating); + final var actual = product.getAverageRating(); - // when - product.updateAverageRating(reviewRating, reviewCount); + // then + assertThat(actual).isEqualTo(0.0); } @Test - void 리뷰_개수가_0개라면_예외가_발생해야하는데_calculatedRating값이_infinity가_나와_통과하고_있다() { + void 리뷰가_여러개인_상품의_리뷰를_삭제하면_평균평점이_갱신된다() { // given - final var product = new Product("testName", 1000L, "testImage", "testContent", null); - final var reviewRating = 3L; - final var reviewCount = 0L; + final var product = new Product("testName", 1000L, "testImage", "testContent", 4.0, null, 4L); + final var reviewRating = 5L; // when - product.updateAverageRating(reviewRating, reviewCount); + product.updateAverageRatingForDelete(reviewRating); + final var actual = product.getAverageRating(); + + // then + assertThat(actual).isEqualTo(3.7); } } diff --git a/backend/src/test/java/com/funeat/review/application/ReviewServiceTest.java b/backend/src/test/java/com/funeat/review/application/ReviewServiceTest.java index 7d35ab166..93fd0e046 100644 --- a/backend/src/test/java/com/funeat/review/application/ReviewServiceTest.java +++ b/backend/src/test/java/com/funeat/review/application/ReviewServiceTest.java @@ -800,28 +800,37 @@ class deleteReview_성공_테스트 { final var tagIds = 태그_아이디_변환(tag1, tag2); final var image = 이미지_생성(); - final var reviewCreateRequest = 리뷰추가요청_재구매O_생성(4L, tagIds); - reviewService.create(productId, authorId, image, reviewCreateRequest); + final var reviewCreateRequest1 = 리뷰추가요청_재구매O_생성(2L, tagIds); + final var reviewCreateRequest2 = 리뷰추가요청_재구매O_생성(4L, tagIds); - final var review = reviewRepository.findAll().get(0); - final var reviewId = review.getId(); + reviewService.create(productId, authorId, image, reviewCreateRequest1); + reviewService.create(productId, authorId, image, reviewCreateRequest2); + + final var reviews = reviewRepository.findAll(); + final var rating2_review = reviews.stream() + .filter(it -> it.getRating() == 2L) + .findFirst() + .get(); final var favoriteRequest = 리뷰좋아요요청_생성(true); - reviewService.likeReview(reviewId, authorId, favoriteRequest); - reviewService.likeReview(reviewId, memberId, favoriteRequest); + reviewService.likeReview(rating2_review.getId(), authorId, favoriteRequest); + reviewService.likeReview(rating2_review.getId(), memberId, favoriteRequest); // when - reviewService.deleteReview(reviewId, authorId); + reviewService.deleteReview(rating2_review.getId(), authorId); // then - final var tags = reviewTagRepository.findAll(); - final var favorites = reviewFavoriteRepository.findAll(); - final var findReview = reviewRepository.findById(reviewId); + final var tags = reviewTagRepository.findByReview(rating2_review); + final var favorites = reviewFavoriteRepository.findByReview(rating2_review); + final var findReview = reviewRepository.findById(rating2_review.getId()); + final var findProduct = productRepository.findById(productId).get(); assertSoftly(soft -> { soft.assertThat(tags).isEmpty(); soft.assertThat(favorites).isEmpty(); soft.assertThat(findReview).isEmpty(); + soft.assertThat(findProduct.getAverageRating()).isEqualTo(4.0); + soft.assertThat(findProduct.getReviewCount()).isEqualTo(1); }); } } From 800f563775bad2bfd9c176fd57b99d418a50c9fb Mon Sep 17 00:00:00 2001 From: Dabeen Jeong Date: Thu, 19 Oct 2023 15:35:11 +0900 Subject: [PATCH 46/55] =?UTF-8?q?[BE]=20fix:=20=EC=A2=8B=EC=95=84=EC=9A=94?= =?UTF-8?q?=20=EC=B7=A8=EC=86=8C=EB=8F=84=20=EC=A2=8B=EC=95=84=EC=9A=94?= =?UTF-8?q?=EB=A5=BC=20=EB=88=84=EB=A5=B4=EC=A7=80=20=EC=95=8A=EC=9D=80=20?= =?UTF-8?q?=EA=B2=83=EC=9C=BC=EB=A1=9C=20=EC=84=A4=EC=A0=95=20(#808)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../java/com/funeat/review/dto/SortingReviewDtoWithoutTag.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/backend/src/main/java/com/funeat/review/dto/SortingReviewDtoWithoutTag.java b/backend/src/main/java/com/funeat/review/dto/SortingReviewDtoWithoutTag.java index 287750e7f..b58ac7165 100644 --- a/backend/src/main/java/com/funeat/review/dto/SortingReviewDtoWithoutTag.java +++ b/backend/src/main/java/com/funeat/review/dto/SortingReviewDtoWithoutTag.java @@ -39,7 +39,7 @@ private static Boolean checkingFavorite(final Boolean favorite) { if (Objects.isNull(favorite)) { return Boolean.FALSE; } - return Boolean.TRUE; + return favorite; } public Long getId() { From 48a20c9a1bfdb2df8d2fb080931a5d4ffaa6564a Mon Sep 17 00:00:00 2001 From: Hanuel Lee <91522259+hanueleee@users.noreply.github.com> Date: Thu, 19 Oct 2023 17:01:57 +0900 Subject: [PATCH 47/55] =?UTF-8?q?fix:=20=EB=A6=AC=EB=B7=B0=20=EC=82=AD?= =?UTF-8?q?=EC=A0=9C=EC=8B=9C=20=EC=8D=B8=EB=84=A4=EC=9D=BC=20=EC=97=85?= =?UTF-8?q?=EB=8D=B0=EC=9D=B4=ED=8A=B8=20=EB=A1=9C=EC=A7=81=20=EC=88=9C?= =?UTF-8?q?=EC=84=9C=20=EB=B3=80=EA=B2=BD=20(#815)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../funeat/review/application/ReviewService.java | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/backend/src/main/java/com/funeat/review/application/ReviewService.java b/backend/src/main/java/com/funeat/review/application/ReviewService.java index 142a8e6b5..f492e90bb 100644 --- a/backend/src/main/java/com/funeat/review/application/ReviewService.java +++ b/backend/src/main/java/com/funeat/review/application/ReviewService.java @@ -247,19 +247,13 @@ public void deleteReview(final Long reviewId, final Long memberId) { if (review.checkAuthor(member)) { eventPublisher.publishEvent(new ReviewDeleteEvent(image)); - updateProduct(product, review.getRating()); deleteThingsRelatedToReview(review); + updateProduct(product, review.getRating()); return; } throw new NotAuthorOfReviewException(NOT_AUTHOR_OF_REVIEW, memberId); } - private void updateProduct(final Product product, final Long deletedRating) { - updateProductImage(product.getId()); - product.updateAverageRatingForDelete(deletedRating); - product.minusReviewCount(); - } - private void deleteThingsRelatedToReview(final Review review) { deleteReviewTags(review); deleteReviewFavorites(review); @@ -282,6 +276,12 @@ private void deleteReviewFavorites(final Review review) { reviewFavoriteRepository.deleteAllByIdInBatch(ids); } + private void updateProduct(final Product product, final Long deletedRating) { + product.updateAverageRatingForDelete(deletedRating); + product.minusReviewCount(); + updateProductImage(product.getId()); + } + public Optional getMostFavoriteReview(final Long productId) { final Product findProduct = productRepository.findById(productId) .orElseThrow(() -> new ProductNotFoundException(PRODUCT_NOT_FOUND, productId)); From a51d2b7ec2b575dc86165191e899793850c986ee Mon Sep 17 00:00:00 2001 From: Leejin Yang Date: Thu, 19 Oct 2023 17:33:34 +0900 Subject: [PATCH 48/55] =?UTF-8?q?[FE]=20feat:=20=EA=BF=80=EC=A1=B0?= =?UTF-8?q?=ED=95=A9=20=ED=8E=98=EC=9D=B4=EC=A7=80=20=EC=A0=91=EC=86=8D=20?= =?UTF-8?q?=EC=8B=9C=20=EC=9D=B4=EC=A0=84=20=ED=8E=98=EC=9D=B4=EC=A7=80=20?= =?UTF-8?q?=EB=A1=9C=EC=BB=AC=EC=8A=A4=ED=86=A0=EB=A6=AC=EC=A7=80=EC=97=90?= =?UTF-8?q?=20=EA=B2=BD=EB=A1=9C=20=EC=A0=80=EC=9E=A5=20(#814)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- frontend/src/constants/index.ts | 2 +- frontend/src/mocks/handlers/recipeHandlers.ts | 6 ++++++ frontend/src/pages/AuthPage.tsx | 8 ++++---- frontend/src/pages/ProductDetailPage.tsx | 4 ++-- frontend/src/pages/RecipePage.tsx | 12 +++++++++--- 5 files changed, 22 insertions(+), 10 deletions(-) diff --git a/frontend/src/constants/index.ts b/frontend/src/constants/index.ts index 22c296123..8865b2802 100644 --- a/frontend/src/constants/index.ts +++ b/frontend/src/constants/index.ts @@ -72,4 +72,4 @@ export const ENVIRONMENT = window.location.href.includes('dev') export const IMAGE_URL = ENVIRONMENT === 'dev' ? process.env.S3_DEV_CLOUDFRONT_PATH : process.env.S3_PROD_CLOUDFRONT_PATH; -export const PRODUCT_PATH_LOCAL_STORAGE_KEY = `funeat-last-product-path-${ENVIRONMENT}`; +export const PREVIOUS_PATH_LOCAL_STORAGE_KEY = `funeat-previous-path-${ENVIRONMENT}`; diff --git a/frontend/src/mocks/handlers/recipeHandlers.ts b/frontend/src/mocks/handlers/recipeHandlers.ts index b213e5926..fff47a879 100644 --- a/frontend/src/mocks/handlers/recipeHandlers.ts +++ b/frontend/src/mocks/handlers/recipeHandlers.ts @@ -7,6 +7,12 @@ import mockRecipes from '../data/recipes.json'; export const recipeHandlers = [ rest.get('/api/recipes/:recipeId', (req, res, ctx) => { + const { mockSessionId } = req.cookies; + + if (!mockSessionId) { + return res(ctx.status(401)); + } + return res(ctx.status(200), ctx.json(recipeDetail), ctx.delay(1000)); }), diff --git a/frontend/src/pages/AuthPage.tsx b/frontend/src/pages/AuthPage.tsx index 579bca327..9f7974e45 100644 --- a/frontend/src/pages/AuthPage.tsx +++ b/frontend/src/pages/AuthPage.tsx @@ -2,7 +2,7 @@ import { useEffect, useState } from 'react'; import { Navigate, useNavigate, useParams, useSearchParams } from 'react-router-dom'; import { loginApi } from '@/apis'; -import { PRODUCT_PATH_LOCAL_STORAGE_KEY } from '@/constants'; +import { PREVIOUS_PATH_LOCAL_STORAGE_KEY } from '@/constants'; import { PATH } from '@/constants/path'; import { useMemberQuery } from '@/hooks/queries/members'; import { getLocalStorage, removeLocalStorage } from '@/utils/localStorage'; @@ -49,11 +49,11 @@ export const AuthPage = () => { return; } - const productPath = getLocalStorage(PRODUCT_PATH_LOCAL_STORAGE_KEY); - const redirectLocation = productPath ? productPath : location; + const previousPath = getLocalStorage(PREVIOUS_PATH_LOCAL_STORAGE_KEY); + const redirectLocation = previousPath ? previousPath : location; navigate(redirectLocation, { replace: true }); - removeLocalStorage(PRODUCT_PATH_LOCAL_STORAGE_KEY); + removeLocalStorage(PREVIOUS_PATH_LOCAL_STORAGE_KEY); refetchMember(); }, [location]); diff --git a/frontend/src/pages/ProductDetailPage.tsx b/frontend/src/pages/ProductDetailPage.tsx index 6471af213..5b6bbca20 100644 --- a/frontend/src/pages/ProductDetailPage.tsx +++ b/frontend/src/pages/ProductDetailPage.tsx @@ -17,7 +17,7 @@ import { } from '@/components/Common'; import { ProductDetailItem, ProductRecipeList } from '@/components/Product'; import { BestReviewItem, ReviewList, ReviewRegisterForm } from '@/components/Review'; -import { PRODUCT_PATH_LOCAL_STORAGE_KEY, RECIPE_SORT_OPTIONS, REVIEW_SORT_OPTIONS } from '@/constants'; +import { PREVIOUS_PATH_LOCAL_STORAGE_KEY, RECIPE_SORT_OPTIONS, REVIEW_SORT_OPTIONS } from '@/constants'; import { PATH } from '@/constants/path'; import ReviewFormProvider from '@/contexts/ReviewFormContext'; import { useGA, useSortOption, useTabMenu } from '@/hooks/common'; @@ -78,7 +78,7 @@ export const ProductDetailPage = () => { }; const handleLoginButtonClick = () => { - setLocalStorage(PRODUCT_PATH_LOCAL_STORAGE_KEY, pathname); + setLocalStorage(PREVIOUS_PATH_LOCAL_STORAGE_KEY, pathname); navigate(PATH.LOGIN); }; diff --git a/frontend/src/pages/RecipePage.tsx b/frontend/src/pages/RecipePage.tsx index bd8b9453e..fb7899a1b 100644 --- a/frontend/src/pages/RecipePage.tsx +++ b/frontend/src/pages/RecipePage.tsx @@ -1,7 +1,7 @@ import { BottomSheet, Heading, Link, Spacing, useBottomSheet } from '@fun-eat/design-system'; import { useQueryErrorResetBoundary } from '@tanstack/react-query'; -import { Suspense, useRef, useState } from 'react'; -import { Link as RouterLink } from 'react-router-dom'; +import { Suspense, useEffect, useRef, useState } from 'react'; +import { Link as RouterLink, useLocation } from 'react-router-dom'; import styled from 'styled-components'; import { @@ -15,10 +15,11 @@ import { SvgIcon, } from '@/components/Common'; import { RecipeList, RecipeRegisterForm } from '@/components/Recipe'; -import { RECIPE_SORT_OPTIONS } from '@/constants'; +import { PREVIOUS_PATH_LOCAL_STORAGE_KEY, RECIPE_SORT_OPTIONS } from '@/constants'; import { PATH } from '@/constants/path'; import RecipeFormProvider from '@/contexts/RecipeFormContext'; import { useGA, useSortOption } from '@/hooks/common'; +import { setLocalStorage } from '@/utils/localStorage'; const RECIPE_PAGE_TITLE = '🍯 꿀조합'; const REGISTER_RECIPE = '꿀조합 작성하기'; @@ -30,9 +31,14 @@ export const RecipePage = () => { const { isOpen, isClosing, handleOpenBottomSheet, handleCloseBottomSheet } = useBottomSheet(); const { reset } = useQueryErrorResetBoundary(); const { gaEvent } = useGA(); + const { pathname } = useLocation(); const recipeRef = useRef(null); + useEffect(() => { + setLocalStorage(PREVIOUS_PATH_LOCAL_STORAGE_KEY, pathname); + }, []); + const handleOpenRegisterRecipeSheet = () => { setActiveSheet('registerRecipe'); handleOpenBottomSheet(); From 7575a5c1389eb197f7f3a37c0b1d711c14b036c3 Mon Sep 17 00:00:00 2001 From: Hanuel Lee <91522259+hanueleee@users.noreply.github.com> Date: Thu, 19 Oct 2023 19:42:35 +0900 Subject: [PATCH 49/55] =?UTF-8?q?[BE]=20fix:=20=EC=83=81=ED=92=88=EC=97=90?= =?UTF-8?q?=20=EC=9D=B4=EB=AF=B8=EC=A7=80=EA=B0=80=20=EC=9E=88=EB=8A=94=20?= =?UTF-8?q?=EB=A6=AC=EB=B7=B0=EA=B0=80=20=EC=97=86=EC=9D=84=20=EA=B2=BD?= =?UTF-8?q?=EC=9A=B0=20=EA=B8=B0=EB=B3=B8=20=EC=9D=B4=EB=AF=B8=EC=A7=80?= =?UTF-8?q?=EB=A1=9C=20=EC=84=A4=EC=A0=95=20(#822)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat: 설정파일에 기본 이미지 경로 추가 * fix: 상품에 사진이 있는 리뷰가 하나도 없을 경우 기본 이미지 설정 * refactor: 설정파일에서 actuator 포트 옵션 제거 --- .../com/funeat/product/domain/Category.java | 4 ++++ .../com/funeat/product/domain/Product.java | 19 +++++++++++++++++-- .../review/application/ReviewService.java | 8 +++++--- .../src/main/resources/application-dev.yml | 5 +++-- .../src/main/resources/application-local.yml | 7 +++++-- .../src/main/resources/application-prod.yml | 5 +++-- backend/src/test/resources/application.yml | 3 +++ 7 files changed, 40 insertions(+), 11 deletions(-) diff --git a/backend/src/main/java/com/funeat/product/domain/Category.java b/backend/src/main/java/com/funeat/product/domain/Category.java index 7702a087e..7504aad1d 100644 --- a/backend/src/main/java/com/funeat/product/domain/Category.java +++ b/backend/src/main/java/com/funeat/product/domain/Category.java @@ -30,6 +30,10 @@ public Category(final String name, final CategoryType type, final String image) this.image = image; } + public boolean isFood() { + return type == CategoryType.FOOD; + } + public Long getId() { return id; } diff --git a/backend/src/main/java/com/funeat/product/domain/Product.java b/backend/src/main/java/com/funeat/product/domain/Product.java index d5c3607c1..818217d34 100644 --- a/backend/src/main/java/com/funeat/product/domain/Product.java +++ b/backend/src/main/java/com/funeat/product/domain/Product.java @@ -10,6 +10,7 @@ import javax.persistence.JoinColumn; import javax.persistence.ManyToOne; import javax.persistence.OneToMany; +import org.springframework.beans.factory.annotation.Value; @Entity public class Product { @@ -28,6 +29,8 @@ public class Product { private Double averageRating = 0.0; + private Long reviewCount = 0L; + @ManyToOne(fetch = FetchType.LAZY) @JoinColumn(name = "category_id") private Category category; @@ -38,7 +41,11 @@ public class Product { @OneToMany(mappedBy = "product") private List productRecipes; - private Long reviewCount = 0L; + @Value("${cloud.aws.image.food}") + private String basicFoodImage; + + @Value("${cloud.aws.image.store}") + private String basicStoreImage; protected Product() { } @@ -107,7 +114,15 @@ public Double calculateRankingScore(final Long reviewCount) { return averageRating - (averageRating - 3.0) * factor; } - public void updateImage(final String topFavoriteImage) { + public void updateBasicImage() { + if (category.isFood()) { + this.image = basicFoodImage; + return; + } + this.image = basicStoreImage; + } + + public void updateFavoriteImage(final String topFavoriteImage) { this.image = topFavoriteImage; } diff --git a/backend/src/main/java/com/funeat/review/application/ReviewService.java b/backend/src/main/java/com/funeat/review/application/ReviewService.java index f492e90bb..a65922c82 100644 --- a/backend/src/main/java/com/funeat/review/application/ReviewService.java +++ b/backend/src/main/java/com/funeat/review/application/ReviewService.java @@ -146,10 +146,12 @@ public void updateProductImage(final Long productId) { final PageRequest pageRequest = PageRequest.of(FIRST_PAGE, ONE); final List topFavoriteReview = reviewRepository.findPopularReviewWithImage(productId, pageRequest); - if (!topFavoriteReview.isEmpty()) { - final String topFavoriteReviewImage = topFavoriteReview.get(START_INDEX).getImage(); - product.updateImage(topFavoriteReviewImage); + if (topFavoriteReview.isEmpty()) { + product.updateBasicImage(); + return; } + final String topFavoriteReviewImage = topFavoriteReview.get(START_INDEX).getImage(); + product.updateFavoriteImage(topFavoriteReviewImage); } public SortingReviewsResponse sortingReviews(final Long productId, final Long memberId, diff --git a/backend/src/main/resources/application-dev.yml b/backend/src/main/resources/application-dev.yml index dfa2fd258..2eafb5528 100644 --- a/backend/src/main/resources/application-dev.yml +++ b/backend/src/main/resources/application-dev.yml @@ -35,8 +35,6 @@ management: enabled: true prometheus: enabled: true - server: - port: { SERVER_PORT } cloud: aws: @@ -46,3 +44,6 @@ cloud: bucket: { S3_BUCKET } folder: { S3_DEV_FOLDER } cloudfrontPath: { S3_DEV_CLOUDFRONT_PATH } + image: + food: { DEV_BASIC_FOOD_IMAGE } + store: { DEV_BASIC_STORE_IMAGE } diff --git a/backend/src/main/resources/application-local.yml b/backend/src/main/resources/application-local.yml index dd4ef8afb..3bab01cff 100644 --- a/backend/src/main/resources/application-local.yml +++ b/backend/src/main/resources/application-local.yml @@ -27,5 +27,8 @@ cloud: static: { S3_REGION } s3: bucket: { S3_BUCKET } - folder: { S3_DEV_FOLDER } - cloudfrontPath: { S3_DEV_CLOUDFRONT_PATH } + folder: { S3_LOCAL_FOLDER } + cloudfrontPath: { S3_LOCAL_CLOUDFRONT_PATH } + image: + food: { LOCAL_BASIC_FOOD_IMAGE } + store: { LOCAL_BASIC_STORE_IMAGE } diff --git a/backend/src/main/resources/application-prod.yml b/backend/src/main/resources/application-prod.yml index 5dbe5844c..339749bf4 100644 --- a/backend/src/main/resources/application-prod.yml +++ b/backend/src/main/resources/application-prod.yml @@ -34,8 +34,6 @@ management: enabled: true prometheus: enabled: true - server: - port: { SERVER_PORT } cloud: aws: @@ -45,3 +43,6 @@ cloud: bucket: { S3_BUCKET } folder: { S3_PROD_FOLDER } cloudfrontPath: { S3_PROD_CLOUDFRONT_PATH } + image: + food: { PROD_BASIC_FOOD_IMAGE } + store: { PROD_BASIC_STORE_IMAGE } diff --git a/backend/src/test/resources/application.yml b/backend/src/test/resources/application.yml index 44ef68eb7..827afa5d8 100644 --- a/backend/src/test/resources/application.yml +++ b/backend/src/test/resources/application.yml @@ -30,6 +30,9 @@ cloud: bucket: testBucket folder: testFolder cloudfrontPath: testCloudfrontPath + image: + food: foodimage + store: storeimage back-office: id: test From e214528724e569718b0e120f10340c9990d9319f Mon Sep 17 00:00:00 2001 From: JFe <33208246+Go-Jaecheol@users.noreply.github.com> Date: Thu, 19 Oct 2023 19:53:39 +0900 Subject: [PATCH 50/55] =?UTF-8?q?[BE]=20refactor:=20=EB=A6=AC=EB=B7=B0=20?= =?UTF-8?q?=EB=B0=8F=20=EA=BF=80=EC=A1=B0=ED=95=A9=20=EB=9E=AD=ED=82=B9=20?= =?UTF-8?q?Response=20=EC=88=98=EC=A0=95=20(#821)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * refactor: RankingReviewDto에 createdAt 추가 * refactor: RankingRecipeDto에 createdAt 추가 --- .../com/funeat/recipe/dto/RankingRecipeDto.java | 14 +++++++++++--- .../com/funeat/review/dto/RankingReviewDto.java | 13 ++++++++++--- 2 files changed, 21 insertions(+), 6 deletions(-) diff --git a/backend/src/main/java/com/funeat/recipe/dto/RankingRecipeDto.java b/backend/src/main/java/com/funeat/recipe/dto/RankingRecipeDto.java index c6fdcfc7c..3f26a69ca 100644 --- a/backend/src/main/java/com/funeat/recipe/dto/RankingRecipeDto.java +++ b/backend/src/main/java/com/funeat/recipe/dto/RankingRecipeDto.java @@ -2,6 +2,7 @@ import com.funeat.recipe.domain.Recipe; import com.funeat.recipe.domain.RecipeImage; +import java.time.LocalDateTime; import java.util.List; public class RankingRecipeDto { @@ -11,23 +12,26 @@ public class RankingRecipeDto { private final String title; private final RecipeAuthorDto author; private final Long favoriteCount; + private final LocalDateTime createdAt; public RankingRecipeDto(final Long id, final String image, final String title, final RecipeAuthorDto author, - final Long favoriteCount) { + final Long favoriteCount, final LocalDateTime createdAt) { this.id = id; this.image = image; this.title = title; this.author = author; this.favoriteCount = favoriteCount; + this.createdAt = createdAt; } public static RankingRecipeDto toDto(final Recipe recipe, final List images, final RecipeAuthorDto author) { if (images.isEmpty()) { - return new RankingRecipeDto(recipe.getId(), null, recipe.getTitle(), author, recipe.getFavoriteCount()); + return new RankingRecipeDto(recipe.getId(), null, recipe.getTitle(), author, + recipe.getFavoriteCount(), recipe.getCreatedAt()); } return new RankingRecipeDto(recipe.getId(), images.get(0).getImage(), recipe.getTitle(), author, - recipe.getFavoriteCount()); + recipe.getFavoriteCount(), recipe.getCreatedAt()); } public Long getId() { @@ -49,4 +53,8 @@ public RecipeAuthorDto getAuthor() { public Long getFavoriteCount() { return favoriteCount; } + + public LocalDateTime getCreatedAt() { + return createdAt; + } } diff --git a/backend/src/main/java/com/funeat/review/dto/RankingReviewDto.java b/backend/src/main/java/com/funeat/review/dto/RankingReviewDto.java index cecbe2cec..5ac70bb59 100644 --- a/backend/src/main/java/com/funeat/review/dto/RankingReviewDto.java +++ b/backend/src/main/java/com/funeat/review/dto/RankingReviewDto.java @@ -1,6 +1,7 @@ package com.funeat.review.dto; import com.funeat.review.domain.Review; +import java.time.LocalDateTime; public class RankingReviewDto { @@ -11,10 +12,11 @@ public class RankingReviewDto { private final String content; private final Long rating; private final Long favoriteCount; + private final LocalDateTime createdAt; private RankingReviewDto(final Long reviewId, final Long productId, final String categoryType, final String productName, final String content, - final Long rating, final Long favoriteCount) { + final Long rating, final Long favoriteCount, final LocalDateTime createdAt) { this.reviewId = reviewId; this.productId = productId; this.categoryType = categoryType; @@ -22,6 +24,7 @@ private RankingReviewDto(final Long reviewId, final Long productId, final String this.content = content; this.rating = rating; this.favoriteCount = favoriteCount; + this.createdAt = createdAt; } public static RankingReviewDto toDto(final Review review) { @@ -32,8 +35,8 @@ public static RankingReviewDto toDto(final Review review) { review.getProduct().getName(), review.getContent(), review.getRating(), - review.getFavoriteCount() - ); + review.getFavoriteCount(), + review.getCreatedAt()); } public Long getReviewId() { @@ -63,4 +66,8 @@ public Long getFavoriteCount() { public String getCategoryType() { return categoryType; } + + public LocalDateTime getCreatedAt() { + return createdAt; + } } From 114abf1c23540ef42ae8e09efc907a0b71547beb Mon Sep 17 00:00:00 2001 From: Dabeen Jeong Date: Thu, 19 Oct 2023 19:56:53 +0900 Subject: [PATCH 51/55] =?UTF-8?q?[BE]=20refactor:=20=ED=94=84=EB=A1=9C?= =?UTF-8?q?=EB=8D=95=EC=85=98=20=EB=A1=9C=EA=B7=B8=EC=9D=B8=20=EC=BF=A0?= =?UTF-8?q?=ED=82=A4=20=EC=9D=B4=EB=A6=84=EA=B3=BC=20=ED=85=8C=EC=8A=A4?= =?UTF-8?q?=ED=8A=B8=20=EB=A1=9C=EA=B7=B8=EC=9D=B8=20=EC=BF=A0=ED=82=A4=20?= =?UTF-8?q?=EC=9D=B4=EB=A6=84=20=ED=86=B5=EC=9D=BC=20(#819)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../java/com/funeat/acceptance/auth/LoginSteps.java | 4 ++-- .../java/com/funeat/acceptance/member/MemberSteps.java | 10 +++++----- .../java/com/funeat/acceptance/recipe/RecipeSteps.java | 10 +++++----- .../java/com/funeat/acceptance/review/ReviewSteps.java | 6 +++--- backend/src/test/resources/application.yml | 6 ++++++ 5 files changed, 21 insertions(+), 15 deletions(-) diff --git a/backend/src/test/java/com/funeat/acceptance/auth/LoginSteps.java b/backend/src/test/java/com/funeat/acceptance/auth/LoginSteps.java index 21dd1bf77..92d2f48c7 100644 --- a/backend/src/test/java/com/funeat/acceptance/auth/LoginSteps.java +++ b/backend/src/test/java/com/funeat/acceptance/auth/LoginSteps.java @@ -29,7 +29,7 @@ public class LoginSteps { public static ExtractableResponse 로그아웃_요청(final String loginCookie) { return given() - .cookie("JSESSIONID", loginCookie) + .cookie("SESSION", loginCookie) .when() .post("/api/logout") .then() @@ -44,6 +44,6 @@ public class LoginSteps { .then() .extract() .response() - .getCookie("JSESSIONID"); + .getCookie("SESSION"); } } diff --git a/backend/src/test/java/com/funeat/acceptance/member/MemberSteps.java b/backend/src/test/java/com/funeat/acceptance/member/MemberSteps.java index 681efb26a..98cf4d6fa 100644 --- a/backend/src/test/java/com/funeat/acceptance/member/MemberSteps.java +++ b/backend/src/test/java/com/funeat/acceptance/member/MemberSteps.java @@ -13,7 +13,7 @@ public class MemberSteps { public static ExtractableResponse 사용자_정보_조회_요청(final String loginCookie) { return given() - .cookie("JSESSIONID", loginCookie) + .cookie("SESSION", loginCookie) .when() .get("/api/members") .then() @@ -24,7 +24,7 @@ public class MemberSteps { final MultiPartSpecification image, final MemberRequest request) { final var requestSpec = given() - .cookie("JSESSIONID", loginCookie); + .cookie("SESSION", loginCookie); if (Objects.nonNull(image)) { requestSpec.multiPart(image); @@ -43,7 +43,7 @@ public class MemberSteps { final Long page) { return given() .when() - .cookie("JSESSIONID", loginCookie) + .cookie("SESSION", loginCookie) .queryParam("sort", sort) .queryParam("page", page) .get("/api/members/reviews") @@ -55,7 +55,7 @@ public class MemberSteps { final Long page) { return given() .when() - .cookie("JSESSIONID", loginCookie) + .cookie("SESSION", loginCookie) .queryParam("sort", sort) .queryParam("page", page) .get("/api/members/recipes") @@ -65,7 +65,7 @@ public class MemberSteps { public static ExtractableResponse 리뷰_삭제_요청(final String loginCookie, final Long reviewId) { return given() - .cookie("JSESSIONID", loginCookie) + .cookie("SESSION", loginCookie) .when() .delete("/api/members/reviews/{reviewId}", reviewId) .then() diff --git a/backend/src/test/java/com/funeat/acceptance/recipe/RecipeSteps.java b/backend/src/test/java/com/funeat/acceptance/recipe/RecipeSteps.java index 6ebc08ba5..8d68fe7e9 100644 --- a/backend/src/test/java/com/funeat/acceptance/recipe/RecipeSteps.java +++ b/backend/src/test/java/com/funeat/acceptance/recipe/RecipeSteps.java @@ -21,7 +21,7 @@ public class RecipeSteps { final List images, final RecipeCreateRequest recipeRequest) { final var requestSpec = given() - .cookie("JSESSIONID", loginCookie); + .cookie("SESSION", loginCookie); if (Objects.nonNull(images) && !images.isEmpty()) { images.forEach(requestSpec::multiPart); @@ -37,7 +37,7 @@ public class RecipeSteps { public static ExtractableResponse 레시피_상세_정보_요청(final String loginCookie, final Long recipeId) { return given() - .cookie("JSESSIONID", loginCookie) + .cookie("SESSION", loginCookie) .when() .get("/api/recipes/{recipeId}", recipeId) .then() @@ -57,7 +57,7 @@ public class RecipeSteps { public static ExtractableResponse 레시피_좋아요_요청(final String loginCookie, final Long recipeId, final RecipeFavoriteRequest request) { return given() - .cookie("JSESSIONID", loginCookie) + .cookie("SESSION", loginCookie) .contentType("application/json") .body(request) .when() @@ -97,7 +97,7 @@ public class RecipeSteps { final Long recipeId, final RecipeCommentCreateRequest request) { return given() - .cookie("JSESSIONID", loginCookie) + .cookie("SESSION", loginCookie) .contentType("application/json") .body(request) .when() @@ -109,7 +109,7 @@ public class RecipeSteps { public static ExtractableResponse 레시피_댓글_조회_요청(final String loginCookie, final Long recipeId, final RecipeCommentCondition condition) { return given() - .cookie("JSESSIONID", loginCookie) + .cookie("SESSION", loginCookie) .contentType("application/json") .param("lastId", condition.getLastId()) .param("totalElements", condition.getTotalElements()) diff --git a/backend/src/test/java/com/funeat/acceptance/review/ReviewSteps.java b/backend/src/test/java/com/funeat/acceptance/review/ReviewSteps.java index e7f828134..d1b556d7d 100644 --- a/backend/src/test/java/com/funeat/acceptance/review/ReviewSteps.java +++ b/backend/src/test/java/com/funeat/acceptance/review/ReviewSteps.java @@ -19,7 +19,7 @@ public class ReviewSteps { final MultiPartSpecification image, final ReviewCreateRequest request) { final var requestSpec = given() - .cookie("JSESSIONID", loginCookie); + .cookie("SESSION", loginCookie); if (Objects.nonNull(image)) { requestSpec.multiPart(image); @@ -36,7 +36,7 @@ public class ReviewSteps { public static ExtractableResponse 리뷰_좋아요_요청(final String loginCookie, final Long productId, final Long reviewId, final ReviewFavoriteRequest request) { return given() - .cookie("JSESSIONID", loginCookie) + .cookie("SESSION", loginCookie) .contentType("application/json") .body(request) .when() @@ -58,7 +58,7 @@ public class ReviewSteps { final Long lastReviewId, final String sort, final Long page) { return given() - .cookie("JSESSIONID", loginCookie) + .cookie("SESSION", loginCookie) .queryParam("sort", sort) .queryParam("page", page) .queryParam("lastReviewId", lastReviewId).log().all() diff --git a/backend/src/test/resources/application.yml b/backend/src/test/resources/application.yml index 827afa5d8..5d2dd0387 100644 --- a/backend/src/test/resources/application.yml +++ b/backend/src/test/resources/application.yml @@ -37,3 +37,9 @@ cloud: back-office: id: test key: test + +server: + servlet: + session: + cookie: + name: SESSION From a7d28e0016606aa8d792738742b9972fef3f4420 Mon Sep 17 00:00:00 2001 From: Leejin Yang Date: Thu, 19 Oct 2023 20:51:28 +0900 Subject: [PATCH 52/55] =?UTF-8?q?[ALL]=20docs:=20README=20=EC=9E=91?= =?UTF-8?q?=EC=84=B1=20(#823)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * docs: README 작성 * docs: 위키 아이콘 수정 * docs: 수정 --- README.md | 115 ++++++++++++++++++++++++++++++++++++++++++++++++------ 1 file changed, 104 insertions(+), 11 deletions(-) diff --git a/README.md b/README.md index ffd189097..e0fb1c48d 100644 --- a/README.md +++ b/README.md @@ -1,15 +1,108 @@ -# 2023-fun-eat +
    -## 팀원 👨‍👨‍👧‍👧👩‍👦‍👦 +
    -| Frontend | Frontend | Frontend | -|:-------------------------------------------------------------------------------------:|:-------------------------------------------------------------------------------------:|:-------------------------------------------------------------------------------------:| -| 타미 | 해온 | 황펭 | -| [타미](https://github.com/xodms0309) | [해온](https://github.com/hae-on) | [황펭](https://github.com/Leejin-Yang) | + -| Backend | Backend | Backend | Backend | -|:--------------------------------------------------------------------------------------:|:--------------------------------------------------------------------------------------:|:--------------------------------------------------------------------------------------:|:-------------------------------------------------------------------------------------:| -| 로건 | 망고 | 오잉 | 우가 | -| [로건](https://github.com/70825) | [망고](https://github.com/Go-Jaecheol) | [오잉](https://github.com/hanueleee) | [우가](https://github.com/wugawuga) | +
    +
    +
    -


    +궁금해? 맛있을걸? 먹어봐!
    +🍙 편의점 음식 리뷰 & 꿀조합 공유 서비스 🍙
    + +
    + +[![Application](http://img.shields.io/badge/funeat.site-D8EAFF?style=for-the-badge&logo=aHR0cHM6Ly9naXRodWIuY29tL3dvb3dhY291cnNlLXRlYW1zLzIwMjMtZnVuLWVhdC9hc3NldHMvODA0NjQ5NjEvOWI1OWY3NzktY2M5MS00MTJhLWE3NDUtZGQ3M2IzY2UxZGNk&logoColor=black&link=https://funeat.site/)](https://funeat.site/) +[![WIKI](http://img.shields.io/badge/-GitHub%20WiKi-FFEC99?style=for-the-badge&logoColor=black&link=https://github.com/woowacourse-teams/2023-fun-eat/wiki)](https://github.com/woowacourse-teams/2023-fun-eat/wiki) +[![Release](https://img.shields.io/github/v/release/woowacourse-teams/2023-fun-eat?style=for-the-badge&color=FFCFCF)](https://github.com/woowacourse-teams/2023-fun-eat/releases/tag/v1.2.0) + +
    + +
    + +# 🥄 서비스 소개 + +![1_메인페이지](https://github.com/woowacourse-teams/2023-fun-eat/assets/55427367/9663f7b5-cd38-4f06-86fb-c6636fc364c6) + +
    + +## 1. 편의점마다 특색있는 음식 궁금해? + +![5_상품목록](https://github.com/woowacourse-teams/2023-fun-eat/assets/55427367/03fb9955-61fa-4228-a270-ce9dffc710c6) +![6_상품상세](https://github.com/woowacourse-teams/2023-fun-eat/assets/55427367/694bc8db-74bd-4fa1-b499-900cd27f5028) +![4_검색](https://github.com/woowacourse-teams/2023-fun-eat/assets/55427367/6a157e08-79d8-450b-9511-ffa461000a22) + +
    +
    + +## 2. 솔직한 리뷰를 보면 더 맛있을걸? + +![2_리뷰](https://github.com/woowacourse-teams/2023-fun-eat/assets/55427367/4bf5ecd7-df08-45d0-b592-8629f3a4e3e6) + +
    +
    + +## 3. 생각지 못했던 꿀조합, 먹어봐! + +![3_꿀조합](https://github.com/woowacourse-teams/2023-fun-eat/assets/55427367/8e560b40-d039-47ce-ad29-5e244cba4bf2) + +
    +
    + +# 🛠️ 기술 스택 + +### 백엔드 + +
    + BE_기술스택 +
    + +
    + +### 프론트엔드 + +
    + FE_기술스택 +
    + +
    + +### 인프라 + +
    + 인프라_기술스택 +
    + +
    +
    + +# 인프라 구조 + +### CI/CD + +
    + cicd +
    + +### 구조 + +
    + 인프라 구조 +
    + +
    +
    + +# 👨‍👨‍👧‍👧👩‍👦‍👦 팀원 + +| Frontend | Frontend | Frontend | Backend | Backend | Backend | Backend | +| :-------------------------------------------------------------------------------------: | :-------------------------------------------------------------------------------------: | :-------------------------------------------------------------------------------------: | :--------------------------------------------------------------------------------------: | :--------------------------------------------------------------------------------------: | :--------------------------------------------------------------------------------------: | :--------------------------------------------------------------------------------------: | +| 타미 | 해온 | 황펭 | 로건 | 망고 | 오잉 | 우가 | +| [🐰 타미](https://github.com/xodms0309) | [🌞 해온](https://github.com/hae-on) | [🐧 황펭](https://github.com/Leejin-Yang) | [😺 로건](https://github.com/70825) | [🥭 망고](https://github.com/Go-Jaecheol) | [👻 오잉](https://github.com/hanueleee) | [🍖 우가](https://github.com/wugawuga) | + +
    + +
    + 팀소개 +
    From 9b00964e75f7e742e2d2e0f24ab89de439d599f7 Mon Sep 17 00:00:00 2001 From: Leejin Yang Date: Thu, 19 Oct 2023 21:08:40 +0900 Subject: [PATCH 53/55] =?UTF-8?q?[FE]=20feat:=20PB=20=EC=83=81=ED=92=88=20?= =?UTF-8?q?=EB=AA=A9=EB=A1=9D=EC=97=90=EC=84=9C=20=EC=83=81=ED=92=88=20?= =?UTF-8?q?=EC=8D=B8=EB=84=A4=EC=9D=BC=20=EC=82=BC=EA=B9=80=EC=9D=B4?= =?UTF-8?q?=EB=A1=9C=20=EC=88=98=EC=A0=95=20(#818)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../ProductDetailItem/ProductDetailItem.tsx | 2 +- .../components/Product/ProductItem/ProductItem.tsx | 14 ++++++++++++-- frontend/src/mocks/data/pbProducts.json | 2 +- 3 files changed, 14 insertions(+), 4 deletions(-) diff --git a/frontend/src/components/Product/ProductDetailItem/ProductDetailItem.tsx b/frontend/src/components/Product/ProductDetailItem/ProductDetailItem.tsx index ab7437f17..b91b97e2b 100644 --- a/frontend/src/components/Product/ProductDetailItem/ProductDetailItem.tsx +++ b/frontend/src/components/Product/ProductDetailItem/ProductDetailItem.tsx @@ -20,7 +20,7 @@ const ProductDetailItem = ({ category, productDetail }: ProductDetailItemProps) return ( - {image !== null ? ( + {image ? ( {name} ) : category === CATEGORY_TYPE.FOOD ? ( diff --git a/frontend/src/components/Product/ProductItem/ProductItem.tsx b/frontend/src/components/Product/ProductItem/ProductItem.tsx index 32300d2bc..b1e935dd8 100644 --- a/frontend/src/components/Product/ProductItem/ProductItem.tsx +++ b/frontend/src/components/Product/ProductItem/ProductItem.tsx @@ -1,9 +1,12 @@ import { Text, useTheme } from '@fun-eat/design-system'; import { memo, useState } from 'react'; +import { useParams } from 'react-router-dom'; import styled from 'styled-components'; import PreviewImage from '@/assets/characters.svg'; +import PBPreviewImage from '@/assets/samgakgimbab.svg'; import { Skeleton, SvgIcon } from '@/components/Common'; +import { CATEGORY_TYPE } from '@/constants'; import type { Product } from '@/types/product'; interface ProductItemProps { @@ -12,12 +15,17 @@ interface ProductItemProps { const ProductItem = ({ product }: ProductItemProps) => { const theme = useTheme(); + const { category } = useParams(); const { name, price, image, averageRating, reviewCount } = product; const [isImageLoading, setIsImageLoading] = useState(true); + if (!category) { + return null; + } + return ( - {image !== null ? ( + {image ? ( <> { /> {isImageLoading && } - ) : ( + ) : category === CATEGORY_TYPE.FOOD ? ( + ) : ( + )} diff --git a/frontend/src/mocks/data/pbProducts.json b/frontend/src/mocks/data/pbProducts.json index e25f1dc48..cd4491c33 100644 --- a/frontend/src/mocks/data/pbProducts.json +++ b/frontend/src/mocks/data/pbProducts.json @@ -37,7 +37,7 @@ "id": 5, "name": "PB 버터링", "price": 1000, - "image": "https://cdn.pixabay.com/photo/2016/03/23/15/00/ice-cream-1274894_1280.jpg", + "image": "", "averageRating": 4.0, "reviewCount": 100 }, From a5b52e74ade391e8c544a55d82ae1fd0723e4d8b Mon Sep 17 00:00:00 2001 From: Leejin Yang Date: Thu, 19 Oct 2023 21:11:09 +0900 Subject: [PATCH 54/55] =?UTF-8?q?[FE]=20feat:=20=EB=9E=AD=ED=82=B9=20?= =?UTF-8?q?=EC=83=81=EB=8C=80=20=EC=8B=9C=EA=B0=84=20=EC=B6=94=EA=B0=80=20?= =?UTF-8?q?(#826)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat: 꿀조합 랭킹 상대 날짜 추가 * feat: 리뷰 랭킹 상대 시간 추가 --- .../Rank/RecipeRankingItem/RecipeRankingItem.tsx | 6 ++++++ .../ReviewRankingItem/ReviewRankingItem.stories.tsx | 1 + .../Rank/ReviewRankingItem/ReviewRankingItem.tsx | 10 +++++++++- frontend/src/mocks/data/recipeRankingList.json | 9 ++++++--- frontend/src/mocks/data/reviewRankingList.json | 6 ++++-- frontend/src/types/ranking.ts | 2 ++ 6 files changed, 28 insertions(+), 6 deletions(-) diff --git a/frontend/src/components/Rank/RecipeRankingItem/RecipeRankingItem.tsx b/frontend/src/components/Rank/RecipeRankingItem/RecipeRankingItem.tsx index 81086851d..b887ad10d 100644 --- a/frontend/src/components/Rank/RecipeRankingItem/RecipeRankingItem.tsx +++ b/frontend/src/components/Rank/RecipeRankingItem/RecipeRankingItem.tsx @@ -5,6 +5,7 @@ import styled from 'styled-components'; import RecipePreviewImage from '@/assets/plate.svg'; import { Skeleton, SvgIcon } from '@/components/Common'; import type { RecipeRanking } from '@/types/ranking'; +import { getRelativeDate } from '@/utils/date'; interface RecipeRankingItemProps { rank: number; @@ -18,6 +19,7 @@ const RecipeRankingItem = ({ rank, recipe }: RecipeRankingItemProps) => { title, author: { nickname, profileImage }, favoriteCount, + createdAt, } = recipe; const [isImageLoading, setIsImageLoading] = useState(true); @@ -49,6 +51,10 @@ const RecipeRankingItem = ({ rank, recipe }: RecipeRankingItemProps) => { {favoriteCount} + + + {getRelativeDate(createdAt)} + diff --git a/frontend/src/components/Rank/ReviewRankingItem/ReviewRankingItem.stories.tsx b/frontend/src/components/Rank/ReviewRankingItem/ReviewRankingItem.stories.tsx index ca874ebf6..099f34737 100644 --- a/frontend/src/components/Rank/ReviewRankingItem/ReviewRankingItem.stories.tsx +++ b/frontend/src/components/Rank/ReviewRankingItem/ReviewRankingItem.stories.tsx @@ -15,6 +15,7 @@ const meta: Meta = { rating: 4.0, favoriteCount: 1256, categoryType: 'food', + createdAt: '2021-08-01T00:00:00.000Z', }, }, }; diff --git a/frontend/src/components/Rank/ReviewRankingItem/ReviewRankingItem.tsx b/frontend/src/components/Rank/ReviewRankingItem/ReviewRankingItem.tsx index ca2aed3dc..205cfb03b 100644 --- a/frontend/src/components/Rank/ReviewRankingItem/ReviewRankingItem.tsx +++ b/frontend/src/components/Rank/ReviewRankingItem/ReviewRankingItem.tsx @@ -4,6 +4,7 @@ import styled from 'styled-components'; import { SvgIcon } from '@/components/Common'; import type { ReviewRanking } from '@/types/ranking'; +import { getRelativeDate } from '@/utils/date'; interface ReviewRankingItemProps { reviewRanking: ReviewRanking; @@ -12,7 +13,7 @@ interface ReviewRankingItemProps { const ReviewRankingItem = ({ reviewRanking }: ReviewRankingItemProps) => { const theme = useTheme(); - const { productName, content, rating, favoriteCount } = reviewRanking; + const { productName, content, rating, favoriteCount, createdAt } = reviewRanking; return ( @@ -36,6 +37,9 @@ const ReviewRankingItem = ({ reviewRanking }: ReviewRankingItemProps) => { {rating.toFixed(1)} + + {getRelativeDate(createdAt)} +
    ); @@ -80,3 +84,7 @@ const RatingIconWrapper = styled.div` padding-bottom: 2px; } `; + +const ReviewDate = styled(Text)` + margin-left: auto; +`; diff --git a/frontend/src/mocks/data/recipeRankingList.json b/frontend/src/mocks/data/recipeRankingList.json index c08391be2..fbb93ff10 100644 --- a/frontend/src/mocks/data/recipeRankingList.json +++ b/frontend/src/mocks/data/recipeRankingList.json @@ -8,7 +8,8 @@ "profileImage": "https://github.com/woowacourse-teams/2023-fun-eat/assets/78616893/991f7b69-53bf-4d03-96e1-988c34d010ed", "nickname": "funeat" }, - "favoriteCount": 153 + "favoriteCount": 153, + "createdAt": "2023-08-09T10:10:10" }, { "id": 2, @@ -18,7 +19,8 @@ "profileImage": "https://github.com/woowacourse-teams/2023-fun-eat/assets/78616893/991f7b69-53bf-4d03-96e1-988c34d010ed", "nickname": "funeat" }, - "favoriteCount": 153 + "favoriteCount": 153, + "createdAt": "2023-08-09T10:10:10" }, { "id": 3, @@ -28,7 +30,8 @@ "profileImage": "https://github.com/woowacourse-teams/2023-fun-eat/assets/78616893/991f7b69-53bf-4d03-96e1-988c34d010ed", "nickname": "funeat" }, - "favoriteCount": 153 + "favoriteCount": 153, + "createdAt": "2023-08-09T10:10:10" } ] } diff --git a/frontend/src/mocks/data/reviewRankingList.json b/frontend/src/mocks/data/reviewRankingList.json index 3a5cc0f73..8f9577454 100644 --- a/frontend/src/mocks/data/reviewRankingList.json +++ b/frontend/src/mocks/data/reviewRankingList.json @@ -7,7 +7,8 @@ "content": "할머니가 먹을 거 같은 맛입니다. 1960년 전쟁 때 맛 보고 싶었는데 그때는 너무 가난해서 먹을 수 없었는데요 이것보다 긴 리뷰도 잘려 보인답니다", "rating": 4.0, "favoriteCount": 1256, - "categoryType": "food" + "categoryType": "food", + "createdAt": "2023-08-09T10:10:10" }, { "reviewId": 1, @@ -16,7 +17,8 @@ "content": "하얀 짜파게티라니 말이 안된다고 생각했었죠. 실제로 맛을 보니까 까만 짜파게티랑 맛이 뭔가 다를게 없네요.", "rating": 4.4, "favoriteCount": 870, - "categoryType": "food" + "categoryType": "food", + "createdAt": "2023-08-09T10:10:10" } ] } diff --git a/frontend/src/types/ranking.ts b/frontend/src/types/ranking.ts index 01c669e0f..42cfe29ca 100644 --- a/frontend/src/types/ranking.ts +++ b/frontend/src/types/ranking.ts @@ -12,6 +12,7 @@ export interface ReviewRanking { rating: number; favoriteCount: number; categoryType: CategoryVariant; + createdAt: string; } export interface RecipeRanking { @@ -20,4 +21,5 @@ export interface RecipeRanking { title: string; author: Member; favoriteCount: number; + createdAt: string; } From 25efbfed86e024f53a1dcc33c56539fad3194121 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?s=E1=B4=8F=CA=9F=CA=99=C9=AA=20=E2=98=94=EF=B8=8F?= Date: Thu, 19 Oct 2023 21:16:20 +0900 Subject: [PATCH 55/55] =?UTF-8?q?[ALL]=20docs:=20=EB=A1=9C=EA=B3=A0=20?= =?UTF-8?q?=EC=9D=B4=EB=AF=B8=EC=A7=80=20=EC=88=98=EC=A0=95=20(#827)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * refactor: 로고 이미지 수정 * docs: 이미지 크기 수정 --- README.md | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index e0fb1c48d..a3819192f 100644 --- a/README.md +++ b/README.md @@ -2,9 +2,8 @@
    - + -


    @@ -15,7 +14,7 @@ [![Application](http://img.shields.io/badge/funeat.site-D8EAFF?style=for-the-badge&logo=aHR0cHM6Ly9naXRodWIuY29tL3dvb3dhY291cnNlLXRlYW1zLzIwMjMtZnVuLWVhdC9hc3NldHMvODA0NjQ5NjEvOWI1OWY3NzktY2M5MS00MTJhLWE3NDUtZGQ3M2IzY2UxZGNk&logoColor=black&link=https://funeat.site/)](https://funeat.site/) [![WIKI](http://img.shields.io/badge/-GitHub%20WiKi-FFEC99?style=for-the-badge&logoColor=black&link=https://github.com/woowacourse-teams/2023-fun-eat/wiki)](https://github.com/woowacourse-teams/2023-fun-eat/wiki) -[![Release](https://img.shields.io/github/v/release/woowacourse-teams/2023-fun-eat?style=for-the-badge&color=FFCFCF)](https://github.com/woowacourse-teams/2023-fun-eat/releases/tag/v1.2.0) +[![Release](https://img.shields.io/github/v/release/woowacourse-teams/2023-fun-eat?style=for-the-badge&color=FFCFCF)](https://github.com/woowacourse-teams/2023-fun-eat/releases/tag/v1.3.0)