Skip to content
This repository has been archived by the owner on Jul 29, 2024. It is now read-only.

[FE] feat: 휴지통 기능 구현 #281

Merged
merged 26 commits into from
Aug 16, 2023
Merged
Show file tree
Hide file tree
Changes from 20 commits
Commits
Show all changes
26 commits
Select commit Hold shift + click to select a range
ffafa7a
test: trash api handler 추가
jeonjeunghoon Aug 11, 2023
6948d35
feat: `trash` api 요청 로직 구현
jeonjeunghoon Aug 11, 2023
f603160
feat: 휴지통 컴포넌트 구현
jeonjeunghoon Aug 11, 2023
942b09f
feat: `TrashCanPage` 페이지 추가
jeonjeunghoon Aug 14, 2023
e9a753b
feat: `TrashCan` 과 `TrashCanPage` 연결
jeonjeunghoon Aug 14, 2023
86ca2dd
feat: `Layout` 에 휴지통 연결
jeonjeunghoon Aug 14, 2023
1f994bd
Merge branch 'develop' of https://github.com/woowacourse-teams/2023-d…
jeonjeunghoon Aug 14, 2023
dbb259d
feat: TrashCanTable 추가
jeonjeunghoon Aug 15, 2023
77caf99
Merge branch 'develop' of https://github.com/woowacourse-teams/2023-d…
jeonjeunghoon Aug 15, 2023
7a6d687
feat: 휴지통 페이지 router에 연결
jeonjeunghoon Aug 15, 2023
58e4ce3
test: 휴지통 mocking 데이터 추가
jeonjeunghoon Aug 15, 2023
fb009f3
fix: 체크 박스 버그 수정
jeonjeunghoon Aug 15, 2023
4f4f689
feat: useDeletedPermanentWritings 추가
jeonjeunghoon Aug 15, 2023
d721455
feat: useRestoreDeletedWritings 추가
jeonjeunghoon Aug 15, 2023
8493ea5
feat: `useTrashCanTable` 훅 구현
jeonjeunghoon Aug 15, 2023
a050fb1
feat: `TrashCanTable` 에 훅 추가
jeonjeunghoon Aug 15, 2023
d0aa74d
test: mocking data 수정
jeonjeunghoon Aug 15, 2023
2a7f07d
feat: `useDeletedWritings` 훅 구현
jeonjeunghoon Aug 15, 2023
6919c18
feat: `DeletedWritingList` 컴포넌트 구현
jeonjeunghoon Aug 15, 2023
6bf8c35
feat: 체크된 글이 없을 때 글 삭제/복구 로직 방어
jeonjeunghoon Aug 15, 2023
5a13f9a
fix: 잘못된 메시지 수정
jeonjeunghoon Aug 16, 2023
b332b1e
Merge branch 'develop' of https://github.com/woowacourse-teams/2023-d…
jeonjeunghoon Aug 16, 2023
fd09c6b
design: 카테고리, 휴지통 스타일 수정
jeonjeunghoon Aug 16, 2023
e32e7eb
Merge branch 'develop' of https://github.com/woowacourse-teams/2023-d…
jeonjeunghoon Aug 16, 2023
bcb8ae0
design: WritingList 스타일 수정
jeonjeunghoon Aug 16, 2023
c8b0d66
chore: backend reverse commit 생성
echo724 Aug 16, 2023
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
65 changes: 65 additions & 0 deletions backend/src/main/java/org/donggle/backend/auth/JwtToken.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
package org.donggle.backend.auth;

import jakarta.persistence.Entity;
import jakarta.persistence.FetchType;
import jakarta.persistence.GeneratedValue;
import jakarta.persistence.GenerationType;
import jakarta.persistence.Id;
import jakarta.persistence.OneToOne;
import lombok.AccessLevel;
import lombok.Getter;
import lombok.NoArgsConstructor;
import org.donggle.backend.domain.member.Member;

import java.util.Objects;

@Entity
@Getter
@NoArgsConstructor(access = AccessLevel.PROTECTED)
public class JwtToken {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
private String refreshToken;
@OneToOne(fetch = FetchType.LAZY)
private Member member;

public JwtToken(final String token, final Member member) {
this.refreshToken = token;
this.member = member;
}

public boolean isDifferentRefreshToken(final String refreshToken) {
return !this.refreshToken.equals(refreshToken);
}

public void updateRefreshToken(final String refreshToken) {
this.refreshToken = refreshToken;
}

@Override
public String toString() {
return "JwtToken{" +
"id=" + id +
", token='" + refreshToken + '\'' +
", member=" + member +
'}';
}

@Override
public boolean equals(final Object o) {
if (this == o) {
return true;
}
if (o == null || getClass() != o.getClass()) {
return false;
}
final JwtToken jwtToken = (JwtToken) o;
return Objects.equals(getId(), jwtToken.getId());
}

@Override
public int hashCode() {
return Objects.hash(getId());
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
package org.donggle.backend.auth;

import jakarta.transaction.Transactional;
import lombok.RequiredArgsConstructor;
import org.donggle.backend.application.repository.TokenRepository;
import org.donggle.backend.domain.member.Member;
import org.springframework.stereotype.Service;

@Service
@Transactional
@RequiredArgsConstructor
public class JwtTokenService {
private final TokenRepository tokenRepository;

public void synchronizeRefreshToken(final Member member, final String refreshToken) {
tokenRepository.findByMemberId(member.getId())
.ifPresentOrElse(
token -> token.updateRefreshToken(refreshToken),
() -> tokenRepository.save(new JwtToken(refreshToken, member))
);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
package org.donggle.backend.auth.exception;

import org.donggle.backend.exception.business.BusinessException;

public class EmptyAuthorizationHeaderException extends BusinessException {
private static final String MESSAGE = "header에 Authorization이 존재하지 않습니다.";

public EmptyAuthorizationHeaderException() {
super(MESSAGE);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
package org.donggle.backend.auth.exception;

import org.donggle.backend.exception.business.BusinessException;

public class InvalidAccessTokenException extends BusinessException {
private static final String MESSAGE = "유효하지 않은 토큰입니다.";

public InvalidAccessTokenException() {
super(MESSAGE);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
package org.donggle.backend.auth.exception;

import org.donggle.backend.exception.business.BusinessException;

public class NoSuchTokenException extends BusinessException {
private static final String MESSAGE = "존재하지 않는 토큰입니다.";

public NoSuchTokenException() {
super(MESSAGE);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,16 @@ public class KakaoOAuthController {
private final int cookieTime;

private final KakaoOAuthService kakaoOAuthService;
private final AuthService authService;

public KakaoOAuthController(@Value("${security.jwt.token.refresh-token-expire-length}") final int cookieTime,
final KakaoOAuthService kakaoOAuthService,
final AuthService authService
) {
this.cookieTime = cookieTime;
this.kakaoOAuthService = kakaoOAuthService;
this.authService = authService;
}

public KakaoOAuthController(@Value("${security.jwt.token.refresh-token-expire-length}") final int cookieTime,
final KakaoOAuthService kakaoOAuthService
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
package org.donggle.backend.auth;

import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;

import static org.assertj.core.api.Assertions.assertThat;

class JwtTokenProviderTest {

private final JwtTokenProvider jwtTokenProvider = new JwtTokenProvider(
"wjdgustmdwjdgustmdwjdgustmdwjsadasdgustmdwjdgustmdwjdgustmdwjdgustmdwjdgustmdwjdgustmdwjdgustmdwjdgustmdwjdgustmdwjdgustmd",
600000,
1200000
);

@Test
@DisplayName("RefreshToken 발급 테스트")
void createRefreshToken() {
//given
//when
final String token = jwtTokenProvider.createRefreshToken(1234L);
final Long payload = jwtTokenProvider.getPayload(token);

//then
assertThat(payload).isEqualTo(1234L);
}

@Test
@DisplayName("AccessToken 발급 테스트")
void createAccessToken() {
//given
//when
final String token = jwtTokenProvider.createAccessToken(23L);
final Long payload = jwtTokenProvider.getPayload(token);

//then
assertThat(payload).isEqualTo(23L);
}
}
12 changes: 10 additions & 2 deletions frontend/src/apis/trash.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,10 @@
import { trashURL } from 'constants/apis/url';
import { http } from './fetch';
import { GetDeletedWritingsResponse } from 'types/apis/trash';

// 글 휴지통으로 이동: POST
export const moveToTrash = (writingIds: number[]) =>
http.post(`${trashURL}`, {
http.post(trashURL, {
json: {
writingIds,
isPermanentDelete: false,
Expand All @@ -12,9 +13,16 @@ export const moveToTrash = (writingIds: number[]) =>

// 글 영구 삭제: POST
export const deletePermanentWritings = (writingIds: number[]) =>
http.post(`${trashURL}`, {
http.post(trashURL, {
json: {
writingIds,
isPermanentDelete: true,
},
});

// 휴지통에 있는 글 목록 조회: GET
export const getDeletedWritings = (): Promise<GetDeletedWritingsResponse> => http.get(trashURL);

// 휴지통에서 글 복구: POST
export const restoreDeletedWritings = (writingIds: number[]) =>
http.post(`${trashURL}/restore`, { json: { writingIds } });
100 changes: 100 additions & 0 deletions frontend/src/components/TrashCan/DeletedWritingList.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,100 @@
import { WritingIcon } from 'assets/icons';
import { usePageNavigate } from 'hooks/usePageNavigate';
import { useParams } from 'react-router-dom';
import { styled } from 'styled-components';
import DeleteButton from 'components/DeleteButton/DeleteButton';
import { useDeletedWritings } from 'hooks/useDeletedWritings';
import { useDeletePermanentWritings } from 'components/TrashCanTable/useDeletePermanentWritings';

const DeletedWritingList = () => {
const { deletedWritings } = useDeletedWritings();
const { goWritingPage } = usePageNavigate();
const writingId = Number(useParams()['writingId']);
const deletePermanentWritings = useDeletePermanentWritings();

if (!deletedWritings || deletedWritings?.length === 0)
return <S.NoWritingsText>No Writings inside</S.NoWritingsText>;

return (
<ul>
{deletedWritings.map((deletedWriting) => (
<S.Item key={deletedWriting.id}>
<S.Button
$isClicked={writingId === deletedWriting.id}
aria-label={`${deletedWriting.title}글 메인화면에 열기`}
onClick={() =>
goWritingPage({ categoryId: deletedWriting.categoryId, writingId: deletedWriting.id })
}
>
<S.IconWrapper>
<WritingIcon width={14} height={14} />
</S.IconWrapper>
<S.Text>{deletedWriting.title}</S.Text>
</S.Button>
<S.DeleteButtonWrapper>
<DeleteButton onClick={() => deletePermanentWritings([writingId])} />
</S.DeleteButtonWrapper>
</S.Item>
))}
</ul>
);
};

export default DeletedWritingList;

const S = {
Item: styled.li`
display: flex;
justify-content: space-between;
align-items: center;
width: 100%;
border-radius: 8px;

&:hover {
background-color: ${({ theme }) => theme.color.gray5};

div {
display: inline-flex;
flex-shrink: 0;
gap: 0.8rem;
}
}
`,

Button: styled.button<{ $isClicked: boolean }>`
display: flex;
align-items: center;
gap: 0.8rem;
min-width: 0;
jeonjeunghoon marked this conversation as resolved.
Show resolved Hide resolved
height: 3.6rem;
padding: 0.8rem;
border-radius: 8px;
background-color: ${({ theme, $isClicked }) => $isClicked && theme.color.gray5};
`,

IconWrapper: styled.div`
flex-shrink: 0;
`,

Text: styled.p`
color: ${({ theme }) => theme.color.gray9};
font-size: 1.4rem;
font-weight: 400;
overflow: hidden;
white-space: nowrap;
text-overflow: ellipsis;
`,

NoWritingsText: styled.p`
padding: 0.8rem;
color: ${({ theme }) => theme.color.gray6};
font-size: 1.4rem;
font-weight: 500;
cursor: default;
`,

DeleteButtonWrapper: styled.div`
display: none;
margin-right: 0.8rem;
`,
};
61 changes: 61 additions & 0 deletions frontend/src/components/TrashCan/TrashCan.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
import Accordion from 'components/@common/Accordion/Accordion';
import { usePageNavigate } from 'hooks/usePageNavigate';
import { styled } from 'styled-components';
import DeletedWritingList from './DeletedWritingList';

const TrashCan = () => {
const { goTrashCanPage } = usePageNavigate();

return (
<Accordion>
<Accordion.Item>
<Accordion.Title>
<S.Button aria-label='휴지통으로 이동하기' onClick={goTrashCanPage}>
<S.Text>휴지통</S.Text>
</S.Button>
</Accordion.Title>
<Accordion.Panel>
<DeletedWritingList />
</Accordion.Panel>
</Accordion.Item>
</Accordion>
);
};

export default TrashCan;

const S = {
Button: styled.button`
display: flex;
justify-content: space-between;
align-items: center;
width: 100%;
height: 3.2rem;
padding: 0.8rem;
border-radius: 8px;
font-size: 1.4rem;

&:hover {
div {
display: inline-flex;
gap: 0.8rem;
}
}
`,

Text: styled.p`
color: ${({ theme }) => theme.color.gray10};
font-weight: 600;
overflow: hidden;
white-space: nowrap;
text-overflow: ellipsis;
`,

NoWritingsText: styled.p`
padding: 0.8rem;
color: ${({ theme }) => theme.color.gray6};
font-size: 1.4rem;
font-weight: 500;
cursor: default;
`,
};
Loading
Loading