Skip to content

Commit

Permalink
알림 ui 구현 및 api 연동 (#751)
Browse files Browse the repository at this point in the history
* feat: (#740) 알림을 넣을 툴킷 컴포넌트 제작

* test: (#740) 알림을 넣을 툴킷 컴포넌트 테스트

* feat: (#740) 알림아이콘 컴포넌트 제작 및 마이페이지 아이콘과 교체

* design: 기존 --gray > --bright-gray로 수정 및 --gray 추가

* feat: (#740) 알림 아이콘을 누르는 경우 툴팁 렌더링 구현

- 배경을 누르면 툴팁 제거
- 툴팁 테두리색 변경

* design: icon button 내 icon 크기 줄이기

- 둥근 배경이 있는 경우에만 해당

* feat: (#740) 알림 관련 api 및 query hook 생성

* feat: (#740) 신고처리 알림 타입 필드명 변경

* test: (#740) msw를 위한 mockData 생성 및 핸들러 생성

* feat: (#740) 알림 쿼리훅과 UI 연결

- msw 데이터 불러오기 성공
- 더보기 클릭 시 추가 데이터 불러오기 성공
- 서스펜스, 에러바운더리, 로딩스피너 도입

* fix: (#740) msw를 위한 mockData에 undefined이 있어 수정

* feat: (#740) 알림 내용 구체적인 메세지로 전달되도록 구현

- 알림 리스트 내 아이템 UI 구현

* design: 툴팁 트리거 버튼과 중심이 일치하지 않아 위치 조정

* design: 스크롤때문에 툴팁 위치가 맞지 않아 스크롤 숨기기 구현 및 위치 조정

* feat: (#740) 알림 item을 눌렀을 경우 tooltip 닫기 구현

* feat: (#740) 읽었던 알림은 회색 처리 + hover 색상 수정

* feat: (#740) 알림 리스트를 담은 컨테이너 분리

* test: (#740) 알림 리스트를 담은 컨테이너 스토리북 생성

* feat: (#740) 리스트 사용하는 곳에서 style을 조정할 수 있도록 수정

* feat: (#740) 모바일버전 홈페이지에서 사이드바로 알림 내용 노출 구현

* feat: (#740) 알림 컨테이너 외부 크기에 맞추어 style 조정

- 사이드바에서 높이 설정
- 툴팁에서는 최대높이 및 가로길이 설정

* feat: (#740) 알림 버튼 활성화 표시를 포함하기 위해 분리

* test: (#740) 활성화 표시가 포함된 알림 트리거 버튼 테스트

* feat: (#740) 기존 알림리스트 트리거 버튼을 알림 버튼으로 교체

* feat: (#740) 활성화 정보 포함 알림버튼을 활성화정보 포함 버튼 컨테이너로 수정

* feat: (#740) 사용자 정보 response에 알림 활성화 여부 추가 및 UI 적용

- msw를 통한 적용 확인

* feat: (#740) 최근 알림 읽기 api, hook 생성 및 적용

- 비회원인 경우 알림 툴팁, 사이드가 안열리도록 조치
- 비회원인 경우 알림 툴팁, 사이드 버튼을 눌러도 api 통신되지 않도록 조치

* refactor: (#740) 최신 알림 읽기 query hook 오타수정

* feat: (#740) 단건 알림 읽기 api 및 query hook 생성

* feat: (#740) 신고 처리 결과 페이지 초안 작성

* feat: (#740) 신고 처리 결과 페이지 라우터 설정 및 진입 트리거 연결

- 신고 알림 리스트에서 페이지 연결

* feat: 신고 사유 type 강화.

- 신고 모달, 신고 api에서 신고 사유 type checking

* feat: (#740) 신고 처리 알림 api 명세 변경에 따른 수정

- 생성시간 뎁스 변경
- 신고 사유 필드 추가

* feat: (#740) 단건 신고 처리 조회 api 및 query hook 생성

* test: (#740) 단건 신고 처리 조회 msw 생성

* test: (#740) 신고된 내역 알림 list 타입 수정에 따른 mockData 수정

* feat: (#740) 신고 조치 결과 페이지 라우터 url 변경

* feat: (#740) 신고 조치 결과 페이지 내부 요소 구현

- 신고 조치 정보 추가
- 게시물인 경우 게시물 정보 통신. (서스펜스/에러 바운더리 적용)

* fix: (#740) 알림 읽기 api 위치 변경 및 잘못된 hook 이름 수정

- api 위치: userInfo > alarm
- hook 이름: useReadLatestAlarm >useReadAlarm

* feat: (#740) 알림 리스트 내 알림을 누른 경우 알림을 읽음 api 통신 구현

* fix: 마이페이지에서는 유저 프로필 내 마이페이지 이동 트리거 제거

* test: 알림 관련 api url을 mock에서 실제 baseUrl로 수정

* refactor: 단건 알림 읽기 쿼리 훅 위치 변경

- 쿼리훅/유저정보 폴더> 쿼리훅/알림 폴더

* test: 사용자 정보 api 수정에 따른 jest 테스트 코드 수정

* fix: (#740) 바뀐 알림 관련 API 명세 적용

- 컨텐츠 알림 목록 api
- 신고 조치 알림 목록 api
- 신고 조치 단건 조회  api

* test: 알림 리스트 data가 없는 경우 msw에 추가

* feat: (#740) 알림 목록에 아이템이 없는 경우의 문구 크기 설정

* feat: (#740) 글로벌 스타일에 페이지 헤더 글씨 크기 및 1.4rem 추가

* design: (#740) 신고된 내역 확인 페이지 글씨크기 키우기 및 반응형 적용

* design: 게시글 모바일버전 카테고리, 닉네임 등 1.2rem -> 1.4rem 수정

* refactor: (#740) 알림 리스트가 포함된 알림 컨테이너 폴더 이동

- component/common > component

* refactor: (#740) 알림 말줄임표 상수화 및 메세지 구체화

* refactor: (#740) 스타일 컴포넌트 컨벤션 지키기

* feat: (#740) 추후 토스트 처리할 위치 표시

* refactor: (#740) 불필요한 코드 삭제

- msw 코드 중 무의미한 url 코드 삭제

* test: (#740) 알림 관련 mock data가 동일한 id를 가지지 않도록 수정

* refactor: (#740) 신고 처리 알림 문구 상수화 및 변경

* feat: 사용자 정보에 종류(유저/관리자) 필드 추가

- 관리자 페이지와 관련된 내용이지만 PR 충돌 방지를 위해 추가

* refactor: (#740) 알람 URL 수정

* test: (#740) 잘못된 알림 아이콘 버튼 스토리명 수정

* refactor: (#740) 단건 신고처리 변수명 수정

- 수정전: ReportConfirmResult
- 수정후: ReportApproveResult

* fix: (#740) .map을 사용하며 key를 설정하지 않아 발생한 오류 해결

* design: (#740) 신고조치 상세 페이지에서 사유 UI 재설정

* feat: (#740) api url mocking이 아닌 실제 서버 url로 수정

* feat: 라우터에서 신고 처리된 내용 페이지에 RouteChangeTracker 삭제

* refactor: (#740) reportApproveResult 쿼리키 상수에 적용
  • Loading branch information
chsua authored Oct 18, 2023
1 parent 628c116 commit 88567a4
Show file tree
Hide file tree
Showing 68 changed files with 1,254 additions and 71 deletions.
12 changes: 10 additions & 2 deletions frontend/__test__/api/user.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,12 +28,20 @@ describe('서버와 통신하여 유저의 정보를 불러올 수 있어야 한
expect(data).toEqual(transformUserInfoResponse(MOCK_USER_INFO));
});

test('클라이언트에서 사용하는 유저 정보 API 명세가 [nickname, gender, birthYear, postCount, voteCount]으로 존재해야한다', async () => {
test('클라이언트에서 사용하는 유저 정보 API 명세가 [nickname, gender, birthYear, postCount, voteCount, hasLatestAlarm, role]으로 존재해야한다', async () => {
const data = await getUserInfo(isLoggedIn);

const userInfoKeys = Object.keys(data ?? {});

expect(userInfoKeys).toEqual(['nickname', 'gender', 'birthYear', 'postCount', 'voteCount']);
expect(userInfoKeys).toEqual([
'nickname',
'gender',
'birthYear',
'postCount',
'voteCount',
'hasLatestAlarm',
'role',
]);
});

test('유저의 닉네임을 수정한다', async () => {
Expand Down
93 changes: 93 additions & 0 deletions frontend/src/api/alarm.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,93 @@
import { ReportMessage, ReportType } from '@type/report';
import { StringDate } from '@type/time';

import { getFetch, patchFetch } from '@utils/fetch';

const BASE_URL = process.env.VOTOGETHER_BASE_URL;

export interface ContentAlarmList {
pageNumber: number;
alarmList: ContentAlarm[];
}

interface ContentAlarm {
alarmId: number;
createdAt: StringDate;
isChecked: boolean;
detail: {
postId: number;
postTitle: string;
commentWriter: string; // 게시글에 관한 알림일때는 ""으로 옴
};
}

export const getContentAlarmList = async (page: number): Promise<ContentAlarmList> => {
const alarmList = await getFetch<ContentAlarm[]>(`${BASE_URL}/alarms/content?page=${page}`);

return {
pageNumber: page,
alarmList,
};
};

export interface ReportAlarmList {
pageNumber: number;
alarmList: ReportAlarm[];
}

interface ReportAlarm {
alarmId: number;
isChecked: boolean;
detail: ReportApproveResult;
}

interface ReportAlarmResponse {
alarmId: number;
isChecked: boolean;
detail: ReportApproveResultResponse;
}

export interface ReportApproveResult {
reportId: number;
type: ReportType;
reasonList: ReportMessage[];
content: string;
createdAt: StringDate;
}

export interface ReportApproveResultResponse {
reportId: number;
type: ReportType;
reasons: ReportMessage[];
content: string;
createdAt: StringDate;
}

const transformReportApproveResultResponse = (reportApproveResult: ReportApproveResultResponse) => {
const { reportId, type, reasons, content, createdAt } = reportApproveResult;
return { reportId, type, reasonList: reasons, content, createdAt };
};

export const getReportAlarmList = async (page: number): Promise<ReportAlarmList> => {
const alarmList = await getFetch<ReportAlarmResponse[]>(`${BASE_URL}/alarms/report?page=${page}`);

return {
pageNumber: page,
alarmList: alarmList.map(alarm => ({
...alarm,
detail: transformReportApproveResultResponse(alarm.detail),
})),
};
};

export const getReportApproveResult = async (reportId: number): Promise<ReportApproveResult> => {
const reportApproveResult = await getFetch<ReportApproveResultResponse>(
`${BASE_URL}/alarms/report/${reportId}`
);

return transformReportApproveResultResponse(reportApproveResult);
};

export const readAlarm = async (alarmId: number) => {
await patchFetch(`${BASE_URL}/alarms/${alarmId}`);
};
10 changes: 9 additions & 1 deletion frontend/src/api/userInfo.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,8 @@ export interface UserInfoResponse {
birthYear: number;
postCount: number;
voteCount: number;
hasLatestAlarm: boolean;
role: 'ADMIN' | 'USER';
}

export interface ModifyNicknameRequest {
Expand All @@ -20,14 +22,16 @@ export interface UpdateUserInfoRequest {
}

export const transformUserInfoResponse = (userInfo: UserInfoResponse): User => {
const { nickname, gender, birthYear, postCount, voteCount } = userInfo;
const { nickname, gender, birthYear, postCount, voteCount, hasLatestAlarm, role } = userInfo;

return {
nickname,
gender,
birthYear,
postCount,
voteCount,
hasLatestAlarm,
role,
};
};

Expand All @@ -53,6 +57,10 @@ export const updateUserInfo = async (userInfo: UpdateUserInfoRequest) => {
await patchFetch<UpdateUserInfoRequest>(`${BASE_URL}/members/me/detail`, userInfo);
};

export const readLatestAlarm = async () => {
await patchFetch(`${BASE_URL}/members/me/check-alarm`);
};

export const logoutUser = async () => {
await fetch('/auth/logout', { method: 'DELETE', credentials: 'include' });
};
Binary file added frontend/src/assets/bell.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
24 changes: 24 additions & 0 deletions frontend/src/components/AlarmContainer/AlarmContainer.stories.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
import type { Meta } from '@storybook/react';

import { CSSProperties } from 'react';

import AlarmContainer from '.';

const meta: Meta<typeof AlarmContainer> = {
component: AlarmContainer,
};

export default meta;

export const Default = () => {
return <AlarmContainer closeToolTip={() => {}} />;
};

const style: CSSProperties = {
maxHeight: '500px',
overflow: 'scroll',
};

export const LimitHeight = () => {
return <AlarmContainer closeToolTip={() => {}} style={style} />;
};
82 changes: 82 additions & 0 deletions frontend/src/components/AlarmContainer/ContentAlarmList/index.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,82 @@
import { Fragment } from 'react';
import { useNavigate } from 'react-router-dom';

import { useContentAlarmList } from '@hooks/query/alarm/useContentAlarmList';
import { useReadAlarm } from '@hooks/query/alarm/useReadAlarm';

import LoadingSpinner from '@components/common/LoadingSpinner';
import SquareButton from '@components/common/SquareButton';

import { PATH } from '@constants/path';

import { SHORTEN_TEXT_LENGTH } from '../constant';
import * as LS from '../ListStyle';
import * as S from '../style';

export default function ContentAlarmList({ closeToolTip }: { closeToolTip: () => void }) {
const { data, fetchNextPage, hasNextPage, isFetchingNextPage, isListEmpty } =
useContentAlarmList();
const { mutate } = useReadAlarm('CONTENT');

const navigation = useNavigate();

if (isListEmpty) {
return <LS.Description>현재 도착한 알림이 없습니다!</LS.Description>;
}

const handleAlarmClick = (alarmId: number, postId: number) => {
mutate(alarmId);

navigation(`${PATH.POST}/${postId}`);
closeToolTip();
};

return (
<LS.ListContainer>
{data?.pages.map((listInfo, pageIndex) => (
<Fragment key={pageIndex}>
{listInfo.alarmList.map(alarm => {
const { postId, postTitle: title, commentWriter: nickname } = alarm.detail;
const shortTitle =
title.length < SHORTEN_TEXT_LENGTH.TITLE
? title
: `${title.slice(0, SHORTEN_TEXT_LENGTH.TITLE)}...`;
const shortNickname =
nickname.length < SHORTEN_TEXT_LENGTH.NICKNAME
? nickname
: `${nickname.slice(0, SHORTEN_TEXT_LENGTH.NICKNAME)}...`;

return (
<LS.ListItem key={alarm.alarmId} $isRead={alarm.isChecked}>
<LS.LinkButton
onClick={() => {
handleAlarmClick(alarm.alarmId, postId);
}}
>
<p>
{nickname === ''
? `"${shortTitle}" 게시글이 마감되었습니다!`
: `"${shortNickname}" 님이 "${shortTitle}" 게시글에 댓글을 달았습니다!`}
</p>
<p>{alarm.createdAt}</p>
</LS.LinkButton>
</LS.ListItem>
);
})}
</Fragment>
))}
{isFetchingNextPage && (
<S.LoadingSpinnerWrapper>
<LoadingSpinner size="sm" />
</S.LoadingSpinnerWrapper>
)}
{!isFetchingNextPage && hasNextPage && (
<LS.ButtonWrapper>
<SquareButton theme="fill" onClick={() => fetchNextPage()}>
더보기
</SquareButton>
</LS.ButtonWrapper>
)}
</LS.ListContainer>
);
}
50 changes: 50 additions & 0 deletions frontend/src/components/AlarmContainer/ListStyle.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
import styled from 'styled-components';

export const Description = styled.p`
font: var(--text-default);
text-align: center;
`;

export const ListContainer = styled.ul`
font: var(--text-default);
`;

export const ListItem = styled.li<{ $isRead: boolean }>`
display: flex;
border: 1px solid var(--gray);
align-items: center;
min-height: 60px;
margin: 2px 0;
background-color: ${props => props.$isRead && 'var(--bright-gray)'};
`;

export const ButtonWrapper = styled.li`
margin-top: 10px;
`;

export const LinkButton = styled.button`
height: 100%;
width: 100%;
padding: 10px;
text-align: left;
cursor: pointer;
&:hover {
background-color: var(--gray);
}
& > *:first-child {
font-weight: 500;
}
& > *:last-child {
margin-top: 5px;
text-align: right;
font: var(--text-small);
}
`;
75 changes: 75 additions & 0 deletions frontend/src/components/AlarmContainer/ReportAlarmList/index.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
import { Fragment } from 'react';
import { useNavigate } from 'react-router-dom';

import { useReadAlarm } from '@hooks/query/alarm/useReadAlarm';
import { useReportAlarmList } from '@hooks/query/alarm/useReportAlarmList';

import LoadingSpinner from '@components/common/LoadingSpinner';
import SquareButton from '@components/common/SquareButton';

import { PATH } from '@constants/path';
import { REPORT_TYPE } from '@constants/policyMessage';

import { SHORTEN_TEXT_LENGTH } from '../constant';
import * as LS from '../ListStyle';
import * as S from '../style';

export default function ReportAlarmList({ closeToolTip }: { closeToolTip: () => void }) {
const { data, fetchNextPage, hasNextPage, isFetchingNextPage, isListEmpty } =
useReportAlarmList();
const { mutate } = useReadAlarm('REPORT');

const navigation = useNavigate();

if (isListEmpty) {
return <LS.Description>현재 도착한 알림이 없습니다!</LS.Description>;
}

const handleAlarmClick = (alarmId: number, reportId: number) => {
mutate(alarmId);

navigation(`${PATH.REPORT_ALARM}/${reportId}`);
closeToolTip();
};

return (
<LS.ListContainer>
{data?.pages.map((listInfo, pageIndex) => (
<Fragment key={pageIndex}>
{listInfo.alarmList.map(alarm => {
const { reportId, type, content } = alarm.detail;
const shortContent =
content.length < SHORTEN_TEXT_LENGTH.DEFAULT
? content
: `${content.slice(0, SHORTEN_TEXT_LENGTH.DEFAULT)}...`;

return (
<LS.ListItem key={alarm.alarmId} $isRead={alarm.isChecked}>
<LS.LinkButton
onClick={() => {
handleAlarmClick(alarm.alarmId, reportId);
}}
>
<p>{`신고를 받아 "${shortContent}" ${REPORT_TYPE[type].actionMessage}`}</p>
<p>{alarm.detail.createdAt}</p>
</LS.LinkButton>
</LS.ListItem>
);
})}
</Fragment>
))}
{isFetchingNextPage && (
<S.LoadingSpinnerWrapper>
<LoadingSpinner size="sm" />
</S.LoadingSpinnerWrapper>
)}
{!isFetchingNextPage && hasNextPage && (
<LS.ButtonWrapper>
<SquareButton theme="fill" onClick={() => fetchNextPage()}>
더보기
</SquareButton>
</LS.ButtonWrapper>
)}
</LS.ListContainer>
);
}
5 changes: 5 additions & 0 deletions frontend/src/components/AlarmContainer/constant.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
export const SHORTEN_TEXT_LENGTH = {
DEFAULT: 8,
TITLE: 10,
NICKNAME: 6,
};
Loading

0 comments on commit 88567a4

Please sign in to comment.