Front-End

vsoghlv@naver.com

React.js todolist 응용해서 가계부 만들기

이전에 공부했던 TodoList 를 응용하여 가계부를 만들어봤다.

가계부에서 구현할 기능들은

  1. 추가
  2. 삭제
  3. 수정
  4. select 박스 선택시 카테고리 별로 보여주기
  5. 사용 금액의 총합

이다.

먼저 컴포넌트를 어떻게 나눠야 할지 생각해 봤다.

  1. 전체적인 레이아웃을 관리해 줄 컴포넌트
  2. 헤드부분(날짜, 총 지출)
  3. 카테고리 선택
  4. 전체적인 List
  5. 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,
    }));
  };

뭔가 저 방법보다 더 간결한 방법이 있을 것 같은데 모르겠다..