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 14 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
5 changes: 4 additions & 1 deletion src/@types/styles/icon.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,4 +27,7 @@ export type IconKind =
| 'location'
| 'warning'
| 'account'
| 'language';
| 'language'
| 'keyboard'
| 'exclamation'
| 'bell';
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,
} from 'react-icons/md';

const ICON: { [key in IconKind]: IconType } = {
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,
};

interface IconProps {
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;
`;
141 changes: 141 additions & 0 deletions src/pages/KeywordSubscribe/index.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,141 @@
import {
deleteSubscribeKeyword,
fetchSubscribeKeyword,
postSubscribeKeyword,
} from '@apis/subscribe/subscribeKeyword';
import InformUpperLayout from '@components/InformUpperLayout';
import { KEYWORD_PAGE } from '@constants/keyword';
import TOAST_MESSAGES from '@constants/toast-message';
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 { KeyboardEventHandler, 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 handleKeyDown: KeyboardEventHandler<HTMLInputElement> = (e) => {
if (e.key !== 'Enter' || e.nativeEvent.isComposing) return;
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 ? true : false);
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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


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

<InputWrapper>
<KeywordInput
onChange={setInputKeyword}
placeholder={KEYWORD_PAGE.PLACEHOLDER}
maxLength={15}
onKeyDown={handleKeyDown}
value={inputKeyword}
/>
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

여기서 onKeyDown 이벤트가 발생할 때마다 handleKeyDown 콜백 함수가 실행될 것 같은데, 이 방법보다는 form으로 감싸고 onSubmit 이벤트에 콜백 함수를 바인딩 하는건 어떨까요? 이렇게 하면 등록버튼, 엔터 모두 정상적으로 동작합니다

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

form 이 더 깔끔한거 같네요 수정할게요

<KeywordSubmit
disabled={!checkIsAvailable()}
isAvailable={checkIsAvailable()}
onClick={onClickSubmit}
>
등록
</KeywordSubmit>
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

common 폴더에 있는 공통 컴포넌트 Button을 사용하지 않은 이유가 있나요?

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

image

사진의 '등록' 버튼에 대한 Button 인데 다른곳에 사용하는 버튼과 모양이 많이 다르다 생각해서 추가적인 CSS 요소가 많을거 같아 공통 컴포넌트를 사용하지 않았어요

공통 컴포넌트의 Button 쓰는게 더 좋을까요?

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

저도 고민이 많이 됐었는데, 일단은 common 폴더에 있는 버튼 컴포넌트를 최대한 재활용 하는 방향으로 사용하고 있긴 합니다. 테오의 프론트엔드 방에서도 질문을 했었는데 스타일이 다르다는 이유로 공통 컴포넌트를 사용하지 않고 매번 새로운 컴포넌트를 생성하기 보다 스타일을 외부에서 주입하면서 미리 구현해둔 공통 컴포넌트를 재사용하는 방향으로 간다고 하네요.. 저도 아직 어떤 방법에 더 옳은 방법인가에 대한 확신은 없지만 새로운 컴포넌트를 매번 만드는 것 보다 스타일을 주입하더라도 공통 컴포넌트를 재사용하는 방향이 더 괜찮은 것 같다는 생각은 하고 있어요~

image

</InputWrapper>

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

export default KeywordSubscribe;

const InputWrapper = styled.div`
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;
}
`;

const KeywordSubmit = styled.button<{ isAvailable: boolean }>`
color: ${(prop) => (prop.isAvailable ? THEME.PRIMARY : THEME.TEXT.GRAY)};
border: none;
background-color: transparent;
position: absolute;
top: 10px;
right: 10%;

&: hover {
cursor: pointer;
}
`;
Loading