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

[#67] 대회 생성 페이지 구현 #100

Merged
merged 26 commits into from
Nov 23, 2023
Merged
Show file tree
Hide file tree
Changes from 21 commits
Commits
Show all changes
26 commits
Select commit Hold shift + click to select a range
b68ab55
feat: 대회 생성 페이지 생성
dev2820 Nov 22, 2023
d8a3cb6
feat: 문제 선택 기능 & 대회 생성시 이동 기능
dev2820 Nov 22, 2023
93d3a8c
refactor: 문제 리스트 패치로직을 분리
dev2820 Nov 22, 2023
2d8fc47
refactor: Competition 생성 로직 분리
dev2820 Nov 22, 2023
f36bd9d
refactor: api 인스턴스 분리 & 타입 정리
dev2820 Nov 22, 2023
d829a0e
fix: 문제 선택 버튼이 아닌 영역을 클릭해도 문제가 선택되는 버그 수정
dev2820 Nov 22, 2023
ffacc86
refactor: Input 관련 Common 분리
dev2820 Nov 22, 2023
f7b4e47
update: 선택된 문제에서 list 제거 & 정렬되도록함
dev2820 Nov 22, 2023
dd56f76
refactor: 불필요한 아이디 제거
dev2820 Nov 22, 2023
2560541
refactor: 페이지 이름 변경
dev2820 Nov 22, 2023
db0bb63
refactor: 대회 문제 선택 리스트 분리
dev2820 Nov 22, 2023
3b319a1
refactor: useProblemList 훅 분리
dev2820 Nov 22, 2023
abf11be
refactor: CompetitionForm을 훅으로 분리
dev2820 Nov 22, 2023
14793f1
refactor: pickedProblem을 CompetitionForm으로 로직을 이동
dev2820 Nov 22, 2023
18eb4af
chore: gitignore 마지막에 eol 추가
dev2820 Nov 22, 2023
8a14d99
update: 대회 입력 검증 로직 추가
dev2820 Nov 23, 2023
c06bf5d
update: competition/create -> contest/create 라우팅 경로 변경
dev2820 Nov 23, 2023
9f3961b
refactor: 불필요한 할당 코드 제거
dev2820 Nov 23, 2023
e1c0a16
update: 대회 생성하기 제목 추가
dev2820 Nov 23, 2023
0880c55
fix: 잘못된 대회 생성 검증 로직 수정
dev2820 Nov 23, 2023
804d8e9
update: 대회 생성에 대한 안내 문구 추가
dev2820 Nov 23, 2023
01df1d2
refactor: DetailPage로 가는 경로 상수화
dev2820 Nov 23, 2023
da285b0
refactor: selectableProblemList 컴포넌트로 분리:
dev2820 Nov 23, 2023
8e0e628
refactor: 불필요한 함수 제거
dev2820 Nov 23, 2023
0169fb6
Merge branch 'fe-dev' into 67-대회-생성-페이지-구현
dev2820 Nov 23, 2023
d653950
Merge branch '67-대회-생성-페이지-구현' of https://github.com/boostcampwm2023/…
dev2820 Nov 23, 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
51 changes: 27 additions & 24 deletions frontend/.gitignore
Original file line number Diff line number Diff line change
@@ -1,24 +1,27 @@
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
lerna-debug.log*

node_modules
dist
dist-ssr
*.local

# Editor directories and files
.vscode/*
!.vscode/extensions.json
.idea
.DS_Store
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw?
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
lerna-debug.log*

node_modules
dist
dist-ssr
*.local

# Editor directories and files
.vscode/*
!.vscode/extensions.json
.idea
.DS_Store
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw?

# environment
.env
29 changes: 29 additions & 0 deletions frontend/src/apis/competitions/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
import api from '@/utils/api';

import type { CompetitionForm, CompetitionInfo, CreateCompetitionResponse } from './types';

export * from './types';
Copy link
Collaborator

Choose a reason for hiding this comment

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

추가적인 타입이 없어도 이렇게 export 하시나요?
다른 곳에서 타입이 필요하다면, api/competitions/types에서 직접 가져오는 게 더 명확해 보이는데, 궁금합니다

Copy link
Collaborator Author

Choose a reason for hiding this comment

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

추가적인 타입이 없다는 말이 무슨말인지 이해 못했어요.

Copy link
Collaborator

Choose a reason for hiding this comment

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

아 아닙니다!! 제가 착각했습니다.


export async function createCompetition(
competitionForm: CompetitionForm,
): Promise<CompetitionInfo | null> {
const { name, detail, maxParticipants, startsAt, endsAt, problems } = competitionForm;

try {
const form = {
name,
detail,
maxParticipants,
startsAt,
endsAt,
problems,
};
const { data } = await api.post<CreateCompetitionResponse>('/competitions', form);

return data;
} catch (err) {
console.error(err);

return null;
}
}
25 changes: 25 additions & 0 deletions frontend/src/apis/competitions/types.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
import type { ProblemId } from '../problems';

export type CompetitionId = number;

export type CompetitionForm = {
name: string;
detail: string;
maxParticipants: number;
startsAt: string;
endsAt: string;
problems: ProblemId[];
};

export type CompetitionInfo = {
id: CompetitionId;
name: string;
detail: string;
maxParticipants: number;
startsAt: string;
endsAt: string;
createdAt: string;
updatedAt: string;
};

export type CreateCompetitionResponse = CompetitionInfo;
Comment on lines +14 to +25
Copy link
Collaborator

Choose a reason for hiding this comment

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

타입 구조가 완전 같아도 네이밍을 정확히 해주는게 정말 좋아보입니다.

17 changes: 17 additions & 0 deletions frontend/src/apis/problems/index.ts
Copy link
Collaborator

Choose a reason for hiding this comment

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

아직 api배포가 되진 않았지만, 대회 전체 조회api응답을 받도록 작성할 예정인데, 그것도 이런 식으로 분리할 수 있겠다는 생각이 드네요!
기조님은 대회 전체 조회 도 이런 식으로 분리하는 게 좋다고 생각하시나요?

Copy link
Collaborator Author

Choose a reason for hiding this comment

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

네 그렇게 생각합니다. 외부 데이터를 받아오는 영역은 분리해서 관리해주는게 좋아요. Model로직과 ViewModel 로직이 섞이지 않게 분리하는거죠

Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
import api from '@/utils/api';

import type { FetchProblemListResponse, ProblemInfo } from './types';

export * from './types';

export async function fetchProblemList(): Promise<ProblemInfo[]> {
try {
const { data } = await api.get<FetchProblemListResponse>('/problems');

return data;
} catch (err) {
console.error(err);

return [];
}
}
8 changes: 8 additions & 0 deletions frontend/src/apis/problems/types.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
export type ProblemId = number;

export type ProblemInfo = {
id: ProblemId;
title: string;
};

export type FetchProblemListResponse = ProblemInfo[];
Copy link
Collaborator

Choose a reason for hiding this comment

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

서버 응답 데이터 타입에는 항상 Response를 붙이시네요👍🏻

Copy link
Collaborator Author

Choose a reason for hiding this comment

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

네 좀 구분하는 편이에요. Response로 들어오는 데이터는 지금은 그냥 데이터이지만 규칙을 정하기에 따라 message나 statusCode를 같이 반환하는 경우도 생기는데 그 때 개념을 분리하기 위함입니다.

76 changes: 76 additions & 0 deletions frontend/src/components/Common/Input.tsx
Copy link
Collaborator

Choose a reason for hiding this comment

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

주말에 천천히 읽어보겠습니다. 좋은 코드 너무 감사합니다. 아직은 이해하기 어렵네요.

Original file line number Diff line number Diff line change
@@ -0,0 +1,76 @@
import { css, cx } from '@style/css';

import type {
ForwardedRef,
HTMLAttributes,
InputHTMLAttributes,
ReactElement,
ReactNode,
TextareaHTMLAttributes,
} from 'react';
import { Children, cloneElement, forwardRef } from 'react';

interface Props extends HTMLAttributes<HTMLDivElement> {
label?: ReactNode;
children: ReactElement;
}

export function Input({ id, label, children, ...props }: Props) {
const child = Children.only(children);
Copy link
Collaborator Author

Choose a reason for hiding this comment

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

입력된 Children이 1개 뿐임을 검증하는 로직입니다


return (
<div {...props}>
<label htmlFor={id}>{label}</label>
{cloneElement(child, {
id,
...child.props,
})}
</div>
);
}

interface TextFieldProps extends Omit<InputHTMLAttributes<HTMLInputElement>, 'type'> {
error?: boolean;
}
Comment on lines +32 to +34
Copy link
Collaborator

Choose a reason for hiding this comment

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

이해포기 🥲

Copy link
Collaborator Author

Choose a reason for hiding this comment

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

ㅋㅋㅋㅋㅋㅋ

Omit = 특정 타입을 제외
InputHTMLAttributes = Input 태그가 가질 수 있는 컴포넌트 attributes 타입입니다.
즉 type을 제외한 모든 Input에 넣을 수 있는 속성을 가리키는 인터페이스에요.

error는 잘못 넣은겁니다. 없다고 봐주세요.

이 부분은 음...시간 되면 잠깐 설명드릴게요


Input.TextField = forwardRef(
({ className, ...props }: TextFieldProps, ref: ForwardedRef<HTMLInputElement>) => {
return <input className={cx(inputStyle, className)} type="text" ref={ref} {...props}></input>;
Copy link
Collaborator Author

Choose a reason for hiding this comment

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

cx는 두 클래스 이름을 합성한다고 보시면 됩니다.

cx('a','b') // 'a b'

},
);

const inputStyle = css({
border: '1px solid black',
width: '20rem',
});

interface TextAreaProps extends TextareaHTMLAttributes<HTMLTextAreaElement> {}

Input.TextArea = forwardRef(
({ className, ...props }: TextAreaProps, ref: ForwardedRef<HTMLTextAreaElement>) => {
return <textarea className={cx(inputStyle, className)} ref={ref} {...props}></textarea>;
},
);

interface NumberFieldProps extends Omit<InputHTMLAttributes<HTMLInputElement>, 'type'> {}

Input.NumberField = forwardRef(
({ className, ...props }: NumberFieldProps, ref: ForwardedRef<HTMLInputElement>) => {
return <input className={cx(inputStyle, className)} type="number" ref={ref} {...props}></input>;
},
);

interface DateTimeFieldProps extends Omit<InputHTMLAttributes<HTMLInputElement>, 'type'> {}

Input.DateTimeField = forwardRef(
({ className, ...props }: DateTimeFieldProps, ref: ForwardedRef<HTMLInputElement>) => {
return (
<input
className={cx(inputStyle, className)}
type="datetime-local"
ref={ref}
{...props}
></input>
);
},
);
1 change: 1 addition & 0 deletions frontend/src/components/Common/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export { Input } from './Input';
115 changes: 115 additions & 0 deletions frontend/src/hooks/competition/useCompetitionForm.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,115 @@
import { useState } from 'react';

import type { CompetitionForm } from '@/apis/competitions';
import { createCompetition } from '@/apis/competitions';
import type { ProblemId } from '@/apis/problems';
import { formatDate, toLocalDate } from '@/utils/date';

type Valid = {
isValid: boolean;
message?: string;
};

const FIVE_MIN_BY_MS = 5 * 60 * 1000;
Copy link
Collaborator

Choose a reason for hiding this comment

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

여기서 300000 대신 5 * 60 * 1000 을 쓰신 이유가 있나요?

Copy link
Collaborator Author

Choose a reason for hiding this comment

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

어떤 값인지 구분하기 편하게 하려고요. 1000 = 1초

1000 = 1초
1000 * 60 = 1분
1000 * 60 * 5 = 5분

이렇게 가독성을 높이는 겁니다 그리고 이렇게 해두면 나중에 분을 변경할 때도 편해서요

const VALIDATION_MESSAGE = {
needLongName: '이름은 1글자 이상이어야합니다',
needMoreParticipants: '최대 참여 인원은 1명 이상이어야 합니다',
tooEarlyStartTime: '대회 시작 시간은 현재보다 5분 늦은 시간부터 가능합니다',
tooEarlyEndTime: '대회 종료 시간은 대회 시작시간보다 늦게 끝나야합니다',
needMoreProblems: '대회 문제는 1개 이상이어야합니다',
};
Comment on lines +14 to +20
Copy link
Collaborator

Choose a reason for hiding this comment

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

이렇게 묶는 것도 정말 좋은 것 같네요, 덕분에 배워갑니다!


export function useCompetitionForm(initialForm: Partial<CompetitionForm> = {}) {
const [name, setName] = useState<string>(initialForm.name ?? '');
const [detail, setDetail] = useState<string>(initialForm.detail ?? '');
const [maxParticipants, setMaxParticipants] = useState<number>(initialForm.maxParticipants ?? 1);

const currentDate = toLocalDate(new Date());
const currentDateStr = formatDate(currentDate, 'YYYY-MM-DDThh:mm');

const [startsAt, setStartsAt] = useState<string>(initialForm.startsAt ?? currentDateStr);
const [endsAt, setEndsAt] = useState<string>(initialForm.endsAt ?? currentDateStr);
Comment on lines +30 to +31
Copy link
Collaborator Author

Choose a reason for hiding this comment

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

기본적으로 현재 시간으로 form이 셋업되도록 합니다.

const [problems, setProblems] = useState<ProblemId[]>([]);

function togglePickedProblem(problemId: ProblemId) {
if (problems.includes(problemId)) {
setProblems((ids) => ids.filter((id) => id !== problemId).sort());
Copy link
Collaborator

Choose a reason for hiding this comment

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

좋은 코드 감사합니다.

} else {
setProblems((ids) => [...ids, problemId].sort());
}
}

function getAllFormData(): CompetitionForm {
return {
name,
detail,
maxParticipants,
startsAt: new Date(startsAt).toISOString(),
endsAt: new Date(endsAt).toISOString(),
problems,
};
}

async function submitCompetition(formData: CompetitionForm) {
return await createCompetition(formData);
}

function validateForm(formData: CompetitionForm): Valid {
const { name, maxParticipants, startsAt, endsAt, problems } = formData;
if (name.length <= 0) {
return {
isValid: false,
message: VALIDATION_MESSAGE.needLongName,
};
}

if (maxParticipants <= 0) {
return {
isValid: false,
message: VALIDATION_MESSAGE.needMoreParticipants,
};
}
if (new Date(startsAt) <= new Date(Date.now() + FIVE_MIN_BY_MS)) {
return {
isValid: false,
message: VALIDATION_MESSAGE.tooEarlyStartTime,
};
}

if (new Date(endsAt) <= new Date(startsAt)) {
return {
isValid: false,
message: VALIDATION_MESSAGE.tooEarlyEndTime,
};
}

if (problems.length <= 0) {
return {
isValid: false,
message: VALIDATION_MESSAGE.needMoreProblems,
};
}

return {
isValid: true,
};
}

return {
name,
detail,
maxParticipants,
startsAt,
endsAt,
problems,
setName,
setDetail,
setMaxParticipants,
setStartsAt,
setEndsAt,
togglePickedProblem,
getAllFormData,
submitCompetition,
validateForm,
};
}
22 changes: 22 additions & 0 deletions frontend/src/hooks/problem/useProblemList.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
import { useEffect, useState } from 'react';

import type { ProblemInfo } from '@/apis/problems';
import { fetchProblemList } from '@/apis/problems';

export function useProblemList() {
const [allProblems, setAllProblems] = useState<ProblemInfo[]>([]);

async function updateProblemList() {
const problems = await fetchProblemList();

setAllProblems(problems);
}

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

return {
allProblems,
};
}
Loading