Front Part 구성
1. 기본 Next.js 구성
구성에 앞서서 프로젝트를 backend, frontend로 폴더를 나눠보도록하겠습니다.
이제 해당 frontend 폴더안에 next.js의 기본 구성을 하겠습니다.
cd frontend
npx create-next-app@latest --typescript
2. 실행
cd todolist
npm run dev
localhost:3000
3. ESLint , Prettier 설정하기
ESLint는 JS문법에서 에러를 표시해주는 도구입니다. 가이드라인이라고 보면 쉽습니다. 코팅스타일과 에러의 기준을 지정할 수 있습니다. 협업시에는 정말 필수가 되기 때문에 설정합니다.
우선 충돌없이 동시에 사용하기 위하여 아래의 패키지를 설치합니다.
npm i -D eslint-config-prettier eslint-plugin-prettier
eslint-config-prettier는 Prettier의 설정 중 ESLint의 설정과 충돌이 나는 설정을 비활성화 해주는 라이브러리입니다.
eslint-plugin-prettier는 Prettier의 규칙을 ESLint에 적용시킬 수 있게 해줍니다.
기본적으로 next.js 프로젝트를 생성하면 .eslintrc.json 파일이 생성됩니다. 이 파일 안에 아래의 코드를 붙여 넣습니다. (최종 코드)
{
"extends": ["next/core-web-vitals", "eslint:recommended", "plugin:prettier/recommended"]
}
next/core-web-vitals
next.js 프로젝트의 핵심적인 성능 향상을 위한 규칙들을 활성화합니다.
eslint:recommended
https://eslint.org/docs/rules/ 에 있는 체크 표시된 모든 규칙들을 활성화합니다.
plugin:prettier/recommended
권장 구성 참고 - Ref. https://github.com/prettier/eslint-plugin-prettier#recommended-configuration
다음으로 prettier 설정입니다. .prettierrc.json 을 생성합니다.
.prettierrc.json
{
"semi": true,
"trailingComma": "es5",
"singleQuote": true,
"tabWidth": 4,
"printWidth": 120,
"useTabs": true,
"bracketSameLine": true,
"jsxSingleQuote": false,
"quoteProps": "as-needed",
"arrowParens": "always",
"bracketSpacing": true,
"htmlWhitespaceSensitivity": "css",
"endOfLine": "lf"
}
아래는 설정할 수 있는 전체 옵션입니다.
{
"arrowParens": "avoid", // 화살표 함수 괄호 사용 방식
"bracketSpacing": false, // 객체 리터럴에서 괄호에 공백 삽입 여부
"endOfLine": "auto", // EoF 방식, OS별로 처리 방식이 다름
"htmlWhitespaceSensitivity": "css", // HTML 공백 감도 설정
"jsxBracketSameLine": false, // JSX의 마지막 `>`를 다음 줄로 내릴지 여부
"jsxSingleQuote": false, // JSX에 singe 쿼테이션 사용 여부
"printWidth": 80, // 줄 바꿈 할 폭 길이
"proseWrap": "preserve", // markdown 텍스트의 줄바꿈 방식 (v1.8.2)
"quoteProps": "as-needed" // 객체 속성에 쿼테이션 적용 방식
"semi": true, // 세미콜론 사용 여부
"singleQuote": true, // single 쿼테이션 사용 여부
"tabWidth": 2, // 탭 너비
"trailingComma": "all", // 여러 줄을 사용할 때, 후행 콤마 사용 방식
"useTabs": false, // 탭 사용 여부
"vueIndentScriptAndStyle": true, // Vue 파일의 script와 style 태그의 들여쓰기 여부 (v1.19.0)
"parser": '', // 사용할 parser를 지정, 자동으로 지정됨
"filepath": '', // parser를 유추할 수 있는 파일을 지정
"rangeStart": 0, // 포맷팅을 부분 적용할 파일의 시작 라인 지정
"rangeEnd": Infinity, // 포맷팅 부분 적용할 파일의 끝 라인 지정,
"requirePragma": false, // 파일 상단에 미리 정의된 주석을 작성하고 Pragma로 포맷팅 사용 여부 지정 (v1.8.0)
"insertPragma": false, // 미리 정의된 @format marker의 사용 여부 (v1.8.0)
"overrides": [
{
"files": "*.json",
"options": {
"printWidth": 200
}
}
], // 특정 파일별로 옵션을 다르게 지정함, ESLint 방식 사용
}
4. husky & lint-staged 설정
이제 마지막으로 git hook을 통한 lint 자동화 작업을 진행하겠습니다. 다만 현재 project는 backend, frontend로 폴더를 나눴기 때문에 repo에 push할 때 backend, frontend를 모두 eslint, prettier 작업을 진행해야하기 때문에 이를 설정해보겠습니다.
가장 root에 아래의 패키지를 설치합니다.
npm i -D husky lint-staged
다음으로 git hook를 활성화합니다.
npx husky install
작업이 끝났다면 package.json 파일의 script 영역에 precommit, prepare를 추가합니다.
"scripts": {
"precommit": "lint-staged",
"prepare": "husky install"
},
이제 hook을 만듭니다.
npx husky add .husky/pre-commit "npm test"
root에 .husky 폴더가 생기며 pre-commit을 확인할 수 있습니다.
pre-commit 파일의 코드를 모두 지우고 아래의 코드를 붙여 넣습니다.
첫 번째 todolist(frontend)의 작업이 끝나면 다음으로 backend로 가야하기 때문에 ../../backend로 이동하여 진행합니다.
여기에서 갑자기 튀어나온 lint-staged는 git의 staged된 상태에 파일들에 특정 명령어를 실행할 수 있도록 해주는 툴입니다. 쉽게 변경된 파일에 대해서 특정 명령어를 실행시켜주는 툴입니다.
#!/bin/sh
. "$(dirname "$0")/_/husky.sh"
cd ./frontend/todolist && npx lint-staged
cd ../../backend && npx lint-staged
각 backend, frontend의 package.json에 아래의 코드를 추가로 넣습니다. ( lint-staged 실행 명령어)
"lint-staged": {
"*.{js,ts,tsx}": [
"eslint --fix",
"prettier --write"
]
}
아래의 코드는 frontend의 pages/_app.tsx 안에 불필요한 코드를 넣어서 commit을 진행해보았습니다. 그러면 사용되지 않는 변수에 대하여 error가 발생됩니다.
위의 error를 수정하고 다시 commit 해보겠습니다.
backend, frontend 모두 문제가 없어 정상적으로 commit이 되었습니다.
컴포넌트 만들기
이제 기본 구성을 끝마쳤으니 컴포넌트 작업을 진행해보도록 하겠습니다.
1. 절대경로 설정
프로젝트가 커지면서 상대경로는 import하기 까다로워 지기 때문에 절대경로 설정을 진행하겠습니다.
tsconfig.json에 baseUrl과 paths를 추가합니다.
tsconfig.json
{
"compilerOptions": {
...
"baseUrl": ".",
"paths": {
"~/": ["."],
"~/*": ["./*"]
}
},
...
}
2. 회색 배경 적용하기
npm i @mui/material @emotion/react @emotion/styled @mui/icons-material
pages/_app.tsx
import { GlobalStyles } from '@mui/material';
function MyApp() {
return (
<>
<GlobalStyles styles={{ body: { backgroundColor: "#cdcdcd" } }} />
</>
)
}
export default MyApp
실행해서 보시면 body의 색이 회색으로 변경되었습니다. 이제 여기 위에 TodoBody를 만들어 보겠습니다.
3. TodoBody 만들기
components/TodoBody.tsx
import React from 'react';
import { styled } from '@mui/system'
type Props = {
children: React.ReactNode;
};
const TodoBodyStyle = styled('div')`
width: 512px;
height: 768px;
position: relative;
background: white;
border-radius: 15px;
box-shadow: 0 0 8px 0 rgba(0, 0, 0, 0.04);
margin: 0 auto;
margin-top: 96px;
margin-bottom: 32px;
display: flex;
flex-direction: column;
`;
const TodoBody : React.FC<Props> = ({ children }) => {
return <TodoBodyStyle>{children}</TodoBodyStyle>;
}
export default TodoBody;
pages/_app.tsx
import { GlobalStyles } from '@mui/material';
import TodoBody from '../components/TodoBody';
function MyApp() {
return (
<>
<GlobalStyles styles={{ body: { backgroundColor: "#cdcdcd" } }} />
<TodoBody>TODO BODY</TodoBody>
</>
)
}
export default MyApp
실행을 시키면 Todo의 Body가 흰색으로 노출되는 것을 확인할 수 있습니다.
TodoBody에 Title(Todo List)를 넘겨주고 TodoTitle을 만들어 보겠습니다.
import React from 'react'
import { styled } from '@mui/system'
type Props = {
children: React.ReactNode
title: string
}
...
const TodoTitle = styled('h2')`
text-align: center;
padding: 60px 0;
margin-bottom: 30px;
border-bottom: 1px solid #e9ecef;
`
const TodoBody: React.FC<Props> = ({ title, children }) => {
return (
<TodoBodyStyle>
<TodoTitle>{title}</TodoTitle>
{children}
</TodoBodyStyle>
)
}
export default TodoBody
이제는 할일의 개수, 완료한 개수에 대해서 보여주는 TodoStatus를 만들어 보겠습니다.
import CheckCircleOutlineRoundedIcon from '@mui/icons-material/CheckCircleOutlineRounded';
import CircleOutlinedIcon from '@mui/icons-material/CircleOutlined';
....
const TodoTitle = styled('h2')`
text-align: center;
padding: 60px 0;
margin-bottom: 0px;
border-bottom: 1px solid #e9ecef;
`
const TodoStatus = styled("ul")`
list-style-type: none;
display: flex;
gap: 20px;
margin-right: 20px;
justify-content: flex-end;
& li {
display: flex;
align-items: flex-end;
}
& em {
font-size : 19px;
margin-left : 2px
}
& .done {
color : #d50000
}
& .ing {
color : #388e3c
}
`
const TodoBody: React.FC<Props> = ({ title, children }) => {
return (
<TodoBodyStyle>
<TodoTitle>{title}</TodoTitle>
<TodoStatus>
<li className='ing'>
<CircleOutlinedIcon/>
<em>{2}</em>
</li>
<li className='done'>
<CheckCircleOutlineRoundedIcon/>
<em>{5}</em>
</li>
</TodoStatus>
{children}
</TodoBodyStyle>
)
}
export default TodoBody
4. TodoList 만들기
_app.tsx
import { GlobalStyles } from '@mui/material'
import TodoBody from '~/components/TodoBody'
import TodoList from '~/components/TodoList'
function MyApp() {
return (
<>
<GlobalStyles styles={{ body: { backgroundColor: '#cdcdcd' } }} />
<TodoBody title="Todo List">
<TodoList/>
</TodoBody>
</>
)
}
export default MyApp
components/TodoList.tsx
공간을 확인하기 위해 background color를 넣었습니다.
import React from 'react'
import { styled } from '@mui/system'
type Props = {
children: React.ReactNode
}
const TodoListStyle = styled("div")`
background: #e9ecef;
flex : 1
`
const TodoList = () => {
return (
<TodoListStyle>
</TodoListStyle>
)
}
export default TodoList
5. TodoItem 만들기
components/TodoList.tsx
import React from 'react'
import { styled } from '@mui/system'
import TodoItem from './TodoItem'
type Props = {
children: React.ReactNode
}
const TodoListStyle = styled("div")`
flex : 1
`
const TodoList = () => {
return (
<TodoListStyle>
<TodoItem status="done" >밥 먹기</TodoItem>
<TodoItem status="ing" >숙제하기</TodoItem>
</TodoListStyle>
)
}
export default TodoList
components/TodoItem.tsx
import React from 'react'
import { styled } from '@mui/system'
import Checkbox from '@mui/material/Checkbox';
import CircleOutlinedIcon from '@mui/icons-material/CircleOutlined';
import CheckCircleOutlineRoundedIcon from '@mui/icons-material/CheckCircleOutlineRounded';
import IconButton from '@mui/material/IconButton';
import DeleteIcon from '@mui/icons-material/Delete';
import { red ,green ,grey } from '@mui/material/colors';
type Props = {
children: React.ReactNode
status : "done" | "ing"
}
const TodoItemStyle = styled("div")`
font-size : 20px;
display: flex;
align-items: center;
`
const Remove = styled(IconButton)`
order: 2;
margin-left: auto;
margin-right: 5px;
`
const TodoItemText = styled("div")`
color: #cdcdcd;
text-decoration: line-through;
`
const TodoItem : React.FC<Props> = ({ status , children}) => {
return (
<TodoItemStyle>
<Checkbox
icon={<CircleOutlinedIcon />}
checked={status === "done" ? true : false}
checkedIcon={<CheckCircleOutlineRoundedIcon />}
sx={{
'& .MuiSvgIcon-root': { fontSize: 35 },
color: green[700],
'&.Mui-checked': {
color: red["A700"],
},
}}
/>
<TodoItemText sx={{
color : status === "done" ? grey[400] : "black",
textDecoration: status === "done" ? "line-through" : "none"
}}>{children}</TodoItemText>
<Remove aria-label="delete">
<DeleteIcon />
</Remove>
</TodoItemStyle>
)
}
export default TodoItem
5. TodoCreator 만들기
components/TodoCreator.tsx
import React, { useState } from 'react'
import { styled } from '@mui/system'
import { Box, IconButton, TextField } from '@mui/material'
import AddCircleIcon from '@mui/icons-material/AddCircle';
const TodoCreatorStyle = styled('div')`
position : relative;
`
const TodoCreatorButton = styled(IconButton)`
text-align: center;
width : 80px;
height : 80px;
z-index : 5;
position: absolute;
left: 50%;
bottom: 0;
transform: translate(-50%, 50%);
background: white;
transition: 0.125s all ease-in;
& svg {
width : 80px;
height : 80px;
}
&:hover {
background : white
}
`
const InputForm = styled("div")`
width: 100%;
height: 300px;
bottom : 0px;
background : #e0dddd;
border-radius: 0px 0px 15px 15px;
`
const InputBox = styled(Box)`
position : relative;
height: 100%;
padding : 0 20px;
display : flex;
flex-direction: column;
justify-content: space-evenly;
}
`
const TodoCreator = () => {
const [open, setOpen] = useState(false);
const handleOpen = () => {
setOpen(!open)
}
return <TodoCreatorStyle>
<TodoCreatorButton size="large" color={open ? "secondary" : "primary"} sx={ {
transform: open ? "translate(-50%, 50%) rotate(135deg)" : "translate(-50%, 50%) rotate(0deg)"
}} onClick={handleOpen} >
<AddCircleIcon />
</TodoCreatorButton>
{ open &&
<InputForm sx={{
}}>
<InputBox component="div" >
<TextField
required
label="title"
defaultValue="제목"
fullWidth={true}
sx={{
}}
/>
<TextField
required
label="description"
defaultValue="설명"
fullWidth={true}
sx={{
}}
/>
</InputBox>
</InputForm>
}
</TodoCreatorStyle>
}
export default TodoCreator
_app.tsx
import { GlobalStyles } from '@mui/material'
import TodoBody from '~/components/TodoBody'
import TodoCreator from '~/components/TodoCreator'
import TodoList from '~/components/TodoList'
function MyApp() {
return (
<>
<GlobalStyles styles={{ body: { backgroundColor: '#cdcdcd' } }} />
<TodoBody title="Todo List">
<TodoList />
<TodoCreator />
</TodoBody>
</>
)
}
export default MyApp
마감기간을 넣기 위해 DateTimePicker를 사용합니다. 사용하기 위해 아래의 패키지를 설치합니다.
npm install @mui/lab
npm install date-fns
components/TodoCreator.tsx
...
import AdapterDateFns from '@mui/lab/AdapterDateFns';
import LocalizationProvider from '@mui/lab/LocalizationProvider';
import DateTimePicker from '@mui/lab/DateTimePicker';
...
const TodoCreator = () => {
...
const [deadline , setDeadline] = React.useState<Date | null>(new Date());
...
return <TodoCreatorStyle>
...
{ open &&
<InputForm sx={{
}}>
<InputBox component="div" >
<TextField
required
label="title"
defaultValue="제목"
fullWidth={true}
sx={{
}}
/>
<TextField
required
label="description"
defaultValue="설명"
fullWidth={true}
sx={{
}}
/>
<LocalizationProvider dateAdapter={AdapterDateFns}>
<DateTimePicker
renderInput={(props) => <TextField {...props} />}
label="DateTimePicker"
value={deadline}
onChange={(newValue) => {
setDeadline(newValue);
}}
/>
</LocalizationProvider>
</InputBox>
</InputForm>
}
</TodoCreatorStyle>
}
export default TodoCreator
6. SnackBar 만들기
등록 버튼과 등록 버튼을 클릭했을 때 성공 혹은 실패(내용부족)에 대해 SnackBar를 만들고자 합니다.
components/TodoCreator.tsx
import { Box, IconButton, TextField, Button, Snackbar } from '@mui/material'
import MuiAlert, { AlertProps } from '@mui/material/Alert';
...
const Alert = React.forwardRef<HTMLDivElement, AlertProps>(function Alert(props,ref) {
return <MuiAlert elevation={6} ref={ref} variant="filled" {...props} />;
});
const TodoCreator = () => {
const [open, setOpen] = useState(false)
const [alertOpen, setAlertOpen] = useState(false)
const [deadline, setDeadline] = React.useState<Date | null>(new Date())
const handleOpen = () => {
setOpen(!open)
}
const handleClick = () => {
setAlertOpen(true);
};
const handleClose = (event?: React.SyntheticEvent | Event, reason?: string) => {
if (reason === 'clickaway') {
return;
}
setAlertOpen(false);
};
return (
<TodoCreatorStyle>
<TodoCreatorButton
size="large"
color={open ? 'secondary' : 'primary'}
sx={{
transform: open ? 'translate(-50%, 50%) rotate(135deg)' : 'translate(-50%, 50%) rotate(0deg)',
}}
onClick={handleOpen}>
<AddCircleIcon />
</TodoCreatorButton>
{open && (
<InputForm sx={{}}>
<InputBox component="div">
<TextField required label="제목" defaultValue="제목" fullWidth={true} sx={{}} />
<TextField required label="설명" defaultValue="설명" fullWidth={true} sx={{}} />
<LocalizationProvider dateAdapter={AdapterDateFns}>
<DateTimePicker
renderInput={(props) => <TextField {...props} />}
label="마간기한"
value={deadline}
onChange={(newValue) => {
setDeadline(newValue)
}}
/>
</LocalizationProvider>
<Button variant="contained" onClick={handleClick} >등록</Button>
</InputBox>
</InputForm>
)}
<Snackbar open={alertOpen} autoHideDuration={6000} onClose={handleClose}>
<Alert onClose={handleClose} severity="success" sx={{ width: '100%' }}>
This is a success message!
</Alert>
</Snackbar>
</TodoCreatorStyle>
)
}
배경이 조금 이상하여 색을 하얀색에 가깝게하고 border-top을 추가합니다.
const InputForm = styled('div')`
width: 100%;
height: 400px;
bottom: 0px;
background: #fcfafa;
border-top: solid 1px #c7c7c7;
border-radius: 0px 0px 15px 15px;
`
이제 제목, 설명, 마감기한(스크린샷에는 오타..)을 입력받고 등록을 누르게 되면 검증을 하도록합니다.
제목이 없을 때, 설명이 없을 때, 마감기한을 설정하지 않았을 때 등록을 누르면 SnackBar의 에러 메세지를 노출하고 제대로 입력하고 등록했을 때는 성공하도록 만들어 보겠습니다.
message는 SnackBar의 메세지, alertType은 SnackBar의 Type입니다.
todoForm은 todo를 등록하기 위한 Form Data 입니다.
components/TodoCreator.tsx
type FormType = {
title: string,
description : string,
deadline : Date
};
type AlertType = "error" | "success"
const Alert = React.forwardRef<HTMLDivElement, AlertProps>(function Alert(
props,
ref,
) {
return <MuiAlert elevation={6} ref={ref} variant="filled" {...props} />;
});
const TodoCreator = () => {
const [open, setOpen] = useState(false)
const [alertOpen, setAlertOpen] = useState(false)
const [message, setMessage] = React.useState<string>("")
const [alertType, setAlertType] = React.useState<AlertType>("success")
const [todoForm, setTodoForm] = React.useState<FormType>({
title : "",
description : "",
deadline : new Date()
})
const handleOpen = () => {
setOpen(!open)
}
const handleClick = () => {
if(todoForm.title === ""){
setAlertType("error")
setMessage("제목을 입력해주세요.")
}
else if(todoForm.description === ""){
setAlertType("error")
setMessage("설명을 입력해주세요.")
}
else if( (todoForm.deadline).getTime() < new Date().getTime() ){
setAlertType("error")
setMessage("마감기한을 설정해주세요.")
}
else{
setAlertType("success")
setMessage(JSON.stringify(todoForm))
}
setAlertOpen(true);
};
const handleClose = (event?: React.SyntheticEvent | Event, reason?: string) => {
if (reason === 'clickaway') {
return;
}
setAlertOpen(false);
};
return (
<TodoCreatorStyle>
<TodoCreatorButton
size="large"
color={open ? 'secondary' : 'primary'}
sx={{
transform: open ? 'translate(-50%, 50%) rotate(135deg)' : 'translate(-50%, 50%) rotate(0deg)',
}}
onClick={handleOpen}>
<AddCircleIcon />
</TodoCreatorButton>
{open && (
<InputForm sx={{}}>
<InputBox component="div">
<TextField required label="제목" fullWidth={true} sx={{}} onChange={ e =>
setTodoForm({
...todoForm ,
title : e.target.value
})
}/>
<TextField required label="설명" fullWidth={true} sx={{}} onChange={ e =>
setTodoForm({
...todoForm ,
description : e.target.value
})
} />
<LocalizationProvider dateAdapter={AdapterDateFns}>
<DateTimePicker
renderInput={(props) => <TextField {...props} />}
label="마감기한"
value={todoForm.deadline}
onChange={(newValue) => {
if(newValue != null)
setTodoForm({
...todoForm ,
deadline : newValue
})
}}
/>
</LocalizationProvider>
<Button variant="contained" onClick={handleClick} >등록</Button>
</InputBox>
</InputForm>
)}
<Snackbar open={alertOpen} autoHideDuration={6000} onClose={handleClose}>
<Alert onClose={handleClose} severity={alertType} sx={{ width: '100%' }}>
{ message }
</Alert>
</Snackbar>
</TodoCreatorStyle>
)
}
export default TodoCreator
마무리
이제 Graphql을 통하여 처음에 만든 Nest.js 서버에 요청을 통해 DB에 입력되도록 설정해보겠습니다.
'Backend > Nestjs' 카테고리의 다른 글
[Nestjs] Nestjs + GraphQL 적용한 TodoList 만들기 (3) (0) | 2022.02.07 |
---|---|
[Nestjs] Nestjs + GraphQL 적용한 TodoList 만들기 (2) (0) | 2022.01.25 |
[Nestjs] Nestjs + GraphQL 적용하기 (1) (0) | 2022.01.17 |