json-server를 사용하고 있으므로 헤로쿠 배포는 가이드를 보며 진행했다.
배포 주소 : https://estimate-board-page.herokuapp.com/
npx create-react-app [project-name] --template typescript
npm install -D prettier eslint-config-prettier eslint-plugin-prettier
CRA에 타입스크립트를 사용하여 과제를 수행하기로 결정했다. 프리티어도 설정했다.
//tsconfig.json
{
"compilerOptions": {
"target": "es5",
"lib": ["dom", "dom.iterable", "esnext"],
"allowJs": true,
"skipLibCheck": true,
"esModuleInterop": true,
"allowSyntheticDefaultImports": true,
"strict": true,
"forceConsistentCasingInFileNames": true,
"noFallthroughCasesInSwitch": true,
"module": "esnext",
"moduleResolution": "node",
"resolveJsonModule": true,
"isolatedModules": true,
"noEmit": true,
"jsx": "react-jsx",
"baseUrl": "src"
},
"include": ["src"]
}
절대경로를 사용할 때, 여러 옵션을 손 쉽게 사용할 수 있는 craco 라이브러리가 있지만, 라이브러리 설치 없이 tsconfig.json만 변경해도 동작하여 이번에는 설치하지 않았다.
npm i -D concurrently json-server
그리고 json 데이터를 만들고 백엔드 서버가 있는것처럼 사용할 수 있는 라이브러리도 설치해줬다.
"scripts": {
...
"json-server": "json-server --watch server/db.json --port 8888",
"dev": "concurrently \"npm run json-server\" \"npm start\""
},
package.json의 스크립트에 위 부분을 추가했다. 그리고 스크립트를 벗어나 맨 아래에는 "proxy": "http://localhost:8888/"
을 넣었다.
server라는 디렉터리를 생성한 후에 db.json에 fetch로 받을 목 데이터를 넣어주었다.
useEffect(() => {
(async function fetchData() {
const data = await (await fetch('/requests')).json();
setApiData(data);
})();
}, []);
이제 data를 fetch를 이용하여 받아 apiData에 초기화 시키고, UI 출력에 사용하면 된다.
db.json에는 데이터가 들어있다. json-server로 이 데이터를 서버에서 받아오는 것 처럼 사용하고 있다. 데이터로 받은 json을 map돌려 카드 컴포넌트에 뿌려주었다. 이제 가공박식과 재료 선택에 따라 필터링을 구현해야한다.
//pages/mainPage.tsx
export const MainPage = () => {
const [apiData, setApiData] = useState<Estimate[]>([]);
const [isChecked, setIsChecked] = useState(false);
const [categories, setCategories] = useState<Category>({
method: [],
material: [],
});
useEffect(() => {
(async function fetchData() {
const data = await (await fetch('/requests')).json();
setApiData(data);
})();
}, []);
};
메인 페이지에서 apiData를 받아온다. apiData에서 받아온 데이터를 기준으로 가공방식과 재료에 대한 필터링 목록들을 생성하기 위해 categories라는 state를 만들었다.
예를들어, 자전거의 재료가 다이아몬드인 데이터가 추가되면 재료에 대한 필터링 목록에 다이아몬드가 추가되는 것이다.
Estimate와 Category는 types 디렉터리에 interface로 타입을 생성해주었다.
//pages/mainPage.tsx
return (
<PageContainer>
<MainHeader>
<h2>들어온 요청</h2>
<span>파트너님에게 딱 맞는 요청서를 찾아보세요.</span>
</MainHeader>
<OptionContainer>
<Filter apiData={apiData} setCategories={setCategories} />
<Toggle isChecked={isChecked} setIsChecked={setIsChecked} />
</OptionContainer>
<EstimateList
apiData={apiData}
isChecked={isChecked}
categories={categories}
/>
</PageContainer>
);
categories에 선택된 목록을 넣어주기 위해 EstimateList에서 필터링 해주기 위해 setCategories을 props로 전달한다.
categories로 필터링된 카드를 뿌려주기 위해 EstimateList 컴포넌트에 categories을 넘겨주고 있다.(isChecked는 상담중인 카드 보여주는 state이다.)
//components/Filter.tsx
interface Props {
apiData: Estimate[];
setCategories: Dispatch<SetStateAction<Category>>;
}
export const Filter = ({ apiData, setCategories }: Props) => {
const methodArr = apiData.map(data => data.method).flat(Infinity);
const materialArr = apiData.map(data => data.material).flat(Infinity);
const methodSet = Array.from(new Set(methodArr));
const materialSet = Array.from(new Set(materialArr));
const [selectMethod, setSelectMethod] = useState<string[]>([]);
const [selectMaterial, setSelectMaterial] = useState<string[]>([]);
useEffect(() => {
setCategories({ method: selectMethod, material: selectMaterial });
}, [selectMethod, selectMaterial, setCategories]);
const handleCheck = (
e: React.ChangeEvent<HTMLInputElement>,
type: string
) => {
let newSelected;
const setStateRef = type === 'method' ? setSelectMethod : setSelectMaterial;
const stateRef = type === 'method' ? selectMethod : selectMaterial;
if (!e.target.checked) {
newSelected = stateRef.filter(method => method !== e.target.id);
setStateRef(newSelected);
return;
}
newSelected = [...stateRef, e.target.id];
setStateRef(newSelected);
};
먼저 props로 받은 데이터에 대한 interface를 선언하고 타입을 부여했다.
그리고 apiData에서 모든 가공 방식은 method, 재료는 material에 넣어 set으로 중복을 제거했다. apiData를 이용하여 필터링 목록을 배열로 생성한 것이다.
useEffect와 handleCheck는 밑에서 언급하겠다.
//components/Filter.tsx
<MaterialUl className={isMaterialOpen ? 'active' : ''}>
{materialSet.map((data, i) => (
<FilterInput
key={`material-${i}`}
onCheck={handleCheck}
type={'material'}
data={data}
clear={clear}
setClear={setClear}
/>
))}
</MaterialUl>
가공방식과 재료 둘다 필터링에 대한 것이므로 재료에 대해서만 설명해보겠다. 이제 return 에서 materialSet으로 map을 돌린다. 어떤 목록이 체크되었는지 알 수 있게 FilterInput 컴포넌트에 handleCheck 를 넘겨주었다.
handleCheck는 가공방식에도 넘겨주고 있는데, 체크된 것이 가공방식인지 재료인지에 따라 동작한다. 이것은 FilterInput에 넘겨주는 type으로 판별하고 있다.
강철
이 선택되었다고 가정하자. 강철은 재료이므로 setStateRef = setSelectMaterial
이 되고, stateRef = selectMaterial
이 된다. e.target.id
에는 목록 이름인 강철을 받아오는데 체크 되어있지 않았던 목록이라면 newSelected에 그대로 들어가 setStateRef(newSelected)
된다. 즉, setSelectMaterial(newSelected)이 되는 것이다.
하지만 체크되어있던 목록이라면, 다시 클릭했을 때 체크가 해제되고 selectMaterial 배열에서 없어져야 한다. 그 부분이 if문 이다. 방금 클릭된 것이 체크된 상태가 아니면 filter로 배열에서 지워준다.
이 과정으로 선택된 목록에 대한 정보를 useEffect를 이용하여 categories에 초기화 하는 것이다.
clear는 필터링을 선택했을 경우 필터링 리셋
이 나타나고, 클릭하면 categories가 초기화되고 필터링이 비워지기 위한 버튼 상태이다.
data는 목록의 이름(ex 알루미늄, 강철 등) 을 넘겨주는 것이다.
//components/Filter.tsx
const [clear, setClear] = useState(false);
const handleReset = () => {
setClear(true);
setSelectMethod([]);
setSelectMaterial([]);
};
필터링을 비워주는 리셋 버튼이다. setSelectMethod와 setSelectMaterial을 빈 배열로 초기화하고, clear도 true로 만든다. 만약에 필터링을 하나라도 클릭한다면 clear는 false가 되는데 이것은 FilterInput에서 수행된다. (그래서 clear와 setClear를 넘기고 있던것)
//components/Filter.tsx
const [isMethodOpen, setIsMethodOpen] = useState(false);
const [isMaterialOpen, setIsMaterialOpen] = useState(false);
const handleOpen = (type: string) => {
if (type === 'method') return setIsMethodOpen(!isMethodOpen);
if (type === 'material') return setIsMaterialOpen(!isMaterialOpen);
const handleBlur = (e: React.FocusEvent<HTMLUListElement>, type: string) => {
if (e.relatedTarget !== null) {
return;
}
if (type === 'method') return setIsMethodOpen(false);
if (type === 'material') return setIsMaterialOpen(false);
};
};
};
가공방식 또는 재료 버튼이 클릭되었는지를 판단하는 state를 생성했다.
셀렉트 박스가 클릭되면 정보가 type에 따라 상태가 변경된다.(열렸는지 닫혔는지)
이것은 블러처리를 하기 위한 작업이다.
//components/Filter.tsx
return (
<FilterBox>
<FilterUl onBlur={e => handleBlur(e, 'material')} tabIndex={0}>
<Select
className={selectMaterial.length > 0 ? 'active' : ''}
onClick={() => handleOpen('material')}
>
재료{selectMaterial.length > 0 && `(${selectMaterial.length})`}
</Select>
<위에서 설명한 MaterialUl 부분>
</FilterUl>
{(selectMethod.length > 0 || selectMaterial.length > 0) && (
<FilterReset onClick={handleReset}>
<img src={Refresh} alt="refreshIcon" />
필터링 리셋
</FilterReset>
)}
</FilterBox>
);
}
모든 기능들이 정상적을 동작한다. 위의 gif에서는 보이지 않지만 셀렉트 박스 바깥을 눌러도 블러처리로 인해 목록들이 닫히고 있다.
목록이 선택되었을 때, 셀렉트 박스의 색 반전은 visible을 이용하고 있다.
위에서 우리는 FilterInput에 props를 여러가지 넘겨주고 있었다. 여기에서 이 값들을 어떻게 사용했는지 확인해보자.
//FilterInput.tsx
const checkedRef = useRef(false);
useEffect(() => {
if (clear === true) {
checkedRef.current = false;
}
}, [clear]);
const handleClick = (e: React.MouseEvent<HTMLInputElement>) => {
setClear(false);
checkedRef.current = !checkedRef.current;
};
return (
<List>
<input
onChange={e => onCheck(e, type)}
type="checkbox"
id={`${data}`}
checked={checkedRef.current}
onClick={handleClick}
/>
<label htmlFor={`${data}`}>{data}</label>
</List>
);
handleClick에서 체크 박스를 클릭하면 clear를 false로 만들고, 현재 체크 상태를 checkedRef에 저장하고 있다. 이렇게 체크에 대한 상태를 관리해줘야 셀렉트 박스를 클릭해서 목록을 닫았다 열었을 때, 체크 된 상태로 있다.
그리고 clear시에 checkedRef.current = false;
을 해주어 리셋 버튼을 누르면 체크도 해제되도록 했다.
//components/EstimateList
return (
<EstimateListContainer>
{!newApiData.length && (
<NoEstimate>조건에 맞는 견적 요청이 없습니다.</NoEstimate>
)}
{newApiData.map(item => (
<EstimateItem item={item} key={item.id} isChecked={isChecked} />
))}
</EstimateListContainer>
);
필터링 된 데이터가 담겨있는 newApiData
를 map 돌리며 UI를 보여주고 있다. newApiData에 대해서 알아보자.
//components/EstimateList
const newApiData = useMemo(() => {
return getFilter(apiData, categories);
}, [apiData, categories]);
categories가 변동되면 실행하는 useMemo를 생성했다. (로직 상 apiData가 변하면 categories도 변하기 때문에 여기에 apiData를 안넣어도 될것 같았지만 그냥 넣었다.)
getFilter에서 newApiData가 만들어진다. utils의 getFilter로 따로 분리해두었다.
//utils/getFilter
const newData = [];
for (let i = 0; i < apiData.length; i++) {
const methodFiltered = apiData[i].method.filter((data: string) =>
categories.method.includes(data)
);
const materialFiltered = apiData[i].material.filter((data: string) =>
categories.material.includes(data)
);
if (
methodFiltered.length >= categories.method.length &&
materialFiltered.length >= categories.material.length
) {
newData.push(apiData[i]);
}
}
return newData;
apiData와 categories를 props로 받아온다. 첫 for문으로 apiData에 있는 데이터를 하나씩 가져온다.
이렇게 생긴 객체가 하나 들어오는 것이다. methodFiltered는 categories.method에 밀링이 있는지 확인하고 선반이 있는지 확인한다. materialFiltered은 알루미늄을 확인한다.
methodFiltered.length >= categories.method.length 이면 필터링이 정상적으로 동작되었다는 의미이다. 만약에 필터링으로 밀링을 선택했다면 2 > 1 이고, 밀링 선반을 선택했다면 2 = 2 이다. 데이터에 method로 폐기
가 있었어도 3 > 2으로, 조건을 만족한다. 필터링 원하는 밀링과 선반이 이 데이터에 있으니 말이다.
이렇게 materialFiltered.length >= categories.material.length도 적용된다면 가공방식과 재료에 대한 필터링을 모두 마친것이므로 newData.push(apiData[i])을 수행하고 리턴해주는 것이다.
이제 필터링된 카드를 UI에 출력해주게 된다.