首页
/ PostHog Dashboard 列表型 Widget 开发模式:Tile 筛选栏、分页脚注与标题导航实现指南

PostHog Dashboard 列表型 Widget 开发模式:Tile 筛选栏、分页脚注与标题导航实现指南

2026-09-09 23:25:40作者:吴年前Myrtle

适用场景:在 PostHog 开源仓库中新增或改造一个列表/表格型(list/table)Dashboard Widget(如 error_tracking_listsession_replay_list,或下一个列表类型)。读者将掌握列表型 Widget 的三大平台级交互模式——tile 筛选栏(widgetFilters)分页脚注(pagination footer)标题导航(titleHref)——的完整数据流、配置分层、RBAC 门控与源码级实现细节,可直接套用到新类型的开发与评审中。

本文以 .agents/skills/manage-dashboard-widgets/references/list-widget-patterns.md 为核心骨架,并结合 products/dashboards/backendproducts/dashboards/frontend 中的真实源码与测试进行印证和扩展。


一、模式总览:列表型 Widget 的三个平台约定

PostHog Dashboard Widget 平台把"内容组件"(列表、表格、卡片)与"卡片外壳"(header、⋯ 菜单、编辑弹窗、网格缩放)做了严格分层。对列表型 Widget 而言,除了通用组件规则(见 composition.md)与日期范围展示(见 layout-and-ux.md),还额外约定三件事:

  1. Tile 筛选栏(widgetFilters:在卡片主体上方渲染常驻筛选条,让用户不打开编辑弹窗即可切换日期、状态、指派人与属性筛选;
  2. 分页脚注(pagination footer):在 WidgetCardContent 底部显示"已展示 / 总数"信息,并支持 hasMore 下的 N+ 兜底文案;
  3. 标题导航(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.tsxwidgetTileFiltersHooks.tsuseWidgetTileConfigPersist)、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 会被强转为 WidgetFilterEntryfilterId 必须与 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 再包一层 ANDPropertyGroupFilter,供查询 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 读取 quickFilterContextallowedPropertyNames,构造"该类型允许哪些快速属性筛选"的判定函数 isAllowed。catalog 中缺少 tileFilters 配置会直接抛错——这保证了"注册了 TileFilters 就一定有合法配置"。
  • useWidgetTileConfigPersist(onUpdateConfig, config):返回 getLatestConfig(读取最新 config,避免闭包过期)、persistConfigDebounced(防抖持久化,配合 WIDGET_TILE_REFRESH_DEBOUNCE_MS = 300,见 constants.ts)与 persistConfigNow(立即持久化)。属性筛选使用防抖路径,日期/状态切换走立即路径。
  • 双态渲染onUpdateConfig 不存在(只读/无编辑权限)时渲染 WidgetTileFiltersBar 下的只读值组件(WidgetDateRangeReadOnlyValueErrorTrackingStatusReadOnlyValueErrorTrackingAssigneeReadOnlyValueWidgetPropertyFiltersReadOnlyValues);存在时渲染 LemonSelectErrorTrackingStatusSelect 等可交互控件,并通过 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.tsxDASHBOARD_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):hasMoretotalCountN+ 文案

3.1 后端 run_* 契约

列表型 Widget 的 run_<type>_widget(如 run_error_tracking_list_widget)统一返回四个字段:resultshasMorelimitoffset。真实实现见 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",
)

三条核心规则:

  1. !hasMoretotalCount = 已展示数:当没有更多数据时,总数就是当前页已展示的行数,无需额外查询;
  2. hasMore + include_total_count=True 时执行 capped 计数查询:上限为 MAX_WIDGET_RESULT_LIMIT。该常量定义在 constants.pyMAX_WIDGET_RESULT_LIMIT = 25,即每个 widget 查询返回行数的硬上限(run_widgets 限流 + UI "25+" 脚注共用此值),同时 DEFAULT_WIDGET_LIST_LIMIT = 10 是 config 省略 limit 时的默认值;
  3. 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 前端脚注渲染

WidgetCardContentfooter 槽位调用 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_listtitleHref: 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 放置

titleHrefDashboardWidgetItem 中被抑制(沿用既有的分享规则):共享/公开 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 依据 loading prop 提前 return,外壳不负责 body 骨架屏。

widget Component 永远不渲染 header、⋯ 菜单、resize 手柄等卡片 chrome——那是 DashboardWidgetItem + catalog 的职责。

5.2 与后端查询 runner 的复用约定

run_<type>_widget 必须调用产品独立场景同一个查询 runner(不能另起并行查询路径)。error_tracking_list 通过 ErrorTrackingQueryRunnerErrorTrackingQuery(见 error_tracking_list.py),并复用 resolve_filter_test_accounts(config, team) 处理测试账户过滤。这样保证"仪表盘 tile 看到的列表 = 产品场景里的列表",只是视口更小。

5.3 类型安全与 CI 约束

  • 前端注册表 DASHBOARD_WIDGET_REGISTRYsatisfies 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>.pyrun_*widget_specs/registry.py 注册 WidgetSpec(含 required_product_accessavailability_requirements);
  • 前端:catalog 条目(groupId/label/defaultLayout/titleHref/tileFilters)→ Component + TileFiltersEditModalregistry.tsx 注册 → Storybook 专有 stories(含 TileFiltersReadOnly 态);
  • 测试:registry.test.tsx 断言 catalog 全覆盖且每个条目有 parseConfigApiErrortest_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(端到端落地清单)。

登录后查看全文
热门项目推荐
相关项目推荐

项目优选

收起
kernelkernel
deepin linux kernel
C
33
18
docsdocs
暂无描述
Markdown
900
5.83 K
ops-transformerops-transformer
本项目是CANN提供的transformer类大模型算子库,实现网络在NPU上加速计算。
C++
1.14 K
2.76 K
pytorchpytorch
作为 Ascend for PyTorch 社区的核心组件,TorchNPU 是昇腾专为 PyTorch 打造的深度学习适配插件,使 PyTorch 框架能够直接调用昇腾 NPU,为开发者提供昇腾 AI 处理器的超强算力。
Python
860
1.35 K
ops-nnops-nn
本项目是CANN提供的神经网络类计算算子库,实现网络在NPU上加速计算。
C++
927
1.85 K
jiuwenswarmjiuwenswarm
JiuwenSwarm 是一款基于openJiuwen开发的智能AI Agent,它能够将大语言模型的强大能力,通过你日常使用的各类通讯应用,直接延伸至你的指尖。
Python
3.94 K
1.02 K
kernelkernel
openEuler内核是openEuler操作系统的核心,既是系统性能与稳定性的基石,也是连接处理器、设备与服务的桥梁。
C
533
603
ops-mathops-math
本项目是CANN提供的数学类基础计算算子库,实现网络在NPU上加速计算。
C++
1.37 K
1.46 K
AscendNPU-IRAscendNPU-IR
AscendNPU-IR是基于MLIR(Multi-Level Intermediate Representation)构建的,面向昇腾亲和算子编译时使用的中间表示,提供昇腾完备表达能力,通过编译优化提升昇腾AI处理器计算效率,支持通过生态框架使能昇腾AI处理器与深度调优
C++
548
396
cann-learning-hubcann-learning-hub
CANN 学习中心仓,支持在线互动运行、边学边练,提供教程、示例与优化方案,一站式助力昇腾开发者快速上手。
Jupyter Notebook
1.04 K
527