PostHog Dashboard Widget 组件组合模式:基于WidgetCard薄壳的复合组件架构实战
【免费下载链接】posthog:hedgehog: PostHog is the leading platform for building self-driving products. Our developer tools – AI observability, analytics, session replay, flags, experiments, error tracking, logs, and more – capture all the context agents need to diagnose problems, uncover opportunities, and ship fixes. Steer it all from Slack, web, desktop, or the MCP.项目地址: https://gitcode.com/GitHub_Trending/po/posthog
导读
PostHog 的 Dashboard Widget(仪表盘小部件)体系允许用户把错误追踪、会话回放、实验、调查、日志等不同产品线的数据以「磁贴」形式聚合到同一张仪表盘上。WidgetCard是这一体系的容器基座:它只负责承载装饰性缩放手柄、编辑态边缘覆盖层与 react-grid-layout(RGL)注入的缩放节点,不接收任何产品级 header/body 属性。本文以 composition.md 为骨架,结合仓库前端源码,完整讲解WidgetCard复合组件模式、DashboardWidgetItem生产调用点、header 布局体系、产品视觉一致性、Storybook 规范与设置弹窗(settings modal)的工程实现。读完后你将掌握如何在 PostHog 中新增一个 widget 类型并正确编排它的外壳、头部、正文、筛选条与编辑弹窗。
WidgetCard 复合模式总览:薄壳 + 调用处组合
PostHog 仪表盘 widget 遵循 Quill 组件库的Card模式:薄壳(thin shell)+ 在调用点(callsite)组合的复合子组件。WidgetCard本身只是「瓦片外壳」——装饰性缩放手柄、编辑态边缘覆盖层、RGL 插槽,不暴露 header/body 属性。
| 文件 / 导出 | 职责 |
|---|---|
WidgetCard | 薄瓦片外壳:装饰性缩放手柄、编辑态边缘覆盖层、RGL 插槽。无产品级 header/body 属性 |
WidgetCardHeader(含内部 title/actions 辅助组件) | 布局路由器:simple与dashboard_tile两种布局;导出widgetCardShouldHideMoreButton |
WidgetCardBody.tsx | 正文插槽;锁定/错误外壳状态。同时导出WidgetCardContent、WidgetCardBodyMessage、WidgetLoadingState、WidgetCardBodySkeleton、WidgetCardSharedPlaceholderBody(公共/共享占位) |
WidgetCardContent | 可滚动列 + 可选 footer(列表/表格类 widget)——来自WidgetCardBody.tsx |
WidgetCardBodyMessage | 空态 / 行内状态文本——来自WidgetCardBody.tsx |
WidgetLoadingState/WidgetCardBodySkeleton | widget 自有的加载 UI——来自WidgetCardBody.tsx |
DashboardWidgetItem | 生产调用点——组合 header + body,串联 ⋯ 菜单、编辑弹窗 portal、产品 RBAC 锁;当hasProductAccess时挂载注册表TileFilters(RBAC 拒绝时隐藏);public场景使用WidgetCardSharedPlaceholderBody而非真实 widget body |
WidgetCardHeaderDescription从WidgetCardHeader.tsx导出仅供测试使用。widgetComponent永不渲染卡片外壳,只渲染正文内容原语(WidgetCardContent、WidgetCardBodyMessage等)。
从源码看,这一「薄壳」定位在 WidgetCard.tsx 中非常清晰:WidgetCard通过forwardRef接收 RGL 注入的className/style,渲染顺序严格固定为children(组合好的 header + body)→DashboardResizeHandles(当showResizeHandles)→EditModeEdgeOverlay(当启用边缘进入编辑态)→gridChildren(RGL 的.react-resizable-handle节点),与InsightCard保持一致。
Compound pattern:组合示例
<WidgetCard ref={ref} className={className} style={style} showResizeHandles={showResizeHandles} canEnterEditModeFromEdge={canEnterEditModeFromEdge} onEnterEditModeFromEdge={onEnterEditModeFromEdge} gridChildren={rglHandles} // react-grid-layout 注入的缩放节点 > <WidgetCardHeader layout={headerLayout} title={title} defaultTitle={defaultTitle} // …catalog 驱动的 header 字段 shouldHideMoreButton={widgetCardShouldHideMoreButton(placement, showEditingControls)} moreButtonOverlay={…} /> <WidgetCardBody locked={locked} error={error}> <WidgetComponent … /> </WidgetCardBody> </WidgetCard>Public / shared dashboard—— header 相同,但 body 只有占位(无run_widgets数据):
{showSharedPlaceholder ? ( <WidgetCardSharedPlaceholderBody copy={headerCatalogEntry.sharedPlaceholder ?? DEFAULT_SHARED_DASHBOARD_WIDGET_PLACEHOLDER} /> ) : ( <WidgetCardBody locked={locked} error={error} …> <WidgetComponent … /> </WidgetCardBody> )}WidgetCard内部渲染顺序(与InsightCard一致):
children—— 组合好的 header + bodyshowResizeHandles时渲染DashboardResizeHandles- 启用边缘进入编辑态时渲染
EditModeEdgeOverlay gridChildren—— RGL 的.react-resizable-handle节点
生产调用点:DashboardWidgetItem
DashboardWidgetItem(DashboardWidgetItem.tsx)是生产环境唯一调用点。它以WidgetCard为最外层节点,通过forwardRef把ref、className、style交给卡片根节点——这是 RGL 通过cloneElement注入布局样式的前提,装饰性缩放手柄与 RGL 的.react-resizable-handle因此共享同一父节点。
其核心编排逻辑(DashboardWidgetItemContent)包括:
- 头部:从 catalog 解析
headerCatalogEntry,组合WidgetCardHeader,把widget.config、headerMeta、TopHeading、description、isLive、refreshControl、moreButtonOverlay传入;widgetCardShouldHideMoreButton(placement, showEditingControls)决定 ⋯ 菜单是否隐藏。 - 正文:
!hasProductAccess时WidgetCardBody进入locked状态;未知 widget 类型不传递run_widgets错误;真实 widget body 被ErrorBoundary包裹(feature: 'dashboard_widget'、widget_type、tile_id作为异常上下文)。 - TileFilters:当
hasProductAccess且 widget 可用且注册表存在TileFilters时,在 header 与 body 之间挂载始终可见的筛选条(RBAC 拒绝时隐藏);无编辑权限时以DASHBOARD_WIDGET_TILE_FILTERS_READONLY_REASON作为disabledReason。 - 编辑弹窗:
EditModal通过createPortal挂到document.body,onSave回调把 config + 名称 + 描述一次 PATCH。
Do 与 Don't:边界与约定
Do
- 保持
WidgetCard为DashboardWidgetItem的最外层节点——RGL 通过ref在卡片根部注入className/style - 在
DashboardWidgetItem(或 story 包装器)中组合WidgetCardHeader+WidgetCardBody,而不是在WidgetCard内部组合 - 通过
gridChildren传递 RGL 手柄,而非children - 让 widget
Component依据loadingprop 自行决定骨架屏还是内容 - 使用
min-w-0+overflow-auto链路(WidgetCardContent本身已做纵向滚动) - 将时间周期存于
config.dateRange——选项、编辑弹窗与 header 展示统一读取:layout-and-ux.md - 默认 header 布局为
dashboard_tile,由getDashboardWidgetCatalogEntry()提供;仅当覆盖默认值时,才在 catalog 条目上设置headerLayout/headerMeta - 仅在 widget 设置弹窗中编辑标题/描述(
EditWidgetModalTileDetailsSection)——卡片 header 是只读展示
Don't
- 不要给
WidgetCard重新加回 header/body 属性——那会破坏复合模式 - 不要把加载占位放进
WidgetCard外壳——会破坏缩放/空态行为 - 不要把 widget body 内容放进
gridChildren——RGL 独占该插槽,只用于缩放手柄 - 不要在 widget 组件内部复制 header、菜单、卡片外壳或筛选开关——limit/sort/test 账号放在编辑弹窗;date/status/property 筛选放在磁贴条(tile bar)而非 ⋯ 菜单
- 不要在
registry.tsx注册却没有对应的DASHBOARD_WIDGET_CATALOG条目
Header 布局:simple与dashboard_tile
两种布局定义在 catalog.ts(DASHBOARD_WIDGET_HEADER_LAYOUTS、DEFAULT_DASHBOARD_WIDGET_HEADER_LAYOUT)。
| 布局 | 行为 |
|---|---|
simple | 单标题行——新类型优先用dashboard_tile。仅通过磁贴 ⋯ 菜单刷新 |
dashboard_tile | 紧凑CardMeta风格:类型 • 日期范围 + 标题 + 分隔线;刷新在 ⋯ 菜单中 |
日期范围展示由WidgetCardHeader从config.dateRange+ 已解析的 catalogheaderMeta派生(通过dateFilterToText格式化)。产品类型芯片文本来自getDashboardWidgetGroupLabel(groupId)(见 catalog.ts 的DASHBOARD_WIDGET_GROUP_LABELS)。
源码细节:在 WidgetCardHeader.tsx 中,showWidgetType与showDateRange均默认取headerMeta的配置;dashboard_tile布局渲染紧凑的CardMeta(compact),描述以max-h-24 overflow-y-auto折叠展示;simple布局则渲染传统 header 区块(p-4 pb-2),并内置拖拽手柄(drag-handle cursor-move,无编辑控件时隐藏)。widgetCardShouldHideMoreButton的实现只有一行:当 placement 为Public或showEditingControls === false时隐藏 ⋯ 菜单。
当保存视图或产品集合替换了日期范围时,需要在widgets/registry.tsx中添加TopHeading插槽:该插槽渲染CardTopHeadingRow,携带组标签与解析后的保存视图名称;复用产品项目的项目级保存视图缓存,仅在需要持久化保存视图 ID 标签时加载;同时把插槽加入 widget 的 Storybook 包装器并测试解析后的 heading。
Loading ownership:加载态归属
widgetComponent从 scene logic 接收loading,必须提前返回WidgetLoadingState。外壳(DashboardWidgetItem→ 组合后的WidgetCardBody)不会为 widget body 内容显示骨架屏。
对应源码:WidgetLoadingState与WidgetCardBodySkeleton(默认 4 行骨架)定义在 WidgetCardBody.tsx;ErrorTrackingWidget在loading时以ErrorTrackingIssueListSkeleton填充WidgetCardContent(ErrorTrackingWidget.tsx),即「产品组件决定骨架、外壳不越权」的典型落地。
RGL 与溢出
- widget 内容位于组合后的
WidgetCardBody内 gridChildren仅供 react-grid-layout 的缩放手柄使用- 宽表格:横向滚动发生在
WidgetCardContent内部,而非仪表盘网格
WidgetCardBody根节点(WidgetCardBody.tsx)使用@container/widget-card容器查询 +flex min-h-0 min-w-0 flex-1 flex-col overflow-hidden p-4 pt-2,内部WidgetCardBodySlot把 flex 高度从卡片外壳传递到正文内容;WidgetCardContent负责overflow-y-auto overflow-x-hidden,从而把表格横向滚动约束在内容列内。
产品视觉一致性(Product visual parity)
当 widget 展示的是既有 PostHog 产品的数据(既有groupId中的变体,或已有 scene 的产品区域中的首个 widget)时:
默认:复用产品 scene 使用的同一套展示——列表行、卡片、空态文案、骨架屏、设置引导。仪表盘磁贴只是更小的视口,用户应能认出这是与应用内一致的产品数据(例如/error-tracking上的ErrorTrackingIssueList,而不是 widget 专用的另起炉灶表格)。以图表为主体的 body 不属于 widget——请使用 insight 磁贴(architecture.md § Charts → insight tiles)。
在哪找组件
Intake 应该已经跑过 repo discovery——优先使用那些组件路径。
- 当工程师指明了 scene 路径、tab、Storybook story 或同类 widget 时,从 intake 的product UI reference出发——不要猜测其他界面
- 否则打开该产品的主 scene(列表、概览或详情索引)
- 记录渲染主数据块的组件——通常位于
products/<product>/frontend/components/或scenes/<area>/ - 把它们导入
products/dashboards/frontend/widgets/<product>/,并在WidgetCardContent(或产品 setup gate 包装器)内组合
已上线的参考实现:ErrorTrackingWidget从products/error_tracking/frontend/导入ErrorTrackingIssueList、ErrorTrackingIssueListSkeleton与ErrorTrackingIngestionPrompt(见 ErrorTrackingWidget.tsx)。
平台 chrome vs 产品 chrome
| 层级 | 归属 |
|---|---|
| 磁贴 header、⋯ 菜单、缩放、编辑弹窗外壳 | Dashboard(WidgetCard、EditWidgetModal*) |
| 行、空态、加载骨架、设置引导 | 产品(导入共享组件) |
不要在 widget body 内复制产品菜单、筛选器或页面级 chrome——配置归属 widget 设置弹窗(layout-and-ux.md)。
Charts
不在 widget 范围内。时间序列、漏斗等图表可视化属于insight 磁贴,而不是新的widget_type。不要把产品图表组件作为 widget 的主 body。
Storybook 与评审
填充了数据的 stories 应渲染相同的产品组件并携带真实的run_*载荷,以便视觉评审捕捉与 scene 的漂移;不确定时与产品 Storybook story 或 scene 并排对比。
当 parity 不可行时
在 PR 中说明原因(例如 scene 是带行内筛选器的整页布局且没有抽离出的列表)。优先把组件薄抽取到产品包中,而不是做一次性 dashboard 专属展示——这样也能让下一个 widget 变体保持一致。
Storybook 规范
平台原语位于Dashboards/Dashboard Widgets/目录:
WidgetCard/——WidgetCard.stories.tsx(header + body 组合模式,见 WidgetCard.stories.tsx)Overview/——DashboardWidgetsOverview.stories.tsx(所有 catalog 类型)
共享框架/mock:widgetCardStoryFixtures.tsx。
按类型的 stories:widgets/<product>/<Component>.stories.tsx,位于Widget types/ /(例如Error tracking/Top issues)。
- Meta
title必须是字符串字面量,匹配DASHBOARD_WIDGET_GROUP_LABELS[groupId]/label(CSF 拒绝动态 title) - 用
WidgetCard+WidgetCardHeader+WidgetCardBody+ catalog header 元数据组合——参见ErrorTrackingWidget.stories.tsx - 产品设置态:Kea seed helper 位于
widgetCardStoryFixtures.tsx(withErrorTrackingProjectState等)——不要从*.stories.tsx导出 decorator(Storybook 会把导出当作 story) - 冻结日期:在 story
parameters中展开widgetStorybookParameters,并把widgetOverviewStoryFixtures.ts中的 fixture 时间戳对齐到WIDGET_STORYBOOK_MOCK_DATE,使 TZLabel / 相对时间文案在视觉评审中保持稳定 - 谨慎堆叠 decorator:story 级
withErrorTrackingProjectState(false)不能被 meta decorator 里 seedtrue覆盖
未知 / 部署偏差的 widget 类型
当前端 catalog 缺少某个widget_type(部分部署、未 rebase 的栈)时:
- Header——
tryGetDashboardWidgetCatalogEntry+getUnknownDashboardWidgetCatalogFallback,保证标题与 ⋯ 菜单(remove、duplicate、copy/move)仍然可用 - Body——
ErrorBoundary包裹DashboardWidgetItemBody,后者调用getDashboardWidgetCatalogEntry(抛错 → 完整错误 UI) - Fetch 错误—— 对未知类型,不要把
run_widgets的 fetcherror传给WidgetCardBody;⋯ 菜单中无 Refresh data 操作 - Analytics——
getDashboardWidgetDefinition仍会按规范类型去重上报 PostHogcaptureException——请补上注册表条目
Widget 设置弹窗(settings modal)
LemonModal+ 每个Edit*WidgetModal各自的 section——没有共享包装器。复制EditErrorTrackingWidgetModal.tsx起步。
| 路径 | 职责 |
|---|---|
EditWidgetModalTileDetailsSection.tsx | 磁贴名称/描述 |
EditWidgetModalFiltersSubsection.tsx | 产品h5下的测试账号 + limit/sort |
editWidgetModalBuilders.ts | 共享 kea actions;buildWidgetTileMetadataPatch——仅展开 actions,reducer 按 logic 内联 |
edit*WidgetModalLogic.ts | 校验 + 保存监听器 |
widgetConfigValidation.ts+*WidgetConfigValidation.ts | Zod;注册表parseConfigApiError |
widgetFilters.ts | widgetFilters持久化/HogQL;编辑配置;磁贴持久化/恢复 hooks |
widgetFiltersUi.tsx | 筛选 chips(编辑流程) |
widgetTileFiltersReadOnly.tsx | WidgetTileFiltersBar+ 只读标签 |
*WidgetTileFilters.tsx | 注册表TileFilters——始终可见的筛选条 |
constants.ts | Fetch 错误文案、WIDGET_TILE_REFRESH_DEBOUNCE_MS、formatWidgetListCountFooter |
<LemonModal … footer={/* Cancel + Save with saveDisabledReason / saving */}> <div className="flex flex-col gap-4"> {showTileDetails ? ( <EditWidgetModalTileDetailsSection tileName={tileName} tileDescription={tileDescription} defaultTitle={defaultTitle} saving={saving} setTileName={setTileName} setTileDescription={setTileDescription} /> ) : null} {showTypeSettings ? ( <> {showTileDetails ? <LemonDivider className="my-0" /> : null} <section className="flex flex-col gap-3"> <h5 className="text-sm font-semibold m-0"> {getDashboardWidgetGroupLabel('error_tracking')} </h5> <div className="flex flex-col gap-4"> <EditWidgetModalFiltersSubsection title="Issue filters" …> <TestAccountFilter … /> {/* limit, sort —— property/date/status 筛选在磁贴条上,不在此处 */} </EditWidgetModalFiltersSubsection> <div>{/* Sorting subsection */}</div> </div> </section> </> ) : null} </div> </LemonModal>参考:EditErrorTrackingWidgetModal.tsx、EditSessionReplayWidgetModal.tsx。不需要的 section 就省略——用showTileDetails/ 产品级 setup 标志等布尔值做门控。
可筛选的列表类 widget:磁贴筛选条、分页 footer、titleHref——见 list-widget-patterns.md。
Kea 编辑弹窗逻辑
- 从
editWidgetModalBuilders.ts展开actions(widgetEditModalListFieldActions、widgetEditModalTileActions、widgetEditModalFilterTestAccountsActions) - 每个 logic 文件中使用内联 reducers——不要展开
widgetEditModal*Reducers(kea typegen 会丢失 reducer 类型) - 使用每类型
*FieldErrors类型内联setFieldErrors、clearFieldError、activeFieldErrors与saveDisabledReason setOrderByaction 接受string;reducer 转为配置枚举类型submit监听器:validate*WidgetConfigInput→onSave(config, buildWidgetTileMetadataPatch(...))—— 一次 PATCH 提交 config + 名称 + 描述- 连接
filterTestAccountsDefaultsLogic,通过resolveWidgetFilterTestAccounts初始化filterTestAccounts
配置校验
- 每类型持久化配置 + 弹窗表单 schema 来自
generated/widget-configs.zod.ts;同目录的*WidgetConfigValidation.ts只做 API 错误解析(复用widgets/widgetConfigValidation.ts的共享 HogQL helper——不要手写字段守卫) - 在
DASHBOARD_WIDGET_REGISTRY条目上注册parseConfigApiError(通过registry.tsx中的parseDashboardWidgetConfigApiError→utils.ts的updateDashboardWidgetTile分发)。配置/代码生成见 config-and-codegen.md
代码分割与懒加载
从 registry.tsx 的注释可以确认:widget UI 是代码分割的——静态图中只保留配置错误解析器、类型与懒加载工厂,登录页面不再急切下载每个 widget 的渲染器、编辑弹窗与磁贴筛选条;每个 widget 的子树只在其磁贴真正渲染时加载,并通过DashboardWidgetItem与WidgetCardHeader中的Suspense边界渲染。WidgetCardHeader的可选TopHeading与TileFilters同样以DashboardWidgetSlot形式在Suspense中渲染,因此新增 widget 类型的静态导入成本趋近于零——这也反过来约束了「catalog 条目必须与注册表条目成对出现」的纪律。
小结
PostHog 的 widget 体系是一个「薄壳 + 调用点组合 + 注册表驱动」的三层架构:WidgetCard只提供瓦片外壳与 RGL 协作,WidgetCardHeader负责两种 header 布局的排版路由,WidgetCardBody提供锁定/错误/加载/占位等状态原语,而DashboardWidgetItem作为唯一生产调用点把它们与产品组件、RBAC 锁、TileFilters、编辑弹窗编排在一起。新增 widget 类型时,遵循本文的 Do/Don't 清单、产品视觉一致性原则与 Storybook 规范,即可保证新磁贴与既有 insight 磁贴在外观与交互上完全对齐。
【免费下载链接】posthog:hedgehog: PostHog is the leading platform for building self-driving products. Our developer tools – AI observability, analytics, session replay, flags, experiments, error tracking, logs, and more – capture all the context agents need to diagnose problems, uncover opportunities, and ship fixes. Steer it all from Slack, web, desktop, or the MCP.项目地址: https://gitcode.com/GitHub_Trending/po/posthog
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考