Skip to content

Complete View Engine example

Run and verify

From this repository, with Node >=20.20.2 and pnpm 10.34.5:

bash
pnpm install
pnpm --filter @ahoo-wang/fetcher-view-engine... build
pnpm storybook

Open View Engine → 入门与业务流程 → 最小接入. The displayed page starts unchanged; interactions are manual. Run the separate regression story for assertions:

bash
pnpm exec vitest run --project=storybook stories/view-engine/QuickStart.test.stories.tsx

Install 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.

ActionExpected result
Open the pageSO-202609-1001 and SO-202609-1002, 3 total rows
Next pageSO-202609-1003
Set Amount to 10000 without queryingCurrent result stays unchanged
Press EnterOnly SO-202609-1001
Clear the applied amount valueAll 3 records are eligible again; the filter control remains
Sort Amount ascendingSO-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.

bash
VIEW_ENGINE_BROWSER_CHANNEL=chrome pnpm exec vitest run --project=storybook stories/view-engine/orders

Full 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.

tsx
/*
 * 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:

bash
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-pack

The 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.

Released under the Apache License 2.0.