---
name: mystique-advanced-sdk
description: "Advanced OMX Component SDK reference: category decision tree, architecture per type, frame utilities, REST hooks, advanced field patterns, testing mocks, multi-component registration"
version: 1.0
relevant-skills: mystique-scaffold, mystique-component, mystique-builder, mystique-validate, frontend-build, frontend-review, rule-scaffold, workflow-builder
---

# Mystique Advanced SDK Reference

Deep reference for OMX Component SDK topics not covered in the basic `mystique-sdk.md`. Source of truth: `@types/mystique/*.d.ts` from SDK v1.2024.9.807.

## 1. Category Decision Tree

Every custom component MUST be registered with exactly one category. The category determines **how the host app treats your component** — data injection, rendering context, and manifest placement rules.

```
Is this component a full page replacement?
├── YES → category: 'page'
│         Gets route-level data, URL params, query variables.
│         Renders at the route level (replaces fc.page).
│         Must handle its own layout (<Children /> for descendants).
│
└── NO → Does it compose/arrange other components?
         ├── YES → category: 'layout'
         │         Structural: grids, tabs, conditionals, accordions.
         │         Receives data from parent, passes to <Children />.
         │         Does NOT fetch its own data (uses useData for context).
         │
         └── NO → Does it modify query variables (search, date range, etc.)?
                  ├── YES → category: 'filter'
                  │         Renders input controls that set query variables.
                  │         Uses useData().variables.setVariables() to update.
                  │         Triggers page query re-execution when variables change.
                  │
                  └── NO → category: 'content'
                           Displays information. Most common category.
                           Receives data via StdProps.data (auto-injected from dataSource).
                           Tables, cards, charts, status displays, custom views.
```

See also: `knowledge/frontend/mystique-sdk.md` §Component Category Decision Tree (summary table) and `/fluent-mystique-sdk-reference` for registration code examples per category.

### Category Architecture Summary

| Category | Data Access | Manifest Position | `<Children />` | Example Use Cases |
|----------|------------|-------------------|-----------------|-------------------|
| `page` | Route-level query, URL params, `useData().variables` | Top-level `component` on a `MystiquePage` | Yes — manages page layout | Custom dashboard, split-view page, wizard |
| `layout` | Inherited from parent via `StdProps.data`, `useData()` | `descendants[]` of page or other layout | Yes — arranges child components | Grid, tabs, conditional show/hide, accordion |
| `content` | `StdProps.data` (auto-scoped by `dataSource`), `useQuery` for extra | `descendants[]` of page or layout | Rarely — leaf node, displays info | Order details card, status badge, map, chart |
| `filter` | `useData().variables` to read/write query vars | `descendants[]` of page, typically above content | No — standalone input | Date range picker, status filter, search bar |

### Data Flow per Category

```
Page Query (manifest data.query)
    │
    ▼
page component ← receives full query result as StdProps.data
    │
    ├── layout component ← receives data, scoped by its dataSource
    │       │
    │       ├── content component ← receives data, further scoped by its dataSource
    │       │
    │       └── content component ← receives data, further scoped by its dataSource
    │
    └── filter component ← reads/writes variables via useData().variables.setVariables()
                            (triggers page query re-fetch when variables change)
```

### Common Mistakes

| Mistake | Symptom | Fix |
|---------|---------|-----|
| Registering a content component as `page` | Component renders alone without manifest descendants | Use `content`; only `page` if replacing the entire route |
| Registering a layout as `content` | Component may still render, but the category no longer reflects its composition role and tooling guidance becomes misleading | Use `layout`; choose categories by primary rendering role |
| Using `useQuery` in a filter component | Extra API call instead of updating the page query | Use `useData().variables.setVariables()` to modify existing query |
| Missing `dataSource` on content component | `StdProps.data` is the full page query result, not the entity | Set `dataSource` in manifest to scope data injection |

## 2. Props Interface per Category

### StdProps (all categories)

```typescript
// From registry.d.ts — injected by the host for ALL registered components
interface StdProps {
  data?: any;  // Page query data, scoped by dataSource chain
}
```

The `data` prop is **automatically injected** by the host. You don't pass it from the manifest — the manifest's `dataSource` property controls which subtree of the query result is injected.

### Page component props

```typescript
// Page components receive MystiquePageProps merged with StdProps
interface MystiquePageProps extends Record<string, any> {
  title: string;                        // Page heading
  actions?: PageActionConfig | true;    // User action button configuration
}

// Your page component signature:
const MyPage: FC<StdProps & { title: string; actions?: PageActionConfig | true }> = (props) => {
  // props.data = full page query result
  // props.title = from manifest props.title
  // props.actions = controls user action buttons
  return <Children />;  // Render descendants defined in manifest
};
```

### Layout component props

```typescript
// Layout components typically define their own custom props
interface MyLayoutProps {
  columns?: number;
  gap?: string;
}

const MyLayout: FC<StdProps & MyLayoutProps> = (props) => {
  // props.data = data from parent, scoped by this component's dataSource
  return (
    <div style={{ display: 'grid', gridTemplateColumns: `repeat(${props.columns ?? 2}, 1fr)` }}>
      <Children />  {/* Renders manifest descendants */}
    </div>
  );
};
```

### Content component props

```typescript
// Content components are leaf nodes — they display data
interface MyCardProps {
  title?: string;
  showStatus?: boolean;
}

const MyCard: FC<StdProps & MyCardProps> = (props) => {
  // props.data = query data scoped by ALL ancestor dataSources
  // Example: if manifest chain is page.dataSource="orderById" → layout.dataSource="" → this.dataSource="items.edges"
  // then props.data = query.orderById.items.edges
  return (
    <Card title={props.title}>
      <CardContent>{JSON.stringify(props.data)}</CardContent>
    </Card>
  );
};
```

### Filter component props

```typescript
// Filter components modify query variables, not display data
const MyStatusFilter: FC<StdProps> = () => {
  const { variables } = useData();

  const handleChange = (status: string) => {
    variables.setVariables({ status }, true);  // true = add to URL
  };

  return (
    <select value={variables.current.status ?? ''} onChange={e => handleChange(e.target.value)}>
      <option value="">All</option>
      <option value="ACTIVE">Active</option>
      <option value="COMPLETE">Complete</option>
    </select>
  );
};
```

## 3. Registration Patterns

### Single component (most common)

```typescript
// src/index.tsx
import { ComponentRegistry } from 'mystique/registry/ComponentRegistry';
import { OrderCard } from './components/OrderCard/OrderCard';

ComponentRegistry.register(['acme.content.order-card'], OrderCard, { category: 'content' });
```

### Multi-component (one bundle, multiple registrations)

```typescript
// src/index.tsx
import { ComponentRegistry } from 'mystique/registry/ComponentRegistry';
import { FieldRegistry } from 'mystique/registry/FieldRegistry';
import { TemplateRegistry } from 'mystique/registry/TemplateRegistry';

// Content components
import { OrderCard } from './components/OrderCard/OrderCard';
import { StoreMap } from './components/StoreMap/StoreMap';
import { StatusTimeline } from './components/StatusTimeline/StatusTimeline';

// Layout component
import { SplitView } from './components/SplitView/SplitView';

// Custom form fields (for user action dialogs)
import { StockReservation } from './fields/StockReservation/StockReservation';
import { LocationPicker } from './fields/LocationPicker/LocationPicker';

// Template helpers
import { formatWeight } from './helpers/formatWeight';

// === Component Registry ===
ComponentRegistry.register(['acme.content.order-card'], OrderCard, { category: 'content' });
ComponentRegistry.register(['acme.content.store-map'], StoreMap, { category: 'content' });
ComponentRegistry.register(['acme.content.status-timeline'], StatusTimeline, { category: 'content' });
ComponentRegistry.register(['acme.layout.split-view'], SplitView, { category: 'layout' });

// === Field Registry ===
// One canonical kebab-case alias per field — same string goes in workflow attribute `type`
FieldRegistry.register(['acme.fields.stock-reservation'], StockReservation as any);
FieldRegistry.register(['acme.fields.location-picker'], LocationPicker as any);

// === Template Registry ===
TemplateRegistry.register('acme.helpers.format-weight', formatWeight);
```

### Multi-alias registration (migration only)

```typescript
// Multiple aliases ONLY when migrating from an old alias to a new one
ComponentRegistry.register(
  ['acme.content.order-card', 'legacy.order-card'],  // New canonical + old for backward compat
  OrderCard,
  { category: 'content' }
);

// First alias is canonical. Second is a migration fallback.
// Remove the legacy alias once all manifests are updated.
// NEVER use `fc.*` prefix — reserved for Fluent Commerce OOTB components.
```

### Namespace Conventions (MANDATORY)

| Registry | Naming Convention | Examples | Notes |
|----------|------------------|----------|-------|
| ComponentRegistry | `<namespace>.<category>.<kebab-name>` | `acme.content.order-card`, `acme.layout.split-view` | Namespace = project name or company. Category must match `register()` category param. |
| FieldRegistry | `<namespace>.fields.<kebab-name>` | `acme.fields.stock-reservation`, `appeasement.fields.amount-calculator` | One canonical alias. Same string used in workflow attribute `type`. |
| TemplateRegistry | `<namespace>.helpers.<kebab-name>` | `acme.helpers.format-weight`, `appeasement.helpers.format-currency` | Accepts `string \| string[]`. Used in `{{acme.helpers.format-weight value}}` templates. |

### Reserved Prefixes

| Prefix | Owner | Never Use For Custom |
|--------|-------|---------------------|
| `fc.*` | Fluent Commerce OOTB components | Custom components. Will clash with platform updates. |
| `mystique.*` | Mystique framework internals | Custom components. |

### Naming Edge Cases

- **One canonical alias per registration**: Use a single `<namespace>.<segment>.<kebab-name>` alias. Multiple aliases only for migration (old alias → new alias).
- **ComponentRegistry.get() with fallback**: `ComponentRegistry.get(['acme.content.order-card', 'fc.card.attribute'])` — returns first match. Useful for graceful degradation.
- **FieldRegistry.get() with fallback**: Same pattern — `FieldRegistry.get(['acme.fields.date-picker', 'date'])` falls back to OOTB date field.
- **`as any` cast on FieldRegistry**: Expected when your field component has a custom props interface that extends `FormFieldProps<T>` with additional props (e.g., `data`). The cast is safe because the SDK passes `FormFieldProps<T>` props at runtime and your component simply ignores unknown ones.

## 4. Frame Utilities (Imperative UI)

The `mystique/frame` module provides imperative functions to show modals, drawers, and toast notifications from component code. These are NOT React components — they are function calls that tell the host app to render UI overlays.

### pushModal — Confirmation Dialogs

```typescript
import { pushModal, clearModals } from 'mystique/frame/pushModal';

// Show a confirmation modal
pushModal({
  title: 'Confirm Cancellation',
  body: <p>Are you sure you want to cancel order {orderRef}?</p>,
  actions: [
    { label: 'Cancel', action: () => clearModals() },
    { label: 'Confirm', action: () => { handleCancel(); clearModals(); } }
  ]
});
```

### pushDrawer — Side Panels

```typescript
import { pushDrawer, clearDrawers } from 'mystique/frame/pushDrawer';

// Show event details in a drawer
pushDrawer({
  title: 'Event Details',
  body: <EventDetailView eventId={eventId} />,
  actions: [
    { label: 'Close', action: () => clearDrawers() }
  ]
});
```

### DataModal — Modal with Own Query

```typescript
import { DataModal } from 'mystique/frame/DataModal';

// DataModal is a React component (not imperative) that runs its own GraphQL query
// and provides a data context to its descendants — like a mini-page inside a modal.
<DataModal
  query={`query($id: ID!) { orderById(id: $id) { ref status } }`}
  descendants={[
    { component: 'fc.card.attribute', props: { attributes: [{ label: 'Ref', value: '{{node.ref}}' }] } }
  ]}
/>
```

### pushToast — Notifications

```typescript
import { pushToast } from 'mystique/frame/pushToast';

// Severity: 'success' | 'info' | 'warning' | 'error'
pushToast('success', 'Order updated successfully', 5000);  // auto-hide after 5s
pushToast('error', <span>Failed: {error.message}</span>, 10000);
pushToast('warning', 'This action cannot be undone', 8000);  // 8000ms is default
```

### ModalContent Interface

```typescript
interface ModalContent {
  title?: string | JSX.Element;           // Heading
  actions?: ModalAction[] | JSX.Element;  // Buttons (bottom for modal, top for drawer)
  body: JSX.Element | JSX.Element[];      // Content
}

interface ModalAction {
  label: string;       // Button text (supports i18n keys)
  action: () => void;  // Click handler
}
```

## 5. REST API Hooks

### useRest (reactive)

```typescript
import { useRest } from 'mystique/hooks/useRest';

// GET request (default)
const response = useRest<ApiResponse>('/api/v4.1/event/123');
// response.status: 'loading' | 'ok' | 'error'
// response.result: ApiResponse (when status === 'ok')

// POST request
const body = { name: 'CancelOrder', entityId: '123', entityType: 'ORDER' };
const postResponse = useRest<EventResponse>('/api/v4.1/event/sync', 'POST', body);
```

**Key details from type definitions:**
- Domain is assumed (endpoint starts AFTER domain, e.g. `/api/v4.1/...`)
- Only `GET` and `POST` methods supported
- Authentication headers added automatically by the host
- Returns `QueryResponse<T>` — same loading/ok/error pattern as other hooks

### getRest (promise-based)

```typescript
import { getRest } from 'mystique/hooks/getRest';

// Use inside useEffect for dynamic endpoints
useEffect(() => {
  getRest<LabelData>(`/api/v4/consignment/${consignmentId}/label`)
    .then(data => setLabel(data));
}, [consignmentId]);
```

**When to use which:**
- `useRest` — Static endpoints known at render time. Re-renders component when response arrives.
- `getRest` — Dynamic endpoints, generated from user interaction. Must manage state manually via `useState` + `useEffect`.

### getApiDownload (file downloads)

```typescript
import { getApiDownload } from 'mystique/hooks/getApiDownload';

const handleDownload = async () => {
  const blob = await getApiDownload(`api/v4/consignment/${id}/labelStream`);
  const url = URL.createObjectURL(blob);
  window.open(url);
};
```

## 6. Advanced Form Field Features

### OnChangeOptions

Every FieldRegistry component receives `onChange(value, options?)`. The `options` parameter controls three powerful features:

```typescript
interface OnChangeOptions {
  // Show confirmation modal on form submit
  confirm?: {
    title: string;           // Modal heading
    content: JSX.Element;    // Modal body (React component)
  };

  // Show persistent summary in form (top-left hovering div)
  summary?: {
    content: JSX.Element;    // Summary content (React component)
  };

  // Mark field as invalid (prevents form submission)
  isInvalid?: boolean;       // Default: false
}
```

### confirm — Pre-submit Confirmation

```typescript
const MyField: FC<FormFieldProps<string>> = ({ onChange, value }) => {
  const handleChange = (newValue: string) => {
    onChange(newValue, {
      confirm: {
        title: 'Confirm Change',
        content: <p>Changing from "{value}" to "{newValue}". This cannot be undone.</p>
      }
    });
  };
  return <input value={value ?? ''} onChange={e => handleChange(e.target.value)} />;
};
```

When the user clicks Submit, the confirm modal appears. Multiple fields can each produce `confirm` — all shown sequentially in one modal.

### summary — Live Form Summary

```typescript
const QuantityField: FC<FormFieldProps<number>> = ({ onChange, value, entityContext }) => {
  const handleChange = (qty: number) => {
    const total = qty * unitPrice;
    onChange(qty, {
      summary: {
        content: <div><strong>Total:</strong> ${total.toFixed(2)}</div>
      }
    });
  };
  return <input type="number" value={value ?? 0} onChange={e => handleChange(Number(e.target.value))} />;
};
```

### isInvalid — Complex Validation

```typescript
const AddressField: FC<FormFieldProps<Address>> = ({ onChange, value }) => {
  // Partially filled address — can't submit but don't want to discard
  const isPartial = value?.street && !value?.postcode;

  const handleChange = (field: string, val: string) => {
    const updated = { ...value, [field]: val };
    const partial = updated.street && !updated.postcode;
    onChange(updated, { isInvalid: partial });
  };

  return (
    <div>
      <input placeholder="Street" onChange={e => handleChange('street', e.target.value)} />
      <input placeholder="Postcode" onChange={e => handleChange('postcode', e.target.value)} />
    </div>
  );
};
```

### entityContext — Entity-Aware Fields

```typescript
// entityContext tells the field which entity the form targets
interface FormEntityContext {
  entity: { type: string; id?: string; ref?: string };
  rootEntity?: { type: string; id?: string; ref?: string };
}

// Use it to load related data for the target entity
const MyField: FC<FormFieldProps<string>> = ({ entityContext, onChange }) => {
  const entityId = entityContext?.[0]?.entity.id;
  const [result] = useQuery(relatedQuery, { id: entityId });
  // ... render field with related data
};
```

### FormField Interface (complete)

```typescript
interface FormField<T> {
  name: string;                              // Field name (matches @EventAttribute name)
  label: string;                             // Display label
  value?: T;                                 // Current value
  defaultValue?: T;                          // Initial value
  options?: FormFieldOption<T>[];            // For select/radio fields
  helperText?: string;                       // Helper text below field
  validation?: Record<string, any>;          // Validation rules
  extensions?: Record<string, unknown>;      // Extra props from settings
}

interface FormFieldProps<T> extends FormField<T> {
  onChange: (value: T, options?: OnChangeOptions) => void;  // Push value to form
  onBlur?: () => void;                                       // Trigger validation
  error?: string;                                            // Validation error message
  entityContext?: FormEntityContext[];                        // Target entity info
  lastSubmit?: number;                                       // Timestamp of last submit
}
```

## 7. Testing Patterns

OOTB Mystique plugins use **Jest + React Testing Library** as the default component-test stack. The recurring patterns in the Store, Returns, and Analytics reference plugins are:

- package-local `TestUtils.tsx` helpers instead of one global mocking style shared across all plugins
- `jest.spyOn(...)` for Mystique hooks such as `useQuery`, `useAuth`, `useData`, `useSettings`, `useI18n`
- focused behavioral assertions first, with Storyshots for selected visual regression coverage
- registry and manifest tests (`src/index.test.ts`, `manifests/Manifest.test.ts`) alongside component tests

Best reference plugins by topic:

- **Store** — broad component coverage, hook mocks, local `TestUtils`, complex interaction tests
- **Returns** — field-heavy tests, `entityContext`, validation, user-action dialog behaviors
- **Analytics** — data-heavy component tests and focused `useData`/`useQuery` usage
- **Core** / **OMS** — Storyshots and snapshot-specific Storybook setup

### Mock useQuery

```typescript
import * as UseQuery from 'mystique/hooks/useQuery';

// Create mock response
const mockResponse: UseQuery.UseQueryResponse = [
  {
    fetching: false,
    stale: false,
    data: { orderById: { id: '123', ref: 'ORD-001', status: 'BOOKED' } }
  },
  () => null  // refetch function (no-op in tests)
];

// Spy and mock
jest.spyOn(UseQuery, 'useQuery').mockImplementation((): any => mockResponse);
```

### Mock sequential `useQuery()` calls

Some OOTB components run multiple queries from one component. In those cases, mock the calls in order with `mockImplementationOnce(...)`:

```typescript
jest.spyOn(UseQuery, 'useQuery')
  .mockImplementationOnce((): any => [
    { fetching: false, stale: false, data: { orderById: { id: '123' } } },
    jest.fn()
  ])
  .mockImplementationOnce((): any => [
    { fetching: false, stale: false, data: { settings: { edges: [] } } },
    jest.fn()
  ]);
```

Use this pattern when a field or component calls `useQuery()` more than once or conditionally chains queries behind earlier data.

### Mock useAuth

```typescript
import * as UseAuth from 'mystique/hooks/useAuth';

jest.spyOn(UseAuth, 'useAuth').mockReturnValue({
  loggedIn: true,
  logout: jest.fn(),
  user: {
    username: 'test_admin',
    roles: [{ role: { name: 'ADMIN' }, contexts: [{ contextType: 'retailer', contextId: '1' }] }]
  },
  context: {
    level: 'retailer',
    current: { contextType: 'retailer', contextId: '1', label: 'Test Retailer' },
    available: [],
    set: jest.fn(),
    roleCheck: (role) => ({ hasRole: true, context: { contextType: 'retailer', contextId: '1' } })
  }
});
```

### Mock useSettings

```typescript
import * as UseSettings from 'mystique/hooks/useSettings';

jest.spyOn(UseSettings, 'useSettings').mockReturnValue({
  myConfig: { status: 'ok', result: { name: 'my.setting', value: { enabled: true } } }
});
```

### Mock useData

```typescript
import * as UseData from 'mystique/hooks/useData';

jest.spyOn(UseData, 'useData').mockReturnValue({
  variables: {
    current: { status: 'ACTIVE' },
    available: [{ name: 'status', type: 'String' }],
    setVariables: jest.fn()
  },
  context: {
    data: { orderById: { id: '123' } },
    dataSource: ['orderById']
  },
  loadingState: 'ready'
});
```

### Mock useI18n

```typescript
import * as UseI18n from 'mystique/hooks/useI18n';

jest.spyOn(UseI18n, 'useI18n').mockReturnValue({
  translate: (key: string) => key,  // Returns key as-is for testing
  translateOr: (keys: string[]) => keys[0],
  prefixedTranslate: (keys: string[]) => keys[0],
  language: { current: 'en', available: [{ code: 'en', label: 'English' }], setLanguage: jest.fn() }
});
```

### Testing a FormField component

```typescript
import { render, fireEvent, screen } from '@testing-library/react';
import { FormFieldProps } from 'mystique/registry/FieldRegistry';
import { MyCustomField } from './MyCustomField';

describe('MyCustomField', () => {
  const defaultProps: FormFieldProps<string> = {
    name: 'reason',
    label: 'Cancellation Reason',
    value: '',
    defaultValue: '',
    source: 'settings.returnReasons',
    entityContext: [{ entity: { id: '123', type: 'ORDER', ref: 'ORD-123' } }],
    lastSubmit: undefined,
    error: undefined,
    onChange: jest.fn(),
    onBlur: jest.fn(),
  };

  it('calls onChange with value and confirm option', () => {
    const { getByRole } = render(<MyCustomField {...defaultProps} />);
    fireEvent.change(getByRole('textbox'), { target: { value: 'Out of stock' } });

    expect(defaultProps.onChange).toHaveBeenCalledWith('Out of stock', expect.objectContaining({
      confirm: expect.objectContaining({ title: expect.any(String) })
    }));
  });

  it('shows validation after submit attempt', () => {
    render(<MyCustomField {...defaultProps} lastSubmit={Date.now()} error="Required" />);
    expect(screen.getByText('Required')).toBeInTheDocument();
  });
});
```

When testing `FieldRegistry` fields, make `entityContext` realistic. That is the preferred source of entity identity in user action dialogs; URL hash parsing is a fallback for missing context or extra route values such as `retailerId`.

### Snapshot strategy (OOTB)

- Use behavioral assertions as the default test style
- Keep `.stories.tsx` files co-located with components/fields for local visual review
- Use Storybook Storyshots for snapshot coverage when visual regression is valuable
- Treat `react-test-renderer` as Storyshots infrastructure, not the default component-test API
- Use a `FixMuiClassnames` wrapper (or equivalent `StylesProvider`) when snapshot stability matters

### Additional test layers

OOTB plugins use more than component tests:

- `src/index.test.ts` for registry exposure and plugin bootstrapping
- `manifests/Manifest.test.ts` for manifest schema, query parsing, and i18n-key validation
- local `TestUtils.tsx` helpers for stable MUI class names and reusable hook mocks

## 8. Mystique Provided Components (importable)

These React components are provided by the host and can be imported in custom components:

| Import | Type | When to Use |
|--------|------|-------------|
| `Card` from `mystique/components/Card` | Container | Wrap content in a themed card with title, highlight bar, actions |
| `CardContent` from `mystique/components/CardContent` | Container | Proper padding inside a Card (skip for edge-to-edge content like tables) |
| `Children` from `mystique/components/Children` | Composition | Render manifest-defined descendants (replaces React `{children}`) |
| `DynamicValue` from `mystique/components/DynamicValue` | Display | Render a template string against data context (text, image, or component) |
| `Loading` from `mystique/components/Loading` | Indicator | Show loading overlay (absolute positioned, needs `position: relative` parent) |
| `List` from `mystique/components/List` | Table | Full-featured data table with pagination, filtering, row expansion |
| `QuantityList` from `mystique/components/QuantityList` | Table | List with quantity selector column (for pick/pack workflows) |
| `DatePicker` from `mystique/components/DatePicker` | Input | Material-UI KeyboardDateTimePicker wrapper |

### Children component — dataOverride and descendantsOverride

The `<Children />` component is how layout and page components render manifest-defined descendants. It supports data transformation and selective rendering.

```typescript
import { Children } from 'mystique/components/Children';
```

#### Basic usage

```typescript
// Render all manifest descendants with inherited data
<Children />

// Render only the child at a specific index (useful for tab sets, split layouts)
<Children index={0} />   // First descendant only
<Children index={1} />   // Second descendant only
```

#### dataOverride — transform data for descendants

`dataOverride` replaces the data context for ALL descendants. The override completely replaces the inherited data — it does not merge.

**Pattern 1: Filter data before passing to children**

```typescript
const MyFilterLayout: FC<StdProps & { statusFilter: string }> = ({ data, statusFilter }) => {
  const filtered = data?.edges?.filter(
    (e: any) => e.node.status === statusFilter
  ) ?? [];
  return <Children dataOverride={{ edges: filtered }} />;
};
```

**Pattern 2: Reshape data from a separate query**

```typescript
const MyDataLayout: FC<StdProps> = ({ data }) => {
  const [result] = useQuery(inventoryQuery, { productRef: data?.ref });
  if (!result?.data) return <Loading />;
  return <Children dataOverride={result.data.inventoryPositions} />;
};
```

**Pattern 3: Merge parent data with additional context**

```typescript
const MyEnrichedLayout: FC<StdProps> = ({ data }) => {
  const auth = useAuth();
  const enriched = { ...data, currentUser: auth.user.username, contextId: auth.context.current.contextId };
  return <Children dataOverride={enriched} />;
};
```

#### descendantsOverride — control which descendants render

`descendantsOverride` replaces the manifest-defined descendants array entirely. Useful for conditional rendering or reordering.

```typescript
interface MyConditionalLayoutProps {
  showAdvanced?: boolean;
  primaryDescendants?: MystiqueComponentInstance[];
  fallbackDescendants?: MystiqueComponentInstance[];
}

const MyConditionalLayout: FC<StdProps & MyConditionalLayoutProps> = ({
  showAdvanced,
  primaryDescendants = [],
  fallbackDescendants = [],
}) => {
  const nextDescendants = showAdvanced ? primaryDescendants : fallbackDescendants;
  return <Children descendantsOverride={nextDescendants} />;
};
```

Use `descendantsOverride` only when your component already has an explicit descendant list to render. Public `useData()` does not expose the current manifest descendants array.

#### Selective data routing to specific children

To pass different data to different descendants (e.g., left panel gets one dataset, right panel gets another), use indexed `<Children>` with separate data overrides:

```typescript
const MySplitLayout: FC<StdProps> = ({ data }) => {
  const summary = { ref: data?.ref, status: data?.status };
  const items = data?.items;
  return (
    <Grid container>
      <Grid item xs={4}>
        <Children index={0} dataOverride={summary} />
      </Grid>
      <Grid item xs={8}>
        <Children index={1} dataOverride={items} />
      </Grid>
    </Grid>
  );
};
```

Manifest configuration for this component:

```json
{
  "component": "acme.layout.split-view",
  "dataSource": "orderById",
  "descendants": [
    { "component": "fc.card.attribute", "props": { "title": "Summary", "attributes": [...] } },
    { "component": "fc.list", "props": { "title": "Items", "attributes": [...] } }
  ]
}
```

#### dataOverride vs manifest dataSource

| Mechanism | Where | Behavior | Use when |
|-----------|-------|----------|----------|
| `dataSource` (manifest) | JSON config | Scopes inherited data by JSONPath | Simple drill-down into existing data |
| `dataOverride` (code) | SDK component | Replaces data entirely | Filtering, reshaping, merging, or injecting data from separate queries |

**Rule of thumb:** If the data transformation is just path navigation (e.g., `orderById.customer`), use manifest `dataSource`. If it requires logic (filtering, enriching, querying), use `<Children dataOverride />`.

### DynamicValue component — rendering types

```typescript
import { DynamicValue } from 'mystique/components/DynamicValue';

// Standard text with template
<DynamicValue
  attribute={{ type: 'standard', label: 'Status', value: '{{capitalise node.status}}' }}
  context={data}
  defaultValue={<span>-</span>}
/>

// Image type
<DynamicValue
  attribute={{ type: 'image', value: '{{node.imageUrl}}', options: { width: 80, height: 80 } }}
  context={data}
/>

// Component type (renders another Mystique component inline)
<DynamicValue
  attribute={{ type: 'component', options: { component: 'fc.status', props: { status: '{{node.status}}' } } }}
  context={data}
/>

// Styled text with conditional colors
<DynamicValue
  attribute={{
    type: 'standard',
    value: '{{node.status}}',
    options: {
      styles: [
        { matches: ['ACTIVE', 'BOOKED'], icon: { name: 'MdTick', colour: 'success' }, text: { colour: 'success' } },
        { matches: ['FAILED', 'CANCELLED'], icon: { name: 'MdError', colour: 'error' }, text: { colour: 'error' } },
        { text: { colour: 'onSurface' } }  // Default case (no matches = match all)
      ]
    }
  }}
  context={data}
/>
```

## 9. Theme Colors (available via MUI theme or style attributes)

```typescript
interface MystiqueThemeColours {
  primary: string;           // Primary actions
  primaryVariant: string;    // Primary transitions/contrast
  onPrimary: string;         // Text on primary
  secondary: string;         // Secondary focal points (FAB, progress)
  secondaryVariant: string;  // Secondary transitions/contrast
  onSecondary: string;       // Text on secondary
  surface: string;           // Card/modal surface
  onSurface: string;         // Text on surface
  background: string;        // Background
  onBackground: string;      // Text on background
  success: string;           // Success messages
  onSuccess: string;         // Text on success
  error: string;             // Error messages
  onError: string;           // Text on error
}
```

Use named colours (`'primary'`, `'success'`, `'error'`) instead of hex values for theme compatibility.

## 10. Common Type Helpers

```typescript
// For typing GraphQL connection responses
import { Connection, Edge } from 'mystique/types/common';

interface Order { id: string; ref: string; status: string; }
const [result] = useQuery<{ orders: Connection<Order> }>(ordersQuery);
// result.data?.orders.edges.map(e => e.node.ref)

// For typing query responses
import { QueryResponse, OkQueryResponse } from 'mystique/types/common';
// QueryResponse<T> = { status: 'loading'|'error', result?: T } | { status: 'ok', result: T }
```

## 11. Custom Action Buttons (Direct Event Dispatch)

Sometimes the standard workflow user action system is insufficient — you need a custom button with a complex form, multi-step confirmation, or conditional logic that workflow `userActions[].attributes[]` cannot express. In these cases a custom ComponentRegistry component can fire events directly via `getRest`.

### When direct event dispatch is appropriate

| Scenario | Use user actions | Use direct dispatch |
|----------|-----------------|-------------------|
| Simple status transition with standard form fields | Yes | No |
| Complex multi-entity form (e.g., amend return with stock checks) | No | Yes |
| Conditional button visibility based on multiple entity states | Partially (manifest `condition`) | Yes (full control) |
| Action that creates a new entity (e.g., fulfilment option) | No | Yes |
| Batch operations across child entities | No | Yes |
| Standard confirm/cancel with reason dropdown | Yes | No |

**Rule of thumb:** If the action can be expressed as a single workflow user action with `attributes[]` fields, use the standard system. If it needs custom queries, multi-step UI, or cross-entity logic, build a custom component.

### Pattern: Action button component on a detail page

A custom component registered as `content` that renders a button, opens a drawer/modal, collects input, and fires an event.

```typescript
import { useState } from 'react';
import { Button, Drawer, Select, MenuItem, Typography } from '@material-ui/core';
import { getRest } from 'mystique/hooks/getRest';
import { useAuth } from 'mystique/hooks/useAuth';
import { useSettings } from 'mystique/hooks/useSettings';
import { pushToast } from 'mystique/frame/pushToast';

interface Props {
  data?: any;  // Page query data scoped by dataSource
}

const CancelAction: React.FC<Props> = ({ data }) => {
  const [open, setOpen] = useState(false);
  const [reason, setReason] = useState('');
  const [submitting, setSubmitting] = useState(false);
  const auth = useAuth();

  // Load reasons from a setting
  const settings = useSettings({ reasons: 'CANCELLATION_REASON' });
  const reasons = settings?.reasons?.result?.value ?? [];

  const canCancel = data?.status && !['CANCELLED', 'COMPLETE'].includes(data.status);

  const handleSubmit = async () => {
    if (!reason || submitting) return;
    setSubmitting(true);
    try {
      const response = await getRest('api/v4.1/event/sync', 'POST', {
        name: 'CancelOrder',
        retailerId: data?.retailer?.id,
        entityType: 'ORDER',
        entityId: data?.id,
        entityRef: data?.ref,
        rootEntityType: 'ORDER',
        rootEntityId: data?.id,
        rootEntityRef: data?.ref,
        attributes: { cancellationReason: reason },
      });

      if (response?.eventStatus?.toUpperCase() === 'COMPLETE') {
        pushToast('success', 'Order cancelled successfully', 5000);
        setOpen(false);
        // Reload page to reflect new status
        setTimeout(() => window.location.reload(), 1000);
      } else {
        pushToast('error', 'Cancellation failed — check event logs', 8000);
      }
    } catch {
      pushToast('error', 'Something went wrong. Please try again.', 8000);
    } finally {
      setSubmitting(false);
    }
  };

  if (!canCancel) return null;

  return (
    <>
      <Button variant="contained" color="primary" onClick={() => setOpen(true)}>
        Cancel Order
      </Button>
      <Drawer anchor="right" open={open} onClose={() => setOpen(false)}>
        {/* Header, form fields, submit button */}
      </Drawer>
    </>
  );
};
```

**Manifest wiring:**
```json
{
  "component": "acme.content.cancel-action",
  "dataSource": "orderById",
  "props": {}
}
```

### Event payload contract

The `/api/v4.1/event/sync` endpoint expects:

```typescript
interface EventPayload {
  name: string;           // Event name — must match a workflow trigger
  retailerId: number;     // From data.retailer.id or auth.context.current.contextId
  entityType: string;     // 'ORDER', 'FULFILMENT', 'RETURN_ORDER', etc.
  entityId: string;       // Entity ID
  entityRef: string;      // Entity ref
  rootEntityType?: string;  // Usually same as entityType for top-level entities
  rootEntityId?: string;
  rootEntityRef?: string;
  accountId?: string;     // Optional — derived from auth context
  attributes?: Record<string, unknown>;  // Event attributes consumed by workflow rules
}
```

**Response shape:**
```typescript
interface EventResponse {
  id: string;
  eventStatus: 'COMPLETE' | 'FAILED' | 'NO_MATCH' | 'PENDING';
}
```

- `COMPLETE` — event processed successfully by a matching workflow ruleset
- `FAILED` — a rule threw an exception during processing
- `NO_MATCH` — no workflow ruleset matched the event + entity type + status combination
- `PENDING` — event queued for async processing (non-sync endpoint)

### Pattern: Action button inside a list row

Register a custom component, then reference it as a `"type": "component"` attribute in `fc.list`:

```typescript
// src/components/PrintLabel/PrintLabel.tsx
import { Button } from '@material-ui/core';
import { getRest } from 'mystique/hooks/getRest';
import { pushToast } from 'mystique/frame/pushToast';

interface Props {
  data?: any;  // Row node data (auto-injected by fc.list)
}

const PrintLabel: React.FC<Props> = ({ data }) => {
  const handlePrint = async () => {
    try {
      const response = await getRest(
        `api/v4/consignment/${data?.id}/labelStream`
      );
      const blob = new Blob([response], { type: 'application/pdf' });
      window.open(URL.createObjectURL(blob));
    } catch {
      pushToast('error', 'Failed to download label', 5000);
    }
  };

  return (
    <Button size="small" onClick={handlePrint} disabled={!data?.id}>
      Print
    </Button>
  );
};

// src/index.tsx
ComponentRegistry.register(['acme.content.print-label'], PrintLabel, { category: 'content' });
```

**Manifest: embed in fc.list column via component attribute:**
```json
{
  "component": "fc.list",
  "dataSource": "orderById.fulfilments",
  "props": {
    "attributes": [
      { "label": "Ref", "value": "{{node.ref}}" },
      { "label": "Status", "value": "{{node.status}}" },
      {
        "label": "Print",
        "type": "component",
        "options": {
          "component": "acme.content.print-label"
        },
        "condition": "{{and node.id}}"
      }
    ]
  }
}
```

**How data flows to the row component:** When `fc.list` renders a `"type": "component"` attribute, it passes the current row's `node` as the `data` prop to the embedded component. The component receives `{ data: node }` — the same scoping as `dataSource: "node"` on a row expansion.

### Pattern: Event-firing button in a list row

Combine the component attribute pattern with `getRest` to fire events per row:

```typescript
const MarkShipped: React.FC<{ data?: any }> = ({ data }) => {
  const [loading, setLoading] = useState(false);

  const handleClick = async () => {
    setLoading(true);
    try {
      await getRest('api/v4.1/event/sync', 'POST', {
        name: 'MarkFulfilmentShipped',
        retailerId: data?.order?.retailer?.id,
        entityType: 'FULFILMENT',
        entityId: data?.id,
        entityRef: data?.ref,
        rootEntityType: 'ORDER',
        rootEntityId: data?.order?.id,
        rootEntityRef: data?.order?.ref,
        attributes: {},
      });
      pushToast('success', `${data?.ref} marked as shipped`, 5000);
    } catch {
      pushToast('error', 'Failed to update fulfilment', 5000);
    } finally {
      setLoading(false);
    }
  };

  const canShip = data?.status === 'PACKED';

  return canShip ? (
    <Button size="small" variant="outlined" disabled={loading} onClick={handleClick}>
      {loading ? 'Shipping...' : 'Ship'}
    </Button>
  ) : null;
};

ComponentRegistry.register(['acme.content.mark-shipped'], MarkShipped, { category: 'content' });
```

**Manifest:**
```json
{
  "label": "Action",
  "type": "component",
  "options": {
    "component": "acme.content.mark-shipped"
  },
  "condition": "{{and node.id}}"
}
```

### Alternative: fc.list row.actions (OOTB user actions per row)

If the action IS a standard workflow user action, you don't need a custom component. Use `row.actions` in the manifest:

```json
{
  "component": "fc.list",
  "dataSource": "orderById.fulfilments",
  "props": {
    "row": {
      "actions": {
        "primary": [
          { "type": "userAction", "name": "Ship" }
        ],
        "secondary": [
          { "type": "userAction", "name": "Cancel" }
        ]
      }
    },
    "attributes": [...]
  }
}
```

This automatically resolves user actions per row based on the row entity's current workflow status. **Always prefer this over custom components when the action is a simple workflow transition.**

### Decision flowchart

```
Need a button/action on a page or list row?
├── Is it a workflow status transition with standard form fields?
│   ├── YES → Use fc.page actions:true or fc.list row.actions
│   └── NO  → Custom component
│
├── Does the button need complex multi-step UI (drawer, wizard, stock checks)?
│   └── YES → Custom component with getRest('api/v4.1/event/sync')
│
├── Does the button download a file or call a non-event API?
│   └── YES → Custom component with getRest/getApiDownload
│
└── Does the button need visibility logic beyond a single template condition?
    └── YES → Custom component (full JS control over rendering)
```

## 12. Material-UI Version Compatibility

### Host-provided version: v4.12.4 (PINNED)

The Mystique host app bundles Material-UI v4.12.4 and exposes it as `window["MaterialUI"]`. The SDK webpack config externalizes `@material-ui/core` imports to this global.

### Bundled v5 path (opt-in)

MUI v5+ (`@mui/material`) uses a different package name, styling engine (Emotion instead of JSS), and component API. It is **not** provided by the host — you must bundle it yourself.

**When bundled as a project dependency, v5 is a valid opt-in path.** Many production implementations use it. Trade-offs:

1. **Bundle size increase** — ~100-200KB added to your plugin
2. **Theme does not auto-propagate** — MUI v5 uses a separate `ThemeProvider` context; access host theme tokens via `useTheme()` from `@material-ui/core` and pass them manually
3. **CSS-in-JS coexistence** — MUI v4 uses JSS, MUI v5 uses Emotion; both inject `<style>` tags — use `StylesProvider` + `createGenerateClassName` to scope v4 styles
4. **You can mix v4 (externalized) and v5 (bundled)** in the same project during migration

**NEVER externalize `@mui/material`** — the host does not provide it as a global. If it appears in webpack externals, the plugin will crash at runtime.

### How to verify the project's MUI path

Check `package.json` dependencies:
```
@mui/material        ← bundled v5 (valid if in dependencies)
@mui/icons-material  ← bundled v5 icons
@mui/x-date-pickers  ← bundled v5 date pickers
```

If these are NOT in `dependencies`, all MUI imports must use v4:
```typescript
import { Box } from '@mui/material';           // ONLY valid if bundled as dependency
import { Box } from '@material-ui/core';        // ALWAYS valid — uses host MUI v4
```

### v4 equivalents (for v4-only projects)

| MUI v5+ (`@mui/material`) | MUI v4 equivalent (`@material-ui/core`) |
|---|---|
| `Box` with `sx` prop | `Box` or `div` with `makeStyles` |
| `@mui/icons-material` | `@material-ui/icons` |
| `@mui/x-date-pickers` | `@material-ui/pickers` (v3) |
| `styled()` (Emotion) | `makeStyles()` (JSS) |
| `useTheme()` from `@mui/material` | `useTheme()` from `@material-ui/core` |

### The `sx` prop

The `sx` prop (`<Box sx={{ p: 2, display: 'flex' }}>`) is a MUI v5 feature. It requires `@mui/material` bundled as a dependency. In v4-only projects, use `makeStyles` instead:

```typescript
// MUI v5 (requires @mui/material in dependencies)
<Box sx={{ padding: 2, display: 'flex', gap: 2 }}>

// MUI v4 equivalent (always works)
const useStyles = makeStyles((theme) => ({
  container: {
    padding: theme.spacing(2),
    display: 'flex',
    gap: theme.spacing(2),
  },
}));
const classes = useStyles();
<div className={classes.container}>
```

## 13. Anti-Patterns Reference

### Mutation via useQuery

**NEVER** pass a GraphQL mutation string to `useQuery()`:

```typescript
// WRONG — mutation executed on every render/variable change
const mutationString = `mutation { createFulfilmentOption(input: {...}) { id } }`;
const [result] = useQuery(mutationString, variables);

// CORRECT — use getRest for mutations
const result = await getRest('api/v4.1/graphql', 'POST', {
  query: mutationString,
  variables,
});

// ALSO CORRECT — use the Event API for entity transitions
const result = await getRest('api/v4.1/event/sync', 'POST', eventPayload);
```

**Why it's dangerous:**
- `useQuery` re-executes whenever variables change — mutations fire repeatedly
- No control over execution timing — the mutation runs during render
- No error isolation — a failed mutation breaks the component's data flow
- Semantically misleading — queries should be idempotent, mutations are not

### Using window.location.reload() after event dispatch

Prefer Mystique's built-in refetch mechanism over full page reloads:

```typescript
// AVOID — full page reload is jarring
setTimeout(() => window.location.reload(), 1000);

// BETTER — if useQuery is used, call refetch
const [result, { refetch }] = useQuery(query, vars);
// After event dispatch:
await refetch();

// ACCEPTABLE — when the entity status changes and the page query
// needs to re-evaluate (user actions, route-level data refresh).
// In this case a reload may be the simplest correct approach.
```

### Accessing localStorage for language

```typescript
// FRAGILE — depends on internal Mystique storage key
const lang = localStorage.getItem('fc.mystique.language.current');

// CORRECT — use the i18n hook
const { currentLanguage } = useI18n();
```

## 14. getQuery() — Imperative GraphQL Fetching

`getQuery` is the imperative (promise-based) counterpart to `useQuery`. Use it when you need to fetch data conditionally, after another async operation, or inside event handlers — situations where a reactive hook would fire prematurely or repeatedly.

```typescript
import { getQuery } from 'mystique/hooks/getQuery';
```

### Signature

```typescript
function getQuery<T>(query: string, variables?: Record<string, any>): Promise<{ data: T }>;
```

### When to use getQuery vs useQuery

| Scenario | Use |
|----------|-----|
| Data needed on mount or when variables change | `useQuery` (reactive) |
| Data needed after a user click or other interaction | `getQuery` (imperative) |
| Data depends on the result of another async call | `getQuery` (chained) |
| Data needed inside useEffect with conditional logic | `getQuery` (controlled) |
| Paginating through all records in a loop | `getQuery` inside an async function |

### Pattern: Conditional fetch on user interaction

```typescript
import { useState } from 'react';
import { getQuery } from 'mystique/hooks/getQuery';

const MyComponent: React.FC<{ data?: any }> = ({ data }) => {
  const [details, setDetails] = useState<any>(null);
  const [loading, setLoading] = useState(false);

  const handleExpand = async () => {
    setLoading(true);
    try {
      const result = await getQuery<{ orderById: any }>(
        `query($id: ID!) { orderById(id: $id) { items { edges { node { ref quantity } } } } }`,
        { id: data?.id }
      );
      setDetails(result.data?.orderById);
    } finally {
      setLoading(false);
    }
  };

  return (
    <div>
      <Button onClick={handleExpand} disabled={loading}>
        {loading ? 'Loading...' : 'Show Items'}
      </Button>
      {details && /* render items */}
    </div>
  );
};
```

### Pattern: Chained queries (result of first feeds second)

```typescript
const fetchInventory = async (productRef: string) => {
  // First: get catalogue ref from settings
  const settingsResult = await getQuery<{ settings: Connection<any> }>(
    `query($name: [String]) { settings(name: $name) { edges { node { lobValue } } } }`,
    { name: ['INVENTORY_CATALOGUE'] }
  );
  const catalogueRef = settingsResult.data?.settings?.edges?.[0]?.node?.lobValue;

  // Second: use catalogue ref to query inventory
  const inventoryResult = await getQuery<{ inventoryPositions: Connection<any> }>(
    `query($productRef: [String], $catalogue: InventoryCatalogueKey) {
      inventoryPositions(productRef: $productRef, catalogue: $catalogue) {
        edges { node { ref qty status locationRef } }
      }
    }`,
    { productRef: [productRef], catalogue: { ref: catalogueRef } }
  );

  return inventoryResult.data?.inventoryPositions?.edges?.map(e => e.node) ?? [];
};
```

### Anti-pattern: getQuery inside render

```typescript
// WRONG — fires on every render, no caching
const MyComponent = ({ data }) => {
  const result = getQuery(query, { id: data?.id }); // Returns a Promise, not data!
  return <div>{result}</div>; // Renders "[object Promise]"
};

// CORRECT — use useQuery for render-time data
const MyComponent = ({ data }) => {
  const [result] = useQuery(query, { id: data?.id });
  return <div>{result.data?.orderById?.ref}</div>;
};
```

## 15. Expandable Master-Detail Tables

A common OMS pattern: Order → Fulfilments → Items, with expand/collapse per row. While `fc.list` supports `row.expansion` in manifests, custom SDK components provide full control over multi-level nesting, conditional expansion, and mixed content.

### Pattern: Expandable table with state-managed rows

```typescript
import { useState } from 'react';
import {
  Table, TableHead, TableBody, TableRow, TableCell,
  IconButton, Collapse, Typography
} from '@material-ui/core';
import { MdKeyboardArrowDown, MdKeyboardArrowRight } from 'react-icons/md';

interface Fulfilment {
  id: string;
  ref: string;
  status: string;
  items: { edges: Array<{ node: FulfilmentItem }> };
}

interface Props {
  data?: { fulfilments?: { edges: Array<{ node: Fulfilment }> } };
}

const FulfilmentTable: React.FC<Props> = ({ data }) => {
  const [expandedIds, setExpandedIds] = useState<Set<string>>(new Set());

  const toggleRow = (id: string) => {
    setExpandedIds(prev => {
      const next = new Set(prev);
      next.has(id) ? next.delete(id) : next.add(id);
      return next;
    });
  };

  const fulfilments = data?.fulfilments?.edges?.map(e => e.node) ?? [];

  return (
    <Table size="small">
      <TableHead>
        <TableRow>
          <TableCell padding="checkbox" />
          <TableCell>Ref</TableCell>
          <TableCell>Status</TableCell>
          <TableCell>Items</TableCell>
        </TableRow>
      </TableHead>
      <TableBody>
        {fulfilments.map(f => (
          <>
            {/* Level 1: Fulfilment row */}
            <TableRow key={f.id} hover>
              <TableCell padding="checkbox">
                <IconButton size="small" onClick={() => toggleRow(f.id)}>
                  {expandedIds.has(f.id) ? <MdKeyboardArrowDown /> : <MdKeyboardArrowRight />}
                </IconButton>
              </TableCell>
              <TableCell>{f.ref}</TableCell>
              <TableCell>{f.status}</TableCell>
              <TableCell>{f.items?.edges?.length ?? 0}</TableCell>
            </TableRow>

            {/* Level 2: Expanded detail (items table nested via colSpan) */}
            {expandedIds.has(f.id) && (
              <TableRow>
                <TableCell colSpan={4} style={{ paddingLeft: 48, paddingBottom: 16 }}>
                  <Typography variant="subtitle2">Fulfilment Items</Typography>
                  <Table size="small">
                    <TableHead>
                      <TableRow>
                        <TableCell>SKU</TableCell>
                        <TableCell>Qty</TableCell>
                      </TableRow>
                    </TableHead>
                    <TableBody>
                      {f.items?.edges?.map(({ node: item }) => (
                        <TableRow key={item.ref}>
                          <TableCell>{item.ref}</TableCell>
                          <TableCell>{item.requestedQuantity}</TableCell>
                        </TableRow>
                      ))}
                    </TableBody>
                  </Table>
                </TableCell>
              </TableRow>
            )}
          </>
        ))}
      </TableBody>
    </Table>
  );
};
```

### Key implementation details

- **State**: Use `Set<string>` of expanded IDs (not a single `expandedId`) — users often need multiple rows expanded simultaneously
- **Nesting**: Use `colSpan` on the expanded `<TableCell>` to span the full row width, then nest a child `<Table>` inside
- **Performance**: For large lists (100+ rows), consider virtualizing with `react-window` or collapsing expanded rows when scrolled off-screen
- **3+ levels**: Repeat the pattern — the nested table can itself have expandable rows with their own `expandedIds` state

### Manifest alternative: fc.list row.expansion

For simpler 2-level expansion, prefer the manifest approach (no custom code):

```json
{
  "component": "fc.list",
  "dataSource": "orderById.fulfilments",
  "props": {
    "row": {
      "expansion": {
        "toggle": true,
        "component": "fc.list",
        "dataSource": "node.items",
        "props": {
          "attributes": [
            { "label": "SKU", "value": "{{node.ref}}" },
            { "label": "Qty", "value": "{{node.requestedQuantity}}" }
          ]
        }
      }
    },
    "attributes": [
      { "label": "Ref", "value": "{{node.ref}}" },
      { "label": "Status", "value": "{{node.status}}" }
    ]
  }
}
```

**Use custom SDK** when you need: 3+ nesting levels, conditional expansion content, mixed layouts inside expanded rows, or expand-all/collapse-all controls.

## 16. Drawer vs Dialog for Action Panels

The Mystique host provides both `pushModal` (Dialog) and `pushDrawer` (Drawer) via frame utilities. Custom components can also render MUI `<Drawer>` or `<Dialog>` directly. Each has distinct UX trade-offs.

### When to use Drawer

- **Complex multi-step forms** — cancellation with per-item quantity + reason selectors
- **Side-by-side comparison** — user sees the entity detail page alongside the action form
- **Large form content** — the form has many fields that would overflow a dialog
- **Iterative actions** — user may submit, review result, and adjust without closing

### When to use Dialog

- **Simple confirmation** — "Are you sure?" with Yes/No
- **Single-field input** — one dropdown or text field
- **Blocking action** — user MUST respond before continuing (modal focus trap)
- **Standard workflow user actions** — OOTB user action forms always render in dialogs

### Pattern: Drawer-based action panel

```typescript
import { useState } from 'react';
import { Button, Drawer, Typography, Select, MenuItem, Divider } from '@material-ui/core';
import { makeStyles } from '@material-ui/core/styles';
import { getRest } from 'mystique/hooks/getRest';
import { useSettings } from 'mystique/hooks/useSettings';
import { pushToast } from 'mystique/frame/pushToast';

const useStyles = makeStyles((theme) => ({
  drawer: { width: 420, padding: theme.spacing(3) },
  actions: { marginTop: theme.spacing(3), display: 'flex', gap: theme.spacing(1) },
}));

const CancelAction: React.FC<{ data?: any }> = ({ data }) => {
  const classes = useStyles();
  const [open, setOpen] = useState(false);
  const [reason, setReason] = useState('');

  // Settings-driven dropdown (see §18)
  const settings = useSettings({ reasons: 'CANCELLATION_REASON' });
  const reasons: Array<{ label: string; value: string }> =
    settings?.reasons?.result?.value ?? [];

  const canCancel = data?.status && !['CANCELLED', 'COMPLETE'].includes(data.status);
  if (!canCancel) return null;

  const handleSubmit = async () => {
    await getRest('api/v4.1/event/sync', 'POST', {
      name: 'CancelOrder',
      retailerId: data?.retailer?.id,
      entityType: 'ORDER',
      entityId: data?.id,
      entityRef: data?.ref,
      attributes: { cancellationReason: reason },
    });
    pushToast('success', 'Cancellation submitted', 5000);
    setOpen(false);
  };

  return (
    <>
      <Button variant="outlined" color="secondary" onClick={() => setOpen(true)}>
        Cancel Order
      </Button>
      <Drawer anchor="right" open={open} onClose={() => setOpen(false)}>
        <div className={classes.drawer}>
          <Typography variant="h6">Cancel Order</Typography>
          <Divider style={{ margin: '16px 0' }} />
          <Select fullWidth value={reason} onChange={e => setReason(e.target.value as string)}>
            {reasons.map(r => (
              <MenuItem key={r.value} value={r.value}>{r.label}</MenuItem>
            ))}
          </Select>
          <div className={classes.actions}>
            <Button variant="contained" color="primary" onClick={handleSubmit} disabled={!reason}>
              Confirm
            </Button>
            <Button onClick={() => setOpen(false)}>Cancel</Button>
          </div>
        </div>
      </Drawer>
    </>
  );
};
```

### Decision table

| Criteria | Dialog | Drawer |
|----------|--------|--------|
| Form fields | 1-3 simple fields | 4+ fields or complex inputs |
| User context | Action is modal/blocking | User benefits from seeing page data |
| Content height | Fits in viewport | May scroll independently |
| OOTB support | `pushModal()`, workflow user actions | `pushDrawer()`, or MUI `<Drawer>` |
| Anchor | Centered overlay | `right` (default), `left`, `top`, `bottom` |

## 17. Cursor-Based Auto-Pagination

When you need ALL records (not just the first page), implement cursor-based pagination. Two approaches: reactive (useQuery + state) and imperative (getQuery loop).

### Pattern 1: Reactive pagination with useQuery

```typescript
import { useState, useEffect, useCallback } from 'react';
import { useQuery } from 'mystique/hooks/useQuery';

interface PageInfo { hasNextPage: boolean; }
interface Edge<T> { node: T; cursor: string; }

const locationsQuery = `
  query($retailerId: [Int!], $after: String) {
    locations(retailerId: $retailerId, first: 50, after: $after) {
      edges { node { id ref name } cursor }
      pageInfo { hasNextPage }
    }
  }
`;

const useAllLocations = (retailerId: number) => {
  const [allLocations, setAllLocations] = useState<any[]>([]);
  const [cursor, setCursor] = useState<string | undefined>(undefined);
  const [done, setDone] = useState(false);

  const [result] = useQuery(locationsQuery, { retailerId, after: cursor });

  useEffect(() => {
    if (!result?.data?.locations) return;
    const { edges, pageInfo } = result.data.locations;
    const newNodes = edges.map((e: Edge<any>) => e.node);

    setAllLocations(prev => {
      // Deduplicate by ref
      const existingRefs = new Set(prev.map(l => l.ref));
      const unique = newNodes.filter((n: any) => !existingRefs.has(n.ref));
      return [...prev, ...unique];
    });

    if (pageInfo.hasNextPage && edges.length > 0) {
      setCursor(edges[edges.length - 1].cursor);
    } else {
      setDone(true);
    }
  }, [result]);

  return { locations: allLocations, loading: !done };
};
```

### Pattern 2: Imperative pagination with getQuery

Simpler when you don't need intermediate renders:

```typescript
import { getQuery } from 'mystique/hooks/getQuery';

async function fetchAllPages<T>(
  query: string,
  variables: Record<string, any>,
  connectionPath: string
): Promise<T[]> {
  const allNodes: T[] = [];
  let cursor: string | undefined;
  let hasMore = true;

  while (hasMore) {
    const result = await getQuery<any>(query, { ...variables, after: cursor });
    const connection = result.data?.[connectionPath];
    if (!connection?.edges?.length) break;

    allNodes.push(...connection.edges.map((e: any) => e.node));

    if (connection.pageInfo?.hasNextPage) {
      cursor = connection.edges[connection.edges.length - 1].cursor;
    } else {
      hasMore = false;
    }
  }

  return allNodes;
}

// Usage inside useEffect or event handler:
const allLocations = await fetchAllPages(
  locationsQuery,
  { retailerId: 123 },
  'locations'
);
```

### Key considerations

- **Page size**: Use `first: 50` or `first: 100` — larger pages mean fewer round-trips but heavier responses
- **Deduplication**: Always deduplicate by `ref` or `id` when accumulating across pages (reactive pattern can trigger duplicate appends)
- **Performance**: For 1000+ records, show a progress indicator and consider whether you truly need all records client-side
- **Avoid in fc.list**: The `fc.list` component handles pagination natively — only use manual pagination in custom components that aggregate or transform data

## 18. Settings-Driven Dropdown Values

Use `useSettings` to load retailer-configurable dropdown options. This keeps option lists editable without code changes.

### Pattern: Settings → MenuItem options

```typescript
import { useState } from 'react';
import { Select, MenuItem, FormControl, InputLabel } from '@material-ui/core';
import { useSettings } from 'mystique/hooks/useSettings';

interface Props {
  settingName?: string;
  label?: string;
  value?: string;
  onChange: (value: string) => void;
}

const SettingsDropdown: React.FC<Props> = ({
  settingName = 'CANCELLATION_REASON',
  label = 'Reason',
  value = '',
  onChange,
}) => {
  const settings = useSettings({ options: settingName });
  const options: Array<{ label: string; value: string }> =
    settings?.options?.result?.value ?? [];

  const loading = settings?.options?.status === 'loading';

  return (
    <FormControl fullWidth disabled={loading}>
      <InputLabel>{label}</InputLabel>
      <Select value={value} onChange={e => onChange(e.target.value as string)}>
        {options.map(opt => (
          <MenuItem key={opt.value} value={opt.value}>{opt.label}</MenuItem>
        ))}
      </Select>
    </FormControl>
  );
};
```

### Setting value format (SDK components)

When SDK components read settings via `useSettings()`, the setting value should be a direct JSON array:

```json
[
  { "label": "Damaged", "value": "DAMAGED" },
  { "label": "Wrong Item", "value": "WRONG_ITEM" },
  { "label": "Changed Mind", "value": "CHANGED_MIND" }
]
```

**Important:** This differs from workflow user action dropdown settings, which require the `{ "active": [...] }` wrapper with `name` instead of `label`. See `knowledge/frontend/mystique-patterns.md` §11 for the full format reference.

### useSettings prop syntax

```typescript
// Single setting
const settings = useSettings({ myKey: 'SETTING_NAME' });
const value = settings?.myKey?.result?.value;

// Multiple settings in one call
const settings = useSettings({
  reasons: 'CANCELLATION_REASON',
  config: 'ORDER_CONFIG',
});
const reasons = settings?.reasons?.result?.value;
const config = settings?.config?.result?.value;
```

The object key (e.g., `reasons`) is your local alias; the value is the setting name in Fluent. Each entry returns `{ status: 'loading'|'ok'|'error', result?: { name, value } }`.

## 19. Hash-Based Cross-Entity Navigation

Mystique uses hash-based routing (`#/path`). To create clickable links between entity pages from custom SDK components, construct the hash URL and either navigate programmatically or render an anchor.

### Pattern: Programmatic navigation

```typescript
// Navigate to another entity's detail page
const navigateToOrder = (orderId: string) => {
  window.location.hash = `#/orders/${orderId}`;
};

// Navigate with additional context (retailer, ref)
const navigateToProduct = (catalogueRef: string, productRef: string) => {
  window.location.hash = `#/variantProduct/${catalogueRef}/${productRef}`;
};
```

### Pattern: Clickable link in a custom component

```typescript
const OrderLink: React.FC<{ orderId: string; orderRef: string }> = ({ orderId, orderRef }) => (
  <a
    href={`#/orders/${orderId}`}
    style={{ color: 'inherit', textDecoration: 'underline', cursor: 'pointer' }}
  >
    {orderRef}
  </a>
);
```

### Pattern: Cross-entity links in fc.list (manifest)

For OOTB list components, use the `link` attribute property — no custom code needed:

```json
{
  "label": "Parent Order",
  "value": "{{node.order.ref}}",
  "link": "#/orders/{{node.order.id}}",
  "condition": "{{and node.order.id}}"
}
```

### Common route patterns

| Entity | Route | Example |
|--------|-------|---------|
| Order detail | `#/orders/:id` | `#/orders/12345` |
| Fulfilment detail | `#/fulfilment/:id` | `#/fulfilment/67890` |
| Product detail | `#/variantProduct/:catalogueRef/:productRef` | `#/variantProduct/MASTER/SKU-001` |
| Customer detail | `#/customer/:id` | `#/customer/111` |
| Location detail | `#/location/:id` | `#/location/222` |
| Inventory position | `#/inventorySearch/product/:productRef` | `#/inventorySearch/product/SKU-001` |

**Important:** Route patterns vary by account configuration and custom fragments. Always verify the target route exists in the active manifest before hardcoding links. Use browser DevTools to inspect the hash when navigating via the sidebar to discover the exact pattern.

### When to use hash navigation vs manifest links

| Scenario | Use |
|----------|-----|
| Static link in a table column | Manifest `"link"` attribute (no code) |
| Dynamic link based on component logic | `window.location.hash` in SDK component |
| Link that depends on async data | SDK component (compute URL after fetch) |
| Open in new tab | `window.open(url + '#/path', '_blank')` — note: Mystique hash routes may not work in isolation |
