이전에 공부했던 TodoList 를 응용하여 가계부를 만들어봤다.
가계부에서 구현할 기능들은
- 추가
- 삭제
- 수정
- select 박스 선택시 카테고리 별로 보여주기
- 사용 금액의 총합
이다.
먼저 컴포넌트를 어떻게 나눠야 할지 생각해 봤다.
- 전체적인 레이아웃을 관리해 줄 컴포넌트
- 헤드부분(날짜, 총 지출)
- 카테고리 선택
- 전체적인 List
- List 안의 개별 컴포넌트
당장은 그 이상 생각나지 않아 일단 만들고 필요하면 추가하려고 한다.
먼저 전체적인 레이아웃을 보여줄 템플릿을 하나 만들고 전체적인 컴포넌트들의 디자인을 한 후 기능 구현을 하나씩 시작했다.
//Templete.js
import React from 'react';
import styled from 'styled-components';
const TemplateBlock = styled.div`
position: relative;
display: flex;
flex-direction: column;
width: 512px;
height: 768px;
margin: 96px auto 32px;
background-color: #faeef9;
border-radius: 10px;
box-shadow: 0 0 10px rgba(0, 0, 0, 0.04);
`;
function Template({ children }) {
return <TemplateBlock>{children}</TemplateBlock>;
}
export default Template;
//HouseholdHead.js
import React from 'react';
import styled from 'styled-components';
import { useHouseholdState } from './HouseholdContext';
const HouseholdHeadBlock = styled.div`
padding: 48px 32px 24px;
border-bottom: 1px solid #dedede;
h1 {
margin: 0;
font-size: 27px;
color: #35548e;
}
p {
font-size: 20px;
margin-top: 20px;
}
span {
font-size: 22px;
color: #f37b7b;
}
.total-expense {
margin-top: 20px;
font-size: 20px;
font-weight: bold;
color: #865487;
}
`;
function numberWithCommas(x) {
return x.toString().replace(/\B(?=(\d{3})+(?!\d))/g, ',');
}
function HouseholdHead() {
const household = useHouseholdState();
const amountTotal = household.reduce((preValue, currentValue) => {
//return preValue.amount + currentValue.amount 이거 안됨 오류
return preValue + currentValue.amount;
}, 0);
console.log(amountTotal);
const today = new Date();
const dateString = today.toLocaleDateString('ko-KR', {
year: 'numeric',
month: 'long',
day: 'numeric',
});
return (
<HouseholdHeadBlock>
<h1>오늘의 지출</h1>
<p>{dateString}</p>
<div className="total-expense">
총 지출 : <span>{numberWithCommas(amountTotal)} </span>원
</div>
</HouseholdHeadBlock>
);
}
export default HouseholdHead;
//CategorySelect.js
import React from 'react';
import styled from 'styled-components';
const CategorySelectBlock = styled.div`
padding: 18px 32px;
border-bottom: 1px solid #dedede;
display: flex;
justify-content: flex-end;
p {
margin-right: 10px;
font-size: 18px;
font-weight: bold;
}
select {
}
`;
function CategorySelect() {
return (
<CategorySelectBlock>
<p>카테고리</p>
<select name="Category" id="Category">
<option value="전체">전체</option>
<option value="식사">식사</option>
<option value="외식">외식</option>
</select>
</CategorySelectBlock>
);
}
export default CategorySelect;
//HouseholdList.js
import React from 'react';
import styled from 'styled-components';
import { useHouseholdState } from './HouseholdContext';
import HouseholdCreate from './HouseholdCreate';
import HouseholdItem from './HouseholdItem';
const HouseholdListBlock = styled.div`
flex: 1;
padding: 20px 32px 48px;
overflow-y: auto;
background-color: #f1e1e1d6;
`;
function HouseholdList() {
const households = useHouseholdState();
return (
<HouseholdListBlock>
{households.map(household => (
<HouseholdItem
key={household.id}
id={household.id}
title={household.title}
category={household.category}
text={household.text}
amount={household.amount}
></HouseholdItem>
))}
</HouseholdListBlock>
);
}
export default HouseholdList;
//HouseholdItem.js
import React from 'react';
import styled, { css } from 'styled-components';
import { MdDelete } from 'react-icons/md';
import { ImPencil } from 'react-icons/im';
import { useHouseholdDispatch } from './HouseholdContext';
const icons = css`
display: flex;
align-items: center;
margin-left: 10px;
justify-content: center;
color: #727258;
font-size: 24px;
cursor: pointer;
&:hover {
color: #ff6b6b;
}
`;
const HouseholdItemBlock = styled.div`
display: flex;
align-items: center;
padding-top: 12px;
padding-bottom: 12px;
&:hover {
}
`;
const Text = styled.div`
flex: 1;
font-size: 21px;
color: #495057;
`;
const Price = styled.div`
flex: 1;
font-size: 21px;
color: #c96a6a;
span {
font-size: 17px;
}
`;
const Category = styled.div`
font-size: 18px;
padding: 5px 10px;
margin-right: 10px;
background-color: ${props => props.category};
border-radius: 3px;
color: white;
`;
const Remove = styled.div`
${icons}
`;
const Edit = styled.div`
${icons}
font-size: 20px;
`;
//카테고리별 색상
const categoryColor = category => {
switch (category) {
case 'meal': {
return '#1376be';
}
case 'bmw': {
return '#1a6e3b';
}
case 'hospital': {
return '#a84c45';
}
default:
return 'white';
}
};
function HouseholdItem({ id, title, category, text, amount }) {
const dispatch = useHouseholdDispatch();
const onRemove = () => {
dispatch({
type: 'REMOVE',
id,
});
};
const numberWithCommas = x => {
return x.toString().replace(/\B(?=(\d{3})+(?!\d))/g, ',');
};
return (
<HouseholdItemBlock>
<Category category={categoryColor(category)}>{title}</Category>
<Text>{text}</Text>
<Price>
{numberWithCommas(amount)} <span>원</span>
</Price>
<Edit>
<ImPencil></ImPencil>
</Edit>
<Remove>
<MdDelete onClick={onRemove}></MdDelete>
</Remove>
</HouseholdItemBlock>
);
}
export default React.memo(HouseholdItem);
//HouseholdList.js
import React, { useState } from 'react';
import styled, { css } from 'styled-components';
import { MdAdd } from 'react-icons/md';
const CircleBtn = styled.button`
display: flex;
position: absolute;
left: 50%;
bottom: 0;
width: 80px;
height: 80px;
transform: translate(-50%, 50%);
font-size: 60px;
color: white;
border-radius: 40px;
border: none;
outline: none;
z-index: 5;
cursor: pointer;
background-color: #38d9a9;
align-items: center;
justify-content: center;
&:hover {
background-color: #63e6be;
}
&:active {
background-color: #20c997;
}
transition: all 0.3s;
${props =>
props.open &&
css`
background-color: #ff6b6b;
&:hover {
background-color: #ff8787;
}
&:active {
background-color: #fa5252;
}
transform: translate(-50%, 50%) rotate(45deg);
`};
`;
const InsertFormPostioner = styled.div`
position: absolute;
width: 100%;
bottom: 0;
left: 0;
`;
const InsertFrom = styled.div`
background-color: #f8f9fa;
padding: 32px;
padding-bottom: 72px;
border-bottom-left-radius: 16px;
border-bottom-right-radius: 16px;
border-top: 1px solid #d998d7;
`;
const Input = styled.input`
width: 100%;
padding: 12px;
border: 1px solid #de6265;
outline: none;
font-size: 18px;
box-sizing: border-box;
`;
function HouseholdCreate() {
const [open, setOpen] = useState(false);
const onToggle = () => {
setOpen(!open);
};
return (
<>
<CircleBtn onClick={onToggle} open={open}>
<MdAdd></MdAdd>
</CircleBtn>
{open && (
<InsertFormPostioner>
<InsertFrom>
<Input placeholder="금액을 입력하시오" autoFocus></Input>
</InsertFrom>
</InsertFormPostioner>
)}
</>
);
}
export default HouseholdCreate;
//HouseholdContext.js
import React, { useReducer, createContext, useContext, useRef } from 'react';
//초기상태
const initialHouseholds = [
{
id: 1,
title: '식사',
category: 'meal',
text: '중국집',
amount: 7000,
},
{
id: 2,
title: '교통',
category: 'bmw',
text: '버스',
amount: 10000,
},
{
id: 3,
title: '병원',
category: 'hospital',
text: '병원감',
amount: 9000,
},
];
/* Create, modify, remove */
function householdReducer(state, action) {
switch (action.type) {
case 'CREATE':
return state.concat(action.household);
case 'REMOVE':
return state.filter(household => household.id !== action.id);
default:
throw new Error(`Unhandled action type : ${action.type}`);
}
}
//Context state, dispatch, nextId
const HouseholdStateContext = createContext();
const HouseholdDispatchContext = createContext();
const HouseholdNextIdContext = createContext();
export function HouseProvider({ children }) {
const [state, dispatch] = useReducer(householdReducer, initialHouseholds);
const nextId = useRef(4);
return (
<HouseholdStateContext.Provider value={state}>
<HouseholdDispatchContext.Provider value={dispatch}>
<HouseholdNextIdContext.Provider value={nextId}>
{children}
</HouseholdNextIdContext.Provider>
</HouseholdDispatchContext.Provider>
</HouseholdStateContext.Provider>
);
}
//커스텀 훅
export function useHouseholdState() {
const context = useContext(HouseholdDispatchContext);
if (!context) {
throw new Error('Cannot find HouseholdStateContext');
}
return useContext(HouseholdStateContext);
}
export function useHouseholdDispatch() {
const context = useContext(HouseholdDispatchContext);
if (!context) {
throw new Error('Cannot find HouseholdDispatchContext');
}
return useContext(HouseholdDispatchContext);
}
export function useHouseholdNextId() {
const context = useContext(HouseholdNextIdContext);
if (!context) {
throw new Error('Cannot find TodoProvider');
}
return useContext(HouseholdNextIdContext);
}
일단 추가, 수정, 카테고리 선택 세가지의 기능을 제외하고는 구현한 상태다.
만들면서 몇가지 막혔을 때가 있는데
//HouseholdHead.js
const household = useHouseholdState();
const amountTotal = household.reduce((preValue, currentValue) => {
//return preValue.amount + currentValue.amount 이거 안됨 오류
return preValue + currentValue.amount;
}, 0);
Head 부분인데 처음에 useHouseholdState() 에서 계속 에러가 발생했다. 알고보니 App.js 에서 아래와 같이 Provider 로 감싸줘야 하는데 그걸 빼먹고 진행해서 오류가 발생하던 거였다.
import React from 'react';
import './reset.css';
import { createGlobalStyle } from 'styled-components';
import Template from './components/Template';
import HouseholdHead from './components/HouseholdHead';
import CategorySelect from './components/CategorySelect';
import HouseholdList from './components/HouseholdList';
import HouseholdCreate from './components/HouseholdCreate';
import { HouseProvider } from './components/HouseholdContext';
const GlobalStyle = createGlobalStyle`
body{
background-color:#b7b7b7;
}
`;
function App() {
return (
<>
<HouseProvider>
<GlobalStyle></GlobalStyle>
<Template>
<HouseholdHead></HouseholdHead>
<CategorySelect></CategorySelect>
<HouseholdList></HouseholdList>
<HouseholdCreate></HouseholdCreate>
</Template>
</HouseProvider>
</>
);
}
export default App;
그 다음 문제는 reduce 에서 발생했는데 주석에 써있듯이 처음에는 아래와 같이 작성했었다. 값은 계속 NaN 으로 찍혔다.
const amountTotal = household.reduce((preValue, currentValue) => {
return preValue.amount + urrentValue.amount;
}, 0);
reduce 에 대한 이해가 부족해서였는데 처음 값은 .amount 로 접근이 가능하지만 그 다음에는 두가지를 합한 값.amount 로 접근하게 되기 때문에 오류가 발생한 것이었다. 그러므로 처음 값은 그냥 값 그대로를 가지고 사용해야 한다.
const amountTotal = household.reduce((preValue, currentValue) => {
return preValue + currentValue.amount;
}, 0);
추가, 수정을 다이얼로그가 뜨는 형태로 바꾸기 위해 공통으로 쓸 다이얼로그와 버튼 컴포넌트를 만들었다.
그 후 추가시 다이얼로그가 나오도록 만들고 수정 또한 같은 방법으로 만들었다.
//HouseholdCreate.js
function HouseholdCreate() {
const [dialog, setDialog] = useState(false);
const dispatch = useHouseholdDispatch();
const nextId = useHouseholdNextId();
const [inputs, setInputs] = useState({
id: 0,
category: '',
text: '',
amount: 0,
});
const { category, text, amount } = inputs;
const onChange = e => {
const { name, value } = e.target;
setInputs({ ...inputs, [name]: value });
};
const onSubmit = e => {
e.preventDefault();
dispatch({
type: 'CREATE',
household: {
id: nextId.current,
category: category,
text: text,
//잊지말고 넘버로 보낼것
amount: Number(amount),
},
});
setInputs({
category: 'meal',
});
setDialog(false);
nextId.current += 1;
};
const onToggle = () => {
setDialog(!dialog);
};
const onCancle = () => {
setDialog(false);
};
return (
<>
<CircleBtn onClick={onToggle} dialog={dialog}>
<MdAdd></MdAdd>
</CircleBtn>
<Dialog
title={'입력 후 확인을 누르세요'}
visible={dialog}
onConfirm={onSubmit}
onCancle={onCancle}
>
<form>
<Select name="category" onChange={onChange}>
<option value="meal">식사</option>
<option value="bmw">교통</option>
<option value="hospital">병원</option>
</Select>
<Input
type="text"
name="text"
placeholder="내용"
onChange={onChange}
/>
<Input
type="text"
name="amount"
placeholder="가격"
onChange={onChange}
/>
</form>
</Dialog>
</>
);
}
export default HouseholdCreate;
//HouseholdItem.js
function HouseholdItem({ id, title, category, text, amount }) {
//삭제버튼 다이얼로그
const [deleteDialog, setDeleteDialog] = useState(false);
const onDeletClick = () => {
setDeleteDialog(true);
};
const onDeletConfirm = () => {
onRemove();
setDeleteDialog(false);
};
const onDeletCancle = () => {
setDeleteDialog(false);
};
//수정버튼 다이얼로그
const [editDialog, setEditDialog] = useState(false);
const onEditClick = () => {
setEditDialog(true);
};
const onEditCancle = () => {
setEditDialog(false);
};
const [inputs, setInputs] = useState({
id: id,
category: category,
text: text,
amount: Number(amount),
});
const onChange = e => {
const { name, value } = e.target;
setInputs({ ...inputs, [name]: value });
};
const onSubmit = e => {
e.preventDefault();
console.log(inputs.category, inputs.text, inputs.amount);
dispatch({
type: 'EDIT',
household: {
id: id,
category: inputs.category,
text: inputs.text,
//잊지말고 넘버로 보낼것
amount: Number(inputs.amount),
},
});
setEditDialog(false);
};
const dispatch = useHouseholdDispatch();
const onRemove = () => {
dispatch({
type: 'REMOVE',
id,
});
};
const numberWithCommas = x => {
return x.toString().replace(/\B(?=(\d{3})+(?!\d))/g, ',');
};
return (
<>
<HouseholdItemBlock>
<Category category={categoryColor(category)}>{title}</Category>
<Text>{text}</Text>
<Price>
{numberWithCommas(amount)} <span>원</span>
</Price>
<Edit>
<ImPencil onClick={onEditClick}></ImPencil>
</Edit>
<Remove>
<MdDelete onClick={onDeletClick}></MdDelete>
</Remove>
</HouseholdItemBlock>
<Dialog
title="정말로 삭제하시겠습니까?"
confirmText="삭제"
cancelText="취소"
visible={deleteDialog}
onConfirm={onDeletConfirm}
onCancle={onDeletCancle}
>
정말로 삭제하시겠습니까
</Dialog>
<Dialog
title="수정하시겠습니까?"
confirmText="수정"
cancelText="취소"
visible={editDialog}
onConfirm={onSubmit}
onCancle={onEditCancle}
>
<form>
<Select name="category" onChange={onChange}>
<option value="meal">식사</option>
<option value="bmw">교통</option>
<option value="hospital">병원</option>
</Select>
<Input
type="text"
name="text"
placeholder="내용"
onChange={onChange}
/>
<Input
type="text"
name="amount"
placeholder="가격"
onChange={onChange}
/>
</form>
</Dialog>
</>
);
}
여러개의 인풋을 한번에 관리하는 방법이 생각이 안나 다시 공부하며 진행해야 했다. onChange 이벤트에서는 각 키값에 각 벨류를 넣도록 하고 onSubmit 을 할 때 저장한 정보들을 보내줬다.
처음 가격을 Number() 로 바꾸지 않고 바로 보내주니 문자열로 변환되 문제가 생겼었다.
수정시에 다이얼로그가 나오면 클릭한 아이템들의 값이 먼저 보이도록 하고 싶었는데 그렇게 설정하니 보이질 readOnly 설정이 적용되 변경되지 않았다. 이 부분은 추후 더 공부해봐야 할 것 같다.
이제 마지막으로 카테고리 선택시에 선택한 카테고리의 값만 보여주도록 설정해주었다.
//HouseholdList.js
function HouseholdList() {
const households = useHouseholdState();
const [selectName, setSelectName] = useState('all');
const selectRerender = e => {
e.preventDefault();
return setSelectName(e.target.value);
};
return (
<>
<CategorySelectBlock>
<p>카테고리</p>
<select name="Category" id="Category" onChange={selectRerender}>
<option value="all">전체</option>
<option value="meal">식사</option>
<option value="bmw">교통</option>
<option value="hospital">의료</option>
</select>
</CategorySelectBlock>
<HouseholdListBlock>
{households.map(household =>
selectName === 'all' ? (
<HouseholdItem
key={household.id}
id={household.id}
title={changeTitle(household.category)}
category={household.category}
text={household.text}
amount={household.amount}
></HouseholdItem>
) : household.category === selectName ? (
<HouseholdItem
key={household.id}
id={household.id}
title={changeTitle(household.category)}
category={household.category}
text={household.text}
amount={household.amount}
></HouseholdItem>
) : (
''
)
)}
</HouseholdListBlock>
</>
);
}
먼저 기존에 따로 작성했던 CategorySelece.js 를 지우고 HouseholdList.js 로 해당 내용을 옮겨왔다.
그 후 useState
를 이용해 값을 select 값이 변경됨에 따라 selectName 값을 변경해주도록 했다.
아래의 return 부분에서 처음에는 filter 를 사용해 변경해주려 했는데 문제가 생겼다.
Objects are not valid as a React child (found: object with keys {id, category, text, amount}). If you meant to render a collection of children, use an array instead
라는 에러가 생기는 문제였는데 검색해봐도 만족스러운 해결법을 찾지 못했다. 후에 공부를 더하고 다시 한번 찾아봐야 될 거 같다.
결국 위와 같이 map 안에서 조건문을 줘서 해결했는데 안에 중복되는 값들을 한번에 처리할 방법이 있을 것 같아 한번 찾아보려 한다.
일단 1차적으로 기능 구현들은 완성이 되었다. 이제 코드들을 조금 더 다듬어보고 정리할 수 있는 부분은 정리해봐야 될 것 같다.
//App.js
import React from 'react';
import './reset.css';
import { createGlobalStyle, ThemeProvider } from 'styled-components';
import Template from './components/Template';
import HouseholdHead from './components/HouseholdHead';
import CategorySelect from './components/CategorySelect';
import HouseholdList from './components/HouseholdList';
import HouseholdCreate from './components/HouseholdCreate';
import { HouseProvider } from './components/HouseholdContext';
const GlobalStyle = createGlobalStyle`
body{
background-color:#b7b7b7;
}
`;
function App() {
return (
<ThemeProvider
theme=}
>
<>
<HouseProvider>
<GlobalStyle></GlobalStyle>
<Template>
<HouseholdHead></HouseholdHead>
<HouseholdList></HouseholdList>
<HouseholdCreate></HouseholdCreate>
</Template>
</HouseProvider>
</>
</ThemeProvider>
);
}
export default App;
//Template.js
import React from 'react';
import styled from 'styled-components';
const TemplateBlock = styled.div`
position: relative;
display: flex;
flex-direction: column;
width: 512px;
height: 768px;
margin: 96px auto 32px;
background-color: #faeef9;
border-radius: 10px;
box-shadow: 0 0 10px rgba(0, 0, 0, 0.04);
`;
function Template({ children }) {
return <TemplateBlock>{children}</TemplateBlock>;
}
export default Template;
//HouseholdHead.js
import React from 'react';
import styled from 'styled-components';
import { useHouseholdState } from './HouseholdContext';
const HouseholdHeadBlock = styled.div`
padding: 48px 32px 24px;
border-bottom: 1px solid #dedede;
h1 {
margin: 0;
font-size: 27px;
color: #35548e;
}
p {
font-size: 20px;
margin-top: 20px;
}
span {
font-size: 22px;
color: #f37b7b;
}
.total-expense {
margin-top: 20px;
font-size: 20px;
font-weight: bold;
color: #865487;
}
`;
function numberWithCommas(x) {
return x.toString().replace(/\B(?=(\d{3})+(?!\d))/g, ',');
}
function HouseholdHead() {
const household = useHouseholdState();
const amountTotal = household.reduce((preValue, currentValue) => {
//return preValue.amount + currentValue.amount 이거 안됨 오류
return preValue + currentValue.amount;
}, 0);
console.log(amountTotal);
const today = new Date();
const dateString = today.toLocaleDateString('ko-KR', {
year: 'numeric',
month: 'long',
day: 'numeric',
});
return (
<HouseholdHeadBlock>
<h1>오늘의 지출</h1>
<p>{dateString}</p>
<div className="total-expense">
총 지출 : <span>{numberWithCommas(amountTotal)} </span>원
</div>
</HouseholdHeadBlock>
);
}
export default HouseholdHead;
//HouseholdList
import React, { useState } from 'react';
import styled from 'styled-components';
import { useHouseholdState } from './HouseholdContext';
import HouseholdItem from './HouseholdItem';
import CategorySelect from './CategorySelect';
const HouseholdListBlock = styled.div`
flex: 1;
padding: 20px 32px 48px;
overflow-y: auto;
background-color: #f1e1e1d6;
`;
const CategorySelectBlock = styled.div`
padding: 18px 32px;
border-bottom: 1px solid #dedede;
display: flex;
justify-content: flex-end;
p {
margin-right: 10px;
font-size: 18px;
font-weight: bold;
}
select {
}
`;
const changeTitle = category => {
switch (category) {
case 'meal':
return '식사';
case 'bmw':
return '교통';
case 'hospital':
return '병원';
default:
return '설정안됨';
}
};
function HouseholdList() {
const households = useHouseholdState();
const [selectName, setSelectName] = useState('all');
const selectRerender = e => {
e.preventDefault();
return setSelectName(e.target.value);
};
return (
<>
<CategorySelectBlock>
<p>카테고리</p>
<select name="Category" id="Category" onChange={selectRerender}>
<option value="all">전체</option>
<option value="meal">식사</option>
<option value="bmw">교통</option>
<option value="hospital">의료</option>
</select>
</CategorySelectBlock>
<HouseholdListBlock>
{households.map(household =>
selectName === 'all' ? (
<HouseholdItem
key={household.id}
id={household.id}
title={changeTitle(household.category)}
category={household.category}
text={household.text}
amount={household.amount}
></HouseholdItem>
) : household.category === selectName ? (
<HouseholdItem
key={household.id}
id={household.id}
title={changeTitle(household.category)}
category={household.category}
text={household.text}
amount={household.amount}
></HouseholdItem>
) : (
''
)
)}
</HouseholdListBlock>
</>
);
}
export default HouseholdList;
//HouseholdItem.js
import React, { useState } from 'react';
import styled, { css } from 'styled-components';
import { MdDelete } from 'react-icons/md';
import { ImPencil } from 'react-icons/im';
import { useHouseholdDispatch } from './HouseholdContext';
import Dialog from '../Dialog';
const icons = css`
display: flex;
align-items: center;
margin-left: 10px;
justify-content: center;
color: #727258;
font-size: 24px;
cursor: pointer;
&:hover {
color: #ff6b6b;
}
`;
const HouseholdItemBlock = styled.div`
display: flex;
align-items: center;
padding-top: 12px;
padding-bottom: 12px;
&:hover {
}
`;
const Text = styled.div`
flex: 1;
font-size: 21px;
color: #495057;
`;
const Price = styled.div`
flex: 1;
font-size: 21px;
color: #c96a6a;
span {
font-size: 17px;
}
`;
const Category = styled.div`
font-size: 18px;
padding: 5px 10px;
margin-right: 10px;
background-color: ${props => props.category};
border-radius: 3px;
color: white;
`;
const Remove = styled.div`
${icons}
`;
const Edit = styled.div`
${icons}
font-size: 20px;
`;
const boxStyle = css`
width: 100%;
padding: 10px;
margin: 7px 0;
border: 1px solid #6b616e;
outline: none;
font-size: 18px;
box-sizing: border-box;
border-radius: 3px;
`;
const Select = styled.select`
${boxStyle}
`;
const Input = styled.input`
${boxStyle}
`;
//카테고리별 색상
const categoryColor = category => {
switch (category) {
case 'meal': {
return '#1376be';
}
case 'bmw': {
return '#1a6e3b';
}
case 'hospital': {
return '#a84c45';
}
default:
return 'white';
}
};
function HouseholdItem({ id, title, category, text, amount }) {
//삭제버튼 다이얼로그
const [deleteDialog, setDeleteDialog] = useState(false);
const onDeletClick = () => {
setDeleteDialog(true);
};
const onDeletConfirm = () => {
onRemove();
setDeleteDialog(false);
};
const onDeletCancle = () => {
setDeleteDialog(false);
};
//수정버튼 다이얼로그
const [editDialog, setEditDialog] = useState(false);
const onEditClick = () => {
setEditDialog(true);
};
const onEditCancle = () => {
setEditDialog(false);
};
const [inputs, setInputs] = useState({
id: id,
category: category,
text: text,
amount: Number(amount),
});
const onChange = e => {
const { name, value } = e.target;
setInputs({ ...inputs, [name]: value });
};
const onSubmit = e => {
e.preventDefault();
console.log(inputs.category, inputs.text, inputs.amount);
dispatch({
type: 'EDIT',
household: {
id: id,
category: inputs.category,
text: inputs.text,
//잊지말고 넘버로 보낼것
amount: Number(inputs.amount),
},
});
setEditDialog(false);
};
const dispatch = useHouseholdDispatch();
const onRemove = () => {
dispatch({
type: 'REMOVE',
id,
});
};
const numberWithCommas = x => {
return x.toString().replace(/\B(?=(\d{3})+(?!\d))/g, ',');
};
return (
<>
<HouseholdItemBlock>
<Category category={categoryColor(category)}>{title}</Category>
<Text>{text}</Text>
<Price>
{numberWithCommas(amount)} <span>원</span>
</Price>
<Edit>
<ImPencil onClick={onEditClick}></ImPencil>
</Edit>
<Remove>
<MdDelete onClick={onDeletClick}></MdDelete>
</Remove>
</HouseholdItemBlock>
<Dialog
title="정말로 삭제하시겠습니까?"
confirmText="삭제"
cancelText="취소"
visible={deleteDialog}
onConfirm={onDeletConfirm}
onCancle={onDeletCancle}
>
정말로 삭제하시겠습니까
</Dialog>
<Dialog
title="수정하시겠습니까?"
confirmText="수정"
cancelText="취소"
visible={editDialog}
onConfirm={onSubmit}
onCancle={onEditCancle}
>
<form>
<Select name="category" onChange={onChange}>
<option value="meal">식사</option>
<option value="bmw">교통</option>
<option value="hospital">병원</option>
</Select>
<Input
type="text"
name="text"
placeholder="내용"
onChange={onChange}
/>
<Input
type="text"
name="amount"
placeholder="가격"
onChange={onChange}
/>
</form>
</Dialog>
</>
);
}
export default React.memo(HouseholdItem);
//HouseholdCreate.js
import React, { useState } from 'react';
import styled, { css } from 'styled-components';
import { MdAdd } from 'react-icons/md';
import Dialog from '../Dialog';
import { useHouseholdDispatch, useHouseholdNextId } from './HouseholdContext';
const CircleBtn = styled.button`
display: flex;
position: absolute;
left: 50%;
bottom: 0;
width: 80px;
height: 80px;
transform: translate(-50%, 50%);
font-size: 60px;
color: white;
border-radius: 40px;
border: none;
outline: none;
z-index: 5;
cursor: pointer;
background-color: #38d9a9;
align-items: center;
justify-content: center;
&:hover {
background-color: #63e6be;
}
&:active {
background-color: #20c997;
}
transition: all 0.3s;
${props =>
props.dialog &&
css`
background-color: #ff6b6b;
&:hover {
background-color: #ff8787;
}
&:active {
background-color: #fa5252;
}
transform: translate(-50%, 50%) rotate(45deg);
`};
`;
const boxStyle = css`
width: 100%;
padding: 10px;
margin: 7px 0;
border: 1px solid #6b616e;
outline: none;
font-size: 18px;
box-sizing: border-box;
border-radius: 3px;
`;
const Select = styled.select`
${boxStyle}
`;
const Input = styled.input`
${boxStyle}
`;
function HouseholdCreate() {
const [dialog, setDialog] = useState(false);
const dispatch = useHouseholdDispatch();
const nextId = useHouseholdNextId();
const [inputs, setInputs] = useState({
id: 0,
category: 'meal',
text: '',
amount: 0,
});
const { category, text, amount } = inputs;
const onChange = e => {
const { name, value } = e.target;
setInputs({ ...inputs, [name]: value });
};
const onSubmit = e => {
e.preventDefault();
dispatch({
type: 'CREATE',
household: {
id: nextId.current,
category: category,
text: text,
//잊지말고 넘버로 보낼것
amount: Number(amount),
},
});
setInputs({
category: 'meal',
});
setDialog(false);
nextId.current += 1;
};
const onToggle = () => {
setDialog(!dialog);
};
const onCancle = () => {
setDialog(false);
};
return (
<>
<CircleBtn onClick={onToggle} dialog={dialog}>
<MdAdd></MdAdd>
</CircleBtn>
<Dialog
title={'입력 후 확인을 누르세요'}
visible={dialog}
onConfirm={onSubmit}
onCancle={onCancle}
>
<form>
<Select name="category" onChange={onChange}>
<option value="meal">식사</option>
<option value="bmw">교통</option>
<option value="hospital">의료</option>
</Select>
<Input
type="text"
name="text"
placeholder="내용"
onChange={onChange}
/>
<Input
type="text"
name="amount"
placeholder="가격"
onChange={onChange}
/>
</form>
</Dialog>
</>
);
}
export default HouseholdCreate;
//HouseholdContext.js
import React, { useReducer, createContext, useContext, useRef } from 'react';
//초기상태
const initialHouseholds = [
{
id: 1,
category: 'meal',
text: '중국집',
amount: 7000,
},
{
id: 2,
category: 'bmw',
text: '버스',
amount: 10000,
},
{
id: 3,
category: 'hospital',
text: '병원감',
amount: 9000,
},
];
/* Create, modify(EDIT), remove */
function householdReducer(state, action) {
switch (action.type) {
case 'CREATE':
return state.concat(action.household);
case 'EDIT':
console.log(initialHouseholds);
return state.map(household =>
household.id === action.household.id
? {
...household,
category: action.household.category,
text: action.household.text,
amount: action.household.amount,
}
: household
);
case 'REMOVE':
return state.filter(household => household.id !== action.id);
default:
throw new Error(`Unhandled action type : ${action.type}`);
}
}
//Context state, dispatch, nextId
const HouseholdStateContext = createContext();
const HouseholdDispatchContext = createContext();
const HouseholdNextIdContext = createContext();
export function HouseProvider({ children }) {
const [state, dispatch] = useReducer(householdReducer, initialHouseholds);
const nextId = useRef(4);
return (
<HouseholdStateContext.Provider value={state}>
<HouseholdDispatchContext.Provider value={dispatch}>
<HouseholdNextIdContext.Provider value={nextId}>
{children}
</HouseholdNextIdContext.Provider>
</HouseholdDispatchContext.Provider>
</HouseholdStateContext.Provider>
);
}
//커스텀 훅
export function useHouseholdState() {
const context = useContext(HouseholdDispatchContext);
if (!context) {
throw new Error('Cannot find HouseholdStateContext');
}
return useContext(HouseholdStateContext);
}
export function useHouseholdDispatch() {
const context = useContext(HouseholdDispatchContext);
if (!context) {
throw new Error('Cannot find HouseholdDispatchContext');
}
return useContext(HouseholdDispatchContext);
}
export function useHouseholdNextId() {
const context = useContext(HouseholdNextIdContext);
if (!context) {
throw new Error('Cannot find TodoProvider');
}
return useContext(HouseholdNextIdContext);
}
//Button.js
import React from 'react';
import styled, { css } from 'styled-components';
import { darken, lighten } from 'polished';
const btnColor = css`
${({ theme, color }) => {
const selected = theme.palette[color];
return css`
background: ${selected};
&:hover {
background: ${lighten(0.1, selected)};
}
&:active {
background: ${darken(0.1, selected)};
}
`;
}}
`;
const btnSizes = {
large: {
height: '3rem',
fontSize: '1.25rem',
},
medium: {
height: '2.25rem',
fontSize: '1rem',
},
small: {
height: '1.75rem',
fontSize: '0.875rem',
},
};
const sizeStyles = css`
${({ size }) => css`
height: ${btnSizes[size].height};
font-size: ${btnSizes[size].fontSize};
`}
`;
const StyledBtn = styled.button`
/* 공통 스타일 */
display: inline-block;
outline: none;
border: none;
border-radius: 4px;
color: white;
font-weight: bold;
cursor: pointer;
padding-left: 1rem;
padding-right: 1rem;
/* 크기 */
${sizeStyles}
/* 색상 */
${btnColor}
/* 기타 */
//붙어있는 경우 마진 레프드 1
& + & {
margin-left: 1rem;
}
`;
function Button({ children, color, size, ...rest }) {
return (
<StyledBtn color={color} size={size} {...rest}>
{children}
</StyledBtn>
);
}
Button.defaultProps = {
color: 'blue',
size: 'medium',
};
export default Button;
//Dialog.js
import React from 'react';
import styled from 'styled-components';
import Button from './Button';
const DarkBkg = styled.div`
position: fixed;
display: flex;
left: 0;
top: 0;
width: 100%;
height: 100%;
align-items: center;
justify-content: center;
background-color: rgba(0, 0, 0, 0.75);
`;
const DialogBlock = styled.div`
width: 320px;
padding: 1.5rem;
background: white;
border-radius: 2px;
h3 {
margin: 0;
font-size: 1.25rem;
}
div {
margin-top: 10px;
font-size: 1.125rem;
}
`;
const ButtonGroup = styled.div`
margin-top: 10px;
display: flex;
justify-content: flex-end;
`;
function Dialog({
title,
children,
confirmText,
cancelText,
visible,
onConfirm,
onCancle,
}) {
if (!visible) return null;
return (
<DarkBkg>
<DialogBlock>
<h3>{title}</h3>
<div>{children}</div>
<ButtonGroup>
<Button color="blue" onClick={onCancle}>
{cancelText}
</Button>
<Button color="pink" onClick={onConfirm}>
{confirmText}
</Button>
</ButtonGroup>
</DialogBlock>
</DarkBkg>
);
}
Dialog.defaultProps = {
cancelText: '취소',
confirmText: '확인',
};
export default Dialog;
커스텀 훅으로 중복되는 인풋 관리
//HouseholdCreate.js
const [inputs, setInputs] = useState({
id: 0,
category: 'meal',
text: '',
amount: 0,
});
const { category, text, amount } = inputs;
const onChange = e => {
const { name, value } = e.target;
setInputs({ ...inputs, [name]: value });
};
//HouseholdItem.js
const [inputs, setInputs] = useState({
id: id,
category: category,
text: text,
amount: Number(amount),
});
const onChange = e => {
const { name, value } = e.target;
setInputs({ ...inputs, [name]: value });
};
기존의 값을 받아오는 input 부분이 위와 같이 중복되고 있어서 커스텀 훅을 사용해 따로 빼서 사용해 봤다.
//useInputs.js
import { useState, useCallback } from 'react';
function useInputs(initialForm) {
const [inputs, setInputs] = useState({
id: 0,
category: 'meal',
text: '',
amount: 0,
});
const onChange = e => {
const { name, value } = e.target;
console.log(name, value);
setInputs(form => ({ ...form, [name]: value }));
};
return [inputs, setInputs, onChange];
}
export default useInputs;
//HouseholdCreate.js
const [inputs, setInputs, onChange] = useInputs({
id: 0,
category: 'meal',
text: '',
amount: 0,
});
const { category, text, amount } = inputs;
//HouseholdItem.js
const [inputs, setInputs, onChange] = useInputs({
id: 0,
category: 'meal',
text: '',
amount: 0,
});
사실 아직 크게 편한지는 잘 모르겠다.. 계속 써봐야 알 것 같다.
만들고 생각해보니 빈값이나 값이 잘못들어간 경우에 대한 처리를 해주지 않았다.
if (!inputs.text || !inputs.amount) {
alert('값을 확인해 주세요.');
return;
}
//useInputs.js
const onlyNumber = e => {
if (e.keycode < 48 || e.which > 57) {
e.preventDefault();
setInputs({
amount: '',
});
e.target.value = '';
}
};
위의 두가지로 처리했다.
뭔가 공부를 더하고 다시 한번 돌아봐야 될 것 같다. 더 줄이고 깔끔하게 할 수 있는지를 확인해봐야 할 것 같다.
21년 1월 7일 추가
몇가지를 수정해봤다.
먼저 App.js 에서
<Template>
<HouseholdHead></HouseholdHead>
<HouseholdList></HouseholdList>
<HouseholdCreate></HouseholdCreate>
</Template>
// self-closing 로 변경
<Template>
<HouseholdHead />
<HouseholdList />
<HouseholdCreate />
</Template>
위의 부분들을 self-closing 형태로 변경했다. 리액트에서는 children 이 없는 경우 self-closing 형태로 작성하는게 조금 더 일반적이라고 한다.
그리고 Button.js 에서
function Button({ children, color, size, ...rest }) {
return (
<StyledBtn color={color} size={size} {...rest}>
{children}
</StyledBtn>
);
}
Button.defaultProps = {
color: 'blue',
size: 'medium',
};
//디펄트 props 바로 넣어주기
function Button({ children, color = 'blue', size = 'medium', ...rest }) {
return (
<StyledBtn color={color} size={size} {...rest}>
{children}
</StyledBtn>
);
}
또 HouseholdContext.js 에서 잘못 작성한 구문이 있어 수정해줬다.
const context = useContext(HouseholdDispatchContext);
if (!context) {
throw new Error('Cannot find HouseholdStateContext');
}
return useContext(HouseholdStateContext);
//후
const context = useContext(HouseholdStateContext);
if (!context) {
throw new Error('Cannot find HouseholdStateContext');
}
return context;
또한 리턴 부분을 전부 context 로 변경해줬다.
그리고 기존의 form 에서 submit 을 하지 않고 dialog 에 submit 이벤트를 줬었는데 이것도 form 에서 submit 이벤트를 주는 걸로 바꿔줬다. 수정부분과 입력부분 둘다 아래와 같은 방식으로 바꿔줬다.
return (
<>
<CircleBtn onClick={onToggle} dialog={dialog}>
<MdAdd></MdAdd>
</CircleBtn>
<form onSubmit={onSubmit}>
<Dialog
title={'입력 후 확인을 누르세요'}
visible={dialog}
onCancle={onCancle}
>
<Select name="category" onChange={onChange}>
<option value="meal">식사</option>
<option value="bmw">교통</option>
<option value="hospital">의료</option>
</Select>
<Input
type="text"
name="text"
placeholder="내용"
onChange={onChange}
/>
<Input
type="number"
name="amount"
placeholder="가격"
onChange={onChange}
onKeyUp={onlyNumber}
/>
</Dialog>
</form>
</>
);
}
그리고 이전에 고민했던 HouseholdList 부분을 아래와 같이 수정했다. filter 를 사용해 더 깔끔하게 바꿔줬다.
//전
{households.map(household =>
selectName === 'all' ? (
<HouseholdItem
key={household.id}
id={household.id}
title={changeTitle(household.category)}
category={household.category}
text={household.text}
amount={household.amount}
></HouseholdItem>
) : household.category === selectName ? (
<HouseholdItem
key={household.id}
id={household.id}
title={changeTitle(household.category)}
category={household.category}
text={household.text}
amount={household.amount}
></HouseholdItem>
) : (
''
)
)}
//후
{households
.filter(
household =>
selectName === 'all' || household.category === selectName
)
.map(household => (
<HouseholdItem
key={household.id}
id={household.id}
title={changeTitle(household.category)}
category={household.category}
text={household.text}
amount={household.amount}
></HouseholdItem>
))}
테스트를 하다보니 인풋 숫자 입력부분에서 오른쪽 키패드가 안먹는 걸 확인했다.
const onlyNumber = e => {
if (
(e.keycode < 48 || e.which > 57) ||
(e.keycode < 96 || e.keycode > 105)
) {
e.preventDefault();
setInputs({
id: inputs.id,
category: inputs.category,
text: inputs.text,
amount: 0,
});
e.target.value = '';
}
};
오른쪽 키패드의 키코드도 추가해줬다.
이외의 자잘한 부분들도 취소버튼 클릭시 기본값 다시 설정을 해주는 등 몇가지 수정한 부분이 있었다. 늘 느끼지만 조금씩 알아갈수록 더 어려워진다.
21년 1월 15일 추가
삭제 다이얼로그에서 삭제버튼에 삭제함수를 보내지 않은 것을 확인해 수정하고 select 버튼에는 autoFoucs 를 줬다.
그리고 수정시 input 에 defaultValue 를 보내줌으로써 수정 버튼을 눌렀을 때 기본값이 나오도록 처리해줬는데 여기서의 오류 처리때문에 여러가지를 변경해줬다.
//components/HouseholdItem.js
<form onSubmit={onSubmit}>
<Dialog
title="수정하시겠습니까?"
confirmText="수정"
cancelText="취소"
visible={editDialog}
onCancle={onEditCancle}
>
<Select
name="category"
onChange={onChange}
autoFocus
defaultValue={category}
>
<option value="meal">식사</option>
<option value="bmw">교통</option>
<option value="hospital">병원</option>
</Select>
<Input
type="text"
name="text"
placeholder="내용"
onChange={onChange}
defaultValue={text}
/>
<Input
type="text"
name="amount"
placeholder="가격"
onChange={onChange}
onKeyPress={onlyNumber}
defaultValue={amount}
/>
</Dialog>
</form>
수정버튼 클릭 후 수정을 하지않고 수정을 누를시 value 를 못받아오는 문제를 해결하기 위해, useInputs 를 사용할 때 기본값을 빈값으로 만들어 버리던 걸 아래와 같이 수정했다. (submit 후도 같은 방식으로 수정) 취소버튼 클릭시에 기본값을 빈값으로 만들던 것도 아래와 같은 방식으로 수정했다.
//기본값 이전
const [inputs, setInputs] = useState({
id: 0,
category: 'meal',
text: '',
amount: 0,
});
//기본값 수정 후
const [inputs, setInputs, onChange, onlyNumber] = useInputs({
id: id,
category: category,
text: text,
amount: amount,
});
수정 후 확인 중 수정버튼 클릭 후 하나의 값만 변경 한 후 다시 수정을 하려고 하면 다른 값들이 빈값으로 변하는 오류도 있어 useInputs.js 의 onChange 를 아래와 같이 수정했다.
const onChange = e => {
const { name, value } = e.target;
console.log(name, value);
setInputs(form => ({
...form,
category: inputs.category,
text: inputs.text,
amount: inputs.amount,
[name]: value,
}));
};
뭔가 저 방법보다 더 간결한 방법이 있을 것 같은데 모르겠다..