Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Feat/#315: 키워드 구독 기능 추가 #319

Merged
merged 20 commits into from
Feb 12, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
20 commits
Select commit Hold shift + click to select a range
0e29f69
feat(icon): 아이콘 추가
pp449 Feb 1, 2024
8dbcb9f
feat(My): 마이페이지에 신규 기능 추가
pp449 Feb 1, 2024
c14b527
feat(My): 마이페이지에서 faq로 이동하는 기능 추가
pp449 Feb 1, 2024
b7b7a55
test(subscribeHandler): 키워드 관련 API 모킹
pp449 Feb 1, 2024
93fe6e7
feat(constants keyword): 키워드 페이지에 필요한 문구를 상수로 정의
pp449 Feb 1, 2024
03bdb24
feat(App): 키워드 알림 페이지 라우팅 추가
pp449 Feb 1, 2024
6a9300e
feat(useInput): 입력을 처리하는 훅 추가
pp449 Feb 2, 2024
ea369af
feat(toast-message): 필요한 토스트메시지 상수 추가
pp449 Feb 2, 2024
25019fe
test(subscribeHandler): 모킹 서버 요청의 path 오타 수정
pp449 Feb 2, 2024
1f9bdf0
feat(apis subscribeKeyword): 구독 관련한 서버 요청 추가
pp449 Feb 2, 2024
9123331
fix(My): 마이페이지에서 구독한 경우에만 알림 설정이 가능하도록 수정
pp449 Feb 2, 2024
cbcdd2f
feat(keywordSubscribe): 키워드 구독 관련한 페이지 UI 및 기능 추가
pp449 Feb 2, 2024
8964dcf
feat(RegisteredKeywordList): 유저가 구독한 키워드들을 보여주는 UI 추가
pp449 Feb 2, 2024
a62d7cd
fix(My): 학과 공지사항 알림받기 -> 알림 설정으로 단어 변경
pp449 Feb 2, 2024
d36a5aa
chore(My): 피드백 반영
pp449 Feb 6, 2024
4013345
chore(keywordSubscribe): 피드백 반영
pp449 Feb 6, 2024
a087a64
Merge branch 'dev' into feat/#315
pp449 Feb 6, 2024
dfc5928
refactor(KeywordSubscribe): 버튼을 공통 컴포넌트 사용으로 변경
pp449 Feb 10, 2024
6c34964
Merge branch 'feat/#315' of https://github.com/GDSC-PKNU-21-22/pknu-n…
pp449 Feb 10, 2024
e4b1af2
test(My): My 컴포넌트에 toast 메세지를 추가하여 테스트코드에 ToastsProvier 추가
pp449 Feb 10, 2024
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions src/@types/styles/icon.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,5 +27,8 @@ export type IconKind =
| 'warning'
| 'account'
| 'language'
| 'keyboard'
| 'exclamation'
| 'bell'
| 'myLocation'
| 'location';
2 changes: 2 additions & 0 deletions src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import Header from '@components/Header';
import Announcement from '@pages/Announcement';
import FAQPage from '@pages/FAQ';
import Home from '@pages/Home';
import KeywordSubscribe from '@pages/KeywordSubscribe';
import MajorDecision from '@pages/MajorDecision';
import MapPage from '@pages/Map';
import My from '@pages/My';
Expand All @@ -24,6 +25,7 @@ const App = () => {
<Route path="/announcement/*" element={<Announcement />} />
<Route path="/major-decision/*" element={<MajorDecision />} />
<Route path="/my" element={<My />} />
<Route path="/keyword" element={<KeywordSubscribe />} />
<Route path="/tip/:type" element={<Tip />} />
<Route path="/FAQ" element={<FAQPage />} />
<Route path="/suggestion" element={<SuggestionPage />} />
Expand Down
40 changes: 40 additions & 0 deletions src/apis/subscribe/subscribeKeyword.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
import http from '@apis/http';

export const fetchSubscribeKeyword = async () => {
const userToken = localStorage.getItem('subscribe');
if (!userToken) return;

const res = await http.get<string[]>(
'/api/subscription/keyword?userToken=' + userToken,
);

return res.data;
};

export const postSubscribeKeyword = async (keyword: string) => {
const userToken = localStorage.getItem('subscribe');
if (!userToken) return;

const res = await http.post('/api/subscription/keyword', {
data: {
subscription: JSON.parse(userToken),
keyword,
},
});

return res;
};

export const deleteSubscribeKeyword = async (keyword: string) => {
const userToken = localStorage.getItem('subscribe');
if (!userToken) return;

const res = await http.delete('/api/subscription/keyword', {
data: {
subscription: JSON.parse(userToken),
keyword,
},
});

return res;
};
6 changes: 6 additions & 0 deletions src/components/Common/Icon/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,9 @@ import {
MdOutlineKeyboardArrowDown,
MdAssignmentInd,
MdLanguage,
MdKeyboard,
MdError,
MdDoorbell,
MdOutlineLocationOn,
} from 'react-icons/md';

Expand Down Expand Up @@ -62,6 +65,9 @@ const ICON: { [key in IconKind]: IconType } = {
warning: MdOutlineError,
account: MdAssignmentInd,
language: MdLanguage,
keyboard: MdKeyboard,
exclamation: MdError,
bell: MdDoorbell,
myLocation: MdOutlineMyLocation,
location: MdOutlineLocationOn,
};
Expand Down
7 changes: 7 additions & 0 deletions src/constants/keyword.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
export const KEYWORD_PAGE = {
TITLE: '키워드 알림 설정',
SUB_TITLE:
'키워드를 설정하시면 키워드가 포함된 공지사항이 올라올 때마다 알림을 보내드려요.',
PLACEHOLDER: '키워드를 입력해주세요. (예 : 장학금)',
REGISTERED_KEYWORD: '등록한 키워드',
};
5 changes: 5 additions & 0 deletions src/constants/toast-message.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,11 @@ const TOAST_MESSAGES = {
OUT_OF_SHOOL: '학교 밖에서는 내 위치 정보를 제공하지 않아요!',
SHARE_LOCATION: '위치 정보를 공유해 주세요',
SEARCH_KEYWORD: '검색어를 입력해주세요',
NEED_SUBSCRIBE: '알림 설정을 먼저 해주세요',
NEED_MORE_TEXT: '2글자 이상 입력해주세요',
DUPLICATE_KEYWORD: '중복된 키워드가 설정되어있습니다',
EXCEED_SUBSCRIBE_MAX_COUNT: '키워드는 최대 5개까지 설정 가능합니다',
ERROR_MESSAGE: '일시적인 에러발생',
} as const;

export default TOAST_MESSAGES;
19 changes: 19 additions & 0 deletions src/hooks/useInput.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
import { ChangeEvent, useCallback, useState } from 'react';

const useInput = () => {
const [inputValue, setInputValue] = useState<string>('');

const handleValue = useCallback((e: ChangeEvent<HTMLInputElement>) => {
const { value } = e.target;

setInputValue(value);
}, []);

const resetValue = useCallback(() => {
setInputValue('');
}, []);

return [inputValue, handleValue, resetValue] as const;
};

export default useInput;
15 changes: 13 additions & 2 deletions src/mocks/handlers/subscribeHandler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,10 +2,21 @@ import { SERVER_URL } from '@config/index';
import { RequestHandler, rest } from 'msw';

export const subscribeHandler: RequestHandler[] = [
rest.post(SERVER_URL + '/api/subscription', (req, res, ctx) => {
rest.post(SERVER_URL + '/api/subscription/major', (req, res, ctx) => {
return res(ctx.status(200));
}),
rest.delete(SERVER_URL + '/api/subscription', (req, res, ctx) => {
rest.delete(SERVER_URL + '/api/subscription/major', (req, res, ctx) => {
return res(ctx.status(200));
}),
rest.get(SERVER_URL + '/api/subscription/keyword', (req, res, ctx) => {
const mockData = ['장학', '등록', '네이버'];

return res(ctx.status(200), ctx.json(mockData));
}),
rest.post(SERVER_URL + '/api/subscription/keyword', (req, res, ctx) => {
return res(ctx.status(200));
}),
rest.delete(SERVER_URL + '/api/subscription/keyword', (req, res, ctx) => {
return res(ctx.status(200));
}),
];
62 changes: 62 additions & 0 deletions src/pages/KeywordSubscribe/components/RegisteredKeywordList.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
import { KEYWORD_PAGE } from '@constants/keyword';
import styled from '@emotion/styled';
import { THEME } from '@styles/ThemeProvider/theme';

interface RegisteredKeywordListProps {
keywords: string[];
deleteKeyword: (keyword: string) => void;
}

const RegisteredKeywordList = ({
keywords,
deleteKeyword,
}: RegisteredKeywordListProps) => {
return (
<Container>
{KEYWORD_PAGE.REGISTERED_KEYWORD}
{keywords && (
<KeywordContainer>
{keywords.map((keyword, index) => (
<KeywordWrapper key={index}>
{keyword}
<KeywordCancel onClick={() => deleteKeyword(keyword)}>
X
</KeywordCancel>
</KeywordWrapper>
))}
</KeywordContainer>
)}
</Container>
);
};

export default RegisteredKeywordList;

const Container = styled.div`
display: flex;
flex-direction: column;
padding: 5% 20px 0 20px;
`;

const KeywordContainer = styled.div`
display: flex;
padding-top: 10px;
flex-wrap: wrap;
`;

const KeywordWrapper = styled.div`
padding: 7px 7px 7px 14px;
border: 1px solid ${THEME.TEXT.GRAY};
border-radius: 15px;
margin: 5px;
display: flex;
align-items: center;
font-size: 0.9rem;
box-shadow: rgba(0, 0, 0, 0.12) 0px 1px 3px, rgba(0, 0, 0, 0.24) 0px 1px 2px;
`;

const KeywordCancel = styled.button`
background-color: transparent;
border: none;
line-height: 1px;
`;
143 changes: 143 additions & 0 deletions src/pages/KeywordSubscribe/index.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,143 @@
import {
deleteSubscribeKeyword,
fetchSubscribeKeyword,
postSubscribeKeyword,
} from '@apis/subscribe/subscribeKeyword';
import Button from '@components/Common/Button';
import InformUpperLayout from '@components/InformUpperLayout';
import { KEYWORD_PAGE } from '@constants/keyword';
import TOAST_MESSAGES from '@constants/toast-message';
import { css } from '@emotion/react';
import styled from '@emotion/styled';
import useInput from '@hooks/useInput';
import useToasts from '@hooks/useToast';
import RegisteredKeywordList from '@pages/KeywordSubscribe/components/RegisteredKeywordList';
import { THEME } from '@styles/ThemeProvider/theme';
import { FormEvent, FormEventHandler, useEffect, useState } from 'react';

const KeywordSubscribe = () => {
const [keywords, setKeywords] = useState<string[]>([]);
const [inputKeyword, setInputKeyword, resetKeywords] = useInput();
const { addToast } = useToasts();

const fetchKeywords = async () => {
const data = await fetchSubscribeKeyword();
if (data) setKeywords(data);
};

const deleteKeyword = async (keyword: string) => {
const res = await deleteSubscribeKeyword(keyword);

if (res?.status !== 200) {
addToast(TOAST_MESSAGES.ERROR_MESSAGE);
return;
}

setKeywords((prevKeyword) => prevKeyword.filter((key) => key !== keyword));
};

const handleSubmit: FormEventHandler<HTMLFormElement> = (
e: FormEvent<HTMLFormElement>,
) => {
e.preventDefault();
onClickSubmit();
};

const onClickSubmit = async () => {
if (keywords.length > 4) {
addToast(TOAST_MESSAGES.EXCEED_SUBSCRIBE_MAX_COUNT);
resetKeywords();
return;
}

if (inputKeyword.length < 2) {
addToast(TOAST_MESSAGES.NEED_MORE_TEXT);
resetKeywords();
return;
}

if (keywords.includes(inputKeyword)) {
addToast(TOAST_MESSAGES.DUPLICATE_KEYWORD);
resetKeywords();
return;
}

const res = await postSubscribeKeyword(inputKeyword);
if (res && res.status === 200) {
setKeywords((prevKeywords) => [...prevKeywords, inputKeyword]);
resetKeywords();
return;
}

addToast(TOAST_MESSAGES.ERROR_MESSAGE);
};

useEffect(() => {
fetchKeywords();
}, []);

const checkIsAvailable = () => inputKeyword.length > 1;

return (
<>
<InformUpperLayout>
<InformUpperLayout.InformTitle title={KEYWORD_PAGE.TITLE} />
<InformUpperLayout.InformSubTitle subTitle={KEYWORD_PAGE.SUB_TITLE} />
</InformUpperLayout>

<InputWrapper onSubmit={handleSubmit}>
<KeywordInput
onChange={setInputKeyword}
placeholder={KEYWORD_PAGE.PLACEHOLDER}
maxLength={15}
value={inputKeyword}
/>
<Button
disabled={!checkIsAvailable()}
css={css`
position: absolute;
top: 10px;
right: 10%;
border: none;
width: auto;
height: auto;
background-color: transparent !important;
color: ${checkIsAvailable() ? THEME.PRIMARY : THEME.TEXT.GRAY};
padding: 0;
margin: 0;
&: hover {
cursor: pointer;
}
`}
>
등록
</Button>
</InputWrapper>

<RegisteredKeywordList
keywords={keywords}
deleteKeyword={deleteKeyword}
/>
</>
);
};

export default KeywordSubscribe;

const InputWrapper = styled.form`
padding: 0 20px 0 20px;
position: relative;
`;

const KeywordInput = styled.input`
width: 90%;
border: none;
border-bottom: 1.5px solid;
padding: 10px;

&:focus {
border: none;
outline: none;
border-bottom: 1.5px solid;
}
`;
6 changes: 5 additions & 1 deletion src/pages/My/index.test.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import Modal from '@components/Common/Modal';
import MajorProvider from '@components/Providers/MajorProvider';
import ModalsProvider from '@components/Providers/ModalsProvider';
import ToastsProvider from '@components/Providers/ToastsProvider';
import { MODAL_BUTTON_MESSAGE, MODAL_MESSAGE } from '@constants/modal-messages';
import useModals from '@hooks/useModals';
import { render, screen } from '@testing-library/react';
Expand Down Expand Up @@ -64,14 +65,17 @@ describe('마이 페이지 동작 테스트', () => {
render(
<MajorProvider>
<ModalsProvider>
<My />
<ToastsProvider>
<My />
</ToastsProvider>
</ModalsProvider>
</MajorProvider>,
{ wrapper: MemoryRouter },
);

const majorEditButton = screen.getByText('학과 선택하러가기');
await userEvent.click(majorEditButton);

expect(mockRouterTo).toHaveBeenCalledWith('/major-decision');
});
});
Loading
Loading