docs/docs/en/building-tips/operations-dashboard.md
This article takes the operation dashboard of the "work order system" as an example to introduce how to use NocoBase's chart block, filter block and JS block in combination to build a data dashboard that supports filter linkage, chart drill-down and custom styles.
Although the examples are from work order scenarios, these methods are also applicable to business systems such as CRM, equipment operations, project management, approval flow, customer success, etc.
:::tip What this article wants to introduce is not "how to use JS blocks to write a large screen", but how to combine NocoBase's native block capabilities and JS blocks: Let the native blocks be responsible for standard capabilities, and let the JS blocks complement the personalized experience. :::
We hope to build an Operations dashboard to help the operation or service team quickly determine the current workload:
The page can be roughly divided into four layers:
When many people make data dashboards, they tend to think of the problem as one of two options:
Either use all NocoBase's native blocks, which are simple to configure, but worry that the style and interaction are not flexible enough; or simply write a large JS block and control the query, chart, filtering, and drill-down by yourself, but this will lose the convenience brought by low-code configuration.
In fact, the more recommended way is to combine the two.
In this Operations dashboard, we did not write the entire page as a large JS screen, but split it according to responsibilities:
The advantage of this is that standard statistics and filtering still retain the configuration capabilities of NocoBase, while personalized display and complex interactions are completed by JS blocks. The page is neither "configurable only" nor "all code", but configuration and code each perform their own duties.
The chart block of NocoBase can first use Query builder to define the statistical caliber, and then use the custom ECharts option to adjust the style.
Taking "work order status statistics" as an example, Query builder can be configured as:
The key is that when customizing the style, you do not need to rewrite the query, you only need to process the chart display based on ctx.data.objects.
const rows = Array.isArray(ctx.data?.objects) ? ctx.data.objects : [];
This line of code reads the chart query results. Then define status labels and colors:
const labels = {
new: ctx.t('New'),
open: ctx.t('Open'),
pending_customer: ctx.t('Pending customer'),
resolved: ctx.t('Resolved'),
closed: ctx.t('Closed'),
};
const colors = {
new: '#1677ff',
open: '#22a06b',
pending_customer: '#f59f00',
resolved: '#13c2c2',
closed: '#8c8c8c',
};
It is recommended that all visible copywriting use ctx.t() to facilitate subsequent multi-language support.
When generating chart data, you can attach drill-down information to each chart data point:
const data = rows.map((row) => ({
value: Number(row.ticketCount || 0),
itemStyle: {
color: colors[row.status] || '#8c8c8c',
borderRadius: [6, 6, 0, 0],
},
ticketingDrilldown: {
label: ctx.t('Status') + ': ' + (labels[row.status] || row.status),
filter: { status: { $eq: row.status } },
},
}));
The most critical thing here is ticketingDrilldown. It is not a standard field of ECharts, but a business context that we put in ourselves, which will be used when clicking on the chart later.
Finally return to ECharts option:
return {
grid: { top: 28, right: 22, bottom: 48, left: 42 },
tooltip: { trigger: 'axis', axisPointer: { type: 'shadow' } },
xAxis: {
type: 'category',
data: rows.map((row) => labels[row.status] || row.status),
},
yAxis: {
type: 'value',
minInterval: 1,
},
series: [
{
name: ctx.t('Tickets'),
type: 'bar',
barWidth: 36,
data,
},
],
};
The core idea of this part is:
The filter area in the operational dashboard should not be just an isolated form. It represents the current observation diameter of the entire page.
For example, if the user selects a service group, a request type, and a creation time, then KPIs, trend charts, status distribution, and drill-down details should all be displayed based on the same set of conditions. Otherwise, the numbers in different blocks on the page will fight with each other, and it will be difficult for users to judge which data is the result within the current range.
Here we directly use the filtering block that comes with the NocoBase system instead of writing a filtering component ourselves. Native filter blocks can be naturally bound to chart blocks, allowing the chart block to continue to use the Query builder, permissions, refresh and filter mechanisms.
Top Dashboard scope can configure these filter items:
For JS blocks, you only need to read the same set of filter conditions in the code and then convert them into query filters. In this way, KPIs and drill-down details can also be consistent with the native chart.
The combination of filter conditions can be encapsulated into a small function:
function combineFilters(...filters) {
const parts = filters.filter(Boolean);
if (!parts.length) return undefined;
if (parts.length === 1) return parts[0];
return { $and: parts };
}
Count by filter:
async function countTickets(filter) {
const resource = ctx.makeResource('MultiRecordResource');
resource.setResourceName('tickets');
resource.setPageSize(1);
if (filter) {
resource.setFilter(filter);
}
await resource.refresh();
const meta = resource.getMeta?.() || {};
return Number(meta.count || meta.total || 0);
}
The key points here are:
resource.setFilter(filter);
await resource.refresh();
The JS block queries business data through resources instead of writing SQL directly. This makes it easier to keep consistent with NocoBase's permissions, data sources, and page runtimes.
KPIs are better suited to use JS blocks. Because KPI is usually not a single query, but a combination of multiple business calibers: unfinished, unassigned, SLA warning, SLA breached, new, resolved, etc.
The JS block can requery data based on the current filtering range and render it into a statistical card.
const { Card, Col, Row, Statistic, Tag } = ctx.libs.antd;
const scopeFilter = getDashboardScopeFilter();
const openBacklog = await countTickets(
combineFilters(scopeFilter, {
status: { $notIn: ['resolved', 'closed', 'cancelled'] },
}),
);
ctx.render(
<Row gutter={[12, 12]}>
<Col span={6}>
<Card size="small">
<Tag color="blue">{ctx.t('Active')}</Tag>
<Statistic title={ctx.t('Open backlog')} value={openBacklog} />
</Card>
</Col>
</Row>,
);
The key points of JS blocks are:
ctx.makeResource() to query data;ctx.libs.antd to render the interface;ctx.render() to output content;In a real page, the filter button and reset button can configure the event flow so that they refresh the KPI JS block and drill-down JS block at the same time after completing the native filter action. In this way, the user clicks once to filter, and both charts and custom content will be updated based on the same range.
Clicking on the chart to drill down is a very practical interaction in the dashboard.
In the work order scenario, the user clicks the "Status: Open" column, and all Open work orders are displayed in the detail area below; when the user clicks "SLA breached", all overtime work orders are displayed below.
The implementation idea is:
ticketingDrilldown;The key code in the chart event is as follows. First find the drill-down JS block:
const DRILLDOWN_TARGET_UID = 'v7mioopm6rm';
function getDrilldownTarget() {
if (typeof ctx.getModel === 'function') {
return ctx.getModel(DRILLDOWN_TARGET_UID);
}
const engine =
ctx.model?.flowEngine || ctx.model?.context?.flowEngine || ctx.engine;
return engine?.getModel?.(DRILLDOWN_TARGET_UID);
}
Then write the drill-down conditions obtained by clicking on the chart into the target block:
function applyDrilldown(drilldown) {
if (!drilldown?.filter) return;
const target = getDrilldownTarget();
if (!target?.context?.defineProperty) return;
target.context.defineProperty('ticketingDashboardDrilldown', {
value: drilldown,
});
target.rerender?.();
}
The most critical are these two lines:
target.context.defineProperty('ticketingDashboardDrilldown', {
value: drilldown,
});
target.rerender?.();
The first line passes the drill-down condition to the JS block, and the second line triggers the JS block refresh.
Finally bind the chart click event:
const clickHandler = (params) => {
applyDrilldown(params?.data?.ticketingDrilldown);
};
chart.on('click', clickHandler);
return () => chart.off('click', clickHandler);
It is recommended here that you must return cleanup:
return () => chart.off('click', clickHandler);
In this way, when the chart is reconfigured or re-rendered, old events can be cleaned up to avoid repeated binding.
The above click event-related code is applicable to v2.2.0-beta.10 and above versions. Reference to the old version code:
chart.off('click');
chart.on('click', clickHandler);
Drill down into the JS block to read the ticketingDashboardDrilldown just written, and then query the data according to the filter in it.
const drilldown = ctx.model?.context?.ticketingDashboardDrilldown;
if (!drilldown) {
ctx.render(
<Alert
type="info"
showIcon
message={ctx.t('Select a chart segment to inspect matching tickets')}
/>,
);
return;
}
If the user has not clicked on the chart, display a prompt. After clicking, query the work order based on drilldown.filter:
const resource = ctx.makeResource('MultiRecordResource');
resource.setResourceName('tickets');
resource.setFilter(drilldown.filter);
resource.setPageSize(10);
await resource.refresh();
const rows = resource.getData?.() || [];
Then render the table:
const { Table, Typography } = ctx.libs.antd;
ctx.render(
<>
<Typography.Title level={5}>
{ctx.t('Drilldown')}: {drilldown.label}
</Typography.Title>
<Table
size="small"
rowKey="id"
dataSource={rows}
pagination={false}
columns={[
{ title: ctx.t('Ticket No'), dataIndex: 'ticketNo' },
{ title: ctx.t('Title'), dataIndex: 'title' },
{ title: ctx.t('Status'), dataIndex: 'status' },
{ title: ctx.t('Priority'), dataIndex: 'priority' },
]}
/>
</>,
);
If you need to clear drill-down conditions, you can refer to
function clearChartDrilldown() {
if (ctx.model?.context?.defineProperty) {
ctx.model.context.defineProperty('ticketingDashboardDrilldown', {
value: null,
});
}
if (typeof ctx.model?.rerender === 'function') {
ctx.model.rerender();
}
}
The key points in this part are:
The most important lesson from this page is: don’t pit native capabilities against JS capabilities.
If a capability is already a native capability of NocoBase, such as filtering, chart query, table display, and permission control, the native block will be used first. In this way, when fields, filter conditions, and chart caliber are subsequently adjusted, they can still be configured on the interface.
JS blocks are more suitable for processing parts that native blocks are not good at, such as combining multiple indicators into a set of KPIs, special card styles, displaying a set of custom details after clicking on the chart, or passing business context between different blocks.
In other words, the native block is responsible for "configurable standard capabilities", and the JS block is responsible for "business-oriented personalized experience". This is also the most reusable construction idea for this dashboard.
This preserves NocoBase's standard query, permissions, filtering and refreshing capabilities. Only when the default chart style cannot express the business focus, use the customized ECharts option for visual optimization.
KPIs often require multiple queries, condition combinations, and custom layouts, and JS blocks are more flexible. Especially when KPIs need to respond to the same set of system filter conditions, it will be clearer to use JS blocks to handle them uniformly.
Recommended writing method:
const handler = (params) => {
// handle click
};
chart.on('click', handler);
return () => chart.off('click', handler);
Do not directly use chart.off('click') to clear all click events, as this may accidentally delete the chart block or configure the panel's own monitoring.
This type of dashboard is very suitable for AI-assisted generation because it involves data models, statistical calibers, chart styles, and page interactions at the same time. You can hand it the content of this article and ask questions using the prompt words below.
You can ask questions like this:
I am using NocoBase to build an operational dashboard for a work order system.
Please take the work order scenario as an example and help me design an Operations dashboard.
The data table tickets contains:
ticketNo、title、status、priority、slaStatus、
requestType、serviceGroup、assignee、createdAt、updatedAt。
The page requires:
1. Top filter: Created at, Service group, Request type, Priority, SLA status.
2. KPI cards: Open backlog, Unassigned, SLA warning, SLA breached, New tickets, Resolved tickets.
3. Chart: Created tickets trend, Ticket status, SLA status, Priority mix.
4. After clicking on the chart, the JS block below displays the matching Ticket drilldown table.
5. The chart style should be suitable for the operating market, with clear colors and compact layout.
6. Use ctx.t() for all JS copy.
7. Chart events use chart.on and return the cleanup function.
8. Prioritize using NocoBase’s native filter blocks and chart blocks. Only use JS blocks for KPIs, drill-down details, special styles, and cross-block interactions. Do not write the entire page as one large JS block.
Please give the configuration ideas for each block and mark the key JS code.
If you already have a page, you can also let AI help you optimize it:
This is my current NocoBase dashboard design:
At the top is the filter area, in the middle are 4 charts, and below is the drill-down JS block.
Please help me optimize from the perspective of operator experience:
1. What indicators should the KPI display?
2. Whether there is a need for linkage between charts;
3. Which columns should be displayed in drill-down details;
4. How should JS block and chart events be organized;
5. Which code should be placed in the chart custom option and which should be placed in the JS block.
In this way, the content generated by AI will be closer to the real business, rather than just giving isolated code.
:::warning If you choose to let AI help you build it, please use the backup manager to back up the project before starting. :::