关于 metabase-data-app-actions
为 Metabase Data App 开发提供指南,说明如何通过 `useAction` 调用预先配置的 SQL 写入操作,并处理参数、结果、错误及数据刷新。
主要功能
- 01通过 `useAction` 执行动作
- 02按 schema 映射参数与输入类型
- 03呈现字段及请求错误
- 04执行后刷新受影响数据
- 05处理执行状态与结果
适用场景
- 01为表单提交创建或更新记录
- 02在确认操作后执行 SQL 写入
- 03根据错误信息定位参数或 SQL 问题
安装
复制下面的命令,在终端中运行。
npx skills add https:/ / github.com/ metabase/ metabase --skill metabase-data-app-actions安装后在 Claude Code 中输入/metabase-data-app-actions确认已加载。
详细介绍
从 Metabase 数据应用触发操作
Metabase 操作是针对数据仓库保存的参数化 SQL 写入——即 INSERT、UPDATE 或 DELETE。操作会提前在 Metabase 实例上配置好,其参数和权限也已设定。数据应用的工作是在用户执行某项操作时——点击按钮、提交表单、确认破坏性提示——用正确的参数来调用操作。
心智模型
- 应用可以运行的每个操作都会以
schema.actions.<actionName>的形式出现在 schema 中,并带有type: "query"。 - 每个操作都会发布一个
parameters列表。每个参数都有一个slug(数据应用发送的键)、一个jsType("string"/"number"/"Date"/"boolean"/"unknown"),以及一个可选的required标志。 - 使用
action.parameters来了解需要渲染和提交哪些字段。结果会报告result["rows-affected"];用它来做轻量级确认,然后刷新页面已经显示的表/问题/查询数据。
schema 中包含什么(以及不包含什么)
只有当类型化 schema 包含操作时,才会生成操作条目。在编写调用操作的代码之前,请确保 schema 是在 include-actions=true 的情况下生成的;使用 database=<name-or-id>&include-actions=true 时,Metabase 只会包含该数据库的操作。
在编写任何调用操作的代码之前,先枚举 schema.actions 下可用的内容。schema 是应用可以运行的操作的完整目录。对数据应用而言,任何不存在的内容都不存在:当应用需要 schema 中缺少的写入时,请用户在一个启用了操作的数据库上,将其创建为不带模型的查询操作(POST /api/action,type: "query" 且没有 model_id;在 Metabase UI 中创建的操作属于某个模型,永远不会被列出),然后重新生成 schema。
hook
import { useAction } from "@metabase/embedding-sdk-react/data-app";
const { execute, isExecuting, result, error, reset } = useAction(MyAction);
- 从
@metabase/embedding-sdk-react/data-app导入它。 该入口的useAction是 data-app 形式:它只接受defineAction(...)导出或null,不接受其他任何东西。主入口的useAction是通用 SDK hook,它还可以接受普通对象和原始 id,因此无法在编译期强制校验定义;在 data app 中,原始 id 在运行时仍会以"被作为原始 id 传给useAction"而失败。 - 参数是
defineAction(...)导出本身,声明在应用根级actions/目录中(每个主题一个<topic>.action.ts文件,放在package.json旁边,绝不放在src/下)——传入MyAction,而不是MyAction.copiedActionEntityId。在 data app 中,该 hook 不接受其他任何东西:内联的{ action: ... }对象、schema 条目或定义的展开副本都会编译失败,报Property 'definedWithDefineAction' is missing。修复方法是将导出添加到actions/,而不是用类型转换,也不是在 hook 处调用defineAction(...)——后者能编译,但永远拿不到副本。生产构建随后会运行其copiedActionEntityId所指的副本,该副本位于应用自己的 collection 中,在仓库的collections/data_apps/下;开发预览则持续运行作者编写的 action,因此应用在其资源存在之前就能工作。编写这些副本的说明见 data-app 指南中关于编写应用 collection 文件的部分(使用 skill discovery)。不要传入schema.actions.<action>或其.id:该条目是编译错误,而 data app 会在运行时以"被作为原始 id 传给useAction"拒绝原始 id。 - 不要写泛型。 定义自带其 schema 条目,因此该 hook 会据此推断出参数对象和可辨识的
result:parameters[]会变成带键对象(required: true的条目是必需键,每个值的类型由其jsType决定),而type: "query"会使 kind 变为"sql"。原始 id 形式仅存在于主入口的 SDKuseAction上,那里useAction<TParameters, TKind>(42)需要显式写出泛型,因为 id 本身不描述任何东西;data app 从不通过 id 来指定 action。 execute(parameters)—— 触发该 action。参数对象以参数的slug为键;声明为required: true的参数是必需键,其他都是可选的。成功时返回响应体,失败时抛出异常(该错误也会写入error状态,供渲染期消费者使用)。当actionId为null或 SDK 尚未初始化时,解析为null(且不发起请求)——如果这些情况可能发生,请在调用处加保护。- 没有
enabled/options参数。 该 hook 仅在调用execute(...)时运行,因此门控选项是多余的。通过在事件处理器中分支来跳过 action:const onClick = async () => { if (!user.canEdit) return; await execute({ id: orderId, discount }); }; isExecuting—— 在调用与其解析之间为true。用它驱动按钮的disabled,这样用户就无法双击产生重复请求。result—— 响应体,由TKind辨识(省略TKind时为AnyActionResult联合类型)。首次调用之前以及reset()之后为null。用它做轻量确认(result?.["rows-affected"]),然后刷新周边数据——见action 运行之后。error—— 最后一次抛出的错误,类型为ActionExecuteError | null。无需类型转换即可直接读取字段:error?.data?.message、error?.status、error?.isCancelled。reset()—— 将result和error清回null。在用户确认成功或关闭错误后很有用。
该 hook 不会在挂载时自动触发。 只有在 Data App 显式调用 execute(...) 时,action 才会运行。
规范用法 —— 一个创建行的表单
import { useAction } from "@metabase/embedding-sdk-react/data-app";
import { CreatePerson } from "../../actions/people.action";
function AddPersonForm({ onCreated }: { onCreated: () => void }) {
const { useState } = React;
const { execute, isExecuting, error, reset } = useAction(CreatePerson);
const [name, setName] = useState("");
const [email, setEmail] = useState("");
async function onSubmit(e: React.FormEvent) {
e.preventDefault();
try {
await execute({ name, email }); // typed: keys match parameter slugs
setName("");
setEmail("");
onCreated(); // ← let the parent refresh dependent data
} catch {
// error is captured into hook state for render-time display
}
}
return (
<form onSubmit={onSubmit}>
<input value={name} onChange={(e) => setName(e.target.value)} />
<input value={email} onChange={(e) => setEmail(e.target.value)} />
<button type="submit" disabled={isExecuting || !name || !email}>
{isExecuting ? "Saving…" : "Add"}
</button>
</form>
);
}
显示错误消息
当 execute(...) 失败时,要同时呈现两层错误。Hook 的 error 类型为 ActionExecuteError | null——结构为 { status?, data: { message?, errors? }, isCancelled }。error.data.message 是整个请求的失败信息。error.data.errors 是按参数 slug 作为键的逐字段校验映射({ <slug>: <message> }),对于整个请求的失败——也就是 SQL 本身的所有失败——则为 {}。直接读取两者,无需类型转换:
const fieldErrors = error?.data.errors ?? {};
{error ? (
<pre style={{ whiteSpace: "pre-wrap", margin: 0 }}>
{error.data.message ?? "Action failed."}
</pre>
) : null}
{action.parameters.map((parameter) => {
const fieldError = fieldErrors[parameter.slug];
return (
<label key={parameter.slug}>
{parameter.displayName}
<input
aria-invalid={Boolean(fieldError)}
style={{ borderColor: fieldError ? "#dc2626" : undefined }}
/>
{fieldError ? <div role="alert">{fieldError}</div> : null}
</label>
);
})}
使用 <pre>(或任何带有 white-space: pre-wrap 的元素)——消息中包含重要的换行符(驱动错误会把 SQL 单独放在一行)。<span> 会把它们折叠成一大段文字。
示例:向一个 CHARACTER(2) 列提交一个 9 个字符的值,会产生
Value too long for column "STATE CHARACTER(2)": "'dadasdasd' (9)";
SQL statement:
UPDATE "PUBLIC"."PEOPLE" SET … WHERE "PUBLIC"."PEOPLE"."ID" = 1 [22001-214]
整个字符串就是 error.data.message。按原样呈现。
不要:
- 渲染
"Failed"/"Something went wrong"/String(error)而不是error.data.message——用户会失去他们仅有的修复信息。 - 对于校验失败只渲染
error.data.message——error.data.errors[parameter.slug]往往是可操作的“哪个字段以及为什么”的细节。 - 把消息改写成更“友好”的版本。原始的 H2/Postgres/SQL 错误比任何改写都更具可操作性。
操作运行之后——让 UI 保持诚实
当操作成功时,用户看到的数据可能已经过时。如果没有显式刷新,列表仍然显示旧的行;统计卡片仍然显示旧的计数;用户刚刚编辑过的行仍然显示旧的值。操作成功了,但 UI 在说谎——而且没有任何警告。
规则很简单也很绝对:操作成功 resolve 之后,屏幕上该操作可能改变的所有数据都必须刷新。
映射参数
schema 操作上的每个 parameters[] 条目都暴露 slug、displayName、jsType,以及可选的 required。Data App 的职责:
-
execute({ … })中的对象键与参数slug字符串匹配。 将它们用作字面字符串键——hook 从定义推导出的类型会在编译期拒绝拼写错误,但仅当参数是defineAction(...)导出(而不是裸 id)时才如此。 -
根据
jsType选择正确的输入type,以便浏览器提供正确的 UX 和内置强制转换。Agent 从不根据 slug 名称臆造输入类型(名为"phone"的 slug 仍然是jsType: "string"→type="text")。jsType输入元素 "string"<input type="text" …>"number"<input type="number" …>"boolean"<input type="checkbox" …>(或 Mantine 的Switch/Checkbox)"Date"<input type="date" …>(如果预期有时间部分,则用"datetime-local")"unknown"<input type="text" …>——尽力而为,在调用处强制转换 -
execute({ … })中的值类型与jsType匹配。jsType: "number"的参数需要number,而不是字符串。使用type="number"时,输入为.valueAsNumber生成数字,但<input>.value仍然是字符串——如果你读取后者,请在调用处强制转换(Number(input))。 -
required: true的参数不能省略。 这反映在 TS 类型中:必需的键就是必需的。 -
displayName用于标签;绝不要把它用作键。
Slug 是操作作者在 Metabase 中给 SQL 参数起的名称;schema 才是唯一事实来源。
表单侧校验 — 与主应用保持一致
schema 暴露了 parameter.required(以及用于类型转换的 jsType)。这就是目前完整的校验约定,与 Metabase 内置的操作执行表单一致。没有长度检查,没有 min/max,没有格式检查 — 任何更细粒度的校验都会在提交后以 BE 错误的形式返回(参见展示错误信息)。
schema 是唯一的事实来源。 读取 parameter.required 并据此接线 — 仅此而已。不要因为某个字段名为 "email" 就添加 required,不要凭直觉把 "name" 限制在 100 个字符以内,不要根据 slug 设置 type="email"。如果 schema 中没有相关声明,该字段就不受约束。
当表单无效时禁用提交按钮。 让它由浏览器的原生判定来驱动,这样 required 就是唯一生效的规则:
const [isFormValid, setIsFormValid] = useState(false);
const onFormChange = (e: React.FormEvent<HTMLFormElement>) =>
setIsFormValid(e.currentTarget.checkValidity());
<form onSubmit={...} onChange={onFormChange}>
<input required={parameter.required} />
<button type="submit" disabled={isExecuting || !isFormValid}>Create</button>
</form>
不要手写 !name || !email 检查。
调试清单
当某个操作看似成功但屏幕没有更新,或者某次调用失败并返回 400 时:
- 在
await execute(...)之后记录result、error和isExecuting,以确认请求确实发出并成功了。 - 确认调用
useAction时传入的是从actions/导入的defineAction(...)导出。传入 schema 条目、内联对象或展开副本会导致编译错误;传入其.id可以编译,但会使execute失去类型,并在运行时失败,报错 "was passed touseActionas a raw id"。在组件内部调用defineAction(...)也能编译,但它在应用的集合中没有副本,因此已部署的应用会抛出 "This action has no copy"。同样的错误也意味着actions/中的某个定义缺少它的copiedActionEntityId。 - 记录传给
execute({ ... })的对象。每个键都必须与schema.actions.<action>.parameters中某个参数的slug匹配;每个值都必须与其声明的jsType匹配。 - 列出屏幕上所有读取该操作所写入行的 data view。确认每一个的数据 hook 都挂载在该操作触发器的上方,以便其刷新回调可以向下传递。
- 确认刷新回调是在
await execute(...)之后调用的,并且刷新本身也被 await。当有多个刷新适用时,确认它们被一起 await(Promise.all)。 - 如果 schema 中没有列出你预期的某个操作,说明该操作未在实例上定义,或者 schema 文件已过期。重新生成 schema。
常见错误
- 从
@metabase/embedding-sdk-react而不是@metabase/embedding-sdk-react/data-app导入useAction。主入口的 hook 是通用 SDK 的 hook,接受任何内容,因此定义校验永远不会运行。 - 把 action 声明在除根级
actions/以外的任何位置:内联在 hook 处、包裹在组件内的defineAction(...)中,或放在src/actions/下。只有actions/中的定义才会与应用的 collection 文件进行校验。 - 把
schema.actions.<action>.id传给useAction,而不是defineAction(...)的导出。这样execute会接受任何Record<string, unknown>,像{ wrongKey: 1 }这样的拼写错误会溜过去,而 data app 在运行时则会以 "was passed touseActionas a raw id" 拒绝这个原始 id。 - 在 action 成功执行后忘记刷新。UI 会继续渲染过期数据,且没有任何错误或警告。
- 渲染
"Failed"/"Something went wrong"/String(error),而不是真实的后端消息。应始终提取error.data.message/error.data.errors(用户需要的诊断信息就在里面)并原样渲染——参见显示错误消息。 - 调用刷新回调时没有
await它。模态框会在新数据到达之前关闭,或表单会在新数据到达之前清空,让用户对着过期的视图愣上一拍。 - 在触发 action 的同一个组件内部加载数据。hook 位于触发器之下,因此它的刷新回调无法被接上。应把数据 hook 提升到父组件,并将其刷新回调向下传给触发器。
- 当 slug 的
jsType为"number"或"boolean"时,把原始<input>字符串作为参数值传入。应在调用处进行类型转换。 - 在上一个请求还在进行中时就让触发器触发。应从 hook 中驱动
disabled={isExecuting}。 - 把 action 的
result直接前置到本地列表以跳过刷新。由服务端填充的默认值(自动 ID、时间戳、计算列、派生 join 列)会与任何客户端侧的猜测产生偏差。 - 因为用户要求了实例未暴露的行为就臆造一个 action。schema 是现有内容的目录;应指出这一缺口,而不是伪造调用。
- 直接使用
fetch("/api/action/...")。沙箱会阻止对 Metabase 源的原始网络调用(原始fetch/XHR 仅对data_app.yaml中声明的外部allowed_hosts有效);通往 action 的唯一路径是useAction。