PostHog Dashboard 列表型 Widget 开发模式:Tile 筛选栏、分页脚注与标题导航实现指南
适用场景:在 PostHog 开源仓库中新增或改造一个列表/表格型(list/table)Dashboard Widget(如 error_tracking_list、session_replay_list,或下一个列表类型)。读者将掌握列表型 Widget 的三大平台级交互模式——tile 筛选栏(widgetFilters)、分页脚注(pagination footer) 与 标题导航(titleHref)——的完整数据流、配置分层、RBAC 门控与源码级实现细节,可直接套用到新类型的开发与评审中。
本文以 .agents/skills/manage-dashboard-widgets/references/list-widget-patterns.md 为核心骨架,并结合 products/dashboards/backend、products/dashboards/frontend 中的真实源码与测试进行印证和扩展。
一、模式总览:列表型 Widget 的三个平台约定
PostHog Dashboard Widget 平台把"内容组件"(列表、表格、卡片)与"卡片外壳"(header、⋯ 菜单、编辑弹窗、网格缩放)做了严格分层。对列表型 Widget 而言,除了通用组件规则(见 composition.md)与日期范围展示(见 layout-and-ux.md),还额外约定三件事:
- Tile 筛选栏(
widgetFilters):在卡片主体上方渲染常驻筛选条,让用户不打开编辑弹窗即可切换日期、状态、指派人与属性筛选; - 分页脚注(pagination footer):在
WidgetCardContent底部显示"已展示 / 总数"信息,并支持hasMore下的N+兜底文案; - 标题导航(
titleHref):卡片标题在查看模式下可点击跳转到对应产品场景页(如/error-tracking、/replay)。
已上线参考实现:error_tracking_list(错误追踪 Top issues)与 session_replay_list(会话回放 Recent recordings)。二者的前后端完整实现是新增列表类型时最直接的镜像样板。
二、Tile 筛选栏(widgetFilters):分层数据流与门控
2.1 职责分层
widgetFilters 是持久化在 config 上的属性筛选选择。整条链路按层拆分,各司其职:
| 分层 | 路径 | 职责 |
|---|---|---|
| Zod(生成) | widget-configs.zod.ts(config + form schemas、类型,直接 import) |
唯一类型真相源,由后端 Pydantic 生成 |
| Zod(表单/弹窗) | *WidgetConfigValidation.ts(对 config schema 做 .pick())、widgetConfigShared.ts(仅 UI 文案) |
编辑弹窗表单校验与 API 错误解析 |
| FE 辅助 | widgetFilters.ts |
widgetFilters 持久化/HogQL 桥接、编辑态与 tile 态的 persist/restore hooks |
| BE 校验 + HogQL | widget_filters.py | 通用 validate_widget_filters + build_*_from_widget_filters 系列构建函数 |
| Tile 栏 | *WidgetTileFilters.tsx、widgetTileFiltersHooks.ts(useWidgetTileConfigPersist)、WidgetPropertyFiltersSection |
常驻筛选条的渲染与交互 |
| Tile 栏挂载 | DashboardWidgetItem |
仅当 hasProductAccess && showTileFilters 时挂载注册表中的 TileFilters |
编辑弹窗 vs 卡片内筛选条的分工:编辑弹窗(Edit modal)负责测试账户(test accounts)、limit、排序;tile 栏负责日期、状态/类型选择器与属性筛选。二者不要混淆——例如 composition.md 明确要求"不要在 widget 组件内重复 header、菜单、卡片 chrome 或筛选开关;limit/sort/test accounts 放编辑弹窗,date/status/property 筛选放 tile 栏(不要放 ⋯ 菜单)"。
2.2 后端校验与 HogQL 桥接
后端侧的核心文件是 widget_filters.py。它提供两个层次的 API:
validate_widget_filters(config):通用校验入口。config["widgetFilters"]必须是dict,每个 key 是非空字符串,value 会被强转为WidgetFilterEntry(filterId必须与 key 一致),最终返回WidgetFilterConfig | None。任何非法结构都会以DRFValidationError抛出带字段路径的错误信息。build_event_property_filters_from_widget_filters/build_property_group_filter_from_widget_filters:把持久化的筛选条目翻译成 HogQL/属性筛选结构。核心逻辑是逐条构造{"type": "event", "key": propertyName, "operator": operator.value},并在有值的情况下把 value 规范为列表;build_property_group_filter_from_widget_filters再包一层AND的PropertyGroupFilter,供查询 runner 直接消费。
从源码结构看,这套设计保证了"持久化格式(widgetFilters 字典)→ 属性筛选(PropertyGroupFilter)"的单向桥接,所有列表型 Widget 共用同一份校验逻辑,不需要每个类型手写 validate_<type>_config。
2.3 前端 Tile 栏实现:只读态与可编辑态
前端真实实现以 error_tracking_list 为例:ErrorTrackingWidgetTileFilters.tsx。其结构揭示了几条关键约定:
export function ErrorTrackingWidgetTileFilters({
config,
onUpdateConfig,
disabledReason,
canMutateErrorTrackingIssues = false,
}: ErrorTrackingWidgetTileFiltersProps): JSX.Element {
const { context: filterDefinitionsContext, isAllowed } = getWidgetTileFiltersSetup('error_tracking_list')
const parsed = parseErrorTrackingWidgetConfig(config)
const dateFrom = (parsed.dateRange?.date_from ?? '-7d') as WidgetDateFromValue
const status = (parsed.status ?? 'active') as ErrorTrackingStatusSelectValue
const widgetFilters = parsed.widgetFilters ?? {}
// ...
const { getLatestConfig, persistConfigDebounced, persistConfigNow } = useWidgetTileConfigPersist(
onUpdateConfig,
config
)
getWidgetTileFiltersSetup(widgetType)(widgetTileFiltersHooks.ts):从DASHBOARD_WIDGET_CATALOG[widgetType].tileFilters读取quickFilterContext与allowedPropertyNames,构造"该类型允许哪些快速属性筛选"的判定函数isAllowed。catalog 中缺少tileFilters配置会直接抛错——这保证了"注册了TileFilters就一定有合法配置"。useWidgetTileConfigPersist(onUpdateConfig, config):返回getLatestConfig(读取最新 config,避免闭包过期)、persistConfigDebounced(防抖持久化,配合WIDGET_TILE_REFRESH_DEBOUNCE_MS = 300,见 constants.ts)与persistConfigNow(立即持久化)。属性筛选使用防抖路径,日期/状态切换走立即路径。- 双态渲染:
onUpdateConfig不存在(只读/无编辑权限)时渲染WidgetTileFiltersBar下的只读值组件(WidgetDateRangeReadOnlyValue、ErrorTrackingStatusReadOnlyValue、ErrorTrackingAssigneeReadOnlyValue、WidgetPropertyFiltersReadOnlyValues);存在时渲染LemonSelect、ErrorTrackingStatusSelect等可交互控件,并通过applyPatch/applyWidgetFilters写回 config。
2.4 挂载门控:权限与可用性
DashboardWidgetItem 是唯一生产挂载点(DashboardWidgetItem.tsx):
const TileFilters = definition?.TileFilters
const { isAvailable: showTileFilters } = useWidgetAvailability(headerCatalogEntry.availability)
// ...
{!showSharedPlaceholder && hasProductAccess && showTileFilters && TileFilters ? (
<Suspense fallback={null}>
<TileFilters
tileId={tile.id}
config={widget.config}
onUpdateConfig={componentProps.onUpdateConfig}
canMutateErrorTrackingIssues={componentProps.canMutateErrorTrackingIssues}
disabledReason={
canUpdateWidgetTileConfig ? undefined : DASHBOARD_WIDGET_TILE_FILTERS_READONLY_REASON
}
/>
</Suspense>
) : null}
关键门控逻辑:
canEditDashboard决定编辑态 vs 只读态:无编辑权限时,筛选控件仍可见但被禁用,并附带DASHBOARD_WIDGET_TILE_FILTERS_READONLY_REASON文案——"You don't have edit permissions for this dashboard. Ask a dashboard collaborator with edit access to add you."(见 constants.ts)。- RBAC 拒绝时整条 bar 隐藏:
hasProductAccess为 false(如产品 RBAC 被拒)时TileFilters完全不渲染,此时卡片 body 显示锁定态(locked state)而不是筛选栏。 - 与仪表盘快速筛选条(quick-filter bar)解耦:widget 的 tile 筛选独立于 dashboard 级快速筛选,互不影响。
- public/shared 放置不渲染筛选栏:
showSharedPlaceholder为真时走WidgetCardSharedPlaceholderBody,没有实时数据也没有筛选条。
2.5 新增列表类型的落地检查单
- 在
*WidgetTileFilters.tsx中实现TileFilters组件,并在registry.tsx的DASHBOARD_WIDGET_REGISTRY条目中挂载TileFilters; - 在 catalog 条目上配置
tileFilters: { quickFilterContext, allowedPropertyNames }; - 确保
widgetFilters持久化在 config 上(校验走后端 widget_filters.py,前端走widgetFilters.ts); - 完整清单参考 checklist-new-widget-type.md §5 Frontend widget component。
三、分页脚注(Pagination Footer):hasMore、totalCount 与 N+ 文案
3.1 后端 run_* 契约
列表型 Widget 的 run_<type>_widget(如 run_error_tracking_list_widget)统一返回四个字段:results、hasMore、limit、offset。真实实现见 error_tracking_list.py:
return run_list_widget(
limit=typed_config["limit"],
count_cap=MAX_WIDGET_RESULT_LIMIT,
include_total_count=include_total_count,
fetch_page=fetch_page,
transform_row=lambda issue: pick_fields(cast(dict[str, object], issue), LIST_ISSUE_FIELDS),
log_key="error_tracking_widget_total_count_failed",
)
三条核心规则:
!hasMore时totalCount= 已展示数:当没有更多数据时,总数就是当前页已展示的行数,无需额外查询;hasMore+include_total_count=True时执行 capped 计数查询:上限为MAX_WIDGET_RESULT_LIMIT。该常量定义在 constants.py:MAX_WIDGET_RESULT_LIMIT = 25,即每个 widget 查询返回行数的硬上限(run_widgets限流 + UI "25+" 脚注共用此值),同时DEFAULT_WIDGET_LIST_LIMIT = 10是 config 省略 limit 时的默认值;- Dashboard 的
run_widgets恒传include_total_count=False:在 dashboard.py 的批量循环中,每个 tile 查询都以include_total_count=False调用query_fn——当页面还有更多行时直接跳过计数查询,避免为每个 tile 多打一次 ClickHouse 计数。totalCount的 capped 计数查询路径由测试验证(test_run_widgets.py 中断言count_call_query.limit == MAX_WIDGET_RESULT_LIMIT)。
计数失败永不使 tile 失败:计数查询抛异常时仅记录日志(log_key="error_tracking_widget_total_count_failed")并省略 total 展示,tile 主体照常渲染。
3.2 前端脚注渲染
WidgetCardContent 的 footer 槽位调用 formatWidgetListCountFooter(shown, totalCount, totalCountCapped, noun, hasMore)(定义于 WidgetCardBody.tsx):
export const WIDGET_LIST_COUNT_ISSUES: WidgetListCountNoun = { singular: 'issue', plural: 'issues' }
export const WIDGET_LIST_COUNT_RECORDINGS: WidgetListCountNoun = { singular: 'recording', plural: 'recordings' }
// 另有 WIDGET_LIST_COUNT_EVENTS / EXPERIMENTS / LOGS / TICKETS
export function formatWidgetListCountFooter(
shown: number,
totalCount: number | undefined,
totalCountIsLowerBound?: boolean,
noun: WidgetListCountNoun = WIDGET_LIST_COUNT_ISSUES,
hasMore?: boolean
): string {
// totalCount 为 undefined 时:
// hasMore && shown > 0 → "N+" 风格文案(下界)
// 否则 → "N issues"(无总数)
}
- 名词(noun):从
constants.ts引用的WIDGET_LIST_COUNT_ISSUES/WIDGET_LIST_COUNT_RECORDINGS等常量中选取单复数形式(WidgetCardBody.tsx同时导出WidgetListCount组件与这些常量,WidgetCard/index.ts统一 re-export); - 省略 total 且
hasMore:输出N+风格文案,表示总数至少为 N; - 单复数:
shown === 1 && totalCount === 1时用单数(如 "1 of 1 issue")。
上述行为均有单元测试覆盖:constants.test.ts 断言了精确总数('1 of 1 issue'、'3 of 12 issues')、capped 总数('1 of 25+ issues')、缺失 total 的兜底('2 issues')与 hasMore 下界风格等场景。
3.3 Storybook 提示
编写列表 Widget 的 Story 时,mock hasMore: true 的 payload 要么提供 totals(totalCount / totalCountCapped),要么依赖 hasMore 触发 N+ 脚注——不要 mock 出既无 hasMore 又无 totalCount 的中间态,否则脚注行为无法被视觉评审准确捕捉。
四、Header 标题导航(titleHref):查看模式下的场景跳转
4.1 catalog 配置与渲染规则
- catalog
titleHref:指向产品场景路由,如urls.errorTracking()、urls.replay()、urls.supportTickets()。在 catalog.ts 中,error_tracking_list的条目即为titleHref: urls.errorTracking(),session_replay_list为titleHref: urls.replay(); WidgetCardHeader的链接包装:当titleHref已设置且isDashboardEditMode为 false 时,标题被包进<Link>。不要用showEditingControls来门控——可编辑的 dashboard 在查看模式下仍保留编辑 chrome,标题导航应该继续生效;DashboardItems传递编辑态:DashboardItems通过isDashboardEditMode={dashboardMode === DashboardMode.Edit}传给DashboardWidgetItem,再由后者下发给 header;- 布局编辑模式保持纯文本:仪表盘布局编辑(layout edit)时标题保持纯文本,避免 header 拖拽与导航竞争;查看(View)模式仍使用
titleHref跳转。
4.2 public/shared 放置
titleHref 在 DashboardWidgetItem 中被抑制(沿用既有的分享规则):共享/公开 dashboard 的占位 body 不渲染实时数据,标题也不提供跳转,避免把私有场景路由暴露给匿名访客。catalog 条目的 sharedPlaceholder(如 "Log in to PostHog to see which errors are affecting your users.")承担该场景下的引导文案。
五、从模式到实现的集成要点
5.1 与 WidgetCard 复合组件的关系
列表型 Widget 的 body 必须使用 body 层原语而非卡片 chrome:
WidgetCardContent:可滚动列 + 可选 footer(列表/表格 Widget 用它);WidgetCardBodyMessage:空态 / 内联状态文本;WidgetLoadingState/WidgetCardBodySkeleton:widget 自有的加载 UI,由Component依据loadingprop 提前 return,外壳不负责 body 骨架屏。
widget Component 永远不渲染 header、⋯ 菜单、resize 手柄等卡片 chrome——那是 DashboardWidgetItem + catalog 的职责。
5.2 与后端查询 runner 的复用约定
run_<type>_widget 必须调用产品独立场景同一个查询 runner(不能另起并行查询路径)。error_tracking_list 通过 ErrorTrackingQueryRunner 跑 ErrorTrackingQuery(见 error_tracking_list.py),并复用 resolve_filter_test_accounts(config, team) 处理测试账户过滤。这样保证"仪表盘 tile 看到的列表 = 产品场景里的列表",只是视口更小。
5.3 类型安全与 CI 约束
- 前端注册表
DASHBOARD_WIDGET_REGISTRY受satisfies Record<DashboardWidgetCatalogKey, DashboardWidgetDefinition>约束(见 registry.tsx),每个 catalog key 必须有对应注册条目; getDashboardWidgetTileFiltersSetup在 catalog 缺tileFilters时抛错,从源头拦截"只注册了TileFilters却漏配 catalog"的半成品;- 后端
EXPECTED_WIDGET_TYPES == WIDGET_REGISTRY.keys()、OpenAPI 多态 config serializer 数量与 registry 一致等断言由 test_run_widgets.py 强制执行。
5.4 新增列表类型的完整路径提醒
- 后端:
widget_specs/configs.py新增*ListWidgetConfig(用WidgetLimit = Annotated[int, Field(ge=1, le=MAX_WIDGET_RESULT_LIMIT)]约束 limit,DEFAULT_WIDGET_LIST_LIMIT作默认值)→widgets/<type>.py写run_*→widget_specs/registry.py注册WidgetSpec(含required_product_access、availability_requirements); - 前端:catalog 条目(
groupId/label/defaultLayout/titleHref/tileFilters)→Component+TileFilters→EditModal→registry.tsx注册 → Storybook 专有 stories(含TileFiltersReadOnly态); - 测试:
registry.test.tsx断言 catalog 全覆盖且每个条目有parseConfigApiError;test_run_widgets.py覆盖权限拒绝与 per-tile error 返回。
六、常见误用与自查清单
| 误用 | 正确做法 |
|---|---|
| 把日期/状态/属性筛选塞进编辑弹窗或 ⋯ 菜单 | 放 tile 筛选栏(*WidgetTileFilters.tsx),弹窗只留 test accounts + limit + sort |
用 showEditingControls 门控标题链接 |
用 isDashboardEditMode === false(编辑态下标题纯文本,避免拖拽冲突) |
| 计数查询失败导致整个 tile 报错 | 只记日志(log_key)+ 省略 totals,tile 正常渲染 |
| 把筛选控件渲染在 RBAC 拒绝的 tile 上 | hasProductAccess 为 false 时完全不挂载 TileFilters,body 显示 locked 态 |
mock Story 时省略 hasMore 又省略 totalCount |
hasMore: true 时要么给 totals,要么依赖 hasMore 触发 N+ 脚注 |
在 registry.tsx 注册但 catalog 漏配 tileFilters |
会被 getWidgetTileFiltersSetup 直接抛错拦截 |
七、结语
列表型 Widget 的三大模式(widgetFilters tile 筛选栏、分页脚注、titleHref 标题导航)构成了 PostHog 仪表盘"数据产品即服务"的最小闭环:用户在卡片内即可完成筛选、感知数据规模并跳转到产品场景深入处理。理解这套分层的本质——持久化契约(Pydantic/Zod)→ 查询桥接(widget_filters.py)→ 平台外壳(DashboardWidgetItem/WidgetCard*)→ 产品内容(复用产品场景组件)——是新增 error_tracking_list/session_replay_list 同款类型时最省力的路径。后续开发可继续参阅同目录下的 architecture.md(平台文件地图与不变量)、composition.md(WidgetCard 复合组件规则)与 checklist-new-widget-type.md(端到端落地清单)。
atomcodeClaude Code 的开源替代方案。连接任意大模型,编辑代码,运行命令,自动验证 — 全自动执行。用 Rust 构建,极致性能。 | An open-source alternative to Claude Code. Connect any LLM, edit code, run commands, and verify changes — autonomously. Built in Rust for speed. Get StartedRust0634
MiniCPM5-2BMiniCPM5-2B 是一款面向端侧、本地部署和资源受限场景的 2B 稠密 Transformer,能够达到同尺寸开源模型 SOTA 水平。Markdown00
jforgamejforgame是一个一站式游戏服务器开发框架。包含游戏服务器开发所需要的各种组件,比如网关,socket服务端与客户端,自定义高效消息编解码,游戏热更新,游戏通用工具等等。包含游戏服,跨服,匹配服,后台管理系统等实现,同时提供大量业务案例以供学习。亦可用于其他socket应用,例如及时聊天等。Java01
fizz-gateway-nodeAn Aggregation API Gateway in Java . FizzGate 是一个基于 Java开发的微服务聚合网关,是拥有自主知识产权的应用网关国产化替代方案,能够实现热服务编排聚合、自动授权选择、线上服务脚本编码、在线测试、高性能路由、API审核管理、回调管理等目的,拥有强大的自定义插件系统可以自行扩展,并且提供友好的图形化配置界面,能够快速帮助企业进行API服务治理、减少中间层胶水代码以及降低编码投入、提高 API 服务的稳定性和安全性。Java00
certd开源SSL证书管理工具;全自动证书申请、更新、续期;通配符证书,泛域名证书申请;证书自动化部署到阿里云、腾讯云、主机、群晖、宝塔;https证书,pfx证书,der证书,TLS证书,nginx证书自动续签自动部署JavaScript00
Spark-X2.5-1.7BSpark-X2.5-1.7B 旨在让强大的 AI 更加实用、高效且易于获取。这些模型在广泛的日常任务中表现出色,涵盖对话、写作、翻译、推理、编程、工具调用和智能体工作流,并在同等规模的开源模型中取得领先结果。Spark-X2.5 将面向效率的架构与最高 1M tokens 的原生上下文窗口相结合,并支持 200 多种语言。Python00