Getting Started with the View Engine: Wire One Business Object
This page answers: how does a React application, starting from zero, wire one Wow business object into the view engine and get a list it can filter, sort, save views of and send commands from?
It walks through the sales orders of Wow's example server (the sales-order aggregate): the server runs in Docker, the application is a Vite + React project. At the end, /orders is a whole order list — the system views "To ship" and "All orders", filters, columns, sorting, totals, saving a view of your own — with a "Ship" button on every paid order.
1. Prerequisites
- Node.js 22.12 or later, TypeScript 6 or later, React 19.0 or later (the peer range of the view engine's
/reactand/uientries; see Compatibility and Versions). - A Vite + React + TypeScript project. If you have none, create one from Vite's
react-tstemplate:pnpm create vite orders-console --template react-ts. - Docker, to run the example server and its MongoDB.
2. Start the example server
The example server's image ships with every Wow release (ahoowang/wow-example-server, also on GHCR and Aliyun). The view engine sends snapshot queries, and snapshots must live in MongoDB: the image's own configuration keeps its stores in memory, where a snapshot query answers QuerySchemaUnavailable, so the environment below moves events and snapshots to MongoDB.
docker network create wow-example
docker run -d --name wow-example-mongo --network wow-example \
-e MONGO_INITDB_ROOT_USERNAME=root -e MONGO_INITDB_ROOT_PASSWORD=root \
-e GLIBC_TUNABLES=glibc.pthread.rseq=1 \
mongo:8.3.11
docker run -d --name wow-example-server --network wow-example -p 8080:8080 \
-e SPRING_AUTOCONFIGURE_EXCLUDE=org.springframework.boot.elasticsearch.autoconfigure.ElasticsearchClientAutoConfiguration,org.springframework.boot.elasticsearch.autoconfigure.ElasticsearchRestClientAutoConfiguration \
-e 'SPRING_MONGODB_URI=mongodb://root:root@wow-example-mongo:27017/wow_example_db?authSource=admin' \
-e WOW_EVENTSOURCING_STORE_STORAGE=mongo \
-e WOW_EVENTSOURCING_SNAPSHOT_STORAGE=mongo \
ahoowang/wow-example-server:9.2.0SPRING_AUTOCONFIGURE_EXCLUDEreplaces the whole exclusion list of the image's configuration: that list excludes MongoDB's auto-configuration too, and this one excludes only Elasticsearch.GLIBC_TUNABLESsidesteps MongoDB 8 exiting at start on some Linux kernels (SERVER-121912); Wow's CI starts it the same way.
The server is ready when curl http://localhost:8080/actuator/health/liveness answers {"status":"UP"}. Then write three orders and pay the first two in full:
order() {
curl -s http://localhost:8080/tenant/demo/owner/demo/sales-order \
-H 'Content-Type: application/json' -H 'Command-Wait-Stage: SNAPSHOT' \
-d "{\"items\":[{\"productId\":\"$1\",\"price\":10,\"quantity\":$2}],
\"address\":{\"country\":\"China\",\"province\":\"Zhejiang\",
\"city\":\"$3\",\"district\":\"$3\",\"detail\":\"No. 1\"},
\"fromCart\":false}" \
| sed -E 's/.*"aggregateId":"([^"]+)".*/\1/'
}
pay() {
curl -s http://localhost:8080/tenant/demo/sales-order/$1/pay \
-H 'Content-Type: application/json' -H 'Command-Wait-Stage: SNAPSHOT' \
-d "{\"paymentId\":\"pay-$1\",\"amount\":$2}" > /dev/null
}
pay "$(order book 3 Hangzhou)" 30
pay "$(order pen 5 Ningbo)" 50
order cup 2 Wenzhou > /dev/nullThe orders belong to the tenant demo; the example prices every product at 10 and refuses any other price.
3. Install
A minor release of the Wow packages may break, so first have pnpm save them with a ~ range, which keeps them on one minor (version ranges). Add this line to the project's pnpm-workspace.yaml, creating the file if the project has none:
savePrefix: '~'Then install:
pnpm add @ahoo-wang/wow-view-engine @ahoo-wang/wow-client \
@ahoo-wang/fetcher @ahoo-wang/fetcher-decorator @ahoo-wang/fetcher-eventstream \
react react-dom react-router
pnpm add -D vite @vitejs/plugin-react typescript @types/react @types/react-domThe three fetcher packages are wow-client's peers; react and react-dom are the peers of the /react and /ui entries, and react-router only /react-router needs. pnpm leaves the packages Vite's template already installed where they are.
The project must import JSON (step 4's query descriptor) and read the types Vite declares for stylesheet imports. CI compiles this page's code with exactly this tsconfig.json, and checks that the install commands above add every package it imports:
{
"compilerOptions": {
"target": "ES2022",
"lib": ["ES2023", "DOM", "DOM.Iterable"],
"module": "ESNext",
"moduleResolution": "Bundler",
"jsx": "react-jsx",
"types": ["vite/client"],
"resolveJsonModule": true,
"strict": true,
"skipLibCheck": true,
"noEmit": true
},
"include": ["src"]
}The example server sends no CORS headers, so during development Vite forwards /api to it and the browser talks only to Vite, on its own origin:
// vite.config.ts
import react from '@vitejs/plugin-react';
import { defineConfig } from 'vite';
export default defineConfig({
plugins: [react()],
server: {
proxy: {
'/api': {
target: 'http://localhost:8080',
rewrite: path => path.replace(/^\/api/, ''),
},
},
},
});4. Save the query descriptor
The server publishes a query descriptor for each aggregate (GET /sales-order/snapshot/schema): which fields there are, the type of each, an enumeration's values, and how each field filters, sorts and aggregates. Save it into the project, beside the definition:
curl -o src/salesOrderDescriptor.json http://localhost:8080/sales-order/snapshot/schemaThe definition is built from this file when its module loads, and a test builds the same one; at run time the engine reads the server's descriptor again and offers only what this deployment answers today. Save it again after upgrading the server.
What the file holds (an excerpt: the five fields the next step lists)
{
"model": "SNAPSHOT",
"version": "sha256:a30adffc8907fe76ad70e44161b1aaa2e5448d8db1717a22f25653f9dbed6ebc",
"timeZone": "UTC",
"record": {
"identity": "aggregateId",
"paging": ["LIST", "PAGED", "CURSOR"],
"defaultScope": "ACTIVE",
"rootOperators": ["ID", "IDS", "AGGREGATE_ID", "AGGREGATE_IDS", "TENANT_ID", "OWNER_ID", "SPACE_ID", "DELETION", "EXPRESSION"]
},
"limits": {
"maxListSize": 1000,
"defaultListSize": 100,
"maxPageSize": 100,
"maxPageWindow": 10000,
"maxFilterNodes": 128,
"maxFilterValues": 1000,
"maxSortFields": 32,
"aggregation": {
"maxGroups": 32,
"maxMetrics": 64,
"maxElements": 5,
"maxLimit": 1000,
"maxExpressionDepth": 8,
"maxExpressionNodes": 256
}
},
"analysis": {
"metrics": ["COUNT", "NUMERIC", "ANY", "DISTINCT_COUNT", "PERCENTILE", "DERIVED", "FIRST", "LAST"],
"approximate": ["PERCENTILE"],
"expressions": true,
"having": {
"metrics": ["COUNT", "NUMERIC", "DISTINCT_COUNT", "PERCENTILE", "DERIVED"]
},
"sort": {"groups": true, "metrics": true},
"dense": true,
"dateUnits": ["YEAR", "QUARTER", "MONTH", "WEEK", "DAY", "HOUR", "MINUTE", "SECOND"],
"dateParts": ["DAY_OF_WEEK", "DAY_OF_MONTH", "HOUR_OF_DAY", "MONTH_OF_YEAR"],
"dateDiffUnits": ["SECOND", "MINUTE", "HOUR", "DAY"],
"firstLastOrderBy": "eventTime"
},
"fields": [
{
"path": "aggregateId",
"role": "AGGREGATE_ID",
"types": ["STRING"],
"kind": "SCALAR",
"nullable": false,
"project": true,
"filter": {
"operators": ["EQ", "NE", "GT", "GTE", "LT", "LTE", "CONTAINS", "STARTS_WITH", "ENDS_WITH", "IN", "NOT_IN", "BETWEEN", "IS_EMPTY_STRING", "IS_NOT_EMPTY_STRING", "IS_NULL", "IS_NOT_NULL", "EXISTS", "NOT_EXISTS"]
},
"sort": {"paged": true, "cursor": true},
"aggregate": {
"groups": ["TERMS"],
"missingKey": true,
"functions": [],
"distinctCount": true,
"percentile": false,
"any": true,
"firstLast": true,
"expressionInput": false,
"inMetricFilter": true
},
"aliases": []
},
{
"path": "firstEventTime",
"role": "FIRST_EVENT_TIME",
"types": ["INTEGER"],
"kind": "SCALAR",
"nullable": false,
"semantic": {"type": "TEMPORAL_EPOCH", "timeUnit": "MILLISECONDS"},
"project": true,
"filter": {
"operators": ["EQ", "NE", "GT", "GTE", "LT", "LTE", "IN", "NOT_IN", "BETWEEN", "IS_NULL", "IS_NOT_NULL", "EXISTS", "NOT_EXISTS", "TODAY", "BEFORE_TODAY", "TOMORROW", "THIS_WEEK", "NEXT_WEEK", "LAST_WEEK", "THIS_MONTH", "LAST_MONTH", "RECENT_DAYS", "EARLIER_DAYS", "YESTERDAY", "NEXT_MONTH", "LAST_YEAR", "THIS_YEAR", "NEXT_YEAR", "BEFORE_NOW", "AFTER_NOW"]
},
"sort": {"paged": true, "cursor": true},
"aggregate": {
"groups": ["TERMS", "HISTOGRAM", "DATE_HISTOGRAM", "DATE_PART"],
"missingKey": false,
"functions": ["SUM", "AVG", "MIN", "MAX", "STDDEV", "VARIANCE"],
"distinctCount": true,
"percentile": true,
"any": true,
"firstLast": true,
"expressionInput": true,
"inMetricFilter": true
},
"aliases": []
},
{
"path": "state.address.city",
"types": ["STRING"],
"kind": "SCALAR",
"nullable": false,
"project": true,
"filter": {
"operators": ["EQ", "NE", "GT", "GTE", "LT", "LTE", "CONTAINS", "STARTS_WITH", "ENDS_WITH", "IN", "NOT_IN", "BETWEEN", "IS_EMPTY_STRING", "IS_NOT_EMPTY_STRING", "IS_NULL", "IS_NOT_NULL", "EXISTS", "NOT_EXISTS"]
},
"sort": {"paged": true, "cursor": true},
"aggregate": {
"groups": ["TERMS"],
"missingKey": true,
"functions": [],
"distinctCount": true,
"percentile": false,
"any": true,
"firstLast": true,
"expressionInput": false,
"inMetricFilter": true
},
"aliases": []
},
{
"path": "state.status",
"types": ["STRING"],
"kind": "SCALAR",
"nullable": false,
"enum": [
{"value": "CREATED"},
{"value": "PAID"},
{"value": "SHIPPED"},
{"value": "RECEIVED"}
],
"project": true,
"filter": {
"operators": ["EQ", "NE", "GT", "GTE", "LT", "LTE", "CONTAINS", "STARTS_WITH", "ENDS_WITH", "IN", "NOT_IN", "BETWEEN", "IS_EMPTY_STRING", "IS_NOT_EMPTY_STRING", "IS_NULL", "IS_NOT_NULL", "EXISTS", "NOT_EXISTS"]
},
"sort": {"paged": true, "cursor": true},
"aggregate": {
"groups": ["TERMS"],
"missingKey": true,
"functions": [],
"distinctCount": true,
"percentile": false,
"any": true,
"firstLast": true,
"expressionInput": false,
"inMetricFilter": true
},
"aliases": []
},
{
"path": "state.totalAmount",
"types": ["DECIMAL"],
"kind": "SCALAR",
"nullable": false,
"project": true,
"filter": {
"operators": ["EQ", "NE", "GT", "GTE", "LT", "LTE", "IN", "NOT_IN", "BETWEEN", "IS_NULL", "IS_NOT_NULL", "EXISTS", "NOT_EXISTS"]
},
"sort": {"paged": true, "cursor": true},
"aggregate": {
"groups": ["TERMS", "HISTOGRAM"],
"missingKey": false,
"functions": ["SUM", "AVG", "MIN", "MAX", "STDDEV", "VARIANCE"],
"distinctCount": true,
"percentile": true,
"any": true,
"firstLast": true,
"expressionInput": true,
"inMetricFilter": true
},
"aliases": []
}
],
"elements": [],
"dynamic": [],
"constraints": [
{"type": "CURSOR_UNIQUE_SORT", "appended": "aggregateId"}
]
}5. Declare the definition: defineView
A definition says how this data can be observed. The facts — paths, types, enumeration values — are read from the query descriptor; defineView writes only the choices: which fields to list, in what order, under what names, the tone of each status, and the system views that ship with the definition. A field not listed does not appear; a path or a value the descriptor lacks is reported by admission rather than thrown.
// src/orders.ts
import type { QueryModelDescriptor } from '@ahoo-wang/wow-client';
import { defineView, text } from '@ahoo-wang/wow-view-engine';
import descriptor from './salesOrderDescriptor.json';
export const ORDERS = 'sales-orders';
export const ordersDefinition = defineView(
descriptor as unknown as QueryModelDescriptor,
{
id: ORDERS,
// The key the engine finds the data source under (step 6).
source: 'sales-order',
// Words are keys, said in the host's words (step 8).
title: text('orders.title'),
// Only the fields listed appear, in this order.
fields: {
aggregateId: { label: text('orders.id'), cell: 'copyable' },
'state.status': {
label: text('orders.status'),
cell: 'status',
options: {
CREATED: { label: text('orders.created'), tone: 'neutral' },
PAID: { label: text('orders.paid'), tone: 'warning' },
SHIPPED: { label: text('orders.shipped'), tone: 'success' },
RECEIVED: { label: text('orders.received'), tone: 'success' },
},
},
'state.address.city': text('orders.city'),
'state.totalAmount': { label: text('orders.total'), summary: ['SUM'] },
firstEventTime: text('orders.placedAt'),
},
// Step 7's "Ship" reads every row's status, whether the list shows it
// or not. A row is not the whole document: it carries the shown
// columns, the row key, the card layout's fields (when the definition
// allows cards) and the fields sorts and summaries read; the fields a
// row must carry besides are named here.
record: { rowFields: ['state.status'] },
// System views: deployed with the definition, read-only for everyone,
// the starting points readers save their own views from.
views: [
{
id: 'to-ship',
title: text('orders.toShip'),
config: {
kind: 'record',
filter: {
op: 'and',
children: [
{ field: 'state.status', operator: 'IN', value: ['PAID'] },
],
},
filterMode: 'simple',
refresh: { interval: null },
sort: [{ field: 'firstEventTime', direction: 'ASC' }],
pageSize: 20,
summaries: [{ field: 'state.totalAmount', fn: 'SUM' }],
layout: 'table',
table: {
columns: [
{ field: 'aggregateId' },
{ field: 'state.address.city' },
{ field: 'state.totalAmount' },
{ field: 'firstEventTime' },
],
},
card: { title: 'aggregateId', fields: ['state.totalAmount'] },
},
},
{
id: 'all',
title: text('orders.all'),
config: {
kind: 'record',
filter: { op: 'and', children: [] },
filterMode: 'simple',
refresh: { interval: null },
sort: [{ field: 'firstEventTime', direction: 'DESC' }],
pageSize: 20,
summaries: [{ field: 'state.totalAmount', fn: 'SUM' }],
layout: 'table',
table: {
columns: [
{ field: 'aggregateId' },
{ field: 'state.status' },
{ field: 'state.address.city' },
{ field: 'state.totalAmount' },
{ field: 'firstEventTime' },
],
},
card: { title: 'aggregateId', fields: ['state.status'] },
},
},
],
},
);
/**
* The words of the keys above and of step 7's action. A host with more
* languages keeps one table per language.
*/
export const ORDER_WORDS = {
'orders.title': 'Sales orders',
'orders.id': 'Order',
'orders.status': 'Status',
'orders.created': 'Awaiting payment',
'orders.paid': 'Paid',
'orders.shipped': 'Shipped',
'orders.received': 'Received',
'orders.city': 'City',
'orders.total': 'Amount',
'orders.placedAt': 'Placed',
'orders.toShip': 'To ship',
'orders.all': 'All orders',
'orders.ship': 'Ship',
'orders.shipTitle': 'Ship {count} orders?',
'orders.shipTitle-one': 'Ship this order?',
'orders.notPaid': 'Only a paid order can ship',
};6. The engine and its resources
The engine sends only Wow queries (paged, cursor, aggregation), so wow-client's snapshot query client is the data source as it is; describe lets the engine read the server's current query descriptor before its first query. One engine per application, built once at start: each resource pairs a definition with its data source, and every page shares the query cache and the descriptors.
// src/engine.ts
import { Fetcher } from '@ahoo-wang/fetcher';
import {
QueryClientFactory,
ResourceAttributionPathSpec,
} from '@ahoo-wang/wow-client';
import {
MemoryViewStore,
ViewEngine,
type ViewSource,
} from '@ahoo-wang/wow-view-engine';
import { ordersDefinition } from './orders';
/** The tenant step 2 wrote the orders under. */
export const TENANT = 'demo';
/** The example server, through Vite's `/api` proxy (step 3). */
export const fetcher = new Fetcher({ baseURL: '/api' });
function salesOrderSource(): ViewSource {
const factory = new QueryClientFactory({
aggregateName: 'sales-order',
// Queries `tenant/demo/sales-order/snapshot/...`: this tenant's orders.
resourceAttribution: ResourceAttributionPathSpec.TENANT,
urlParams: { path: { tenantId: TENANT } },
fetcher,
});
const snapshots = factory.createSnapshotQueryClient();
const descriptors = factory.createQueryDescriptorClient();
return {
paged: (query, attributes, abort) =>
snapshots.paged(query, attributes, abort),
cursor: (query, attributes, abort) =>
snapshots.cursor(query, attributes, abort),
aggregate: (query, attributes, abort) =>
snapshots.aggregate(query, attributes, abort),
describe: (previous, attributes, abort) =>
descriptors.describeSnapshot(previous, attributes, abort),
};
}
export const engine = new ViewEngine({
resources: [{ definition: ordersDefinition, source: salesOrderSource() }],
// The views readers save; `MemoryViewStore` forgets them on reload.
store: new MemoryViewStore(),
});MemoryViewStore keeps this page free of storage. To keep saved views, use WowViewStore from @ahoo-wang/wow-view-store, which keeps them on Wow's view store server — the example server already embeds it.
7. Declare one action
The commands on a record are declared, not drawn: the host says what — the command, when it is available, why not when it is not, whether to ask first; the engine does the how — the primary action is a button in the row; over a selection the button reads "Ship 2/3" and lists the ones that cannot, with why; then the confirmation, progress and per-record results, and the view is read again when the run ends.
// src/actions.ts
import {
CommandClient,
CommandStage,
waitStrategy,
} from '@ahoo-wang/wow-client';
import { actions, text, type RecordRow } from '@ahoo-wang/wow-view-engine';
import { fetcher, TENANT } from './engine';
const commands = new CommandClient({
fetcher,
basePath: `tenant/${TENANT}/sales-order`,
});
// Answer once the snapshot is written: the engine reads the view again as
// soon as `run` resolves, and must see the order shipped.
const headers = waitStrategy({ stage: CommandStage.SNAPSHOT });
const paid = (row: RecordRow) =>
(row.data.state as { status?: string } | undefined)?.status === 'PAID';
export const orderActions = actions([
{
id: 'ship',
label: text('orders.ship'),
// A button in the row; other actions go behind the row's "⋯" menu.
primary: true,
// `true`, or why not: the reason shows on the disabled button.
available: row => (paid(row) ? true : text('orders.notPaid')),
// Routine, but not taken back once sent: asked for one order too, with no danger tone.
confirm: { title: text('orders.shipTitle') },
// Rejects when the server refuses; the engine reports why on that order.
run: async row => {
await commands.send({
path: `${row.key}/package`,
method: 'POST',
headers,
body: {},
});
},
},
]);Shipping is the example server's ShipOrder command (POST /tenant/{tenantId}/sales-order/{id}/package). With a command client generated by wow-generator, run calls its method instead, and the route and body are typed.
8. ViewHost and the page
ViewHost is the one layer the host writes around its pages: the data (the engine), the router, the language and its words, the theme, and what each resource does in this host (bind: which route it lives on, which actions it carries). The workbench needs only the definition's id.
// src/App.tsx
import '@ahoo-wang/wow-view-engine/styles.css';
import '@ahoo-wang/wow-view-engine/themes/porcelain.css';
import { useReactRouter } from '@ahoo-wang/wow-view-engine/react-router';
import {
bind,
DataWorkbench,
en,
ViewHost,
} from '@ahoo-wang/wow-view-engine/ui';
import { orderActions } from './actions';
import { engine } from './engine';
import { ORDER_WORDS, ORDERS } from './orders';
/** The engine's own English words, and the definition's keys. */
const MESSAGES = { ...en, ...ORDER_WORDS };
const BINDINGS = [
bind(ORDERS, {
// Every way to the orders goes through this route; the open view is
// in `?view=`.
route: view =>
view === null ? '/orders' : `/orders?${new URLSearchParams({ view })}`,
actions: orderActions,
}),
];
export function App() {
return (
<ViewHost
engine={engine}
router={useReactRouter()}
locale="en"
messages={MESSAGES}
bindings={BINDINGS}
preset="porcelain"
>
<main style={{ height: '100vh' }}>
<DataWorkbench definitionId={ORDERS} />
</main>
</ViewHost>
);
}// src/main.tsx
import { StrictMode } from 'react';
import { createRoot } from 'react-dom/client';
import { BrowserRouter } from 'react-router';
import { App } from './App';
createRoot(document.getElementById('root')!).render(
<StrictMode>
<BrowserRouter>
<App />
</BrowserRouter>
</StrictMode>,
);- Router: call
useReactRouter()inside a React Router router component. With a router the engine keeps the address itself: the workbench's open view is in?view=, a record's detail follows?id=. Over another router library, write the two members ofViewRouter(locationandgo) yourself. - Theme:
preset="porcelain"wears one of the engine's built-in looks, whose stylesheet is imported; a host with a shadcn theme writestheme="host"and importsshadcn-bridge.cssinstead. See Theming the View Engine. - Light and dark:
colorModeissystemby default; the engine follows the system and puts.darkon<html>. - Height: the workbench fills its container, so the container needs a height.
9. Run it
pnpm devOpen http://localhost:5173/orders:
- The first system view, "To ship", opens with the two paid orders, and their amounts totalled under the table.
- Press "Ship" on a row and confirm "Ship this order?": the command is sent, the snapshot is written, the view is read again, and that order leaves "To ship". Select several and press "Ship N", and the engine counts them in the same question.
- Switch to "All orders": all three are there; the third is "Awaiting payment", and its "Ship" button is disabled with the reason.
- Add a filter (a city, say) and the title says it is not saved; "Save as" keeps it as your own view, and the address's
?view=follows.
The full working version
- Storybook's integration walk-through wires an order object in the same five steps, with navigation, words in more than one language, a second action and an in-memory router; the bottom of the page is it running.
- Its source files:
ordersDefinition.ts,wowSource.ts,ordersEngine.ts,OrdersHost.tsx,orderActions.ts,wowCommands.ts,OrdersPage.tsx, andintegration.test.ts, which holds them to/testing'sadmitandactionHarness. - A real host: the compensation console's
src/views/.
Next steps
| Next | Read |
|---|---|
| What the view engine is and the facts it rests on | View Engine |
| The words this page used: definition, record and analysis views, boards, system / shared / personal views, revision, the store | View Engine Core Concepts |
Finish step 5's definition: text keys, narrowing, rowFields, system views and boards, checking it with admit | Writing a Definition |
Wiring beyond step 8: bind, the router port, navigation, embeds, messages and locale, testing with /testing | Fitting the View Engine into a Host |
| Actions beyond step 7: placement, confirmation and forms, bulk, outcomes | Declared Actions |
| Keep saved views on a Wow server, or write a store of your own | Where Views Live |
| Run under a strict Content Security Policy | Content Security Policy for the View Engine |
| Another look, a brand colour, a shadcn theme | Theming the View Engine |
| Keyboard, screen readers and WCAG 2.2 AA | Accessibility of the View Engine |
| Every public name's signature, and the issue codes | wow-view-engine reference |