Local Viewer example
Maintenance mode (deprecated)
@ahoo-wang/fetcher-viewer is deprecated and in maintenance mode: existing functionality is maintained, with no new features. Further data-view development belongs to @ahoo-wang/fetcher-view-engine; use View Engine for new projects. This page remains a maintenance reference for existing consumers. The packages use different models and APIs, so migration requires adaptation.
This browser example uses four users and no backend. The application filters and sorts the full dataset, then slices the requested page. Viewer receives the resulting { list, total }; it does not transform the supplied rows for you.
Run in your application
Use a current Node release supported by the Vite scaffolder (Node 22.12+ is a suitable baseline). The Fetcher libraries declare Node >=18.20.8; that is not a promise that the current Vite tooling runs on Node 18. This recipe uses React 19 and Ant Design 6.
pnpm create vite local-viewer --template react-ts
cd local-viewer
pnpm install
pnpm add @ahoo-wang/fetcher@5.0.0 @ahoo-wang/fetcher-viewer@5.0.0 \
@ahoo-wang/fetcher-react@5.0.0 @ahoo-wang/fetcher-wow@5.0.0 \
@ahoo-wang/fetcher-decorator@5.0.0 @ahoo-wang/fetcher-eventstream@5.0.0 \
@ahoo-wang/fetcher-eventbus@5.0.0 @ahoo-wang/fetcher-storage@5.0.0 \
@ahoo-wang/fetcher-openapi@5.0.0 @ahoo-wang/fetcher-cosec@5.0.0 \
react@^19.2.8 react-dom@^19.2.8 antd@^6.6.3 \
@ant-design/icons@^6.3.4 dayjs@^1.11.23This explicitly includes the complete declared Viewer peer graph, including CoSec through fetcher-react. Installing those packages does not require a Wow or CoSec server for this local example. Direct dependencies such as immer, dequal and reflect-metadata are installed transitively. Do not copy the repository's workspace: or catalog: specifiers into a consumer project.
Create src/LocalViewer.tsx with the entire file below. It includes the data, definition, saved views and application component; no Storybook fixture is required.
/*
* 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 { useCallback, useState } from 'react';
import { App } from 'antd';
import { FullscreenProvider } from '@ahoo-wang/fetcher-react';
import { Viewer } from '@ahoo-wang/fetcher-viewer';
import type { ViewDefinition, ViewState } from '@ahoo-wang/fetcher-viewer';
import { all, Operator, SortDirection } from '@ahoo-wang/fetcher-wow';
import type { Condition, FieldSort, PagedList } from '@ahoo-wang/fetcher-wow';
const users = [
{ id: 'u-ada', name: 'Ada', active: true },
{ id: 'u-lin', name: 'Lin', active: false },
{ id: 'u-grace', name: 'Grace', active: true },
{ id: 'u-zoe', name: 'Zoe', active: false },
];
type User = (typeof users)[number];
const definition: ViewDefinition = {
id: 'local-users',
name: 'Local users',
fields: [
{ name: 'id', label: 'ID', type: 'text', primaryKey: true },
{
name: 'name',
label: 'Name',
type: 'text',
primaryKey: false,
sorter: true,
},
{
name: 'active',
label: 'Active',
type: 'text',
primaryKey: false,
render: value => (value ? 'Yes' : 'No'),
},
],
availableFilters: [
{
label: 'User',
filters: [
{
key: 'active',
field: { name: 'active', label: 'Active' },
component: 'bool',
},
],
},
],
// Required metadata; this local application does not fetch these URLs.
dataUrl: '/local-users/paged',
countUrl: '/local-users/count',
};
const initialView: ViewState = {
id: 'all-users',
name: 'All users',
definitionId: definition.id,
type: 'PERSONAL',
source: 'SYSTEM',
isDefault: true,
filters: [
{ key: 'active', type: 'bool', field: { name: 'active', label: 'Active' } },
],
columns: definition.fields.map(field => ({
key: field.name,
name: field.name,
fixed: field.primaryKey,
hidden: false,
})),
tableSize: 'middle',
pageSize: 2,
condition: all(),
sorter: [],
};
function filterUsers(condition: Condition): User[] {
if (condition.operator === Operator.ALL) return [...users];
if (
condition.field === 'active' &&
(condition.operator === Operator.TRUE ||
condition.operator === Operator.FALSE)
) {
return users.filter(
user => user.active === (condition.operator === Operator.TRUE),
);
}
throw new Error('This example supports only the Active boolean filter.');
}
function queryUsers(
condition: Condition,
page: number,
size: number,
sorter: FieldSort[] = [],
): PagedList<User> {
const rows = filterUsers(condition);
if (
sorter.length > 1 ||
sorter.some(
sort =>
sort.field !== 'name' ||
![SortDirection.ASC, SortDirection.DESC].includes(sort.direction),
)
) {
throw new Error(
'This example supports only ascending/descending Name sorting.',
);
}
if (sorter.length) {
const direction = sorter[0].direction === SortDirection.ASC ? 1 : -1;
rows.sort(
(left, right) => left.name.localeCompare(right.name, 'en') * direction,
);
}
return {
list: rows.slice((page - 1) * size, page * size),
total: rows.length,
};
}
export function LocalViewer() {
const [savedViews, setSavedViews] = useState<ViewState[]>([initialView]);
const [data, setData] = useState(() => queryUsers(all(), 1, 2));
const [savedName, setSavedName] = useState('');
const [error, setError] = useState('');
const load = useCallback(
(
condition: Condition,
page: number,
size: number,
sorter?: FieldSort[],
) => {
try {
setData(queryUsers(condition, page, size, sorter));
setError('');
} catch (failure) {
setData({ list: [], total: 0 });
setError(failure instanceof Error ? failure.message : String(failure));
}
},
[],
);
return (
<App>
<FullscreenProvider>
<Viewer<User>
definition={definition}
defaultViews={savedViews}
defaultView={initialView}
dataSource={data}
enableRowSelection={false}
pagination={{ showSizeChanger: false }}
onLoadData={load}
onSwitchView={view =>
load(view.condition, 1, view.pageSize, view.sorter)
}
onGetRecordCount={async (_url, condition) =>
filterUsers(condition).length
}
onCreateView={(view, onSuccess) => {
const saved = { ...view, id: crypto.randomUUID() };
setSavedViews(current => [...current, saved]);
setSavedName(saved.name);
onSuccess?.(saved);
}}
onUpdateView={(view, onSuccess) => {
setSavedViews(current =>
current.map(saved => (saved.id === view.id ? view : saved)),
);
setSavedName(view.name);
onSuccess?.(view);
}}
onDeleteView={(view, onSuccess) => {
setSavedViews(current =>
current.filter(saved => saved.id !== view.id),
);
onSuccess?.(view);
}}
/>
{error && <p role="alert">{error}</p>}
<output aria-live="polite">{savedName && `Saved: ${savedName}`}</output>
</FullscreenProvider>
</App>
);
}Replace src/main.tsx with this complete entry. The scaffold's index.html already provides <div id="root"></div>; the default demo CSS is not imported.
import { createRoot } from 'react-dom/client';
import 'antd/dist/reset.css';
import { LocalViewer } from './LocalViewer';
createRoot(document.getElementById('root')!).render(<LocalViewer />);pnpm devOpen the local URL printed by Vite. LocalViewer supplies Ant Design's App and FullscreenProvider itself.
Observe the result
- The first page contains Ada, Lin. Page 2 contains Grace, Zoe.
- Return to page 1. Click the Name header once: Ada, Grace. Click it again: Zoe, Lin (descending).
- In Active, select 是 (true), then 搜索 (Search). Only Grace, Ada remain, in descending order. 否 selects inactive users; 未设置 removes this filter when you search again.
- Click 另存为 (Save as), name the view Active descending, and confirm. The application displays Saved: Active descending.
- Select All users in the left panel: Ada, Lin return. Select Active descending: Grace, Ada, the true filter and descending header are restored. View switching returns to page 1; page size is saved, page index is not.
The example implements only the Active boolean filter and Name ascending/descending sorting. Unexpected conditions or sort fields produce a visible error and an empty table. Extend the local calculation only when adding matching UI capabilities; it is not a general Wow query interpreter.
State and backend boundary
The application owns savedViews in React memory and invokes each mutation's success callback after accepting the change. Viewer then updates its internal view collection. Refreshing or unmounting the application loses these saved views. For durable saving, await your storage/API operation before calling success, and display failures without reporting success.
dataUrl and countUrl are required definition metadata. Normal data loading and the view-count callback here use local functions. The toolbar also exposes server-oriented data monitoring: leave the bell monitor disabled in this example, because its count polling needs a real compatible endpoint. There is no local monitor service or backend in this sample.
For remote rows, replace the application calculation with a request and pass the returned PagedList to dataSource. Authentication, authorization, error handling and durable storage remain application/server responsibilities. Choose View, Viewer or FetcherViewer based on the protocol you actually have.
Run and verify in this repository
Repository development requires Node >=20.20.2 and pnpm 10.34.5. From the repository root:
pnpm install
pnpm storybookOpen Docs → Local Viewer → Local Data and perform the actions above manually. Reload the story to return to its initial state. Automated interactions run separately in the regression story.
Run the same browser checks headlessly:
pnpm exec vitest run --project=storybook stories/docs/LocalViewer.test.stories.tsxThe checks assert table row collections and order, filtered results, and restored saved settings. Callback text is only an additional save confirmation, not a substitute for verifying the displayed rows.
