Back to Intro to Storybook
Chapters
  • 시작하기
  • 간단한 컴포넌트
  • 복합적 컴포넌트
  • 데이터
  • 화면
  • 배포하기
  • 테스트
  • 애드온(Addon)
  • 마무리
  • 기여하기

복합적 컴포넌트 조합하기

간단한 컴포넌트로 복합적 컴포넌트를 조합해보세요

지난 챕터에서 첫 번째 컴포넌트를 만들어보았습니다. 이번 챕터에서는 이전에 학습한 내용을 바탕으로 Task의 목록인 TaskList를 만들어보겠습니다. 컴포넌트를 결합하여 복잡성이 커지는 경우 어떤 일이 일어나는지 함께 살펴보겠습니다.

Tasklist

Taskbox는 핀으로 고정된 task를 일반 task 위에 배치하여 강조합니다. 따라서 일반 task와 핀으로 고정된 task에 대한 두 가지 유형의 TaskList 스토리(story)를 만들어야 합니다.

일반 task과 핀으로 고정된 task

Task 데이터는 비동기식으로 전송될 수 있기 때문에, 연결이 없는 상태를 렌더링 할 수 있도록 로딩 상태(state) 또한 필요합니다. task가 없는 경우를 위해 비어있는 상태도 필요할 것입니다.

로딩 중 task과 빈 task

설정하기

복합 컴포넌트는 기본 컴포넌트와 크게 다르지 않습니다. TaskList 컴포넌트와 그에 해당하는 스토리 파일을 만들어보겠습니다. src/components/TaskList.jsxsrc/components/TaskList.stories.jsx를 생성해 주세요.

우선 TaskList의 대략적인 구현부터 시작하겠습니다. 이전의 Task 컴포넌트를 가져온 후, 속성과 액션을 입력값으로 전달해 주세요.

Copy
src/components/TaskList.jsx
import Task from './Task';

export default function TaskList({ loading, tasks, onPinTask, onArchiveTask }) {
  const events = {
    onPinTask,
    onArchiveTask,
  };

  if (loading) {
    return <div className="list-items">loading</div>;
  }

  if (tasks.length === 0) {
    return <div className="list-items">empty</div>;
  }

  return (
    <div className="list-items">
      {tasks.map(task => (
        <Task key={task.id} task={task} {...events} />
      ))}
    </div>
  );
}

그리고, 스토리 파일 안에 TaskList의 테스트 상태값들을 만들어 보세요.

Copy
src/components/TaskList.stories.jsx
import TaskList from './TaskList';

import * as TaskStories from './Task.stories';

export default {
  component: TaskList,
  title: 'TaskList',
  decorators: [(story) => <div style={{ margin: '3rem' }}>{story()}</div>],
  tags: ['autodocs'],
  args: {
    ...TaskStories.ActionsData,
  },
};

export const Default = {
  args: {
    // Shaping the stories through args composition.
    // The data was inherited from the Default story in Task.stories.jsx.
    tasks: [
      { ...TaskStories.Default.args.task, id: '1', title: 'Task 1' },
      { ...TaskStories.Default.args.task, id: '2', title: 'Task 2' },
      { ...TaskStories.Default.args.task, id: '3', title: 'Task 3' },
      { ...TaskStories.Default.args.task, id: '4', title: 'Task 4' },
      { ...TaskStories.Default.args.task, id: '5', title: 'Task 5' },
      { ...TaskStories.Default.args.task, id: '6', title: 'Task 6' },
    ],
  },
};

export const WithPinnedTasks = {
  args: {
    tasks: [
      ...Default.args.tasks.slice(0, 5),
      { id: '6', title: 'Task 6 (pinned)', state: 'TASK_PINNED' },
    ],
  },
};

export const Loading = {
  args: {
    tasks: [],
    loading: true,
  },
};

export const Empty = {
  args: {
    // Shaping the stories through args composition.
    // Inherited data coming from the Loading story.
    ...Loading.args,
    loading: false,
  },
};

💡 데코레이터(Decorators)는 스토리에 임의의 래퍼(wrapper)를 제공하는 하나의 방법입니다. 이 예시에서 우리는 데코레이터 key를 사용하여 기본 내보내기에서 렌더링 된 컴포넌트에 padding을 추가합니다. 또한 데코레이터는 “providers”(React context를 설정하는 라이브러리 컴포넌트)에서 스토리를 감싸 줄 때 사용될 수 있습니다.

TaskStories를 가져옴으로써 최소한의 노력으로 스토리 속의 인수(arguments, 줄임말로 args)를 구성(compose)할 수 있었습니다.

이를 통해 두 컴포넌트가 받을 수 있는 데이터와 액션(mocked callbacks)이 모두 보존됩니다.

이제 스토리북에서 새로운 TaskList 스토리를 확인해보겠습니다.

상태(States) 구현하기

우리의 컴포넌트는 아직 기본 뼈대만을 갖추었지만, 앞으로 작업하게 될 스토리에 대한 아이디어를 얻었습니다. .list-items 래퍼(wrapper)가 지나치게 단순하다고 생각할 수도 있습니다. 맞습니다! 대부분의 경우에 우리는 단지 래퍼(wrapper)를 추가하기 위해서 새로운 컴포넌트를 만들지 않습니다. 하지만 TaskList 컴포넌트의 진정한 복잡성withPinnedTasks, loading 그리고 empty에서 드러날 것입니다.

Copy
src/components/TaskList.jsx
import Task from './Task';

export default function TaskList({ loading, tasks, onPinTask, onArchiveTask }) {
  const events = {
    onPinTask,
    onArchiveTask,
  };
  const LoadingRow = (
    <div className="loading-item">
      <span className="glow-checkbox" />
      <span className="glow-text">
        <span>Loading</span> <span>cool</span> <span>state</span>
      </span>
    </div>
  );
  if (loading) {
    return (
      <div className="list-items" data-testid="loading" key={"loading"}>
        {LoadingRow}
        {LoadingRow}
        {LoadingRow}
        {LoadingRow}
        {LoadingRow}
        {LoadingRow}
      </div>
    );
  }
  if (tasks.length === 0) {
    return (
      <div className="list-items" key={"empty"} data-testid="empty">
        <div className="wrapper-message">
          <span className="icon-check" />
          <p className="title-message">You have no tasks</p>
          <p className="subtitle-message">Sit back and relax</p>
        </div>
      </div>
    );
  }

  const tasksInOrder = [
    ...tasks.filter((t) => t.state === 'TASK_PINNED'),
    ...tasks.filter((t) => t.state !== 'TASK_PINNED'),
  ];
  return (
    <div className="list-items">
      {tasksInOrder.map((task) => (
        <Task key={task.id} task={task} {...events} />
      ))}
    </div>
  );
}

위에서 추가된 마크업으로 다음과 같은 UI가 나타납니다:

목록에서 핀으로 고정된 task의 위치를 확인해 주세요. 핀으로 고정된 task를 사용자를 위해 목록의 맨 위에 위치하도록 우선순위를 부여합니다.

데이터 요구사항 및 props

컴포넌트가 커질수록 입력에 필요한 데이터 요구사항도 함께 커집니다. TaskList에서 prop의 요구사항을 정의해봅시다. Task는 하위 컴포넌트이기 때문에 렌더링에 필요한 적합한 형태의 데이터를 제공해야 합니다. 시간 절약을 위해서 Task에서 사용한 propTypes를 재사용하겠습니다.

Copy
src/components/TaskList.jsx
+ import PropTypes from 'prop-types';

import Task from './Task';

export default function TaskList({ loading, tasks, onPinTask, onArchiveTask }) {
  const events = {
    onPinTask,
    onArchiveTask,
  };
  const LoadingRow = (
    <div className="loading-item">
      <span className="glow-checkbox" />
      <span className="glow-text">
        <span>Loading</span> <span>cool</span> <span>state</span>
      </span>
    </div>
  );
  if (loading) {
    return (
      <div className="list-items" data-testid="loading" key={"loading"}>
        {LoadingRow}
        {LoadingRow}
        {LoadingRow}
        {LoadingRow}
        {LoadingRow}
        {LoadingRow}
      </div>
    );
  }
  if (tasks.length === 0) {
    return (
      <div className="list-items" key={"empty"} data-testid="empty">
        <div className="wrapper-message">
          <span className="icon-check" />
          <p className="title-message">You have no tasks</p>
          <p className="subtitle-message">Sit back and relax</p>
        </div>
      </div>
    );
  }

  const tasksInOrder = [
    ...tasks.filter((t) => t.state === 'TASK_PINNED'),
    ...tasks.filter((t) => t.state !== 'TASK_PINNED'),
  ];
  return (
    <div className="list-items">
      {tasksInOrder.map((task) => (
        <Task key={task.id} task={task} {...events} />
      ))}
    </div>
  );
}

+ TaskList.propTypes = {
+  /** Checks if it's in loading state */
+  loading: PropTypes.bool,
+  /** The list of tasks */
+  tasks: PropTypes.arrayOf(Task.propTypes.task).isRequired,
+  /** Event to change the task to pinned */
+  onPinTask: PropTypes.func,
+  /** Event to change the task to archived */
+  onArchiveTask: PropTypes.func,
+ };
+ TaskList.defaultProps = {
+  loading: false,
+ };
💡 깃(Git)에 변경된 사항을 commit하는 것을 잊지 마세요!
Keep your code in sync with this chapter. View 429780a on GitHub.
Is this free guide helping you? Tweet to give kudos and help other devs find it.
Next Chapter
데이터
UI 컴포넌트에 데이터를 연결하는 방법을 배워보세요
✍️ Edit on GitHub – PRs welcome!
Join the community
6,652 developers and counting
WhyWhy StorybookComponent-driven UI
Open source software
Storybook

Maintained by
Chromatic
Special thanks to Netlify and CircleCI