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 })} + />