diff --git a/ai-docs/patterns/mobx-patterns.md b/ai-docs/patterns/mobx-patterns.md new file mode 100644 index 000000000..9e799f857 --- /dev/null +++ b/ai-docs/patterns/mobx-patterns.md @@ -0,0 +1,315 @@ +# MobX Patterns + +> Quick reference for LLMs working with MobX state management in this repository. + +--- + +## Rules + +- **MUST** use the singleton store pattern via `Store.getInstance()` +- **MUST** wrap widgets with `observer` HOC from `mobx-react-lite` +- **MUST** use `runInAction` for all state mutations +- **MUST** access store via `import store from '@webex/cc-store'` +- **MUST** mark state properties as `observable` +- **MUST** use `makeObservable` in store constructor +- **NEVER** mutate state outside of `runInAction` +- **NEVER** access store directly in presentational components +- **NEVER** create multiple store instances + +--- + +## Store Singleton Pattern + +```typescript +// store.ts +import { makeObservable, observable, action, runInAction } from 'mobx'; + +class Store { + private static instance: Store; + // Observable state + agentId: string = ''; + currentState: string = ''; + idleCodes: IdleCode[] = []; + isLoggedIn: boolean = false; + + private constructor() { + makeObservable(this); + } + + static getInstance(): Store { + if (!Store.instance) { + Store.instance = new Store(); + } + return Store.instance; + } +} + +export default Store.getInstance(); +``` + +--- + +## makeAutoObservable Pattern + +**ALWAYS use `makeAutoObservable` for store classes in this repository.** + +```typescript +import { makeAutoObservable, observable } from 'mobx'; + +class Store implement IStore{ + // Plain property declarations (no decorators) + + private static instance: Store; + agentId: string = ''; + teams: Team[] = []; + currentState: string = ''; + isLoggedIn: boolean = false; + cc: ContactCenter | null = null; + + constructor() { + // makeAutoObservable automatically makes properties observable + makeAutoObservable(this, { + // Only specify overrides for special cases + cc: observable.ref, // Don't observe nested properties on the SDK instance + }); + } +} +``` + +--- + +## runInAction Pattern + +**ALWAYS use runInAction for state mutations:** + +```typescript +import { runInAction } from 'mobx'; + +// ✅ CORRECT +const handleLogin = async () => { + const result = await cc.login(); + runInAction(() => { + store.agentId = result.agentId; + store.isLoggedIn = true; + store.teams = result.teams; + }); +}; + +// ❌ WRONG - Direct mutation +const handleLogin = async () => { + const result = await cc.login(); + store.agentId = result.agentId; // NOT ALLOWED +}; +``` + +--- + +## Observer HOC Pattern + +**ALWAYS wrap widgets that access store with observer:** + +```typescript +import { observer } from 'mobx-react-lite'; +import store from '@webex/cc-store'; + +const UserStateInternal: React.FC = observer((props) => { + // Access store - component will re-render when these change + const { currentState, idleCodes, agentId } = store; + + return ( + + ); +}); +``` + +--- + +## Store Import Pattern + +```typescript +// ✅ CORRECT - Import singleton +import store from '@webex/cc-store'; + +const MyWidget = observer(() => { + const { agentId, teams } = store; + // ... +}); + +// ❌ WRONG - Creating new instance +import { Store } from '@webex/cc-store'; +const store = new Store(); // NOT ALLOWED +``` + +--- + +## Action Pattern + +```typescript +import { action, makeObservable } from 'mobx'; + +class Store { + @observable currentState: string = ''; + + constructor() { + makeObservable(this); + } + + @action + setCurrentState(state: string) { + this.currentState = state; + } + + @action + reset() { + this.currentState = ''; + this.agentId = ''; + this.isLoggedIn = false; + } +} +``` + +--- + +## Computed Pattern + +```typescript +import { observable, computed, makeObservable } from 'mobx'; + +class Store { + tasks: ITask[] = []; + + constructor() { + makeObservable(this); + } + + get activeTasks(): ITask[] { + return this.tasks.filter(task => task.status === 'active'); + } + + get taskCount(): number { + return this.tasks.length; + } +} +``` + +--- + +## Event Handling with Store Pattern + +```typescript +import { runInAction } from 'mobx'; +import store from '@webex/cc-store'; + +// In helper.ts or hook +useEffect(() => { + const handleTaskIncoming = (task: ITask) => { + runInAction(() => { + store.incomingTask = task; + }); + }; + + store.cc.on(TASK_EVENTS.TASK_INCOMING, handleTaskIncoming); + + return () => { + store.cc.off(TASK_EVENTS.TASK_INCOMING, handleTaskIncoming); + }; +}, []); +``` + +--- + +## Store Wrapper Pattern + +```typescript +// storeEventsWrapper.ts +import { runInAction } from 'mobx'; +import store from './store'; + +export const initStoreEventListeners = () => { + store.cc.on(CC_EVENTS.AGENT_STATE_CHANGED, (data) => { + runInAction(() => { + store.currentState = data.state; + store.lastStateChangeTimestamp = Date.now(); + }); + }); + + store.cc.on(CC_EVENTS.AGENT_LOGOUT_SUCCESS, () => { + runInAction(() => { + store.reset(); + }); + }); +}; +``` + +--- + +## Store Access in Widgets + +```typescript +// Widget file +import { observer } from 'mobx-react-lite'; +import store from '@webex/cc-store'; + +const StationLoginInternal = observer(() => { + // Destructure what you need from store + const { + cc, + teams, + dialNumbers, + isAgentLoggedIn, + loginConfig, + } = store; + + // Use in component + return ( + + ); +}); +``` + +--- + +## Async Action Pattern + +```typescript +import { runInAction } from 'mobx'; + +const fetchData = async () => { + // Set loading state + runInAction(() => { + store.isLoading = true; + store.error = null; + }); + + try { + const result = await store.cc.fetchTeams(); + + // Update with result + runInAction(() => { + store.teams = result.teams; + store.isLoading = false; + }); + } catch (error) { + // Handle error + runInAction(() => { + store.error = error; + store.isLoading = false; + }); + } +}; +``` + +--- + +## Related + +- [React Patterns](./react-patterns.md) +- [TypeScript Patterns](./typescript-patterns.md) +- [Testing Patterns](./testing-patterns.md) diff --git a/ai-docs/patterns/react-patterns.md b/ai-docs/patterns/react-patterns.md new file mode 100644 index 000000000..ccd133620 --- /dev/null +++ b/ai-docs/patterns/react-patterns.md @@ -0,0 +1,288 @@ +# React Patterns + +> Quick reference for LLMs working with React in this repository. + +--- + +## Rules + +- **Component style** + - **MUST** use functional components with hooks + - **MUST NOT** use class components + +- **Three-layer architecture (Widget → Hook → Component)** + - **MUST** follow the pattern: **Widget → Hook → Presentational Component** + - **MUST** encapsulate business logic and SDK calls inside custom hooks (`helper.ts`) + - **MUST** keep presentational components in the `cc-components` package + - **MUST NOT** access store directly in presentational components + - **MUST NOT** call SDK methods directly from widgets or presentational components (only from hooks) + +- **MobX + Error handling** + - **MUST** wrap every widget with `ErrorBoundary` from `react-error-boundary` + - **MUST** use `observer` from `mobx-react-lite` for widgets that access the store + +--- + +## Three-Layer Architecture + +``` +┌─────────────────────────────────────┐ +│ Widget (observer) │ ← MobX observer, ErrorBoundary wrapper +│ packages/*/src/{widget}/index.tsx │ +├─────────────────────────────────────┤ +│ Custom Hook │ ← Business logic, SDK calls, events +│ packages/*/src/helper.ts │ +├─────────────────────────────────────┤ +│ Presentational Component │ ← Pure UI, props only +│ packages/cc-components/src/... │ +└─────────────────────────────────────┘ +``` + +--- +## Widget Pattern + +```typescript +// index.tsx +import { observer } from 'mobx-react-lite'; +import { ErrorBoundary } from 'react-error-boundary'; +import store from '@webex/cc-store'; +import { UserStateComponent } from '@webex/cc-components'; +import { useUserState } from '../helper'; +import { IUserStateProps } from './user-state.types'; + +const UserStateInternal: React.FC = observer((props) => { + const { onStateChange } = props; + + // Get data from store + const { cc, idleCodes, currentState, agentId } = store; + + // Use custom hook for logic + const { selectedState, isLoading, handleSetState } = useUserState({ + cc, + idleCodes, + currentState, + onStateChange, + }); + + // Render presentational component + return ( + + ); +}); + +const UserState: React.FC = (props) => ( + <>} + onError={(error) => store.onErrorCallback?.('UserState', error)} + > + + +); + +export { UserState }; +``` + +--- + +## Error Boundary Pattern + +**ALWAYS wrap widgets with this pattern:** + +```typescript +import { ErrorBoundary } from 'react-error-boundary'; +import { observer } from 'mobx-react-lite'; +import store from '@webex/cc-store'; + +// Internal observer component +const UserStateInternal: React.FC = observer((props) => { + // Widget logic here + return ; +}); + +// External wrapper with ErrorBoundary +const UserState: React.FC = (props) => { + return ( + <>} + onError={(error: Error) => { + if (store.onErrorCallback) { + store.onErrorCallback('UserState', error); + } + }} + > + + + ); +}; + +export { UserState }; +``` + +--- + +## Custom Hook Pattern + +**ALWAYS encapsulate business logic in hooks:** + +```typescript +// helper.ts +export const useUserState = (props: UseUserStateProps) => { + const { cc, idleCodes, currentState, onStateChange } = props; + + const [selectedState, setSelectedState] = useState(null); + const [isLoading, setIsLoading] = useState(false); + + // Event listener setup + useEffect(() => { + const handleStateChange = (data: StateChangeEvent) => { + setSelectedState(data.state); + onStateChange?.(data.state); + }; + + cc.on(CC_EVENTS.AGENT_STATE_CHANGED, handleStateChange); + + return () => { + cc.off(CC_EVENTS.AGENT_STATE_CHANGED, handleStateChange); + }; + }, [cc, onStateChange]); + + // Action handler + const handleSetState = useCallback(async (state: IdleCode) => { + setIsLoading(true); + try { + await cc.setAgentState(state); + } catch (error) { + console.error('Failed to set state:', error); + } finally { + setIsLoading(false); + } + }, [cc]); + + return { + selectedState, + isLoading, + handleSetState, + }; +}; +``` + +--- + + +## Presentational Component Pattern + +```typescript +// cc-components/src/components/UserState/UserState.tsx +import React from 'react'; +import { IUserStateComponentProps } from './user-state.types'; + +export const UserStateComponent: React.FC = ({ + idleCodes, + currentState, + selectedState, + isLoading, + onStateSelect, +}) => { + return ( +
+ {idleCodes.map((code) => ( + + ))} +
+ ); +}; +``` + +--- + +## useEffect Cleanup Pattern + +**ALWAYS clean up event listeners and subscriptions:** + +```typescript +useEffect(() => { + const handler = (data: EventData) => { + // Handle event + }; + + cc.on(CC_EVENTS.SOME_EVENT, handler); + + // Cleanup function + return () => { + cc.off(CC_EVENTS.SOME_EVENT, handler); + }; +}, [cc]); +``` + +--- + +## useCallback Pattern + +**ALWAYS use useCallback for handlers passed to child components:** + +```typescript +const handleClick = useCallback((id: string) => { + // Handle click +}, [dependency1, dependency2]); +``` + +--- + +## Conditional Rendering Pattern + +```typescript +// Loading state +if (isLoading) { + return ; +} + +// Error state +if (error) { + return ; +} + +// Empty state +if (!data || data.length === 0) { + return ; +} + +// Normal render +return ; +``` + +--- + +## Props Destructuring Pattern + +```typescript +const Component: React.FC = ({ + prop1, + prop2, + optionalProp = 'default', + onCallback, +}) => { + // Component logic +}; +``` + +--- + +## Related + +- [TypeScript Patterns](./typescript-patterns.md) +- [MobX Patterns](./mobx-patterns.md) +- [Web Component Patterns](./web-component-patterns.md) +- [Testing Patterns](./testing-patterns.md) diff --git a/ai-docs/patterns/testing-patterns.md b/ai-docs/patterns/testing-patterns.md new file mode 100644 index 000000000..92f528192 --- /dev/null +++ b/ai-docs/patterns/testing-patterns.md @@ -0,0 +1,372 @@ +# Testing Patterns + +> Quick reference for LLMs working with tests in this repository. + +--- + +## Rules + +- **MUST** use Jest for unit tests +- **MUST** use React Testing Library for component tests +- **MUST** use Playwright for E2E tests +- **MUST** mock the store using `@webex/test-fixtures` +- **MUST** use `data-testid` attributes for test selectors +- **MUST** place unit tests in `tests/` folder within each package +- **MUST** place E2E tests in `playwright/` folder at repo root +- **NEVER** test implementation details - test behavior +- **NEVER** use CSS selectors in tests - use `data-testid` + +--- + +## Test File Structure + +``` +packages/contact-center/{package}/ +├── src/ +│ └── {widget}/ +│ └── index.tsx +└── tests/ + └── {widget}/ + └── index.test.tsx + +playwright/ +├── tests/ +│ ├── station-login-test.spec.ts +│ ├── user-state-test.spec.ts +│ └── tasklist-test.spec.ts +└── Utils/ + ├── stationLoginUtils.ts + └── userStateUtils.ts +``` + +--- + +## Jest Unit Test Pattern + +```typescript +// tests/{widget}/index.test.tsx +import { render, screen, fireEvent, waitFor } from '@testing-library/react'; +import { UserState } from '@webex/cc-user-state'; +import { mockStore } from '@webex/test-fixtures'; + +// Mock the store +jest.mock('@webex/cc-store', () => ({ + __esModule: true, + default: mockStore, +})); + +describe('UserState', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('should render idle codes', () => { + mockStore.idleCodes = [ + { id: '1', name: 'Available' }, + { id: '2', name: 'Break' }, + ]; + + render(); + + expect(screen.getByText('Available')).toBeInTheDocument(); + expect(screen.getByText('Break')).toBeInTheDocument(); + }); + + it('should call onStateChange when state is selected', async () => { + const onStateChange = jest.fn(); + mockStore.idleCodes = [{ id: '1', name: 'Available' }]; + + render(); + + fireEvent.click(screen.getByText('Available')); + + await waitFor(() => { + expect(onStateChange).toHaveBeenCalled(); + }); + }); +}); +``` + +--- + +## Mock Store Pattern + +```typescript +// test-fixtures/src/mockStore.ts +export const mockStore = { + cc: { + on: jest.fn(), + off: jest.fn(), + login: jest.fn(), + logout: jest.fn(), + setAgentState: jest.fn(), + }, + agentId: 'test-agent-123', + isAgentLoggedIn: false, + teams: [], + idleCodes: [], + currentState: 'Available', + onErrorCallback: jest.fn(), +}; + +// Usage in test +jest.mock('@webex/cc-store', () => ({ + __esModule: true, + default: mockStore, +})); +``` + +--- + +## Hook Testing Pattern + +```typescript +import { renderHook, act } from '@testing-library/react'; +import { useUserState } from '../../src/helper'; +import { mockStore } from '@webex/test-fixtures'; + +describe('useUserState', () => { + it('should handle state change', async () => { + const onStateChange = jest.fn(); + + const { result } = renderHook(() => + useUserState({ + cc: mockStore.cc, + idleCodes: mockStore.idleCodes, + currentState: 'Available', + onStateChange, + }) + ); + + await act(async () => { + await result.current.handleSetState({ id: '1', name: 'Break' }); + }); + + expect(mockStore.cc.setAgentState).toHaveBeenCalled(); + }); +}); +``` + +--- + +## data-testid Pattern + +```typescript +// In component + + +
+ {/* content */} +
+ +// In test +const loginButton = screen.getByTestId('login-button'); +const dropdown = screen.getByTestId('user-state-dropdown'); +``` + +--- + +## Playwright E2E Test Pattern + +```typescript +// playwright/tests/station-login-test.spec.ts +import { test, expect } from '@playwright/test'; +import { StationLoginUtils } from '../Utils/stationLoginUtils'; + +test.describe('Station Login', () => { + let utils: StationLoginUtils; + + test.beforeEach(async ({ page }) => { + utils = new StationLoginUtils(page); + await utils.navigateToApp(); + }); + + test('should login successfully', async ({ page }) => { + await utils.selectTeam('Team A'); + await utils.selectDialNumber('+1234567890'); + await utils.clickLogin(); + + await expect(page.getByTestId('login-success')).toBeVisible(); + }); + + test('should show error on invalid credentials', async ({ page }) => { + await utils.clickLogin(); + + await expect(page.getByTestId('error-message')).toBeVisible(); + }); +}); +``` + +### TestManager Pattern + +```typescript +// playwright/tests/station-login-test.spec.ts +import {test, expect} from '@playwright/test'; +import {TestManager} from '../test-manager'; +import { + telephonyLogin, + verifyLoginMode, + ensureUserStateVisible, +} from '../Utils/stationLoginUtils'; +import {LOGIN_MODE} from '../constants'; + +test.describe('Station Login Tests - Dial Number Mode', () => { + let testManager: TestManager; + + test.beforeAll(async ({browser}, testInfo) => { + const projectName = testInfo.project.name; + testManager = new TestManager(projectName); + await testManager.setupForStationLogin(browser); + }); + + test.afterAll(async () => { + if (testManager) { + await testManager.cleanup(); + } + }); + + test('should login with Dial Number mode and verify login state', async () => { + await ensureUserStateVisible( + testManager.agent1Page, + LOGIN_MODE.DIAL_NUMBER, + process.env[`${testManager.projectName}_ENTRY_POINT`], + ); + + await telephonyLogin( + testManager.agent1Page, + LOGIN_MODE.DIAL_NUMBER, + process.env[`${testManager.projectName}_ENTRY_POINT`], + ); + + await verifyLoginMode(testManager.agent1Page, 'Dial Number'); + }); +}); +``` + +--- + +## Playwright Utils Pattern + +```typescript +// playwright/Utils/stationLoginUtils.ts +import { Page, Locator } from '@playwright/test'; + +export class StationLoginUtils { + private page: Page; + + constructor(page: Page) { + this.page = page; + } + + async navigateToApp(): Promise { + await this.page.goto('/'); + } + + async selectTeam(teamName: string): Promise { + await this.page.getByTestId('team-dropdown').click(); + await this.page.getByText(teamName).click(); + } + + async selectDialNumber(number: string): Promise { + await this.page.getByTestId('dial-number-input').fill(number); + } + + async clickLogin(): Promise { + await this.page.getByTestId('login-button').click(); + } + + async waitForLoginSuccess(): Promise { + await this.page.waitForSelector('[data-testid="login-success"]'); + } +} +``` + +--- + +## Async Testing Pattern + +```typescript +// Using waitFor +await waitFor(() => { + expect(screen.getByText('Success')).toBeInTheDocument(); +}); + +// Using findBy (auto-waits) +const successMessage = await screen.findByText('Success'); +expect(successMessage).toBeInTheDocument(); + +// Using act for state updates +await act(async () => { + fireEvent.click(button); +}); +``` + +--- + +## Mock Event Pattern + +```typescript +// Mock event listener +const mockOn = jest.fn(); +const mockOff = jest.fn(); + +mockStore.cc = { + on: mockOn, + off: mockOff, +}; + +// Simulate event +const eventHandler = mockOn.mock.calls[0][1]; +act(() => { + eventHandler({ state: 'Break' }); +}); +``` + +--- + +## Snapshot Testing Pattern + +```typescript +it('should match snapshot', async () => { + const { container } = await render(); + expect(container).toMatchSnapshot(); +}); +``` + +--- + +## Test Commands + +```bash +# Run all unit tests +yarn test:unit + +# Run all style tests +yarn test:styles + +# Run all E2E tests +yarn test:e2e + + +# Run all tests for tooling +yarn test:tooling + +# Run specific package tests +yarn workspace @webex/cc-station-login test:unit + +# Run with coverage +yarn run test:unit --coverage + +# Run specific E2E test +npx playwright test tests/station-login-test.spec.ts +``` + +--- + +## Related + +- [React Patterns](./react-patterns.md) +- [TypeScript Patterns](./typescript-patterns.md) +- [MobX Patterns](./mobx-patterns.md) diff --git a/ai-docs/patterns/typescript-patterns.md b/ai-docs/patterns/typescript-patterns.md new file mode 100644 index 000000000..c8dfd1890 --- /dev/null +++ b/ai-docs/patterns/typescript-patterns.md @@ -0,0 +1,216 @@ +# TypeScript Patterns + +> Quick reference for LLMs working with TypeScript in this repository. + +--- + +## Rules + +- **MUST** prefix all interfaces with `I` (e.g., `IUserState`, `IStationLoginProps`) +- **MUST** use PascalCase for components and interfaces +- **MUST** use camelCase for hooks with `use` prefix (e.g., `useUserState`) +- **MUST** use `.tsx` extension for components, `.ts` for hooks and utilities +- **MUST** co-locate types in `{component}.types.ts` files +- **MUST** use `Pick` and `Partial` to derive types from parent interfaces +- **MUST** document every interface property with JSDoc comments +- **MUST** use enums for event names and constants +- **NEVER** use `any` without ESLint disable comment and explanation +- **NEVER** duplicate type definitions - derive from source with `Pick` + +--- + +## Naming Conventions + +### Components +```typescript +// PascalCase, .tsx extension +UserState.tsx +StationLogin.tsx +CallControl.tsx +``` + +### Hooks +```typescript +// camelCase with 'use' prefix, .ts extension +useUserState.ts +useStationLogin.ts +useCallControl.ts +``` + +### Interfaces & Types +```typescript +// PascalCase with 'I' prefix +interface IUserState { ... } +interface IStationLoginProps { ... } +interface IContactCenter { ... } +``` + +### Constants +```typescript +// SCREAMING_SNAKE_CASE +const MAX_RETRY_COUNT = 3; +const DEFAULT_TIMEOUT = 5000; +``` + +### File Structure +``` +packages/*/src/{widget}/index.tsx # Widget entry +packages/*/src/helper.ts # Hooks/helpers +packages/*/src/{widget}/{widget}.types.ts # Types +``` + +--- + +## Import Patterns + +### Store Import +```typescript +import store from '@webex/cc-store'; +``` + +### Components Import +```typescript +import { Component } from '@webex/cc-components'; +``` + +### Types Import +```typescript +import { IUserState } from './user-state.types'; +``` + +### MobX Import +```typescript +import { observer } from 'mobx-react-lite'; +import { runInAction } from 'mobx'; +``` + +--- + +## Interface Patterns + +### Pattern 1: Interface with I Prefix +```typescript +interface IUserState { + idleCodes: IdleCode[]; + agentId: string; + cc: IContactCenter; + currentState: string; + onStateChange?: (arg: IdleCode | ICustomState) => void; +} +``` + +### Pattern 2: Use Pick to Derive Types +```typescript +// Widget picks only what it needs from component interface +export type IUserStateProps = Pick; + +// Hook picks different subset +export type UseUserStateProps = Pick< + IUserState, + 'idleCodes' | 'agentId' | 'cc' | 'currentState' | 'logger' +>; +``` + +### Pattern 3: Combine Pick with Partial +```typescript +// Required props + optional callback props +export type StationLoginProps = + Pick & + Partial>; +``` + +### Pattern 4: Union Types +```typescript +type ICustomState = ICustomStateSet | ICustomStateReset; +``` + +--- + +## Enum Patterns + +### Event Enums +```typescript +export enum TASK_EVENTS { + TASK_INCOMING = 'task:incoming', + TASK_ASSIGNED = 'task:assigned', + TASK_HOLD = 'task:hold', +} + +export enum CC_EVENTS { + AGENT_DN_REGISTERED = 'agent:dnRegistered', + AGENT_LOGOUT_SUCCESS = 'agent:logoutSuccess', +} +``` + +### State Enums +```typescript +export enum AgentUserState { + Available = 'Available', + RONA = 'RONA', + Engaged = 'ENGAGED', +} +``` + +--- + +## JSDoc Pattern + +```typescript +/** + * Interface representing the properties for the Station Login component. + */ +export interface IStationLoginProps { + /** + * Webex Contact Center instance. + */ + cc: IContactCenter; + + /** + * Array of teams the agent belongs to. + */ + teams: Team[]; + + /** + * Handler called when login completes. + */ + onLogin?: () => void; +} +``` + +--- + +## Type Export Pattern + +```typescript +// Central export from *.types.ts +export type { + IContactCenter, + ITask, + Profile, + Team, +}; + +export { + CC_EVENTS, + TASK_EVENTS, +}; +``` + +--- + +## Callback Type Pattern + +```typescript +// Optional callback with specific signature +onStateChange?: (arg: IdleCode | ICustomState) => void; +onLogin?: () => void; +onSaveEnd?: (isComplete: boolean) => void; +``` + +--- + +## Related + +- [React Patterns](./react-patterns.md) +- [MobX Patterns](./mobx-patterns.md) +- [Testing Patterns](./testing-patterns.md)