From c87347c46d980e068f5823cd71e8d4788d430502 Mon Sep 17 00:00:00 2001 From: jonniebigodes Date: Mon, 17 Feb 2025 16:19:55 +0000 Subject: [PATCH 1/2] Docs: Intro to Storybook (React) - Move to TypeScript --- .../react/en/composite-component.md | 151 ++++++--------- content/intro-to-storybook/react/en/data.md | 106 ++++++---- content/intro-to-storybook/react/en/deploy.md | 2 +- .../react/en/get-started.md | 2 +- content/intro-to-storybook/react/en/screen.md | 183 ++++++++++-------- .../react/en/simple-component.md | 179 +++++++++++------ content/intro-to-storybook/react/en/test.md | 30 ++- .../react/en/using-addons.md | 37 ++-- 8 files changed, 395 insertions(+), 295 deletions(-) diff --git a/content/intro-to-storybook/react/en/composite-component.md b/content/intro-to-storybook/react/en/composite-component.md index 81596d5a4..2368c7a96 100644 --- a/content/intro-to-storybook/react/en/composite-component.md +++ b/content/intro-to-storybook/react/en/composite-component.md @@ -2,7 +2,7 @@ title: 'Assemble a composite component' tocTitle: 'Composite component' description: 'Assemble a composite component out of simpler components' -commit: '429780a' +commit: 'cfa25b6' --- Last chapter, we built our first component; this chapter extends what we learned to make TaskList, a list of Tasks. Let’s combine components together and see what happens when we introduce more complexity. @@ -19,14 +19,32 @@ Since `Task` data can be sent asynchronously, we **also** need a loading state t ## Get set up -A composite component isn’t much different from the basic components it contains. Create a `TaskList` component and an accompanying story file: `src/components/TaskList.jsx` and `src/components/TaskList.stories.jsx`. +A composite component isn’t much different from the basic components it contains. Create a `TaskList` component and an accompanying story file: `src/components/TaskList.tsx` and `src/components/TaskList.stories.tsx`. Start with a rough implementation of the `TaskList`. You’ll need to import the `Task` component from earlier and pass in the attributes and actions as inputs. -```jsx:title=src/components/TaskList.jsx +```tsx:title=src/components/TaskList.tsx +import type { TaskData } from '../types'; + import Task from './Task'; -export default function TaskList({ loading, tasks, onPinTask, onArchiveTask }) { +type TaskListProps = { + /** Checks if it's in loading state */ + loading?: boolean; + /** The list of tasks */ + tasks: TaskData[]; + /** Event to change the task to pinned */ + onPinTask: (id: string) => void; + /** Event to change the task to archived */ + onArchiveTask: (id: string) => void; +}; + +export default function TaskList({ + loading = false, + tasks, + onPinTask, + onArchiveTask, +}: TaskListProps) { const events = { onPinTask, onArchiveTask, @@ -42,7 +60,7 @@ export default function TaskList({ loading, tasks, onPinTask, onArchiveTask }) { return (
- {tasks.map(task => ( + {tasks.map((task) => ( ))}
@@ -52,25 +70,30 @@ export default function TaskList({ loading, tasks, onPinTask, onArchiveTask }) { Next, create `Tasklist`’s test states in the story file. -```jsx:title=src/components/TaskList.stories.jsx +```tsx:title=src/components/TaskList.stories.tsx +import type { Meta, StoryObj } from '@storybook/react'; + import TaskList from './TaskList'; import * as TaskStories from './Task.stories'; -export default { +const meta = { component: TaskList, title: 'TaskList', decorators: [(story) =>
{story()}
], - tags: ['autodocs'], + tags: ["autodocs"], args: { ...TaskStories.ActionsData, }, -}; +} satisfies Meta; + +export default meta; +type Story = StoryObj; -export const Default = { +export const Default: Story = { args: { // Shaping the stories through args composition. - // The data was inherited from the Default story in Task.stories.jsx. + // The data was inherited from the Default story in Task.stories.tsx. tasks: [ { ...TaskStories.Default.args.task, id: '1', title: 'Task 1' }, { ...TaskStories.Default.args.task, id: '2', title: 'Task 2' }, @@ -82,7 +105,7 @@ export const Default = { }, }; -export const WithPinnedTasks = { +export const WithPinnedTasks: Story = { args: { tasks: [ ...Default.args.tasks.slice(0, 5), @@ -91,14 +114,14 @@ export const WithPinnedTasks = { }, }; -export const Loading = { +export const Loading: Story = { args: { tasks: [], loading: true, }, }; -export const Empty = { +export const Empty: Story = { args: { // Shaping the stories through args composition. // Inherited data coming from the Loading story. @@ -129,10 +152,28 @@ Now check Storybook for the new `TaskList` stories. Our component is still rough, but now we have an idea of the stories to work toward. You might be thinking that the `.list-items` wrapper is overly simplistic. You're right – in most cases, we wouldn’t create a new component just to add a wrapper. But the **real complexity** of the `TaskList` component is revealed in the edge cases `withPinnedTasks`, `loading`, and `empty`. -```jsx:title=src/components/TaskList.jsx +```tsx:title=src/components/TaskList.tsx +import type { TaskData } from '../types'; + import Task from './Task'; -export default function TaskList({ loading, tasks, onPinTask, onArchiveTask }) { +type TaskListProps = { + /** Checks if it's in loading state */ + loading?: boolean; + /** The list of tasks */ + tasks: TaskData[]; + /** Event to change the task to pinned */ + onPinTask: (id: string) => void; + /** Event to change the task to archived */ + onArchiveTask: (id: string) => void; +}; + +export default function TaskList({ + loading = false, + tasks, + onPinTask, + onArchiveTask, +}: TaskListProps) { const events = { onPinTask, onArchiveTask, @@ -170,8 +211,8 @@ export default function TaskList({ loading, tasks, onPinTask, onArchiveTask }) { } const tasksInOrder = [ - ...tasks.filter((t) => t.state === 'TASK_PINNED'), - ...tasks.filter((t) => t.state !== 'TASK_PINNED'), + ...tasks.filter((t) => t.state === "TASK_PINNED"), + ...tasks.filter((t) => t.state !== "TASK_PINNED"), ]; return (
@@ -194,80 +235,6 @@ The added markup results in the following UI: Note the position of the pinned item in the list. We want the pinned item to render at the top of the list to make it a priority for our users. -## Data requirements and props - -As the component grows, so do input requirements. Define the prop requirements of `TaskList`. Because `Task` is a child component, make sure to provide data in the right shape to render it. To save time and headache, reuse the `propTypes` you defined in `Task` earlier. - -```diff:title=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 = ( -
- - - Loading cool state - -
- ); - if (loading) { - return ( -
- {LoadingRow} - {LoadingRow} - {LoadingRow} - {LoadingRow} - {LoadingRow} - {LoadingRow} -
- ); - } - if (tasks.length === 0) { - return ( -
-
- -

You have no tasks

-

Sit back and relax

-
-
- ); - } - - const tasksInOrder = [ - ...tasks.filter((t) => t.state === 'TASK_PINNED'), - ...tasks.filter((t) => t.state !== 'TASK_PINNED'), - ]; - return ( -
- {tasksInOrder.map((task) => ( - - ))} -
- ); -} - -+ 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, -+ }; -``` -
💡 Don't forget to commit your changes with git!
diff --git a/content/intro-to-storybook/react/en/data.md b/content/intro-to-storybook/react/en/data.md index 64cdb02aa..af7b77efa 100644 --- a/content/intro-to-storybook/react/en/data.md +++ b/content/intro-to-storybook/react/en/data.md @@ -2,7 +2,7 @@ title: 'Wire in data' tocTitle: 'Data' description: 'Learn how to wire in data to your UI component' -commit: 'c70ec15' +commit: 'f9eaeef' --- So far, we have created isolated stateless components-–great for Storybook, but ultimately not helpful until we give them some data in our app. @@ -21,25 +21,34 @@ Add the necessary dependencies to your project with: yarn add @reduxjs/toolkit react-redux ``` -First, we’ll construct a simple Redux store that responds to actions that change the task's state in a file called `store.js` in the `src/lib` directory (intentionally kept simple): +First, we’ll construct a simple Redux store that responds to actions that change the task's state in a file called `store.ts` in the `src/lib` directory (intentionally kept simple): -```js:title=src/lib/store.js +```ts:title=src/lib/store.ts /* A simple redux store/actions/reducer implementation. * A true app would be more complex and separated into different files. */ -import { configureStore, createSlice } from '@reduxjs/toolkit'; +import type { TaskData } from '../types'; + +import { configureStore, createSlice, PayloadAction } from '@reduxjs/toolkit'; + +interface TaskBoxState { + tasks: TaskData[]; + status: 'idle' | 'loading' | 'failed'; + error: string | null; +} /* * The initial state of our store when the app loads. * Usually, you would fetch this from a server. Let's not worry about that now */ -const defaultTasks = [ +const defaultTasks: TaskData[] = [ { id: '1', title: 'Something', state: 'TASK_INBOX' }, { id: '2', title: 'Something more', state: 'TASK_INBOX' }, { id: '3', title: 'Something else', state: 'TASK_INBOX' }, { id: '4', title: 'Something again', state: 'TASK_INBOX' }, ]; -const TaskBoxData = { + +const TaskBoxData: TaskBoxState = { tasks: defaultTasks, status: 'idle', error: null, @@ -54,11 +63,13 @@ const TasksSlice = createSlice({ name: 'taskbox', initialState: TaskBoxData, reducers: { - updateTaskState: (state, action) => { - const { id, newTaskState } = action.payload; - const task = state.tasks.findIndex((task) => task.id === id); - if (task >= 0) { - state.tasks[task].state = newTaskState; + updateTaskState: ( + state, + action: PayloadAction<{ id: string; newTaskState: TaskData['state'] }> + ) => { + const task = state.tasks.find((task) => task.id === action.payload.id); + if (task) { + task.state = action.payload.newTaskState; } }, }, @@ -72,46 +83,48 @@ export const { updateTaskState } = TasksSlice.actions; * Read more about Redux's configureStore in the docs: * https://redux-toolkit.js.org/api/configureStore */ + const store = configureStore({ reducer: { taskbox: TasksSlice.reducer, }, }); +// Define RootState and AppDispatch types +export type RootState = ReturnType; +export type AppDispatch = typeof store.dispatch; + export default store; ``` Then we’ll update our `TaskList` component to connect to the Redux store and render the tasks we are interested in: -```jsx:title=src/components/TaskList.jsx +```tsx:title=src/components/TaskList.tsx import Task from './Task'; import { useDispatch, useSelector } from 'react-redux'; -import { updateTaskState } from '../lib/store'; +import { updateTaskState, RootState, AppDispatch } from '../lib/store'; export default function TaskList() { // We're retrieving our state from the store - const tasks = useSelector((state) => { + const tasks = useSelector((state: RootState) => { const tasksInOrder = [ ...state.taskbox.tasks.filter((t) => t.state === 'TASK_PINNED'), ...state.taskbox.tasks.filter((t) => t.state !== 'TASK_PINNED'), ]; const filteredTasks = tasksInOrder.filter( - (t) => t.state === 'TASK_INBOX' || t.state === 'TASK_PINNED' + (t) => t.state === "TASK_INBOX" || t.state === 'TASK_PINNED' ); return filteredTasks; }); - - const { status } = useSelector((state) => state.taskbox); - - const dispatch = useDispatch(); - - const pinTask = (value) => { + const { status } = useSelector((state: RootState) => state.taskbox); + const dispatch = useDispatch(); + const pinTask = (value: string) => { // We're dispatching the Pinned event back to our store dispatch(updateTaskState({ id: value, newTaskState: 'TASK_PINNED' })); }; - const archiveTask = (value) => { + const archiveTask = (value: string) => { // We're dispatching the Archive event back to our store dispatch(updateTaskState({ id: value, newTaskState: 'TASK_ARCHIVED' })); }; @@ -123,9 +136,9 @@ export default function TaskList() {
); - if (status === 'loading') { + if (status === "loading") { return ( -
+
{LoadingRow} {LoadingRow} {LoadingRow} @@ -137,7 +150,7 @@ export default function TaskList() { } if (tasks.length === 0) { return ( -
+

You have no tasks

@@ -148,13 +161,13 @@ export default function TaskList() { } return ( -
+
{tasks.map((task) => ( pinTask(task)} - onArchiveTask={(task) => archiveTask(task)} + onPinTask={pinTask} + onArchiveTask={archiveTask} /> ))}
@@ -162,7 +175,7 @@ export default function TaskList() { } ``` -Now that we have some actual data populating our component, obtained from the Redux store, we could have wired it to `src/App.js` and render the component there. But for now, let's hold off doing that and continue on our component-driven journey. +Now that we have some actual data populating our component, obtained from the Redux store, we could have wired it to `src/App.tsx` and render the component there. But for now, let's hold off doing that and continue on our component-driven journey. Don't worry about it. We'll take care of it in the next chapter. @@ -174,7 +187,11 @@ Our Storybook stories have stopped working with this change because our `Tasklis We can use various approaches to solve this issue. Still, as our app is pretty straightforward, we can rely on a decorator, similar to what we did in the [previous chapter](/intro-to-storybook/react/en/composite-component) and provide a mocked store-- in our Storybook stories: -```jsx:title=src/components/TaskList.stories.jsx +```tsx:title=src/components/TaskList.stories.tsx +import type { Meta, StoryObj } from '@storybook/react'; + +import type { TaskData } from '../types'; + import TaskList from './TaskList'; import * as TaskStories from './Task.stories'; @@ -192,18 +209,24 @@ export const MockedState = { { ...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' }, - ], + ] as TaskData[], status: 'idle', error: null, }; // A super-simple mock of a redux store -const Mockstore = ({ taskboxState, children }) => ( +const Mockstore = ({ + taskboxState, + children, +}: { + taskboxState: typeof MockedState; + children: React.ReactNode; +}) => ( { @@ -222,24 +245,27 @@ const Mockstore = ({ taskboxState, children }) => ( ); -export default { +const meta = { component: TaskList, title: 'TaskList', decorators: [(story) =>
{story()}
], tags: ['autodocs'], excludeStories: /.*MockedState$/, -}; +} satisfies Meta; + +export default meta; +type Story = StoryObj; -export const Default = { +export const Default: Story = { decorators: [ (story) => {story()}, ], }; -export const WithPinnedTasks = { +export const WithPinnedTasks: Story = { decorators: [ (story) => { - const pinnedtasks = [ + const pinnedtasks: TaskData[] = [ ...MockedState.tasks.slice(0, 5), { id: '6', title: 'Task 6 (pinned)', state: 'TASK_PINNED' }, ]; @@ -258,7 +284,7 @@ export const WithPinnedTasks = { ], }; -export const Loading = { +export const Loading: Story = { decorators: [ (story) => ( ( { -+ const response = await fetch( -+ 'https://jsonplaceholder.typicode.com/todos?userId=1' -+ ); -+ const data = await response.json(); -+ const result = data.map((task) => ({ -+ id: `${task.id}`, -+ title: task.title, -+ state: task.completed ? 'TASK_ARCHIVED' : 'TASK_INBOX', -+ })); -+ return result; -+ }); +export const fetchTasks = createAsyncThunk('taskbox/fetchTasks', async () => { + const response = await fetch( + 'https://jsonplaceholder.typicode.com/todos?userId=1' + ); + const data = await response.json(); + const result = data.map( + (task: { id: number; title: string; completed: boolean }) => ({ + id: `${task.id}`, + title: task.title, + state: task.completed ? 'TASK_ARCHIVED' : 'TASK_INBOX', + }) + ); + return result; +}); /* * The store is created here. @@ -63,11 +72,13 @@ const TasksSlice = createSlice({ name: 'taskbox', initialState: TaskBoxData, reducers: { - updateTaskState: (state, action) => { - const { id, newTaskState } = action.payload; - const task = state.tasks.findIndex((task) => task.id === id); - if (task >= 0) { - state.tasks[task].state = newTaskState; + updateTaskState: ( + state, + action: PayloadAction<{ id: string; newTaskState: TaskData['state'] }> + ) => { + const task = state.tasks.find((task) => task.id === action.payload.id); + if (task) { + task.state = action.payload.newTaskState; } }, }, @@ -75,25 +86,25 @@ const TasksSlice = createSlice({ * Extends the reducer for the async actions * You can read more about it at https://redux-toolkit.js.org/api/createAsyncThunk */ -+ extraReducers(builder) { -+ builder -+ .addCase(fetchTasks.pending, (state) => { -+ state.status = 'loading'; -+ state.error = null; -+ state.tasks = []; -+ }) -+ .addCase(fetchTasks.fulfilled, (state, action) => { -+ state.status = 'succeeded'; -+ state.error = null; -+ // Add any fetched tasks to the array -+ state.tasks = action.payload; -+ }) -+ .addCase(fetchTasks.rejected, (state) => { -+ state.status = 'failed'; -+ state.error = "Something went wrong"; -+ state.tasks = []; -+ }); -+ }, + extraReducers(builder) { + builder + .addCase(fetchTasks.pending, (state) => { + state.status = 'loading'; + state.error = null; + state.tasks = []; + }) + .addCase(fetchTasks.fulfilled, (state, action) => { + state.status = 'succeeded'; + state.error = null; + // Add any fetched tasks to the array + state.tasks = action.payload; + }) + .addCase(fetchTasks.rejected, (state) => { + state.status = 'failed'; + state.error = 'Something went wrong'; + state.tasks = []; + }); + }, }); // The actions contained in the slice are exported for usage in our components @@ -110,24 +121,28 @@ const store = configureStore({ }, }); +// Define RootState and AppDispatch types +export type RootState = ReturnType; +export type AppDispatch = typeof store.dispatch; + export default store; ``` -Now that we've updated our store to retrieve the data from a remote API endpoint and prepared it to handle the various states of our app, let's create our `InboxScreen.jsx` in the `src/components` directory: +Now that we've updated our store to retrieve the data from a remote API endpoint and prepared it to handle the various states of our app, let's create our `InboxScreen.tsx` in the `src/components` directory: -```jsx:title=src/components/InboxScreen.jsx +```tsx:title=src/components/InboxScreen.tsx import { useEffect } from 'react'; import { useDispatch, useSelector } from 'react-redux'; -import { fetchTasks } from '../lib/store'; +import { AppDispatch, fetchTasks, RootState } from '../lib/store'; -import TaskList from './TaskList'; +import TaskList from "./TaskList"; export default function InboxScreen() { - const dispatch = useDispatch(); + const dispatch = useDispatch(); // We're retrieving the error field from our updated store - const { error } = useSelector((state) => state.taskbox); + const { error } = useSelector((state: RootState) => state.taskbox); // The useEffect triggers the data fetching when the component is mounted useEffect(() => { dispatch(fetchTasks()); @@ -153,11 +168,12 @@ export default function InboxScreen() {
); } + ``` We also need to change our `App` component to render the `InboxScreen` (eventually, we would use a router to choose the correct screen, but let's not worry about that here): -```diff:title=src/App.jsx +```diff:title=src/App.tsx - import { useState } from 'react' - import reactLogo from './assets/react.svg' - import viteLogo from '/vite.svg' @@ -204,24 +220,30 @@ export default App; However, where things get interesting is in rendering the story in Storybook. -As we saw previously, the `TaskList` component is now a **connected** component and relies on a Redux store to render the tasks. As our `InboxScreen` is also a connected component, we'll do something similar and provide a store to the story. So when we set our stories in `InboxScreen.stories.jsx`: +As we saw previously, the `TaskList` component is now a **connected** component and relies on a Redux store to render the tasks. As our `InboxScreen` is also a connected component, we'll do something similar and provide a store to the story. So when we set our stories in `InboxScreen.stories.tsx`: + +```tsx:title=src/components/InboxScreen.stories.tsx +import type { Meta, StoryObj } from '@storybook/react'; -```jsx:title=src/components/InboxScreen.stories.jsx import InboxScreen from './InboxScreen'; + import store from '../lib/store'; import { Provider } from 'react-redux'; -export default { +const meta = { component: InboxScreen, title: 'InboxScreen', decorators: [(story) => {story()}], tags: ['autodocs'], -}; +} satisfies Meta; + +export default meta; +type Story = StoryObj; -export const Default = {}; +export const Default: Story = {}; -export const Error = {}; +export const Error: Story = {}; ``` We can quickly spot an issue with the `error` story. Instead of displaying the right state, it shows a list of tasks. One way to sidestep this issue would be to provide a mocked version for each state, similar to what we did in the last chapter. Instead, we'll use a well-known API mocking library alongside a Storybook addon to help us solve this issue. @@ -240,20 +262,19 @@ In your terminal, run the following command to generate a generic service worker yarn init-msw ``` -Then, we'll need to update our `.storybook/preview.js` and initialize them: +Then, we'll need to update our `.storybook/preview.ts` and initialize them: + +```diff:title=.storybook/preview.ts +import type { Preview } from '@storybook/react'; + +import { initialize, mswLoader } from 'msw-storybook-addon'; -```diff:title=.storybook/preview.js import '../src/index.css'; // Registers the msw addon -+ import { initialize, mswLoader } from 'msw-storybook-addon'; - -// Initialize MSW -+ initialize(); +initialize(); -//👇 Configures Storybook to log the actions( onArchiveTask and onPinTask ) in the UI. -/** @type { import('@storybook/react').Preview } */ -const preview = { +const preview: Preview = { parameters: { controls: { matchers: { @@ -262,7 +283,7 @@ const preview = { }, }, }, -+ loaders: [mswLoader], + loaders: [mswLoader], }; export default preview; @@ -270,7 +291,9 @@ export default preview; Finally, update the `InboxScreen` stories and include a [parameter](https://storybook.js.org/docs/writing-stories/parameters) that mocks the remote API calls: -```diff:title=src/components/InboxScreen.stories.jsx +```diff:title=src/components/InboxScreen.stories.tsx +import type { Meta, StoryObj } from '@storybook/react'; + import InboxScreen from './InboxScreen'; import store from '../lib/store'; @@ -281,14 +304,17 @@ import store from '../lib/store'; import { Provider } from 'react-redux'; -export default { +const meta = { component: InboxScreen, title: 'InboxScreen', decorators: [(story) => {story()}], tags: ['autodocs'], -}; +} satisfies Meta; + +export default meta; +type Story = StoryObj; -export const Default = { +export const Default: Story = { + parameters: { + msw: { + handlers: [ @@ -300,7 +326,7 @@ export const Default = { + }, }; -export const Error = { +export const Error: Story = { + parameters: { + msw: { + handlers: [ @@ -346,7 +372,9 @@ The `@storybook/addon-interactions` helps us visualize our tests in Storybook, p Let's see it in action! Update your newly created `InboxScreen` story, and set up component interactions by adding the following: -```diff:title=src/components/InboxScreen.stories.jsx +```diff:title=src/components/InboxScreen.stories.tsx +import type { Meta, StoryObj } from '@storybook/react'; + import InboxScreen from './InboxScreen'; import store from '../lib/store'; @@ -364,14 +392,17 @@ import { Provider } from 'react-redux'; + waitForElementToBeRemoved + } from '@storybook/test'; -export default { +const meta = { component: InboxScreen, title: 'InboxScreen', decorators: [(story) => {story()}], tags: ['autodocs'], -}; +} satisfies Meta; + +export default meta; +type Story = StoryObj; -export const Default = { +export const Default: Story = { parameters: { msw: { handlers: [ @@ -395,7 +426,7 @@ export const Default = { + }, }; -export const Error = { +export const Error: Story = { parameters: { msw: { handlers: [ diff --git a/content/intro-to-storybook/react/en/simple-component.md b/content/intro-to-storybook/react/en/simple-component.md index b50750122..07056aa4f 100644 --- a/content/intro-to-storybook/react/en/simple-component.md +++ b/content/intro-to-storybook/react/en/simple-component.md @@ -2,7 +2,7 @@ title: 'Build a simple component' tocTitle: 'Simple component' description: 'Build a simple component in isolation' -commit: '9b36e1a' +commit: 'bc897c5' --- We’ll build our UI following a [Component-Driven Development](https://www.componentdriven.org/) (CDD) methodology. It’s a process that builds UIs from the “bottom-up”, starting with components and ending with screens. CDD helps you scale the amount of complexity you’re faced with as you build out the UI. @@ -20,16 +20,38 @@ As we start to build `Task`, we first write our test states that correspond to t ## Get set up -First, let’s create the task component and its accompanying story file: `src/components/Task.jsx` and `src/components/Task.stories.jsx`. +First, let’s create the task component and its accompanying story file: `src/components/Task.tsx` and `src/components/Task.stories.tsx`. We’ll begin with a baseline implementation of the `Task`, simply taking in the attributes we know we’ll need and the two actions you can take on a task (to move it between lists): -```jsx:title=src/components/Task.jsx -export default function Task({ task: { id, title, state }, onArchiveTask, onPinTask }) { +```tsx:title=src/components/Task.tsx +type TaskData = { + id: string; + title: string; + state: 'TASK_ARCHIVED' | 'TASK_INBOX' | 'TASK_PINNED'; +}; + +type TaskProps = { + task: TaskData; + onArchiveTask: (id: string) => void; + onPinTask: (id: string) => void; +}; + +export default function Task({ + task: { id, title, state }, + onArchiveTask, + onPinTask, +}: TaskProps) { return (
); @@ -40,8 +62,10 @@ Above, we render straightforward markup for `Task` based on the existing HTML st Below we build out Task’s three test states in the story file: -```jsx:title=src/components/Task.stories.jsx -import { fn } from "@storybook/test"; +```tsx:title=src/components/Task.stories.tsx +import type { Meta, StoryObj } from '@storybook/react'; + +import { fn } from '@storybook/test'; import Task from './Task'; @@ -50,7 +74,7 @@ export const ActionsData = { onPinTask: fn(), }; -export default { +const meta = { component: Task, title: 'Task', tags: ['autodocs'], @@ -59,9 +83,12 @@ export default { args: { ...ActionsData, }, -}; +} satisfies Meta; + +export default meta; +type Story = StoryObj; -export const Default = { +export const Default: Story = { args: { task: { id: '1', @@ -71,7 +98,7 @@ export const Default = { }, }; -export const Pinned = { +export const Pinned: Story = { args: { task: { ...Default.args.task, @@ -80,7 +107,7 @@ export const Pinned = { }, }; -export const Archived = { +export const Archived: Story = { args: { task: { ...Default.args.task, @@ -125,13 +152,14 @@ When creating a story, we use a base `task` arg to build out the shape of the ta We'll need to make a couple of changes to Storybook's configuration files so it notices our recently created stories and allows us to use the application's CSS file (located in `src/index.css`). -Start by changing your Storybook configuration file (`.storybook/main.js`) to the following: +Start by changing your Storybook configuration file (`.storybook/main.ts`) to the following: -```diff:title=.storybook/main.js -/** @type { import('@storybook/react-vite').StorybookConfig } */ -const config = { +```diff:title=.storybook/main.ts +import type { StorybookConfig } from '@storybook/react-vite'; + +const config: StorybookConfig = { - stories: ['../src/**/*.mdx', '../src/**/*.stories.@(js|jsx|ts|tsx)'], -+ stories: ['../src/components/**/*.stories.@(js|jsx)'], ++ stories: ['../src/components/**/*.stories.@(ts|tsx)'], staticDirs: ['../public'], addons: [ '@storybook/addon-links', @@ -139,21 +167,22 @@ const config = { '@storybook/addon-interactions', ], framework: { - name: '@storybook/react-vite', + name: "@storybook/react-vite", options: {}, }, }; + export default config; ``` -After completing the change above, inside the `.storybook` folder, change your `preview.js` to the following: +After completing the change above, inside the `.storybook` folder, change your `preview.ts` to the following: + +```diff:title=.storybook/preview.ts +import type { Preview } from '@storybook/react'; -```diff:title=.storybook/preview.js + import '../src/index.css'; -//👇 Configures Storybook to log the actions( onArchiveTask and onPinTask ) in the UI. -/** @type { import('@storybook/react').Preview } */ -const preview = { +const preview: Preview = { parameters: { controls: { matchers: { @@ -184,8 +213,27 @@ Now that we have Storybook set up, styles imported, and test cases built out, we The component is still rudimentary at the moment. First, write the code that achieves the design without going into too much detail: -```jsx:title=src/components/Task.jsx -export default function Task({ task: { id, title, state }, onArchiveTask, onPinTask }) { +```tsx:title=src/components/Task.tsx +type TaskData = { + id: string; + title: string; + state: 'TASK_ARCHIVED' | 'TASK_INBOX' | 'TASK_PINNED'; +}; + +type TaskProps = { + /** Composition of the task */ + task: TaskData; + /** Event to change the task to archived */ + onArchiveTask: (id: string) => void; + /** Event to change the task to pinned */ + onPinTask: (id: string) => void; +}; + +export default function Task({ + task: { id, title, state }, + onArchiveTask, + onPinTask, +}: TaskProps) { return (