--- name: cratis-react-page description: Step-by-step guidance for building a React page in a Cratis Arc application — DataPage lists, CommandDialog toolbar actions, confirmation and busy-indicator dialogs, row selection, details components, observable queries, and MVVM. Use when building or modifying a page that lists/displays data, adding a table, wiring Add/Edit/Delete, connecting a component to a proxy-generated query (standard or observable), or asking the user to confirm something. --- ## Workflow ### Step 1 — Prerequisites - Backend query and command endpoints must already exist (see `cratis-readmodel` and `cratis-command` skills). - Run a Debug `dotnet build` on the backend to regenerate proxies before importing them. Import `DataPage` (and its `Column`/`MenuItem` helpers) from the **subpath**, not the root barrel: ```tsx import { DataPage, MenuItem } from '@cratis/components/DataPage'; import { Column } from '@cratis/components/DataPage'; import { CommandDialog } from '@cratis/components/CommandDialog'; import { useDialog, DialogProps } from '@cratis/arc.react/dialogs'; ``` --- ### Step 2 — Basic DataPage setup `DataPage` combines a toolbar/menu, a data table, and an optional details component. `title`, `query`, `emptyMessage`, and `children` are required; columns are declared compositionally inside `` using PrimeReact ``. ```tsx import { DataPage } from '@cratis/components/DataPage'; import { Column } from '@cratis/components/DataPage'; import { AllAccounts } from './AllAccounts'; export const AccountsPage = () => ( ); ``` --- ### Step 3 — Add menu actions Toolbar actions go in ``. `MenuItem` is a PrimeReact menu item (use `command`, not `onClick`); the `disableOnUnselected` flag greys it out until a row is selected. Create a separate dialog component using `DialogProps`, then wire it up with `useDialog`. **Dialog component (`CreateAccountDialog.tsx`):** ```tsx import { DialogProps } from '@cratis/arc.react/dialogs'; import { CommandDialog } from '@cratis/components/CommandDialog'; import { InputTextField } from '@cratis/components/CommandForm'; import { CreateAccount } from './CreateAccount'; export const CreateAccountDialog = ({ closeDialog }: DialogProps) => ( command={CreateAccount} title="Create Account" okLabel="Create"> value={c => c.name} title="Account Name" /> ); ``` **Page component:** ```tsx import { DataPage, MenuItem } from '@cratis/components/DataPage'; import { Column } from '@cratis/components/DataPage'; import { useDialog } from '@cratis/arc.react/dialogs'; import { CreateAccountDialog } from './CreateAccountDialog'; export const AccountsPage = () => { const [CreateAccountWrapper, showCreateAccount] = useDialog(CreateAccountDialog); return ( <> showCreateAccount()} /> ); }; ``` See [dialogs.md](https://github.com/Cratis/AI/blob/main/.ai/rules/dialogs.md) and the `stepper-command-dialog` skill for the full dialog patterns. #### Confirming, and showing that something is in progress Do not build either of these into a page. `ConfirmationDialog` and `BusyIndicatorDialog` are registered once at the app root through `DialogComponents` and raised by hook from anywhere, which is what keeps every confirmation and every busy indicator looking the same: ```tsx import { DialogButtons, DialogResult, useConfirmationDialog, useBusyIndicator } from '@cratis/arc.react/dialogs'; const [confirm] = useConfirmationDialog(); if (await confirm('Delete this account?', `"${account.name}" disappears permanently.`, DialogButtons.YesNo) !== DialogResult.Yes) return; ``` **A busy indicator dialog is only for work that genuinely has to block the user.** It is modal and deliberately non-dismissible, so it takes the whole screen away until the work finishes — reach for it when carrying on would be wrong: a multi-step import, a migration, something the next click would corrupt or duplicate. For everything else — and that is most of it — the in-flight button is the right control. A command that appends events and returns in milliseconds sits behind a button that already disables itself and shows progress (eventual-consistency rule 9); putting a modal in front of it makes the screen flash and tells the user nothing. Never open one just to signal "working". When you do use it, pair it with `closeBusy()` in a `finally` — a non-dismissible dialog whose close was skipped strands the user with no way out: ```tsx const [showBusy, closeBusy] = useBusyIndicator('Importing', 'This takes a moment.'); showBusy(); try { await importEverything(); } finally { closeBusy(); } ``` --- ### Step 4 — Row selection and edit dialog Track selection with `selection` + `onSelectionChange`, and supply the row data as props to the edit dialog. **Edit dialog (`EditAccountDialog.tsx`):** ```tsx import { DialogProps } from '@cratis/arc.react/dialogs'; import { CommandDialog } from '@cratis/components/CommandDialog'; import { InputTextField } from '@cratis/components/CommandForm'; import { EditAccount } from './EditAccount'; interface EditAccountDialogProps extends DialogProps { accountId: string; name: string; } export const EditAccountDialog = ({ accountId, name }: EditAccountDialogProps) => ( command={EditAccount} title="Edit Account" okLabel="Save" initialValues={{ accountId }} currentValues={{ name }}> value={c => c.name} title="Account Name" /> ); ``` **Page wiring:** ```tsx const [selected, setSelected] = useState(); const [EditAccountWrapper, showEditAccount] = useDialog(EditAccountDialog); { setSelected(e.value); if (e.value) showEditAccount({ accountId: e.value.id, name: e.value.name }); }}> ``` - `initialValues` sets the change-tracking baseline (e.g. IDs that must be present but aren't user-entered). - `currentValues` pre-populates the visible field values. --- ### Step 5 — Observable vs standard query The **same `query` prop** accepts a standard query (`IQueryFor`) or an observable query (`IObservableQueryFor`) — there is no separate `observableQuery` prop. Pass the observable query proxy and `DataPage` subscribes to live updates automatically: ```tsx ``` Observable results push updates automatically; for snapshot data that changes only on user action, pass the standard query and call `onRefresh` after a command succeeds. --- ### Step 6 — Details component (optional) `detailsComponent` renders detail for the selected row. It receives `{ item, onRefresh }`: ```tsx import { IDetailsComponentProps } from '@cratis/components/DataPage'; const AccountDetail = ({ item }: IDetailsComponentProps) => (
{item.name}
); ``` --- ### Step 7 — MVVM view model (for complex pages) For pages with complex state or coordination logic, wrap the page in a view model (see [react.md](https://github.com/Cratis/AI/blob/main/.ai/rules/react.md)): ```tsx import { withViewModel } from '@cratis/arc.react.mvvm'; import { injectable } from 'tsyringe'; @injectable() class AccountsViewModel { selectedAccount?: AccountSummary; select(account: AccountSummary) { this.selectedAccount = account; } } export const AccountsPage = withViewModel(AccountsViewModel, ({ viewModel }) => ( viewModel.select(e.value)}> )); ``` Read `viewModel.property` inside JSX (never destructure observables at the top of the body). See [react.md](https://github.com/Cratis/AI/blob/main/.ai/rules/react.md) for the full MVVM rules. --- ## Quick decision guide | Need | Use | | --- | --- | | Read-only list | `DataPage` with a standard `query` | | Real-time updates | `DataPage` with an observable query passed to the same `query` prop | | Add / create action | `` + `MenuItem` + `CommandDialog` + `useDialog` | | Edit selected row | `selection` + `onSelectionChange` + `CommandDialog` + `currentValues`/`initialValues` | | Detail for selected row | `detailsComponent` prop | | Complex page logic | `withViewModel` MVVM wrapper | ## Key DataPage props | Prop | Purpose | | --- | --- | | `title` (required) | toolbar title | | `query` (required) | the query proxy — standard or observable | | `emptyMessage` (required) | shown when there are no rows | | `children` (required) | `` + optional `` | | `queryArguments` | arguments passed to the query | | `selection` / `onSelectionChange` | controlled single-row selection | | `detailsComponent` | `React.FC>` rendered for the selected row | | `globalFilterFields` / `defaultFilters` / `clientFiltering` | filtering | | `onRefresh` | invoked to re-fetch a standard query | | `tablePt` / `menubarPt` / `*Unstyled` | PrimeReact pass-through styling |