Metabase Data App 触发数据写入实战:useAction 与 defineAction 完整指南
【免费下载链接】metabaseThe easy-to-use open source Business Intelligence and Embedded Analytics tool that lets everyone work with data :bar_chart:项目地址: https://gitcode.com/GitHub_Trending/me/metabase
Metabase 的 data app 运行在 Near Membrane 沙箱中,原生fetch/XHR 到 Metabase 源站的调用会被直接拦截,因此"表单提交、更新行、删除条目、执行已保存的 action"这类写操作只有一条正路:通过@metabase/embedding-sdk-react的useActionHook 调用实例上预定义好的 action。本篇基于仓库中的技能文档 skills/metabase-data-app-actions/SKILL.md 展开,完整覆盖 action 的心智模型、typed schema 前置条件、useAction的全部返回值语义、标准建表表单示例、错误展示、参数映射与表单校验,并结合仓库内 SDK 源码(execute-action.ts、action.ts 及其单元测试)印证底层调用链与权限机制,读完即可在 data app 中正确实现"用户点击 → 执行写操作 → 刷新界面数据"的完整闭环。
心智模型:Action 属于 Model,Data App 只负责"触发"
Metabaseaction是服务端定义在数据仓库上的写操作,分两类:
- 对某个 model 的基础 CRUD 操作(insert / update / delete);
- 用户在 Metabase 中编写的自定义 SQL 命令。
Action 的参数、model 绑定、权限都在 Metabase 实例上预先配置好。Data app 的职责是在用户执行某个交互(点击按钮、提交表单、确认危险操作提示)时,用正确的参数调用这个已存在的 action。具体来说:
- 每个 action 都有一个父级 model,在 schema 中表现为
schema.models.<modelName>.actions.<actionName>。Model 条目出现在 schema 里只是为了给 action 编目,不要把 model 当 question 渲染、不要把 model id 传给InteractiveQuestion、不要拉取 model 的行数据——读视图请使用 semantic-layer 的 query/question。 - action 的
type要么是"implicit"(对 model 的 CRUD),要么是"query"(用户编写的自定义 SQL)。 - Implicit action 带有
implicitKind,声明它做什么:"row/create"、"row/update"、"row/delete"或"bulk/*"变体。 - 每个 action 发布一份
parameters列表。每个参数有slug(Data app 提交时使用的键)、jsType("string"/"number"/"Date"/"boolean"/"unknown")和可选的required标记。 - 用
action.parameters来决定渲染并提交哪些字段。create 结果里可能包含result["created-row"],但该行的类型只是Record<string, RowValue>;只拿它做轻量确认,然后刷新页面上既有的表格/question/query 数据。不要去拉取或渲染父级 model 本身。
Schema 前置条件:actions 只在包含 models 时才生成
Action 条目只有在 typed schema 包含 models 时才会生成。编写任何调用 action 的代码之前,确认 schema 是用include-models=true生成的;使用database=<name-or-id>&include-models=true可以只为指定数据库包含 models/actions。不要依赖question-collections来拿 actions——question collections 只增加已保存的 question。
写代码前,先浏览 schema,枚举你关心的各个 model 下schema.models.<m>.actions中有什么。schema 就是该实例 action 的完整目录(catalog),不是用来展示 model 数据的目录:如果某个 model 的actions下有create、update、delete,那这些就是可被调用的 action;不存在的东西,对 Data app 而言就是不存在。
useAction Hook 逐项拆解
import { useAction } from "@metabase/embedding-sdk-react"; const { execute, isExecuting, result, error, reset } = useAction(MyAction);参数规则——传defineAction(...)导出本身
useAction(MyAction)的第一个参数是defineAction(...)的导出对象——传MyAction,不要传MyAction.copiedActionId。生产构建会执行同步(synchronized)之后的 action 副本,该副本的 model 位于 app 自己的 collection 中;开发预览则继续执行原始 authored action,因此 app 在从未同步过的状态下也能工作。不要传schema.models.<model>.actions.<action>或它的.id:传 schema 条目是编译错误;而原始 id 属于原始 model,app 的用户无权读取,生产环境会因权限调用失败。在 data app 之外,Hook 也接受原始数值 id 或entity_id字符串。
这一点在 SDK 源码中有直接印证。action.ts 中定义了defineAction导出的形状:
export type SdkActionDefinition = { action: { id: SdkActionId }; copiedActionId?: number; };execute-action.ts 中的toExecutableActionId实现了环境切换逻辑:dev 预览(isDataAppDev)或非 data app 环境直接使用input.action.id;生产 data app 环境则要求input.copiedActionId存在,否则抛出"This action has not been synchronized. Run npm run sync-resources and rebuild.";而在 data app 中传入原始 id 会直接抛出"Action ${input} was passed to useAction as a raw id..."。配套的单元测试 execute-action.unit.spec.ts 逐条验证了这四种行为:生产构建执行副本、dev 预览执行原 action、data app 外执行原 action、未同步定义在生产中拒绝执行、data app 内拒绝原始 id。
泛型规则——不要手写泛型
defineAction导出自带 schema 条目,因此 Hook 能从中同时推断出参数对象和带判别标签的result:parameters[]变成按键索引的对象(required: true的条目是必选键,各值按jsType定类型);implicitKind/type变成 kind("row/create"→"create","row/update"→"update","row/delete"→"delete",任意"bulk/*"→"bulk",type === "query"→"sql")。只有原始 id 形式才需要显式写泛型——useAction<TParameters, TKind>(42)——因为一个 id 什么信息都不携带。
execute(parameters)— 触发 action。参数对象以参数的slug为键;声明了required: true的参数是必选键,其余可选。成功时返回响应体,失败时抛出异常(错误同时写入error状态供渲染层消费)。当actionId为null或 SDK 尚未初始化时,它不发起请求就 resolve 为null——如果这些情况在你的调用点可达,请自行做守卫。
没有enabled/options参数
Hook 只在调用execute(...)时才运行,门控选项是冗余的。要跳过 action,就在事件处理器里分支:
const onClick = async () => { if (!user.canEdit) return; await execute({ id: orderId, discount }); };isExecuting— 在调用发起与 resolve 之间为true。用驱动按钮的disabled,避免用户双击出重复请求。
result— 响应体,按TKind判别(省略TKind时是AnyActionResult联合类型)。首次调用之前和reset()之后为null。用于轻量确认(insert 之后看result?.["created-row"],SQL action 之后看result?.["rows-affected"]),但不要认为 create 行是类型丰富的 model 行——应该刷新周边数据,见下文"action 执行之后"。
error— 最近一次抛出的错误,类型ActionExecuteError | null。无需 cast 直接读字段:error?.data?.message、error?.status、error?.isCancelled。测试 execute-action.unit.spec.ts 中的用例验证了这个错误形状:非 2xx 响应(如 403)会以{ status: 403, data: { message: "denied" } }的形式 reject,与文档描述完全一致。
reset()— 把result和error清回null。适合在用户确认成功或关闭错误之后调用。
Hook 不会在挂载时自动触发——action 只在 Data app 显式调用execute(...)时运行。
底层调用链:从 Hook 到/api/action/:id/execute
从源码结构看,useAction最终落到 SDK bundle 的 executeAction 函数:它通过 curried(store) => fn形状挂载到window.METABASE_EMBEDDING_SDK_BUNDLE(与createDashboard/queryQuestion同一机制),并在给定 store 上 dispatchmetabase/api的 execute-action mutation。请求最终 POST 到/api/action/:id/execute,body 为{ parameters: {...} };参数包在 SDK 层保持宽松类型,数值合法性由服务端校验。测试用例进一步确认了parameters缺省时默认发送{}、以及entity_id字符串会路由到/api/action/:eid/execute。这也解释了为什么文档强调不要绕过 Hook 直接fetch("/api/action/..."):沙箱会拦截原生网络调用(只有data_app.yaml中声明的外部allowed_hosts允许裸fetch/XHR),useAction是唯一通路。
标准用法:创建一个表单来建行
import { useAction } from "@metabase/embedding-sdk-react"; 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 }); // 类型化:键必须匹配参数 slug setName(""); setEmail(""); onCreated(); // ← 让父组件刷新依赖数据 } catch { // 错误已捕获进 Hook 状态,供渲染层展示 } } 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({ name, email })的键必须与参数slug一致;成功后调用onCreated()让父组件(数据 Hook 所在层)刷新,本组件只负责清空表单。
错误信息的展示:两层都要呈现
当execute(...)失败时,要呈现两层错误。Hook 的error类型为ActionExecuteError | null,形状为{ status?, data: { message?, errors? }, isCancelled }。error.data.message是整请求级别的失败信息;error.data.errors是按参数 slug 为键的逐字段校验映射({ <slug>: <message> }),整请求失败时为{}。两层都直接读取,无需 cast:
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的元素)——消息里含有有意义的换行(driver 错误会把 SQL 单独放一行)。<span>会把它们压成一堵文字墙。
举例:把一个 9 字符的值提交进CHARACTER(2)列会得到
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 错误比任何改写都更有行动价值。
action 执行之后——保持 UI 诚实
当 action 成功时,用户看到的数据可能已经过期。没有显式刷新,列表仍显示旧行;统计卡仍显示旧计数;用户刚编辑的行仍显示旧值。action 成功了,但 UI 在撒谎——而且没有任何警告。
规则简单而绝对:action 成功 resolve 之后,屏幕上任何可能被该 action 改变的数据都必须刷新。
参数映射:slug 是键,jsType 决定输入控件
schema 中 action 的每个parameters[]条目暴露slug、displayName、jsType和可选的required。Data app 要做的事:
execute({ … })的对象键匹配参数的slug字符串。把它们当作字面字符串键使用——当参数是defineAction(...)导出(而非裸 id)时,Hook 从定义推导出的形状会在编译期拒绝拼写错误。- 根据
jsType选对输入控件的type,让浏览器提供正确的 UX 和内置类型转换。不要从 slug 名称臆测输入类型(一个叫"phone"的 slug 仍然是jsType: "string"→type="text"):
jsType | 输入控件 |
|---|---|
"string" | <input type="text" …> |
"number" | <input type="number" …> |
"boolean" | <input type="checkbox" …>(或 MantineSwitch/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用于标签,绝不用于键。
对 implicit action,slug 与 model 的列名(slug 化)一致;对自定义 SQL action,slug 是 action 作者在 Metabase 中给 SQL 参数起的名字。无论哪种情况,schema 都是唯一事实来源。
表单侧校验:与主应用对齐
schema 暴露parameter.required(以及用于类型转换的jsType)——这就是当前完整的校验契约,与 Metabase 内置的 action 执行表单一致。没有长度检查、没有 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之类的检查。
调试清单
当 action 看似成功但界面没更新,或调用以 400 失败时:
- 在
await execute(...)之后打印result、error、isExecuting,确认请求确实发出并成功。 - 确认
useAction收到的是defineAction(...)导出。传 schema 条目是编译错误;传它的.id能编译,但execute失去类型,且生产环境 403——因为 authored action 不是 app 用户有权执行的那个(对应源码 execute-action.ts 的toExecutableActionId逻辑)。 - 打印传给
execute({ ... })的对象。每个键必须匹配schema.models.<model>.actions.<action>.parameters中的某个参数slug;每个值必须匹配其声明的jsType。 - 列出屏幕上所有读取被改动 model 的数据视图。确认它们的数据 Hook 都挂载在 action 触发点之上,这样其刷新回调才能向下传递。
- 确认刷新回调在
await execute(...)之后被调用,且刷新本身也被 await。多个刷新并存时,确认它们一起被 await(Promise.all)。 - 对 implicit action,确认正确的
implicitKind与你的意图一致(row/createvsrow/updatevsrow/delete)——选错 action 就会发出错误的写。 - 如果 schema 中没有你期望的 action,要么该 action 未在实例上定义,要么 schema 文件过期了。重新生成 schema。
常见错误一览
- 把
schema.models.<model>.actions.<action>.id传给useAction而不是defineAction(...)导出。execute此后接受任意Record<string, unknown>,{ wrongKey: 1 }这类拼写错误溜过编译期,且生产环境 403——因为 authored action 不是 app 用户能运行的那个。 - 成功之后忘记刷新。UI 继续渲染过期数据,没有错误也没有警告。
- 渲染
"Failed"/"Something went wrong"/String(error)而非真实后端消息。永远提取error.data.message/error.data.errors(用户需要的诊断就在里面)并原样渲染。 - 调用刷新回调时没有
await。弹窗关闭或表单清空时新数据还没到,用户短暂盯着过期视图。 - 在触发 action 的同一个组件内部加载数据。Hook 位于触发点之下,其刷新回调无法接线。把数据 Hook 提升到父组件,把刷新回调传给触发点。
- 参数
jsType为"number"或"boolean"时传原始<input>字符串。在调用点强制转换。 - 上一次请求未结束时让触发点再次触发。用 Hook 的
isExecuting驱动disabled={isExecuting}。 - 为了跳过刷新而把 action 的
result直接前置塞进本地列表。服务端填充的默认值(自增 ID、时间戳、计算列、派生 join 列)与任何客户端猜测都会分叉。 - 用户要求了实例没有暴露的行为,于是发明一个不存在的 action。schema 是"存在什么"的目录——暴露这个缺口,不要伪造调用。
- 试图直接
fetch("/api/action/...")。沙箱拦截到 Metabase 源站的裸网络调用(裸fetch/XHR 只能到达 data_app.yaml 中声明的外部allowed_hosts);通往 action 的唯一路径是useAction。
延伸阅读
- 本技能文档原文:skills/metabase-data-app-actions/SKILL.md
- data app 脚手架、
data_app.yaml字段与沙箱限制:skills/metabase-data-app-setup/SKILL.md 与模板 skills/metabase-data-app-setup/template/data_app.yaml - SDK 侧 action 执行实现与类型:execute-action.ts、types/action.ts、bundle 导出入口 sdk-bundle-exports.ts
- 行为验证测试:execute-action.unit.spec.ts
适用前提说明:data app 属于 Metabase 面向 64 版的 contenteditable="false">【免费下载链接】metabaseThe easy-to-use open source Business Intelligence and Embedded Analytics tool that lets everyone work with data :bar_chart:项目地址: https://gitcode.com/GitHub_Trending/me/metabase
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考