From ed22954462d905c8d7a94ea512e8decbe66d6a61 Mon Sep 17 00:00:00 2001 From: ikrasovytskyi Date: Thu, 13 Aug 2026 00:40:11 +0200 Subject: [PATCH] dashboards(tiles): each tile can measure its OWN slice, not the view's total (#304) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Every tile on a dashboard necessarily showed the same number, because the only scope was the view's single filter. The founder's screenshot: three tiles, all reading 303, one of them labelled "Count of Epics Opened" — the label was free text and lied. "it's like bullshitty functionality now. like I can't select what to show. Except for how to aggregate." A tile now carries its own `filter`, ANDed with the view's (and the viewer's personal override). Same filter AST as views / /records/query / rollups — not a second condition language — and the same `FiltersSection` builder the view toolbar uses, so there's one filter UI in the product rather than two. Implementation note: a tile is now its own component that owns its query, rather than every tile sharing one page-level fetch. That means the tile's scope goes to the SERVER, so operator semantics are the server's (no client-side filter evaluator to drift), and results stay grant-scoped — a tile can never read past the viewer's access. Two tiles with identical scope share one request; react-query dedupes on the query key, so N tiles is not N round trips. `cleanViewConfig` prunes a tile filter's dead conditions exactly as it prunes the view's, but keeps the TILE — #305's rule: unconfigured/partially-dead is not junk. Scope: same-database only. Cross-database tiles are the other half of #304 and are deliberately separate — a per-tile database needs viewer-scoped access checks, and that belongs in its own reviewable change. Widget (chart) filters likewise wait for their fetch, so no field is added that would be silently ignored. Verified: API suite 1824 passed / 187 files (only backup-restore.test.ts fails — needs Docker, documented); web 491 passed; lint, typecheck, build green; SDK regenerated for the schema change. Co-Authored-By: Claude Opus 5 --- apps/api/src/views/views.service.test.ts | 30 ++++++++ apps/api/src/views/views.service.ts | 14 +++- .../src/components/views/dashboard-view.tsx | 76 +++++++++++++++++-- docs/api/openapi.json | 6 ++ packages/schemas/src/views.ts | 7 ++ packages/sdk/src/generated/schema.ts | 2 + 6 files changed, 127 insertions(+), 8 deletions(-) diff --git a/apps/api/src/views/views.service.test.ts b/apps/api/src/views/views.service.test.ts index cc6b5c1d..379fbf8f 100644 --- a/apps/api/src/views/views.service.test.ts +++ b/apps/api/src/views/views.service.test.ts @@ -152,6 +152,36 @@ describe('cleanViewConfig — dashboard metric tiles (MN-225 / #168)', () => { expect(result).toHaveLength(1); }); + // #304: a tile carries its OWN filter so each tile can measure a different slice. + // It must survive the read path (the zod config would strip an unknown key) and get + // the same dead-field pruning the view's own filter gets. + it('keeps a tile filter, and prunes only its dead conditions (#304)', () => { + const result = tiles([ + { + id: '66666666-6666-6666-6666-666666666666', + label: '', + op: 'count', + filter: { and: [{ field: 'amount', op: 'gt', value: 5 }] }, + } as never, + ]); + expect(result).toHaveLength(1); + expect(result![0]!.filter).toEqual({ and: [{ field: 'amount', op: 'gt', value: 5 }] }); + }); + + it('drops a tile-filter condition on a deleted field but keeps the tile (#304)', () => { + const result = tiles([ + { + id: '77777777-7777-7777-7777-777777777777', + label: '', + op: 'count', + filter: { and: [{ field: 'ghost', op: 'eq', value: 1 }] }, + } as never, + ]); + // The tile is still configured — only the dead condition goes (#305's rule). + expect(result).toHaveLength(1); + expect(result![0]!.filter).toBeUndefined(); + }); + it('defaults to an empty array when tiles are absent', () => { expect(tiles(undefined as unknown as ViewConfig['dashboard_tiles'])).toEqual([]); }); diff --git a/apps/api/src/views/views.service.ts b/apps/api/src/views/views.service.ts index a9cd4cb7..dab10bf7 100644 --- a/apps/api/src/views/views.service.ts +++ b/apps/api/src/views/views.service.ts @@ -97,9 +97,17 @@ export function cleanViewConfig( // field) produced `{op:'sum'}` with no field, and the tile was silently // garbage-collected on the very next read — the card "deleted itself". // "Unconfigured" and "dangling" are different states; only the latter is junk. - dashboard_tiles: (config.dashboard_tiles ?? []).filter( - (t) => t.field_api_name == null || liveApiNames.has(t.field_api_name), - ), + dashboard_tiles: (config.dashboard_tiles ?? []) + .filter((t) => t.field_api_name == null || liveApiNames.has(t.field_api_name)) + // #304: a tile's own filter gets the same pruning the view's filter gets — + // a condition on a deleted field is dropped rather than left to fail at query + // time. The TILE itself survives (it is still configured); only the dead + // condition goes, exactly as cleanFilterNode does for the view. + .map((t) => + t.filter + ? { ...t, filter: cleanFilterNode(t.filter, liveApiNames) as typeof t.filter } + : t, + ), // Dashboard chart/table widgets (MN-225 / #168, Phase 2): same rule. // #305: requiring a live `group_by_field_api_name` meant a freshly added // chart (created with no group-by, by design — you pick it afterwards) was diff --git a/apps/web/src/components/views/dashboard-view.tsx b/apps/web/src/components/views/dashboard-view.tsx index 47e29f67..973be82b 100644 --- a/apps/web/src/components/views/dashboard-view.tsx +++ b/apps/web/src/components/views/dashboard-view.tsx @@ -2,9 +2,10 @@ import { useEffect, useMemo } from 'react'; import { Plus, Trash2 } from 'lucide-react'; -import { useDatabase, useRecordsInfinite } from '../table-view/use-table-data'; -import type { FilterNode, ViewConfig } from './use-view-state'; -import { queryBodyFromConfig } from './use-view-state'; +import { useDatabase, useMembers, useRecordsInfinite } from '../table-view/use-table-data'; +import type { FilterNode, ViewConfig, FilterGroup } from './use-view-state'; +import { andFilterNodes, queryBodyFromConfig } from './use-view-state'; +import { FiltersSection } from './view-toolbar'; import { TILE_OPS, computeTileValue, @@ -23,6 +24,8 @@ export interface DashboardTile { label: string; op: TileOp; field_api_name?: string; + /** #304 — this tile's own scope, ANDed with the view's filter. */ + filter?: FilterNode; } const SELECT_CLASS = @@ -57,6 +60,12 @@ export function DashboardView({ onPatch: (updates: Partial) => void; }) { const database = useDatabase(ws, db); + // #304 — the tile filter builder needs the roster for person-field conditions. + const members = useMembers(ws, !readOnly); + const memberList = useMemo( + () => (members.data ?? []).map((m) => ({ id: m.user.id, name: m.user.name })), + [members.data], + ); const queryBody = useMemo(() => queryBodyFromConfig(config, personalFilter), [config, personalFilter]); const records = useRecordsInfinite(ws, db, queryBody); @@ -125,7 +134,6 @@ export function DashboardView({
{tiles.map((tile) => { - const value = loading ? null : computeTileValue(tile.op, tile.field_api_name, rows); const heading = tile.label.trim() || defaultTileLabel(tile.op, fieldName.get(tile.field_api_name ?? '')); return (
- {loading ? : formatTileValue(value)} + {!readOnly && ( @@ -158,6 +166,18 @@ export function DashboardView({ onChange={(e) => updateTile(tile.id, { label: e.target.value })} className="h-8 rounded-[var(--radius-control)] border border-border-default bg-card px-2 text-[13px] text-ink placeholder:text-faint" /> + {/* #304 — this tile's own scope. The SAME builder the view toolbar + uses (one filter spec, one UI), so a tile can measure a slice + instead of every tile repeating the view's total. No viewId is + passed: Personal scope is a per-VIEW override, not a per-tile one. */} + updateTile(tile.id, { filter: filter as FilterNode | undefined })} + />