원티드 프리온보딩 과제 1, 2일차

1일차

처음에는 처음에는 ContextAPI로 작업할 것입니다. 나중에는 Jotai 라이브러리를 직접 구현하는 튜토리얼을 활용해서 만들고 리팩토링을 진행할 것입니다. 리이브러리를 활용하지

Jotai를 DIY로 만드는 튜토리얼이 있었습니다. 라이브러리 설치가 아닌 구현으로 우회하는 것이기 때문에 창의적인 문제 해결이라 생각 됩니다. 물론 저의 추측에 불과합니다.

물론 contextAPI로 전역상태관리도 생각하고 있습니다.

git 히스토리도 잘 체크할 것 같습니다. 일단은 git-flow 전략을 활용할 것입니다.

404 페이지도 제공해야 하기 때문에 실제 제출은 1.0.0 이상이 될 것이라 예상합니다.


2일차

Router 컴포넌트로 라우팅 관심사 분리

// App.tsx
import { MouseEvent } from 'react';

/**
 * @todo 1. 라우트 컴포넌트로 관심사 분리하기
 * @todo 2. 버튼 컴포넌트 분리
 * @todo 3. 환경변수 설정으로 개발환경, 배포환경 origin 분리하기
 */

function App() {
  const handleSignUp = (e: MouseEvent<HTMLButtonElement>) => {
    e.preventDefault();
    window.location.href = '/signup';
  };
  const handleSignIn = (e: MouseEvent<HTMLButtonElement>) => {
    e.preventDefault();
    window.location.href = '/signin';
  };

  switch (window.location.href) {
    case 'http://localhost:3000/signin':
      return <div>signin</div>;
    case 'http://localhost:3000/signup':
      return <div>signup</div>;
    case 'http://localhost:3000/':
      return (
        <div className="flex h-screen items-center justify-center gap-4">
          <button
            className="box-border flex w-40 border-collapse justify-center self-center rounded border border-green-500 bg-white py-2 text-green-500"
            onClick={(e) => handleSignUp(e)}
          >
            회원가입
          </button>
          <button className="w-40 rounded bg-green-500 px-4 py-2 text-white" onClick={handleSignIn}>
            로그인
          </button>
        </div>
      );
    default:
      return <div>404page</div>;
  }
}

export default App;

하드 코딩되어 있는 http://localhost:3000이 문제였습니다. 배포하게 되면 origin이 바뀌는데 생각을 너무 짧게 하고 있었습니다.

// Router.tsx
import { Main, NotFound, Signin, Signup } from '../pages';

/**
 * @todo 1. 레이아웃을 위한 컴포넌트를 추가합니다. Nav, Header, Footer
 */
function Router() {
  switch (window.location.href) {
    case window.origin + '/signin':
      return <Signin />;
    case window.origin + '/signup':
      return <Signup />;
    case window.origin + '/':
      return <Main />;
    default:
      return <NotFound />;
  }
}

export default Router;
import Router from './router/Router';

function App() {
  return <Router />;
}

export default App;

이메일과 비밀번호 유효성 검증

import checkEmail from './checkEmail';

describe('checkEmail', () => {
  it("@가 없으면 '이메일에 @이 포함되어야 합니다.'라는 실패 이유를 문자열을 반환합니다.", () => {
    const failEmail = 'useremail.com';
    expect(checkEmail(failEmail)).toBe('이메일에 @이 포함되어야 합니다.');
  });

  it("@를 포함하면 ''처럼 비어있는 문자열을 반환합니다.", () => {
    const successEmail = 'user@email.com';
    expect(checkEmail(successEmail)).toBe('');
  });
});
import checkPassword from './checkPassword';

describe('checkPassword', () => {
  it("8자리 미만이면 '비밀번호는 8자리 이상이어야 합니다.'라는 실패 이유를 문자열로 반환합니다.", () => {
    const failPassword = '1234567';
    expect(checkPassword(failPassword)).toBe('비밀번호는 8자리 이상이어야 합니다.');
  });

  it("8자리 이상이면 ''처럼 비어있는 문자열로 반환합니다.", () => {
    const successPassword = '12345678';
    expect(checkPassword(successPassword)).toBe('');
  });
});
/**
 * @param {string} str email인지 검증할 문자열을 대입합니다.
 * @returns {string} 실패하는 이유를 설명하는 문자열을 반환합니다.
 * switch 문으로 작성하면 검증 조건추가를 확장하기 쉽습니다.
 */
function checkEmail(str: string) {
  const regexEmail = /@/;
  switch (false) {
    case regexEmail.test(str):
      return '이메일에 @이 포함되어야 합니다.';
    default:
      return '';
  }
}

export default checkEmail;
/**
 * @param {string} str password인지 검증할 문자열을 대입합니다.
 * @returns {string} 실패하는 이유를 설명하는 문자열을 반환합니다.
 * switch 문으로 작성하면 검증 조건추가를 확장하기 쉽습니다.
 */
function checkPassword(str: string) {
  const regexPassword = /^.{8,}$/;
  switch (false) {
    case regexPassword.test(str):
      return '비밀번호는 8자리 이상이어야 합니다.';
    default:
      return '';
  }
}

export default checkPassword;

횡단 관심사

CustomButton Switch-Case 리팩토링

interface CustomButtonProps {
  text: string;
  hierarchy: 'primary' | 'secondary';
  href?: string;
  onClick?: () => void;
}

/**
 * @param {CustomButtonProps} Props href가 있으면 onClick을 사용하지 않습니다. onClick이 있으면 href를 사용하지 않습니다.
 * @returns {HTMLAnchorElement | HTMLButtonElement}
 * <CustomButton text={buttonText} hierarchy="primary" onClick={() => {}} />
 * <CustomButton text={buttonText} hierarchy="primary" href="/" />
 * @see https://www.builder.io/blog/buttons
 * @todo 1. href와 onClick이 호출할 때 상호배타적이도록 타입을 지정합니다.
 */

function CustomButton({ text, href, hierarchy, onClick }: CustomButtonProps) {
  let styling = '';
  switch (hierarchy) {
    case 'primary':
      styling = 'w-40 rounded bg-green-500 px-4 py-2 text-white flex justify-center';
      break;
    case 'secondary':
      styling =
        'box-border flex w-40 border-collapse justify-center self-center rounded border border-green-500 bg-white py-2 text-green-500';
      break;
    default:
      styling = 'w-40 rounded bg-green-500 px-4 py-2 text-white';
      break;
  }

  if (href) {
    return (
      <a className={styling} href={href}>
        {text}
      </a>
    );
  }

  return (
    <button className={styling} type="button" onClick={onClick}>
      {text}
    </button>
  );
}

export default CustomButton;

원래 코드였습니다. 하지만 data-testid를 넣어야 할 것을 잊었습니다. 그리고 비활성화도 잊고 있었습니다. 이런 문제 때문에 일단은 switch-case 문으로 작성했었습니다. 커밋도 안 올렸습니다. 깃헙 이슈에 보존만 했습니다.

interface CustomButtonProps {
  text: string;
  hierarchy: 'primary' | 'secondary';
  href?: string;
  onClick?: () => void;
  testId?: string;
  disabled?: boolean;
}

/**
 * @param {CustomButtonProps} Props href가 있으면 onClick을 사용하지 않습니다. onClick이 있으면 href를 사용하지 않습니다.
 * @returns {HTMLAnchorElement | HTMLButtonElement}
 * <CustomButton text={buttonText} hierarchy="primary" onClick={() => {}} />
 * <CustomButton text={buttonText} hierarchy="primary" href="/" />
 * @see https://www.builder.io/blog/buttons
 * @todo 1. href와 onClick이 호출할 때 상호배타적이도록 타입을 지정합니다.
 */

function CustomButton({ text, href, hierarchy, onClick, testId, disabled }: CustomButtonProps) {
  let styling = '';
  switch (hierarchy) {
    case 'primary':
      styling =
        'w-40 rounded bg-green-500 px-4 py-2 text-white flex justify-center hover:bg-green-400 focus:bg-green-600';
      break;
    case 'secondary':
      styling =
        'box-border flex w-40 border-collapse justify-center self-center rounded border border-green-500 bg-white py-2 text-green-500 hover:bg-green-50 focus:bg-green-100';
      break;

    default:
      styling = 'w-40 rounded bg-green-500 px-4 py-2 text-white';
      break;
  }

  switch (true) {
    // <CustomButton text={buttonText} hierarchy="primary" href="/" />
    case !!href && !!testId && !disabled:
      return (
        <a className={styling} href={href} data-testid={testId}>
          {text}
        </a>
      );

    // <CustomButton text={buttonText} hierarchy="primary" href="/" disabled/>
    case !!href && !!testId && disabled:
      return (
        <a className={styling + 'pointer-events-none cursor-default'} href={href} data-testid={testId}>
          {text}
        </a>
      );

    case !!href && !testId && !disabled:
      return (
        <a className={styling} href={href}>
          {text}
        </a>
      );

    case !!href && !testId && disabled:
      return (
        <a className={styling + 'pointer-events-none cursor-default'} href={href}>
          {text}
        </a>
      );

    case !href && !!testId && !disabled:
      return (
        <button className={styling} type="button" onClick={onClick} data-testid={testId}>
          {text}
        </button>
      );

    case !href && !!testId && disabled:
      return (
        <button className={styling} type="button" onClick={onClick} data-testid={testId}>
          {text}
        </button>
      );

    case !href && !testId && !disabled:
      return (
        <button className={styling} type="button" onClick={onClick}>
          {text}
        </button>
      );

    case !href && !testId && disabled:
      return (
        <button className={styling} type="button" onClick={onClick} disabled>
          {text}
        </button>
      );

    default:
      return (
        <button className={styling} type="button" onClick={onClick}>
          {text}
        </button>
      );
  }
}

export default CustomButton;

interface LinkProps {
  href: string;
  text: string;
  styling: string;
  testId?: string;
  disabled?: boolean;
}

function Link({ href, testId, text, styling, disabled }: LinkProps) {
  return (
    <a className={styling + disabled && 'pointer-events-none cursor-default'} href={href} data-testid={testId}>
      {text}
    </a>
  );
}

interface ButtonProps {
  text: string;
  onClick: () => void;
  styling: string;
  testId?: string;
  disabled?: boolean;
}

function Button({ text, onClick, styling, testId, disabled }: ButtonProps) {
  return (
    <button className={styling} onClick={onClick} data-testid={testId}>
      {text}
    </button>
  );
}

이렇게 드러운 코드 고치는 방법이 있었습니다. 조건부 props도 존재했습니다. 리액트를 꽤 오랫동안 다루었지만 오늘 처음봤습니다. 아니면 초반에 보고 쓸 일이 많지 않아서 잊고 있던 것일수도 있습니다.

interface CustomButtonProps {
  text: string;
  hierarchy: 'primary' | 'secondary';
  href?: string;
  onClick?: () => void;
  testId?: string;
  disabled?: boolean;
}

/**
 * @param {CustomButtonProps} Props href가 있으면 onClick을 사용하지 않습니다. onClick이 있으면 href를 사용하지 않습니다.
 * @returns {HTMLAnchorElement | HTMLButtonElement}
 * <CustomButton text={buttonText} hierarchy="primary" onClick={() => {}} />
 * <CustomButton text={buttonText} hierarchy="secondary" href="/" />
 * @see https://www.builder.io/blog/buttons
 * @todo 1. href와 onClick이 호출할 때 상호배타적이도록 타입을 지정합니다.
 */

function CustomButton({ text, href, hierarchy, onClick, testId, disabled }: CustomButtonProps) {
  let styling = '';
  switch (hierarchy) {
    case 'primary':
      styling = !disabled
        ? 'w-40 rounded bg-green-500 px-4 py-2 text-white flex justify-center hover:bg-green-400 active:bg-green-600'
        : 'pointer-events-none flex w-40 cursor-default justify-center rounded bg-gray-700 px-4 py-2 text-gray-400';

      break;
    case 'secondary':
      styling = !disabled
        ? 'box-border flex w-40 border-collapse justify-center self-center rounded border border-green-500 bg-white py-2 text-green-500 hover:bg-green-50 active:bg-green-100'
        : 'pointer-events-none box-border flex w-40 border-collapse cursor-default justify-center self-center rounded border border-gray-700  bg-white py-2  text-gray-700';
      break;

    default:
      styling = 'w-40 rounded bg-green-500 px-4 py-2 text-white';
      break;
  }

  if (href) {
    return (
      <a className={styling} href={href} {...(testId && { 'data-testid': testId })}>
        {text}
      </a>
    );
  }

  return (
    <button
      className={styling}
      type="button"
      onClick={onClick}
      {...(testId && { 'data-testid': testId })}
      {...(disabled && { disabled })}
    >
      {text}
    </button>
  );
}

export default CustomButton;

{...(props 이름) && {"(속성 이름)": (속성 값)}} 이렇게 작성해서 넣으면 조건부로 props를 넣을 수 있습니다.

custom button 테스트 코드 작성

import { render, screen } from '@testing-library/react';
import CustomButton from './CustomButton';

describe('CustomButton', () => {
  it('부여한 속성의 값이 화면에 보여야 합니다.', () => {
    const buttonText = '회원가입';
    render(<CustomButton text={buttonText} hierarchy="primary" onClick={() => {}} />);
    const textElement = screen.getByText(buttonText);
    expect(textElement).toBeInTheDocument();
  });

  it('href 속성을 부여하지 않으면 button을 해야 합니다.', () => {
    render(<CustomButton text="회원가입" hierarchy="primary" onClick={() => {}} />);
    const buttonElement = screen.getByRole('button');
    expect(buttonElement).toBeInTheDocument();
  });

  it('href 속성을 부여하면 a태그 역할을 해야 합니다.', () => {
    render(<CustomButton text="회원가입" hierarchy="primary" href="/" />);
    const anchorElement = screen.getByRole('link');
    expect(anchorElement).toBeInTheDocument();
  });
});