From 749f8504c99c387ae75169e80ae1b804304d9858 Mon Sep 17 00:00:00 2001 From: chsua <113416448+chsua@users.noreply.github.com> Date: Fri, 14 Jul 2023 13:21:08 +0900 Subject: [PATCH 1/4] =?UTF-8?q?=ED=97=A4=EB=8D=94=20=EC=BB=B4=ED=8F=AC?= =?UTF-8?q?=EB=84=8C=ED=8A=B8=20=EA=B5=AC=ED=98=84=5FFeat/#16=20(#39)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat: (#16) 검색 아이콘 색상(검/흰) 분리 * feat: (#16) 검색바 컴포넌트 생성 * test: (#16) 검색바 컴포넌트 크기별 테스트 * feat: (#16) 탭, 모니터용 긴 헤더 컴포넌트 생성 * test: (#16) 탭, 모니터용 긴 헤더 컴포넌트 테스트 생성 * feat: (#16) 로고, 프로젝트명 버튼 컴포넌트 생성 * test: (#16) 로고, 프로젝트명 버튼 컴포넌트 테스트 생성 * refactor: 로고 버튼으로 기존 코드 변경 * feat: (#16) 모바일 버전 메인페이지 헤더 컴포넌트 생성 * test: (#16) 모바일 버전 메인페이지 헤더 컴포넌트 테스트 생성 * feat: (#16) 모바일용 짧은 헤더 템플릿 컴포넌트 생성 - 내용을 children 프롭스로 전달받도록 구현 - 상단 고정 등 공동의 스타일 공유하기 위해 컴포넌트 제작 * test: (#16) 모바일용 짧은 헤더 템플릿 컴포넌트 테스트 * design: (#16) 검색바 버튼 커서를 포인터로 수정 * test: (#16) 스토리명 파스칼케이스로 수정 * refactor: (#15) 로고 컴포넌트 프롭스명 수정 * test: (#15) 로고 컴포넌트 프롭스명 수정에 따른 테스트 수정 * style: (#15) 코드 컨벤션에 맞게 CSS 순서 정렬 * refactor: 짧은 기본 헤더 이름 수정 - NarrowOriginHeader > NarrowMainHeader --- frontend/src/assets/search_black.svg | 3 ++ .../assets/{search.svg => search_white.svg} | 0 .../common/AddButton/AddButton.stories.tsx | 6 +-- .../HeaderTextButton.stories.tsx | 2 +- .../common/IconButton/IconButton.stories.tsx | 6 +-- .../components/common/IconButton/index.tsx | 2 +- .../common/LogoButton/LogoButton.stories.tsx | 25 ++++++++++ .../components/common/LogoButton/index.tsx | 47 +++++++++++++++++++ .../src/components/common/LogoButton/style.ts | 24 ++++++++++ .../NarrowMainHeader.stories.tsx | 15 ++++++ .../common/NarrowMainHeader/index.tsx | 14 ++++++ .../common/NarrowMainHeader/style.ts | 22 +++++++++ .../NarrowTemplateHeader.stories.tsx | 44 +++++++++++++++++ .../common/NarrowTemplateHeader/index.tsx | 7 +++ .../common/NarrowTemplateHeader/style.ts | 17 +++++++ .../common/SearchBar/SearchBar.stories.tsx | 26 ++++++++++ .../src/components/common/SearchBar/index.tsx | 22 +++++++++ .../src/components/common/SearchBar/style.ts | 47 +++++++++++++++++++ .../SquareButton/SquareButton.stories.tsx | 4 +- .../common/WideHeader/WideHeader.stories.tsx | 14 ++++++ .../components/common/WideHeader/index.tsx | 13 +++++ .../src/components/common/WideHeader/style.ts | 25 ++++++++++ 22 files changed, 375 insertions(+), 10 deletions(-) create mode 100644 frontend/src/assets/search_black.svg rename frontend/src/assets/{search.svg => search_white.svg} (100%) create mode 100644 frontend/src/components/common/LogoButton/LogoButton.stories.tsx create mode 100644 frontend/src/components/common/LogoButton/index.tsx create mode 100644 frontend/src/components/common/LogoButton/style.ts create mode 100644 frontend/src/components/common/NarrowMainHeader/NarrowMainHeader.stories.tsx create mode 100644 frontend/src/components/common/NarrowMainHeader/index.tsx create mode 100644 frontend/src/components/common/NarrowMainHeader/style.ts create mode 100644 frontend/src/components/common/NarrowTemplateHeader/NarrowTemplateHeader.stories.tsx create mode 100644 frontend/src/components/common/NarrowTemplateHeader/index.tsx create mode 100644 frontend/src/components/common/NarrowTemplateHeader/style.ts create mode 100644 frontend/src/components/common/SearchBar/SearchBar.stories.tsx create mode 100644 frontend/src/components/common/SearchBar/index.tsx create mode 100644 frontend/src/components/common/SearchBar/style.ts create mode 100644 frontend/src/components/common/WideHeader/WideHeader.stories.tsx create mode 100644 frontend/src/components/common/WideHeader/index.tsx create mode 100644 frontend/src/components/common/WideHeader/style.ts diff --git a/frontend/src/assets/search_black.svg b/frontend/src/assets/search_black.svg new file mode 100644 index 000000000..5e4b6ac25 --- /dev/null +++ b/frontend/src/assets/search_black.svg @@ -0,0 +1,3 @@ + + + diff --git a/frontend/src/assets/search.svg b/frontend/src/assets/search_white.svg similarity index 100% rename from frontend/src/assets/search.svg rename to frontend/src/assets/search_white.svg diff --git a/frontend/src/components/common/AddButton/AddButton.stories.tsx b/frontend/src/components/common/AddButton/AddButton.stories.tsx index 693af5bc1..0888f10a2 100644 --- a/frontend/src/components/common/AddButton/AddButton.stories.tsx +++ b/frontend/src/components/common/AddButton/AddButton.stories.tsx @@ -9,14 +9,14 @@ const meta: Meta = { export default meta; type Story = StoryObj; -export const size_S: Story = { +export const SizeS: Story = { render: () => , }; -export const size_M: Story = { +export const sizeM: Story = { render: () => , }; -export const size_L: Story = { +export const sizeL: Story = { render: () => , }; diff --git a/frontend/src/components/common/HeaderTextButton/HeaderTextButton.stories.tsx b/frontend/src/components/common/HeaderTextButton/HeaderTextButton.stories.tsx index fc25cc703..cca319fbd 100644 --- a/frontend/src/components/common/HeaderTextButton/HeaderTextButton.stories.tsx +++ b/frontend/src/components/common/HeaderTextButton/HeaderTextButton.stories.tsx @@ -9,6 +9,6 @@ const meta: Meta = { export default meta; type Story = StoryObj; -export const defaultButton: Story = { +export const DefaultButton: Story = { render: () => 확 인, }; diff --git a/frontend/src/components/common/IconButton/IconButton.stories.tsx b/frontend/src/components/common/IconButton/IconButton.stories.tsx index 102c0dd79..d071655c2 100644 --- a/frontend/src/components/common/IconButton/IconButton.stories.tsx +++ b/frontend/src/components/common/IconButton/IconButton.stories.tsx @@ -10,14 +10,14 @@ const meta: Meta = { export default meta; type Story = StoryObj; -export const category: Story = { +export const Category: Story = { render: () => , }; -export const back: Story = { +export const Back: Story = { render: () => , }; -export const search: Story = { +export const Search: Story = { render: () => , }; diff --git a/frontend/src/components/common/IconButton/index.tsx b/frontend/src/components/common/IconButton/index.tsx index 21a9631b1..1fcf7f9cd 100644 --- a/frontend/src/components/common/IconButton/index.tsx +++ b/frontend/src/components/common/IconButton/index.tsx @@ -2,7 +2,7 @@ import { ButtonHTMLAttributes } from 'react'; import backIcon from '@assets/back.svg'; import categoryIcon from '@assets/category.svg'; -import searchIcon from '@assets/search.svg'; +import searchIcon from '@assets/search_white.svg'; import * as S from './style'; diff --git a/frontend/src/components/common/LogoButton/LogoButton.stories.tsx b/frontend/src/components/common/LogoButton/LogoButton.stories.tsx new file mode 100644 index 000000000..a174f863f --- /dev/null +++ b/frontend/src/components/common/LogoButton/LogoButton.stories.tsx @@ -0,0 +1,25 @@ +import type { Meta, StoryObj } from '@storybook/react'; + +import LogoButton from '.'; + +const meta: Meta = { + component: LogoButton, + decorators: [ + storyFn =>
{storyFn()}
, + ], +}; + +export default meta; +type Story = StoryObj; + +export const Icon: Story = { + render: () => , +}; + +export const Text: Story = { + render: () => , +}; + +export const Full: Story = { + render: () => , +}; diff --git a/frontend/src/components/common/LogoButton/index.tsx b/frontend/src/components/common/LogoButton/index.tsx new file mode 100644 index 000000000..d3ae88b39 --- /dev/null +++ b/frontend/src/components/common/LogoButton/index.tsx @@ -0,0 +1,47 @@ +import { ButtonHTMLAttributes } from 'react'; + +import logo from '@assets/logo.svg'; +import votogether from '@assets/projectName.svg'; + +import * as S from './style'; + +type Content = 'icon' | 'text' | 'full'; + +const contentCategory: { [key in Content]: { name: string; url: string } } = { + icon: { + name: '로고 아이콘', + url: logo, + }, + text: { + name: 'votogether', + url: votogether, + }, + full: { + name: 'votogether', + url: '', + }, +}; + +interface LogoButtonProps extends ButtonHTMLAttributes { + content: Content; +} + +export default function LogoButton({ content, ...rest }: LogoButtonProps) { + const src = contentCategory[content].url; + const ariaLabelText = contentCategory[content].name; + + if (content === 'full') { + return ( + + 로고 아이콘 + VoTogether + + ); + } + + return ( + + 로고 아이콘 + + ); +} diff --git a/frontend/src/components/common/LogoButton/style.ts b/frontend/src/components/common/LogoButton/style.ts new file mode 100644 index 000000000..8ebdbf50d --- /dev/null +++ b/frontend/src/components/common/LogoButton/style.ts @@ -0,0 +1,24 @@ +import { styled } from 'styled-components'; + +type Content = 'icon' | 'text' | 'full'; + +export const Button = styled.button<{ content: Content }>` + display: flex; + align-items: center; + gap: 10px; + + background-color: rgba(0, 0, 0, 0); + + height: 100%; + + cursor: pointer; + + & :first-child { + height: 100%; + border-radius: 5px; + } + + & :last-child { + height: ${props => props.content !== 'icon' && '60%'}; + } +`; diff --git a/frontend/src/components/common/NarrowMainHeader/NarrowMainHeader.stories.tsx b/frontend/src/components/common/NarrowMainHeader/NarrowMainHeader.stories.tsx new file mode 100644 index 000000000..cf0410cba --- /dev/null +++ b/frontend/src/components/common/NarrowMainHeader/NarrowMainHeader.stories.tsx @@ -0,0 +1,15 @@ +import type { Meta, StoryObj } from '@storybook/react'; + +import NarrowMainHeader from '.'; + +const meta: Meta = { + component: NarrowMainHeader, + decorators: [storyFn =>
{storyFn()}
], +}; + +export default meta; +type Story = StoryObj; + +export const Primary: Story = { + render: () => , +}; diff --git a/frontend/src/components/common/NarrowMainHeader/index.tsx b/frontend/src/components/common/NarrowMainHeader/index.tsx new file mode 100644 index 000000000..3e783b531 --- /dev/null +++ b/frontend/src/components/common/NarrowMainHeader/index.tsx @@ -0,0 +1,14 @@ +import IconButton from '../IconButton'; +import LogoButton from '../LogoButton'; + +import * as S from './style'; + +export default function NarrowMainHeader() { + return ( + + + + + + ); +} diff --git a/frontend/src/components/common/NarrowMainHeader/style.ts b/frontend/src/components/common/NarrowMainHeader/style.ts new file mode 100644 index 000000000..ef5271a4a --- /dev/null +++ b/frontend/src/components/common/NarrowMainHeader/style.ts @@ -0,0 +1,22 @@ +import { styled } from 'styled-components'; + +export const Container = styled.div` + display: flex; + align-items: center; + justify-content: space-between; + gap: 20px; + + width: 100%; + height: 55px; + padding: 0 20px; + + position: absolute; + top: 0; + + background-color: #1f1f1f; + + & :nth-child(2) { + margin-right: auto; + height: 60%; + } +`; diff --git a/frontend/src/components/common/NarrowTemplateHeader/NarrowTemplateHeader.stories.tsx b/frontend/src/components/common/NarrowTemplateHeader/NarrowTemplateHeader.stories.tsx new file mode 100644 index 000000000..8a95c5871 --- /dev/null +++ b/frontend/src/components/common/NarrowTemplateHeader/NarrowTemplateHeader.stories.tsx @@ -0,0 +1,44 @@ +import type { Meta, StoryObj } from '@storybook/react'; + +import NarrowTemplateHeader from '.'; + +const meta: Meta = { + component: NarrowTemplateHeader, + decorators: [storyFn =>
{storyFn()}
], +}; + +export default meta; +type Story = StoryObj; + +export const BothSideHeader: Story = { + render: () => ( + +
예시
+
예시
+
+ ), +}; + +export const ThreeComponentHeaderLeft: Story = { + render: () => ( + +
+
예시
+
예시
+
+
예시
+
+ ), +}; + +export const ThreeComponentHeaderRight: Story = { + render: () => ( + +
예시
+
+
예시
+
예시
+
+
+ ), +}; diff --git a/frontend/src/components/common/NarrowTemplateHeader/index.tsx b/frontend/src/components/common/NarrowTemplateHeader/index.tsx new file mode 100644 index 000000000..0726aac35 --- /dev/null +++ b/frontend/src/components/common/NarrowTemplateHeader/index.tsx @@ -0,0 +1,7 @@ +import { ReactNode } from 'react'; + +import * as S from './style'; + +export default function NarrowTemplateHeader({ children }: { children: ReactNode }) { + return {children}; +} diff --git a/frontend/src/components/common/NarrowTemplateHeader/style.ts b/frontend/src/components/common/NarrowTemplateHeader/style.ts new file mode 100644 index 000000000..867fa2d34 --- /dev/null +++ b/frontend/src/components/common/NarrowTemplateHeader/style.ts @@ -0,0 +1,17 @@ +import { styled } from 'styled-components'; + +export const Container = styled.div` + display: flex; + align-items: center; + justify-content: space-between; + gap: 20px; + + width: 100%; + height: 55px; + padding: 0 20px; + + position: absolute; + top: 0; + + background-color: #1f1f1f; +`; diff --git a/frontend/src/components/common/SearchBar/SearchBar.stories.tsx b/frontend/src/components/common/SearchBar/SearchBar.stories.tsx new file mode 100644 index 000000000..f9c14b8b4 --- /dev/null +++ b/frontend/src/components/common/SearchBar/SearchBar.stories.tsx @@ -0,0 +1,26 @@ +import type { Meta, StoryObj } from '@storybook/react'; + +import SearchBar from '.'; + +const meta: Meta = { + component: SearchBar, +}; + +export default meta; +type Story = StoryObj; + +export const SizeSm: Story = { + render: () => , +}; + +export const SizeMd: Story = { + render: () => , +}; + +export const SizeLg: Story = { + render: () => , +}; + +export const SizeFree: Story = { + render: () => , +}; diff --git a/frontend/src/components/common/SearchBar/index.tsx b/frontend/src/components/common/SearchBar/index.tsx new file mode 100644 index 000000000..309f6b57b --- /dev/null +++ b/frontend/src/components/common/SearchBar/index.tsx @@ -0,0 +1,22 @@ +import { FormHTMLAttributes } from 'react'; + +import searchIcon from '@assets/search_black.svg'; + +import { Size } from '../AddButton/type'; + +import * as S from './style'; + +interface SearchBarProps extends FormHTMLAttributes { + size: Size | 'free'; +} + +export default function SearchBar({ size, ...rest }: SearchBarProps) { + return ( + + + + 검색버튼 + + + ); +} diff --git a/frontend/src/components/common/SearchBar/style.ts b/frontend/src/components/common/SearchBar/style.ts new file mode 100644 index 000000000..aea53b4c9 --- /dev/null +++ b/frontend/src/components/common/SearchBar/style.ts @@ -0,0 +1,47 @@ +import { styled } from 'styled-components'; + +import { Size } from '../AddButton/type'; + +interface SearchBarProps { + size: Size | 'free'; +} + +const formSize = { + sm: '170px', + md: '250px', + lg: '400px', +}; + +export const Form = styled.form` + display: flex; + align-items: center; + justify-content: space-between; + gap: 5px; + + width: ${props => (props.size === 'free' ? '100%' : formSize[props.size])}; + height: 36px; + padding: 5px 10px; + border-radius: 5px; + + background-color: #cccccc; + color: red; + + font-size: 1rem; +`; + +export const Input = styled.input` + width: 100%; + height: 100%; + outline: 0; + + background-color: rgba(0, 0, 0, 0); + + font-size: 14px; + letter-spacing: 1px; +`; + +export const Button = styled.button` + background-color: rgba(0, 0, 0, 0); + + cursor: pointer; +`; diff --git a/frontend/src/components/common/SquareButton/SquareButton.stories.tsx b/frontend/src/components/common/SquareButton/SquareButton.stories.tsx index 911ced831..4836088d0 100644 --- a/frontend/src/components/common/SquareButton/SquareButton.stories.tsx +++ b/frontend/src/components/common/SquareButton/SquareButton.stories.tsx @@ -10,10 +10,10 @@ const meta: Meta = { export default meta; type Story = StoryObj; -export const color_blank: Story = { +export const ColorBlank: Story = { render: () => 확 인, }; -export const color_fill: Story = { +export const ColorFill: Story = { render: () => 버 튼, }; diff --git a/frontend/src/components/common/WideHeader/WideHeader.stories.tsx b/frontend/src/components/common/WideHeader/WideHeader.stories.tsx new file mode 100644 index 000000000..8a5573d46 --- /dev/null +++ b/frontend/src/components/common/WideHeader/WideHeader.stories.tsx @@ -0,0 +1,14 @@ +import type { Meta, StoryObj } from '@storybook/react'; + +import WideHeader from '.'; + +const meta: Meta = { + component: WideHeader, +}; + +export default meta; +type Story = StoryObj; + +export const Primary: Story = { + render: () => , +}; diff --git a/frontend/src/components/common/WideHeader/index.tsx b/frontend/src/components/common/WideHeader/index.tsx new file mode 100644 index 000000000..42db7d696 --- /dev/null +++ b/frontend/src/components/common/WideHeader/index.tsx @@ -0,0 +1,13 @@ +import LogoButton from '../LogoButton'; +import SearchBar from '../SearchBar'; + +import * as S from './style'; + +export default function WideHeader() { + return ( + + + + + ); +} diff --git a/frontend/src/components/common/WideHeader/style.ts b/frontend/src/components/common/WideHeader/style.ts new file mode 100644 index 000000000..c8c025f32 --- /dev/null +++ b/frontend/src/components/common/WideHeader/style.ts @@ -0,0 +1,25 @@ +import { styled } from 'styled-components'; + +export const Container = styled.div` + display: flex; + justify-content: space-between; + align-items: center; + + width: 100%; + height: 70px; + + position: absolute; + top: 0; + + background-color: #1f1f1f; + + padding: 0 80px; + + & :first-child { + height: 70%; + + & :last-child { + height: 40%; + } + } +`; From 60a16c84b178e8a3614384919553e353bd0d3af7 Mon Sep 17 00:00:00 2001 From: chsua <113416448+chsua@users.noreply.github.com> Date: Fri, 14 Jul 2023 15:17:06 +0900 Subject: [PATCH 2/4] =?UTF-8?q?=EB=A6=AC=EC=95=A1=ED=8A=B8=20=EC=BF=BC?= =?UTF-8?q?=EB=A6=AC,=20msw=20=EC=84=A4=EC=B9=98=20=EB=B0=8F=20=EC=85=8B?= =?UTF-8?q?=ED=8C=85=20(#48)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat: (#46) msw, react-query, .env 적용 - 스토리북에도 적용 * feat: (#46) react-query 앱 컴포넌트에 적용 * refactor: (#46) env파일 삭제 * chore: (#46) env파일 gitignore에 추가 --- frontend/.gitignore | 3 +- frontend/.storybook/main.ts | 1 + frontend/.storybook/preview.tsx | 18 +- .../.storybook/public/mockServiceWorker.js | 303 +++++ frontend/.storybook/webpack.config.js | 17 + frontend/package-lock.json | 1106 ++++++++++++++++- frontend/package.json | 8 + frontend/public/mockServiceWorker.js | 303 +++++ frontend/src/App.tsx | 8 +- frontend/src/index.tsx | 9 +- frontend/src/mocks/example/get.ts | 7 + frontend/src/mocks/handlers.ts | 3 + frontend/src/mocks/worker.ts | 5 + frontend/webpack.common.js | 4 +- 14 files changed, 1785 insertions(+), 10 deletions(-) create mode 100644 frontend/.storybook/public/mockServiceWorker.js create mode 100644 frontend/.storybook/webpack.config.js create mode 100644 frontend/public/mockServiceWorker.js create mode 100644 frontend/src/mocks/example/get.ts create mode 100644 frontend/src/mocks/handlers.ts create mode 100644 frontend/src/mocks/worker.ts diff --git a/frontend/.gitignore b/frontend/.gitignore index 30bc16279..7af7f0475 100644 --- a/frontend/.gitignore +++ b/frontend/.gitignore @@ -1 +1,2 @@ -/node_modules \ No newline at end of file +/node_modules +.env \ No newline at end of file diff --git a/frontend/.storybook/main.ts b/frontend/.storybook/main.ts index f75be279a..d47f8e0c4 100644 --- a/frontend/.storybook/main.ts +++ b/frontend/.storybook/main.ts @@ -33,5 +33,6 @@ const config: StorybookConfig = { ); return config; }, + staticDirs: ['./public'], }; export default config; diff --git a/frontend/.storybook/preview.tsx b/frontend/.storybook/preview.tsx index 03d1a9c88..d35ff88c1 100644 --- a/frontend/.storybook/preview.tsx +++ b/frontend/.storybook/preview.tsx @@ -1,7 +1,13 @@ import type { Preview } from '@storybook/react'; +import { initialize, mswDecorator } from 'msw-storybook-addon'; + import { GlobalStyle } from '../src/styles/globalStyle'; import React from 'react'; +import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; + +const queryClient = new QueryClient(); +initialize(); const preview: Preview = { parameters: { @@ -14,13 +20,21 @@ const preview: Preview = { }, }, decorators: [ + mswDecorator, Story => ( <> - - + + + + ), ], }; +if (typeof global.process === 'undefined') { + const { worker } = require('../src/mocks/worker'); + worker.start(); +} + export default preview; diff --git a/frontend/.storybook/public/mockServiceWorker.js b/frontend/.storybook/public/mockServiceWorker.js new file mode 100644 index 000000000..8ee70b3e4 --- /dev/null +++ b/frontend/.storybook/public/mockServiceWorker.js @@ -0,0 +1,303 @@ +/* eslint-disable */ +/* tslint:disable */ + +/** + * Mock Service Worker (1.2.2). + * @see https://github.com/mswjs/msw + * - Please do NOT modify this file. + * - Please do NOT serve this file on production. + */ + +const INTEGRITY_CHECKSUM = '3d6b9f06410d179a7f7404d4bf4c3c70' +const activeClientIds = new Set() + +self.addEventListener('install', function () { + self.skipWaiting() +}) + +self.addEventListener('activate', function (event) { + event.waitUntil(self.clients.claim()) +}) + +self.addEventListener('message', async function (event) { + const clientId = event.source.id + + if (!clientId || !self.clients) { + return + } + + const client = await self.clients.get(clientId) + + if (!client) { + return + } + + const allClients = await self.clients.matchAll({ + type: 'window', + }) + + switch (event.data) { + case 'KEEPALIVE_REQUEST': { + sendToClient(client, { + type: 'KEEPALIVE_RESPONSE', + }) + break + } + + case 'INTEGRITY_CHECK_REQUEST': { + sendToClient(client, { + type: 'INTEGRITY_CHECK_RESPONSE', + payload: INTEGRITY_CHECKSUM, + }) + break + } + + case 'MOCK_ACTIVATE': { + activeClientIds.add(clientId) + + sendToClient(client, { + type: 'MOCKING_ENABLED', + payload: true, + }) + break + } + + case 'MOCK_DEACTIVATE': { + activeClientIds.delete(clientId) + break + } + + case 'CLIENT_CLOSED': { + activeClientIds.delete(clientId) + + const remainingClients = allClients.filter((client) => { + return client.id !== clientId + }) + + // Unregister itself when there are no more clients + if (remainingClients.length === 0) { + self.registration.unregister() + } + + break + } + } +}) + +self.addEventListener('fetch', function (event) { + const { request } = event + const accept = request.headers.get('accept') || '' + + // Bypass server-sent events. + if (accept.includes('text/event-stream')) { + return + } + + // Bypass navigation requests. + if (request.mode === 'navigate') { + return + } + + // Opening the DevTools triggers the "only-if-cached" request + // that cannot be handled by the worker. Bypass such requests. + if (request.cache === 'only-if-cached' && request.mode !== 'same-origin') { + return + } + + // Bypass all requests when there are no active clients. + // Prevents the self-unregistered worked from handling requests + // after it's been deleted (still remains active until the next reload). + if (activeClientIds.size === 0) { + return + } + + // Generate unique request ID. + const requestId = Math.random().toString(16).slice(2) + + event.respondWith( + handleRequest(event, requestId).catch((error) => { + if (error.name === 'NetworkError') { + console.warn( + '[MSW] Successfully emulated a network error for the "%s %s" request.', + request.method, + request.url, + ) + return + } + + // At this point, any exception indicates an issue with the original request/response. + console.error( + `\ +[MSW] Caught an exception from the "%s %s" request (%s). This is probably not a problem with Mock Service Worker. There is likely an additional logging output above.`, + request.method, + request.url, + `${error.name}: ${error.message}`, + ) + }), + ) +}) + +async function handleRequest(event, requestId) { + const client = await resolveMainClient(event) + const response = await getResponse(event, client, requestId) + + // Send back the response clone for the "response:*" life-cycle events. + // Ensure MSW is active and ready to handle the message, otherwise + // this message will pend indefinitely. + if (client && activeClientIds.has(client.id)) { + ;(async function () { + const clonedResponse = response.clone() + sendToClient(client, { + type: 'RESPONSE', + payload: { + requestId, + type: clonedResponse.type, + ok: clonedResponse.ok, + status: clonedResponse.status, + statusText: clonedResponse.statusText, + body: + clonedResponse.body === null ? null : await clonedResponse.text(), + headers: Object.fromEntries(clonedResponse.headers.entries()), + redirected: clonedResponse.redirected, + }, + }) + })() + } + + return response +} + +// Resolve the main client for the given event. +// Client that issues a request doesn't necessarily equal the client +// that registered the worker. It's with the latter the worker should +// communicate with during the response resolving phase. +async function resolveMainClient(event) { + const client = await self.clients.get(event.clientId) + + if (client?.frameType === 'top-level') { + return client + } + + const allClients = await self.clients.matchAll({ + type: 'window', + }) + + return allClients + .filter((client) => { + // Get only those clients that are currently visible. + return client.visibilityState === 'visible' + }) + .find((client) => { + // Find the client ID that's recorded in the + // set of clients that have registered the worker. + return activeClientIds.has(client.id) + }) +} + +async function getResponse(event, client, requestId) { + const { request } = event + const clonedRequest = request.clone() + + function passthrough() { + // Clone the request because it might've been already used + // (i.e. its body has been read and sent to the client). + const headers = Object.fromEntries(clonedRequest.headers.entries()) + + // Remove MSW-specific request headers so the bypassed requests + // comply with the server's CORS preflight check. + // Operate with the headers as an object because request "Headers" + // are immutable. + delete headers['x-msw-bypass'] + + return fetch(clonedRequest, { headers }) + } + + // Bypass mocking when the client is not active. + if (!client) { + return passthrough() + } + + // Bypass initial page load requests (i.e. static assets). + // The absence of the immediate/parent client in the map of the active clients + // means that MSW hasn't dispatched the "MOCK_ACTIVATE" event yet + // and is not ready to handle requests. + if (!activeClientIds.has(client.id)) { + return passthrough() + } + + // Bypass requests with the explicit bypass header. + // Such requests can be issued by "ctx.fetch()". + if (request.headers.get('x-msw-bypass') === 'true') { + return passthrough() + } + + // Notify the client that a request has been intercepted. + const clientMessage = await sendToClient(client, { + type: 'REQUEST', + payload: { + id: requestId, + url: request.url, + method: request.method, + headers: Object.fromEntries(request.headers.entries()), + cache: request.cache, + mode: request.mode, + credentials: request.credentials, + destination: request.destination, + integrity: request.integrity, + redirect: request.redirect, + referrer: request.referrer, + referrerPolicy: request.referrerPolicy, + body: await request.text(), + bodyUsed: request.bodyUsed, + keepalive: request.keepalive, + }, + }) + + switch (clientMessage.type) { + case 'MOCK_RESPONSE': { + return respondWithMock(clientMessage.data) + } + + case 'MOCK_NOT_FOUND': { + return passthrough() + } + + case 'NETWORK_ERROR': { + const { name, message } = clientMessage.data + const networkError = new Error(message) + networkError.name = name + + // Rejecting a "respondWith" promise emulates a network error. + throw networkError + } + } + + return passthrough() +} + +function sendToClient(client, message) { + return new Promise((resolve, reject) => { + const channel = new MessageChannel() + + channel.port1.onmessage = (event) => { + if (event.data && event.data.error) { + return reject(event.data.error) + } + + resolve(event.data) + } + + client.postMessage(message, [channel.port2]) + }) +} + +function sleep(timeMs) { + return new Promise((resolve) => { + setTimeout(resolve, timeMs) + }) +} + +async function respondWithMock(response) { + await sleep(response.delay) + return new Response(response.body, response) +} diff --git a/frontend/.storybook/webpack.config.js b/frontend/.storybook/webpack.config.js new file mode 100644 index 000000000..7eca295d9 --- /dev/null +++ b/frontend/.storybook/webpack.config.js @@ -0,0 +1,17 @@ +const path = require('path'); +const Dotenv = require('dotenv-webpack'); + +const envPath = + process.env.NODE_ENV === 'development' + ? path.join(__dirname, '../webpack/.env.development') + : path.join(__dirname, '../webpack/.env.production'); + +module.exports = ({ config }) => { + config.plugins.push( + new Dotenv({ + path: envPath, + }) + ); + + return config; +}; diff --git a/frontend/package-lock.json b/frontend/package-lock.json index f748625d3..f0c1cbcbb 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -9,6 +9,8 @@ "version": "1.0.0", "license": "ISC", "dependencies": { + "@tanstack/react-query": "^4.29.19", + "dotenv": "^16.3.1", "react": "^18.2.0", "react-dom": "^18.2.0", "react-router-dom": "^6.14.1" @@ -30,6 +32,7 @@ "@types/react-dom": "^18.2.6", "@types/styled-components": "^5.1.26", "clean-webpack-plugin": "^4.0.0", + "dotenv-webpack": "^8.0.1", "eslint": "^8.44.0", "eslint-config-prettier": "^8.8.0", "eslint-config-react-app": "^7.0.1", @@ -38,6 +41,8 @@ "html-webpack-plugin": "^5.5.3", "jest": "^29.6.0", "jest-environment-jsdom": "^29.6.0", + "msw": "^1.2.2", + "msw-storybook-addon": "^1.8.0", "prettier": "^2.8.8", "storybook": "^7.0.26", "styled-components": "^6.0.2", @@ -3890,6 +3895,47 @@ "react": ">=16" } }, + "node_modules/@mswjs/cookies": { + "version": "0.2.2", + "resolved": "https://registry.npmjs.org/@mswjs/cookies/-/cookies-0.2.2.tgz", + "integrity": "sha512-mlN83YSrcFgk7Dm1Mys40DLssI1KdJji2CMKN8eOlBqsTADYzj2+jWzsANsUTFbxDMWPD5e9bfA1RGqBpS3O1g==", + "dev": true, + "dependencies": { + "@types/set-cookie-parser": "^2.4.0", + "set-cookie-parser": "^2.4.6" + }, + "engines": { + "node": ">=14" + } + }, + "node_modules/@mswjs/interceptors": { + "version": "0.17.9", + "resolved": "https://registry.npmjs.org/@mswjs/interceptors/-/interceptors-0.17.9.tgz", + "integrity": "sha512-4LVGt03RobMH/7ZrbHqRxQrS9cc2uh+iNKSj8UWr8M26A2i793ju+csaB5zaqYltqJmA2jUq4VeYfKmVqvsXQg==", + "dev": true, + "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", + "outvariant": "^1.2.1", + "strict-event-emitter": "^0.2.4", + "web-encoding": "^1.1.5" + }, + "engines": { + "node": ">=14" + } + }, + "node_modules/@mswjs/interceptors/node_modules/strict-event-emitter": { + "version": "0.2.8", + "resolved": "https://registry.npmjs.org/strict-event-emitter/-/strict-event-emitter-0.2.8.tgz", + "integrity": "sha512-KDf/ujU8Zud3YaLtMCcTI4xkZlZVIYxTLr+XIULexP+77EEVWixeXroLUXQXiVtH4XH2W7jr/3PT1v3zBuvc3A==", + "dev": true, + "dependencies": { + "events": "^3.3.0" + } + }, "node_modules/@ndelangen/get-tarball": { "version": "3.0.9", "resolved": "https://registry.npmjs.org/@ndelangen/get-tarball/-/get-tarball-3.0.9.tgz", @@ -3961,6 +4007,12 @@ "node": ">= 8" } }, + "node_modules/@open-draft/until": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@open-draft/until/-/until-1.0.3.tgz", + "integrity": "sha512-Aq58f5HiWdyDlFffbbSjAlv596h/cOnt2DO1w3DOC7OJ5EHs0hd/nycJfiu9RJbT6Yk6F1knnRRXNSpxoIVZ9Q==", + "dev": true + }, "node_modules/@pmmmwh/react-refresh-webpack-plugin": { "version": "0.5.10", "resolved": "https://registry.npmjs.org/@pmmmwh/react-refresh-webpack-plugin/-/react-refresh-webpack-plugin-0.5.10.tgz", @@ -6812,6 +6864,41 @@ "url": "https://opencollective.com/storybook" } }, + "node_modules/@tanstack/query-core": { + "version": "4.29.19", + "resolved": "https://registry.npmjs.org/@tanstack/query-core/-/query-core-4.29.19.tgz", + "integrity": "sha512-uPe1DukeIpIHpQi6UzIgBcXsjjsDaLnc7hF+zLBKnaUlh7jFE/A+P8t4cU4VzKPMFB/C970n/9SxtpO5hmIRgw==", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + } + }, + "node_modules/@tanstack/react-query": { + "version": "4.29.19", + "resolved": "https://registry.npmjs.org/@tanstack/react-query/-/react-query-4.29.19.tgz", + "integrity": "sha512-XiTIOHHQ5Cw1WUlHaD4fmVUMhoWjuNJlAeJGq7eM4BraI5z7y8WkZO+NR8PSuRnQGblpuVdjClQbDFtwxTtTUw==", + "dependencies": { + "@tanstack/query-core": "4.29.19", + "use-sync-external-store": "^1.2.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + }, + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0 || ^18.0.0", + "react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0", + "react-native": "*" + }, + "peerDependenciesMeta": { + "react-dom": { + "optional": true + }, + "react-native": { + "optional": true + } + } + }, "node_modules/@testing-library/dom": { "version": "8.20.1", "resolved": "https://registry.npmjs.org/@testing-library/dom/-/dom-8.20.1.tgz", @@ -7136,6 +7223,21 @@ "@types/node": "*" } }, + "node_modules/@types/cookie": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/@types/cookie/-/cookie-0.4.1.tgz", + "integrity": "sha512-XW/Aa8APYr6jSVVA1y/DEIZX0/GMKLEVekNG727R8cs56ahETkRAy/3DR7+fJyh7oUgGwNQaRfXCun0+KbWY7Q==", + "dev": true + }, + "node_modules/@types/debug": { + "version": "4.1.8", + "resolved": "https://registry.npmjs.org/@types/debug/-/debug-4.1.8.tgz", + "integrity": "sha512-/vPO1EPOs306Cvhwv7KfVfYvOJqA/S/AXjaHQiJboCZzcNDb+TIJFN9/2C9DZ//ijSKWioNyUxD792QmDJ+HKQ==", + "dev": true, + "dependencies": { + "@types/ms": "*" + } + }, "node_modules/@types/detect-port": { "version": "1.3.3", "resolved": "https://registry.npmjs.org/@types/detect-port/-/detect-port-1.3.3.tgz", @@ -7332,6 +7434,12 @@ "integrity": "sha512-xWGDIW6x921xtzPkhiULtthJHoJvBbF3q26fzloPCK0hsvxtPVelvftw3zjbHWSkR2km9Z+4uxbDDK/6Zw9B8w==", "dev": true }, + "node_modules/@types/js-levenshtein": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@types/js-levenshtein/-/js-levenshtein-1.1.1.tgz", + "integrity": "sha512-qC4bCqYGy1y/NP7dDVr7KJarn+PbX1nSpwA7JXdu0HxT3QYjO8MJ+cntENtHFVy2dRAyBV23OZ6MxsW1AM1L8g==", + "dev": true + }, "node_modules/@types/jsdom": { "version": "20.0.1", "resolved": "https://registry.npmjs.org/@types/jsdom/-/jsdom-20.0.1.tgz", @@ -7385,6 +7493,12 @@ "integrity": "sha512-K0VQKziLUWkVKiRVrx4a40iPaxTUefQmjtkQofBkYRcoaaL/8rhwDWww9qWbrgicNOgnpIsMxyNIUM4+n6dUIA==", "dev": true }, + "node_modules/@types/ms": { + "version": "0.7.31", + "resolved": "https://registry.npmjs.org/@types/ms/-/ms-0.7.31.tgz", + "integrity": "sha512-iiUgKzV9AuaEkZqkOLDIvlQiL6ltuZd9tGcW3gwpnX8JbuiuhFlEGmmFXEXkN50Cvq7Os88IY2v0dkDqXYWVgA==", + "dev": true + }, "node_modules/@types/node": { "version": "20.3.3", "resolved": "https://registry.npmjs.org/@types/node/-/node-20.3.3.tgz", @@ -7517,6 +7631,15 @@ "@types/node": "*" } }, + "node_modules/@types/set-cookie-parser": { + "version": "2.4.2", + "resolved": "https://registry.npmjs.org/@types/set-cookie-parser/-/set-cookie-parser-2.4.2.tgz", + "integrity": "sha512-fBZgytwhYAUkj/jC/FAV4RQ5EerRup1YQsXQCh8rZfiHkc4UahC192oH0smGwsXol3cL3A5oETuAHeQHmhXM4w==", + "dev": true, + "dependencies": { + "@types/node": "*" + } + }, "node_modules/@types/sockjs": { "version": "0.3.33", "resolved": "https://registry.npmjs.org/@types/sockjs/-/sockjs-0.3.33.tgz", @@ -8081,6 +8204,15 @@ } } }, + "node_modules/@xmldom/xmldom": { + "version": "0.8.9", + "resolved": "https://registry.npmjs.org/@xmldom/xmldom/-/xmldom-0.8.9.tgz", + "integrity": "sha512-4VSbbcMoxc4KLjb1gs96SRmi7w4h1SF+fCoiK0XaQX62buCc1G5d0DC5bJ9xJBNPDSVCmIrcl8BiYxzjrqaaJA==", + "dev": true, + "engines": { + "node": ">=10.0.0" + } + }, "node_modules/@xtuc/ieee754": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/@xtuc/ieee754/-/ieee754-1.2.0.tgz", @@ -8108,6 +8240,13 @@ "esbuild": ">=0.10.0" } }, + "node_modules/@zxing/text-encoding": { + "version": "0.9.0", + "resolved": "https://registry.npmjs.org/@zxing/text-encoding/-/text-encoding-0.9.0.tgz", + "integrity": "sha512-U/4aVJ2mxI0aDNI8Uq0wEhMgY+u4CNtEb0om3+y3+niDAsoTCOB33UF0sxpzqzdqXLqmvc+vZyAt4O8pPdfkwA==", + "dev": true, + "optional": true + }, "node_modules/abab": { "version": "2.0.6", "resolved": "https://registry.npmjs.org/abab/-/abab-2.0.6.tgz", @@ -9357,6 +9496,12 @@ "node": ">=10" } }, + "node_modules/chardet": { + "version": "0.7.0", + "resolved": "https://registry.npmjs.org/chardet/-/chardet-0.7.0.tgz", + "integrity": "sha512-mT8iDcrh03qDGRRmoA2hmBJnxpllMR+0/0qlzjqZES6NdiWDcZkCNAk4rPFZ9Q85r27unkiNNg8ZOiwZXBHwcA==", + "dev": true + }, "node_modules/chokidar": { "version": "3.5.3", "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-3.5.3.tgz", @@ -9498,6 +9643,15 @@ "@colors/colors": "1.5.0" } }, + "node_modules/cli-width": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/cli-width/-/cli-width-3.0.0.tgz", + "integrity": "sha512-FxqpkPPwu1HjuN93Omfm4h8uIanXofW0RxVEW3k5RKx+mJJYSthzNhp32Kzxxy3YAEZ/Dc/EWN1vZRY0+kOhbw==", + "dev": true, + "engines": { + "node": ">= 10" + } + }, "node_modules/cliui": { "version": "7.0.4", "resolved": "https://registry.npmjs.org/cliui/-/cliui-7.0.4.tgz", @@ -10531,7 +10685,6 @@ "version": "16.3.1", "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-16.3.1.tgz", "integrity": "sha512-IPzF4w4/Rd94bA9imS68tZBaYyBWSCE47V1RGuMrB94iyTOIEwRmVL2x/4An+6mETpLrKJ5hQkB8W4kFAadeIQ==", - "dev": true, "engines": { "node": ">=12" }, @@ -10539,6 +10692,24 @@ "url": "https://github.com/motdotla/dotenv?sponsor=1" } }, + "node_modules/dotenv-defaults": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/dotenv-defaults/-/dotenv-defaults-2.0.2.tgz", + "integrity": "sha512-iOIzovWfsUHU91L5i8bJce3NYK5JXeAwH50Jh6+ARUdLiiGlYWfGw6UkzsYqaXZH/hjE/eCd/PlfM/qqyK0AMg==", + "dev": true, + "dependencies": { + "dotenv": "^8.2.0" + } + }, + "node_modules/dotenv-defaults/node_modules/dotenv": { + "version": "8.6.0", + "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-8.6.0.tgz", + "integrity": "sha512-IrPdXQsk2BbzvCBGBOTmmSH5SodmqZNt4ERAZDmW4CT+tL8VtvinqywuANaFu4bOMWki16nqf0e4oC0QIaDr/g==", + "dev": true, + "engines": { + "node": ">=10" + } + }, "node_modules/dotenv-expand": { "version": "10.0.0", "resolved": "https://registry.npmjs.org/dotenv-expand/-/dotenv-expand-10.0.0.tgz", @@ -10548,6 +10719,21 @@ "node": ">=12" } }, + "node_modules/dotenv-webpack": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/dotenv-webpack/-/dotenv-webpack-8.0.1.tgz", + "integrity": "sha512-CdrgfhZOnx4uB18SgaoP9XHRN2v48BbjuXQsZY5ixs5A8579NxQkmMxRtI7aTwSiSQcM2ao12Fdu+L3ZS3bG4w==", + "dev": true, + "dependencies": { + "dotenv-defaults": "^2.0.2" + }, + "engines": { + "node": ">=10" + }, + "peerDependencies": { + "webpack": "^4 || ^5" + } + }, "node_modules/duplexify": { "version": "3.7.1", "resolved": "https://registry.npmjs.org/duplexify/-/duplexify-3.7.1.tgz", @@ -11834,6 +12020,20 @@ "integrity": "sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g==", "dev": true }, + "node_modules/external-editor": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/external-editor/-/external-editor-3.1.0.tgz", + "integrity": "sha512-hMQ4CX1p1izmuLYyZqLMO/qGNw10wSv9QDCPfzXfyFrOaCSSoRfqE1Kf1s5an66J5JZC62NewG+mK49jOCtQew==", + "dev": true, + "dependencies": { + "chardet": "^0.7.0", + "iconv-lite": "^0.4.24", + "tmp": "^0.0.33" + }, + "engines": { + "node": ">=4" + } + }, "node_modules/extract-zip": { "version": "1.7.0", "resolved": "https://registry.npmjs.org/extract-zip/-/extract-zip-1.7.0.tgz", @@ -11958,6 +12158,21 @@ "integrity": "sha512-3yurQZ2hD9VISAhJJP9bpYFNQrHHBXE2JxxjY5aLEcDi46RmAzJE2OC9FAde0yis5ElW0jTTzs0zfg/Cca4XqQ==", "dev": true }, + "node_modules/figures": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/figures/-/figures-3.2.0.tgz", + "integrity": "sha512-yaduQFRKLXYOGgEn6AZau90j3ggSOyiqXU0F9JZfeXYhNa+Jk4X+s45A2zg5jns87GAFa34BBm2kXw4XpNcbdg==", + "dev": true, + "dependencies": { + "escape-string-regexp": "^1.0.5" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/file-entry-cache": { "version": "6.0.1", "resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-6.0.1.tgz", @@ -12807,6 +13022,15 @@ "integrity": "sha512-EtKwoO6kxCL9WO5xipiHTZlSzBm7WLT627TqC/uVRd0HKmq8NXyebnNYxDoBi7wt8eTWrUrKXCOVaFq9x1kgag==", "dev": true }, + "node_modules/graphql": { + "version": "16.7.1", + "resolved": "https://registry.npmjs.org/graphql/-/graphql-16.7.1.tgz", + "integrity": "sha512-DRYR9tf+UGU0KOsMcKAlXeFfX89UiiIZ0dRU3mR0yJfu6OjZqUcp68NnFLnqQU5RexygFoDy1EW+ccOYcPfmHg==", + "dev": true, + "engines": { + "node": "^12.22.0 || ^14.16.0 || ^16.0.0 || >=17.0.0" + } + }, "node_modules/gunzip-maybe": { "version": "1.4.2", "resolved": "https://registry.npmjs.org/gunzip-maybe/-/gunzip-maybe-1.4.2.tgz", @@ -12947,6 +13171,12 @@ "he": "bin/he" } }, + "node_modules/headers-polyfill": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/headers-polyfill/-/headers-polyfill-3.1.2.tgz", + "integrity": "sha512-tWCK4biJ6hcLqTviLXVR9DTRfYGQMXEIUj3gwJ2rZ5wO/at3XtkI4g8mCvFdUF9l1KMBNCfmNAdnahm1cgavQA==", + "dev": true + }, "node_modules/hoist-non-react-statics": { "version": "3.3.2", "resolved": "https://registry.npmjs.org/hoist-non-react-statics/-/hoist-non-react-statics-3.3.2.tgz", @@ -13340,6 +13570,102 @@ "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", "dev": true }, + "node_modules/inquirer": { + "version": "8.2.5", + "resolved": "https://registry.npmjs.org/inquirer/-/inquirer-8.2.5.tgz", + "integrity": "sha512-QAgPDQMEgrDssk1XiwwHoOGYF9BAbUcc1+j+FhEvaOt8/cKRqyLn0U5qA6F74fGhTMGxf92pOvPBeh29jQJDTQ==", + "dev": true, + "dependencies": { + "ansi-escapes": "^4.2.1", + "chalk": "^4.1.1", + "cli-cursor": "^3.1.0", + "cli-width": "^3.0.0", + "external-editor": "^3.0.3", + "figures": "^3.0.0", + "lodash": "^4.17.21", + "mute-stream": "0.0.8", + "ora": "^5.4.1", + "run-async": "^2.4.0", + "rxjs": "^7.5.5", + "string-width": "^4.1.0", + "strip-ansi": "^6.0.0", + "through": "^2.3.6", + "wrap-ansi": "^7.0.0" + }, + "engines": { + "node": ">=12.0.0" + } + }, + "node_modules/inquirer/node_modules/ansi-styles": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", + "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", + "dev": true, + "dependencies": { + "color-convert": "^2.0.1" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/inquirer/node_modules/chalk": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", + "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", + "dev": true, + "dependencies": { + "ansi-styles": "^4.1.0", + "supports-color": "^7.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/chalk?sponsor=1" + } + }, + "node_modules/inquirer/node_modules/color-convert": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", + "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", + "dev": true, + "dependencies": { + "color-name": "~1.1.4" + }, + "engines": { + "node": ">=7.0.0" + } + }, + "node_modules/inquirer/node_modules/color-name": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", + "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", + "dev": true + }, + "node_modules/inquirer/node_modules/has-flag": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", + "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", + "dev": true, + "engines": { + "node": ">=8" + } + }, + "node_modules/inquirer/node_modules/supports-color": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", + "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", + "dev": true, + "dependencies": { + "has-flag": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/internal-slot": { "version": "1.0.5", "resolved": "https://registry.npmjs.org/internal-slot/-/internal-slot-1.0.5.tgz", @@ -13632,6 +13958,12 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/is-node-process": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/is-node-process/-/is-node-process-1.2.0.tgz", + "integrity": "sha512-Vg4o6/fqPxIjtxgUH5QLJhwZ7gW5diGCVlXpuUfELC62CuxM1iHcRe51f2W1FDy04Ai4KJkagKjx3XaqyfRKXw==", + "dev": true + }, "node_modules/is-number": { "version": "7.0.0", "resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz", @@ -16335,6 +16667,15 @@ "url": "https://github.com/chalk/supports-color?sponsor=1" } }, + "node_modules/js-levenshtein": { + "version": "1.1.6", + "resolved": "https://registry.npmjs.org/js-levenshtein/-/js-levenshtein-1.1.6.tgz", + "integrity": "sha512-X2BB11YZtrRqY4EnQcLX5Rh373zbK4alC1FW7D7MBhL2gtcC17cTnr6DmfHZeS0s2rTHjUTMMHfG7gO8SSdw+g==", + "dev": true, + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/js-tokens": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", @@ -17213,6 +17554,202 @@ "integrity": "sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w==", "dev": true }, + "node_modules/msw": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/msw/-/msw-1.2.2.tgz", + "integrity": "sha512-GsW3PE/Es/a1tYThXcM8YHOZ1S1MtivcS3He/LQbbTCx3rbWJYCtWD5XXyJ53KlNPT7O1VI9sCW3xMtgFe8XpQ==", + "dev": true, + "hasInstallScript": true, + "dependencies": { + "@mswjs/cookies": "^0.2.2", + "@mswjs/interceptors": "^0.17.5", + "@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.1.2", + "inquirer": "^8.2.0", + "is-node-process": "^1.2.0", + "js-levenshtein": "^1.1.6", + "node-fetch": "^2.6.7", + "outvariant": "^1.4.0", + "path-to-regexp": "^6.2.0", + "strict-event-emitter": "^0.4.3", + "type-fest": "^2.19.0", + "yargs": "^17.3.1" + }, + "bin": { + "msw": "cli/index.js" + }, + "engines": { + "node": ">=14" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/mswjs" + }, + "peerDependencies": { + "typescript": ">= 4.4.x <= 5.1.x" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/msw-storybook-addon": { + "version": "1.8.0", + "resolved": "https://registry.npmjs.org/msw-storybook-addon/-/msw-storybook-addon-1.8.0.tgz", + "integrity": "sha512-dw3vZwqjixmiur0vouRSOax7wPSu9Og2Hspy9JZFHf49bZRjwDiLF0Pfn2NXEkGviYJOJiGxS1ejoTiUwoSg4A==", + "dev": true, + "dependencies": { + "is-node-process": "^1.0.1" + }, + "peerDependencies": { + "msw": ">=0.35.0 <2.0.0" + } + }, + "node_modules/msw/node_modules/ansi-styles": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", + "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", + "dev": true, + "dependencies": { + "color-convert": "^2.0.1" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/msw/node_modules/chalk": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.1.tgz", + "integrity": "sha512-diHzdDKxcU+bAsUboHLPEDQiw0qEe0qd7SYUn3HgcFlWgbDcfLGswOHYeGrHKzG9z6UYf01d9VFMfZxPM1xZSg==", + "dev": true, + "dependencies": { + "ansi-styles": "^4.1.0", + "supports-color": "^7.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/chalk?sponsor=1" + } + }, + "node_modules/msw/node_modules/cliui": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/cliui/-/cliui-8.0.1.tgz", + "integrity": "sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ==", + "dev": true, + "dependencies": { + "string-width": "^4.2.0", + "strip-ansi": "^6.0.1", + "wrap-ansi": "^7.0.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/msw/node_modules/color-convert": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", + "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", + "dev": true, + "dependencies": { + "color-name": "~1.1.4" + }, + "engines": { + "node": ">=7.0.0" + } + }, + "node_modules/msw/node_modules/color-name": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", + "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", + "dev": true + }, + "node_modules/msw/node_modules/cookie": { + "version": "0.4.2", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.4.2.tgz", + "integrity": "sha512-aSWTXFzaKWkvHO1Ny/s+ePFpvKsPnjc551iI41v3ny/ow6tBG5Vd+FuqGNhh1LxOmVzOlGUriIlOaokOvhaStA==", + "dev": true, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/msw/node_modules/has-flag": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", + "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", + "dev": true, + "engines": { + "node": ">=8" + } + }, + "node_modules/msw/node_modules/path-to-regexp": { + "version": "6.2.1", + "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-6.2.1.tgz", + "integrity": "sha512-JLyh7xT1kizaEvcaXOQwOc2/Yhw6KZOvPf1S8401UyLk86CU79LN3vl7ztXGm/pZ+YjoyAJ4rxmHwbkBXJX+yw==", + "dev": true + }, + "node_modules/msw/node_modules/supports-color": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", + "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", + "dev": true, + "dependencies": { + "has-flag": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/msw/node_modules/type-fest": { + "version": "2.19.0", + "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-2.19.0.tgz", + "integrity": "sha512-RAH822pAdBgcNMAfWnCBU3CFZcfZ/i1eZjwFU/dsLKumyuuP3niueg2UAukXYF0E2AAoc82ZSSf9J0WQBinzHA==", + "dev": true, + "engines": { + "node": ">=12.20" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/msw/node_modules/yargs": { + "version": "17.7.2", + "resolved": "https://registry.npmjs.org/yargs/-/yargs-17.7.2.tgz", + "integrity": "sha512-7dSzzRQ++CKnNI/krKnYRV7JKKPUXMEh61soaHKg9mrWEhzFWhFnxPxGl+69cD1Ou63C13NUPCnmIcrvqCuM6w==", + "dev": true, + "dependencies": { + "cliui": "^8.0.1", + "escalade": "^3.1.1", + "get-caller-file": "^2.0.5", + "require-directory": "^2.1.1", + "string-width": "^4.2.3", + "y18n": "^5.0.5", + "yargs-parser": "^21.1.1" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/msw/node_modules/yargs-parser": { + "version": "21.1.1", + "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-21.1.1.tgz", + "integrity": "sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw==", + "dev": true, + "engines": { + "node": ">=12" + } + }, "node_modules/multicast-dns": { "version": "7.2.5", "resolved": "https://registry.npmjs.org/multicast-dns/-/multicast-dns-7.2.5.tgz", @@ -17226,6 +17763,12 @@ "multicast-dns": "cli.js" } }, + "node_modules/mute-stream": { + "version": "0.0.8", + "resolved": "https://registry.npmjs.org/mute-stream/-/mute-stream-0.0.8.tgz", + "integrity": "sha512-nnbWWOkoWyUsTjKrhgD0dcz22mdkSnpYqbEjIm2nhwhuxlSkpywJmBo8h0ZqJdkp73mb90SssHkN4rsRaBAfAA==", + "dev": true + }, "node_modules/nanoid": { "version": "3.3.6", "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.6.tgz", @@ -17703,6 +18246,21 @@ "node": ">=8" } }, + "node_modules/os-tmpdir": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/os-tmpdir/-/os-tmpdir-1.0.2.tgz", + "integrity": "sha512-D2FR03Vir7FIu45XBY20mTb+/ZSWB00sjU9jdQXt83gDrI4Ztz5Fs7/yy74g2N5SVQY4xY1qDr4rNddwYRVX0g==", + "dev": true, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/outvariant": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/outvariant/-/outvariant-1.4.0.tgz", + "integrity": "sha512-AlWY719RF02ujitly7Kk/0QlV+pXGFDHrHf9O2OKqyqgBieaPOIeuSkL8sRK6j2WK+/ZAURq2kZsY0d8JapUiw==", + "dev": true + }, "node_modules/p-limit": { "version": "2.3.0", "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-2.3.0.tgz", @@ -19039,6 +19597,15 @@ "url": "https://github.com/sponsors/isaacs" } }, + "node_modules/run-async": { + "version": "2.4.1", + "resolved": "https://registry.npmjs.org/run-async/-/run-async-2.4.1.tgz", + "integrity": "sha512-tvVnVv01b8c1RrA6Ep7JkStj85Guv/YrMcwqYQnwjsAS2cTmmPGBBjAjpCW7RrSodNSoE2/qg9O4bceNvUuDgQ==", + "dev": true, + "engines": { + "node": ">=0.12.0" + } + }, "node_modules/run-parallel": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/run-parallel/-/run-parallel-1.2.0.tgz", @@ -19062,6 +19629,15 @@ "queue-microtask": "^1.2.2" } }, + "node_modules/rxjs": { + "version": "7.8.1", + "resolved": "https://registry.npmjs.org/rxjs/-/rxjs-7.8.1.tgz", + "integrity": "sha512-AA3TVj+0A2iuIoQkWEK/tqFjBq2j+6PO6Y0zJcvzLAFhEFIO3HL0vls9hWLncZbAAbK0mar7oZ4V079I/qPMxg==", + "dev": true, + "dependencies": { + "tslib": "^2.1.0" + } + }, "node_modules/safe-buffer": { "version": "5.2.1", "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", @@ -19349,6 +19925,12 @@ "integrity": "sha512-KiKBS8AnWGEyLzofFfmvKwpdPzqiy16LvQfK3yv/fVH7Bj13/wl3JSR1J+rfgRE9q7xUJK4qvgS8raSOeLUehw==", "dev": true }, + "node_modules/set-cookie-parser": { + "version": "2.6.0", + "resolved": "https://registry.npmjs.org/set-cookie-parser/-/set-cookie-parser-2.6.0.tgz", + "integrity": "sha512-RVnVQxTXuerk653XfuliOxBP81Sf0+qfQE73LIYKcyMYHG94AuH0kgrQpRDuTZnSmjpysHmzxJXKNfa6PjFhyQ==", + "dev": true + }, "node_modules/setprototypeof": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz", @@ -19711,6 +20293,12 @@ "integrity": "sha512-AiisoFqQ0vbGcZgQPY1cdP2I76glaVA/RauYR4G4thNFgkTqr90yXTo4LYX60Jl+sIlPNHHdGSwo01AvbKUSVQ==", "dev": true }, + "node_modules/strict-event-emitter": { + "version": "0.4.6", + "resolved": "https://registry.npmjs.org/strict-event-emitter/-/strict-event-emitter-0.4.6.tgz", + "integrity": "sha512-12KWeb+wixJohmnwNFerbyiBrAlq5qJLwIt38etRtKtmmHyDSoGlIqFE9wx+4IwG0aDjI7GV8tc8ZccjWZZtTg==", + "dev": true + }, "node_modules/string_decoder": { "version": "1.3.0", "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.3.0.tgz", @@ -20316,6 +20904,12 @@ "integrity": "sha512-N+8UisAXDGk8PFXP4HAzVR9nbfmVJ3zYLAWiTIoqC5v5isinhr+r5uaO8+7r3BMfuNIufIsA7RdpVgacC2cSpw==", "dev": true }, + "node_modules/through": { + "version": "2.3.8", + "resolved": "https://registry.npmjs.org/through/-/through-2.3.8.tgz", + "integrity": "sha512-w89qg7PI8wAdvX60bMDP+bFoD5Dvhm9oLheFp5O4a2QF0cSBGsBX4qZmadPMvVqlLJBBci+WqGGOAPvcDeNSVg==", + "dev": true + }, "node_modules/through2": { "version": "2.0.5", "resolved": "https://registry.npmjs.org/through2/-/through2-2.0.5.tgz", @@ -20362,6 +20956,18 @@ "integrity": "sha512-eHY7nBftgThBqOyHGVN+l8gF0BucP09fMo0oO/Lb0w1OF80dJv+lDVpXG60WMQvkcxAkNybKsrEIE3ZtKGmPrA==", "dev": true }, + "node_modules/tmp": { + "version": "0.0.33", + "resolved": "https://registry.npmjs.org/tmp/-/tmp-0.0.33.tgz", + "integrity": "sha512-jRCJlojKnZ3addtTOjdIqoRuPEKBvNXcGYqzO6zWZX8KfKEpnGY5jfggJQ3EjKuu8D4bJRr0y+cYJFmYbImXGw==", + "dev": true, + "dependencies": { + "os-tmpdir": "~1.0.2" + }, + "engines": { + "node": ">=0.6.0" + } + }, "node_modules/tmpl": { "version": "1.0.5", "resolved": "https://registry.npmjs.org/tmpl/-/tmpl-1.0.5.tgz", @@ -21013,6 +21619,14 @@ "react-dom": "16.8.0 - 18" } }, + "node_modules/use-sync-external-store": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/use-sync-external-store/-/use-sync-external-store-1.2.0.tgz", + "integrity": "sha512-eEgnFxGQ1Ife9bzYs6VLi8/4X6CObHMw9Qr9tPY43iKwsPw8xE8+EFsf/2cFZ5S3esXgpWgtSCtLNS41F+sKPA==", + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0 || ^18.0.0" + } + }, "node_modules/util": { "version": "0.12.5", "resolved": "https://registry.npmjs.org/util/-/util-0.12.5.tgz", @@ -21141,6 +21755,18 @@ "defaults": "^1.0.3" } }, + "node_modules/web-encoding": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/web-encoding/-/web-encoding-1.1.5.tgz", + "integrity": "sha512-HYLeVCdJ0+lBYV2FvNZmv3HJ2Nt0QYXqZojk3d9FJOLkwnuhzM9tmamh8d7HPM8QqjKH8DeHkFTx+CFlWpZZDA==", + "dev": true, + "dependencies": { + "util": "^0.12.3" + }, + "optionalDependencies": { + "@zxing/text-encoding": "0.9.0" + } + }, "node_modules/webidl-conversions": { "version": "3.0.1", "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-3.0.1.tgz", @@ -24391,6 +25017,43 @@ "@types/react": ">=16" } }, + "@mswjs/cookies": { + "version": "0.2.2", + "resolved": "https://registry.npmjs.org/@mswjs/cookies/-/cookies-0.2.2.tgz", + "integrity": "sha512-mlN83YSrcFgk7Dm1Mys40DLssI1KdJji2CMKN8eOlBqsTADYzj2+jWzsANsUTFbxDMWPD5e9bfA1RGqBpS3O1g==", + "dev": true, + "requires": { + "@types/set-cookie-parser": "^2.4.0", + "set-cookie-parser": "^2.4.6" + } + }, + "@mswjs/interceptors": { + "version": "0.17.9", + "resolved": "https://registry.npmjs.org/@mswjs/interceptors/-/interceptors-0.17.9.tgz", + "integrity": "sha512-4LVGt03RobMH/7ZrbHqRxQrS9cc2uh+iNKSj8UWr8M26A2i793ju+csaB5zaqYltqJmA2jUq4VeYfKmVqvsXQg==", + "dev": true, + "requires": { + "@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", + "outvariant": "^1.2.1", + "strict-event-emitter": "^0.2.4", + "web-encoding": "^1.1.5" + }, + "dependencies": { + "strict-event-emitter": { + "version": "0.2.8", + "resolved": "https://registry.npmjs.org/strict-event-emitter/-/strict-event-emitter-0.2.8.tgz", + "integrity": "sha512-KDf/ujU8Zud3YaLtMCcTI4xkZlZVIYxTLr+XIULexP+77EEVWixeXroLUXQXiVtH4XH2W7jr/3PT1v3zBuvc3A==", + "dev": true, + "requires": { + "events": "^3.3.0" + } + } + } + }, "@ndelangen/get-tarball": { "version": "3.0.9", "resolved": "https://registry.npmjs.org/@ndelangen/get-tarball/-/get-tarball-3.0.9.tgz", @@ -24450,6 +25113,12 @@ "fastq": "^1.6.0" } }, + "@open-draft/until": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@open-draft/until/-/until-1.0.3.tgz", + "integrity": "sha512-Aq58f5HiWdyDlFffbbSjAlv596h/cOnt2DO1w3DOC7OJ5EHs0hd/nycJfiu9RJbT6Yk6F1knnRRXNSpxoIVZ9Q==", + "dev": true + }, "@pmmmwh/react-refresh-webpack-plugin": { "version": "0.5.10", "resolved": "https://registry.npmjs.org/@pmmmwh/react-refresh-webpack-plugin/-/react-refresh-webpack-plugin-0.5.10.tgz", @@ -26513,6 +27182,20 @@ "file-system-cache": "2.3.0" } }, + "@tanstack/query-core": { + "version": "4.29.19", + "resolved": "https://registry.npmjs.org/@tanstack/query-core/-/query-core-4.29.19.tgz", + "integrity": "sha512-uPe1DukeIpIHpQi6UzIgBcXsjjsDaLnc7hF+zLBKnaUlh7jFE/A+P8t4cU4VzKPMFB/C970n/9SxtpO5hmIRgw==" + }, + "@tanstack/react-query": { + "version": "4.29.19", + "resolved": "https://registry.npmjs.org/@tanstack/react-query/-/react-query-4.29.19.tgz", + "integrity": "sha512-XiTIOHHQ5Cw1WUlHaD4fmVUMhoWjuNJlAeJGq7eM4BraI5z7y8WkZO+NR8PSuRnQGblpuVdjClQbDFtwxTtTUw==", + "requires": { + "@tanstack/query-core": "4.29.19", + "use-sync-external-store": "^1.2.0" + } + }, "@testing-library/dom": { "version": "8.20.1", "resolved": "https://registry.npmjs.org/@testing-library/dom/-/dom-8.20.1.tgz", @@ -26776,6 +27459,21 @@ "@types/node": "*" } }, + "@types/cookie": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/@types/cookie/-/cookie-0.4.1.tgz", + "integrity": "sha512-XW/Aa8APYr6jSVVA1y/DEIZX0/GMKLEVekNG727R8cs56ahETkRAy/3DR7+fJyh7oUgGwNQaRfXCun0+KbWY7Q==", + "dev": true + }, + "@types/debug": { + "version": "4.1.8", + "resolved": "https://registry.npmjs.org/@types/debug/-/debug-4.1.8.tgz", + "integrity": "sha512-/vPO1EPOs306Cvhwv7KfVfYvOJqA/S/AXjaHQiJboCZzcNDb+TIJFN9/2C9DZ//ijSKWioNyUxD792QmDJ+HKQ==", + "dev": true, + "requires": { + "@types/ms": "*" + } + }, "@types/detect-port": { "version": "1.3.3", "resolved": "https://registry.npmjs.org/@types/detect-port/-/detect-port-1.3.3.tgz", @@ -26965,6 +27663,12 @@ } } }, + "@types/js-levenshtein": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@types/js-levenshtein/-/js-levenshtein-1.1.1.tgz", + "integrity": "sha512-qC4bCqYGy1y/NP7dDVr7KJarn+PbX1nSpwA7JXdu0HxT3QYjO8MJ+cntENtHFVy2dRAyBV23OZ6MxsW1AM1L8g==", + "dev": true + }, "@types/jsdom": { "version": "20.0.1", "resolved": "https://registry.npmjs.org/@types/jsdom/-/jsdom-20.0.1.tgz", @@ -27018,6 +27722,12 @@ "integrity": "sha512-K0VQKziLUWkVKiRVrx4a40iPaxTUefQmjtkQofBkYRcoaaL/8rhwDWww9qWbrgicNOgnpIsMxyNIUM4+n6dUIA==", "dev": true }, + "@types/ms": { + "version": "0.7.31", + "resolved": "https://registry.npmjs.org/@types/ms/-/ms-0.7.31.tgz", + "integrity": "sha512-iiUgKzV9AuaEkZqkOLDIvlQiL6ltuZd9tGcW3gwpnX8JbuiuhFlEGmmFXEXkN50Cvq7Os88IY2v0dkDqXYWVgA==", + "dev": true + }, "@types/node": { "version": "20.3.3", "resolved": "https://registry.npmjs.org/@types/node/-/node-20.3.3.tgz", @@ -27150,6 +27860,15 @@ "@types/node": "*" } }, + "@types/set-cookie-parser": { + "version": "2.4.2", + "resolved": "https://registry.npmjs.org/@types/set-cookie-parser/-/set-cookie-parser-2.4.2.tgz", + "integrity": "sha512-fBZgytwhYAUkj/jC/FAV4RQ5EerRup1YQsXQCh8rZfiHkc4UahC192oH0smGwsXol3cL3A5oETuAHeQHmhXM4w==", + "dev": true, + "requires": { + "@types/node": "*" + } + }, "@types/sockjs": { "version": "0.3.33", "resolved": "https://registry.npmjs.org/@types/sockjs/-/sockjs-0.3.33.tgz", @@ -27571,6 +28290,12 @@ "dev": true, "requires": {} }, + "@xmldom/xmldom": { + "version": "0.8.9", + "resolved": "https://registry.npmjs.org/@xmldom/xmldom/-/xmldom-0.8.9.tgz", + "integrity": "sha512-4VSbbcMoxc4KLjb1gs96SRmi7w4h1SF+fCoiK0XaQX62buCc1G5d0DC5bJ9xJBNPDSVCmIrcl8BiYxzjrqaaJA==", + "dev": true + }, "@xtuc/ieee754": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/@xtuc/ieee754/-/ieee754-1.2.0.tgz", @@ -27592,6 +28317,13 @@ "tslib": "^2.4.0" } }, + "@zxing/text-encoding": { + "version": "0.9.0", + "resolved": "https://registry.npmjs.org/@zxing/text-encoding/-/text-encoding-0.9.0.tgz", + "integrity": "sha512-U/4aVJ2mxI0aDNI8Uq0wEhMgY+u4CNtEb0om3+y3+niDAsoTCOB33UF0sxpzqzdqXLqmvc+vZyAt4O8pPdfkwA==", + "dev": true, + "optional": true + }, "abab": { "version": "2.0.6", "resolved": "https://registry.npmjs.org/abab/-/abab-2.0.6.tgz", @@ -28523,6 +29255,12 @@ "integrity": "sha512-kWWXztvZ5SBQV+eRgKFeh8q5sLuZY2+8WUIzlxWVTg+oGwY14qylx1KbKzHd8P6ZYkAg0xyIDU9JMHhyJMZ1jw==", "dev": true }, + "chardet": { + "version": "0.7.0", + "resolved": "https://registry.npmjs.org/chardet/-/chardet-0.7.0.tgz", + "integrity": "sha512-mT8iDcrh03qDGRRmoA2hmBJnxpllMR+0/0qlzjqZES6NdiWDcZkCNAk4rPFZ9Q85r27unkiNNg8ZOiwZXBHwcA==", + "dev": true + }, "chokidar": { "version": "3.5.3", "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-3.5.3.tgz", @@ -28612,6 +29350,12 @@ "string-width": "^4.2.0" } }, + "cli-width": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/cli-width/-/cli-width-3.0.0.tgz", + "integrity": "sha512-FxqpkPPwu1HjuN93Omfm4h8uIanXofW0RxVEW3k5RKx+mJJYSthzNhp32Kzxxy3YAEZ/Dc/EWN1vZRY0+kOhbw==", + "dev": true + }, "cliui": { "version": "7.0.4", "resolved": "https://registry.npmjs.org/cliui/-/cliui-7.0.4.tgz", @@ -29437,8 +30181,24 @@ "dotenv": { "version": "16.3.1", "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-16.3.1.tgz", - "integrity": "sha512-IPzF4w4/Rd94bA9imS68tZBaYyBWSCE47V1RGuMrB94iyTOIEwRmVL2x/4An+6mETpLrKJ5hQkB8W4kFAadeIQ==", - "dev": true + "integrity": "sha512-IPzF4w4/Rd94bA9imS68tZBaYyBWSCE47V1RGuMrB94iyTOIEwRmVL2x/4An+6mETpLrKJ5hQkB8W4kFAadeIQ==" + }, + "dotenv-defaults": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/dotenv-defaults/-/dotenv-defaults-2.0.2.tgz", + "integrity": "sha512-iOIzovWfsUHU91L5i8bJce3NYK5JXeAwH50Jh6+ARUdLiiGlYWfGw6UkzsYqaXZH/hjE/eCd/PlfM/qqyK0AMg==", + "dev": true, + "requires": { + "dotenv": "^8.2.0" + }, + "dependencies": { + "dotenv": { + "version": "8.6.0", + "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-8.6.0.tgz", + "integrity": "sha512-IrPdXQsk2BbzvCBGBOTmmSH5SodmqZNt4ERAZDmW4CT+tL8VtvinqywuANaFu4bOMWki16nqf0e4oC0QIaDr/g==", + "dev": true + } + } }, "dotenv-expand": { "version": "10.0.0", @@ -29446,6 +30206,15 @@ "integrity": "sha512-GopVGCpVS1UKH75VKHGuQFqS1Gusej0z4FyQkPdwjil2gNIv+LNsqBlboOzpJFZKVT95GkCyWJbBSdFEFUWI2A==", "dev": true }, + "dotenv-webpack": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/dotenv-webpack/-/dotenv-webpack-8.0.1.tgz", + "integrity": "sha512-CdrgfhZOnx4uB18SgaoP9XHRN2v48BbjuXQsZY5ixs5A8579NxQkmMxRtI7aTwSiSQcM2ao12Fdu+L3ZS3bG4w==", + "dev": true, + "requires": { + "dotenv-defaults": "^2.0.2" + } + }, "duplexify": { "version": "3.7.1", "resolved": "https://registry.npmjs.org/duplexify/-/duplexify-3.7.1.tgz", @@ -30441,6 +31210,17 @@ "integrity": "sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g==", "dev": true }, + "external-editor": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/external-editor/-/external-editor-3.1.0.tgz", + "integrity": "sha512-hMQ4CX1p1izmuLYyZqLMO/qGNw10wSv9QDCPfzXfyFrOaCSSoRfqE1Kf1s5an66J5JZC62NewG+mK49jOCtQew==", + "dev": true, + "requires": { + "chardet": "^0.7.0", + "iconv-lite": "^0.4.24", + "tmp": "^0.0.33" + } + }, "extract-zip": { "version": "1.7.0", "resolved": "https://registry.npmjs.org/extract-zip/-/extract-zip-1.7.0.tgz", @@ -30555,6 +31335,15 @@ "integrity": "sha512-3yurQZ2hD9VISAhJJP9bpYFNQrHHBXE2JxxjY5aLEcDi46RmAzJE2OC9FAde0yis5ElW0jTTzs0zfg/Cca4XqQ==", "dev": true }, + "figures": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/figures/-/figures-3.2.0.tgz", + "integrity": "sha512-yaduQFRKLXYOGgEn6AZau90j3ggSOyiqXU0F9JZfeXYhNa+Jk4X+s45A2zg5jns87GAFa34BBm2kXw4XpNcbdg==", + "dev": true, + "requires": { + "escape-string-regexp": "^1.0.5" + } + }, "file-entry-cache": { "version": "6.0.1", "resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-6.0.1.tgz", @@ -31195,6 +31984,12 @@ "integrity": "sha512-EtKwoO6kxCL9WO5xipiHTZlSzBm7WLT627TqC/uVRd0HKmq8NXyebnNYxDoBi7wt8eTWrUrKXCOVaFq9x1kgag==", "dev": true }, + "graphql": { + "version": "16.7.1", + "resolved": "https://registry.npmjs.org/graphql/-/graphql-16.7.1.tgz", + "integrity": "sha512-DRYR9tf+UGU0KOsMcKAlXeFfX89UiiIZ0dRU3mR0yJfu6OjZqUcp68NnFLnqQU5RexygFoDy1EW+ccOYcPfmHg==", + "dev": true + }, "gunzip-maybe": { "version": "1.4.2", "resolved": "https://registry.npmjs.org/gunzip-maybe/-/gunzip-maybe-1.4.2.tgz", @@ -31291,6 +32086,12 @@ "integrity": "sha512-F/1DnUGPopORZi0ni+CvrCgHQ5FyEAHRLSApuYWMmrbSwoN2Mn/7k+Gl38gJnR7yyDZk6WLXwiGod1JOWNDKGw==", "dev": true }, + "headers-polyfill": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/headers-polyfill/-/headers-polyfill-3.1.2.tgz", + "integrity": "sha512-tWCK4biJ6hcLqTviLXVR9DTRfYGQMXEIUj3gwJ2rZ5wO/at3XtkI4g8mCvFdUF9l1KMBNCfmNAdnahm1cgavQA==", + "dev": true + }, "hoist-non-react-statics": { "version": "3.3.2", "resolved": "https://registry.npmjs.org/hoist-non-react-statics/-/hoist-non-react-statics-3.3.2.tgz", @@ -31573,6 +32374,80 @@ "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", "dev": true }, + "inquirer": { + "version": "8.2.5", + "resolved": "https://registry.npmjs.org/inquirer/-/inquirer-8.2.5.tgz", + "integrity": "sha512-QAgPDQMEgrDssk1XiwwHoOGYF9BAbUcc1+j+FhEvaOt8/cKRqyLn0U5qA6F74fGhTMGxf92pOvPBeh29jQJDTQ==", + "dev": true, + "requires": { + "ansi-escapes": "^4.2.1", + "chalk": "^4.1.1", + "cli-cursor": "^3.1.0", + "cli-width": "^3.0.0", + "external-editor": "^3.0.3", + "figures": "^3.0.0", + "lodash": "^4.17.21", + "mute-stream": "0.0.8", + "ora": "^5.4.1", + "run-async": "^2.4.0", + "rxjs": "^7.5.5", + "string-width": "^4.1.0", + "strip-ansi": "^6.0.0", + "through": "^2.3.6", + "wrap-ansi": "^7.0.0" + }, + "dependencies": { + "ansi-styles": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", + "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", + "dev": true, + "requires": { + "color-convert": "^2.0.1" + } + }, + "chalk": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", + "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", + "dev": true, + "requires": { + "ansi-styles": "^4.1.0", + "supports-color": "^7.1.0" + } + }, + "color-convert": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", + "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", + "dev": true, + "requires": { + "color-name": "~1.1.4" + } + }, + "color-name": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", + "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", + "dev": true + }, + "has-flag": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", + "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", + "dev": true + }, + "supports-color": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", + "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", + "dev": true, + "requires": { + "has-flag": "^4.0.0" + } + } + } + }, "internal-slot": { "version": "1.0.5", "resolved": "https://registry.npmjs.org/internal-slot/-/internal-slot-1.0.5.tgz", @@ -31769,6 +32644,12 @@ "integrity": "sha512-dqJvarLawXsFbNDeJW7zAz8ItJ9cd28YufuuFzh0G8pNHjJMnY08Dv7sYX2uF5UpQOwieAeOExEYAWWfu7ZZUA==", "dev": true }, + "is-node-process": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/is-node-process/-/is-node-process-1.2.0.tgz", + "integrity": "sha512-Vg4o6/fqPxIjtxgUH5QLJhwZ7gW5diGCVlXpuUfELC62CuxM1iHcRe51f2W1FDy04Ai4KJkagKjx3XaqyfRKXw==", + "dev": true + }, "is-number": { "version": "7.0.0", "resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz", @@ -33776,6 +34657,12 @@ } } }, + "js-levenshtein": { + "version": "1.1.6", + "resolved": "https://registry.npmjs.org/js-levenshtein/-/js-levenshtein-1.1.6.tgz", + "integrity": "sha512-X2BB11YZtrRqY4EnQcLX5Rh373zbK4alC1FW7D7MBhL2gtcC17cTnr6DmfHZeS0s2rTHjUTMMHfG7gO8SSdw+g==", + "dev": true + }, "js-tokens": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", @@ -34458,6 +35345,143 @@ "integrity": "sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w==", "dev": true }, + "msw": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/msw/-/msw-1.2.2.tgz", + "integrity": "sha512-GsW3PE/Es/a1tYThXcM8YHOZ1S1MtivcS3He/LQbbTCx3rbWJYCtWD5XXyJ53KlNPT7O1VI9sCW3xMtgFe8XpQ==", + "dev": true, + "requires": { + "@mswjs/cookies": "^0.2.2", + "@mswjs/interceptors": "^0.17.5", + "@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.1.2", + "inquirer": "^8.2.0", + "is-node-process": "^1.2.0", + "js-levenshtein": "^1.1.6", + "node-fetch": "^2.6.7", + "outvariant": "^1.4.0", + "path-to-regexp": "^6.2.0", + "strict-event-emitter": "^0.4.3", + "type-fest": "^2.19.0", + "yargs": "^17.3.1" + }, + "dependencies": { + "ansi-styles": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", + "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", + "dev": true, + "requires": { + "color-convert": "^2.0.1" + } + }, + "chalk": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.1.tgz", + "integrity": "sha512-diHzdDKxcU+bAsUboHLPEDQiw0qEe0qd7SYUn3HgcFlWgbDcfLGswOHYeGrHKzG9z6UYf01d9VFMfZxPM1xZSg==", + "dev": true, + "requires": { + "ansi-styles": "^4.1.0", + "supports-color": "^7.1.0" + } + }, + "cliui": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/cliui/-/cliui-8.0.1.tgz", + "integrity": "sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ==", + "dev": true, + "requires": { + "string-width": "^4.2.0", + "strip-ansi": "^6.0.1", + "wrap-ansi": "^7.0.0" + } + }, + "color-convert": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", + "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", + "dev": true, + "requires": { + "color-name": "~1.1.4" + } + }, + "color-name": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", + "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", + "dev": true + }, + "cookie": { + "version": "0.4.2", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.4.2.tgz", + "integrity": "sha512-aSWTXFzaKWkvHO1Ny/s+ePFpvKsPnjc551iI41v3ny/ow6tBG5Vd+FuqGNhh1LxOmVzOlGUriIlOaokOvhaStA==", + "dev": true + }, + "has-flag": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", + "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", + "dev": true + }, + "path-to-regexp": { + "version": "6.2.1", + "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-6.2.1.tgz", + "integrity": "sha512-JLyh7xT1kizaEvcaXOQwOc2/Yhw6KZOvPf1S8401UyLk86CU79LN3vl7ztXGm/pZ+YjoyAJ4rxmHwbkBXJX+yw==", + "dev": true + }, + "supports-color": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", + "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", + "dev": true, + "requires": { + "has-flag": "^4.0.0" + } + }, + "type-fest": { + "version": "2.19.0", + "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-2.19.0.tgz", + "integrity": "sha512-RAH822pAdBgcNMAfWnCBU3CFZcfZ/i1eZjwFU/dsLKumyuuP3niueg2UAukXYF0E2AAoc82ZSSf9J0WQBinzHA==", + "dev": true + }, + "yargs": { + "version": "17.7.2", + "resolved": "https://registry.npmjs.org/yargs/-/yargs-17.7.2.tgz", + "integrity": "sha512-7dSzzRQ++CKnNI/krKnYRV7JKKPUXMEh61soaHKg9mrWEhzFWhFnxPxGl+69cD1Ou63C13NUPCnmIcrvqCuM6w==", + "dev": true, + "requires": { + "cliui": "^8.0.1", + "escalade": "^3.1.1", + "get-caller-file": "^2.0.5", + "require-directory": "^2.1.1", + "string-width": "^4.2.3", + "y18n": "^5.0.5", + "yargs-parser": "^21.1.1" + } + }, + "yargs-parser": { + "version": "21.1.1", + "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-21.1.1.tgz", + "integrity": "sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw==", + "dev": true + } + } + }, + "msw-storybook-addon": { + "version": "1.8.0", + "resolved": "https://registry.npmjs.org/msw-storybook-addon/-/msw-storybook-addon-1.8.0.tgz", + "integrity": "sha512-dw3vZwqjixmiur0vouRSOax7wPSu9Og2Hspy9JZFHf49bZRjwDiLF0Pfn2NXEkGviYJOJiGxS1ejoTiUwoSg4A==", + "dev": true, + "requires": { + "is-node-process": "^1.0.1" + } + }, "multicast-dns": { "version": "7.2.5", "resolved": "https://registry.npmjs.org/multicast-dns/-/multicast-dns-7.2.5.tgz", @@ -34468,6 +35492,12 @@ "thunky": "^1.0.2" } }, + "mute-stream": { + "version": "0.0.8", + "resolved": "https://registry.npmjs.org/mute-stream/-/mute-stream-0.0.8.tgz", + "integrity": "sha512-nnbWWOkoWyUsTjKrhgD0dcz22mdkSnpYqbEjIm2nhwhuxlSkpywJmBo8h0ZqJdkp73mb90SssHkN4rsRaBAfAA==", + "dev": true + }, "nanoid": { "version": "3.3.6", "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.6.tgz", @@ -34819,6 +35849,18 @@ } } }, + "os-tmpdir": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/os-tmpdir/-/os-tmpdir-1.0.2.tgz", + "integrity": "sha512-D2FR03Vir7FIu45XBY20mTb+/ZSWB00sjU9jdQXt83gDrI4Ztz5Fs7/yy74g2N5SVQY4xY1qDr4rNddwYRVX0g==", + "dev": true + }, + "outvariant": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/outvariant/-/outvariant-1.4.0.tgz", + "integrity": "sha512-AlWY719RF02ujitly7Kk/0QlV+pXGFDHrHf9O2OKqyqgBieaPOIeuSkL8sRK6j2WK+/ZAURq2kZsY0d8JapUiw==", + "dev": true + }, "p-limit": { "version": "2.3.0", "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-2.3.0.tgz", @@ -35815,6 +36857,12 @@ } } }, + "run-async": { + "version": "2.4.1", + "resolved": "https://registry.npmjs.org/run-async/-/run-async-2.4.1.tgz", + "integrity": "sha512-tvVnVv01b8c1RrA6Ep7JkStj85Guv/YrMcwqYQnwjsAS2cTmmPGBBjAjpCW7RrSodNSoE2/qg9O4bceNvUuDgQ==", + "dev": true + }, "run-parallel": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/run-parallel/-/run-parallel-1.2.0.tgz", @@ -35824,6 +36872,15 @@ "queue-microtask": "^1.2.2" } }, + "rxjs": { + "version": "7.8.1", + "resolved": "https://registry.npmjs.org/rxjs/-/rxjs-7.8.1.tgz", + "integrity": "sha512-AA3TVj+0A2iuIoQkWEK/tqFjBq2j+6PO6Y0zJcvzLAFhEFIO3HL0vls9hWLncZbAAbK0mar7oZ4V079I/qPMxg==", + "dev": true, + "requires": { + "tslib": "^2.1.0" + } + }, "safe-buffer": { "version": "5.2.1", "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", @@ -36065,6 +37122,12 @@ "integrity": "sha512-KiKBS8AnWGEyLzofFfmvKwpdPzqiy16LvQfK3yv/fVH7Bj13/wl3JSR1J+rfgRE9q7xUJK4qvgS8raSOeLUehw==", "dev": true }, + "set-cookie-parser": { + "version": "2.6.0", + "resolved": "https://registry.npmjs.org/set-cookie-parser/-/set-cookie-parser-2.6.0.tgz", + "integrity": "sha512-RVnVQxTXuerk653XfuliOxBP81Sf0+qfQE73LIYKcyMYHG94AuH0kgrQpRDuTZnSmjpysHmzxJXKNfa6PjFhyQ==", + "dev": true + }, "setprototypeof": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz", @@ -36358,6 +37421,12 @@ "integrity": "sha512-AiisoFqQ0vbGcZgQPY1cdP2I76glaVA/RauYR4G4thNFgkTqr90yXTo4LYX60Jl+sIlPNHHdGSwo01AvbKUSVQ==", "dev": true }, + "strict-event-emitter": { + "version": "0.4.6", + "resolved": "https://registry.npmjs.org/strict-event-emitter/-/strict-event-emitter-0.4.6.tgz", + "integrity": "sha512-12KWeb+wixJohmnwNFerbyiBrAlq5qJLwIt38etRtKtmmHyDSoGlIqFE9wx+4IwG0aDjI7GV8tc8ZccjWZZtTg==", + "dev": true + }, "string_decoder": { "version": "1.3.0", "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.3.0.tgz", @@ -36803,6 +37872,12 @@ "integrity": "sha512-N+8UisAXDGk8PFXP4HAzVR9nbfmVJ3zYLAWiTIoqC5v5isinhr+r5uaO8+7r3BMfuNIufIsA7RdpVgacC2cSpw==", "dev": true }, + "through": { + "version": "2.3.8", + "resolved": "https://registry.npmjs.org/through/-/through-2.3.8.tgz", + "integrity": "sha512-w89qg7PI8wAdvX60bMDP+bFoD5Dvhm9oLheFp5O4a2QF0cSBGsBX4qZmadPMvVqlLJBBci+WqGGOAPvcDeNSVg==", + "dev": true + }, "through2": { "version": "2.0.5", "resolved": "https://registry.npmjs.org/through2/-/through2-2.0.5.tgz", @@ -36851,6 +37926,15 @@ "integrity": "sha512-eHY7nBftgThBqOyHGVN+l8gF0BucP09fMo0oO/Lb0w1OF80dJv+lDVpXG60WMQvkcxAkNybKsrEIE3ZtKGmPrA==", "dev": true }, + "tmp": { + "version": "0.0.33", + "resolved": "https://registry.npmjs.org/tmp/-/tmp-0.0.33.tgz", + "integrity": "sha512-jRCJlojKnZ3addtTOjdIqoRuPEKBvNXcGYqzO6zWZX8KfKEpnGY5jfggJQ3EjKuu8D4bJRr0y+cYJFmYbImXGw==", + "dev": true, + "requires": { + "os-tmpdir": "~1.0.2" + } + }, "tmpl": { "version": "1.0.5", "resolved": "https://registry.npmjs.org/tmpl/-/tmpl-1.0.5.tgz", @@ -37327,6 +38411,12 @@ "@juggle/resize-observer": "^3.3.1" } }, + "use-sync-external-store": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/use-sync-external-store/-/use-sync-external-store-1.2.0.tgz", + "integrity": "sha512-eEgnFxGQ1Ife9bzYs6VLi8/4X6CObHMw9Qr9tPY43iKwsPw8xE8+EFsf/2cFZ5S3esXgpWgtSCtLNS41F+sKPA==", + "requires": {} + }, "util": { "version": "0.12.5", "resolved": "https://registry.npmjs.org/util/-/util-0.12.5.tgz", @@ -37437,6 +38527,16 @@ "defaults": "^1.0.3" } }, + "web-encoding": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/web-encoding/-/web-encoding-1.1.5.tgz", + "integrity": "sha512-HYLeVCdJ0+lBYV2FvNZmv3HJ2Nt0QYXqZojk3d9FJOLkwnuhzM9tmamh8d7HPM8QqjKH8DeHkFTx+CFlWpZZDA==", + "dev": true, + "requires": { + "@zxing/text-encoding": "0.9.0", + "util": "^0.12.3" + } + }, "webidl-conversions": { "version": "3.0.1", "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-3.0.1.tgz", diff --git a/frontend/package.json b/frontend/package.json index 4a44b6126..f4f92b520 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -16,6 +16,8 @@ "test": "jest" }, "dependencies": { + "@tanstack/react-query": "^4.29.19", + "dotenv": "^16.3.1", "react": "^18.2.0", "react-dom": "^18.2.0", "react-router-dom": "^6.14.1" @@ -44,6 +46,7 @@ "@types/react-dom": "^18.2.6", "@types/styled-components": "^5.1.26", "clean-webpack-plugin": "^4.0.0", + "dotenv-webpack": "^8.0.1", "eslint": "^8.44.0", "eslint-config-prettier": "^8.8.0", "eslint-config-react-app": "^7.0.1", @@ -52,6 +55,8 @@ "html-webpack-plugin": "^5.5.3", "jest": "^29.6.0", "jest-environment-jsdom": "^29.6.0", + "msw": "^1.2.2", + "msw-storybook-addon": "^1.8.0", "prettier": "^2.8.8", "storybook": "^7.0.26", "styled-components": "^6.0.2", @@ -62,5 +67,8 @@ "webpack-cli": "^5.1.4", "webpack-dev-server": "^4.15.1", "webpack-merge": "^5.9.0" + }, + "msw": { + "workerDirectory": "public" } } diff --git a/frontend/public/mockServiceWorker.js b/frontend/public/mockServiceWorker.js new file mode 100644 index 000000000..8ee70b3e4 --- /dev/null +++ b/frontend/public/mockServiceWorker.js @@ -0,0 +1,303 @@ +/* eslint-disable */ +/* tslint:disable */ + +/** + * Mock Service Worker (1.2.2). + * @see https://github.com/mswjs/msw + * - Please do NOT modify this file. + * - Please do NOT serve this file on production. + */ + +const INTEGRITY_CHECKSUM = '3d6b9f06410d179a7f7404d4bf4c3c70' +const activeClientIds = new Set() + +self.addEventListener('install', function () { + self.skipWaiting() +}) + +self.addEventListener('activate', function (event) { + event.waitUntil(self.clients.claim()) +}) + +self.addEventListener('message', async function (event) { + const clientId = event.source.id + + if (!clientId || !self.clients) { + return + } + + const client = await self.clients.get(clientId) + + if (!client) { + return + } + + const allClients = await self.clients.matchAll({ + type: 'window', + }) + + switch (event.data) { + case 'KEEPALIVE_REQUEST': { + sendToClient(client, { + type: 'KEEPALIVE_RESPONSE', + }) + break + } + + case 'INTEGRITY_CHECK_REQUEST': { + sendToClient(client, { + type: 'INTEGRITY_CHECK_RESPONSE', + payload: INTEGRITY_CHECKSUM, + }) + break + } + + case 'MOCK_ACTIVATE': { + activeClientIds.add(clientId) + + sendToClient(client, { + type: 'MOCKING_ENABLED', + payload: true, + }) + break + } + + case 'MOCK_DEACTIVATE': { + activeClientIds.delete(clientId) + break + } + + case 'CLIENT_CLOSED': { + activeClientIds.delete(clientId) + + const remainingClients = allClients.filter((client) => { + return client.id !== clientId + }) + + // Unregister itself when there are no more clients + if (remainingClients.length === 0) { + self.registration.unregister() + } + + break + } + } +}) + +self.addEventListener('fetch', function (event) { + const { request } = event + const accept = request.headers.get('accept') || '' + + // Bypass server-sent events. + if (accept.includes('text/event-stream')) { + return + } + + // Bypass navigation requests. + if (request.mode === 'navigate') { + return + } + + // Opening the DevTools triggers the "only-if-cached" request + // that cannot be handled by the worker. Bypass such requests. + if (request.cache === 'only-if-cached' && request.mode !== 'same-origin') { + return + } + + // Bypass all requests when there are no active clients. + // Prevents the self-unregistered worked from handling requests + // after it's been deleted (still remains active until the next reload). + if (activeClientIds.size === 0) { + return + } + + // Generate unique request ID. + const requestId = Math.random().toString(16).slice(2) + + event.respondWith( + handleRequest(event, requestId).catch((error) => { + if (error.name === 'NetworkError') { + console.warn( + '[MSW] Successfully emulated a network error for the "%s %s" request.', + request.method, + request.url, + ) + return + } + + // At this point, any exception indicates an issue with the original request/response. + console.error( + `\ +[MSW] Caught an exception from the "%s %s" request (%s). This is probably not a problem with Mock Service Worker. There is likely an additional logging output above.`, + request.method, + request.url, + `${error.name}: ${error.message}`, + ) + }), + ) +}) + +async function handleRequest(event, requestId) { + const client = await resolveMainClient(event) + const response = await getResponse(event, client, requestId) + + // Send back the response clone for the "response:*" life-cycle events. + // Ensure MSW is active and ready to handle the message, otherwise + // this message will pend indefinitely. + if (client && activeClientIds.has(client.id)) { + ;(async function () { + const clonedResponse = response.clone() + sendToClient(client, { + type: 'RESPONSE', + payload: { + requestId, + type: clonedResponse.type, + ok: clonedResponse.ok, + status: clonedResponse.status, + statusText: clonedResponse.statusText, + body: + clonedResponse.body === null ? null : await clonedResponse.text(), + headers: Object.fromEntries(clonedResponse.headers.entries()), + redirected: clonedResponse.redirected, + }, + }) + })() + } + + return response +} + +// Resolve the main client for the given event. +// Client that issues a request doesn't necessarily equal the client +// that registered the worker. It's with the latter the worker should +// communicate with during the response resolving phase. +async function resolveMainClient(event) { + const client = await self.clients.get(event.clientId) + + if (client?.frameType === 'top-level') { + return client + } + + const allClients = await self.clients.matchAll({ + type: 'window', + }) + + return allClients + .filter((client) => { + // Get only those clients that are currently visible. + return client.visibilityState === 'visible' + }) + .find((client) => { + // Find the client ID that's recorded in the + // set of clients that have registered the worker. + return activeClientIds.has(client.id) + }) +} + +async function getResponse(event, client, requestId) { + const { request } = event + const clonedRequest = request.clone() + + function passthrough() { + // Clone the request because it might've been already used + // (i.e. its body has been read and sent to the client). + const headers = Object.fromEntries(clonedRequest.headers.entries()) + + // Remove MSW-specific request headers so the bypassed requests + // comply with the server's CORS preflight check. + // Operate with the headers as an object because request "Headers" + // are immutable. + delete headers['x-msw-bypass'] + + return fetch(clonedRequest, { headers }) + } + + // Bypass mocking when the client is not active. + if (!client) { + return passthrough() + } + + // Bypass initial page load requests (i.e. static assets). + // The absence of the immediate/parent client in the map of the active clients + // means that MSW hasn't dispatched the "MOCK_ACTIVATE" event yet + // and is not ready to handle requests. + if (!activeClientIds.has(client.id)) { + return passthrough() + } + + // Bypass requests with the explicit bypass header. + // Such requests can be issued by "ctx.fetch()". + if (request.headers.get('x-msw-bypass') === 'true') { + return passthrough() + } + + // Notify the client that a request has been intercepted. + const clientMessage = await sendToClient(client, { + type: 'REQUEST', + payload: { + id: requestId, + url: request.url, + method: request.method, + headers: Object.fromEntries(request.headers.entries()), + cache: request.cache, + mode: request.mode, + credentials: request.credentials, + destination: request.destination, + integrity: request.integrity, + redirect: request.redirect, + referrer: request.referrer, + referrerPolicy: request.referrerPolicy, + body: await request.text(), + bodyUsed: request.bodyUsed, + keepalive: request.keepalive, + }, + }) + + switch (clientMessage.type) { + case 'MOCK_RESPONSE': { + return respondWithMock(clientMessage.data) + } + + case 'MOCK_NOT_FOUND': { + return passthrough() + } + + case 'NETWORK_ERROR': { + const { name, message } = clientMessage.data + const networkError = new Error(message) + networkError.name = name + + // Rejecting a "respondWith" promise emulates a network error. + throw networkError + } + } + + return passthrough() +} + +function sendToClient(client, message) { + return new Promise((resolve, reject) => { + const channel = new MessageChannel() + + channel.port1.onmessage = (event) => { + if (event.data && event.data.error) { + return reject(event.data.error) + } + + resolve(event.data) + } + + client.postMessage(message, [channel.port2]) + }) +} + +function sleep(timeMs) { + return new Promise((resolve) => { + setTimeout(resolve, timeMs) + }) +} + +async function respondWithMock(response) { + await sleep(response.delay) + return new Response(response.body, response) +} diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index f8c506c6e..c6df8dd8c 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -1,12 +1,16 @@ +import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; + import Home from '@pages/Home'; import Example from '@components/Example'; +const queryClient = new QueryClient(); + const App = () => ( - <> + - + ); export default App; diff --git a/frontend/src/index.tsx b/frontend/src/index.tsx index 90b7aadbe..e494a44a0 100644 --- a/frontend/src/index.tsx +++ b/frontend/src/index.tsx @@ -1,7 +1,14 @@ -import App from './App'; import React from 'react'; + import ReactDOM from 'react-dom/client'; +import App from './App'; +import { worker } from './mocks/worker'; + +if (process.env.NODE_ENV === 'development') { + worker.start(); +} + const root = ReactDOM.createRoot(document.getElementById('root') as HTMLElement); root.render( diff --git a/frontend/src/mocks/example/get.ts b/frontend/src/mocks/example/get.ts new file mode 100644 index 000000000..7e54b94c4 --- /dev/null +++ b/frontend/src/mocks/example/get.ts @@ -0,0 +1,7 @@ +import { rest } from 'msw'; + +export const example = [ + rest.get('/example', (req, res, ctx) => { + return res(ctx.status(200)); + }), +]; diff --git a/frontend/src/mocks/handlers.ts b/frontend/src/mocks/handlers.ts new file mode 100644 index 000000000..f6e89fef8 --- /dev/null +++ b/frontend/src/mocks/handlers.ts @@ -0,0 +1,3 @@ +import { example } from './example/get'; + +export const handlers = [...example]; diff --git a/frontend/src/mocks/worker.ts b/frontend/src/mocks/worker.ts new file mode 100644 index 000000000..9c10cad95 --- /dev/null +++ b/frontend/src/mocks/worker.ts @@ -0,0 +1,5 @@ +import { setupWorker } from 'msw'; + +import { handlers } from './handlers'; + +export const worker = setupWorker(...handlers); diff --git a/frontend/webpack.common.js b/frontend/webpack.common.js index 65fdb5edd..e91a13e9d 100644 --- a/frontend/webpack.common.js +++ b/frontend/webpack.common.js @@ -1,6 +1,7 @@ const path = require('path'); const { CleanWebpackPlugin } = require('clean-webpack-plugin'); +const DotenvWebpack = require('dotenv-webpack'); const HtmlWebpackPlugin = require('html-webpack-plugin'); module.exports = { @@ -52,10 +53,11 @@ module.exports = { template: './public/index.html', }), new CleanWebpackPlugin(), + new DotenvWebpack(), ], devtool: 'inline-source-map', devServer: { - static: './dist', + static: 'public', hot: true, open: true, }, From d3ffc40c0d40d64d93cd554f8f443e7827e0df66 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B9=80=EC=98=81=EA=B8=B8/KIM=20YOUNG=20GIL?= <80146176+Gilpop8663@users.noreply.github.com> Date: Fri, 14 Jul 2023 17:34:05 +0900 Subject: [PATCH 3/4] =?UTF-8?q?fetch=20=EC=9C=A0=ED=8B=B8=20=ED=95=A8?= =?UTF-8?q?=EC=88=98=20=EA=B5=AC=ED=98=84,=20=EC=98=88=EC=8B=9C=20useQuery?= =?UTF-8?q?=20=ED=9B=85=20=EA=B5=AC=ED=98=84=20,=20=EC=98=88=EC=8B=9C=20ap?= =?UTF-8?q?i=20=ED=95=A8=EC=88=98=20=EA=B5=AC=ED=98=84=20=20(#51)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat: (#49) fetch 유틸 함수 구현 (GET, POST, PUT, PATCH, DELETE) * feat: (#49) api 폴더 안에 컨벤션이 될 예시 함수 구현 * feat: (#49) 컨벤션이 될 예시 useQuery 훅 구현 --- frontend/src/api/example.ts | 37 ++++++++++++ frontend/src/hooks/query/useExample.tsx | 19 ++++++ frontend/src/utils/fetch.ts | 77 +++++++++++++++++++++++++ 3 files changed, 133 insertions(+) create mode 100644 frontend/src/api/example.ts create mode 100644 frontend/src/hooks/query/useExample.tsx create mode 100644 frontend/src/utils/fetch.ts diff --git a/frontend/src/api/example.ts b/frontend/src/api/example.ts new file mode 100644 index 000000000..00a060f29 --- /dev/null +++ b/frontend/src/api/example.ts @@ -0,0 +1,37 @@ +// 장바구니을 가져오는 예시 코드 + +/** + * 게시물 get: api/cart + * 게시물 Cart: api/cart + */ +import { deleteFetch, getFetch, patchFetch, postFetch, putFetch } from '@utils/fetch'; + +export interface Cart { + id: number; + text: string; +} + +export const getCartList = async () => { + return await getFetch('api/cart'); +}; + +export const createCart = async () => { + return await postFetch('api/cart', { id: 12, text: '생성' }); +}; + +export const editCart = async () => { + return await putFetch('api/cart', { id: 12, text: '생성' }); +}; + +// remove or delete +export const deleteCart = async () => { + return await deleteFetch('api/cart/1'); +}; + +/** + * + * patch와 put은 edit을 접두어로 붙힌다. 어색하다면 바꾼다. + */ +export const editOption = async () => { + return await patchFetch('api/cart/1/213123'); +}; diff --git a/frontend/src/hooks/query/useExample.tsx b/frontend/src/hooks/query/useExample.tsx new file mode 100644 index 000000000..804f042aa --- /dev/null +++ b/frontend/src/hooks/query/useExample.tsx @@ -0,0 +1,19 @@ +import { useQuery } from '@tanstack/react-query'; + +import { Cart, getCartList } from '@api/example'; + +const convertCartList = (cartList: { id: number; text: string }[]) => { + return cartList.map(cart => ({ id: cart.id, content: cart.text })); +}; + +export const useExample = () => { + const { data, error, isLoading } = useQuery(['carts'], getCartList, { + onSuccess: data => { + const updatedData = convertCartList(data); + + return updatedData; + }, + }); + + return { data, error, isLoading }; +}; diff --git a/frontend/src/utils/fetch.ts b/frontend/src/utils/fetch.ts new file mode 100644 index 000000000..d6f05ff8f --- /dev/null +++ b/frontend/src/utils/fetch.ts @@ -0,0 +1,77 @@ +const headers = { + 'Content-Type': 'application/json;charset=utf-8', + Authorization: `Bearer `, +}; + +export const getFetch = async (url: string): Promise => { + const response = await fetch(url, { + method: 'GET', + headers, + }); + + const data = await response.json(); + + if (!response.ok) { + throw new Error(data.message); + } + + return data; +}; + +export const postFetch = async (url: string, body: T): Promise => { + const response = await fetch(url, { + method: 'POST', + body: JSON.stringify(body), + headers, + }); + + const data = await response.json(); + + if (!response.ok) { + throw new Error(data.message); + } + + return data; +}; + +export const putFetch = async (url: string, body: T): Promise => { + const response = await fetch(url, { + method: 'PUT', + body: JSON.stringify(body), + headers, + }); + + const data = await response.json(); + + if (!response.ok) { + throw new Error(data.message); + } + + return data; +}; + +export const patchFetch = async (url: string) => { + const response = await fetch(url, { + method: 'PATCH', + headers, + }); + + const data = await response.json(); + + if (!response.ok) { + throw new Error(data.message); + } +}; + +export const deleteFetch = async (url: string) => { + const response = await fetch(url, { + method: 'DELETE', + headers, + }); + + const data = await response.json(); + + if (!response.ok) { + throw new Error(data.message); + } +}; From 1f36fed2c17fd496e5327945b093bb7d0109272c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B9=80=EC=98=81=EA=B8=B8/KIM=20YOUNG=20GIL?= <80146176+Gilpop8663@users.noreply.github.com> Date: Fri, 14 Jul 2023 18:10:23 +0900 Subject: [PATCH 4/4] =?UTF-8?q?=ED=88=AC=ED=91=9C=20=EC=9E=91=EC=84=B1=20?= =?UTF-8?q?=ED=8E=98=EC=9D=B4=EC=A7=80=EC=9D=98=20=ED=88=AC=ED=91=9C=20?= =?UTF-8?q?=EC=84=A0=ED=83=9D=EC=A7=80=20=EC=BB=B4=ED=8F=AC=EB=84=8C?= =?UTF-8?q?=ED=8A=B8=20UI=20=EA=B5=AC=ED=98=84=20=20(#40)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat: (#20) 삭제, 파일 업로드 버튼 컴포넌트 UI 구현 * feat: (#20) 이미지 업로드 버튼을 눌렀을 때 이미지 업로드 창이 나오도록 구현 및 파일명 변경 * feat: (#20) 투표 선택지 아이템 컴포넌트 UI 구현 * feat: (#20) 투표 선택지 작성 리스트 컴포넌트 UI 구현 * feat: (#20) 훅 테스트 코드 작성 시작 * test: (#20) 투표 선택지 작성에 사용하는 훅 테스트 작성 * feat: (#20) 투표 선택지 작성 훅 구현 * feat: (#20) 투표 선택지 작성 훅 적용 및 UI 구현 * feat: (#20) 50자 이상 적었을 때 사용자에게 안내 기능 구현 * feat: (#20) 사진의 이미지가 5MB가 넘어갈 경우 유저에게 안내하도록 구현 * design: (#20): 삭제 버튼을 감싼 태그가 항상 왼쪽의 공간을 차지하도록 CSS 변경 * refactor: (#20) svg 코드를 assets 폴더로 이동 후 import 하여 사용하도록 수정 회색 버튼을 cssText로 관리하여 공통으로 관리하도록 수정 * refactor: (#20) 코드 가독성을 위한 함수명, 변수명 수정 * design: (#20) 화면 크기에 따라 폰트, 버튼 사이즈 변경되도록 구현 * style: (#20) CSS 속성 순서 변경 및 불필요한 타입 선언 제거 * chore: (#20) 함수 동작 과정에 대한 설명 주석 추가 * chore: (#20) 테스트 문구 변경 --- .../__test__/hooks/useWritingOption.test.tsx | 132 ++++++++++++++++++ frontend/src/assets/photo_white.svg | 3 + frontend/src/assets/x_mark_white.svg | 3 + .../OptionCancelButton.stories.tsx | 14 ++ .../OptionCancelButton/index.tsx | 15 ++ .../OptionCancelButton/style.ts | 9 ++ .../OptionUploadImageButton.stories.tsx | 14 ++ .../OptionUploadImageButton/index.tsx | 25 ++++ .../OptionUploadImageButton/style.ts | 19 +++ .../WritingVoteOption.stories.tsx | 53 +++++++ .../WritingVoteOption/index.tsx | 71 ++++++++++ .../WritingVoteOption/style.ts | 100 +++++++++++++ .../WritingVoteOptionList.stories.tsx | 66 +++++++++ .../WritingVoteOptionList/index.tsx | 46 ++++++ .../optionList/WritingVoteOptionList/style.ts | 16 +++ frontend/src/hooks/useWritingOption.tsx | 88 ++++++++++++ 16 files changed, 674 insertions(+) create mode 100644 frontend/__test__/hooks/useWritingOption.test.tsx create mode 100644 frontend/src/assets/photo_white.svg create mode 100644 frontend/src/assets/x_mark_white.svg create mode 100644 frontend/src/components/optionList/WritingVoteOptionList/WritingVoteOption/OptionCancelButton/OptionCancelButton.stories.tsx create mode 100644 frontend/src/components/optionList/WritingVoteOptionList/WritingVoteOption/OptionCancelButton/index.tsx create mode 100644 frontend/src/components/optionList/WritingVoteOptionList/WritingVoteOption/OptionCancelButton/style.ts create mode 100644 frontend/src/components/optionList/WritingVoteOptionList/WritingVoteOption/OptionUploadImageButton/OptionUploadImageButton.stories.tsx create mode 100644 frontend/src/components/optionList/WritingVoteOptionList/WritingVoteOption/OptionUploadImageButton/index.tsx create mode 100644 frontend/src/components/optionList/WritingVoteOptionList/WritingVoteOption/OptionUploadImageButton/style.ts create mode 100644 frontend/src/components/optionList/WritingVoteOptionList/WritingVoteOption/WritingVoteOption.stories.tsx create mode 100644 frontend/src/components/optionList/WritingVoteOptionList/WritingVoteOption/index.tsx create mode 100644 frontend/src/components/optionList/WritingVoteOptionList/WritingVoteOption/style.ts create mode 100644 frontend/src/components/optionList/WritingVoteOptionList/WritingVoteOptionList.stories.tsx create mode 100644 frontend/src/components/optionList/WritingVoteOptionList/index.tsx create mode 100644 frontend/src/components/optionList/WritingVoteOptionList/style.ts create mode 100644 frontend/src/hooks/useWritingOption.tsx diff --git a/frontend/__test__/hooks/useWritingOption.test.tsx b/frontend/__test__/hooks/useWritingOption.test.tsx new file mode 100644 index 000000000..8af754a1b --- /dev/null +++ b/frontend/__test__/hooks/useWritingOption.test.tsx @@ -0,0 +1,132 @@ +import { renderHook, act } from '@testing-library/react'; + +import { useWritingOption } from '../../src/hooks/useWritingOption'; + +/** + * @jest-environment jsdom + */ + +const MOCK_MAX_VOTE_OPTION = [ + { id: 12341, text: '', imageUrl: '' }, + { id: 123451, text: '', imageUrl: '' }, + { + id: 1234221, + text: '방학 때 강릉으로 강아지와 기차여행을 하려했지만 장마가 와서 취소했어요. 여행을 별로 좋', + imageUrl: '', + }, + { + id: 12342261, + text: '방학 때 강릉으로 강아지와 기차여행을 하려했지만 장마가 와서 취소했어요. 여행을 별로 좋', + imageUrl: 'https://source.unsplash.com/random', + }, + { + id: 1234451, + text: '', + imageUrl: 'https://source.unsplash.com/random', + }, +]; + +const MOCK_MIN_VOTE_OPTION = [ + { id: 12341, text: '', imageUrl: '' }, + { id: 123341, text: '', imageUrl: '' }, +]; +describe('useWritingOption 훅을 테스트 한다.', () => { + test('초기 값이 없다면 기본으로 2개의 선택지가 존재해야 한다.', () => { + const { result } = renderHook(() => useWritingOption()); + + const { optionList } = result.current; + + expect(optionList.length).toBe(2); + + expect(optionList[0].text).toBe(''); + + expect(optionList[0].imageUrl).toBe(''); + }); + + test('기존 데이터가 있는 경우 기존 데이터가 선택지의 초기 값으로 존재해야 한다.', () => { + const { result } = renderHook(() => useWritingOption(MOCK_MIN_VOTE_OPTION)); + + const { optionList } = result.current; + + expect(optionList).toEqual(MOCK_MIN_VOTE_OPTION); + }); + + test('투표 선택지를 추가할 수 있어야 한다. 생성된 선택지는 text와 imageUrl 값을 가지고 있다.', () => { + const { result } = renderHook(() => useWritingOption(MOCK_MIN_VOTE_OPTION)); + + const { addOption } = result.current; + + act(() => { + addOption(); + }); + + const { optionList } = result.current; + + expect(optionList.length).toBe(MOCK_MIN_VOTE_OPTION.length + 1); + + expect(optionList[2].text).toBe(''); + + expect(optionList[2].imageUrl).toBe(''); + }); + + test('투표 선택지가 5개일 땐 투표 선택지를 추가할 수 없다', () => { + const { result } = renderHook(() => useWritingOption(MOCK_MAX_VOTE_OPTION)); + + const { addOption } = result.current; + + act(() => { + addOption(); + }); + + const { optionList } = result.current; + + expect(optionList).toEqual(MOCK_MAX_VOTE_OPTION); + }); + + test('투표 선택지가 3개 이상일때는 투표 선택지의 아이디를 이용하여 삭제할 수 있다.', () => { + const { result } = renderHook(() => useWritingOption(MOCK_MAX_VOTE_OPTION)); + + const { deleteOption } = result.current; + + act(() => { + deleteOption(MOCK_MAX_VOTE_OPTION[0].id); + }); + + const { optionList } = result.current; + + expect(optionList).toEqual(MOCK_MAX_VOTE_OPTION.slice(1, 5)); + }); + + test('투표 선택지가 2개일때는 삭제할 수 없다.', () => { + const { result } = renderHook(() => useWritingOption(MOCK_MIN_VOTE_OPTION)); + + const { deleteOption } = result.current; + + act(() => { + deleteOption(MOCK_MIN_VOTE_OPTION[0].id); + }); + + const { optionList } = result.current; + + expect(optionList).toEqual(MOCK_MIN_VOTE_OPTION); + }); + + test('선택한 이미지가 있을 때 취소할 수 있다.', () => { + const MOCK_IMAGE_OPTION = [ + { id: 12341, text: '', imageUrl: 'https' }, + { id: 123412, text: '', imageUrl: 'imageUrl' }, + ]; + + const { result } = renderHook(() => useWritingOption(MOCK_IMAGE_OPTION)); + + const { removeImage } = result.current; + + act(() => { + removeImage(MOCK_MIN_VOTE_OPTION[0].id); + }); + + const { optionList } = result.current; + + expect(optionList[0].imageUrl).toBe(''); + }); +}); diff --git a/frontend/src/assets/photo_white.svg b/frontend/src/assets/photo_white.svg new file mode 100644 index 000000000..330feedd6 --- /dev/null +++ b/frontend/src/assets/photo_white.svg @@ -0,0 +1,3 @@ + + + diff --git a/frontend/src/assets/x_mark_white.svg b/frontend/src/assets/x_mark_white.svg new file mode 100644 index 000000000..11b81b1ea --- /dev/null +++ b/frontend/src/assets/x_mark_white.svg @@ -0,0 +1,3 @@ + + + diff --git a/frontend/src/components/optionList/WritingVoteOptionList/WritingVoteOption/OptionCancelButton/OptionCancelButton.stories.tsx b/frontend/src/components/optionList/WritingVoteOptionList/WritingVoteOption/OptionCancelButton/OptionCancelButton.stories.tsx new file mode 100644 index 000000000..e6444c985 --- /dev/null +++ b/frontend/src/components/optionList/WritingVoteOptionList/WritingVoteOption/OptionCancelButton/OptionCancelButton.stories.tsx @@ -0,0 +1,14 @@ +import type { Meta, StoryObj } from '@storybook/react'; + +import OptionCancelButton from '.'; + +const meta: Meta = { + component: OptionCancelButton, +}; + +export default meta; +type Story = StoryObj; + +export const Default: Story = { + render: () => , +}; diff --git a/frontend/src/components/optionList/WritingVoteOptionList/WritingVoteOption/OptionCancelButton/index.tsx b/frontend/src/components/optionList/WritingVoteOptionList/WritingVoteOption/OptionCancelButton/index.tsx new file mode 100644 index 000000000..a28be23b6 --- /dev/null +++ b/frontend/src/components/optionList/WritingVoteOptionList/WritingVoteOption/OptionCancelButton/index.tsx @@ -0,0 +1,15 @@ +import React from 'react'; + +import xMarkIcon from '@assets/x_mark_white.svg'; + +import * as S from './style'; + +interface OptionCancelButtonProps extends React.ButtonHTMLAttributes {} + +export default function OptionCancelButton({ ...rest }: OptionCancelButtonProps) { + return ( + + + + ); +} diff --git a/frontend/src/components/optionList/WritingVoteOptionList/WritingVoteOption/OptionCancelButton/style.ts b/frontend/src/components/optionList/WritingVoteOptionList/WritingVoteOption/OptionCancelButton/style.ts new file mode 100644 index 000000000..e241c1217 --- /dev/null +++ b/frontend/src/components/optionList/WritingVoteOptionList/WritingVoteOption/OptionCancelButton/style.ts @@ -0,0 +1,9 @@ +import { styled } from 'styled-components'; + +import { ButtonCssText, IconImage } from '../style'; + +export const Container = styled.button` + ${ButtonCssText} +`; + +export const Image = styled(IconImage)``; diff --git a/frontend/src/components/optionList/WritingVoteOptionList/WritingVoteOption/OptionUploadImageButton/OptionUploadImageButton.stories.tsx b/frontend/src/components/optionList/WritingVoteOptionList/WritingVoteOption/OptionUploadImageButton/OptionUploadImageButton.stories.tsx new file mode 100644 index 000000000..3d8982234 --- /dev/null +++ b/frontend/src/components/optionList/WritingVoteOptionList/WritingVoteOption/OptionUploadImageButton/OptionUploadImageButton.stories.tsx @@ -0,0 +1,14 @@ +import type { Meta, StoryObj } from '@storybook/react'; + +import OptionUploadImageButton from '.'; + +const meta: Meta = { + component: OptionUploadImageButton, +}; + +export default meta; +type Story = StoryObj; + +export const Default: Story = { + render: () => , +}; diff --git a/frontend/src/components/optionList/WritingVoteOptionList/WritingVoteOption/OptionUploadImageButton/index.tsx b/frontend/src/components/optionList/WritingVoteOptionList/WritingVoteOption/OptionUploadImageButton/index.tsx new file mode 100644 index 000000000..8e276b236 --- /dev/null +++ b/frontend/src/components/optionList/WritingVoteOptionList/WritingVoteOption/OptionUploadImageButton/index.tsx @@ -0,0 +1,25 @@ +import React from 'react'; + +import photoIcon from '@assets/photo_white.svg'; + +import * as S from './style'; + +interface OptionUploadImageButtonProps extends React.InputHTMLAttributes { + optionId: number; +} + +export default function OptionUploadImageButton({ + optionId, + ...rest +}: OptionUploadImageButtonProps) { + const id = optionId.toString(); + + return ( + + + + + + + ); +} diff --git a/frontend/src/components/optionList/WritingVoteOptionList/WritingVoteOption/OptionUploadImageButton/style.ts b/frontend/src/components/optionList/WritingVoteOptionList/WritingVoteOption/OptionUploadImageButton/style.ts new file mode 100644 index 000000000..a8083d4aa --- /dev/null +++ b/frontend/src/components/optionList/WritingVoteOptionList/WritingVoteOption/OptionUploadImageButton/style.ts @@ -0,0 +1,19 @@ +import { styled } from 'styled-components'; + +import { ButtonCssText, IconImage } from '../style'; + +export const Container = styled.div` + width: 24px; + height: 24px; + border-radius: 50%; +`; + +export const Label = styled.label` + ${ButtonCssText} +`; + +export const FileInput = styled.input` + visibility: hidden; +`; + +export const Image = styled(IconImage)``; diff --git a/frontend/src/components/optionList/WritingVoteOptionList/WritingVoteOption/WritingVoteOption.stories.tsx b/frontend/src/components/optionList/WritingVoteOptionList/WritingVoteOption/WritingVoteOption.stories.tsx new file mode 100644 index 000000000..b3fb3684d --- /dev/null +++ b/frontend/src/components/optionList/WritingVoteOptionList/WritingVoteOption/WritingVoteOption.stories.tsx @@ -0,0 +1,53 @@ +import type { Meta, StoryObj } from '@storybook/react'; + +import WritingVoteOption from '.'; + +const meta: Meta = { + component: WritingVoteOption, +}; + +export default meta; +type Story = StoryObj; + +export const IsDeletable: Story = { + render: () => ( + {}} + handleRemoveImageClick={() => {}} + handleUploadImage={() => {}} + optionId={Math.floor(Math.random() * 100000)} + text="방학 때 강릉으로 강아지와 기차여행을 하려했지 + 만 장마가 와서 취소했어요. 여행을 별로 좋" + isDeletable={true} + /> + ), +}; + +export const IsNotDeletable: Story = { + render: () => ( + {}} + handleRemoveImageClick={() => {}} + handleUploadImage={() => {}} + optionId={Math.floor(Math.random() * 100000)} + text="방학 때 강릉으로 강아지와 기차여행을 하려했지 + 만 장마가 와서 취소했어요. 여행을 별로 좋" + isDeletable={false} + /> + ), +}; + +export const ShowImage: Story = { + render: () => ( + {}} + handleRemoveImageClick={() => {}} + handleUploadImage={() => {}} + optionId={Math.floor(Math.random() * 100000)} + text="방학 때 강릉으로 강아지와 기차여행을 하려했지 + 만 장마가 와서 취소했어요. 여행을 별로 좋" + isDeletable={true} + imageUrl="https://source.unsplash.com/random" + /> + ), +}; diff --git a/frontend/src/components/optionList/WritingVoteOptionList/WritingVoteOption/index.tsx b/frontend/src/components/optionList/WritingVoteOptionList/WritingVoteOption/index.tsx new file mode 100644 index 000000000..2e26a1ea3 --- /dev/null +++ b/frontend/src/components/optionList/WritingVoteOptionList/WritingVoteOption/index.tsx @@ -0,0 +1,71 @@ +import React, { ChangeEvent } from 'react'; + +import OptionCancelButton from './OptionCancelButton'; +import OptionUploadImageButton from './OptionUploadImageButton'; +import * as S from './style'; + +interface WritingVoteOptionProps { + optionId: number; + text: string; + isDeletable: boolean; + handleDeleteOptionClick: () => void; + handleRemoveImageClick: () => void; + handleUploadImage: (event: React.ChangeEvent) => void; + imageUrl?: string; +} + +const MAX_WRITING_LENGTH = 50; + +export default function WritingVoteOption({ + optionId, + text, + isDeletable, + handleDeleteOptionClick, + handleRemoveImageClick, + handleUploadImage, + imageUrl, +}: WritingVoteOptionProps) { + const handleTextChange = (event: ChangeEvent) => { + const { value } = event.target; + const standard = value.length; + + if (standard === MAX_WRITING_LENGTH) { + event.target.setCustomValidity(`선택지 내용은 ${MAX_WRITING_LENGTH}자까지 입력 가능합니다.`); + event.target.reportValidity(); + return; + } + + event.target.setCustomValidity(''); + }; + + return ( + + + {isDeletable && ( + + )} + + + + + {!imageUrl && ( + + )} + + {imageUrl && ( + + + + + + + )} + + + ); +} diff --git a/frontend/src/components/optionList/WritingVoteOptionList/WritingVoteOption/style.ts b/frontend/src/components/optionList/WritingVoteOptionList/WritingVoteOption/style.ts new file mode 100644 index 000000000..5a061ebd6 --- /dev/null +++ b/frontend/src/components/optionList/WritingVoteOptionList/WritingVoteOption/style.ts @@ -0,0 +1,100 @@ +import { styled } from 'styled-components'; + +export const Container = styled.li` + display: flex; + gap: 10px; +`; + +export const OptionContainer = styled.div` + display: flex; + flex-direction: column; + align-items: center; + + width: 100%; + padding: 20px; + border-radius: 4px; + + background-color: #e6e6e6; +`; + +export const ContentContainer = styled.div` + display: flex; + justify-content: space-between; + gap: 10px; + + width: 100%; +`; + +export const ContentTextArea = styled.textarea` + width: 100%; + height: 90px; + padding: 8px; + + font-size: 1.3rem; + line-height: 2.4rem; + + background-color: #e6e6e6; + + resize: none; + + @media (min-width: 960px) { + height: 120px; + + font-size: 1.6rem; + } +`; + +export const ImageContainer = styled.div` + width: 80%; + margin-top: 20px; + + position: relative; +`; + +export const Image = styled.img` + width: 100%; + border-radius: 4px; + + aspect-ratio: 1/1; + object-fit: cover; +`; + +export const ImageCancelWrapper = styled.div` + position: absolute; + top: 10px; + right: 10px; +`; + +export const CancelButtonWrapper = styled.div` + width: 34px; + height: 100%; +`; + +export const ButtonCssText = ` +display: flex; +justify-content: center; +align-items: center; + +width: 24px; +height: 24px; +border-radius: 50%; + +background-color: #bebebe; + +cursor: pointer; + +@media (min-width: 960px) { + width:28px; + height:28px; +} +`; + +export const IconImage = styled.img` + width: 14px; + height: 14px; + + @media (min-width: 960px) { + width: 16px; + height: 16px; + } +`; diff --git a/frontend/src/components/optionList/WritingVoteOptionList/WritingVoteOptionList.stories.tsx b/frontend/src/components/optionList/WritingVoteOptionList/WritingVoteOptionList.stories.tsx new file mode 100644 index 000000000..ead20aaa2 --- /dev/null +++ b/frontend/src/components/optionList/WritingVoteOptionList/WritingVoteOptionList.stories.tsx @@ -0,0 +1,66 @@ +import type { Meta, StoryObj } from '@storybook/react'; + +import { styled } from 'styled-components'; + +import WritingVoteOptionList from '.'; + +const meta: Meta = { + component: WritingVoteOptionList, +}; + +export default meta; +type Story = StoryObj; + +const ListWrapper = styled.div` + width: 100%; + max-width: 320px; +`; + +const MOCK_MAX_VOTE_OPTION = [ + { id: 1234123, text: '', imageUrl: '' }, + { id: 1234177, text: '', imageUrl: '' }, + { + id: 1234221, + text: '방학 때 강릉으로 강아지와 기차여행을 하려했지만 장마가 와서 취소했어요. 여행을 별로 좋', + imageUrl: '', + }, + { + id: 1834221, + text: '방학 때 강릉으로 강아지와 기차여행을 하려했지만 장마가 와서 취소했어요. 여행을 별로 좋', + imageUrl: 'https://source.unsplash.com/random', + }, + { + id: 1234451, + text: '', + imageUrl: 'https://source.unsplash.com/random', + }, +]; + +const MOCK_MIN_VOTE_OPTION = [ + { id: 123741, text: '', imageUrl: '' }, + { id: 123415, text: '', imageUrl: '' }, +]; + +export const DefaultOptionList: Story = { + render: () => ( + + + + ), +}; + +export const MaxCountOptionList: Story = { + render: () => ( + + + + ), +}; + +export const MinCountOptionList: Story = { + render: () => ( + + + + ), +}; diff --git a/frontend/src/components/optionList/WritingVoteOptionList/index.tsx b/frontend/src/components/optionList/WritingVoteOptionList/index.tsx new file mode 100644 index 000000000..233c80147 --- /dev/null +++ b/frontend/src/components/optionList/WritingVoteOptionList/index.tsx @@ -0,0 +1,46 @@ +import React from 'react'; + +import { useWritingOption } from '@hooks/useWritingOption'; +import type { WritingVoteOptionType } from '@hooks/useWritingOption'; + +import AddButton from '@components/common/AddButton'; + +import * as S from './style'; +import WritingVoteOption from './WritingVoteOption'; + +interface WritingVoteOptionListProps { + initialOptionList?: WritingVoteOptionType[]; +} + +const MINIMUM_COUNT = 2; +const MAXIMUM_COUNT = 5; + +export default function WritingVoteOptionList({ initialOptionList }: WritingVoteOptionListProps) { + const { optionList, addOption, deleteOption, removeImage, handleUploadImage } = + useWritingOption(initialOptionList); + const isDeletable = optionList.length > MINIMUM_COUNT; + + return ( + + {optionList.map(optionItem => ( + deleteOption(optionItem.id)} + handleRemoveImageClick={() => removeImage(optionItem.id)} + handleUploadImage={(event: React.ChangeEvent) => + handleUploadImage(event, optionItem.id) + } + imageUrl={optionItem.imageUrl} + /> + ))} + {optionList.length < MAXIMUM_COUNT && ( + + + + )} + + ); +} diff --git a/frontend/src/components/optionList/WritingVoteOptionList/style.ts b/frontend/src/components/optionList/WritingVoteOptionList/style.ts new file mode 100644 index 000000000..d3f33342c --- /dev/null +++ b/frontend/src/components/optionList/WritingVoteOptionList/style.ts @@ -0,0 +1,16 @@ +import { styled } from 'styled-components'; + +export const Container = styled.ul` + display: flex; + flex-direction: column; + + gap: 20px; +`; + +export const AddButtonWrapper = styled.div` + display: flex; + justify-content: center; + + position: relative; + left: 17px; +`; diff --git a/frontend/src/hooks/useWritingOption.tsx b/frontend/src/hooks/useWritingOption.tsx new file mode 100644 index 000000000..d7d87bb21 --- /dev/null +++ b/frontend/src/hooks/useWritingOption.tsx @@ -0,0 +1,88 @@ +import React, { useState } from 'react'; + +export interface WritingVoteOptionType { + id: number; + text: string; + imageUrl?: string; +} + +const MIN_COUNT = 2; +const MAX_COUNT = 5; + +const MAX_FILE_SIZE = 5000000; + +const INIT_OPTION_LIST = [ + { id: Math.floor(Math.random() * 100000), text: '', imageUrl: '' }, + { id: Math.floor(Math.random() * 100000), text: '', imageUrl: '' }, +]; + +export const useWritingOption = (initialOptionList: WritingVoteOptionType[] = INIT_OPTION_LIST) => { + const [optionList, setOptionList] = useState(initialOptionList); + + const addOption = () => { + if (optionList.length >= MAX_COUNT) return; + + const updatedOptionList = [ + ...optionList, + { id: Math.floor(Math.random() * 100000), text: '', imageUrl: '' }, + ]; + + setOptionList(updatedOptionList); + }; + + const deleteOption = (optionId: number) => { + if (optionList.length <= MIN_COUNT) return; + + const removedOptionList = optionList.filter(optionItem => optionItem.id !== optionId); + + setOptionList(removedOptionList); + }; + + const removeImage = (optionId: number) => { + const updatedOptionList = optionList.map(optionItem => { + if (optionItem.id === optionId) { + return { ...optionItem, imageUrl: '' }; + } + + return optionItem; + }); + + setOptionList(updatedOptionList); + }; + + const handleUploadImage = (event: React.ChangeEvent, optionId: number) => { + const { files } = event.target; + + if (!files) return; + + const file = files[0]; + + event.target.setCustomValidity(''); + + if (file.size > MAX_FILE_SIZE) { + event.target.setCustomValidity('사진의 용량은 5MB 이하만 가능합니다.'); + event.target.reportValidity(); + + return; + } + + const reader = new FileReader(); + + // readAsDataURL 메서드를 통해 파일을 모두 읽고 나면 reader의 loadend 이벤트에서 이미지 미리보기 결과를 확인할 수 있습니다. + reader.readAsDataURL(file); + + reader.onloadend = () => { + const updatedOptionList = optionList.map(optionItem => { + if (optionItem.id === optionId) { + return { ...optionItem, imageUrl: reader.result?.toString() }; + } + + return optionItem; + }); + + setOptionList(updatedOptionList); + }; + }; + + return { optionList, addOption, deleteOption, removeImage, handleUploadImage }; +};