Building filterable dashboards#
A dashboard with multiple chart widgets, a shared filter bar, and drill-down to detail grids. The charts consume ChartStore endpoints; the filter state is lifted into React context so every widget re-fetches when a filter changes.
Architecture#
FilterBar (shared state)
├── SummaryCard (total projects, total value, ...)
├── PieChart (projects by stage)
├── BarChart (monthly progress)
└── GridDrillDown (top vendors → click → vendor detail)Shared filter context#
// src/pages/dashboard/DashboardFilterContext.tsx
import { createContext, useContext, useState, type ReactNode } from 'react';
interface Filters {
departmentId?: number;
stage?: string;
contractorId?: number;
year?: number;
}
const Ctx = createContext<{
filters: Filters;
setFilters: (f: Filters) => void;
}>({ filters: {}, setFilters: () => {} });
export function DashboardFilterProvider({ children }: { children: ReactNode }) {
const [filters, setFilters] = useState<Filters>({});
return <Ctx.Provider value={{ filters, setFilters }}>{children}</Ctx.Provider>;
}
export const useDashboardFilters = () => useContext(Ctx);Filter bar#
// src/pages/dashboard/FilterBar.tsx
import { Group } from '@mantine/core';
import { MantineSelect, MantineServerLookup } from '@palmyralabs/rt-forms-mantine';
import { PalmyraForm } from '@palmyralabs/rt-forms';
import { useDashboardFilters } from './DashboardFilterContext';
export default function FilterBar() {
const { setFilters } = useDashboardFilters();
const onFormChange = (data: any) => setFilters(data);
return (
<PalmyraForm formData={{}} onValidChange={() => {}}>
<Group>
<MantineServerLookup
attribute="departmentId"
label="Department"
queryOptions={{ endPoint: '/department', queryAttribute: 'name',
labelAttribute: 'name', idAttribute: 'id' }}
onChange={(_v, rec) => setFilters(prev => ({ ...prev, departmentId: rec?.id }))}
/>
<MantineSelect
attribute="stage"
label="Stage"
options={[
{ value: 'PLANNING', label: 'Planning' },
{ value: 'EXECUTION', label: 'Execution' },
{ value: 'COMPLETED', label: 'Completed' },
]}
onChange={(v) => setFilters(prev => ({ ...prev, stage: v }))}
/>
</Group>
</PalmyraForm>
);
}Chart widget using ChartStore#
// src/pages/dashboard/ProjectsByStageChart.tsx
import { useEffect, useState } from 'react';
import { PieChart } from '@palmyralabs/rt-apexchart'; // or rt-chartjs
import AppStoreFactory from '../../wire/StoreFactory';
import { useDashboardFilters } from './DashboardFilterContext';
export default function ProjectsByStageChart() {
const { filters } = useDashboardFilters();
const [data, setData] = useState<any[]>([]);
useEffect(() => {
const store = AppStoreFactory.getChartStore({}, '/dashboard/projects-by-stage');
store.query({ filter: filters }).then(setData);
}, [filters]);
return (
<PieChart
data={data}
labelKey="stage"
valueKey="count"
title="Projects by Stage"
/>
);
}Summary cards#
// src/pages/dashboard/SummaryCards.tsx
import { useEffect, useState } from 'react';
import { Group, Paper, Text, Title } from '@mantine/core';
import AppStoreFactory from '../../wire/StoreFactory';
import { useDashboardFilters } from './DashboardFilterContext';
export default function SummaryCards() {
const { filters } = useDashboardFilters();
const [stats, setStats] = useState({ projects: 0, totalValue: 0, vendors: 0 });
useEffect(() => {
const store = AppStoreFactory.getChartStore({}, '/dashboard/summary');
store.query({ filter: filters }).then(rows => {
if (rows[0]) setStats(rows[0]);
});
}, [filters]);
return (
<Group>
<Paper p="md" shadow="xs"><Title order={4}>{stats.projects}</Title><Text>Projects</Text></Paper>
<Paper p="md" shadow="xs"><Title order={4}>{stats.totalValue.toLocaleString()}</Title><Text>Total Value</Text></Paper>
<Paper p="md" shadow="xs"><Title order={4}>{stats.vendors}</Title><Text>Vendors</Text></Paper>
</Group>
);
}Composing the dashboard page#
// src/pages/dashboard/DashboardPage.tsx
import { DashboardFilterProvider } from './DashboardFilterContext';
import FilterBar from './FilterBar';
import SummaryCards from './SummaryCards';
import ProjectsByStageChart from './ProjectsByStageChart';
import MonthlyProgressChart from './MonthlyProgressChart';
export default function DashboardPage() {
return (
<DashboardFilterProvider>
<div style={{ padding: 16 }}>
<FilterBar />
<SummaryCards />
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 16, marginTop: 16 }}>
<ProjectsByStageChart />
<MonthlyProgressChart />
</div>
</div>
</DashboardFilterProvider>
);
}Key patterns#
- One filter context, many consumers. Every chart re-fetches when
filterschanges — no prop-drilling, no duplicated state. ChartStoreper widget. Each chart hits its own endpoint. The backend can optimise each query independently.- Drill-down to grid. On chart click, navigate to a
PalmyraGridpre-filtered to the clicked segment — pass the filter viainitParams.
See also: PalmyraChartStore, PalmyraGrid.