인프런 커뮤니티 질문&답변

이경석님의 프로필 이미지
이경석

작성한 질문수

[코드캠프] 부트캠프에서 만든 고농축 프론트엔드 코스

포트폴리오 리뷰 - pagination 및 inputs 리팩토링

수정 페이지 마운트 직후, 온체인지 핸들러가 인풋 값을 ""으로 인식하여, 인풋 값를 지웠을 경우, 온체인지 핸들러가 값의 변동을 인식하지 못하는 문제가 있습니다.

해결된 질문

작성

·

352

0

안녕하세요 강의 잘 보고있습니다.

수정 페이지 마운트 직후, 온체인지 핸들러가 인풋 값을 ""으로 인식하고, 인풋 값를 지웠을 경우, 온체인지 핸들러가 값의 변동을 인식하지 못하는 문제가 있습니다.

즉, 수정 페이지의 각 인풋 디폴트 값들에 데이터가 불러와진 후, 값을 입력하는 것이 아니라 삭제할 경우에 값의 변동을 온체인지 핸들러가 인식하지 못하여 발생합니다.

따라서, 인풋의 온체인지 핸들러를 통해 인풋 값들이 비어있는지 확인하는 유효성 검사에 작은 오류가 있습니다.

 

  1. 수정페이지 마운트 직후 데이터를 불러온 뒤, 값이 인풋에 입력된 후, 온체인지핸들러가 변동을 인식하게 할 수 있을까요?

  2. 혹시 비슷한 인풋 값들을 객체에 묶어 저장하는 방식을 지양해야 할까요?

 

 

import { type ChangeEvent, useState, useRef, useEffect } from 'react';
import { useRouter } from 'next/router';
import BoardWrite_presenter from './BoardWrite_presenter';
import { CREATE_BOARD, UPDATE_BOARD, UPLOAD_FILE } from './BoardWrite_queries';
import { useMutation, useQuery } from '@apollo/client';
import {
	type IBoardWrite_container_Props,
	type IUpdatedVariables,
} from './BoardWrite_types';
import { type Address } from 'react-daum-postcode/lib/loadPostcode';

import { checkFileValidation } from '../../commons/checkFileValidation';
import { Modal, Spin } from 'antd';

import type { RcFile } from 'antd/es/upload';
import type { UploadFile } from 'antd/es/upload/interface';
import { getBase64 } from '@/src/commons/utils/utils';
import { FETCH_BOARD } from '../detail/BoardDetail_queries';
import { type IQuery } from '@/src/commons/types/generated/types';
import { Loading3QuartersOutlined } from '@ant-design/icons';

export default function BoardWrite_container(
	props: IBoardWrite_container_Props
) {
	const router = useRouter();
	const boardId = router.query.boardId;


	if (props.isEditing && !boardId) {
		return (
			<Spin indicator={<Loading3QuartersOutlined spin />} />
		)
	}

	const { data } = useQuery<Pick<IQuery, 'fetchBoard'>>(FETCH_BOARD, {
		variables: {
			boardId,
		},
	});

	const fetchBoard = data?.fetchBoard;

	if (props.isEditing && !boardId && !fetchBoard) {
		return (
			<Spin indicator={<Loading3QuartersOutlined spin />} />
		)
	}

	const [createBoard] = useMutation(CREATE_BOARD);
	const [updateBoard] = useMutation(UPDATE_BOARD);

	const [images, setImages] = useState('youtube');
	const [isOpen, setIsOpen] = useState(false);

	const [writerError, setWriterError] = useState(false);
	const [passwordError, setPasswordError] = useState(false);
	const [titleError, setTitleError] = useState(false);
	const [contentsError, setContentsError] = useState(false);

	const [valid, setValid] = useState(false);

	interface ICoreInput {
		[key: string]: string
		writer: string
		password: string
		title: string
		contents: string
	}

	const [coreInput, setCoreInput] = useState<ICoreInput>({
		writer: fetchBoard?.writer ?? '',
		password: '',
		title: fetchBoard?.title ?? '',
		contents: fetchBoard?.contents ?? ''
	})

	const [coreInputErorr, setCoreInputErorr] = useState({
		writer: false,
		password: false,
		title: false,
		contents: false
	})

	useEffect(() => {
		if (fetchBoard) {
			setCoreInput({
				...coreInput,
				writer: String(fetchBoard.writer),
				title: String(fetchBoard.title),
				contents: String(fetchBoard.contents),
			})
		}
	}, [data])


	const onChangeCoreInput = (e: ChangeEvent<HTMLInputElement | HTMLTextAreaElement>) => {

		setCoreInput({ ...coreInput, [e.currentTarget.id]: e.currentTarget.value });
		setCoreInputErorr({ ...coreInputErorr, [e.currentTarget.id]: false });

		const AllInputs: string[] = [];

		for (const prop in coreInput) {
			if (prop !== e.currentTarget.id) {
				AllInputs.push(coreInput[prop])
			}
			else {
				AllInputs.push(e.currentTarget.value)
			}
		}
		console.log("AllInputs : " + AllInputs)

		if (!AllInputs.includes('' && "undefined")) {
			setValid(true);
		} else setValid(false);
	};

	const onChangeImages = (e: ChangeEvent<HTMLInputElement>) => {
		setImages(e.target.value);
	};

	const [input, setInput] = useState({
		zipcode: "",
		address: "",
		addressDetail: "",
		youtubeUrl: ""
	})

	const onChangeInput = (e: ChangeEvent<HTMLInputElement>) => {
		setInput({ ...input, [e.target.id]: e.target.value });
	};

	const onSubmit = async (e: { preventDefault: () => void }) => {
		e.preventDefault();
		if (coreInput.writer === '') {
			setWriterError(true);
		}
		if (coreInput.password === '') {
			setPasswordError(true);
		}
		if (coreInput.title === '') {
			setTitleError(true);
		}
		if (coreInput.contents === '') {
			setContentsError(true);
		}

		if (coreInput.writer && coreInput.password && coreInput.title && coreInput.contents) {
			try {
				const result = await createBoard({
					variables: {
						createBoardInput: {
							writer: coreInput.writer,
							contents: coreInput.contents,
							password: coreInput.password,
							title: coreInput.title,
							youtubeUrl: input.youtubeUrl,
							images,
							boardAddress: {
								zipcode: input.zipcode,
								address: input.address,
								addressDetail: input.addressDetail,
							},
						},
					},
				});
				void router.push(`/boards/${result.data.createBoard._id}`);
			} catch (error) {
				if (error instanceof Error) alert(error.message);
			}
		}
	};

	const onUpdate = async (e: { preventDefault: () => void }) => {
		e.preventDefault();

		const updatedVariables: IUpdatedVariables = {
			boardId,
			password: coreInput.password,
			updateBoardInput: {
				contents: coreInput.contents,
				title: coreInput.title,
				youtubeUrl: input.youtubeUrl,
				images,
				boardAddress: {
					zipcode: input.zipcode,
					address: input.address,
					addressDetail: input.addressDetail,
				},
			},
		};

		if (!coreInput.password) {
			setPasswordError(true);
		}
		if (!coreInput.title) {
			setTitleError(true);
		}
		if (!coreInput.contents) {
			setContentsError(true);
		}

		if (!coreInput.password && !coreInput.title && !coreInput.contents) {
			try {
				const result = await updateBoard({
					variables: updatedVariables,
				});
				void router.push(`/boards/${result.data.updateBoard._id}`);
			} catch (error) {
				if (error instanceof Error) alert(error.message);
			}
		}
	};

	const onToggleModal = () => {
		setIsOpen((prev) => !prev);
	};

	const handleComplete = (data: Address) => {
		let fullAddress = data.address;
		let extraAddress = '';

		if (data.addressType === 'R') {
			if (data.bname !== '') {
				extraAddress += data.bname;
			}
			if (data.buildingName !== '') {
				extraAddress +=
					extraAddress !== ''
						? `, ${data.buildingName}`
						: data.buildingName;
			}
			fullAddress += extraAddress !== '' ? ` (${extraAddress})` : '';
		}
		setInput({ ...input, address: fullAddress, zipcode: data.zonecode });
		onToggleModal();
	};

	const [uploadFile] = useMutation(UPLOAD_FILE);

	const [imgUrl, setImgUrl] = useState("")
	const fileRef = useRef<HTMLInputElement>(null)

	const onClickFile = () => {
		fileRef.current?.click()
	}

	const onChangeFile = async (e: ChangeEvent<HTMLInputElement>) => {
		const file = e.target.files?.[0];

		const isValid = checkFileValidation(file);
		if (!isValid) { return }

		if (isValid) {

			try {
				const result = await uploadFile({
					variables: {
						file
					}
				})

				setImgUrl(result.data?.uploadFile.url)

			} catch (error) {
				if (error instanceof Error) {
					Modal.error({ content: error.message })
				}
			}
		}
	}

	const [previewOpen, setPreviewOpen] = useState(false);
	const [previewImage, setPreviewImage] = useState('');
	const [previewTitle, setPreviewTitle] = useState('');
	const [fileList, setFileList] = useState<UploadFile[]>([]);

	const handleCancel = () => { setPreviewOpen(false); };

	const handlePreview = async (file: UploadFile) => {
		if (!file.url && !file.preview) {
			file.preview = await getBase64(file.originFileObj as RcFile);
		}

		setPreviewImage(file.url ?? (file.preview as string));
		setPreviewOpen(true);
		setPreviewTitle(file.url ? (file.name || file.url.substring(file.url.lastIndexOf('/') + 1)) : "");
	};

	return (
		<BoardWrite_presenter
			onChangeInput={onChangeInput}
			onChangeCoreInput={onChangeCoreInput}

			onChangeImages={onChangeImages}

			writerError={writerError}
			passwordError={passwordError}
			titleError={titleError}
			contentsError={contentsError}

			zipcode={input.zipcode}
			address={input.address}

			onSubmit={onSubmit}
			onUpdate={onUpdate}

			valid={valid}
			isEditing={props.isEditing}
			data={data}

			isOpen={isOpen}
			handleComplete={handleComplete}

			onToggleModal={onToggleModal}

			imgUrl={imgUrl}
			onClickFile={onClickFile}
			onChangeFile={onChangeFile}
			fileRef={fileRef}


			fileList={fileList}
			handlePreview={handlePreview}
			setFileList={setFileList}
			previewOpen={previewOpen}
			previewTitle={previewTitle}
			handleCancel={handleCancel}
			previewImage={previewImage}
		/>
	);
}
import { Button, Image, Modal, Upload } from 'antd';
import DaumPostcodeEmbed from 'react-daum-postcode';
import { Main, Title } from '../../../commons/styles/emotion';
import * as S from './BoardWrite_styles';
import { type IBoardWrite_presenter_Props } from './BoardWrite_types';
import type { UploadProps } from 'antd/es/upload';
import { PlusOutlined } from '@ant-design/icons';

export default function BoardWrite_presenter(
	props: IBoardWrite_presenter_Props
) {
	const valid = props.valid;
	const isEditing = props.isEditing;
	const data = props.data?.fetchBoard;

	const handleChange: UploadProps['onChange'] = ({ fileList: newFileList }) => { props.setFileList(newFileList); };

	const uploadButton = (
		<div>
			<PlusOutlined />
			<div style={{ marginTop: 8 }}>Upload</div>
		</div>
	);

	return (
		<Main>
			<S.Form>
				<Title>게시물 {isEditing ? '수정' : '등록'}</Title>
				<div className="writer">
					<S.InputWrapper>
						<label>작성자</label>
						<input
							id="writer"
							onChange={props.onChangeCoreInput}
							type="text"
							placeholder="이름을 입력해주세요."
							defaultValue={props.data?.fetchBoard.writer ?? ""}
							readOnly={!!props.data?.fetchBoard.writer}
						/>
						{props.writerError && (
							<p className="alert">이름을 입력해주세요.</p>
						)}
					</S.InputWrapper>
					<S.InputWrapper>
						<label>비밀번호</label>
						<input
							id="password"
							onChange={props.onChangeCoreInput}
							autoComplete="off"
							type="password"
							placeholder="비밀번호를 입력해주세요."
							defaultValue={``}
						/>
						{props.passwordError && (
							<p className="alert">비밀번호를 입력해주세요.</p>
						)}
					</S.InputWrapper>
				</div>

				<S.InputWrapper>
					<label>제목</label>
					<input
						id='title'
						onChange={props.onChangeCoreInput}
						type="text"
						placeholder="제목을 작성해주세요."
						defaultValue={data?.title}
					/>
					{props.titleError && (
						<p className="alert">제목을 작성해주세요.</p>
					)}
				</S.InputWrapper>

				<S.InputWrapper>
					<label>내용</label>
					<textarea
						id='contents'
						onChange={props.onChangeCoreInput}
						placeholder="내용을 작성해주세요."
						defaultValue={props.data?.fetchBoard.contents}
					/>
					{props.contentsError && (
						<p className="alert">내용을 작성해주세요.</p>
					)}
				</S.InputWrapper>

				<S.InputWrapper>
					<label>주소</label>
					<div className="zipcode">
						<input
							id="zipcode"
							onChange={props.onChangeInput}
							type="text"
							// placeholder="00000"
							readOnly
							value={
								props.address ||
								(props.data?.fetchBoard.boardAddress?.address ?? "")
							}
						/>
						<button
							onClick={(e) => {
								e.preventDefault();
								props.onToggleModal();
							}}
						>
							우편번호 검색
						</button>
						{props.isOpen && (
							<Modal
								title={'우편번호 검색'}
								open={props.isOpen}
								onOk={props.onToggleModal}
								onCancel={props.onToggleModal}
							>
								<DaumPostcodeEmbed
									onComplete={props.handleComplete}
								></DaumPostcodeEmbed>
							</Modal>
						)}
					</div>
					<input
						id='address'
						onChange={props.onChangeInput}
						className="address"
						type="text"
						readOnly
						value={props.address !== "undefined" ? props.address : ""}
					/>
					<input
						id='addressDetail'
						onChange={props.onChangeInput}
						type="text"
						defaultValue={
							data?.boardAddress?.addressDetail
								? data?.boardAddress?.addressDetail
								: ''
						}
					/>
				</S.InputWrapper>

				<S.InputWrapper>
					<label>유튜브</label>
					<input
						id='youtubeUrl'
						onChange={props.onChangeInput}
						type="text"
						placeholder="링크를 복사해주세요."
						defaultValue={
							data?.youtubeUrl ? data?.youtubeUrl : ''
						}
					/>
				</S.InputWrapper>

				<S.InputWrapper>
					<label>사진업로드</label>
					<Button onClick={props.onClickFile}>사진 업로드</Button>
					<input
						style={{ display: "none" }}
						ref={props.fileRef}
						onChange={props.onChangeFile}
						type="file"

					/>
				</S.InputWrapper>
				{props.imgUrl && <Image src={`https://storage.googleapis.com/${props.imgUrl}`}></Image>}

				<S.InputWrapper>
					<label>사진 첨부</label>
					<Upload
						// action="https://www.mocky.io/v2/5cc8019d300000980a055e76"
						listType="picture-card"
						fileList={props.fileList}
						onPreview={props.handlePreview}
						onChange={handleChange}
					>
						{props.fileList.length >= 8 ? null : uploadButton}
					</Upload>
					<Modal open={props.previewOpen} title={props.previewTitle} footer={null} onCancel={props.handleCancel}>
						<img alt={props.previewTitle} style={{ width: '100%' }} src={props.previewImage} />
					</Modal>
				</S.InputWrapper>

				<S.InputWrapper>
					<div className="radios">
						<span>
							<input
								type="radio"
								id="youtube"
								name="radios"
								value="youtube"
								defaultChecked
								onChange={props.onChangeImages}
							/>
							<label htmlFor="youtube">유튜브</label>
						</span>
						<span>
							<input
								type="radio"
								id="image"
								name="radios"
								value="image"
								onChange={props.onChangeImages}
							/>
							<label htmlFor="image">사진</label>
						</span>
					</div>
				</S.InputWrapper>
				<S.SubmitButton
					onClick={isEditing ? props.onUpdate : props.onSubmit}
					valid={valid}
					disabled={!valid}
				>
					{isEditing ? '수정하기' : '등록하기'}
				</S.SubmitButton>
			</S.Form>
		</Main>
	);
}

답변 1

0

안녕하세요 이경석님!
코드 확인 결과 현재 몇몇 input에는 defaultValue만 설정되어 있고 value에 state값을 전달하고 있지 않습니다
또한 defaultValue가 설정 되어있기에 useEffect를 통한 set은 하지 않으셔도 괜찮을것 같습니다
이후 문제가 발생한다면 다시 알려주시면 감사하겠습니다

이경석님의 프로필 이미지
이경석
질문자

구현하고 싶은것을 먼저 요약해 말씀드리겠습니다.

  • 현재는 수정 페이지에서, Submit 버튼의 isActive는 isEdit가 true일 경우 true가 됩니다. 저는 필수 input에 값이 없으면 Submit의 isActive를 false로 만들기 위하여 리팩토링을 진행중입니다.

 

답변해주신 사항은 확인하였습니다. 감사합니다. 하지만 수정페이지에서 input value에 state 값을 전달하게 되면, input value 수정이 불가능합니다. 따라서 readOnly로 사용하지 않는 input은 value 대신 defaultValue를 사용하였습니다.

 

제공해주신 학습자료 "포트폴리오 리뷰 - image upload 및 comment" 에 첨부된 day20 포트폴리오 예제를 내려받아 확인 한 결과, submit 버튼에 들어오는 isActive 값이 수정중에 항상 true으로 들어오는데, 필수 input(작성자, 제목, 비번, 내용)에 값이 없으면 버튼의 isActive를 해제하기 위하여 submit 버튼을 아래와 같이 수정하여 사용중입니다. (원래는 isActive={props.isEdit ? true : props.isActive}로 작성됨.)

          <S.SubmitButton
            onClick={props.isEdit ? props.onClickUpdate : props.onClickSubmit}
            isActive={props.isActive}  // 수정한 부분
          >
            {props.isEdit ? "수정하기" : "등록하기"}
          </S.SubmitButton>

또한 useEffect로 초기에 비어있는 필수 스테이트(작성자, 제목, 비번, 내용)를 지정하여,
onChange핸들러 내에 setIsActive를 담당하는 부분에 state값을 set하였습니다.

  // 추가한 부분.
  useEffect(() => { 
    if (props.data?.fetchBoard) {
      setWriter(String(props.data?.fetchBoard.writer))
      setTitle(String(props.data?.fetchBoard.title))
      setContents(String(props.data?.fetchBoard.contents))
    }
  }, [props.data])

 

이 문제가 발생한 이유는 바로 수정페이지 마운트시, 필수 input(작성자, 제목, 비번, 내용)에 아무것도 입력하지 않은 채로 글 제목이나 글 내용을 삭제한 시점에서 onChage 이벤트가 작동하지 않기 때문에 발생합니다. (핸들러 내에 아래처럼 콘솔로그를 삽입한 결과

console.log(event.target.value)

마운트시 defaultValue가 존재하여도, onChage 이벤트는 비어있다고 인식하고 있다 생각합니다.) 이후 (당연하게 마운트시 값이 비어있는)비밀번호를 입력하면, 이때 최초로 onChange 이벤트가 발생하지만, 제목이나 내용 input이 비어있어도 state는 useEffect로 마운트시 받아진 값을 가지고있어 isActive가 true로 바뀌어 버립니다.


초기에 내려진 인풋 값을 지웠을때에도 onChange핸들러가 인식할 수 있게 만드는것이 js 구조적으로 어렵다는 생각이 들어, 우회할 수 있는 다른 방법이 있다면 적용하겠습니다.

감사합니다.

이경석님의 프로필 이미지
이경석

작성한 질문수

질문하기