Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
196 changes: 196 additions & 0 deletions docs/storybook-app-dev.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,196 @@
# HOW TO: Use Storybook to Develop React Apps Against Mock Data

**Relates to:** [Issue #98](https://github.com/PepperDash/mobile-control-react-app-core/issues/98) · [Document Mobile Control data flow #89](https://github.com/PepperDash/mobile-control-react-app-core/issues/89)

> **Storybook is not yet configured in this repository or in `mobile-control-react-app-core`.** This guide establishes the pattern for setting it up in an app that consumes the library, so you can build UI without a running Essentials program, plugins, or a Crestron processor. See [storybook-contributors.md](./storybook-contributors.md) for the library-side gap this pattern runs into and what would remove it.

---

## Why

The library's UI is almost entirely data-driven — what renders is determined by state pushed from [Essentials](https://github.com/PepperDash/Essentials) over WebSocket (see [redux-state-app-dev.md](./redux-state-app-dev.md)). Without Storybook, seeing what a component looks like normally means standing up a full Essentials program, plugins, and config just to get data flowing. Storybook lets you skip all of that:

- Build and review components in isolation, with mock data you control.
- Work on the frontend in parallel with backend/plugin development, once you've agreed on the shape of the state.
- Recreate real device/room state (captured from an actual session) to reproduce and fix rendering bugs without reconnecting to that hardware.

---

## Install Storybook

If your app doesn't already have Storybook, follow the official setup for a Vite + React project: <https://storybook.js.org/docs/get-started/frameworks/react-vite>

This adds a `.storybook/` config folder and a `storybook` script to `package.json`.

---

## Case 1 — Components That Only Take Props

If a component has no dependency on the Redux store or `useWebsocketContext` — it only reads `props` — write a story directly with no extra setup.

```tsx
// SelectButton.tsx
interface SelectButtonProps {
isSelected?: boolean;
onClick: () => void;
disabled?: boolean;
children?: React.ReactNode;
}

const SelectButton = ({ isSelected, onClick, disabled, children }: SelectButtonProps) => (
<button
className={`btn ${isSelected ? 'btn-primary-selected' : 'btn-primary'}`}
onClick={onClick}
disabled={disabled}
>
{children}
</button>
);

export default SelectButton;
```

```tsx
// SelectButton.stories.tsx
import { Meta, StoryObj } from '@storybook/react';
import SelectButton from './SelectButton';

export default {
component: SelectButton,
parameters: { layout: 'fullscreen' },
} as Meta<typeof SelectButton>;

type Story = StoryObj<typeof SelectButton>;

export const Default: Story = {
render: () => <SelectButton onClick={() => console.log('clicked')}>Label</SelectButton>,
};

export const Selected: Story = {
render: () => (
<SelectButton isSelected onClick={() => console.log('clicked')}>Selected</SelectButton>
),
};

export const Disabled: Story = {
render: () => (
<SelectButton disabled onClick={() => console.log('clicked')}>Disabled</SelectButton>
),
};
```

Run `npm run storybook` to view it.

---

## Case 2 — Components That Read From the Redux Store

Most components in a real app don't take device/room state as props — they read it from the store via selector hooks (`useGetDevice`, `useRoomState`, etc., see [redux-state-app-dev.md](./redux-state-app-dev.md)). To render one of these in Storybook, the story needs to (1) wrap the component in a Redux `Provider`, and (2) seed that store with state, before the component mounts.

### Do not use `MobileControlProvider` in stories

`MobileControlProvider` wraps its children in `WebsocketProvider`, which dispatches `wsConnect()` on mount and renders a `DisconnectedMessage` until the connection succeeds (`src/lib/shared/MobileControlProvider/MobileControlProvider.tsx`, `src/lib/utils/WebsocketProvider.tsx`). In Storybook there is nothing to connect to, so the component would never render.

You don't need it anyway: `useWebsocketContext()` reads from `WebsocketContext`, which has safe no-op defaults (`sendMessage`, `sendSimpleMessage`, etc. all resolve to `() => null` — see `src/lib/utils/WebsocketContext.ts`). Any component or interface hook that only *sends* commands (e.g. `useIHasPowerControl`) renders and is clickable in Storybook with zero setup — the click just goes nowhere, which is fine for visual/interaction development.

### Build a mock store per story

Use a plain `Provider` from `react-redux` around a fresh `configureStore` — never the library's exported singleton `store` (it concats the WebSocket middleware and will attempt a real connection). Combine only the slice reducers you need for the component under test.

```tsx
// mockStore.ts
import { configureStore } from '@reduxjs/toolkit';
import { devicesReducer, roomsReducer } from 'mobile-control-react-app-core'; // see note below

export const createMockStore = () =>
configureStore({
reducer: {
devices: devicesReducer,
rooms: roomsReducer,
},
});
```

> **Note:** as of this writing, `devicesReducer` / `roomsReducer` are not part of the library's public export surface (`src/lib/store/index.ts` only re-exports the action creators and hooks, not the reducers — see [storybook-contributors.md](./storybook-contributors.md)). Until that's added, define equivalent local reducers in your app, or deep-import from the package's build output as a stopgap.

### Seed the store the same way Essentials would

The WebSocket middleware, on a real connection, dispatches `devicesActions.setDeviceState({ type: '/device/<key>', content })` and `roomsActions.setRoomState({ type: '/room/<key>', content })` as messages arrive (`src/lib/store/middleware/websocketMiddleware.ts`). Both action creators are already exported from the library, so a story can dispatch the exact same actions to make the store look like Essentials populated it:

```tsx
// DisplayStatus.stories.tsx
import { Provider } from 'react-redux';
import { Meta, StoryObj } from '@storybook/react';
import { devicesActions } from 'mobile-control-react-app-core';
import { createMockStore } from './mockStore';
import DisplayStatus from './DisplayStatus';

export default {
component: DisplayStatus,
decorators: [
(Story) => {
const store = createMockStore();

// Same action, same shape, the WS middleware would dispatch on a real message
store.dispatch(
devicesActions.setDeviceState({
type: '/device/display-1',
content: { powerState: true },
}),
);

return <Provider store={store}><Story /></Provider>;
},
],
} as Meta<typeof DisplayStatus>;

type Story = StoryObj<typeof DisplayStatus>;

export const PoweredOn: Story = {
args: { deviceKey: 'display-1' },
};
```

The device key is parsed out of the `type` path (`type.slice(type.lastIndexOf('/') + 1)`), and `content` is deep-merged into any existing state for that key with `lodash.mergeWith` — arrays are replaced, not merged (`src/lib/store/devices/devices.slice.ts`). That means you can dispatch multiple partial updates in sequence to build up state incrementally, exactly like Essentials does over a live session.

Room state works the same way with `roomsActions.setRoomState({ type: '/room/<roomKey>', content })`.

---

## Case 3 — Replaying a Captured Real Session

To reproduce a specific real-world scenario (a bug report, an edge-case device configuration), capture the sequence of state messages from a real Essentials-connected session and replay them as store actions.

1. **Capture:** temporarily log every dispatched `setDeviceState` / `setRoomState` action from a running app connected to real Essentials (e.g., a Redux middleware that appends `action` to an array, or the Redux DevTools action log exported as JSON).
2. **Save** the captured array as a fixture, e.g. `fixtures/conference-room-in-call.json` — an ordered list of `{ type, content }` messages.
3. **Replay** it into the mock store before rendering the story:

```tsx
import fixture from '../fixtures/conference-room-in-call.json';
import { devicesActions, roomsActions } from 'mobile-control-react-app-core';

const store = createMockStore();

fixture.forEach((message: { type: string; content: unknown }) => {
if (message.type.startsWith('/device/')) {
store.dispatch(devicesActions.setDeviceState(message));
} else if (message.type.startsWith('/room/')) {
store.dispatch(roomsActions.setRoomState(message));
}
});
```

Because state merges incrementally in the same order the real middleware applies it, replaying the captured messages in order reproduces the same final store shape the live session had — without needing to reconnect to that room or device.

---

## Summary

| Component depends on… | What the story needs |
| -------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------- |
| Only `props` | Nothing extra — render directly. |
| `useWebsocketContext` (commands only) | Nothing extra — the context's no-op defaults make buttons safely clickable. |
| Redux store (`useGetDevice`, `useRoomState`, etc.) | A `Provider` wrapping a fresh `configureStore`, seeded via `devicesActions.setDeviceState` / `roomsActions.setRoomState`. |
| A specific real-world scenario | The same store setup, seeded by replaying a captured sequence of state messages. |

Never wrap a story in `MobileControlProvider` or the real exported `store` — both assume (and attempt) a live WebSocket connection.
75 changes: 75 additions & 0 deletions docs/storybook-contributors.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
# Storybook Support — Contributor Notes

**Relates to:** [Issue #98](https://github.com/PepperDash/mobile-control-react-app-core/issues/98) · [Document Mobile Control data flow #89](https://github.com/PepperDash/mobile-control-react-app-core/issues/89)

This library does not currently have Storybook configured, and it doesn't need to in order for app developers to use Storybook in *their own* app repo — see [storybook-app-dev.md](./storybook-app-dev.md) for that workflow. This note covers what's missing on the library side to make that workflow clean, in case someone picks it up.

---

## The gap: reducers aren't exported

App-dev stories need to build a mock Redux store (a plain `configureStore`, not the library's singleton `store`, since that one concats `createWebSocketMiddleware()` and will try to connect). That means they need the individual slice reducers.

Each slice file exports its reducer (`devicesReducer`, `roomsReducer`, `runtimeConfigReducer`, `appConfigReducer`, `uiReducer`), but the public barrel `src/lib/store/index.ts` only re-exports:

```ts
export { appConfigActions } from './appConfig/appConfig.slice';
export { devicesActions } from './devices/devices.slice';
export { roomsActions } from './rooms/rooms.slice';
export { runtimeConfigActions } from './runtimeConfig/runtimeConfig.slice';
// ...
export { uiActions } from './ui/ui.slice';
export * from './ui/ui.slice'; // only slice with a reducer wildcard-exported
```

So `devicesReducer`, `roomsReducer`, `runtimeConfigReducer`, and `appConfigReducer` are not part of the package's public API. A consuming app can't `import { devicesReducer } from 'mobile-control-react-app-core'` — only `ui`'s reducer happens to leak through today, incidentally, via its wildcard export.

### Suggested fix

Add explicit reducer exports alongside the existing action exports in `src/lib/store/index.ts`:

```ts
export { devicesReducer } from './devices/devices.slice';
export { roomsReducer } from './rooms/rooms.slice';
export { runtimeConfigReducer } from './runtimeConfig/runtimeConfig.slice';
export { appConfigReducer } from './appConfig/appConfig.slice';
```

Or, better for consumers, export a small factory that assembles a store with no WebSocket middleware — the same shape as the app-dev doc's `createMockStore`, just owned by the library instead of copy-pasted into every app:

```ts
// src/lib/testing/createMockStore.ts
import { configureStore } from '@reduxjs/toolkit';
import type { RootState } from '../store';
import { appConfigReducer } from '../store/appConfig/appConfig.slice';
import { devicesReducer } from '../store/devices/devices.slice';
import { roomsReducer } from '../store/rooms/rooms.slice';
import { runtimeConfigReducer } from '../store/runtimeConfig/runtimeConfig.slice';
import { uiReducer } from '../store/ui/ui.slice';

export const createMockStore = (preloadedState?: Partial<RootState>) =>
configureStore({
Comment thread
jkdevito marked this conversation as resolved.
reducer: {
appConfig: appConfigReducer,
runtimeConfig: runtimeConfigReducer,
rooms: roomsReducer,
devices: devicesReducer,
ui: uiReducer,
},
preloadedState,
});
```

Exporting this from the library (e.g. as `mobile-control-react-app-core/testing`) means app developers never have to hand-assemble the reducer map themselves, and it stays in sync automatically if a new slice is added.

---

## Not needed: a mock `WebsocketContext` provider

No action required here. `WebsocketContext` (`src/lib/utils/WebsocketContext.ts`) already has safe no-op defaults for every function it carries. Components that call `useWebsocketContext()` for commands work in Storybook with no provider at all — only state *reads* (Redux store) need mocking. Don't add a Storybook-specific websocket mock; it would just duplicate what the context default already does.

---

## Optional: Storybook for this library's own components

Separately from unblocking app-dev usage, this repo's own shared components (`src/lib/shared/Buttons`, `src/lib/shared/Icons`, `src/lib/shared/layout/habanero`, etc.) have no Storybook of their own. Adding `.storybook/` here (via `@storybook/react-vite`, matching the app's Vite 6 setup) would let contributors iterate on those components visually without building a consuming app first. This is a separate, lower-priority effort from the reducer-export gap above — the app-dev workflow doesn't depend on it.
Loading