Workflow
Step 1 — Prerequisites
- Backend query and command endpoints must already exist (see
cratis-readmodelandcratis-commandskills). - Run a Debug
dotnet buildon the backend to regenerate proxies before importing them.
Import DataPage (and its Column/MenuItem helpers) from the subpath, not the root barrel:
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 <DataPage.Columns> using PrimeReact <Column>.
import { DataPage } from '@cratis/components/DataPage';
import { Column } from '@cratis/components/DataPage';
import { AllAccounts } from './AllAccounts';
export const AccountsPage = () => (
<DataPage
title="Accounts"
query={AllAccounts}
emptyMessage="No accounts yet.">
<DataPage.Columns>
<Column field="name" header="Name" />
<Column field="balance" header="Balance" />
</DataPage.Columns>
</DataPage>
);
Step 3 — Add menu actions
Toolbar actions go in <DataPage.MenuItems>. 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):
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) => (
<CommandDialog<CreateAccount> command={CreateAccount} title="Create Account" okLabel="Create">
<InputTextField<CreateAccount> value={c => c.name} title="Account Name" />
</CommandDialog>
);
Page component:
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 (
<>
<DataPage title="Accounts" query={AllAccounts} emptyMessage="No accounts yet.">
<DataPage.Columns>
<Column field="name" header="Name" />
</DataPage.Columns>
<DataPage.MenuItems>
<MenuItem label="Add Account" command={() => showCreateAccount()} />
</DataPage.MenuItems>
</DataPage>
<CreateAccountWrapper />
</>
);
};
See 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:
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:
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):
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) => (
<CommandDialog<EditAccount>
command={EditAccount}
title="Edit Account"
okLabel="Save"
initialValues={{ accountId }}
currentValues={{ name }}>
<InputTextField<EditAccount> value={c => c.name} title="Account Name" />
</CommandDialog>
);
Page wiring:
const [selected, setSelected] = useState<AccountSummary | undefined>();
const [EditAccountWrapper, showEditAccount] = useDialog(EditAccountDialog);
<DataPage
title="Accounts"
query={AllAccounts}
emptyMessage="No accounts yet."
selection={selected}
=> {
setSelected(e.value);
if (e.value) showEditAccount({ accountId: e.value.id, name: e.value.name });
}}>
<DataPage.Columns>
<Column field="name" header="Name" />
</DataPage.Columns>
</DataPage>
<EditAccountWrapper />
initialValuessets the change-tracking baseline (e.g. IDs that must be present but aren't user-entered).currentValuespre-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:
<DataPage title="Accounts" query={ObserveAllAccounts} emptyMessage="No accounts yet.">
<DataPage.Columns>
<Column field="name" header="Name" />
</DataPage.Columns>
</DataPage>
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 }:
import { IDetailsComponentProps } from '@cratis/components/DataPage';
const AccountDetail = ({ item }: IDetailsComponentProps<AccountSummary>) => (
<div>{item.name}</div>
);
<DataPage title="Accounts" query={AllAccounts} emptyMessage="No accounts yet." detailsComponent={AccountDetail}>
<DataPage.Columns>
<Column field="name" header="Name" />
</DataPage.Columns>
</DataPage>
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):
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 }) => (
<DataPage title="Accounts" query={AllAccounts} emptyMessage="No accounts yet."
selection={viewModel.selectedAccount}
=> viewModel.select(e.value)}>
<DataPage.Columns>
<Column field="name" header="Name" />
</DataPage.Columns>
</DataPage>
));
Read viewModel.property inside JSX (never destructure observables at the top of the body). See 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 | <DataPage.MenuItems> + 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) |
<DataPage.Columns> + optional <DataPage.MenuItems> |
queryArguments |
arguments passed to the query |
selection / onSelectionChange |
controlled single-row selection |
detailsComponent |
React.FC<IDetailsComponentProps<T>> rendered for the selected row |
globalFilterFields / defaultFilters / clientFiltering |
filtering |
onRefresh |
invoked to re-fetch a standard query |
tablePt / menubarPt / *Unstyled |
PrimeReact pass-through styling |