Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions plugins/analytics/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,7 @@ one is malformed.

- Rocketflare kit **0.17.1** or later (`minKit`; earlier plugin releases support older kits).
- No other plugin, no bindings and no secrets.
- npm dependencies the install adds to `apps/web`: `drizzle-cube` (pinned 0.8.3), `recharts`, `d3`,
- npm dependencies the install adds to `apps/web`: `drizzle-cube` (pinned 0.12.2), `recharts`, `d3`,
`react-grid-layout`, `react-is`.

## Install
Expand Down Expand Up @@ -78,7 +78,7 @@ is an optional complement.

## Known gaps

- drizzle-cube is pinned to 0.8.3; newer drizzle-cube features (for example the MCP chart tool) are
- drizzle-cube is pinned to 0.12.2; newer drizzle-cube features (for example the MCP chart tool) are
not enabled.
- One example fact table; anything else you model is yours to add (the `analytics-cubes` skill
walks through it).
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6,8 +6,9 @@ categories are folders (`general-templates/`), each exporting `Record<key, Dashb

- Read `DASHBOARD_PATTERNS.md` before writing one — the layout gotchas there are silent at runtime.
- `layoutMode: 'rows'` + explicit `rows` (widths sum to 12); `groups` for KPI strips; one
`isUniversalTime` filter; `recordsTable` needs `ungrouped: true`; portlet x/y/w/h must mirror the
rows. `tests/config/all-templates.test.ts` (config project, no DB) asserts all of it AND that
`isUniversalTime` filter, pinned to a `member` on portlets without `timeDimensions`;
`recordsTable` needs `ungrouped: true`; portlet x/y/w/h must mirror the rows.
`tests/config/all-templates.test.ts` (config project, no DB) asserts all of it AND that
every `Cube.member` in a portlet query exists in `allCubes()`; the cube isolation test
additionally executes every portlet query against Postgres.
- Member names are frozen: a template references `Cube.measure` strings that are also stored in
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -108,6 +108,9 @@ Add `timeDimensions` with `granularity` to enable sparkline trends in KPI cards:
- Don't put `dateRange` in portlet queries - let dashboard filters control it
- `granularity: 'week'` is good for KPI sparklines over months
- The date filter applies to the `timeDimensions.dimension` field
- A portlet with no `timeDimensions` (a KPI, a breakdown, a `recordsTable`) cannot take the date
filter that way: pin it to a field, `{ filterId: 'my-date-filter', member: 'MyCube.dateField' }`,
or leave it unmapped. Mapped by id alone, drizzle-cube shows a warning on the card

## Stacked Percentage Area Charts

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -190,7 +190,8 @@ export const TENANT_OVERVIEW_TEMPLATE: DashboardConfig = {
'TenantUsers.joinedAt': { kind: 'date', dateGranularity: 'day' },
},
},
dashboardFilterMapping: [TIME_FILTER],
// No timeDimensions to carry the date range, so the filter is pinned to a member.
dashboardFilterMapping: [{ filterId: TIME_FILTER, member: 'TenantUsers.joinedAt' }],
x: 0,
y: 12,
w: 12,
Expand Down
4 changes: 2 additions & 2 deletions plugins/analytics/apps/web/src/plugins/analytics/plugin.json
Original file line number Diff line number Diff line change
Expand Up @@ -152,7 +152,7 @@
"dependencies": {
"apps/web": {
"d3": "^7.9.0",
"drizzle-cube": "0.8.3",
"drizzle-cube": "0.12.2",
"react-grid-layout": "^2.2.4",
"react-is": "^19.3.0",
"recharts": "^3.10.1"
Expand All @@ -173,7 +173,7 @@
"file": "apps/web/vite.config.ts",
"after": "'@': path.resolve(",
"lines": [
"'@nivo/heatmap': path.resolve(__dirname, './src/plugins/analytics/ui/lib/nivo-heatmap.tsx'),"
"'@nivo/heatmap': path.resolve(import.meta.dirname, './src/plugins/analytics/ui/lib/nivo-heatmap.tsx'),"
]
}
],
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -212,6 +212,28 @@ describe.each(CASES)('%s template', (id, config: DashboardConfig) => {
}
})

it('lets every portlet mapped to a universal time filter reach it', () => {
const universal = new Set((config.filters ?? []).filter(f => f.isUniversalTime).map(f => f.id))
for (const p of portlets) {
const query = JSON.parse(p.query ?? '{}') as { timeDimensions?: unknown[] }
for (const entry of p.dashboardFilterMapping ?? []) {
const filterId = typeof entry === 'string' ? entry : entry.filterId
if (!universal.has(filterId)) continue
const member = typeof entry === 'string' ? undefined : entry.member
if (member) {
expect(KNOWN_MEMBERS.has(member), `${id} portlet ${p.id} pins unknown ${member}`).toBe(
true
)
} else {
expect(
query.timeDimensions?.length ?? 0,
`${id} portlet ${p.id} needs timeDimensions or a member for ${filterId}`
).toBeGreaterThan(0)
}
}
}
})

it('keeps gauges out of rows too short to draw them', () => {
const rowOf = new Map<string, number>()
const groupById = new Map(groups.map(g => [g.id, g]))
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,10 @@ type FakeProps = {
config: FakeConfig
editable?: boolean
dashboardFilters?: { id: string; filter: { values: unknown[] } }[]
onDashboardFiltersChange?: (
filters: { id: string; filter: Record<string, unknown> }[],
changedFilterId: string
) => void
onConfigChange?: (c: FakeConfig) => void
onSave?: (c: FakeConfig) => Promise<void> | void
}
Expand Down Expand Up @@ -61,6 +65,20 @@ vi.mock('drizzle-cube/client', () => ({
<button type="button" onClick={() => props.onSave?.(props.config)}>
fake-save
</button>
<button
type="button"
onClick={() =>
props.onDashboardFiltersChange?.(
(props.dashboardFilters ?? []).map(f => ({
...f,
filter: { ...f.filter, values: ['last 3 months'], dateRange: 'last 3 months' },
})),
'time-filter'
)
}
>
fake-bar-3m
</button>
</div>
),
}))
Expand Down Expand Up @@ -115,6 +133,28 @@ describe('Dashboard view page', () => {
)
})

it("follows drizzle-cube's own filter bar, and the page control follows it back", async () => {
mount(analyticsPage())
await screen.findByTestId('analytics-dashboard')

fireEvent.click(screen.getByRole('button', { name: 'fake-bar-3m' }))
await waitFor(() =>
expect(
JSON.parse(screen.getByTestId('analytics-dashboard').getAttribute('data-filters') ?? '[]')
).toMatchObject([{ filter: { values: ['last 3 months'], dateRange: 'last 3 months' } }])
)
for (const name of ['Last 7 days', 'Last 30 days', 'Last 90 days']) {
expect(screen.getByRole('button', { name })).toHaveAttribute('aria-pressed', 'false')
}

fireEvent.click(screen.getByRole('button', { name: 'Last 30 days' }))
await waitFor(() =>
expect(
JSON.parse(screen.getByTestId('analytics-dashboard').getAttribute('data-filters') ?? '[]')
).toMatchObject([{ filter: { values: ['last 30 days'], dateRange: 'last 30 days' } }])
)
})

it('edit mode → onConfigChange → one debounced PATCH of the whole config', async () => {
const fetchMock = mount(analyticsPage())
await screen.findByTestId('analytics-dashboard')
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import { describe, expect, it } from 'vitest'
import {
dashboardDateFilters,
dateFilterParams,
dateFilterStateOf,
dateRangeLabel,
dateRangeValue,
parseDateFilterParams,
Expand Down Expand Up @@ -38,11 +39,51 @@ describe('date filter — pure helpers', () => {
'range=custom&from=2025-01-31&to=2025-01-01', // inverted
'range=custom&from=yesterday&to=2025-01-01',
'range=custom&from=2025-13-40&to=2025-12-31',
'range=rel',
'range=rel&rel=past%2090%20days',
'range=rel&rel=Last%203%20months',
'range=rel&rel=last%200%20days',
]) {
expect(parseDateFilterParams(params(q))).toEqual({ preset: '90d' })
}
})

it('parses any relative range the server accepts, folding our presets back into them', () => {
for (const rel of ['today', 'last 3 months', 'last 12 months', 'this year', 'next quarter']) {
expect(parseDateFilterParams(params(`range=rel&rel=${encodeURIComponent(rel)}`))).toEqual({
preset: 'rel',
rel,
})
}
expect(parseDateFilterParams(params('range=rel&rel=last%207%20days'))).toEqual({ preset: '7d' })
expect(dateFilterParams({ preset: 'rel', rel: 'last 3 months' })).toEqual({
range: 'rel',
rel: 'last 3 months',
})
expect(dateRangeValue({ preset: 'rel', rel: 'this year' })).toBe('this year')
expect(dateRangeLabel({ preset: 'rel', rel: 'last 3 months' })).toBe('Last 3 months')
expect(dateRangeValue({ preset: 'rel' })).toBe('last 90 days')
})

it("maps a filter bar's choice to state, or to nothing it cannot represent", () => {
expect(dateFilterStateOf('last 30 days')).toEqual({ preset: '30d' })
expect(dateFilterStateOf(['last 30 days'])).toEqual({ preset: '30d' })
expect(dateFilterStateOf('this quarter')).toEqual({ preset: 'rel', rel: 'this quarter' })
expect(dateFilterStateOf(['2025-01-01', '2025-01-31'])).toEqual({
preset: 'custom',
from: '2025-01-01',
to: '2025-01-31',
})
for (const bad of [
'since January',
[],
['2025-01-31', '2025-01-01'],
['2025-01-01', '2025-01-02', '2025-01-03'],
]) {
expect(dateFilterStateOf(bad), JSON.stringify(bad)).toBeNull()
}
})

it('emits exactly the relative strings drizzle-cube parses, or an ISO pair', () => {
expect(dateRangeValue({ preset: '7d' })).toBe('last 7 days')
expect(dateRangeValue({ preset: '30d' })).toBe('last 30 days')
Expand Down Expand Up @@ -82,17 +123,38 @@ describe('date filter — pure helpers', () => {
// The source config is not mutated
expect(TEMPLATE_CONFIG.filters[0].filter.values).toEqual(['last 90 days'])
})

it('overrides a saved dateRange too, which drizzle-cube reads before values', () => {
const saved = {
filters: [
{
id: 'time-filter',
label: 'Date Range',
isUniversalTime: true,
filter: {
member: '__universal_time__',
operator: 'inDateRange' as const,
values: ['last 30 days'],
dateRange: 'last 30 days',
},
},
],
}
expect(dashboardDateFilters(saved, 'last 7 days')[0]?.filter).toMatchObject({
values: ['last 7 days'],
dateRange: 'last 7 days',
})
expect(dashboardDateFilters(saved, ['2025-01-01', '2025-01-31'])[0]?.filter).toMatchObject({
values: ['2025-01-01', '2025-01-31'],
dateRange: ['2025-01-01', '2025-01-31'],
})
})
})

describe('useDashboardDateFilter — URL sync', () => {
function setup(initial = '/analytics/x') {
const wrapper = ({ children }: { children: ReactNode }) => (
<MemoryRouter
initialEntries={[initial]}
future={{ v7_startTransition: true, v7_relativeSplatPath: true }}
>
{children}
</MemoryRouter>
<MemoryRouter initialEntries={[initial]}>{children}</MemoryRouter>
)
return renderHook(() => ({ filter: useDashboardDateFilter(), location: useLocation() }), {
wrapper,
Expand Down
19 changes: 11 additions & 8 deletions plugins/analytics/apps/web/src/plugins/analytics/ui/CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -22,11 +22,11 @@ Paths below are relative to `apps/web/src/plugins/analytics/`.
- **GM wrote no chart code.** drizzle-cube renders everything: `AnalyticsDashboard` (react-grid-layout
editor, portlet editor with its own query builder, drill-down, charts) and `AnalysisBuilder`
(`/analytics/explore`). The kit owns the glue only: pages, hooks over `/api/analytics/*`
(`@rocketflare/shared/analytics`), the provider wiring and the theme mapping. drizzle-cube 0.8.3 client
(`@rocketflare/shared/analytics`), the provider wiring and the theme mapping. drizzle-cube 0.12.2 client
API actually used: `CubeProvider` from `drizzle-cube/client/providers` (`apiOptions`,
`queryClient`, `features`), `AnalyticsDashboard` (`config`, `editable`, `dashboardFilters`,
`onConfigChange`, `onSave`, `loadingComponent`), `AnalysisBuilder` + `AnalysisBuilderRef`
(`getAnalysisConfig()`), types `DashboardConfig`/`PortletConfig`/`CubeApiOptions`/
`onDashboardFiltersChange`, `onConfigChange`, `onSave`, `loadingComponent`), `AnalysisBuilder`
+ `AnalysisBuilderRef` (`getAnalysisConfig()`), types `DashboardConfig`/`PortletConfig`/`CubeApiOptions`/
`FeaturesConfig` from `drizzle-cube/client`, and `drizzle-cube/client/styles.css`.
- **Same-origin cookie auth**: `CubeClientProvider` passes `apiOptions = { apiUrl:
'/cubejs-api/v1', credentials: 'include', headers: { 'X-Requested-With': 'fetch' } }` — the
Expand Down Expand Up @@ -66,11 +66,14 @@ Paths below are relative to `apps/web/src/plugins/analytics/`.
Template pages (`templateKey !== null`) offer "Reset to template", never delete (server: 403
`template_page`); user pages the reverse. "Start from template" copies the config from the
pure `src/dashboards` registry client-side (`getTemplate(key).config` → `POST /pages`).
- **Date range** is URL state (`useDashboardDateFilter`), never a store: presets emit exactly
`'last 7|30|90 days'` or an ISO pair — an unknown relative string makes drizzle-cube DROP the
condition and silently query all time, so anything unparseable falls back to 90 days.
`dashboardDateFilters(config, range)` returns override copies of the `isUniversalTime` filters;
`AnalyticsDashboard dashboardFilters` merges them by id, so a KPI with its own window is untouched.
- **Date range** is URL state (`useDashboardDateFilter`), never a store: the page buttons emit
`'last 7|30|90 days'` or an ISO pair, and drizzle-cube's own filter bar may set any relative
range its server parses (`?range=rel&rel=last 3 months`, checked against `RELATIVE_RANGE`). The
server rejects anything else with a 400, so anything unparseable falls back to 90 days.
`dashboardDateFilters(config, range)` returns override copies of the `isUniversalTime` filters,
`values` and `dateRange` both; `AnalyticsDashboard dashboardFilters` merges them by id, so a KPI
with its own window is untouched. The bar's changes come back through
`onDashboardFiltersChange`; without that callback drizzle-cube disables a host-owned filter.
- **Explore → Save to dashboard** (admin+): `ref.getAnalysisConfig()` becomes a portlet
(`analysisConfig`, the canonical format) appended as a full-width `rows` entry with mirrored
x/y/w/h (`appendPortlet`, pure) and saved with `PATCH /pages/:id`.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,8 @@
* no chart code: the grid editor, portlet editor, drill-down and charts are the library's. This
* component owns the glue — the page config as local state (seeded from the row, re-seeded when
* the server row changes and nothing is dirty, e.g. after a reset), the page-level date range
* applied as `dashboardFilters` overrides of the template's `isUniversalTime` filter, and
* applied as `dashboardFilters` overrides of the template's `isUniversalTime` filter (and a change
* made in drizzle-cube's own filter bar handed back to the page through `onRangeChange`), and
* autosave: in edit mode every `onConfigChange` schedules a debounced whole-config PATCH
* (`useAutosaveDashboardConfig`), `onSave` (the editor's explicit save) flushes it, leaving edit
* mode flushes it, and while anything is unsaved a `beforeunload` guard warns the reader.
Expand All @@ -17,13 +18,19 @@ import { AnalyticsDashboard, type DashboardConfig } from 'drizzle-cube/client'
import { useCallback, useEffect, useMemo, useState } from 'react'
import { LoadingIndicator } from '@/plugins/api/ui'
import { useAutosaveDashboardConfig } from '../hooks/useAnalyticsPages'
import { type DateRangeValue, dashboardDateFilters } from '../hooks/useDashboardDateFilter'
import {
type DashboardFilter,
type DateRangeValue,
dashboardDateFilters,
} from '../hooks/useDashboardDateFilter'

interface DashboardLoaderProps {
page: AnalyticsPage
/** Edit mode (admin+, `manage Dashboard`) — the caller has already checked the ability. */
editing: boolean
range: DateRangeValue
/** A universal-time filter value chosen in drizzle-cube's filter bar. */
onRangeChange?: (value: unknown) => void
onDirtyChange?: (dirty: boolean) => void
}

Expand All @@ -32,7 +39,13 @@ export function asDashboardConfig(config: DashboardConfigJson): DashboardConfig
return config as unknown as DashboardConfig
}

export function DashboardLoader({ page, editing, range, onDirtyChange }: DashboardLoaderProps) {
export function DashboardLoader({
page,
editing,
range,
onRangeChange,
onDirtyChange,
}: DashboardLoaderProps) {
const [config, setConfig] = useState<DashboardConfig>(() => asDashboardConfig(page.config))
const autosave = useAutosaveDashboardConfig(page.id)
const { dirty, flush, schedule } = autosave
Expand Down Expand Up @@ -82,12 +95,22 @@ export function DashboardLoader({ page, editing, range, onDirtyChange }: Dashboa

const dashboardFilters = useMemo(() => dashboardDateFilters(config, range), [config, range])

const handleFiltersChange = useCallback(
(filters: DashboardFilter[], changedFilterId: string) => {
const changed = filters.find(f => f.id === changedFilterId)
if (!changed?.isUniversalTime || !('member' in changed.filter)) return
onRangeChange?.(changed.filter.dateRange ?? changed.filter.values)
},
[onRangeChange]
)

return (
<div data-testid="dashboard-loader" data-dirty={dirty} data-editing={editing}>
<AnalyticsDashboard
config={config}
editable={editing}
dashboardFilters={dashboardFilters}
onDashboardFiltersChange={handleFiltersChange}
onConfigChange={handleConfigChange}
onSave={editing ? handleSave : undefined}
loadingComponent={<LoadingIndicator size="lg" centered />}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,14 +5,14 @@
*/
import { useState } from 'react'
import {
type ButtonPreset,
DATE_RANGE_PRESETS,
type DateFilterState,
type DateRangePreset,
} from '../hooks/useDashboardDateFilter'

interface DateRangeControlProps {
state: DateFilterState
onPreset: (preset: Exclude<DateRangePreset, 'custom'>) => void
onPreset: (preset: ButtonPreset) => void
onCustom: (from: string, to: string) => void
}

Expand Down
Loading
Loading