Skip to content

Commit

Permalink
feat-fe: Toast구현 (#570)
Browse files Browse the repository at this point in the history
Co-authored-by: Jeongwoo Park <[email protected]>
  • Loading branch information
2 people authored and seongjinme committed Aug 23, 2024
1 parent 825bfe3 commit b40ca1e
Show file tree
Hide file tree
Showing 12 changed files with 315 additions and 34 deletions.
5 changes: 4 additions & 1 deletion frontend/.storybook/preview.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import { initialize, mswLoader } from 'msw-storybook-addon';
import handlers from '@mocks/handlers';
import { ModalProvider } from '@contexts/ModalContext';
import { withRouter } from 'storybook-addon-remix-react-router';
import ToastProvider from '@contexts/ToastContext';

initialize();

Expand All @@ -31,7 +32,9 @@ const preview: Preview = {
<QueryClientProvider client={new QueryClient()}>
<Global styles={globalStyles()} />
<ThemeProvider theme={theme}>
<Story />
<ToastProvider>
<Story />
</ToastProvider>
</ThemeProvider>
</QueryClientProvider>
</ModalProvider>
Expand Down
9 changes: 2 additions & 7 deletions frontend/src/api/APIClient.ts
Original file line number Diff line number Diff line change
Expand Up @@ -95,13 +95,8 @@ export default class APIClient implements APIClientType {
const response = await fetch(url, this.getRequestInit({ method, body, hasCookies }));

if (!response.ok) {
const { status, statusText } = response;
const defaultErrorMessage = `API통신에 실패했습니다: ${statusText}`;

const errorData = await response.json().catch(() => null);
const errorMessage = `${defaultErrorMessage}${errorData?.message ? ` - ${errorData.message}` : ''}`;

throw new ApiError({ message: errorMessage, statusCode: status, method });
const json = await response.json();
throw new ApiError({ message: json.detail ?? '', statusCode: response.status, method });
}

// Content-Type 확인 후 응답 처리
Expand Down
84 changes: 84 additions & 0 deletions frontend/src/components/common/Toast/Toast.stories.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,84 @@
/* eslint-disable max-len */
import type { Meta, StoryObj } from '@storybook/react';
import Toast from '.';

const meta: Meta<typeof Toast> = {
title: 'Common/Toast',
component: Toast,
parameters: {
layout: 'centered',
docs: {
description: {
component: 'Toast 컴포넌트는 메시지를 화면에 나타내며, type에 따라 스타일이 달라집니다.',
},
},
},
tags: ['autodocs'],
argTypes: {
message: {
description: '표시할 메시지입니다.',
control: { type: 'text' },
defaultValue: 'This is a toast message',
},
type: {
description: 'Toast의 타입입니다.',
control: { type: 'select' },
options: ['default', 'success', 'error', 'primary'],
defaultValue: 'default',
},
visible: {
description: 'Toast의 렌더링 여부입니다.',
control: { type: 'boolean' },
defaultValue: true,
},
},
args: {
visible: true,
},
};

export default meta;
type Story = StoryObj<typeof Toast>;

export const Default: Story = {
args: {
message: 'Default Alert!',
type: 'default',
},
};

export const Success: Story = {
args: {
message: 'Success Alert!',
type: 'success',
},
};

export const Error: Story = {
args: {
message: 'Error Alert!',
type: 'error',
},
};

export const Primary: Story = {
args: {
message: 'Primary Alert!',
type: 'primary',
},
};

export const LongAlert: Story = {
args: {
message: 'Primary Alert Primary Alert Primary Alert Primary Alert! ',
type: 'primary',
},
};

export const SuperLongAlert: Story = {
args: {
message:
'Primary Alert Primary Alert Primary Alert Primary Alert! Primary Alert Primary Alert Primary Alert Primary Alert Primary Alert Primary Alert Primary Alert Primary !!! Alert Primary Alert Primary Alert Primary Alert Primary Alert Primary',
type: 'primary',
},
};
40 changes: 40 additions & 0 deletions frontend/src/components/common/Toast/index.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
import { ToastType } from '@contexts/ToastContext';
import { useEffect, useState } from 'react';
import S from './style';

interface ToastProps {
message: string;
type?: ToastType;
visible: boolean;
}

export default function Toast({ message, type = 'default', visible }: ToastProps) {
return (
<S.ToastContainer
type={type}
visible={visible}
>
<S.Message>{message}</S.Message>
</S.ToastContainer>
);
}

export function ToastModal({ message, type = 'default' }: Omit<ToastProps, 'visible'>) {
const [visible, setVisible] = useState(true);

useEffect(() => {
const timer = setTimeout(() => {
setVisible(false);
}, 3000);

return () => clearTimeout(timer);
}, []);

return (
<Toast
message={message}
type={type}
visible={visible}
/>
);
}
86 changes: 86 additions & 0 deletions frontend/src/components/common/Toast/style.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,86 @@
/* eslint-disable default-case */
/* eslint-disable consistent-return */
import { keyframes } from '@emotion/react';
import styled from '@emotion/styled';

interface ToastContainerProps {
type: 'default' | 'success' | 'error' | 'primary';
}

const slideIn = keyframes`
from {
transform: translateY(-20px) translateX(-50%);
opacity: 0;
}
to {
transform: translateY(0) translateX(-50%);
opacity: 1;
}
`;

const slideOut = keyframes`
from {
transform: translateY(0) translateX(-50%);
opacity: 1;
}
to {
transform: translateY(-20px) translateX(-50%);
opacity: 0;
}
`;

const ToastContainer = styled.div<ToastContainerProps & { visible: boolean }>`
position: absolute;
top: 5%;
left: 50%;
transform: translate(-50%, -50%);
min-width: 20rem;
max-width: max(50vw, 32rem);
display: flex;
align-items: center;
justify-content: center;
width: fit-content;
height: 4.8rem;
padding: 0 1.2rem;
border-radius: 0.8rem;
box-shadow: 0 1px 4px rgba(0, 0, 0, 0.3);
background-color: ${({ type, theme }) => {
switch (type) {
case 'default':
return theme.baseColors.grayscale[50];
case 'success':
return theme.colors.feedback.success;
case 'error':
return theme.colors.feedback.error;
case 'primary':
return theme.colors.brand.primary;
}
}};
color: ${({ type, theme }) =>
type === 'default' ? theme.baseColors.grayscale[800] : theme.baseColors.grayscale[50]};
${({ theme }) => theme.typography.common.block}
animation: ${({ visible }) => (visible ? slideIn : slideOut)} 0.5s ease-out;
animation-fill-mode: forwards;
z-index: 1000;
`;

const Message = styled.div`
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
max-width: 100%;
`;

const S = {
ToastContainer,
Message,
};

export default S;
66 changes: 66 additions & 0 deletions frontend/src/contexts/ToastContext.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
import { ToastModal } from '@components/common/Toast';
import { createContext, useState, ReactNode, useContext, useMemo, useCallback, useRef } from 'react';

type ToastContextType = {
alert: (message: string) => void;
error: (message: string) => void;
success: (message: string) => void;
primary: (message: string) => void;
};

export type ToastType = 'default' | 'success' | 'error' | 'primary';

const ToastContext = createContext<ToastContextType | null>(null);

export default function ToastProvider({ children }: { children: ReactNode }) {
const [toastList, setToastList] = useState<{ id: number; type: ToastType; message: string }[]>([]);
const idRef = useRef(0);

const removeToast = (id: number) => {
setToastList((prev) => prev.filter((toast) => toast.id !== id));
};

const handleAlert = useCallback(
(type: ToastType) => (message: string) => {
const id = idRef.current;
setToastList((prev) => [...prev, { id, type, message }]);
idRef.current += 1;

setTimeout(() => removeToast(id), 4000);
},
[],
);

const providerValue = useMemo(
() => ({
alert: handleAlert('default'),
error: handleAlert('error'),
success: handleAlert('success'),
primary: handleAlert('primary'),
}),
[handleAlert],
);

return (
<ToastContext.Provider value={providerValue}>
{toastList.map(({ id, type, message }) => (
<ToastModal
type={type}
message={message}
key={id}
/>
))}
{children}
</ToastContext.Provider>
);
}

export const useToast = () => {
const value = useContext(ToastContext);

if (!value) {
throw new Error('Toast Context가 존재하지 않습니다.');
}

return value;
};
4 changes: 1 addition & 3 deletions frontend/src/hooks/useSignIn/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,14 +4,12 @@ import { useNavigate } from 'react-router-dom';

export default function useSignIn() {
const navigate = useNavigate();

const signInMutate = useMutation({
mutationFn: ({ email, password }: { email: string; password: string }) => authApi.login({ email, password }),
onSuccess: ({ clubId }) => {
navigate(`/dashboard/${clubId}/posts`);
},
onError: (error) => {
window.alert(error.message);
},
});

return {
Expand Down
21 changes: 6 additions & 15 deletions frontend/src/main.tsx
Original file line number Diff line number Diff line change
@@ -1,12 +1,12 @@
import * as Sentry from '@sentry/react';
import ReactGA from 'react-ga4';

import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import React from 'react';
import ReactDOM from 'react-dom/client';

import { ModalProvider } from '@contexts/ModalContext';
import { Global, ThemeProvider } from '@emotion/react';
import ToastProvider from '@contexts/ToastContext';

import { BASE_URL } from '@constants/constants';
import globalStyles from './styles/globalStyles';
Expand Down Expand Up @@ -36,25 +36,16 @@ async function setPrev() {
});
}

const queryClient = new QueryClient({
defaultOptions: {
queries: {
throwOnError: true,
retry: 0,
},
},
});

setPrev().then(() => {
ReactDOM.createRoot(document.getElementById('root')!).render(
<React.StrictMode>
<ModalProvider>
<QueryClientProvider client={queryClient}>
<ThemeProvider theme={theme}>
<Global styles={globalStyles()} />
<ThemeProvider theme={theme}>
<Global styles={globalStyles()} />
<ToastProvider>
<AppRouter />
</ThemeProvider>
</QueryClientProvider>
</ToastProvider>
</ThemeProvider>
</ModalProvider>
</React.StrictMode>,
);
Expand Down
4 changes: 1 addition & 3 deletions frontend/src/mocks/handlers/authHandlers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,9 +15,8 @@ const authHandlers = [
await new Promise((resolve) => setTimeout(resolve, 2000));

if (!body.email || !body.password || body.email !== '[email protected]' || body.password !== 'admin') {
return new Response(null, {
return new Response(JSON.stringify({ detail: '로그인 정보가 일치하지 않습니다.' }), {
status: 401,
statusText: '[Mock Data Error] Login Failed',
});
}

Expand All @@ -27,7 +26,6 @@ const authHandlers = [

return new Response(responseBody, {
status: 201,
statusText: 'Created',
headers: {
'Content-Type': 'application/json',
},
Expand Down
Loading

0 comments on commit b40ca1e

Please sign in to comment.