mcpField
M
metabase-data-app-actions

metabase-data-app-actions

github.com/metabase开发49.9k814 次安装今天更新
代码仓库

关于 metabase-data-app-actions

为 Metabase Data App 开发提供指南,说明如何通过 `useAction` 调用预先配置的 SQL 写入操作,并处理参数、结果、错误及数据刷新。

主要功能

  1. 01通过 `useAction` 执行动作
  2. 02按 schema 映射参数与输入类型
  3. 03呈现字段及请求错误
  4. 04执行后刷新受影响数据
  5. 05处理执行状态与结果

适用场景

  1. 01为表单提交创建或更新记录
  2. 02在确认操作后执行 SQL 写入
  3. 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 形式仅存在于主入口的 SDK useAction 上,那里 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 时:

  1. 在 await execute(...) 之后记录 result、error 和 isExecuting,以确认请求确实发出并成功了。
  2. 确认调用 useAction 时传入的是从 actions/ 导入的 defineAction(...) 导出。传入 schema 条目、内联对象或展开副本会导致编译错误;传入其 .id 可以编译,但会使 execute 失去类型,并在运行时失败,报错 "was passed to useAction as a raw id"。在组件内部调用 defineAction(...) 也能编译,但它在应用的集合中没有副本,因此已部署的应用会抛出 "This action has no copy"。同样的错误也意味着 actions/ 中的某个定义缺少它的 copiedActionEntityId。
  3. 记录传给 execute({ ... }) 的对象。每个键都必须与 schema.actions.<action>.parameters 中某个参数的 slug 匹配;每个值都必须与其声明的 jsType 匹配。
  4. 列出屏幕上所有读取该操作所写入行的 data view。确认每一个的数据 hook 都挂载在该操作触发器的上方,以便其刷新回调可以向下传递。
  5. 确认刷新回调是在 await execute(...) 之后调用的,并且刷新本身也被 await。当有多个刷新适用时,确认它们被一起 await(Promise.all)。
  6. 如果 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 to useAction as 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。

评论

支持 Markdown