name: dataviews-table-migration description: Build new vendor dashboard DataViews list pages from scratch OR migrate legacy Filter/StatusFilter/DataViewTable components to the unified @wedevs/plugin-ui DataViews component. Covers fresh builds (types, hook, list, route, PHP nav) and legacy migration (Scenario A status tabs, Scenario B multi-list merge).
DataViews List — Build from Scratch
Build vendor dashboard list pages using <DataViews> from @wedevs/plugin-ui (exported via @dokan/components). This skill covers the full workflow: types, custom data hook, list component, module entry point, route, and PHP navigation.
When to Use
- Creating a new vendor dashboard list/table page
- The component will live under
src/dashboard/<feature>/ - Uses the
@wedevs/plugin-uiDataViewscomponent (NOTAdminDataViews)
When NOT to Use
- Admin panel tables — those use
AdminDataViewsfrom@dokan/components(different component, different API). Quick check: if the import isAdminDataViews, this skill does not apply.
Two Paths
| Goal | Jump to |
|---|---|
| Build a new list page from scratch | Step 1: Define Types |
Migrate a legacy <Filter> / <StatusFilter> / multi-list page |
Legacy Migration Guide |
Reference Implementation
ProductList on branch feat/product-list-table-migration (commit 13247bcb5):
src/dashboard/products/
├── ProductList.tsx # Main list component (DataViews)
├── index.tsx # Module entry point
├── types.ts # TypeScript interfaces
└── hooks/useProducts.ts # Data-fetching hook
Supporting changes:
src/routing/routes.tsx— React route registrationincludes/functions-dashboard-navigation.php— PHPreact_routekey
File Structure
Every new list module follows this layout:
src/dashboard/<feature>/
├── <Feature>List.tsx # Main DataViews list component
├── index.tsx # Module entry point (wrapper div)
├── types.ts # Item type, status union, filter state, status count
└── hooks/use<Feature>.ts # Custom hook: fetch, delete, status counts
Step 1: Define Types (types.ts)
Define four things: the item shape, the status union, the filter state, and the status count shape.
// src/dashboard/<feature>/types.ts
export interface <Feature>Image {
id: number;
src: string;
name: string;
alt: string;
}
export interface <Feature>Item {
id: number;
name: string;
status: string;
// ... domain-specific fields
date_created: string;
permalink?: string;
}
export type <Feature>Status = 'all' | 'active' | 'inactive';
// Use the actual API statuses. Examples:
// Products: 'all' | 'publish' | 'draft' | 'pending'
// Orders: 'all' | 'processing' | 'completed' | 'on-hold'
export interface <Feature>FilterState {
page: number;
per_page: number;
status: <Feature>Status;
search: string;
// Add more filters as the API supports them
}
export interface <Feature>StatusCount {
value: string;
label: string;
count: number;
}
Key rules
<Feature>StatusCountmust havevalue,label,count— this is the shapetabs.itemsexpects.<Feature>FilterStatemirrors the API query parameters exactly.- Keep types in a dedicated file — the hook and list component both import from here.
Step 2: Create the Data Hook (hooks/use<Feature>.ts)
Encapsulate all API interactions in a single custom hook. The hook returns: data, loading state, pagination, status counts, and mutation functions.
// src/dashboard/<feature>/hooks/use<Feature>.ts
import { useState, useCallback, useEffect } from '@wordpress/element';
import apiFetch from '@wordpress/api-fetch';
import { addQueryArgs } from '@wordpress/url';
import type {
<Feature>Item,
<Feature>FilterState,
<Feature>StatusCount,
} from '../types';
interface Use<Feature>Return {
data: <Feature>Item[];
isLoading: boolean;
totalItems: number;
totalPages: number;
statusCounts: <Feature>StatusCount[];
fetch<Feature>s: () => void;
fetchStatusCounts: () => void;
delete<Feature>: ( id: number ) => Promise< void >;
delete<Feature>s: ( ids: number[] ) => Promise< void >;
}
export const use<Feature> = (
filterArgs: <Feature>FilterState
): Use<Feature>Return => {
const [ data, setData ] = useState< <Feature>Item[] >( [] );
const [ isLoading, setIsLoading ] = useState( true );
const [ totalItems, setTotalItems ] = useState( 0 );
const [ totalPages, setTotalPages ] = useState( 0 );
// Initialize with default counts so tabs render immediately (never empty)
const [ statusCounts, setStatusCounts ] = useState< <Feature>StatusCount[] >( [
{ value: 'all', label: 'All', count: 0 },
{ value: 'active', label: 'Active', count: 0 },
{ value: 'inactive', label: 'Inactive', count: 0 },
] );
// ── Fetch list data ────────────────────────────────────────
// NOTE: If the API returns status counts as response headers
// (Pattern B), extract them here too — see "Status Count Patterns".
const fetchItems = useCallback( async () => {
setIsLoading( true );
try {
const queryArgs: Record< string, any > = {
per_page: filterArgs.per_page,
page: filterArgs.page,
};
if ( filterArgs.status !== 'all' ) {
queryArgs.status = filterArgs.status;
}
if ( filterArgs.search ) {
queryArgs.search = filterArgs.search;
}
const response = ( await apiFetch( {
path: addQueryArgs( '/dokan/v1/<endpoint>', queryArgs ),
parse: false, // parse: false to access response headers
} ) ) as Response;
const responseData: <Feature>Item[] = await response.json();
setData( responseData );
setTotalItems( parseInt( response.headers.get( 'X-WP-Total' ) ?? '0', 10 ) );
setTotalPages( parseInt( response.headers.get( 'X-WP-TotalPages' ) ?? '0', 10 ) );
// ── Pattern B only: extract status counts from same response ──
// Uncomment if the API returns X-Status-* headers on the list endpoint.
// const all = parseInt( response.headers.get( 'X-Status-All' ) ?? '0', 10 );
// const active = parseInt( response.headers.get( 'X-Status-Active' ) ?? '0', 10 );
// const inactive = parseInt( response.headers.get( 'X-Status-Inactive' ) ?? '0', 10 );
// setStatusCounts( [
// { value: 'all', label: 'All', count: all },
// { value: 'active', label: 'Active', count: active },
// { value: 'inactive', label: 'Inactive', count: inactive },
// ] );
} catch ( error ) {
console.error( 'Error fetching items:', error );
setData( [] );
} finally {
setIsLoading( false );
}
}, [
filterArgs.page,
filterArgs.per_page,
filterArgs.status,
filterArgs.search,
] );
// ── Fetch status counts (Pattern A only) ──────────────────
// Only needed when using a dedicated /summary endpoint.
// For Pattern B (header-based), counts are already set inside fetchItems above.
// For Pattern C (no counts), remove this entirely.
// See "Status Count Patterns" section after the hook code.
const fetchStatusCounts = useCallback( async () => {
// ... Pattern A implementation (see below)
}, [] );
// ── Delete single item ─────────────────────────────────────
const deleteItem = useCallback( async ( id: number ) => {
await apiFetch( {
path: `/dokan/v1/<endpoint>/${ id }`,
method: 'DELETE',
data: { force: true },
} );
}, [] );
// ── Bulk delete ────────────────────────────────────────────
const deleteItems = useCallback( async ( ids: number[] ) => {
await Promise.all(
ids.map( ( id ) =>
apiFetch( {
path: `/dokan/v1/<endpoint>/${ id }`,
method: 'DELETE',
data: { force: true },
} )
)
);
}, [] );
// ── Auto-fetch on filter changes ───────────────────────────
useEffect( () => {
void fetchItems();
}, [ fetchItems ] );
// Pattern A only — separate status count fetch on mount.
// For Pattern B, remove this effect (counts come from fetchItems).
useEffect( () => {
void fetchStatusCounts();
}, [ fetchStatusCounts ] );
return {
data,
isLoading,
totalItems,
totalPages,
statusCounts,
fetch<Feature>s: fetchItems,
fetchStatusCounts, // Pattern A: exposed for re-fetch after mutations
// Pattern B: can be omitted (just re-call fetchItems)
// Pattern C: remove entirely
delete<Feature>: deleteItem,
delete<Feature>s: deleteItems,
};
};
Key rules
parse: falseon list fetch to readX-WP-TotalandX-WP-TotalPagesheaders.- Default status counts — initialize with zeroes so tabs render on first paint.
useCallbackon every async function — prevents unnecessary re-renders and infinite loops when passed as effect dependencies.- Only include
filterArgsfields that the API actually supports inqueryArgs.
Status Count Patterns
Choose the pattern that matches how the REST API provides counts. Check the REST controller to determine which one applies.
Pattern A: Dedicated /summary Endpoint
Use when the API has a separate summary/stats endpoint (e.g., /dokan/v1/products/summary). Status counts are fetched independently from the list data.
When to use: The REST controller has a dedicated route like /<resource>/summary that returns counts in the response body.
// Separate fetchStatusCounts function — called independently
const fetchStatusCounts = useCallback( async () => {
try {
const response = ( await apiFetch( {
path: '/dokan/v1/<endpoint>/summary',
} ) ) as { post_counts: Record< string, number > };
const counts = response.post_counts ?? {};
const allCount = Object.values( counts ).reduce( ( sum, c ) => sum + c, 0 );
setStatusCounts( [
{ value: 'all', label: 'All', count: allCount },
{ value: 'active', label: 'Active', count: counts.active ?? 0 },
{ value: 'inactive', label: 'Inactive', count: counts.inactive ?? 0 },
] );
} catch ( error ) {
console.error( 'Error fetching summary:', error );
}
}, [] );
// Auto-fetch on mount
useEffect( () => {
void fetchStatusCounts();
}, [ fetchStatusCounts ] );
Key: Expose fetchStatusCounts from the hook so the list component can re-fetch counts after mutations (delete, status change).
Examples: ProductController::get_product_summary, OrderController::get_order_summary
Pattern B: Status Counts in List Response Headers
Use when the list endpoint returns status counts as custom HTTP headers alongside X-WP-Total / X-WP-TotalPages on the same response. No separate API call needed — counts are extracted from the same parse: false response you already use for pagination.
When to use: The REST controller sets headers like X-Status-All, X-Status-Pending, etc. on the list response. Check the PHP controller for lines like $response->header( 'X-Status-All', ... ).
// Inside fetchItems — extract counts from the SAME response as data + pagination
const fetchItems = useCallback( async () => {
setIsLoading( true );
try {
const queryArgs: Record< string, any > = { /* ... */ };
const response = ( await apiFetch( {
path: addQueryArgs( '/dokan/v1/<endpoint>', queryArgs ),
parse: false,
} ) ) as Response;
const responseData = await response.json();
// Standard pagination headers (always present)
setData( responseData );
setTotalItems( parseInt( response.headers.get( 'X-WP-Total' ) ?? '0', 10 ) );
setTotalPages( parseInt( response.headers.get( 'X-WP-TotalPages' ) ?? '0', 10 ) );
// Status counts from custom headers (same response, same call)
const all = parseInt( response.headers.get( 'X-Status-All' ) ?? '0', 10 );
const approved = parseInt( response.headers.get( 'X-Status-Approved' ) ?? '0', 10 );
const pending = parseInt( response.headers.get( 'X-Status-Pending' ) ?? '0', 10 );
setStatusCounts( [
{ value: 'all', label: 'All', count: all },
{ value: 'approved', label: 'Approved', count: approved },
{ value: 'pending', label: 'Pending', count: pending },
] );
} catch ( error ) { /* ... */ }
finally { setIsLoading( false ); }
}, [ /* filterArgs deps */ ] );
Key differences from Pattern A:
- No separate
fetchStatusCountsfunction — remove it from the hook entirely. - No separate
useEffectfor counts — remove thefetchStatusCountseffect. - No
fetchStatusCountsin the return — the list component doesn't need it. - Counts update automatically on every list fetch (paginate, search, filter, tab switch).
- After mutations (delete, status change), just re-call
fetchItems— counts come along for free.
Hook return for Pattern B:
return {
data,
isLoading,
totalItems,
totalPages,
statusCounts, // still exposed — updated inside fetchItems
fetch<Feature>s: fetchItems,
// no fetchStatusCounts here
delete<Feature>: deleteItem,
delete<Feature>s: deleteItems,
};
Examples: StoreController (X-Status-All, X-Status-Approved, X-Status-Pending), WithdrawController (X-Status-Pending, X-Status-Completed, X-Status-Cancelled)
Pattern C: No Status Counts (Pagination Only)
Use when the API has no count support at all — only standard X-WP-Total / X-WP-TotalPages headers.
When to use: The REST controller doesn't provide any status-specific counts. Tabs are either static (no counts shown) or not used.
// No statusCounts state needed
// No fetchStatusCounts function needed
// Tabs can still exist — just without count badges:
const tabs = {
items: [
{ value: 'all', label: __( 'All', 'dokan-lite' ) },
{ value: 'active', label: __( 'Active', 'dokan-lite' ) },
{ value: 'inactive', label: __( 'Inactive', 'dokan-lite' ) },
],
onSelect: ( status ) => { /* ... */ },
};
Key: Tab items omit the count property entirely. The component renders tabs without count badges.
Which Pattern to Choose — Decision Flow
- Check the REST controller for the endpoint you're consuming.
- Does it have a
/summaryroute? → Use Pattern A. - Does the list response set
X-Status-*headers? → Use Pattern B. - Neither? → Use Pattern C (no counts), or add a summary endpoint to the backend first.
Step 3: Build the List Component (<Feature>List.tsx)
This is the main component. It wires up state, fields, tabs, actions, and the <DataViews> component.
// src/dashboard/<feature>/<Feature>List.tsx
import { useState, useMemo } from '@wordpress/element';
import { __ } from '@wordpress/i18n';
import { useToast } from '@getdokan/dokan-ui';
import { DataViews, DokanBadge, DokanModal } from '@dokan/components';
import PriceHtml from '../../components/PriceHtml';
import DateTimeHtml from '../../components/DateTimeHtml';
import { use<Feature> } from './hooks/use<Feature>';
import type { <Feature>Item, <Feature>Status, <Feature>FilterState } from './types';
3a. Helper Functions
Define status badge mapping and label helpers at the top of the file, outside the component:
const getStatusBadgeVariant = ( status: string ) => {
switch ( status ) {
case 'active':
return 'success';
case 'inactive':
return 'secondary';
default:
return 'info';
}
};
const getStatusLabel = ( status: string ) => {
switch ( status ) {
case 'active':
return __( 'Active', 'dokan-lite' );
case 'inactive':
return __( 'Inactive', 'dokan-lite' );
default:
return status;
}
};
3b. Component State
function <Feature>List() {
const toast = useToast();
const [ deleteItems, setDeleteItems ] = useState< <Feature>Item[] >( [] );
const [ isDeleting, setIsDeleting ] = useState( false );
const [ selection, setSelection ] = useState< string[] >( [] );
const [ filterArgs, setFilterArgs ] = useState< <Feature>FilterState >( {
page: 1,
per_page: 10,
status: 'all',
search: '',
} );
const [ view, setView ] = useState( {
perPage: 10,
page: 1,
search: '',
type: 'table',
status: 'all', // tabs sync to this key by default (viewKey = 'status')
} );
const {
data,
isLoading,
totalItems,
totalPages,
statusCounts,
fetch<Feature>s,
fetchStatusCounts,
delete<Feature>,
delete<Feature>s,
} = use<Feature>( filterArgs );
Dual-state pattern (view + filterArgs)
| State | Purpose | Consumed by |
|---|---|---|
view |
Controls DataViews UI: pagination, search, layout type, active tab | <DataViews> component |
filterArgs |
Drives the API request: page, per_page, status, search |
use<Feature> hook |
Both are kept in sync via onViewChange and tabs.onSelect. This separation is intentional — the view object may contain UI-only keys (like type: 'table') that the API doesn't need.
3c. Define Fields (Columns)
Each field needs id, label, enableSorting: false, and a render function.
const fields = [
{
id: 'name',
label: __( 'Name', 'dokan-lite' ),
enableSorting: false,
render: ( { item }: { item: <Feature>Item } ) => (
<div className="flex items-center gap-3">
{ /* Optional: image thumbnail */ }
<span className="font-medium text-gray-900">
{ item.name }
</span>
</div>
),
},
{
id: 'status',
label: __( 'Status', 'dokan-lite' ),
enableSorting: false,
render: ( { item }: { item: <Feature>Item } ) => (
<DokanBadge
variant={ getStatusBadgeVariant( item.status ) }
label={ getStatusLabel( item.status ) }
/>
),
},
{
id: 'price',
label: __( 'Price', 'dokan-lite' ),
enableSorting: false,
render: ( { item }: { item: <Feature>Item } ) => (
<div>
{ item.price ? (
<PriceHtml price={ item.price } />
) : (
<span>{ '\u2013' }</span>
) }
</div>
),
},
{
id: 'date_created',
label: __( 'Date', 'dokan-lite' ),
enableSorting: false,
render: ( { item }: { item: <Feature>Item } ) => (
<DateTimeHtml.Date date={ item.date_created } />
),
},
];
Field rules
- Always set
enableSorting: falseunless the REST API supportsorderbyfor that field. renderreceives{ item }typed to your item interface.- Use
DokanBadgefor status columns withvariantmapped from status string. - Use
PriceHtmlfor formatted currency display (see "Rendering Prices" below). - Use
DateTimeHtml.Datefor date-only orDateTimeHtmlfor date + time. - Use
'\u2013'(en-dash) for empty/null values.
Rendering Prices
Two approaches exist in the codebase. Use the one that fits the context:
<PriceHtml> component — preferred for vendor dashboard DataViews fields. Import from @dokan/components. Renders a formatted price using WooCommerce currency settings. Just pass the raw numeric value.
import { PriceHtml } from '@dokan/components';
// In a field render:
render: ( { item } ) => (
<div>
{ item.price ? <PriceHtml price={ item.price } /> : <span>{ '\u2013' }</span> }
</div>
),
// Props (all optional except price):
<PriceHtml
price={ 49.99 } // string | number — required
precision={ null } // decimal places
thousand="" // thousands separator
decimal="" // decimal separator
format="" // e.g. '%s%v' for $10, '%v%s' for 10$
/>
formatPrice() utility — use when you need the formatted string directly (e.g., in tooltips, concatenated strings, non-JSX contexts). Import from @dokan/utilities. Returns a formatted string, wrap in <RawHTML> for rendering.
import { formatPrice } from '@dokan/utilities';
import { RawHTML } from '@wordpress/element';
// Inline helper pattern (used in admin pages):
const price = ( amount ) => <RawHTML>{ formatPrice( amount ) }</RawHTML>;
// In a field render:
render: ( { item } ) => price( item.amount ),
// In non-JSX contexts (tooltips, labels):
const label = `Total: ${ formatPrice( item.total ) }`;
When to use which:
| Context | Use |
|---|---|
DataViews field render (vendor dashboard) |
<PriceHtml price={value} /> |
DataViews field render (admin dashboard) |
<RawHTML>{ formatPrice(value) }</RawHTML> or <PriceHtml> |
| String concatenation, tooltips, labels | formatPrice(value) |
3d. Define Tabs
const tabs = {
items: statusCounts, // Array of { value, label, count }
onSelect: ( status: string ) => {
setFilterArgs( ( prev ) => ( {
...prev,
status: status as <Feature>Status,
page: 1,
search: '',
} ) );
setView( ( prev ) => ( { ...prev, page: 1, search: '' } ) );
},
};
Tab rules
itemsmust be{ value: string; label: string; count?: number }[].onSelectresetspageto 1 and clearssearch— prevents stale paginated results.- The component auto-syncs
tabs.items[n].valuetoview.status(defaultviewKey). - For non-status tabs (e.g., view-type switcher), add
viewKey: 'myCustomKey'and add matching key to view state.
3e. Define Actions
const actions = useMemo( () => [
{
id: 'view',
label: () => __( 'View', 'dokan-lite' ),
callback: ( [ item ]: <Feature>Item[] ) => {
if ( item.permalink ) {
window.open( item.permalink, '_blank' );
}
},
},
{
id: 'delete',
label: () => __( 'Delete Permanently', 'dokan-lite' ),
isDestructive: true,
supportsBulk: true,
callback: ( items: <Feature>Item[] ) => {
setDeleteItems( items ); // triggers confirmation modal
},
},
], [] );
Action rules
labelmust be a string or() => string— never JSX.isDestructive: true— the@wedevs/plugin-uiDataViews component automatically wraps these in anAlertDialogconfirmation. You do NOT need to build your own confirmation modal for destructive actions.supportsBulk: true— enables bulk selection checkbox column. RequiresselectionandonChangeSelectionprops on DataViews.useMemoto prevent unnecessary re-renders. Only include dependencies that actually affect the actions array.isEligible?: (item) => boolean— use to conditionally show actions per-row (e.g., only show "Approve" for pending items).
3f. View Change Handler
const onViewChange = ( newView: typeof view ) => {
setView( newView );
setFilterArgs( ( prev ) => ( {
...prev,
page: newView.page,
per_page: newView.perPage,
search: newView.search,
} ) );
};
This is the bridge between view (UI state) and filterArgs (API state). Every time the user paginates, searches, or changes per-page, both states update.
3g. Delete Confirmation Handler
const handleDeleteConfirm = async () => {
if ( deleteItems.length === 0 ) return;
setIsDeleting( true );
try {
if ( deleteItems.length === 1 ) {
await delete<Feature>( deleteItems[ 0 ].id );
} else {
await delete<Feature>s( deleteItems.map( ( item ) => item.id ) );
}
toast( {
type: 'success',
title: deleteItems.length === 1
? __( 'Item deleted successfully', 'dokan-lite' )
: __( 'Items deleted successfully', 'dokan-lite' ),
} );
setSelection( [] );
fetch<Feature>s();
fetchStatusCounts();
} catch ( error ) {
toast( {
type: 'error',
title: __( 'Failed to delete', 'dokan-lite' ),
} );
} finally {
setIsDeleting( false );
setDeleteItems( [] );
}
};
Post-mutation rules
- Clear
selectionafter bulk delete. - Re-fetch both list data and status counts — counts change after mutations.
- Use
useToastfrom@getdokan/dokan-uifor success/error notifications.
3h. Render
return (
<>
<DataViews
namespace="dokan-<feature>-data-view"
data={ data }
fields={ fields }
view={ view }
onChangeView={ onViewChange }
getItemId={ ( item: <Feature>Item ) => item.id }
isLoading={ isLoading }
paginationInfo={ {
totalItems,
totalPages,
} }
tabs={ tabs }
search={ true }
actions={ actions }
selection={ selection }
onChangeSelection={ ( ids: string[] ) => setSelection( ids ) }
/>
</>
);
}
export default <Feature>List;
DataViews props reference
| Prop | Type | Required | Notes |
|---|---|---|---|
namespace |
string |
Yes | Enables WordPress filter hooks: {snake_namespace}_dataviews_{element} |
data |
Item[] |
Yes | Current page of items |
fields |
Field[] |
Yes | Column definitions |
view |
View |
Yes | Current view state (page, search, type, status) |
onChangeView |
(view) => void |
Yes | Called on paginate, search, tab change |
getItemId |
(item) => string |
Yes* | Required if item doesn't have id: string |
isLoading |
boolean |
No | Shows skeleton loader when true |
paginationInfo |
{ totalItems, totalPages } |
Yes | Drives pagination controls |
tabs |
TabsProps |
No | Status/view-type tabs |
search |
boolean |
No | Enables built-in debounced search input |
searchPlaceholder |
string |
No | Custom placeholder for search input |
actions |
Action[] |
No | Row/bulk actions dropdown |
selection |
string[] |
No | Currently selected item IDs (for bulk actions) |
onChangeSelection |
(ids) => void |
No | Selection change handler |
filter |
DataViewFilterProps |
No | Inline filter fields (funnel button) |
onClickItem |
(item) => void |
No | Row click handler |
isItemClickable |
(item) => boolean |
No | Per-row click eligibility |
empty |
JSX.Element |
No | Custom empty state |
emptyTitle |
string |
No | Empty state heading |
emptyDescription |
string |
No | Empty state description |
header |
JSX.Element |
No | Content above tabs |
When to include optional props
tabs— only if the list has meaningful status/view-type groupings.search— only if the REST endpoint supports asearchparameter.filter— only if the list needs controls beyond tabs + search (date range, vendor picker, etc.).actions— only if rows have operations (view, edit, delete, status toggle).selection+onChangeSelection— only if at least one action hassupportsBulk: true.
Step 4: Module Entry Point (index.tsx)
Minimal wrapper that provides the outer container div:
// src/dashboard/<feature>/index.tsx
import <Feature>List from './<Feature>List';
const Index = () => {
return (
<div className="dokan-<feature>-wrapper dokan-react-<feature> space-y-6">
<<Feature>List />
</div>
);
};
export default Index;
Naming convention
- Wrapper class:
dokan-<feature>-wrapper dokan-react-<feature> - Use
space-y-6for vertical spacing if the page may have multiple sections later.
Step 5: Register the React Route (src/routing/routes.tsx)
Add the route to the routes array:
import <Feature> from '@src/dashboard/<feature>';
// Inside the exported array:
{
id: 'dokan-<feature>',
title: __( '<Feature Title>', 'dokan-lite' ),
element: <<Feature> />,
path: '/<feature>',
exact: true,
order: <number>, // determines sidebar ordering
capabilities: [ 'dokan_view_<feature>_menu' ], // PHP capability check
},
DokanRoute shape
type DokanRoute = {
id: string; // unique identifier, prefix with 'dokan-'
title?: string; // page title shown in header
element: JSX.Element; // the component to render
path: string; // URL path (relative to dashboard root)
exact?: boolean; // exact path match
order?: number; // sidebar menu ordering
capabilities?: string[]; // required WordPress capabilities
backUrl?: string; // if set, shows a back arrow linking to this URL
icon?: JSX.Element; // optional icon for header
header?: JSX.Element; // custom header component override
footer?: JSX.Element; // custom footer component override
parent?: string; // parent route ID for nesting
};
Step 6: Register PHP Navigation (includes/functions-dashboard-navigation.php)
Add 'react_route' => '<feature>' to the menu item in dokan_get_dashboard_nav():
'<feature>' => [
'title' => __( '<Feature Title>', 'dokan-lite' ),
'icon' => '<i class="fas fa-<icon>"></i>',
'url' => dokan_get_navigation_url( '<feature>' ),
'pos' => <number>,
'icon_name' => '<LucideIconName>', // Lucide icon component name
'permission' => 'dokan_view_<feature>_menu',
'react_route' => '<feature>', // ← THIS is what enables the React SPA route
],
The react_route key tells the PHP-rendered sidebar menu to trigger the React router instead of a full page navigation. The value must match the path in routes.tsx (without the leading /).
Adding Inline Filters (Optional)
If the list needs additional filter controls beyond tabs and search (e.g., date range, vendor picker, customer select), add the filter prop:
const [ dateRange, setDateRange ] = useState< { startDate: string | null; endDate: string | null } | null >( null );
const filter = {
fields: [
{
id: 'date-range',
label: __( 'Date Range', 'dokan-lite' ),
field: (
<DateRangePicker
startDate={ dateRange?.startDate ?? null }
endDate={ dateRange?.endDate ?? null }
onChange={ ( start, end ) => setDateRange( { startDate: start, endDate: end } ) }
/>
),
},
{
id: 'customer',
label: __( 'Customer', 'dokan-lite' ),
field: (
<CustomerFilter
value={ selectedCustomer }
onChange={ setSelectedCustomer }
/>
),
},
],
onReset: () => {
setDateRange( null );
setSelectedCustomer( null );
},
onFilterRemove: ( filterId: string ) => {
if ( filterId === 'date-range' ) setDateRange( null );
if ( filterId === 'customer' ) setSelectedCustomer( null );
},
};
// Then add to DataViews:
<DataViews
filter={ filter }
// ... other props
/>
Filter field shape
interface DataViewFilterField {
id: string; // unique identifier, used in onFilterRemove
label: string; // shown in the "Add Filter" popover
field: React.ReactNode; // the filter control (any React element)
}
When filter.fields is provided alongside tabs, the DataViews component automatically injects a funnel button into the tab header. Clicking it opens the filter panel. The funnel button shows a badge count of active filters.
View-Type Tabs (Non-Status Tabs)
When tabs represent different view types rather than statuses (e.g., "Coupons" vs "Marketing"), use a custom viewKey:
type ViewType = 'coupons' | 'marketing';
const [ view, setView ] = useState( {
perPage: 10,
page: 1,
search: '',
type: 'table',
viewType: 'coupons' as ViewType, // custom key — NOT 'status'
fields: couponFields.map( ( f ) => f.id ),
} );
const tabs = {
items: [
{ label: __( 'Coupons', 'dokan-lite' ), value: 'coupons' },
{ label: __( 'Marketing', 'dokan-lite' ), value: 'marketing' },
],
viewKey: 'viewType', // syncs to view.viewType instead of view.status
onSelect: ( viewType: ViewType ) => {
setData( [] ); // clear stale data from previous tab
setView( ( prev ) => ( {
...prev,
page: 1,
search: '',
viewType,
fields: ( viewType === 'coupons' ? couponFields : marketingFields )
.map( ( f ) => f.id ),
} ) );
},
};
View-type tab rules
viewKeymust match a key in theviewobject.- Clear
dataon tab switch to prevent stale row flash. - Update
view.fieldswhen tabs have different columns. - Switch
fields,actions, and fetch logic based on the active tab value.
Shared Components Reference
Import from @dokan/components:
| Component | Usage |
|---|---|
DataViews |
Main table component (@wedevs/plugin-ui) |
DokanBadge |
Status badges with variants: primary, secondary, info, warning, success, danger |
DokanModal |
Confirmation dialogs |
PriceHtml |
Formatted currency display — <PriceHtml price={value} />. Reads WooCommerce currency settings automatically. See "Rendering Prices" section for full props |
DateTimeHtml |
Full date + time. .Date for date only. .Time for time only |
CustomerFilter |
Async customer select filter |
DateRangePicker |
Date range filter control |
Filter |
Legacy filter component (do NOT use in new code) |
Import from @dokan/utilities:
| Export | Usage |
|---|---|
formatPrice |
Returns formatted price string — use for non-JSX contexts or wrap in <RawHTML>. See "Rendering Prices" |
formatNumber |
Format number with WooCommerce precision/separators |
unformatNumber |
Parse a formatted number string back to raw number |
Import from @getdokan/dokan-ui:
| Component | Usage |
|---|---|
useToast |
Toast notification hook — `toast({ type: 'success' |
Import from @wordpress/*:
| Package | Exports |
|---|---|
@wordpress/element |
useState, useMemo, useCallback, useEffect, RawHTML |
@wordpress/i18n |
__, sprintf |
@wordpress/api-fetch |
apiFetch — WordPress REST API fetch wrapper |
@wordpress/url |
addQueryArgs — appends query params to URL path |
Checklist
Before considering the implementation complete:
- Types —
types.tswith item interface, status union, filter state, status count - Hook —
hooks/use<Feature>.tswithparse: false, header-based pagination, default status counts - Fields — every field has
id,label,enableSorting: false,render - Tabs —
itemsarray fromstatusCounts,onSelectresets page + search - Actions — plain text labels,
isDestructive: trueon destructive,supportsBulkif needed - View state —
perPage,page,search,type: 'table',status: 'all' -
onChangeView— syncsviewtofilterArgs - Selection —
selection+onChangeSelectionif any action hassupportsBulk - Post-mutation — re-fetch list + status counts, clear selection, show toast
- Entry point —
index.tsxwith wrapper div - Route — registered in
src/routing/routes.tsxwith correctcapabilities - PHP nav —
react_routekey added inincludes/functions-dashboard-navigation.php - Namespace —
namespace="dokan-<feature>-data-view"for WordPress filter hooks - Search — only
search={true}if the API endpoint supportssearchparameter - i18n — all user-facing strings wrapped in
__( '...', 'dokan-lite' )
Legacy Migration Guide
Migrate existing Dokan Pro list/table views from old patterns (separate <Filter>, <StatusFilter>, <DataViewTable>) to the new unified <DataViews> component from @wedevs/plugin-ui.
Trigger Patterns
Use this section when you see any of these old patterns:
- Separate
<Filter>component from@dokan/components - Custom
<StatusFilter>component alongside DataViews <DataViews>withdefaultLayoutsprop but notabs/filterpropssearch={false}on DataViews (search handled externally via<SimpleInput>)- Filter state managed in a separate
<Filter>wrapper - Separate tab/header components that switch between different list views via URL params or state
- Multiple list components for related data, conditionally rendered by a wrapper
Migration Scenarios
Identify which scenario applies before starting:
Scenario A: Status Tabs + Filters
A single list with status filtering (All / Open / Closed) and/or additional filters (customer, date range, search) managed by separate <Filter> and <StatusFilter> components.
Migrate to: One <DataViews> with tabs (status counts from API), filter (inline filter fields), and search (only if the old component had search).
Scenario B: View-Type Tabs (Merging Multiple Lists)
Multiple separate list components toggled by an external tab/header component. Each list may have different columns, actions, and API endpoints.
Migrate to: One unified <DataViews> with tabs using a custom viewKey. Fields, actions, and fetch logic switch dynamically based on the active tab. Delete old separate components and the external tab switcher.
Component Locations Needing Migration
High Priority (uses separate <Filter> + <StatusFilter>)
modules/rma/src/js/vendor-dashboard/components/RequestsList.tsx
Medium Priority (uses defaultLayouts, no tabs/filter)
src/features/reviews/ReviewTable.tsx(has separateReviewStatusTabs)src/features/shipping/ZoneList.tsxsrc/features/shipping/ShippingMethods.tsxmodules/vendor-staff/src/js/components/StaffList.tsxmodules/subscription/src/js/frontend/components/SubscriptionOrders.tsx
Already Migrated (reference examples)
- Scenario A:
modules/store-support/src/js/vendor-dashboard/components/TicketsList.tsx(branch:refactor/support-tickets-vendor-table) - Scenario B:
src/frontend/coupons/components/CouponList.tsx(branch:refactor/coupons-dataviews-migration)
Base Branch
All migration work should be branched from and PR'd against: refactor/coupons-dataviews-migration in dokan-pro repository. PR: https://github.com/getdokan/dokan-pro/pull/5507
Architecture Overview
Old Patterns
Scenario A — Separate Filter + StatusFilter:
+-------------------------------------------+
| <Filter fields={[...]} /> |
+-------------------------------------------+
| <StatusFilter /> |
| All (10) | Open (5) | Closed (5) |
+-------------------------------------------+
| <DataViews defaultLayouts={...} |
| search={false} ... /> |
+-------------------------------------------+
Scenario B — External Tab Switcher + Multiple Lists:
+-------------------------------------------+
| <TabHeader /> |
| [Tab A] | [Tab B] (URL param toggle) |
+-------------------------------------------+
| { isTabA ? <ListA /> : <ListB /> } |
| Each has its own <DataViews> |
+-------------------------------------------+
New Pattern (both scenarios)
+-------------------------------------------+
| <DataViews |
| tabs={...} filter={...} |
| search={true/false} ... /> |
| |
| [Tab1] [Tab2] [funnel] [search?] |
| | Filter1 | Filter2 | Reset | |
| | Table rows... | |
| | Pagination | |
+-------------------------------------------+
Migration Steps (Scenario A: Status Tabs + Filters)
Step 1: Update Imports
Remove:
import { Filter } from '@dokan/components';
import { SimpleInput } from '@getdokan/dokan-ui';
import StatusFilter from './Navigation/StatusFilter';
Keep/Add:
import { DataViews, CustomerFilter /* if needed */ } from '@dokan/components';
Step 2: Move Status into View State
Remove separate status state:
const [selectedStatus, setSelectedStatus] = useState('all');
Add status to the view object:
const [view, setView] = useState({
perPage: 10,
page: 1,
search: '',
type: 'table',
status: 'all', // <-- tabs sync to this via viewKey (default: 'status')
});
Step 3: Migrate Status Tabs to tabs Prop
Old: Separate <StatusFilter> component.
New: tabs prop on DataViews using a status hook:
const { statusCounts, fetchStatusCounts } = useStatusFilters();
const tabs = {
items: statusCounts.map((s) => ({ ...s, value: s.key })),
onSelect: onStatusClick,
};
Tab item shape:
interface Tab {
label: string;
value: string;
count?: number;
className?: string;
icon?: React.ComponentType<{ className?: string }>;
disabled?: boolean;
}
Initialize status counts with defaults so tabs render immediately (not empty):
const defaultStatuses = [
{ key: 'all', label: __('All', 'dokan'), count: 0 },
{ key: 'open', label: __('Open', 'dokan'), count: 0 },
{ key: 'closed', label: __('Closed', 'dokan'), count: 0 },
];
const [statusCounts, setStatusCounts] = useState(defaultStatuses);
Step 4: Migrate Filters to filter Prop
Old: <Filter fields={[...]} showFilter showReset onFilter onReset />
New: filter prop with structured field definitions:
const filter = {
fields: [
{
id: 'some-filter',
label: __('Filter Label', 'dokan'),
field: <SomeFilterComponent value={value} onChange={setValue} />,
},
],
onReset: () => { /* clear all filter state */ },
onFilterRemove: (filterId: string) => { /* clear specific filter */ },
};
Each filter field requires id, label, and field (React element).
Step 5: Enable Built-in Search (only if previously supported)
Only set search={true} if the old component had a search feature (e.g., a <SimpleInput> for keyword search inside <Filter>, or search={false} with external search state). If the old component had no search at all, keep search={false} or omit the prop.
Old: External <SimpleInput> + search={false} on DataViews.
New: search={true} — value comes through view.search via onChangeView:
const onViewChange = (newView) => {
setView(newView);
setFilterArgs((prev) => ({
...prev,
page: newView.page,
per_page: newView.perPage,
search: newView.search,
}));
};
Remove the separate search state entirely.
If no search existed before, simply omit search or set search={false}, and do not add search to the view object or onChangeView handler.
Step 6: Simplify Status Click Handler
Remove setSelectedStatus — status now lives in view.status:
const onStatusClick = (status) => {
setFilterArgs((prev) => ({
...prev, status, page: 1, search: '',
/* reset other filters as appropriate */
}));
setView((prev) => ({ ...prev, page: 1 }));
};
Step 7: Clean Up Actions
Remove isPrimary and JSX from labels. Use isDestructive for destructive actions:
Old:
{ id: 'delete', isPrimary: true, label: () => <span className="text-danger">{__('Delete', 'dokan')}</span> }
New:
{ id: 'delete', isDestructive: true, label: () => __('Delete', 'dokan') }
Step 8: Fetch Status Counts in Effect
Move count fetching from the StatusFilter component into the list's effect:
useEffect(() => {
void fetchData();
void fetchStatusCounts();
}, [filterArgs]);
Step 9: Update JSX
Remove <Filter>, <StatusFilter>, defaultLayouts. Add tabs, filter, and search (only if previously supported):
<DataViews
namespace="..."
data={items}
tabs={tabs}
filter={filter}
fields={fields}
search={true} // only if old component had search — otherwise omit or set false
view={view}
actions={actions}
isLoading={isLoading}
paginationInfo={{ totalItems, totalPages }}
getItemId={(item) => item.id}
onChangeView={onViewChange}
/>
Step 10: Delete Dead Files
- Status filter components (
StatusFilter.tsx,StatusFilterSkeleton.tsx) - Unused imports (
Filter,SimpleInput, related types)
Migration Steps (Scenario B: Merging Multiple Lists into Tabs)
Step 1: Identify Components to Merge
Look for:
- A wrapper that conditionally renders different list components based on URL param or state
- A tab/header component that navigates between views
- Two or more list components each with their own
<DataViews>, fields, and fetch logic
Step 2: Create the Unified Component
A single component that switches behavior based on the active tab:
1. Define field arrays per tab:
const fieldsForTabA = [ /* columns for tab A */ ];
const fieldsForTabB = [ /* columns for tab B */ ];
2. Add a custom key to view state:
type ViewType = 'tab_a' | 'tab_b';
const [view, setView] = useState({
perPage: 10,
page: 1,
search: '',
type: 'table',
viewType: 'tab_a' as ViewType, // <-- custom view key
fields: fieldsForTabA.map((f) => f.id !== 'title' ? f.id : ''),
});
3. Switch fields/actions based on active tab:
const isTabA = view.viewType !== 'tab_b';
const fields = isTabA ? fieldsForTabA : fieldsForTabB;
4. Configure tabs with custom viewKey:
const tabs = {
items: [
{ label: __('Tab A', 'dokan'), value: 'tab_a' },
{ label: __('Tab B', 'dokan'), value: 'tab_b' },
],
viewKey: 'viewType', // <-- syncs to view.viewType instead of default view.status
onSelect: (viewType: ViewType) => {
setData([]); // clear stale data
setView((prev) => ({
...prev,
page: 1,
search: '',
viewType,
fields: (viewType === 'tab_a' ? fieldsForTabA : fieldsForTabB)
.map((f) => f.id !== 'title' ? f.id : ''),
}));
},
};
5. Conditionally build actions (some tabs may have none):
const actions = useMemo(() => {
if (!isTabA) return [];
const itemActions = [ /* edit, delete, etc. */ ];
return itemActions.some((a) => a.isEligible()) ? itemActions : [];
}, [isTabA, /* capabilities */]);
6. Branch fetch logic by active tab:
const fetchData = async () => {
setIsLoading(true);
try {
if (isTabA) {
// Endpoint A — may support pagination, search, etc.
} else {
// Endpoint B — may have different response shape
}
} catch (error) { /* ... */ }
finally { setIsLoading(false); }
};
7. Conditionally provide pagination (some endpoints may not support it):
const paginationInfo = isTabA
? { totalItems, totalPages: Math.ceil(totalItems / view.perPage) }
: {};
Step 3: Simplify the Wrapper
Remove the tab/header component and URL-based conditional rendering. The wrapper just renders the unified component:
const Wrapper = ({ navigate }) => (
<div>
<UnifiedList navigate={navigate} />
</div>
);
Step 4: Delete Old Files
- Old individual list components
- Old tab/header component
- Verify no external imports exist before deleting
Common Patterns (Legacy Migration)
Status Hook Shape
interface StatusItem { key: string; label: string; count: number; }
Map to tab items: statusCounts.map(s => ({ ...s, value: s.key }))
Custom viewKey for Non-Status Tabs
When tabs represent a view type (not status), set viewKey on tabs and add a matching key to the view object:
tabs = { items: [...], viewKey: 'myKey' };
view = { ..., myKey: 'defaultTab' };
Dynamic Fields per Tab
Switch the fields array based on active tab. Also update view.fields in onSelect so DataViews renders the correct columns.
Conditional Actions per Tab
Use useMemo to return different (or empty) action sets based on the active tab.
Clear Data on Tab Switch
Always setData([]) in onSelect to avoid flashing stale rows from the previous tab.
URL Sync
DataViews automatically syncs pagination, search, and tab state to URL query parameters. No manual navigate() calls needed.
Reference Migrations
- Scenario A:
modules/store-support/src/js/vendor-dashboard/components/TicketsList.tsx(branch:refactor/support-tickets-vendor-table) in dokan-pro repository. PR: https://github.com/getdokan/dokan-pro - Scenario B:
src/frontend/coupons/components/CouponList.tsx(branch:refactor/coupons-dataviews-migration) in dokan-pro repository. PR: https://github.com/getdokan/dokan-pro