Build an Interactive Operations Dashboard with NocoBase NocoBase, an AI-powered no-code/low-code development platform, published a blog post demonstrating how to build an interactive operations dashboard for a ticketing system by combining its native blocks (filter, chart) with custom JavaScript blocks. The approach balances configuration ease with personalized styling and complex interactions, applicable to CRM, project management, and other business systems. This article uses an operations dashboard for a ticketing system as an example. It shows how to combine NocoBase chart blocks, filter blocks, and JS blocks to build a data dashboard that supports linked filters, chart drilldowns, and custom styling. Although the example comes from a ticketing scenario, the same approach also applies to CRM, equipment maintenance, project management, approval workflows, customer success, and other business systems. 💡 The point of this article is not to show how to write a large dashboard with a JS block. Instead, it explains how to combine NocoBase native blocks with JS blocks: native blocks handle standard capabilities, while JS blocks fill in the personalized experience. 💬 Hey, you’re reading the NocoBase blog. NocoBase is the most extensible AI-powered no-code/low-code development platform for building enterprise applications, internal tools, and all kinds of systems. It’s fully self-hosted, plugin-based, and developer-friendly. → Explore NocoBase on GitHub https://github.com/nocobase/nocobase Scenario Goal We want to build an Operations dashboard that helps operations or service teams quickly understand the current workload: - How many tickets are still open - Which tickets have SLA risks - How new ticket volume is trending - How tickets are distributed by status and priority - How to view matching records after clicking a chart The page can be roughly divided into four layers: - Top filter area: time, service group, request type, priority, and SLA status - KPI area: Open backlog, Unassigned, SLA warning, and more - Chart analysis area: trends, status distribution, SLA distribution, and priority mix - Drilldown detail area: show matching records after a chart click Start with the Right Building Approach When building a data dashboard, it is easy to turn the problem into a binary choice. Either you use only NocoBase native blocks, which are easy to configure but may feel less flexible in styling and interaction; or you write a large JS block and control queries, charts, filters, and drilldowns yourself, which means losing much of the convenience that low-code configuration provides. A better approach is to combine the two. In this Operations dashboard, we do not write the entire page as one large JS dashboard. Instead, we split responsibilities: - The top filters use NocoBase’s built-in filter block. - Trend charts, status distribution, and SLA distribution use native chart blocks. - KPI cards and drilldown details use JS blocks. - The filter block affects both chart blocks and JS blocks. - After a chart click, the drilldown condition is passed to the JS detail block below. The benefit is clear: standard statistics and filtering still keep NocoBase’s configuration capabilities, while personalized display and complex interactions are handled by JS blocks. The page is neither “configuration only” nor “all code.” Configuration and code each handle what they are good at. 1. How to Customize Chart Block Styles In a NocoBase chart block, you can first define the aggregation logic with Query builder, and then adjust the style with a custom ECharts option. Using “ticket status statistics” as an example, Query builder can be configured as follows: - Data table: tickets - Metric: id count, alias ticketCount - Dimension: status The key point 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 . js const rows = Array.isArray ctx.data?.objects ? ctx.data.objects : ; This line reads the chart query result. Then define the status labels and colors: js 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 to use ctx.t for all visible text so that multilingual support can be added later. When generating chart data, you can attach drilldown information to each chart data point: js 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 important part here is ticketingDrilldown . It is not a standard ECharts field. It is business context that we put into the data ourselves, and it will be used later when the user clicks the chart. Finally, return the 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: - Query builder is responsible for statistical data. - Custom option is responsible for visual expression. - Custom fields carry drilldown context. 2. Make the System Filter Block the Observation Scope for the Whole Page The filter area in an operations dashboard should not be an isolated form. It represents the current observation scope of the entire page. For example, when a user selects a service group, a request type, and a creation time range, the KPI cards, trend chart, status distribution, and drilldown details should all be displayed under the same conditions. Otherwise, numbers from different blocks may conflict with each other, making it hard for users to know which result belongs to the current scope. Here, we directly use NocoBase’s built-in filter block instead of writing a custom filter component. The native filter block can be naturally bound to chart blocks, allowing the Chart block to continue using Query builder, permissions, refresh, and filtering mechanisms. The top Dashboard scope can include these filter fields: - Created at - Service group - Request type - Priority - SLA status For JS blocks, you only need to read the same set of filter conditions in code and convert them into a query filter. This allows KPI cards and drilldown details to stay consistent with native charts. The filter combination can be wrapped as a small function: js function combineFilters ...filters { const parts = filters.filter Boolean ; if parts.length return undefined; if parts.length === 1 return parts 0 ; return { $and: parts }; } Count records by filter condition: js 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 lines are: resource.setFilter filter ; await resource.refresh ; JS blocks query business data through resource instead of directly writing SQL. This makes it easier to stay aligned with NocoBase permissions, data sources, and page runtime behavior. 3. Use JS Blocks to Display KPI Cards KPI cards are better suited to JS blocks, because a KPI is usually not a single query. It is often a combination of several business metrics, such as unfinished tickets, unassigned tickets, SLA warning, SLA breached, new tickets, and resolved tickets. A JS block can re-query data according to the current filter scope and render the results as statistic cards. js 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