关于 metabase-data-app-semantic-layer
面向 Metabase Data App 构建者的语义层开发规范,指导使用生成的 Schema、查询与操作定义及 SDK 组件构建 React 界面。它强调将查询逻辑与呈现分离,并避免臆造字段、指标和可视化数据。
主要功能
- 01以生成的 Schema 作为数据上下文
- 02通过 defineQuery 和 defineAction 定义查询与操作
- 03使用语义层对象和 SDK 查询辅助函数
- 04将运行时筛选状态与基础查询分开
- 05用 Metabase 查询结果驱动可视化
适用场景
- 01基于语义层构建 Data App 界面
- 02为筛选控件定义动态查询
- 03使用 Metabase 数据呈现图表和指标
安装
复制下面的命令,在终端中运行。
npx skills add https:/ / github.com/ metabase/ metabase --skill metabase-data-app-semantic-layer安装后在 Claude Code 中输入/metabase-data-app-semantic-layer确认已加载。
附带文件references/filter-ui-patterns.mdreferences/visualization-settings.md
详细介绍
Metabase 数据应用语义层
核心规则
保持语义层与展示层分离。
- 所有 Metabase 上下文都必须来自生成的 schema 文件,通常是
src/metabase.data.ts或src/*.metabase.data.ts(其运行时对象,以及当某个条目有上下文要提供时开头的/* metadata: {...} */块),而应用集合中各文件的写入依据必须来自npm run print-resources。绝不来自仓库中的 Metabase YAML,也绝不来自你自己的 API 调用。 - 在构建 React UI 时,不要通过 MCP 工具发现数据、创建 Metabase 内容、创建表或编辑语义层。
- 从
@metabase/embedding-sdk-react/data-app导入数据应用查询辅助函数。 - 每个查询都是根级
queries/目录中的defineQuery(...)具名导出,每个操作都是根级actions/目录中的defineAction(...)具名导出,二者都与package.json并列。在编写第一个 hook 调用之前创建这两个目录;模板已自带它们,每个目录中都有一个 README。这些 hook 会在编译时强制执行此规则:useMetabaseQuery、useMetabaseQueryObject和useAction会拒绝内联对象、satisfies MetabaseQueryOptions对象以及定义的展开副本。错误信息为Property 'definedWithDefineQuery' is missing(或'definedWithDefineAction');修复方法始终是将该对象作为定义移入queries/或actions/并导入它,绝不要用类型断言。 - 每个
savedQuestionEntityId和copiedActionEntityId都指向应用集合中的一个文件(参见 将每个查询和操作写入应用的集合)。在重构和重命名过程中保留它,绝不要将它复制到另一个定义,并且在其文件存在时绝不要移除它。 - 优先使用生成的 schema 对象,而不是原始 ID 或字符串。为顶层表对象提取本地常量。
- 绝不要在应用代码中手写
DatasetQuery/MBQL 对象。不要将内联查询对象(如{ type: "query", query: { "source-table": table.id } })、原始source-table子句、原始字段 ID、裸表 ID 或指标 ID 传递给 SDK 组件、useMetabaseQuery或useMetabaseQueryObject。优先使用生成的表和指标 schema 对象;对于简单的表源查询,显式的源引用(如{ type: "table", id: table.id })也是有效的。 - 使用
source: schema.tables.<name>、生成的fields、生成的segments、生成的measures、aggregations中生成的指标、生成的指标dimensions、filter(...)、breakout(...)、orderBy(...),以及诸如aggregations.count()和aggregations.sum(...)的aggregations辅助函数来构建查询。不要使用source: schema.metrics.<name>;指标是聚合表达式,不是查询源。 - 不要将现有的已保存问题用作
useMetabaseQuery或useMetabaseQueryObject的源。类型化 schema 不会暴露schema.questions,也不支持question-collections,因为应用的资源无法复制现有的已保存问题。 - 优先使用语义丰富的表查询,而不是浅层的表转储。当经过整理的表的 measures、segments、filters 和 breakouts 能让生成的应用更有用时,请使用它们。
- 优先使用语义层定义,而不是 React 侧的推断。如果 schema 中有某个概念的 segment 或 measure,请使用它,而不是从原始行重新创建该概念。二者都只属于静态查询——动态的第二个参数不能接收它们,参见 静态和动态查询部分。
- 筛选 UI 必须默认显示数据。空控件、"All" 选项和不完整的自定义范围应产生无筛选,而不是阻止查询或显示空白仪表板。
- 不要硬编码分类筛选选项值。生成的 schema 字段只能证明该字段存在,不能证明存在哪些值;在运行时使用筛选所应用的同一个生成 schema 字段,从 Metabase 查询选项。
- 仪表板级筛选应明显影响每个兼容的卡片、表格、KPI 和趋势。如果某个筛选只能应用于一个查询,请在 UI 中使该范围显而易见;不要显示重复或无效的日期控件。
- 实体筛选(其中存储的值是 id/key,而 UI 显示标签)必须使用单个可搜索的组合框。单击/聚焦必须在输入之前立即打开选项列表。在运行时查询选项、搜索标签,并存储原始值。绝不要将实体筛选渲染为
<select>;普通 select 仅用于用户明确提供的简短封闭枚举。 - 使用来自
@metabase/embedding-sdk-react/data-app的DateRangePopover来实现自定义日期范围:它包装应用自己的触发元素,并在其下方打开一个 Metabase 风格的日期范围日历。触发器仍属于应用——像其他筛选控件一样为其设置样式——而来自同一条目的useDateFormatter()生成其标签。它随 SDK 一起提供,不需要依赖,也不需要 CSS 导入。DateRangeCalendar是同一个内联日历,适用于应用已有容器的情况。不要为日期范围安装日期选择器库——不要react-datepicker、react-day-picker、flatpickr,也不要 UI 套件的选择器(@mui/x-date-pickers、antd、rsuite……)。也不要使用原生<input type="date">:其占位符和日历弹出框由浏览器控制,通常显示mm/dd/yyyy,并且无法可靠地设置主题。 - 绝不要用
new Date("YYYY-MM-DD")构建日期标签——仅日期字符串会按 UTC 解析,在格林尼治以西会显示前一天。使用useDateFormatter()中的formatDateRange/formatDate,它们按本地时间解析并按实例的 locale 格式化。 - 仅在 SDK 日历未覆盖的情况下(例如单个日期或日期时间选择)才使用第三方日期选择器;
react-datepicker是默认选择。然后导入其样式表(react-datepicker/dist/react-datepicker.css),如有需要,为应用的视觉风格添加少量 CSS 覆盖,并传递Date | null——对于不完整的范围,绝不要传new Date("")或其他无效日期;显式地类型化严格回调参数,例如onChange={(date: Date | null) => ...}。 - 日期栏默认必须包含 Custom,且放在最后:时长预设、All time,然后是 Custom。仅当用户明确要求只用固定预设或不要日期范围控件时,才省略 Custom。
- 绝不要发明聚合或 measure 对象,例如
{ name: "count" }或{ name: "sum", field: ... }。使用生成的表 measures 或导出的聚合辅助函数。 - 只渲染 Metabase 返回的值或对返回值进行的确定性转换。不要发明 KPI 值、趋势、标签、状态、评级、时间戳、排名、洞察、分段或图表系列。
- 不要自定义渲染含糊的业务字段,例如
margin、rate、score、percent、health、risk或efficiency。除非语义层单位明确支持,否则不要添加%、乘以 100、用颜色编码或渲染星级;请使用 SDK 表格/图表、省略该字段,或请求整理。 - 可视化数据必须通过
useMetabaseQuery或useMetabaseQueryObject搭配InteractiveQuestion/StaticQuestion从 Metabase 获取。不要硬编码可直接用于图表的数组、示例数据、演示值或符合 schema 形状的模拟值。 - 使用
InteractiveQuestion/StaticQuestion渲染图表。何时允许改用自定义可视化,以及在两者中使用哪一个,由设置技能的 渲染图表:Metabase 优先 决定。 useMetabaseQueryObject(...)返回{ query, error, isLoading }。只将query属性作为card={{ query }}传递给InteractiveQuestion或StaticQuestion;绝不要将整个 hook 结果作为card.query传递。useMetabaseQuery().rows是以键为索引的对象,不是元组数组。绝不要读取row[0]/row[1],也绝不要用as unknown as [string, number][]、DisplayRow或其他元组类型断言来掩盖这一点。如果 TypeScript 提示属性0不存在,那是在捕获一个真实的 bug。对于类型化的data.rows,使用字面量键,例如row.count或生成的字段名,例如row[ordersTable.fields.createdAt.name]。将data.columns与rawRows搭配使用,或在显式收窄键之后使用;不要用data.columns中任意的string值来索引类型化的行。- 不要将查询对象类型断言为
Parameters<typeof useMetabaseQuery>[0]或DefinedQuery。那会抹去生成的表/指标验证和定义契约。在定义处使用defineQuery<typeof table>(...)验证表归属;hook 接收该导出,不使用泛型。 - 不要用
ReturnType<typeof filter>[]或push(...)构建共享筛选数组;这可能使重载推断失效。在组件之间传递原始筛选状态,并使用展开内联构建每个查询的filters: [...]。 - 将运行时状态排除在
queries/中的基础查询之外。值来自控件的子句——选中的计划、日期范围、搜索框——属于useMetabaseQuery/useMetabaseQueryObject的第二个参数,而不属于查询。参见 “静态和动态查询部分”。 - 不要在带有
aggregations和breakouts的查询中包含fields;breakouts 决定分组结果列。仅对行选择查询使用fields。 - 在渲染字段之前,验证它存在于生成的 schema 对象中并且由查询返回。不要根据 Metabase API、业务直觉或旧模拟数据猜测表键、字段键或列名;只使用实际在
src/metabase.data.ts中输出的条目。 - 避免不受支持的时效性或运营性声明,例如 "real-time"、"live"、"understaffed" 或 "risk",除非返回的数据或经过整理的语义层定义支持它们。
- 在声称工作完成或准备最终交接之前,运行 TypeScript 仅类型检查并报告命令/结果。如果检查失败,在任何最终总结之前修复类型错误。
生成 Schema
如果 schema 文件已存在,直接使用它。如果缺失或已过期,请将 schema 生成视为针对此数据应用的语义层策划,而不是机械导出。
在生成之前选择导出范围:
- 遵循明确指定的范围。否则,根据应用的用途推断所需的内容类型:表、精选指标或操作。例如,“show orders” 需要表;行数和求和也可以使用表聚合。
- 选择能覆盖这些需求的最窄受支持范围,使用请求或项目上下文中的 collection ID 和数据库名称或 ID。当库类型明确但更窄的 collection 未知时,使用该库的整棵树。
- 只询问选择范围所需的上下文,例如当请求需要数据库范围但未指明时,询问使用哪个数据库。一旦确定范围,简要说明并直接生成,无需等待确认。
范围参数:
include-data-library=true用于整个Library / Data树。include-metric-library=true用于整个Library / metrics树。library-collections=<id-or-entity-id>[,<id-or-entity-id>]用于特定的 Data 或 metrics 库子集合。include-actions=true用于应用可以运行的操作,位于schema.actions下。当与database=<name-or-id>组合使用时,仅包含该数据库的操作。database=<name-or-id>当应用应使用某个数据库中的表时。请与库范围分开使用;API 会拒绝该组合。
当应用同时需要表和精选指标时,组合使用库范围。
当应用需要 schema.actions 下的任何已保存操作时,使用 include-actions=true;它会包含所有可读操作,除非 database 将其限定为一个数据库。它可以与 library-collections、include-data-library 或 include-metric-library 组合使用,以便一个 schema 可以包含选定的表/指标以及所有可读操作。
如果用户要求任何类似变更的流程,例如创建、更新、删除、提交、审批、执行操作或运行写操作,请在 typed-schema URL 中包含 include-actions=true。即使用户指定了某一个具体操作,也要这样做,因为操作只能通过生成的 schema 被发现。
Metabase URL 和 API key 位于 repo-root 的 .env.local 中,分别为
DATA_APP_MB_URL 和 DATA_APP_MB_API_KEY(每个 repo 一个文件,通常位于
app 目录往上两级,而不是在 app 目录中)。该 key 必须属于 Administrators 组:
typed schema 只会应答管理员,任何其他 key 都会得到 403。下面的命令会 source 该文件,因此
shell 会直接将值替换进 curl —— 你永远不需要自己读取、提取
或处理凭据。
绝不要要求用户将 API key 粘贴到聊天中,也绝不要
cat/echo.env.local—— 它已被 git 忽略,并且可能包含其他密钥,因此其 内容必须保持在对话之外。对它执行source,以便 shell 使用这些 值而不暴露它们。如果$DATA_APP_MB_API_KEY或$DATA_APP_MB_URL在 source 后为空 或仍为默认的mb_replace_me占位符,请让 用户自行添加真实值,然后继续。
从 repo-root 的 .env.local 中 source 凭据并生成限定范围的
schema。下面的示例从 Data 库导出表数据;请将其
查询参数替换为上面选择的范围:
ROOT="$(git rev-parse --show-toplevel 2>/dev/null)"
if [ -z "$ROOT" ]; then
echo "Not inside the connected git repo — cd into it first." >&2
exit 1
fi
(
source "$ROOT/.env.local" 2>/dev/null
# Fail early (before curl) if either var is missing or placeholder-only.
if [ -z "$DATA_APP_MB_URL" ] || [ "$DATA_APP_MB_URL" = "mb_replace_me" ] ||
[ -z "$DATA_APP_MB_API_KEY" ] || [ "$DATA_APP_MB_API_KEY" = "mb_replace_me" ]; then
echo "Set real DATA_APP_MB_URL / DATA_APP_MB_API_KEY in repo-root .env.local" >&2
exit 1
fi
curl \
-o src/metabase.data.ts \
-H "x-api-key: $DATA_APP_MB_API_KEY" \
-H "Accept: text/typescript" \
"$DATA_APP_MB_URL/api/typed-schemas/v1/typescript?include-data-library=true"
)
生成文件中的某个条目在带有可提供的上下文时会先以一个 /* metadata: {...} */ JSON 块开头,再是其运行时属性,例如字段的显示名称、描述和语义类型,表的数据库、schema 和真实名称,以及指标的描述、过滤器和是否已验证。它是注释,因此永远不会进入 bundle,应用也永远不会导入它;请阅读它,并且绝不要编辑或重新格式化该文件。
导出成功后,请验证 schema 是否包含所请求应用所需的全部实体。如果缺少任何实体,请使用可用上下文修订范围,或在构建 UI 之前请求缺失的上下文。
如果 schema 生成失败,不要隐藏、转述掉该错误,也不要重试以绕过它。请将类型化 schema 错误呈现给用户,并在存在时包含失败的 id、名称和消息。
将每个查询和操作写入应用的集合
数据应用运行的每个查询和操作都会经过应用自身集合中的副本:对该集合的读访问权限让查看者可以运行应用的卡片,但他们只能看到自己已经可以读取的表中的数据,因为应用授予的是该集合的权限,而不是这些表的权限。请将每一个都声明为根级目录(与 package.json 同级)中的命名导出 —— defineQuery(...) 放入 queries/,defineAction(...) 放入 actions/。CLI 只扫描这两个目录,因此 src/queries/、src/actions/ 或任何其他源目录下的定义永远不会被发现。发现范围涵盖 .js、.jsx、.ts、.tsx、.cjs、.cts、.mjs 和 .mts。
import { defineAction, defineQuery } from "@metabase/embedding-sdk-react/data-app";
import schema from "../src/metabase.data";
// queries/revenue.query.ts
export const RevenueQuery = defineQuery({
savedQuestionEntityId: "<entity ID of its saved question>",
source: schema.tables.orders,
});
// actions/orders.action.ts
export const CreateOrder = defineAction({
copiedActionEntityId: "<entity ID of its copied action>",
action: schema.actions.createOrder,
});
每个定义都由序列化的 Metabase YAML 支撑,你需要将其写入仓库的 collections/data_apps/ 目录,也就是 data-apps 命名空间的集合所在的位置,由 Metabase 导出的形式。在拉取仓库之前,Metabase 中不会有任何变化;拉取会将这些文件作为序列化内容加载到应用的集合中,就像仓库中存放的其他所有内容一样,因此请写入这些文件,而不是在该集合中手动创建任何内容。从应用的目录 data_apps/<slug>/ 来看,该目录是 ../../collections/data_apps/。
data_apps/<slug>/data_app.yaml collection: <entity ID of the app's collection>
collections/data_apps/<collection>.yaml the app's collection, a root collection of the data-apps namespace
collections/data_apps/<collection>/<name>.yaml a saved question per query, copies of the metrics they use, and a copy of each action the app runs
<collection> 是集合经 Metabase slug 化后的名称:Data App: Sales 对应 data_app__sales。拉取和 CLI 通过内容而非路径来查找应用的文件:即 manifest 中指定了 entity_id 的集合,以及 collection_id 为该集合的卡片和操作。
请以 Metabase representation 格式编写 YAML。在写入或编辑 collections/data_apps/ 下的任何文件之前,请加载用于读写 Metabase representation YAML 的技能(使用技能发现),并对该目录下的每个文件 —— 集合、卡片和操作 —— 遵循该技能及其格式规范。如果没有此类技能,请使用 npx skills add metabase/agent-skills/skills --skill metabase-representation-format 安装;如果仍无法加载,请停止并告知用户,而不是写入这些文件。以下任何一项都不能替代它:来自早期应用的 YAML(包括 git 历史中的文件)、针对某个字段搜索规范,或 npm run check-resources 通过,因为其 schema 验证检查的是 schema,而不是格式的约定。每个卡片和副本都根据 npm run print-resources 打印的内容写入,如下所示;格式技能则是你编写它的方式。绝不要猜测字段的形状。使用 npx representations generate-entity-id(多个时使用 --count <n>)从应用自身的 dev 依赖中生成每个新实体 ID;绝不要凭空编造、复用来自另一个应用的 ID,或在其副本上保留源实体的 ID。
-
集合。 每个应用一次,并且在
collections/data_apps/下的任何其他内容之前,将collections/data_apps/<collection>.yaml写入为一个名为Data App: <app name>且带有namespace: data-apps的集合,并将其实体 ID 作为collection: <id>放入data_app.yaml。脚手架通常随 manifest 一起写入它:当data_app.yaml已经指定了一个其文件存在的集合时,跳过此步骤。print-resources在 manifest 指定该集合之前拒绝运行,因为它会将已保存的问题打印到其中。它必须是该命名空间的根集合:没有parent_id、personal_owner_id、type、authority_level或archive_operation_id,并且不是is_remote_synced、is_sample或archived。拉取会拒绝没有collection:的 manifest,以及其集合在仓库中没有文件的 manifest。 -
打印资源是根据什么写出来的。 每个
defineQuery都带有其savedQuestionEntityId,每个defineAction都带有其copiedActionEntityId,在你打印之前,用npx representations generate-entity-id生成:保存的问题会连同定义的 ID 一起打印,因此print-resources会拒绝没有该 ID 的查询并指出其名称。然后npm run print-resources(可选地带上一个文件,npm run print-resources -- queries/revenue.query.ts)会把应用的定义发送到 Metabase,使用仓库根目录.env.local中的DATA_APP_MB_URL和DATA_APP_MB_API_KEY,并以 JSON 打印序列化所写出的所有内容,每个引用都按名称或实体 ID 而非数字 ID:queries:每个定义的export、file和savedQuestionEntityId,Metabase 为其写出的保存的问题,即entity(以 export 命名,位于应用的 collection 中,带有该实体 ID,保存着 dev preview 运行的查询),以及它所聚合的metrics的实体 ID;actions:每个定义的export、file和copiedActionEntityId,以及源 action,即entity;metrics:查询所聚合的 metrics,每个都作为entity。
action 或 metric 旁边的数字
id只表明它是哪个源;应用的 collection 中没有任何文件持有它。该 export 仅响应管理员,因此 API key 必须是 Administrators 组中的 key。使用任何其他 key 时,命令会以
403失败:停下并告知用户,绝不要以其他方式写这些文件。无法构建或复制的条目会有一个
error代替(例如查询中指名的不存在的字段、属于某个 model 的 action,或读取另一张 card 的 metric 或 action);停下并告知用户。任何定义变更后再次运行它,并更新它影响的文件。 -
每个查询一个保存的问题。 将打印出的
entity原样写入collections/data_apps/<collection>/<name>.yaml。它的名称、type、display、creator_id、collection_id、实体 ID 和serdes/meta都已设置好;不要添加任何内容。唯一的改动在其dataset_query中:每个 metric 引用([metric, {}, <entity ID>])指向该 metric 在应用中的副本(第 4 步),而不是源 metric 的实体 ID。你要亲自按照格式 skill 的布局(它如何排列 MBQL 子句)来写,保持打印出的键顺序,而不是用脚本序列化打印出的 JSON:脚本会跳过格式 skill,其输出也不会对照格式进行检查。该查询就是 dev preview 所运行的,因此其中任何差异都会改变生产环境运行的内容;写入文件后,将其解析回来并与打印出的 entity 比较,并修正除 metric 引用之外的每一处差异。 -
每个 metric 一个副本。 查询所聚合的每个 metric 也必须被复制,因为查看者只能读取应用的 collection。将 metric 打印出的
entity作为一个 card 写入collections/data_apps/<collection>/,带有新的实体 ID,并将collection_id设置为应用的 collection,然后从每个使用该 metric 的问题中引用该副本的实体 ID。一个副本可供它们全部使用。 -
每个 action 一个副本。 将 action 打印出的
entity写入collections/data_apps/<collection>/,带有新的实体 ID,设置为定义的copiedActionEntityId,并将collection_id设置为应用的 collection。应用只运行不属于任何 model 的 query actions,因此副本永远不会有model_id。 每张卡片和每份文案都要你亲自根据打印导出内容编写,遵循格式技能,绝不能依据 YAML(不是仓库导出的集合或其顶层actions/,不是其他应用的集合,不是 git 历史),绝不能依据你自己的 API 调用,也绝不能用把打印出的 JSON 转储为 YAML 的脚本。它的引用已经是 YAML 使用的形式,因此照原样复制。副本精确保留其导出打印的内容,除了使其成为副本的部分:新的entity_id(以及匹配的serdes/metaid,其标签取自副本名称)和collection_id。打印内容已经省略了序列化未设置或处于默认值的内容(description、archived、enable_embedding、public_uuid、表单字段的未设置设置等),正如格式会省略它们:绝不要添加打印内容中没有的键,绝不要保留值为null的键,并保持打印的键顺序。
每张卡片和每个操作都带有打印内容给出的 creator_id;Metabase 在加载应用时会将其替换为其内部用户。
如果应用需要的某个源不在 src/metabase.data.ts 中,或者其导出带有 error,请停止并告知用户;绝不要从头编写替代品。在 Metabase 中更改某个源后,再次运行 npm run print-resources 并再次编写其副本,保留副本的实体 ID。
运行 npm run check-resources,并修复它报告的每个文件。当定义与应用集合的文件不一致时,它会失败并列出每个问题:清单未命名任何集合,或命名了一个没有文件的集合,定义没有其实体 ID 或命名了一个没有文件持有的实体 ID,查询命名了不是问题的卡片,或者某个问题或操作没有定义命名它;然后它会根据格式的 schema 验证仓库中的每个 Metabase YAML 文件,包括应用集合文件。npm run build 会运行第一项检查。一旦没有东西使用某个指标副本,就自己删除它。这两个命令都不会调用 Metabase。下一次拉取会再次验证一切,并拒绝应用的资源,同时显示一条命名该文件的消息,展示在 Data apps 管理页面上。修复该文件并再次推送。
将定义本身传递给 hook,让 SDK 解析要运行的内容——生产构建运行副本,而开发预览运行编写的表或操作,因此应用在其资源存在之前即可工作:
const { data } = useMetabaseQuery(RevenueQuery, {
filters: [filter(RevenueQuery.source.fields.status, "=", selectedStatus)],
});
const { execute, isExecuting, error } = useAction(CreateOrder);
绝不要传递内联的表源查询(即使是只读、筛选选项或辅助查询)、原始操作 id、savedQuestionEntityId、copiedActionEntityId 或手工构建的 { source: { type: "card", id } },也绝不要将定义展开到新对象中。每一种都会破坏替换;编写的 id 还会绕过权限边界。TypeScript 会拒绝其中大多数:hook 只接受 defineQuery/defineAction 返回的内容,因此内联对象、satisfies 类型的对象、展开副本和 schema.actions.<action> 都无法编译。当 tsc 报告 Property 'definedWithDefineQuery' is missing 或 Property 'definedWithDefineAction' is missing 时,该参数不是定义:将其移入 queries/ 或 actions/ 并导入该导出。不要用类型转换或在调用处将内联对象包装在 defineQuery(...) 中来消除它,那样可以编译,但永远不会由保存的问题支撑。将固定的权限边界筛选、聚合和分组保留在 defineQuery 内——它们已烘焙进保存的问题中,因此不要在其外部再次应用它们,并将运行时子句放入 hook 的第二个参数(参见 静态和动态查询部分)。useAction 不需要泛型:定义会为 execute 的参数和 result 设定类型。
在 package.json 中配置 "print-resources": "embedding-sdk-react data-apps print-resources"、"check-resources": "embedding-sdk-react data-apps check-resources && representations validate-schema --folder ../.."(并将 @metabase/representations 作为 dev 依赖),以及 "build": "vite build"。在添加、修改、重命名或删除任何定义后,更新应用集合中的文件,然后运行 npm run check-resources 和 npm run build。在 npm run build 成功之前,不要测试或交接该应用。将 data_app.yaml、应用在 collections/data_apps/ 下的文件以及定义一起提交;Metabase 拉取仓库时,应用会读取它们。
标准模式
每个查询对应两个文件:定义位于 queries/,Hook 调用位于组件中。为保持简洁,从这里开始的示例都省略了 savedQuestionEntityId;每个真实定义都会带上它(参见 将每个查询和操作写入应用的集合)。
// queries/orders.query.ts
import {
aggregations,
breakout,
defineQuery,
filter,
orderBy,
} from "@metabase/embedding-sdk-react/data-app";
import schema from "../src/metabase.data";
const ordersTable = schema.tables.orders;
export const PaidRevenueByMonth = defineQuery({
source: ordersTable,
filters: [
ordersTable.segments.completed,
filter(ordersTable.fields.status, "=", "paid"),
],
aggregations: [aggregations.sum(ordersTable.fields.amount)],
breakouts: [breakout(ordersTable.fields.createdAt, { unit: "month" })],
orderBys: [orderBy(ordersTable.fields.createdAt, "desc", { unit: "month" })],
limit: 100,
});
// src/pages/Overview.tsx
import { useMetabaseQuery } from "@metabase/embedding-sdk-react/data-app";
import { PaidRevenueByMonth } from "../../queries/orders.query";
const { data, isLoading, error } = useMetabaseQuery(PaidRevenueByMonth);
useMetabaseQuery(...) 会从定义中推断出带类型的行数据,因此不要在 Hook 上写泛型。要检查字段、分段和度量所属的表,请将泛型放在定义上:defineQuery<typeof ordersTable>({ ... })。当你需要从 data.rows 获取精确的行键时,在选定字段查询上不要加它。下面的配方展示了传给 defineQuery 的对象;每一个都是 queries/ 中的导出,绝不是写在 Hook 处的参数。
在每个渲染树中,每个 schema 条目最多调用一次。 对同一个 questionId(或同一个 tableId 加上完全相同的过滤条件/度量/分组)多次调用 useMetabaseQuery 会挂载相互独立的订阅,触发重复查询,并让使用方在加载中途出现不一致。将调用提升到需要该数据的最高层组件;把 data / isLoading / error 作为 props 向下传递。不同的 id——或同一个 id 搭配不同的过滤条件/分组——是不同的数据源;请分别调用。
使用带键的 schema 对象:
- 表:
source: schema.tables.<table> - 度量:
aggregations中的schema.metrics.<metric> - 字段:
schema.tables.<table>.fields.<field> - 分段:
schema.tables.<table>.segments.<segment> - 度量值:
schema.tables.<table>.measures.<measure> - 度量维度:
schema.metrics.<metric>.dimensions.<group>.<dimension>
不要传入诸如 "created_at" 或 "segment" 这样的原始维度字符串。
静态与动态查询部分
两个查询钩子都接受一个可选的第二参数:在应用运行期间会发生变化的条件子句。
// revenue.query.ts — static, and identical on every render
const orders = schema.tables.orders;
export const RevenueQuery = defineQuery({
source: orders,
aggregations: [aggregations.sum(orders.fields.total)],
breakouts: [
breakout(orders.fields.createdAt, { unit: "month" }),
breakout(orders.fields.plan),
],
});
// the component supplies only what the UI changes
const { data } = useMetabaseQuery(RevenueQuery, {
filters: plan === null ? [] : [filter(orders.fields.plan, "=", plan)],
});
即使看起来没有任何东西依赖于此,也要按这种方式拆分:第一个参数在每次渲染时都必须完全相同,只有第二个参数可以随运行时状态变化。
动态子句作为它们自己的阶段运行,因此它们看到的是静态查询的结果列,而不是其源表。这就是为什么上面的 plan 是一个拆分项:一个基于源列进行过滤的控件,只有当该列保留到结果中时才能起作用。如果没有保留,就把它添加为一个拆分项,或者让静态查询保持未聚合状态。同样,对聚合后的静态查询进行过滤时,要基于 count/sum,而不是它们背后的字段。
分段和度量只属于静态部分。 它们是针对表定义的,而动态阶段没有表——因此 filters: [orders.segments.completed] 和 aggregations: [orders.measures.revenue] 会被拒绝,在编译时如此,在运行时亦然。这是通常的"优先使用精选定义"规则不适用的唯一地方。
把精选定义放在它能被解析的静态查询中,让动态子句基于其产出结果工作:
export const CompletedOrders = defineQuery({
source: orders,
filters: [orders.segments.completed], // the segment resolves here
aggregations: [orders.measures.revenue],
breakouts: [breakout(orders.fields.plan)],
});
const { data } = useMetabaseQuery(CompletedOrders, {
// a result column, not a segment or measure
filters: plan === null ? [] : [filter({ type: "column", name: "PLAN" }, "=", plan)],
});
如果某个控件必须开启和关闭一个分段,那是静态查询之间的选择,而不是动态子句:为每个状态定义一个查询并选择该查询,或者把同样的条件表达为对结果列的过滤。
如果你在某个查询对象上看到 savedQuestionEntityId,或者在某个操作定义上看到 copiedActionEntityId,请不要移除它们。它们各自指明了该定义在应用集合中的文件——参见将每个查询和操作写入应用的集合。
表查询配方
对于表查询,将生成的表对象作为 source 传入:
// queries/records.query.ts
const recordsTable = schema.tables.records;
export const RecordStatuses = defineQuery({
source: recordsTable,
fields: [recordsTable.fields.id, recordsTable.fields.status],
});
对于分组表摘要,至少包含一个聚合:
export const ActiveAmountByMonth = defineQuery({
source: recordsTable,
filters: [
recordsTable.segments.activeRecords,
filter(recordsTable.fields.amount, ">", 100),
],
aggregations: [recordsTable.measures.totalAmount],
breakouts: [breakout(recordsTable.fields.createdAt, { unit: "month" })],
orderBys: [orderBy(recordsTable.fields.createdAt, "desc", { unit: "month" })],
});
对于没有精选度量的基本聚合,使用 aggregations 辅助函数:
export const AmountByCategory = defineQuery({
source: recordsTable,
aggregations: [
aggregations.count(),
aggregations.sum(recordsTable.fields.amount),
],
breakouts: [breakout(recordsTable.fields.category)],
});
当某个查询多次使用同一个辅助函数时,为每一个指定一个 name。该名称会成为结果列的名称和行键,它是有类型的,并且 orderBy(...) 和运行时子句正是通过它来引用该聚合。如果没有名称,列会以 sum、sum_2 等返回,按其中之一排序或过滤就会失败:
const totalAmount = aggregations.sum(recordsTable.fields.amount, {
name: "total_amount",
});
const totalTax = aggregations.sum(recordsTable.fields.tax, {
name: "total_tax",
});
export const TaxByCategory = defineQuery({
source: recordsTable,
aggregations: [totalAmount, totalTax],
breakouts: [breakout(recordsTable.fields.category)],
orderBys: [orderBy(totalTax, "desc")],
});
// Rows are keyed `total_amount` and `total_tax`.
表字段、分段、度量、过滤器、拆分项和 orderBy 都必须来自被查询的表。当你希望 TypeScript 在定义处验证这种归属关系时,使用 defineQuery<RecordsTable>({ ... })。
度量聚合配方
对于由度量支持的查询,将生成的表对象作为 source 传入,并将生成的度量对象放入 aggregations:
// queries/revenue.query.ts
const ordersTable = schema.tables.orders;
const revenueMetric = schema.metrics.revenue;
export const Revenue = defineQuery({
source: ordersTable,
aggregations: [revenueMetric],
});
在聚合所属度量的查询中,使用生成的度量维度进行过滤和拆分。来自度量源表的维度可以直接使用。当生成的字段包含 sourceFieldId 时,来自关联表的维度也可以使用;优先使用这些关联表维度以获得可读的标签,而不是按原始外键 ID 分组:
export const PaidRevenueByMonthAndFranchise = defineQuery({
source: ordersTable,
aggregations: [revenueMetric],
filters: [filter(revenueMetric.dimensions.orders.status, "=", "paid")],
breakouts: [
breakout(revenueMetric.dimensions.orders.createdAt, { unit: "month" }),
breakout(revenueMetric.dimensions.franchises.name),
],
orderBys: [
orderBy(revenueMetric.dimensions.orders.createdAt, "desc", {
unit: "month",
}),
],
});
// Prefer readable related-table dimensions when available.
breakout(revenueMetric.dimensions.franchises.name);
// Avoid raw FK IDs when the related-table dimension exists.
breakout(revenueMetric.dimensions.orders.franchiseId);
由度量支持的查询可以包含基于生成的度量维度的辅助聚合。当生成的 schema 暴露它们时,它们还可以使用表源中兼容的已保存分段和度量:
export const CompletedRevenueByStatus = defineQuery({
source: ordersTable,
filters: [schema.tables.orders.segments.completed],
aggregations: [
revenueMetric,
schema.tables.orders.measures.totalRevenue,
aggregations.sum(revenueMetric.dimensions.orders.amount),
],
breakouts: [breakout(revenueMetric.dimensions.orders.status)],
});
度量聚合必须属于表源。不要在表源查询中使用源卡片度量。生成的度量维度以其所属度量为作用域:如果某个查询在过滤器、辅助聚合、拆分项或 orderBy 中使用了 revenueMetric.dimensions.*,那么它也必须在 aggregations 中包含 revenueMetric。不要将度量维度用作不相关的 count() 或表度量查询的独立表字段。生成的度量维度也必须解析到表源。当你希望 TypeScript 在定义处验证这一点时,使用 defineQuery<typeof ordersTable>({ ... })。
SDK 渲染的视图
表字段、segments、度量聚合和指标聚合必须来自所查询的表。过滤条件、辅助聚合、分组和 orderBy 中使用的生成指标维度必须解析到所查询的表,并且属于同一查询的 aggregations 中包含的某个指标。
当表查询使用 fields、segments、aggregations、breakouts 或 orderBys 时,让 defineQuery 推断结构,或者在所有权校验比精确的结果行键更重要时编写 defineQuery<typeof recordsTable>。
交互式 Metabase 视图
某个元素究竟是不是 SDK 问题——以及它是 StaticQuestion 还是 InteractiveQuestion——由数据应用设置技能决定(渲染图表:Metabase 优先)。一旦确定:在 queries/ 中声明查询,用 useMetabaseQueryObject(TheQuery) 解析它,然后将结果通过 SDK 问题组件的 card prop 传入。
useMetabaseQueryObject 支持生成的表查询,包括指标聚合。当自定义 React 需要直接的行数据时使用 useMetabaseQuery;当应由 Metabase 渲染或管理可视化时使用 useMetabaseQueryObject。不要向 useMetabaseQueryObject 传入泛型;它返回 { query, error, isLoading },而非查询结果行。
使用 SDK 组件渲染下的示例使用 return null 进行最简的加载和错误处理。在真实应用中,请在此处渲染应用现有的加载或错误 UI。当 query 为 null 时,传入 card={{ query }} 是安全的;不要将完整的 { query, error, isLoading } hook 结果作为 card.query 传入。
在可复用的图表/卡片组件中包装 useMetabaseQueryObject 时,请解构并渲染 error;不要只读取 { query },否则查询构建失败会看起来像是无尽的加载。在该子组件内部调用 hook 是合法的 React。不要在父组件的循环、条件或回调中直接调用 hook。
错误/正确模式:
const trendQuery = useMetabaseQueryObject(TrendQuery);
<InteractiveQuestion card={{ query: trendQuery }} />; // wrong
const { query: trendQuery } = useMetabaseQueryObject(TrendQuery);
<InteractiveQuestion card={{ query: trendQuery }} />; // right
Hook 类型:
- 两个 hook 都只接受从
queries/导入的defineQuery导出,不接受其他内容;内联对象会导致编译错误。不要在 hook 上编写泛型。 useMetabaseQuery(...)从定义中推断带类型的行数据。当所有权校验很重要时,将defineQuery<typeof table>写在定义上。useMetabaseQueryObject(...)返回{ query, error, isLoading }。将query属性传给card={{ query }}。- 不要使用
as Parameters<typeof useMetabaseQuery>[0]或as DefinedQuery来平息查询类型错误。前者会隐藏无效的表字段、指标聚合和分组;后者会隐藏没有已保存问题的查询,这会在生产环境中失败。
基本 prop 约定为:
- 生成的表查询,包括指标聚合:
<StaticQuestion card={{ query }} /> - 完整的交互式问题:
<InteractiveQuestion card={{ query }} />
当你需要 SDK 支持的问题显示集合时,不要复制本地列表。在生成的应用中,搜索 node_modules/@metabase/embedding-sdk-react/dist/index.d.ts 找到确切的声明 declare const cardDisplayTypes: readonly [...],并将该元组作为事实来源。不要将整个声明文件读入上下文。
始终通过 card 对象传递 SDK 渲染的临时问题。当用户未要求特定图表类型,且 Metabase 默认值可以从查询中推断出合理的展示方式时,从 card={{ query }} 开始。当用户请求或设计需要特定图表类型(例如用于展示分布的饼图),但不要求设置级别的自定义时,使用 card={{ query, visualization }}。仅当用户明确要求设置级别的呈现更改(例如隐藏或重命名轴标签、显示值标签、堆叠条形、添加目标线、排列表格列、显示饼图总计/标签,或控制系列/切片顺序)时,才添加 visualizationSettings。在 node_modules/@metabase/embedding-sdk-react/dist/index.d.ts 中搜索 export declare type MetabaseCard、相关的 *VisualizationSettings 类型,以及你计划使用的任何设置键。阅读附加在这些声明上的 JSDoc 注释,然后将 TypeScript 声明作为合法 visualization 和 visualizationSettings 组合的唯一依据。使用 useMetabaseQueryObject 构建查询;不要调用内部查询解析辅助函数,不要通过 any 进行类型转换,也不要凭记忆硬编码设置。
有关所公开设置的简要说明及其使用时机,请阅读 references/visualization-settings.md。仅将该文件视为指导;已安装的 SDK 声明决定什么是合法的。
在编写 card 之前,请检查 node_modules/@metabase/embedding-sdk-react/dist/data-app.d.ts 中 useMetabaseQueryObject 的返回类型。解构返回的 query,并在 card.query 中使用该值;对于已配置的 card,使用 satisfies MetabaseCard 为该对象标注类型。如果 TypeScript 报告重复的不透明 DatasetQuery 符号,不要强制类型转换;在使用 card 之前先更新 SDK 包。
不要为生成的查询或可视化设置杜撰替代的 prop 名称。如果 SDK 类型表明某个 prop 不存在,请相信它,并使用文档中记录的 card prop 结构。
当需要使用 useMetabaseQuery 时,在渲染之前,使用命名属性将类型化行映射到显式的本地视图模型:
const orderedAtKey = ordersTable.fields.orderedAt.name;
const chartRows = (data?.rows ?? []).map((row) => ({
label: String(row[orderedAtKey] ?? "Unknown"),
value: row.count,
}));
如果结果键仅在运行时来自 data.columns,请使用 rawRows 并匹配相应的列位置,或者在索引 data.rows 之前将键收窄为字面量。
使用 SDK 组件渲染
仅图表,不含工具栏:
import {
InteractiveQuestion,
StaticQuestion,
type MetabaseCard,
} from "@metabase/embedding-sdk-react";
import { useMetabaseQueryObject } from "@metabase/embedding-sdk-react/data-app";
import { AmountByMonth } from "../../queries/events.query";
const { query, isLoading, error } = useMetabaseQueryObject(AmountByMonth);
if (error) {
return null;
}
if (isLoading || !query) {
return null;
}
return (
<InteractiveQuestion card={{ query }}>
<InteractiveQuestion.QuestionVisualization height="500px" />
</InteractiveQuestion>
);
已配置的 SDK 可视化:
// queries/events.query.ts
export const TotalAmountByMonth = defineQuery({
source: eventsTable,
aggregations: [eventsTable.measures.totalAmount],
breakouts: [breakout(eventsTable.fields.occurredAt, { unit: "month" })],
});
// the component
const { query, isLoading, error } = useMetabaseQueryObject(TotalAmountByMonth);
if (error) {
return null;
}
if (isLoading || !query) {
return null;
}
const trendCard = {
query,
visualization: "bar",
visualizationSettings: {
"graph.show_values": true,
"graph.y_axis.title_text": "Total amount",
},
} satisfies MetabaseCard;
return (
<InteractiveQuestion card={trendCard}>
<InteractiveQuestion.QuestionVisualization height="500px" />
</InteractiveQuestion>
);
过滤与分组
使用辅助函数,因为它们提供更好的自动补全和更简短的错误信息。
filter(ordersTable.fields.quantity, ">", 0);
filter(ordersTable.fields.status, "contains", "paid");
filter(ordersTable.fields.quantity, "between", [10, 20]);
filter(ordersTable.fields.status, "not-empty");
breakout(ordersTable.fields.createdAt, { unit: "month" });
breakout(ordersTable.fields.amount, {
binning: { strategy: "num-bins", numBins: 10 },
});
breakout(ordersTable.fields.state);
orderBy(ordersTable.fields.createdAt, "desc", { unit: "month" });
不要手写 orderBys 对象字面量,例如 { field, direction } 或 { fieldId, direction };请使用 orderBy(...)。当对与日期分组使用相同的日期字段进行排序时,请向 breakout(...) 和 orderBy(...) 传递相同的 unit。
对于 top-N 分组汇总,请按聚合结果排序,而不是原始源字段。将聚合辅助函数存储在局部常量中,并将该同一常量同时传递给 aggregations 和 orderBy(...):
// queries/inventory.query.ts
const avgQuantity = aggregations.avg(inventoryTable.fields.quantityOnHand);
export const TopIngredientsByQuantity = defineQuery({
source: inventoryTable,
aggregations: [avgQuantity],
breakouts: [breakout(inventoryTable.fields.ingredient)],
orderBys: [orderBy(avgQuantity, "desc")],
limit: 15,
});
对于用户可选择的排序,排序是运行时状态,因此它属于 hook 的第二个参数。构建允许的生成字段的类型化映射,而不是索引整个 fields 对象:
type SortKey = "revenue" | "orders";
type ScorecardTable = typeof scorecardTable;
type ScorecardField = ScorecardTable["fields"][keyof ScorecardTable["fields"]];
const sortFields = {
revenue: scorecardTable.fields.netRevenue,
orders: scorecardTable.fields.orders,
} satisfies Record<SortKey, ScorecardField>;
const { data } = useMetabaseQuery(Scorecard, {
orderBys: [orderBy(sortFields[sortKey], "desc")],
});
Scorecard 是 queries/ 中未聚合的 defineQuery({ source: scorecardTable }) 导出;其源字段会保留到结果中,因此动态阶段可以按它们排序。
对于指标查询,请将生成的指标维度传递给 filter(...) 和 breakout(...):
filter(revenueMetric.dimensions.orders.status, "=", "paid");
breakout(revenueMetric.dimensions.orders.createdAt, { unit: "month" });
过滤运算符规则:
- string:
=、!=、contains、does-not-contain、starts-with、ends-with、is-empty、not-empty、is-null、not-null - number:
=、!=、>、>=、<、<=、between、is-null、not-null - date:
=、!=、>、>=、<、<=、between、time-interval、is-null、not-null - boolean:
=、is-null、not-null
只有日期维度可以使用 unit。非日期维度可以在没有 unit 的情况下用作分组;数值维度可以使用 binning。
细分已经是过滤器:
filters: [
schema.tables.records.segments.activeRecords,
filter(schema.tables.records.fields.amount, ">", 100),
];
当精选细分与产品意图完全匹配时,请先使用它们。当 UI 需要阈值、类别、日期范围、文本匹配、布尔条件或其他尚未由精选细分表示的收窄条件时,请使用 filter(...)。
过滤 UI 模式
当用户要求自定义过滤器时,请构建正常的 React 控件,以向语义查询过滤器提供输入。
在实现过滤器之前,请为可见仪表板创建过滤器契约。至少需要确定:
- 对于每个过滤器,命名提供其选项的运行时查询。
- 对于每个过滤器,命名在
filter(...)中使用的原始值。 - 对于每个卡片、表格、KPI 和趋势,命名可以接收该过滤器的生成表字段或指标维度。
- 如果某个过滤器仅适用于一个部分,请将其保持为部分范围,或从全局过滤器栏中省略。
- 如果页面需要不同的日期字段,例如
snapshotDate,请为该页面使用一个可见的日期控件。 - 描述同一概念的 KPI/详情对应使用相同的相关过滤器。
有关过滤器状态规则、运行时候选选项、过期选项重置、可搜索控件和自定义日期选择器实现,请使用 references/filter-ui-patterns.md 中的详细检查清单。
对于常见的带记忆的日期/类别过滤器形式:
type DatePreset = "30d" | "90d" | "custom" | "all";
const [datePreset, setDatePreset] = useState<DatePreset>("all");
const [customRange, setCustomRange] = useState<[string | null, string | null]>([
null,
null,
]);
const [status, setStatus] = useState("all");
const dateRange = useMemo((): readonly [string, string] | null => {
if (datePreset === "all") {
return null;
}
if (datePreset === "custom") {
const [start, end] = customRange;
return start && end ? [start, end] : null;
}
return getPresetDateRange(datePreset);
}, [datePreset, customRange]);
const orderFilters = useMemo(
() => [
...(dateRange
? [filter(ordersTable.fields.createdAt, "between", dateRange)]
: []),
...(status === "all"
? []
: [filter(ordersTable.fields.status, "=", status)]),
],
[dateRange, status],
);
上面的 customRange 就是 <DateRangePopover value={customRange} onChange={setCustomRange}> 所存储的内容,因此无需转换。当回退选择器的状态使用 Date | null 时,请使用本地 YYYY-MM-DD 格式化器转换所选日期,然后再将它们传递给 filter(..., "between", range)。不要将 date.toISOString().split("T")[0] 用于本地日期过滤器。
结果形态与图表
- 优先使用带键的
data.rows。 - 切勿把
data.rows当作位置数组。不要对useMetabaseQuery的行对象使用row[0]、row[1]、DisplayRow或元组类型断言。 - 在映射底层
rawRows之前先检查data.columns,但不要用任意的data.columns[].name字符串去索引有类型的data.rows。 - 运行时行对象以返回的 Metabase 列名为键,通常是
column.name,例如total_amount或average_score。不要假定生成的 schema 键(如totalAmount或averageScore)就是运行时行键。 - 对于生成的字段引用,React 属性路径与运行时结果键可能不同:
schema.tables.orders.fields.orderedAt.name可能是ordered_at。请使用row[ordersTable.fields.orderedAt.name]、row.count或data.columns元数据,而不是猜测row.orderedAt。 - 将行值视为可空。在调用
toFixed、toLocaleString等数字/字符串方法或字符串变换之前先做防护。 - 仅在已知位置形态时使用
rawRows。 - 聚合列可能命名为
count、sum或avg;需要时对照元数据。 - 如果查询有多个同类型的辅助聚合,例如多个
aggregations.sum(...)调用,请为每一个命名({ name: "..." })并按这些名称读取行。切勿依赖像sum_2这样的生成名称。 - 分组查询可能包含
null分组桶。请将其渲染为"Unknown"或有意识地将其过滤掉。 - 时间序列图表需要多个有序分组桶。不要为标量或单点结果伪造迷你趋势图。
- 单位或量级不同的多序列图表需要单独的坐标轴或归一化。
- 格式化面向用户的值:货币最多保留 2 位小数,计数使用整数,日期使用可读标签。
- 除非语义层描述或检查过的样本值使含义和单位显而易见,否则不要渲染含义不明确的派生业务值。
- 空结果与加载中不同。在
isLoading为 false 后,渲染清晰的空状态,而不是留下骨架屏或空白 KPI。
呈现指引
对于图表形态和表格形态的数据,优先使用 Metabase 渲染的面板。当自定义面板确有必要时,React 应用可以对 data.rows 进行分组、排序、格式化并派生仅用于展示的值,但不要将自定义面板作为默认选择。
合适的转换:
- 对行进行分组以生成摘要。
- 仅在自定义列表明显优于 Metabase 行/条形/表格可视化时,才对行进行排序和截取以生成排名列表。
- 根据实际数据形态选择图表类型,在编写自定义图表代码之前优先使用 Metabase 的
bar、line、area、row、combo、pivot和table显示。 - 显示加载、错误和空状态。
- 限制密集结果的显示。表格、告警列表、日志和排名列表应使用 top-N 截取、分组、分页或固定/最大高度滚动区域,以免大量结果集撑满整个页面。
当页面感觉像原始表格浏览器时,寻找基于 schema 的方式来丰富它:
- 使用 segment 获取精选子集,例如活跃、已完成、已逾期、高优先级或需要关注的记录。
- 使用 measure 获取精选聚合,而不是在 React 中临时重新计算一切。
- 当 schema 为页面的核心业务问题暴露了精选的 metric 聚合时,使用 metric。
- 使用 filter 将查询聚焦到 UI 的意图上。
- 使用 breakout 创建趋势、类别对比和分组摘要。
- 如果丰富后的结果仍是可排序/可下钻的表格,请使用 SDK 可视化组件渲染它,而不是在 React 中重新实现表格行为。
当语义层已有相应概念时,避免手动分类。优先使用精选的 segment、字段或 measure,而不是在 React 中进行字符串匹配、阈值启发式判断或类别重建。
如果没有精选的 schema 条目支持预期的 UI,请省略该部分或请求进行语义层精选。不要在完成的 app 中保留模拟数据或占位分析。
最终检查
- 运行
npm run typecheck。出现Property 'definedWithDefineQuery' is missing或Property 'definedWithDefineAction' is missing表示某个 hook 接收到的不是queries/或actions/导出;将该对象移到对应目录并导入。 - 在改动过的文件中搜索
useMetabaseQuery(、useMetabaseQueryObject(和useAction(。第一个参数必须是从queries/或actions/导入的标识符;即使能编译通过,此处出现{、defineQuery(、defineAction(或展开运算符也是错误的。查询 hook 的第二个参数是动态对象,需内联书写。 - 确认
queries/和actions/与package.json同级,而不是位于src/下,并且应用渲染的每个定义都存放在那里。 - 确认你加载了表示格式技能,并依据它检查了应用集合中的每个文件;在交接说明中写明这一点,或说明你没有做。
- 确认每个定义都有其实体 ID 及其在应用集合中的文件,并且每个已保存问题都与其定义匹配。运行
npm run check-resources,然后运行npm run build;当应用集合文件不再支撑这些定义时,两者都会失败。 - 在聊天或交接说明中保持 TypeScript 诊断信息简洁。在本地使用完整输出修复应用,但报告分组的根本原因和少量代表性诊断,而不是粘贴整个
tsc输出。 - 验证每个渲染值都能追溯到返回的行属性、schema 字段、度量或确定性转换。
- 在改动过的文件中搜索
row[0]、row[1]、row.orderedAt、row.orderDate、as unknown as、DisplayRow、<select、margin、rate、score、percent、%、* 100和.toFixed;修复位置式行访问、结果键猜测、实体<select>筛选器以及不受支持的业务字段解读。 - 验证每个日期预设栏都包含 Custom 作为最后一项,除非明确省略;每个可见日期筛选器都影响当前页面;并且没有页面对同一范围显示重复的日期筛选器。
- 验证
data_app.yaml指向构建产物的路径,并且该构建产物路径已纳入 git 跟踪。 - 对于每个可见筛选器,验证 "All" 映射为无筛选,选中值来自运行时查询结果,并且每个非 All 选项都会改变其声称影响的每张卡片。
常见错误
- 在构建应用期间创建或搜索 Metabase 内容。应用集合中的文件通过
npm run print-resources写入,绝不在 Metabase 中创建。 - 从仓库中的 Metabase YAML(其导出内容、其顶层
actions/或 git 历史)或从自己的 API 调用中复制指标或 action,而不是从npm run print-resources中获取。 - 自行编写已保存问题、添加打印结果中不存在的键,或更改打印实体中的任何内容(将指标引用改为应用副本除外)。
- 在 hook 调用处编写查询对象,而不是使用
defineQuery从queries/中导出;或者在useAction处编写 action,而不是使用defineAction从actions/中导出。这两者现在都是编译错误;修复方法是改到正确的目录,而不是用强制类型转换。 - 在调用处将内联对象包裹在
defineQuery(...)或defineAction(...)中。这样能编译,但它从不基于已保存问题,因此在生产环境中会被拒绝。 - 将定义放在
src/queries/或src/actions/下,而 CLI 从不查找这些位置。 - 导入旧版 hook,而不是
useMetabaseQuery。 - 将原始数字 ID 复制到常量中,而不是使用生成的 schema 对象。
- 自行编造临时度量对象,例如
{ name: "count" }或{ name: "sum", field: fieldId }。 - 为表字段传递原始字符串。
- 添加查找辅助函数,而不是使用带键的生成 schema 对象。
- 自行编造 SDK 组件 prop 名称,而不是为生成的表查询使用
query。 - 混用来自不相关表的字段、分段或度量。
- 将分段或度量传递给动态的第二个参数,而该参数只能解析结果列。
- 添加会发送空值的筛选器 UI,而不是省略该筛选器。
- 硬编码分类筛选值,而不是从 Metabase 查询运行时值。
- 在有稳定 ID 可用时,显示实体名称却按这些名称进行筛选。
- 将仪表板级筛选器仅应用于一个 KPI,而相关图表和表格忽略它。
- 同时显示全局 Date Range 和页面特定的 Snapshot Date,导致其中一个日期筛选器无效。
- 让 KPI 及其明细表使用不同的日期或类别筛选器,却不解释差异。
- 使用自行编造的
%、星级、颜色或阈值来渲染Margin/Rate/Score/Health。 - 发布没有 Custom range 选项的日期预设栏,或将 Custom 放在 All time 之前。
- 在有面向用户的名称可用时,却用
franchise_id这类不透明 ID 绘制图表。 - 在普通
<select>中渲染实体筛选器,即使当前运行时选项列表很短。 - 为日期范围使用原生
<input type="date">或任何日期选择器依赖项(react-datepicker、react-day-picker、某个 UI 套件的选择器),而不是DateRangePopover,并发布由浏览器控制的mm/dd/yyyy占位符或未套用主题的日历弹层。 - 使用
new Date("YYYY-MM-DD").toLocaleDateString()来标注日期触发器,而不是useDateFormatter(),导致格林尼治以西的用户看到的标签早一天。 - 假设
filter(...)会完全验证值类型。 - 让
null分桶成为最新的时间序列数据点。 - 硬编码业务值、标签、时间戳或排名。
- 手工创建可直接用于图表的数组,而不是从查询到的
data.rows中推导。 - 将带类型的 SDK 行强制转换为通用元组行,例如
[string, number]。 - 在 Metabase 返回
ordered_at这类列名时,却读取row.orderedAt这类生成的对象属性名。 - 渲染 schema 或返回的查询结果中不存在的字段。
- 在 SDK 仍在进行身份验证或加载时渲染
No data。 - 创建嵌套的
MetabaseProvider实例,而不是在应用边界共享一个 provider。