-
Notifications
You must be signed in to change notification settings - Fork 1
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
The head ref may contain hidden characters: "67-\uB300\uD68C-\uC0DD\uC131-\uD398\uC774\uC9C0-\uAD6C\uD604"
[#67] 대회 생성 페이지 구현 #100
Changes from 21 commits
b68ab55
d8a3cb6
93d3a8c
2d8fc47
f36bd9d
d829a0e
ffacc86
f7b4e47
dd56f76
2560541
db0bb63
3b319a1
abf11be
14793f1
18eb4af
8a14d99
c06bf5d
9f3961b
e1c0a16
0880c55
804d8e9
01df1d2
da285b0
8e0e628
0169fb6
d653950
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
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 |
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'; | ||
|
||
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; | ||
} | ||
} |
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
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 타입 구조가 완전 같아도 네이밍을 정확히 해주는게 정말 좋아보입니다. |
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 아직 api배포가 되진 않았지만, 대회 전체 조회api응답을 받도록 작성할 예정인데, 그것도 이런 식으로 분리할 수 있겠다는 생각이 드네요! There was a problem hiding this comment. Choose a reason for hiding this commentThe 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 []; | ||
} | ||
} |
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[]; | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 서버 응답 데이터 타입에는 항상 Response를 붙이시네요👍🏻 There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 네 좀 구분하는 편이에요. Response로 들어오는 데이터는 지금은 그냥 데이터이지만 규칙을 정하기에 따라 message나 statusCode를 같이 반환하는 경우도 생기는데 그 때 개념을 분리하기 위함입니다. |
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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); | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 이해포기 🥲 There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. ㅋㅋㅋㅋㅋㅋ Omit = 특정 타입을 제외 error는 잘못 넣은겁니다. 없다고 봐주세요. 이 부분은 음...시간 되면 잠깐 설명드릴게요 |
||
|
||
Input.TextField = forwardRef( | ||
({ className, ...props }: TextFieldProps, ref: ForwardedRef<HTMLInputElement>) => { | ||
return <input className={cx(inputStyle, className)} type="text" ref={ref} {...props}></input>; | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. cx는 두 클래스 이름을 합성한다고 보시면 됩니다.
|
||
}, | ||
); | ||
|
||
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> | ||
); | ||
}, | ||
); |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1 @@ | ||
export { Input } from './Input'; |
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; | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 여기서 300000 대신 5 * 60 * 1000 을 쓰신 이유가 있나요? There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 어떤 값인지 구분하기 편하게 하려고요. 1000 = 1초 1000 = 1초 이렇게 가독성을 높이는 겁니다 그리고 이렇게 해두면 나중에 분을 변경할 때도 편해서요 |
||
const VALIDATION_MESSAGE = { | ||
needLongName: '이름은 1글자 이상이어야합니다', | ||
needMoreParticipants: '최대 참여 인원은 1명 이상이어야 합니다', | ||
tooEarlyStartTime: '대회 시작 시간은 현재보다 5분 늦은 시간부터 가능합니다', | ||
tooEarlyEndTime: '대회 종료 시간은 대회 시작시간보다 늦게 끝나야합니다', | ||
needMoreProblems: '대회 문제는 1개 이상이어야합니다', | ||
}; | ||
Comment on lines
+14
to
+20
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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()); | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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, | ||
}; | ||
} |
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, | ||
}; | ||
} |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
추가적인 타입이 없어도 이렇게 export 하시나요?
다른 곳에서 타입이 필요하다면, api/competitions/types에서 직접 가져오는 게 더 명확해 보이는데, 궁금합니다
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
추가적인 타입이 없다는 말이 무슨말인지 이해 못했어요.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
아 아닙니다!! 제가 착각했습니다.