Skip to content

Commit

Permalink
Merge pull request #100 from boostcampwm2023/67-대회-생성-페이지-구현
Browse files Browse the repository at this point in the history
[#67] 대회 생성 페이지 구현
  • Loading branch information
dev2820 authored Nov 23, 2023
2 parents d1621e6 + d653950 commit 7ed363f
Show file tree
Hide file tree
Showing 18 changed files with 589 additions and 24 deletions.
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';

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;
17 changes: 17 additions & 0 deletions frontend/src/apis/problems/index.ts
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[];
76 changes: 76 additions & 0 deletions frontend/src/components/Common/Input.tsx
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);

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

interface TextFieldProps extends Omit<InputHTMLAttributes<HTMLInputElement>, 'type'> {
error?: boolean;
}

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

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';
48 changes: 48 additions & 0 deletions frontend/src/components/Problem/SelectableProblemList.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
import type { MouseEvent } from 'react';

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

interface ProblemListProps {
problemList: ProblemInfo[];
pickedProblemIds: ProblemId[];
onSelectProblem: (problemId: ProblemId) => void;
}

const SelectableProblemList = ({
problemList,
pickedProblemIds,
onSelectProblem,
}: ProblemListProps) => {
function handleSelectProblem(e: MouseEvent<HTMLUListElement>) {
const $target = e.target as HTMLElement;
if ($target.tagName !== 'BUTTON') return;

const $li = $target.closest('li');
if (!$li) return;

const problemId = Number($li.dataset['problemId']);
onSelectProblem(problemId);
}

return (
<ul onClick={handleSelectProblem}>
{problemList.map(({ id, title }) => (
<li key={id} data-problem-id={id}>
<span>
{id}: {title}
</span>
<SelectButton isPicked={pickedProblemIds.includes(id)}></SelectButton>
</li>
))}
</ul>
);
};

export default SelectableProblemList;

const SelectButton = ({ isPicked }: { isPicked: boolean }) => {
if (isPicked) {
return <button>취소</button>;
}
return <button>선택</button>;
};
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;
const VALIDATION_MESSAGE = {
needLongName: '이름은 1글자 이상이어야합니다',
needMoreParticipants: '최대 참여 인원은 1명 이상이어야 합니다',
tooEarlyStartTime: '대회 시작 시간은 현재보다 5분 늦은 시간부터 가능합니다',
tooEarlyEndTime: '대회 종료 시간은 대회 시작시간보다 늦게 끝나야합니다',
needMoreProblems: '대회 문제는 1개 이상이어야합니다',
};

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);
const [problems, setProblems] = useState<ProblemId[]>([]);

function togglePickedProblem(problemId: ProblemId) {
if (problems.includes(problemId)) {
setProblems((ids) => ids.filter((id) => id !== problemId).sort());
} 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,
};
}
Loading

0 comments on commit 7ed363f

Please sign in to comment.