Skip to content

Commit

Permalink
투표 작성 페이지의 투표 선택지 컴포넌트 UI 구현 (#40)
Browse files Browse the repository at this point in the history
* feat: (#20) 삭제, 파일 업로드 버튼 컴포넌트 UI 구현

* feat: (#20) 이미지 업로드 버튼을 눌렀을 때 이미지 업로드 창이 나오도록 구현 및 파일명 변경

* feat: (#20) 투표 선택지 아이템 컴포넌트 UI 구현

* feat: (#20) 투표 선택지 작성 리스트 컴포넌트 UI 구현

* feat: (#20) 훅 테스트 코드 작성 시작

* test: (#20) 투표 선택지 작성에 사용하는 훅 테스트 작성

* feat: (#20) 투표 선택지 작성 훅 구현

* feat: (#20) 투표 선택지 작성 훅 적용 및 UI 구현

* feat: (#20) 50자 이상 적었을 때 사용자에게 안내 기능 구현

* feat: (#20) 사진의 이미지가 5MB가 넘어갈 경우 유저에게 안내하도록 구현

* design: (#20): 삭제 버튼을 감싼 태그가 항상 왼쪽의 공간을 차지하도록 CSS 변경

* refactor: (#20) svg 코드를 assets 폴더로 이동 후 import 하여 사용하도록 수정
회색 버튼을 cssText로 관리하여 공통으로 관리하도록 수정

* refactor: (#20) 코드 가독성을 위한 함수명, 변수명 수정

* design: (#20) 화면 크기에 따라 폰트, 버튼 사이즈 변경되도록 구현

* style: (#20) CSS 속성 순서 변경 및 불필요한 타입 선언 제거

* chore: (#20) 함수 동작 과정에 대한 설명 주석 추가

* chore: (#20) 테스트 문구 변경
  • Loading branch information
Gilpop8663 authored and tjdtls690 committed Sep 12, 2023
1 parent e5e169f commit 2e136a2
Show file tree
Hide file tree
Showing 16 changed files with 674 additions and 0 deletions.
132 changes: 132 additions & 0 deletions frontend/__test__/hooks/useWritingOption.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,132 @@
import { renderHook, act } from '@testing-library/react';

import { useWritingOption } from '../../src/hooks/useWritingOption';

/**
* @jest-environment jsdom
*/

const MOCK_MAX_VOTE_OPTION = [
{ id: 12341, text: '', imageUrl: '' },
{ id: 123451, text: '', imageUrl: '' },
{
id: 1234221,
text: '방학 때 강릉으로 강아지와 기차여행을 하려했지만 장마가 와서 취소했어요. 여행을 별로 좋',
imageUrl: '',
},
{
id: 12342261,
text: '방학 때 강릉으로 강아지와 기차여행을 하려했지만 장마가 와서 취소했어요. 여행을 별로 좋',
imageUrl: 'https://source.unsplash.com/random',
},
{
id: 1234451,
text: '',
imageUrl: 'https://source.unsplash.com/random',
},
];

const MOCK_MIN_VOTE_OPTION = [
{ id: 12341, text: '', imageUrl: '' },
{ id: 123341, text: '', imageUrl: '' },
];
describe('useWritingOption 훅을 테스트 한다.', () => {
test('초기 값이 없다면 기본으로 2개의 선택지가 존재해야 한다.', () => {
const { result } = renderHook(() => useWritingOption());

const { optionList } = result.current;

expect(optionList.length).toBe(2);

expect(optionList[0].text).toBe('');

expect(optionList[0].imageUrl).toBe('');
});

test('기존 데이터가 있는 경우 기존 데이터가 선택지의 초기 값으로 존재해야 한다.', () => {
const { result } = renderHook(() => useWritingOption(MOCK_MIN_VOTE_OPTION));

const { optionList } = result.current;

expect(optionList).toEqual(MOCK_MIN_VOTE_OPTION);
});

test('투표 선택지를 추가할 수 있어야 한다. 생성된 선택지는 text와 imageUrl 값을 가지고 있다.', () => {
const { result } = renderHook(() => useWritingOption(MOCK_MIN_VOTE_OPTION));

const { addOption } = result.current;

act(() => {
addOption();
});

const { optionList } = result.current;

expect(optionList.length).toBe(MOCK_MIN_VOTE_OPTION.length + 1);

expect(optionList[2].text).toBe('');

expect(optionList[2].imageUrl).toBe('');
});

test('투표 선택지가 5개일 땐 투표 선택지를 추가할 수 없다', () => {
const { result } = renderHook(() => useWritingOption(MOCK_MAX_VOTE_OPTION));

const { addOption } = result.current;

act(() => {
addOption();
});

const { optionList } = result.current;

expect(optionList).toEqual(MOCK_MAX_VOTE_OPTION);
});

test('투표 선택지가 3개 이상일때는 투표 선택지의 아이디를 이용하여 삭제할 수 있다.', () => {
const { result } = renderHook(() => useWritingOption(MOCK_MAX_VOTE_OPTION));

const { deleteOption } = result.current;

act(() => {
deleteOption(MOCK_MAX_VOTE_OPTION[0].id);
});

const { optionList } = result.current;

expect(optionList).toEqual(MOCK_MAX_VOTE_OPTION.slice(1, 5));
});

test('투표 선택지가 2개일때는 삭제할 수 없다.', () => {
const { result } = renderHook(() => useWritingOption(MOCK_MIN_VOTE_OPTION));

const { deleteOption } = result.current;

act(() => {
deleteOption(MOCK_MIN_VOTE_OPTION[0].id);
});

const { optionList } = result.current;

expect(optionList).toEqual(MOCK_MIN_VOTE_OPTION);
});

test('선택한 이미지가 있을 때 취소할 수 있다.', () => {
const MOCK_IMAGE_OPTION = [
{ id: 12341, text: '', imageUrl: 'https' },
{ id: 123412, text: '', imageUrl: 'imageUrl' },
];

const { result } = renderHook(() => useWritingOption(MOCK_IMAGE_OPTION));

const { removeImage } = result.current;

act(() => {
removeImage(MOCK_MIN_VOTE_OPTION[0].id);
});

const { optionList } = result.current;

expect(optionList[0].imageUrl).toBe('');
});
});
3 changes: 3 additions & 0 deletions frontend/src/assets/photo_white.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
3 changes: 3 additions & 0 deletions frontend/src/assets/x_mark_white.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
import type { Meta, StoryObj } from '@storybook/react';

import OptionCancelButton from '.';

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

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

export const Default: Story = {
render: () => <OptionCancelButton />,
};
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
import React from 'react';

import xMarkIcon from '@assets/x_mark_white.svg';

import * as S from './style';

interface OptionCancelButtonProps extends React.ButtonHTMLAttributes<HTMLButtonElement> {}

export default function OptionCancelButton({ ...rest }: OptionCancelButtonProps) {
return (
<S.Container aria-label="삭제" type="button" {...rest}>
<S.Image src={xMarkIcon} alt="" />
</S.Container>
);
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
import { styled } from 'styled-components';

import { ButtonCssText, IconImage } from '../style';

export const Container = styled.button`
${ButtonCssText}
`;

export const Image = styled(IconImage)``;
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
import type { Meta, StoryObj } from '@storybook/react';

import OptionUploadImageButton from '.';

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

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

export const Default: Story = {
render: () => <OptionUploadImageButton labelId={123} />,
};
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
import React from 'react';

import photoIcon from '@assets/photo_white.svg';

import * as S from './style';

interface OptionUploadImageButtonProps extends React.InputHTMLAttributes<HTMLInputElement> {
optionId: number;
}

export default function OptionUploadImageButton({
optionId,
...rest
}: OptionUploadImageButtonProps) {
const id = optionId.toString();

return (
<S.Container>
<S.Label htmlFor={id} aria-label="선택지 이미지 업로드 버튼" title="이미지 업로드">
<S.Image src={photoIcon} alt="" />
</S.Label>
<S.FileInput id={id} type="file" accept="image/*" {...rest} />
</S.Container>
);
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
import { styled } from 'styled-components';

import { ButtonCssText, IconImage } from '../style';

export const Container = styled.div`
width: 24px;
height: 24px;
border-radius: 50%;
`;

export const Label = styled.label`
${ButtonCssText}
`;

export const FileInput = styled.input`
visibility: hidden;
`;

export const Image = styled(IconImage)``;
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
import type { Meta, StoryObj } from '@storybook/react';

import WritingVoteOption from '.';

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

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

export const IsDeletable: Story = {
render: () => (
<WritingVoteOption
handleDeleteOptionClick={() => {}}
handleRemoveImageClick={() => {}}
handleUploadImage={() => {}}
optionId={Math.floor(Math.random() * 100000)}
text="방학 때 강릉으로 강아지와 기차여행을 하려했지
만 장마가 와서 취소했어요. 여행을 별로 좋"
isDeletable={true}
/>
),
};

export const IsNotDeletable: Story = {
render: () => (
<WritingVoteOption
handleDeleteOptionClick={() => {}}
handleRemoveImageClick={() => {}}
handleUploadImage={() => {}}
optionId={Math.floor(Math.random() * 100000)}
text="방학 때 강릉으로 강아지와 기차여행을 하려했지
만 장마가 와서 취소했어요. 여행을 별로 좋"
isDeletable={false}
/>
),
};

export const ShowImage: Story = {
render: () => (
<WritingVoteOption
handleDeleteOptionClick={() => {}}
handleRemoveImageClick={() => {}}
handleUploadImage={() => {}}
optionId={Math.floor(Math.random() * 100000)}
text="방학 때 강릉으로 강아지와 기차여행을 하려했지
만 장마가 와서 취소했어요. 여행을 별로 좋"
isDeletable={true}
imageUrl="https://source.unsplash.com/random"
/>
),
};
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
import React, { ChangeEvent } from 'react';

import OptionCancelButton from './OptionCancelButton';
import OptionUploadImageButton from './OptionUploadImageButton';
import * as S from './style';

interface WritingVoteOptionProps {
optionId: number;
text: string;
isDeletable: boolean;
handleDeleteOptionClick: () => void;
handleRemoveImageClick: () => void;
handleUploadImage: (event: React.ChangeEvent<HTMLInputElement>) => void;
imageUrl?: string;
}

const MAX_WRITING_LENGTH = 50;

export default function WritingVoteOption({
optionId,
text,
isDeletable,
handleDeleteOptionClick,
handleRemoveImageClick,
handleUploadImage,
imageUrl,
}: WritingVoteOptionProps) {
const handleTextChange = (event: ChangeEvent<HTMLTextAreaElement>) => {
const { value } = event.target;
const standard = value.length;

if (standard === MAX_WRITING_LENGTH) {
event.target.setCustomValidity(`선택지 내용은 ${MAX_WRITING_LENGTH}자까지 입력 가능합니다.`);
event.target.reportValidity();
return;
}

event.target.setCustomValidity('');
};

return (
<S.Container>
<S.CancelButtonWrapper>
{isDeletable && (
<OptionCancelButton title="선택지 삭제하기" onClick={handleDeleteOptionClick} />
)}
</S.CancelButtonWrapper>
<S.OptionContainer>
<S.ContentContainer>
<S.ContentTextArea
defaultValue={text}
onChange={handleTextChange}
placeholder="내용을 입력해주세요."
maxLength={MAX_WRITING_LENGTH}
/>
{!imageUrl && (
<OptionUploadImageButton optionId={optionId} onChange={handleUploadImage} />
)}
</S.ContentContainer>
{imageUrl && (
<S.ImageContainer>
<S.Image src={imageUrl} alt={text} />
<S.ImageCancelWrapper>
<OptionCancelButton onClick={handleRemoveImageClick} />
</S.ImageCancelWrapper>
</S.ImageContainer>
)}
</S.OptionContainer>
</S.Container>
);
}
Loading

0 comments on commit 2e136a2

Please sign in to comment.