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 filters changes — no prop-drilling, no duplicated state.
  • ChartStore per widget. Each chart hits its own endpoint. The backend can optimise each query independently.
  • Drill-down to grid. On chart click, navigate to a PalmyraGrid pre-filtered to the clicked segment — pass the filter via initParams.

See also: PalmyraChartStore, PalmyraGrid.