Complete View Engine example
Run and verify
From this repository, with Node >=20.20.2 and pnpm 10.34.5:
pnpm install
pnpm --filter @ahoo-wang/fetcher-view-engine... build
pnpm storybookOpen View Engine → 入门与业务流程 → 最小接入. The displayed page starts unchanged; interactions are manual. Run the separate regression story for assertions:
pnpm exec vitest run --project=storybook stories/view-engine/QuickStart.test.stories.tsxInstall Playwright Chromium first (pnpm exec playwright install chromium), or set VIEW_ENGINE_BROWSER_CHANNEL=chrome to use installed Chrome. Full business and narrow-dark regressions live in stories/view-engine/orders.
| Action | Expected result |
|---|---|
| Open the page | SO-202609-1001 and SO-202609-1002, 3 total rows |
| Next page | SO-202609-1003 |
| Set Amount to 10000 without querying | Current result stays unchanged |
| Press Enter | Only SO-202609-1001 |
| Clear the applied amount value | All 3 records are eligible again; the filter control remains |
| Sort Amount ascending | SO-202609-1002, SO-202609-1003 on page 1 |
Sales order lifecycle
Start with the complete order workbench. Create an order for two monitors (CNY 2,400), submit it as sales, approve it as the manager, collect payment as finance, then release, prepare, ship and receive it. Record the invoice, reconcile, and close the order. The order detail shows the next action and responsible role. Use its handoff button to change operator while keeping the same order open. Aftersales orders remain queued until closure; a failed refresh can be retried inside the detail without repeating the write.
The chapter stories cover prepaid and credit release, partial shipments, rejected deliveries, returns, refunds and invoice credits. They share 18 consistent order seeds and start independently. Query and view settings use the public engine; business forms, validation and mutations live in packages/view-engine/examples/react/sales-order/. Resetting the workbench restores business records. View persistence is demonstrated separately and never persists business orders.
VIEW_ENGINE_BROWSER_CHANNEL=chrome pnpm exec vitest run --project=storybook stories/view-engine/ordersFull shared component
The source below is the component rendered by the Storybook Minimal story. It uses the public package entries. The local source supports only the advertised amount lower-bound/AND predicates and ordinary paging; other operators require a business QueryApi. It returns full records and supplies no saved-view service, so saving is unavailable. The saved-view guide covers that next step.
/*
* Copyright [2021-present] [ahoo wang <ahoowang@qq.com> (https://github.com/Ahoo-Wang)].
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
* http://www.apache.org/licenses/LICENSE-2.0
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { ExampleViewPage } from '../../packages/view-engine/examples/react/ExampleViewPage.js';
import { IndexedDBViewHost } from '@ahoo-wang/fetcher-view-engine/react';
import {
createFilterConfiguration,
resolveRecordPresentation,
newFilterNode,
type RecordData,
type RecordQuerySource,
type RecordViewDefinition,
type ViewHost,
type ViewInstanceList,
} from '@ahoo-wang/fetcher-view-engine';
import { useState } from 'react';
import type { RecordCardRenderContext } from '@ahoo-wang/fetcher-view-engine/react';
import type { ReactNode } from 'react';
import {
FilterOperator,
SortDirection,
type FilterExpression,
type PagedList,
type PagedQueryRequest,
} from '@ahoo-wang/fetcher-wow';
import '@ahoo-wang/fetcher-view-engine/styles.css';
const orders = [
{ id: 'SO-202609-1001', amount: 12000, status: 'confirmed' },
{ id: 'SO-202609-1002', amount: 6000, status: 'confirmed' },
{ id: 'SO-202609-1003', amount: 8000, status: 'confirmed' },
];
const definition: RecordViewDefinition = {
id: 'first-orders',
sourceId: 'orders',
title: '第一个数据视图',
record: {
allowedLayouts: ['table', 'card'],
rowKey: 'id',
defaultPresentation: {
card: {
title: { id: 'title', field: 'id' },
fields: [
{ id: 'amount', field: 'amount' },
{ id: 'status', field: 'status' },
],
},
},
},
allowedOperators: [
FilterOperator.MATCH_ALL,
FilterOperator.AND,
FilterOperator.GTE,
],
fields: [
{
field: 'id',
label: '订单编号',
type: 'string',
sortable: true,
operators: [],
cellRenderer: { name: 'text', options: { copyable: true } },
},
{
field: 'amount',
label: '金额',
type: 'number',
sortable: true,
operators: [FilterOperator.GTE],
summaryFunctions: [],
numberFormat: { style: 'currency', currency: 'CNY' },
cellRenderer: { name: 'number' },
},
{
field: 'status',
label: '状态',
type: 'string',
operators: [],
options: [
{ value: 'draft', label: '草稿' },
{ value: 'confirmed', label: '已确认' },
],
cellRenderer: {
name: 'status',
options: { tones: [{ value: 'confirmed', tone: 'success' }] },
},
},
],
};
const instances: ViewInstanceList = {
defaultInstanceId: 'my-orders',
instances: [
{
id: 'my-orders',
definitionId: definition.id,
title: '我的订单',
kind: 'record',
scope: { type: 'personal' },
revision: '1',
config: {
filters: createFilterConfiguration({
...newFilterNode(FilterOperator.GTE, 'amount'),
props: { value: 0 },
}),
sort: [{ field: 'id', direction: SortDirection.ASC }],
pagination: { mode: 'paged', size: 2 },
presentation: {
layout: 'table',
table: {
columns: [
{ id: 'id', kind: 'field', field: 'id', width: 200 },
{ id: 'amount', kind: 'field', field: 'amount', width: 160 },
{ id: 'status', kind: 'field', field: 'status', width: 140 },
],
},
},
},
},
],
};
// ponytail: local amount/AND demo only; use a business QueryApi for other predicates.
function matches(amount: number, expression: FilterExpression): boolean {
if (expression.op === FilterOperator.MATCH_ALL) return true;
if (expression.op === FilterOperator.AND)
return expression.operands.every(item => matches(amount, item));
if (
expression.op === FilterOperator.GTE &&
expression.field === 'amount' &&
typeof expression.value === 'number'
)
return amount >= expression.value;
throw new Error('示例只支持金额下限和 AND 条件');
}
const source: RecordQuerySource = {
async paged<T extends Partial<RecordData> = RecordData>(
query: PagedQueryRequest,
_attributes?: Record<string, unknown>,
controller?: AbortController,
): Promise<PagedList<T>> {
controller?.signal.throwIfAborted();
if (!('filter' in query)) throw new Error('示例使用 FilterExpression 查询');
const rows = orders.filter(order => matches(order.amount, query.filter));
for (const sort of [...(query.sort ?? [])].reverse()) {
if (sort.field !== 'id' && sort.field !== 'amount')
throw new Error('示例只支持按编号或金额排序');
const direction = sort.direction === SortDirection.ASC ? 1 : -1;
rows.sort(
(left, right) =>
direction *
(sort.field === 'amount'
? left.amount - right.amount
: left.id.localeCompare(right.id)),
);
}
const { index = 1, size = 2 } = query.pagination ?? {};
// This source always returns whole rows; QueryApi also permits projected rows.
return {
list: rows.slice((index - 1) * size, index * size) as unknown as T[],
total: rows.length,
};
},
};
const host: ViewHost = {
resolveSource(id) {
if (id !== definition.sourceId) throw new Error('未知数据源');
return source;
},
};
type RecordViewExampleProps = {
appearance?: 'light' | 'dark';
layout?: 'table' | 'card';
renderCard?(context: RecordCardRenderContext): ReactNode;
persistViews?: boolean;
};
export function RecordViewExample(props: RecordViewExampleProps) {
return (
<RecordViewWorkspace key={String(props.persistViews ?? false)} {...props} />
);
}
function RecordViewWorkspace({
appearance = 'light',
layout = 'table',
renderCard,
persistViews = false,
}: RecordViewExampleProps) {
const [viewHost] = useState(() =>
persistViews
? new IndexedDBViewHost({
scopeKey: 'card-example-user',
serviceKey: 'card-example',
definition,
instances: {
...instances,
instances: instances.instances.map(instance => ({
...instance,
config: {
...instance.config,
presentation: resolveRecordPresentation(
definition,
layout,
instance.config.presentation,
),
},
})),
},
resolveSource: host.resolveSource,
})
: host,
);
return (
<div
className="fve-root"
data-theme={appearance}
style={{ padding: 16, minWidth: 0 }}
>
<ExampleViewPage
scopeKey="docs:orders"
definitionId={definition.id}
definition={definition}
instances={
persistViews
? undefined
: layout === 'table'
? instances
: {
...instances,
instances: instances.instances.map(instance => ({
...instance,
config: {
...instance.config,
presentation: resolveRecordPresentation(
definition,
layout,
instance.config.presentation,
),
},
})),
}
}
host={viewHost}
record={{ selectable: true, renderCard }}
initialSidebarCollapsed
/>
</div>
);
}Use a separate React application
Until this package is published, build and verify its local archive:
pnpm --filter @ahoo-wang/fetcher-view-engine... build
node packages/view-engine/scripts/verify-package.mjs
pnpm --filter @ahoo-wang/fetcher-view-engine pack --pack-destination /tmp/view-engine-packThe pack command prints the archive filename. In a React 19 TypeScript application, install that absolute .tgz path with pnpm add, satisfy the declared peer dependencies, copy the shared component, and render <RecordViewExample />. If an internal dependency version is not published in your environment, pack the corresponding workspace dependency as well; the package verifier exercises the workspace-built artifacts together without publishing them.
This verifies a browser UI and a local record source. Authentication, durable view storage and your backend's query behavior belong to the application integration; see ViewHost.
