diff --git a/docs/brainstorms/2026-07-03-bitable-comparative-evaluation-requirements.md b/docs/brainstorms/2026-07-03-bitable-comparative-evaluation-requirements.md new file mode 100644 index 0000000..c9822a2 --- /dev/null +++ b/docs/brainstorms/2026-07-03-bitable-comparative-evaluation-requirements.md @@ -0,0 +1,453 @@ +--- +date: 2026-07-03 +topic: bitable-comparative-evaluation +--- + +# 多维表格三向对比评估与优先级建议 + +## Summary + +评估 agentkit bitable 当前实现与 Twenty、飞书多维表格在样式/功能/UX 三维的差距,按 C 先行(UX 打磨)→ A 跟进(功能广度)策略给出分优先级建议,并把 P0 差距交接给 ce-plan。Agent-as-data-author 作为贯穿差异化主线不阻塞任何一条。 + +--- + +## Problem Frame + +agentkit bitable 后端 v1 已基本齐全:`BitableFile→Table→Field/Record/View` 三层骨架、28 个 REST 端点、公式引擎(10 函数 + AST 白名单 + DAG + 异步重算)、三类采集(Excel/DB/API)全部就位。但前端产品形态残缺——`ViewType` 枚举列 5 种只渲染 grid、列头菜单"编辑"仍跳右侧抽屉、无记录详情抽屉、无分组/条件格式、视觉未达飞书/Twenty 水准。 + +既有的 2026-06-24 模块需求与 2026-06-29 UI 完善需求两份文档已规划方向,但缺乏一份**基于实际代码盘点 + 竞品对标**的差距评估来驱动下一轮优先级决策。本评估填补这个空缺:用 grounding 实证当前实现状态(非规划状态),用竞品研究锁定差距坐标,用优先级策略锚定 ce-plan 交接范围。 + +--- + +## Current Implementation Inventory + +以下为代码盘点的实证状态(已逐条验证)。 + +**后端(已就位):** + +- 容器层级:`BitableFile`(`src/agentkit/bitable/models.py:65-84`)→ `Table`(`file_id` 外键)→ `Field`/`Record`/`View`,schema V2(`src/agentkit/bitable/db.py:40`,含 `bitable_files` 表 + `file_id` 列迁移) +- REST 端点:28 个,前缀 `/api/v1/bitable`(`src/agentkit/server/routes/bitable.py`),JWT 或 `X-Internal-Token` 认证,404-before-403 所有权检查 +- 字段类型(后端 `FieldType` 枚举):`text`/`number`/`date`/`select`/`multiselect`/`attachment`/`image`/`formula`/`lookup`(9 种) +- 字段所有权:`FieldOwner.agent`/`user`,upsert 只更新 agent 列,用户列不被覆盖 +- 默认字段:5 个模板(标题 `text` / 状态 `select` / 日期 `date` / 创建人 `text` / 创建时间 `date`)——注意 `FieldType` 枚举无 `user`/`datetime` 类型,创建人与创建时间实际是 `text`/`date`(R8 schema V3 后将迁移为 `user`/`datetime`,见默认字段迁移矩阵) +- 公式引擎:10 函数(`SUM`/`AVG`/`COUNT`/`MIN`/`MAX`/`ABS`/`ROUND`/`IF`/`LEN`/`CONCAT`),AST 节点白名单(见 `formula/parser.py`,永不 eval/exec),DAG 拓扑排序 + 环检测,异步 `RecalcWorker`(0.5s 轮询、300s reaper、600s 过期阈值) +- 跨表引用:单向 `lookup`(`lookup_target` 配 `table_id`/`field_id`/`filter_field_id`/`filter_value`) +- 采集:Excel(SSRF 守护的 URL 抓取 + openpyxl,`src/agentkit/bitable/ingestion/excel.py`)、数据库(SQLAlchemy 反射,`database.py`)、API(形状变换,`api_collector.py`) +- CLI:4 命令(list-tables / create-table / import-excel / query,`src/agentkit/cli/bitable.py`) + +**前端(部分就位):** + +- 文件层:`BitableFileListView.vue`(文件列表 + `FileCard` + `FileCreateModal`)、`BitableFileDetailView.vue`(文件详情 + 顶栏 + 左侧表列表 + 主区 grid) +- grid 视图:`BitableGrid.vue`(vxe-grid 封装,列头菜单 `ColumnHeaderMenu` 编辑/隐藏/删除,select/multiselect 编辑器 `SelectCellEditor`/`SelectDisplay`,附件/图片单元格 `AttachmentCell`/`ImageCell`) +- 字段配置:`FieldManagePanel.vue`(右侧抽屉,宽 480)+ `FieldConfigForm.vue`(8 类型:text/number/date/select/multiselect/formula/attachment/image——**UI 无 `lookup` 选项**,尽管后端支持) +- 视图切换:`ViewSwitcher.vue`(editable-card tabs,**无视图类型选择**,`handleCreateView` 硬编码 `'grid'`,`src/agentkit/server/frontend/src/views/BitableFileDetailView.vue:242`) +- 视图配置:`ViewConfigPanel.vue`(筛选 `FilterBuilder` + 排序 + 隐藏字段,**无分组、无条件格式**) +- store:`src/agentkit/server/frontend/src/stores/bitable.ts`(Pinia,含 recalc 轮询 2s 间隔) +- API 客户端:`src/agentkit/server/frontend/src/api/bitable.ts`(**无 `deleteView` 方法**,后端也无 DELETE 视图端点) + +**未就位:** + +- 视图组件:仅 `BitableGrid`,无 `KanbanView`/`GalleryView`/`GanttView`/`FormView` +- 记录详情抽屉:无(仅 grid 单元格编辑) +- 列内联字段配置:无(列头"编辑"跳右侧 `FieldManagePanel`,非内联) +- 分组、条件格式:无 +- 仪表盘、自动化、表单、甘特:无 +- 字段级/记录级权限:无(仅文件/表所有权) +- 前端采集入口 UI:无(后端三类采集已就位,前端无触发入口) + +**测试覆盖:** + +- 单元测试:`tests/unit/bitable/` 13 个文件(test_service/test_routes/test_recalc/test_models/test_ingestion_excel/test_formula_parser/test_formula_engine/test_file_crud/test_default_fields/test_db/test_cli/test_bitable_tool/test_attachment) +- e2e 测试:`src/agentkit/server/frontend/e2e/` 含 3 个 bitable spec(bitable-view / bitable-file-flow / bitable-field-ops) + +**既有规划执行进度(对照 2026-06-29 UI 完善需求):** + +- 阶段一:2026-06-29-R1 文件层 [OK]、2026-06-29-R2 默认字段 [OK](受限:无 user/datetime 类型)、2026-06-29-R3 表内字段操作 部分(列头菜单存在但编辑跳抽屉)、2026-06-29-R4 select 编辑器 [OK]、2026-06-29-R5 三层导航 [OK] +- 阶段二/三/四:未开始 + +--- + +## Three-Way Comparison + +| 维度 | agentkit bitable(当前) | Twenty | 飞书多维表格 | +|---|---|---|---| +| **样式** | | | | +| 视觉密度 | 中等,vxe-grid 默认 | 中低,Notion/Linear 风 | 中高,业务工具质感 | +| 配色体系 | 功能性,无强设计语言 | 浅色 + 蓝紫主调,克制留白 | 浅色 + 蓝紫,彩色 chip 标签活泼 | +| 字段类型图标 | 无 | 有 | 有,清晰 | +| 彩色标签 chip | select 有 `SelectDisplay`,未达飞书水准 | 有 | 有,活泼成熟 | +| 整体质感 | "薄、形态残缺" | Figma 级,开源最佳之一 | 业务工具成熟质感 | +| **功能** | | | | +| 容器层级 | `BitableFile→Table` [OK] | `Object→Record`(无文件层) | `App→Table` [OK] | +| 字段类型数 | 9(后端)/ 8(前端 UI 无 lookup) | 20+ | 25+ | +| 视图类型 | 枚举 5,实际只渲染 grid | 3(Table/Kanban/Calendar) | 5-6(grid/kanban/gallery/gantt/form/+calendar) | +| 公式 | 10 函数 + AST 白名单 + DAG + 异步重算 | 无原生(Workflow + Code Action 替代) | 丰富函数库 + LOOKUP/ROLLUP/FILTER + AI 生成 | +| 跨表关联 | 单向 lookup | Relation(多向)+ 关系上挂属性 | 单向/双向 link + Lookup + Rollup | +| 采集/导入 | [OK] Excel/DB/API 三类(agent 驱动) | 无(CRM 手动录入) | Excel 导入 + OpenAPI(无 agent) | +| 字段所有权 | [OK] agent/user 列 + upsert 保留用户列 | 无 | 无 | +| 仪表盘 | 无 | 基础 | 12+ 图表 + 关联多表 + 千人千面 + AI 解释 | +| 表单视图 | 无 | 无 | [OK](NPS/评分 + AI 搭建) | +| 自动化 | 无 | Workflows(Beta)+ Serverless + MCP | 6 触发 × 9 动作 + AI 节点 + 字段捷径 | +| 单表容量 | PG 部门级(<10 万行规划) | PG(未公开) | 百万热行 | +| **UX** | | | | +| 字段管理入口 | 列头菜单(编辑跳右侧抽屉)+ 字段管理抽屉 | Settings 后台集中 + 表头 `+` | 列头 `+`/字段配置面板/记录详情页/AI 生成 | +| 记录详情 | 无(仅 grid 单元格) | 右侧抽屉(Notes/Tasks/活动线) | 展开记录详情面板 | +| 内联编辑 | grid 单元格可编辑,字段配置非内联 | 单元格内联编辑 | 单元格内联 + 字段内联配置 | +| 命令面板 | 无 | ⌘K 全局命令 | 无(飞书系全局搜索替代) | +| 视图切换 | tabs,无类型选择 | 视图保存 + 可见性控制 | 视图切换 + 类型绑定字段自动生成 | +| 分组 | 无 | 有 | 有(多字段分组) | +| 条件格式 | 无 | 无 | 有 | +| 筛选/排序 | [OK] 有 | [OK] AND/OR + 多字段 | [OK] + 字段名搜索 | +| 协作 | 单用户 | Notes/Tasks/@mention + 邮件日历同步 | 实时 + 评论 + 机器人通知 + 飞书 IM | +| 移动端 | 响应式 web | 响应式 web(无原生 App) | 飞书 App 原生 + 移动 web | +| 权限 | 文件/表所有权 | RBAC(对象/字段/记录)+ SSO + 审计 | 高级权限(颗粒级)+ AI 配置 + 组织架构继承 | +| 默认字段 | [OK] 5 字段(但创建人=text、创建时间=date) | 无默认集 | 主索引字段(1-2 列) | +| AI 能力 | Agent-as-data-author(差异化) | AI Agents + MCP Server(Cloud) | 全家桶(搭建/问数/公式/选项/配色/解释/侧边栏) | + +--- + +## Gap Analysis + +### 样式差距 + +- **G1.** 无字段类型图标(飞书/Twenty 均有,列头视觉辨识度低) +- **G2.** 彩色标签 chip 未达飞书水准(select 选项有 `SelectDisplay` 但配色/质感粗糙) +- **G3.** 整体质感"薄"(密度/留白/配色无统一设计语言,未达 Twenty 的 Figma 级或飞书的业务成熟感) + +### 功能差距 + +- **G4.** 视图只有 grid(枚举 5 种但只渲染 1 种)——飞书 5-6 种、Twenty 3 种 +- **G5.** 字段类型 9 种(前端 UI 8 种无 lookup)——飞书 25+、Twenty 20+;关键缺:`user`/`checkbox`/`url`/`email`/`phone`/`auto-number`/`datetime`/`modified-by`/`location`/`barcode`/`rating`/`progress`/`currency`/`rich-text`/`date-range`/`json`/双向关联 +- **G6.** 默认字段"创建人"=`text`、"创建时间"=`date`——因 `FieldType` 无 `user`/`datetime`,默认字段集本身暴露字段类型缺口(R8 schema V3 迁移将修正:`text` → `user`、`date` → `datetime`) +- **G7.** 公式仅 10 函数——缺 `LOOKUP`/`ROLLUP`/`FILTER`/`SORT`/日期函数/条件函数/字符串函数;飞书有丰富函数库 + AI 生成公式 +- **G8.** 跨表关联仅单向 lookup——无双向关联、无 rollup 聚合 +- **G9.** 无仪表盘——飞书 12+ 图表类型 + 关联多表 + 千人千面 +- **G10.** 无表单视图——飞书有(含 NPS/评分 + AI 搭建) +- **G11.** 无自动化触发器——飞书 6 触发 × 9 动作 + AI 节点;Twenty 有 Workflows + Serverless + MCP +- **G12.** 无甘特视图——飞书有(基于日期+状态) +- **G13.** 单表容量部门级——飞书百万热行(v3 规划列式/分区,尚未实施) + +### UX 差距 + +- **G14.** 字段配置非内联——列头"编辑"跳右侧抽屉,飞书/Twenty 支持列头直接管理 +- **G15.** 无记录详情抽屉——飞书/Twenty 均有(行点击展开详情面板) +- **G16.** 无视图类型选择——`ViewSwitcher` 无类型切换,`handleCreateView` 硬编码 grid +- **G17.** 无分组——飞书/Twenty 均有(多字段分组) +- **G18.** 无条件格式——飞书有(规则自动着色) +- **G19.** 无命令面板(⌘K)——Twenty 有全局快捷操作 +- **G20.** 协作单用户——飞书实时 + 评论 + 通知;Twenty 有 Notes/Tasks/@mention +- **G21.** 无移动端原生——飞书有原生 App +- **G22.** 权限粗粒度——仅文件/表所有权,飞书/Twenty 有字段级/记录级 + SSO + 审计 +- **G23.** 前端无采集入口 UI——后端三类采集就位但前端无触发入口(2026-06-29 阶段二未做)。**闭合路径**:R15c(路径 (a) 新增 `/collections` 端点 + 前端采集入口 UI;路径 (b) 由 agent 通过 BitableTool 触发——依赖 R15a 完成) + +### 差异化优势(非差距,应加倍投入) + +- **D1.** Agent-as-data-author:三类 agent 驱动采集 + upsert 保留用户列——飞书/Twenty 均无 agent 原生模型 +- **D2.** 字段所有权模型:agent/user 列分离,用户列跨采集保留——独一无二 +- **D3.** 内部服务令牌:agent 用 `X-Internal-Token` 认证——agent 原生设计 +- **D4.** 公式引擎安全:AST 白名单 + DAG + 异步重算——比 Twenty 的 Workflow 变通方案更原生 + +--- + +## Key Decisions + +**KD1. 优先级策略:C 先行(UX 打磨)→ A 跟进(功能广度),B(Agent 差异化)作为贯穿主线。** 理由:后端已齐,最大感知差距是已存在的 grid 体验未达飞书/Twenty 水准(G14-G19);先打磨已有功能到竞品水准,再补广度。B 不阻塞任何一条,作为持续投入线。 + +**KD2. 飞书为主标杆,Twenty 为辅。** 飞书是更接近的纯多维表格形态(通用结构化数据载体),agentkit bitable 不是 CRM,Twenty 的 CRM 领域特性不适用。Twenty 贡献 UX 模式(⌘K 命令面板、记录详情抽屉、代码优先 `defineObject`)而非功能广度。 + +**KD3. Agent-as-data-author 是差异化强项,评估中不当差距处理。** 飞书的 AI 全家桶(搭建/问数/公式生成)与 agentkit 的 agent 采集是不同范式——飞书 AI 辅助人类建表,agentkit agent 自主建表写入。后者是 agentkit 身份主线,应加倍投入而非追赶飞书的 AI 辅助形态。 + +**KD4. 评估覆盖样式/功能/UX + 邻近能力(仪表盘/自动化/协作/移动/AI),但以 agentkit 产品身份过滤。** 飞书生态绑定(IM/文档/日历深度集成)与 Twenty 的 CRM 特性(管线/邮件同步)标记为本产品身份之外,不纳入优先级。 + +**KD5. 视觉决策用文字探讨,不开浏览器探针。** 沿用 2026-06-29 KD5,本次评估为分析交付,形状决策留待 ce-plan。 + +**KD6. P0/P1 关键范围决议(Resolve Before Planning 解决)。** +- R3 视图切换器:P0 暴露全部 5 种类型(grid/kanban/gallery/gantt/form),未实现的禁用态展示并标注"规划中",预告路线图而非隐藏。 +- R5 视觉对齐:P0 引入统一设计 token 系统(CSS 变量层:颜色/间距/圆角/字号),以 token 驱动重写列头/chip/密度等,避免后续 P1/P2 视觉不一致。 +- R8 字段类型扩展:P1 一次性补全 16 字段类型 + 1 双向关联 = 17 项,与飞书对齐全面;schema V3 迁移复杂度接受。 + +--- + +## Agent 对等评估方法 + +bitable 后端有 28 个 REST 端点,但 `BitableTool`(`src/agentkit/tools/bitable_tool.py`)仅暴露 6 个动作(`create_table`/`import_excel`/`import_database`/`collect_api`/`upsert_records`/`query_records`),存在系统性 agent 孤儿风险。下表为每个 R-ID 标注复用端点、需新增的 BitableTool 动作、以及 agent 孤儿风险等级。 + +| R-ID | 复用/新增端点 | BitableTool 需新动作 | Agent 孤儿风险 | +|------|--------------|---------------------|---------------| +| R1 | 复用 PATCH /fields | `update_field` | 高 — agent 无法内联改字段 | +| R2 | 复用 GET /records/{id} | (无需新动作,复用 `query_records`) | 低 | +| R3 | 复用 POST /views(需扩展 schema 接受 type) | `create_view` | 高 — agent 无法建非 grid 视图 | +| R4 | 复用 PATCH /views(需扩展 group_by/conditional_formatting) | `update_view` | 高 — agent 无法配置分组/条件格式 | +| R5 | 纯前端 token 系统 | (无需新动作) | 无 | +| R6/R7 | 复用 POST /views + PATCH /views | `create_view`/`update_view`(同 R3/R4) | 中 — 与 R3/R4 共享动作 | +| R8 | 复用 POST /fields(需扩展类型枚举) | `create_field`(**必需**——17 个新类型需 agent 能批量建字段,R1 的 `update_field` 不覆盖创建路径) | 中 | +| R9/R10 | 复用公式引擎(`formula/parser.py`)+ POST /fields(双向关联 schema) | (无需新动作,复用 `create_field`/`update_field`) | 低 | +| R11 | 复用 POST /views | `create_view`(同 R3) | 中 | +| R15a | 复用 DELETE /views(需新增后端端点) | `delete_view` | 高 — 后端 + agent 双侧缺口 | +| R15b | 复用 `create_table` + `create_field` + `create_view` 编排 | (无新动作,编排层) | 低 | +| R15c | 路径 (a) 新增 /collections 端点;路径 (b) 复用 BitableTool | 路径 (b) 需 `create_collection`/`update_collection` | 中 — 取决于路径决策 | + +**评估结论**:R15a 是 agent 对等的最高优先级子项,应与 R3/R4 同步推进(共享 `create_view`/`update_view` 动作)。其余 R-ID 的 agent 孤儿风险可通过 R1/R3/R4/R15a 四个新动作一并闭合。 + +--- + +## Priority Recommendations + +以下 R-ID 为分优先级的差距闭合建议,P0 交接 ce-plan。 + +### P0 — UX 打磨(C 先行,立即交接 ce-plan) + +- R1. 列内联字段配置:列头菜单直接编辑字段(重命名/改类型/选项管理),不跳右侧抽屉。闭合 G14。 +- R2. 记录详情侧边抽屉:行点击展开详情面板,含所有字段类型的可视化展示与编辑。闭合 G15。 +- R3. 视图类型切换与创建:`ViewSwitcher` 支持选 grid/kanban/gallery/gantt/form,新建视图选类型(不再硬编码 grid)。P0 暴露全部 5 种类型,未实现的以禁用态展示并标注"规划中"(预告路线图)。**后端依赖**:POST /views 需扩展 schema 接受 `type` 字段(当前硬编码 grid)。闭合 G16,为 P1 视图实现铺路。 +- R4. grid 视图内分组与条件格式:多字段分组 + 规则自动着色。**后端扩展**:View.config 新增 `group_by`/`conditional_formatting` schema,通过 PATCH /views 暴露。**Agent 对等**:BitableTool 的 `create_view`/`update_view` 动作需支持传入这些配置(见 Agent 对等评估方法)。闭合 G17、G18。 +- R5. 视觉风格对齐:P0 引入统一设计 token 系统(CSS 变量层:颜色/间距/圆角/字号),以 token 驱动重写列头/chip/密度等,配套字段类型图标与彩色 chip 标签达飞书水准。闭合 G1、G2、G3。 +- R15a. BitableTool 动作补全(P0 提升,agent 对等最高优先级子项):从 6 个动作扩展到 10 个(新增 `create_view`/`update_view`/`update_field`/`delete_view`),消除与 28 个 REST 端点的 agent 孤儿风险。**与 R3/R4 同步推进**(共享 `create_view`/`update_view` 动作)。强化 D1、D2,闭合 Agent 对等缺口(见 Agent 对等评估方法)。 + +### P1 — 功能广度(A 跟进,下一轮 ce-plan) + +- R6. 看板视图实现:按 select 字段分列 + 拖拽卡片改值。闭合 G4(部分)。 +- R7. 画廊视图实现:以附件/图片为主视觉的卡片网格。闭合 G4(部分)。 +- R8. 字段类型扩展:P1 一次性补全 16 字段类型 + 1 双向关联 = 17 项(`user`/`checkbox`/`url`/`email`/`phone`/`auto-number`/`datetime`/`modified-by`/`location`/`barcode`/`rating`/`progress`/`currency`/`rich-text`/`date-range`/`json` + 双向关联),与飞书对齐全面,修正默认字段的 `user`/`datetime` 缺口。注:`datetime` 为通用日期时间类型,默认字段「创建时间」使用 `datetime` 类型;`modified-by` 为自动管理的用户类型。闭合 G5、G6。 +- R9. 公式库扩展:补 `LOOKUP`/`ROLLUP`/日期/条件/字符串函数,对标飞书函数库。闭合 G7。 +- R10. 跨表关联增强:双向关联 + rollup 聚合。闭合 G8。 + +### P2 — 后续轮次 + +- R11. 甘特视图 + 表单视图。闭合 G4(完整)、G10、G12。 +- R12. 自动化触发器:事件(记录新增/字段变更/定时)→ 动作(写字段/发通知/调 API)。闭合 G11。 +- R13. 仪表盘:图表组件库 + 关联多表。闭合 G9。 +- R14. 细粒度权限:字段级/记录级 + 角色自定义。闭合 G22。 + +### B 线 — Agent 差异化(贯穿,不阻塞 P0/P1) + +- R15b. 自然语言→表结构 agent 技能:agent 接收自然语言描述,调用 BitableTool 完成「建表 + 建字段 + 建视图」一站式编排,作为 agentkit 差异化主线。强化 D1、D2。 +- R15c. 定时采集 + 前端采集入口 UI:在现有 Excel/DB/API 三类采集基础上,新增定时调度能力(cron 表达式驱动)+ 前端采集入口 UI(闭合 G23)。**路径决策**:(a) 新增 REST 端点 `/api/v1/bitable/collections`(CRUD + 调度器管理);(b) 由 agent 通过 BitableTool 触发(依赖 R15a 完成)。ce-plan 阶段二选一。 + +--- + +## Acceptance Criteria (P0) + +每个 P0 R-ID 的可测试验收标准(Given/When/Then),供 ce-plan 估算测试工作量。 + +**R1. 列内联字段配置** +- Given select 字段有 3 选项,When 用户点击列头菜单,Then 重命名/改类型/选项管理均内联完成(不跳右侧抽屉) +- Given 字段名变更提交,When 后端 PATCH /fields/{id},Then 返回 200 + grid 在 1 帧内重渲染新标签 +- Given 字段类型从 text 改为 number,When 现有记录值不可转换,Then 显示警告 + 阻止提交 + +**R2. 记录详情侧边抽屉** +- Given grid 行,When 用户点击行,Then 右侧抽屉展开显示所有字段(含 attachment/formula/lookup) +- Given 抽屉打开且字段为 attachment/image,When 渲染,Then 显示缩略图/预览(非原始 URL) +- Given 抽屉打开且字段为 formula,When 渲染,Then 显示计算结果(只读,不可编辑) +- Given 抽屉中编辑 user-owned 字段并提交,When 后端 upsert,Then agent 列不被覆盖 + +**R3. 视图类型切换与创建** +- Given ViewSwitcher,When 用户点击新建视图,Then 显示 5 种类型选择(grid/kanban/gallery/gantt/form) +- Given kanban/gallery/gantt/form 未实现,When 渲染,Then 以禁用态展示 + 标注"规划中" + tooltip 说明 +- Given 新建视图选 grid,When 创建,Then 调用 POST /views 传 type=grid(不再硬编码) +- Given 禁用态类型,When 点击,Then 不触发创建 + 显示路线图提示 + +**R4. grid 视图内分组与条件格式** +- Given grid 视图,When 用户开启分组,Then 支持最多 3 字段分组(对标飞书/Twenty) +- Given 条件格式规则,When 运算符为 equals/not-equals/contains/is-empty/greater-than/less-than/between,Then 匹配单元格自动着色 +- Given 两条规则匹配同一单元格,When 冲突,Then 首条规则优先(按用户排序) +- Given 着色,When 颜色来源审计,Then 全部来自 design token 调色板(8 色预设,无硬编码 hex) + +**R5. 视觉风格对齐** +- Given 任何 bitable 组件,When 审计 CSS,Then 所有颜色来自 design token(grep 无硬编码 hex) +- Given 字段类型渲染,When 列头图标,Then 9 种(P0)类型各有 Ant Design Outlined icon(Component 类型) +- Given select 选项 chip,When 渲染,Then 对比度 ≥4.5:1(WCAG AA,axe-core 可测试) +- Given chip 配色,When 审计,Then 全部来自 design token 调色板 + +**R15a. BitableTool 动作补全 + 视图删除端点** +- Given BitableTool,When 调用 `create_view`/`update_view`/`update_field`/`delete_view`,Then 4 个新动作全部可用(BitableTool 从 6 扩展到 10 个动作) +- Given 视图列表,When 用户点击删除,Then 调用 DELETE /views/{id}(**后端需新增端点**,前端 api/bitable.ts 需补 `deleteView` 方法——见 Outstanding Questions) +- Given R3/R4 配置变更,When agent 调用 `create_view`/`update_view` 传 `type`/`group_by`/`conditional_formatting`,Then 配置成功写入(与 REST PATCH /views 等价) +- Given 字段配置,When agent 调用 `update_field`,Then 与 PATCH /fields/{id} 等价(agent 不再需要绕过工具直接调 REST) + +**横切验收标准(适用所有 P0 R-ID)** + +可访问性(WCAG AA): +- Given 任何交互元素(按钮/链接/单元格/抽屉触发),When 键盘 Tab 导航,Then 可达且焦点可见(focus-visible 样式) +- Given grid 视图,When 屏幕阅读器遍历,Then 列头/单元格有正确 ARIA role 与 label(`role="grid"`/`role="columnheader"`/`aria-label`) +- Given select/multiselect 编辑器,When 键盘操作,Then 方向键导航 + Enter 选中 + Esc 关闭(不依赖鼠标) +- Given 颜色对比度审计,When axe-core 扫描,Then 所有关键文本 ≥4.5:1、大字号 ≥3:1(WCAG AA) + +空状态(无数据时): +- Given 新建表无字段无记录,When 渲染 grid,Then 显示"暂无字段,点击列头 + 创建"+ "暂无记录"双空状态 +- Given 视图列表为空,When 渲染 ViewSwitcher,Then 显示"暂无视图,新建 grid 视图"提示(非空白) +- Given 抽屉/筛选器/分组无数据,When 渲染,Then 显示对应空状态文案(非空白、非报错) + +--- + +## Test Strategy + +**测试约定**:后端 pytest(asyncio_mode=auto,标记 integration/redis/postgres);前端 vitest(纯函数抽 helpers/,不用 @vue/test-utils);e2e Playwright。 + +**Test File Mapping**(R-ID → 测试文件映射): + +| R-ID | 后端单元测试 | 前端单元测试 | e2e 测试 | 集成标记 | +|---|---|---|---|---| +| R1 | tests/unit/bitable/test_routes.py(PATCH /fields/{id}) | helpers/fieldRenderUtils.ts | bitable-field-ops.spec.ts(extend) | - | +| R2 | tests/unit/bitable/test_service.py(upsert 保留 user 列) | helpers/recordDrawerUtils.ts | new: bitable-record-drawer.spec.ts | - | +| R3 | tests/unit/bitable/test_routes.py(POST /views type 参数) | helpers/viewSwitcherUtils.ts | bitable-view.spec.ts(extend) | - | +| R4 | new: test_grouping.py + test_conditional_formatting.py | helpers/groupingRulesUtils.ts | new: bitable-grouping.spec.ts | - | +| R5 | - | helpers/designTokenAudit.ts(grep token 使用) | bitable-view.spec.ts(visual regression) | - | +| R8 | test_models.py + test_default_fields.py + test_service.py | helpers/fieldTypeUtils.ts | bitable-field-ops.spec.ts(extend) | integration(schema V3 迁移) | +| R15a | test_bitable_tool.py(4 new actions: create_view/update_view/update_field/delete_view) | - | new: bitable-agent-parity.spec.ts | redis(notify_callback) | +| R15b | new: test_nl_to_table.py(agent 编排:建表 + 建字段 + 建视图) | helpers/nlTableUtils.ts | new: bitable-nl-table.spec.ts | redis(agent 编排) | +| R15c | new: test_collections.py(CRUD + 调度器)+ test_collection_security.py(SSRF/凭据) | helpers/collectionSchedulerUtils.ts | new: bitable-collection-ui.spec.ts | postgres(调度持久化)、redis(cron 触发) | + +**Agent 对等测试契约**:每个 P0/P1 R-ID 交付时,附 BitableTool 调用契约测试——验证 agent 能完成等价操作(人类可做的 agent 也能做)。BitableTool 当前 6 actions vs 28 REST 端点,新增端点的 R-ID 需同步补 BitableTool 动作 + 契约测试。 + +**R15c 测试策略补充**:路径 (a) `/collections` 端点需覆盖 (1) CRUD 正常路径;(2) cron 表达式校验与调度触发;(3) SSRF 守护复用 `excel.py` 的 URL 白名单逻辑;(4) 凭据加密存储(建议复用 agentkit 现有 secrets 模块,禁明文);(5) 端点访问控制:JWT 或 `X-Internal-Token`(与 28 个既有端点一致)。路径 (b) 由 agent 通过 BitableTool 触发,复用 R15a 测试契约,无需单独端点测试。 + +--- + +## R8 Field Type Acceptance Matrix + +R8 扩展的 16 字段类型 + 1 双向关联的逐类型验收标准(**覆盖 P1 范围,非 P0**——P0 验收标准见 Acceptance Criteria (P0))。每行映射约 4-6 个单元测试(test_models.py / test_service.py / test_default_fields.py)。 + +**Schema V3 迁移成本估算**:17 个新类型中 2 个需 V2→V3 数据迁移(`user`/`datetime`),1 个需双向关联 schema(无 V2 数据)。预估后端工作量:models.py 扩展 + migration 脚本 + RecalcWorker cache 失效路径 + service 兼容层 ≈ 3-5 个工作日。前端工作量:FieldConfigForm.vue 8 类型 → 24 类型 + SelectCellEditor 扩展 ≈ 4-6 个工作日。回滚策略见 Outstanding Questions。 + +| 类型 | 有效输入 | 无效输入(422) | 所有权 | 公式参与 | V2→V3 迁移 | PII / 安全 | +|---|---|---|---|---|---|---| +| `user` | user_id 字符串 | 非存在 user_id | both | 不支持 | 创建人 `text` → `user` | **PII**:user_id 不直接暴露用户邮箱/手机,但需脱敏日志 | +| `checkbox` | `true`/`false` | 非布尔值 | both | 支持(IF 条件) | - | - | +| `url` | `https://example.com` | 非合法 URL | both | 不支持 | - | - | +| `email` | `a@b.com` | 非合法邮箱 | both | 不支持 | - | **PII**:列表/导出需脱敏选项;agent 写入需脱敏审计 | +| `phone` | `+86-...` 字符串 | 空字符串 OK | both | 不支持 | - | **PII**:同 `email` | +| `auto-number` | 自增整数(后端分配) | 不可手动写(agent 侧写保护:BitableTool `create_record`/`upsert_records` 拒绝传 auto-number 字段) | agent only | 不支持 | - | - | +| `datetime` | ISO 8601 `2026-07-03T12:00:00Z` | 非日期格式 | both | 支持(日期函数) | 创建时间 `date` → `datetime` | - | +| `modified-by` | user_id(自动管理) | 不可手动写(agent 侧同 `auto-number`) | agent only | 不支持 | - | **PII**:同 `user` | +| `location` | `{lat: float, lng: float}` | 非合法坐标 | both | 不支持 | - | - | +| `barcode` | 字符串 | 空字符串 OK | both | 不支持 | - | - | +| `rating` | 1-5 整数 | 超范围 | both | 支持(AVG) | - | - | +| `progress` | 0-100 整数 | 超范围 | both | 支持(AVG) | - | - | +| `currency` | `{amount: number, code: str}` | 负数 OK | both | 支持(SUM) | - | - | +| `rich-text` | HTML 子集(白名单标签/属性,**服务端 sanitize**——禁 ` @@ -292,35 +538,153 @@ defineExpose({ /* KTD10: CSS isolation — all vxe-table style overrides scoped to .bitable-grid-scope. Use :deep() to reach vxe-table internals. */ .bitable-grid-scope :deep(.vxe-table) { - font-size: 13px; + font-size: var(--bitable-font-sm); } .bitable-grid-scope :deep(.vxe-header--column) { - background: var(--bg-secondary, #fafafa); + background: var(--bitable-color-bg-secondary); font-weight: 600; } .bitable-grid-scope :deep(.vxe-body--column.is--dirty) { - background: var(--bg-tertiary, #f3f4f6); + background: var(--bitable-color-bg-tertiary); } .bitable-grid-scope :deep(.vxe-cell--dirty) { - color: var(--color-primary, #1a1a1a); + color: var(--bitable-color-primary); +} + +/* U3: seq column (#) is the row-detail affordance — pointer cursor signals + clickability without a dedicated expand icon. */ +.bitable-grid-scope :deep(.vxe-header--column.col--seq), +.bitable-grid-scope :deep(.vxe-body--column.col--seq) { + cursor: pointer; } .bitable-grid-scope__add-col { display: flex; align-items: center; - gap: 4px; + gap: var(--bitable-spacing-xs); cursor: pointer; - color: var(--text-secondary, #8c8c8c); - font-size: 12px; - padding: 0 8px; + color: var(--bitable-color-text-secondary); + font-size: var(--bitable-font-xs); + padding: 0 var(--bitable-spacing-sm); height: 100%; user-select: none; } .bitable-grid-scope__add-col:hover { - color: var(--color-primary, #1a1a1a); + color: var(--bitable-color-primary); +} + +/* ── U5: Grouping ─────────────────────────────────────────────────────── */ + +.bitable-grid-scope__group-header { + display: flex; + align-items: center; + gap: var(--bitable-spacing-sm); + padding: var(--bitable-spacing-xs) var(--bitable-spacing-sm); + background: var(--bitable-color-bg-secondary); + border-bottom: 1px solid var(--bitable-color-border); + cursor: pointer; + user-select: none; + font-size: var(--bitable-font-sm); + color: var(--bitable-color-text); +} + +.bitable-grid-scope__group-header:hover { + background: var(--bitable-color-bg-tertiary); +} + +.bitable-grid-scope__caret { + display: inline-flex; + width: 16px; + justify-content: center; + color: var(--bitable-color-text-secondary); + font-size: var(--bitable-font-sm); +} + +.bitable-grid-scope__group-key { + font-weight: 600; + color: var(--bitable-color-text); +} + +.bitable-grid-scope__group-count { + color: var(--bitable-color-text-secondary); + font-size: var(--bitable-font-xs); +} + +.bitable-grid-scope__group-agg { + color: var(--bitable-color-text-tertiary); + font-size: var(--bitable-font-xs); + margin-left: var(--bitable-spacing-sm); +} + +.bitable-grid-scope__group-grid { + border-bottom: 1px solid var(--bitable-color-border); +} + +/* ── U5: Conditional Formatting (8 colors + bold) ─────────────────────── */ +/* Row-level classes applied via row-config.className. Group headers are + separate
elements, so they're naturally unaffected by CF. */ + +.bitable-grid-scope :deep(.bitable-cf--red) .vxe-body--column { + background: var(--bitable-cf-red-bg); +} +.bitable-grid-scope :deep(.bitable-cf--red) .vxe-cell { + color: var(--bitable-cf-red-fg); +} + +.bitable-grid-scope :deep(.bitable-cf--orange) .vxe-body--column { + background: var(--bitable-cf-orange-bg); +} +.bitable-grid-scope :deep(.bitable-cf--orange) .vxe-cell { + color: var(--bitable-cf-orange-fg); +} + +.bitable-grid-scope :deep(.bitable-cf--yellow) .vxe-body--column { + background: var(--bitable-cf-yellow-bg); +} +.bitable-grid-scope :deep(.bitable-cf--yellow) .vxe-cell { + color: var(--bitable-cf-yellow-fg); +} + +.bitable-grid-scope :deep(.bitable-cf--green) .vxe-body--column { + background: var(--bitable-cf-green-bg); +} +.bitable-grid-scope :deep(.bitable-cf--green) .vxe-cell { + color: var(--bitable-cf-green-fg); +} + +.bitable-grid-scope :deep(.bitable-cf--blue) .vxe-body--column { + background: var(--bitable-cf-blue-bg); +} +.bitable-grid-scope :deep(.bitable-cf--blue) .vxe-cell { + color: var(--bitable-cf-blue-fg); +} + +.bitable-grid-scope :deep(.bitable-cf--purple) .vxe-body--column { + background: var(--bitable-cf-purple-bg); +} +.bitable-grid-scope :deep(.bitable-cf--purple) .vxe-cell { + color: var(--bitable-cf-purple-fg); +} + +.bitable-grid-scope :deep(.bitable-cf--gray) .vxe-body--column { + background: var(--bitable-cf-gray-bg); +} +.bitable-grid-scope :deep(.bitable-cf--gray) .vxe-cell { + color: var(--bitable-cf-gray-fg); +} + +.bitable-grid-scope :deep(.bitable-cf--neutral) .vxe-body--column { + background: var(--bitable-cf-neutral-bg); +} +.bitable-grid-scope :deep(.bitable-cf--neutral) .vxe-cell { + color: var(--bitable-cf-neutral-fg); +} + +.bitable-grid-scope :deep(.bitable-cf--bold) .vxe-cell { + font-weight: 700; } diff --git a/src/agentkit/server/frontend/src/components/bitable/ColumnHeaderMenu.vue b/src/agentkit/server/frontend/src/components/bitable/ColumnHeaderMenu.vue index 69ef205..bbf5eef 100644 --- a/src/agentkit/server/frontend/src/components/bitable/ColumnHeaderMenu.vue +++ b/src/agentkit/server/frontend/src/components/bitable/ColumnHeaderMenu.vue @@ -1,14 +1,26 @@ @@ -76,7 +98,14 @@ import { } from 'ant-design-vue' import type { IBitableField, IBitableView } from '@/api/bitable' import { useBitableStore } from '@/stores/bitable' +import { useResponsiveBreakpoint } from '@/composables/useResponsiveBreakpoint' import FilterBuilder from './FilterBuilder.vue' +import GroupingEditor from './GroupingEditor.vue' +import ConditionalFormatEditor from './ConditionalFormatEditor.vue' +import type { + GroupByItem, + ConditionalFormatRule, +} from '@/helpers/groupingRulesUtils' const props = defineProps<{ open: boolean @@ -90,6 +119,14 @@ const emit = defineEmits<{ const store = useBitableStore() +// U1 step 7: ViewConfigPanel becomes a bottom drawer on mobile. +// ponytail: only the placement + width differ; the rest of the UI is shared. +// Ceiling: the drawer height on mobile is unbounded — long rule lists may +// scroll past the fold. Acceptable for v1; upgrade path: a max-height + scroll. +const { isMobile } = useResponsiveBreakpoint() +const drawerPlacement = computed(() => (isMobile.value ? 'bottom' : 'right')) +const drawerWidth = computed(() => (isMobile.value ? '100%' : 520)) + const activeTab = ref('filter') const filterRef = ref | null>(null) @@ -110,13 +147,31 @@ const hiddenFieldIds = ref( (props.view?.config?.hidden_fields as string[]) ?? [], ) -// Reset when view changes +// U5: group_by + conditional_formatting local working copies. +const groupByItems = ref(extractGroupBy(props.view)) +const cfRules = ref(extractConditionalFormat(props.view)) + +function extractGroupBy(view: IBitableView | null): GroupByItem[] { + const raw = view?.config?.group_by + if (!Array.isArray(raw)) return [] + return raw as GroupByItem[] +} + +function extractConditionalFormat(view: IBitableView | null): ConditionalFormatRule[] { + const raw = view?.config?.conditional_formatting + if (!Array.isArray(raw)) return [] + return raw as ConditionalFormatRule[] +} + +// Reset when view changes (covers initial load + view-switch reloads) watch( () => props.view?.id, () => { sortFieldId.value = (props.view?.config?.sort as { field?: string })?.field ?? '' sortOrder.value = (props.view?.config?.sort as { order?: string })?.order ?? 'asc' hiddenFieldIds.value = (props.view?.config?.hidden_fields as string[]) ?? [] + groupByItems.value = extractGroupBy(props.view) + cfRules.value = extractConditionalFormat(props.view) }, ) @@ -151,6 +206,19 @@ async function saveHidden(): Promise { } await store.updateView(props.view.id, { config }) } + +// U5: save group_by + conditional_formatting through the new +// updateViewConfig action (which calls the same PATCH /views endpoint; +// the route layer validates the U5 sub-keys and 422s on invalid input). +// Both tabs' save buttons call this — saving either tab persists both U5 +// keys together (matches the existing merge-then-PATCH behavior). +async function saveU5Config(): Promise { + if (!props.view) return + await store.updateViewConfig(props.view.id, { + group_by: groupByItems.value, + conditional_formatting: cfRules.value, + }) +} diff --git a/src/agentkit/server/frontend/src/composables/useResponsiveBreakpoint.ts b/src/agentkit/server/frontend/src/composables/useResponsiveBreakpoint.ts new file mode 100644 index 0000000..931139c --- /dev/null +++ b/src/agentkit/server/frontend/src/composables/useResponsiveBreakpoint.ts @@ -0,0 +1,79 @@ +/** + * useResponsiveBreakpoint — 响应式断点 composable + * + * 断点(移动优先,与 plan U1 Open Questions 一致): + * - isMobile: viewport < 768px + * - isTablet: 768px ≤ viewport < 1024px + * - isDesktop: viewport ≥ 1024px + * - isWide: viewport ≥ 1440px(供宽屏布局可选使用) + * + * 消费点:U3 RecordDetailDrawer(isMobile 时全屏覆盖)、 + * U5 ViewConfigPanel(isMobile 时改底部抽屉)。U1 仅暴露 API。 + * + * 实现:window.matchMedia + change 事件监听,onUnmounted 清理。 + * SSR 安全:未定义 window 时返回全 false 默认值( ponytail: 不引入 + * 专门的 SSR 检测库,matchMedia 不存在即视为非移动端默认布局)。 + */ +import { ref, onMounted, onUnmounted, type Ref } from 'vue' + +export interface ResponsiveBreakpoint { + isMobile: Ref + isTablet: Ref + isDesktop: Ref + isWide: Ref +} + +const MOBILE_MAX = 767 // < 768 +const TABLET_MAX = 1023 // < 1024 +const WIDE_MIN = 1440 // ≥ 1440 + +export function useResponsiveBreakpoint(): ResponsiveBreakpoint { + const isMobile = ref(false) + const isTablet = ref(false) + const isDesktop = ref(false) + const isWide = ref(false) + + // matchMedia 不支持 change 事件的旧浏览器回退(ponytail: 不做 + // resize 节流兜底——现代浏览器均支持 matchMedia change,避免额外 + // 依赖 lodash/throttle;已知 ceiling: IE11 不可用,本项目目标现代浏览器) + const mobileMql: MediaQueryList | null = + typeof window !== 'undefined' && window.matchMedia + ? window.matchMedia(`(max-width: ${MOBILE_MAX}px)`) + : null + const tabletMql: MediaQueryList | null = + typeof window !== 'undefined' && window.matchMedia + ? window.matchMedia(`(min-width: ${MOBILE_MAX + 1}px) and (max-width: ${TABLET_MAX}px)`) + : null + const wideMql: MediaQueryList | null = + typeof window !== 'undefined' && window.matchMedia + ? window.matchMedia(`(min-width: ${WIDE_MIN}px)`) + : null + + function sync(): void { + isMobile.value = mobileMql?.matches ?? false + isTablet.value = tabletMql?.matches ?? false + isWide.value = wideMql?.matches ?? false + // desktop = 既非 mobile 也非 tablet(≥ 1024) + isDesktop.value = !isMobile.value && !isTablet.value + } + + // sync 是稳定的函数引用,3 个 mql 共用同一 handler(每个 mql 独立 add/remove)。 + onMounted(() => { + sync() + mobileMql?.addEventListener('change', sync) + tabletMql?.addEventListener('change', sync) + wideMql?.addEventListener('change', sync) + }) + + onUnmounted(() => { + mobileMql?.removeEventListener('change', sync) + tabletMql?.removeEventListener('change', sync) + wideMql?.removeEventListener('change', sync) + }) + + return { isMobile, isTablet, isDesktop, isWide } +} + +// ponytail self-check: 断点边界互斥性。运行时调用 sync() 后, +// isMobile/isTablet/isDesktop 三者有且仅有一个为 true(基于互斥的 +// matchMedia 查询)。trivial 互斥逻辑,不另立测试文件。 diff --git a/src/agentkit/server/frontend/src/helpers/fieldRenderUtils.ts b/src/agentkit/server/frontend/src/helpers/fieldRenderUtils.ts new file mode 100644 index 0000000..e6f50a8 --- /dev/null +++ b/src/agentkit/server/frontend/src/helpers/fieldRenderUtils.ts @@ -0,0 +1,150 @@ +/** + * Bitable field render / type-conversion utilities (U2). + * + * Pure functions — no Vue, no store, no I/O. Safe to unit-test in isolation. + * + * `checkTypeCompatibility` implements the conversion matrix from the U2 spec: + * - text <-> select/multiselect: compatible (values double as options) + * - number -> text: compatible + * - text -> number: compatible only if every non-empty value parses as a number + * - date -> text: compatible (ISO string) + * - text -> date: compatible only if every non-empty value is ISO-date-shaped + * - attachment/image/formula/lookup -> anything: NOT compatible (structured data) + * - anything -> attachment/image/formula/lookup: NOT compatible (can't synthesize) + * - select <-> multiselect: compatible (shared option list, single value <-> [single]) + * + * ponytail: unlisted pairs are blocked conservatively. Ceiling: a few safe pairs + * (e.g. number -> select) are also blocked to keep the matrix small and explicit; + * upgrade path = extend COMPATIBLE_PAIRS / add a guard below with a test. + */ + +import type { FieldType } from '@/api/bitable' + +export interface CompatibilityResult { + compatible: boolean + reason?: string +} + +// ISO-8601 date / date-time. Tolerant: date-only, with/without time + TZ. +const ISO_DATE_RE = + /^\d{4}-\d{2}-\d{2}(T\d{2}:\d{2}(:\d{2}(\.\d+)?)?(Z|[+-]\d{2}:?\d{2})?)?$/ + +const STRUCTURED_OR_CALC: ReadonlySet = new Set([ + 'attachment', + 'image', + 'formula', + 'lookup', +]) + +function isEmpty(v: unknown): boolean { + return v == null || v === '' +} + +function isNumeric(v: unknown): boolean { + if (typeof v === 'number') return Number.isFinite(v) + if (typeof v === 'string') { + const s = v.trim() + if (s === '') return false + // ponytail: Number() accepts hex ('0xFF') and ('' ) — guard explicitly. + if (s.startsWith('0x') || s.startsWith('0X')) return false + const n = Number(s) + return !Number.isNaN(n) && Number.isFinite(n) + } + return false +} + +function isISODate(v: unknown): boolean { + if (typeof v !== 'string') return false + const s = v.trim() + if (!ISO_DATE_RE.test(s)) return false + const t = Date.parse(s) + return !Number.isNaN(t) +} + +/** + * Check whether existing field values can be carried over when the field's type + * changes from `oldType` to `newType`. + * + * @param existingValues raw values collected from records for this field + * (may include nulls / empty strings; they are ignored). + */ +export function checkTypeCompatibility( + oldType: FieldType, + newType: FieldType, + existingValues: unknown[], +): CompatibilityResult { + if (oldType === newType) return { compatible: true } + + // Structured / calculated source types cannot be converted away. + if (STRUCTURED_OR_CALC.has(oldType)) { + return { + compatible: false, + reason: `${oldType} 字段不支持类型转换(数据结构差异较大)`, + } + } + // Cannot synthesize structured / calculated target types from plain values. + if (STRUCTURED_OR_CALC.has(newType)) { + return { + compatible: false, + reason: `不支持转换到 ${newType} 字段(无法由现有值构造)`, + } + } + + const nonEmpty = existingValues.filter((v) => !isEmpty(v)) + + // text <-> select / multiselect + if ( + (oldType === 'text' && (newType === 'select' || newType === 'multiselect')) || + ((oldType === 'select' || oldType === 'multiselect') && newType === 'text') + ) { + return { compatible: true } + } + + // select <-> multiselect (shared option list) + if ( + (oldType === 'select' && newType === 'multiselect') || + (oldType === 'multiselect' && newType === 'select') + ) { + return { compatible: true } + } + + // number -> text + if (oldType === 'number' && newType === 'text') { + return { compatible: true } + } + + // text -> number: all non-empty values must parse as numbers + if (oldType === 'text' && newType === 'number') { + if (!nonEmpty.every(isNumeric)) { + return { + compatible: false, + reason: '存在非数字文本值,无法转换为数字类型', + } + } + return { compatible: true } + } + + // date -> text (ISO string) + if (oldType === 'date' && newType === 'text') { + return { compatible: true } + } + + // text -> date: all non-empty values must be ISO dates + if (oldType === 'text' && newType === 'date') { + if (!nonEmpty.every(isISODate)) { + return { + compatible: false, + reason: '存在非 ISO 日期格式的文本值,无法转换为日期类型', + } + } + return { compatible: true } + } + + // number -> date / date -> number: ambiguous (epoch vs calendar) — block. + // select -> number / number -> select already covered where applicable above; + // any unlisted pair falls through to the conservative block below. + return { + compatible: false, + reason: `不支持从 ${oldType} 转换到 ${newType}`, + } +} diff --git a/src/agentkit/server/frontend/src/helpers/groupingRulesUtils.ts b/src/agentkit/server/frontend/src/helpers/groupingRulesUtils.ts new file mode 100644 index 0000000..c459118 --- /dev/null +++ b/src/agentkit/server/frontend/src/helpers/groupingRulesUtils.ts @@ -0,0 +1,568 @@ +/** + * Bitable grouping + conditional formatting utilities (U5 / R4). + * + * Pure functions — no Vue, no store, no I/O. Safe to unit-test in isolation. + * + * Three concerns: + * 1. Conditional format rule matching (7 operators, first-match-wins) + * 2. Grouping tree construction (nested levels + number-field aggregation) + * 3. Color key → CSS var mapping (8 colors → --bitable-cf--{bg,fg}) + * + * The 8-color palette + token names are defined in bitable-tokens.css. + * Adding a color requires a token there + a `ColorKey` entry here + a + * `ColorKey` Literal in `agentkit.bitable.view_config` (Python). + */ + +import type { IBitableField, IBitableRecord } from '@/api/bitable' + +// --------------------------------------------------------------------------- +// Types — mirror src/agentkit/bitable/view_config.py (kept in sync by tests) +// --------------------------------------------------------------------------- + +export type GroupDirection = 'asc' | 'desc' + +export type ConditionalOperator = + | 'equals' + | 'not-equals' + | 'contains' + | 'is-empty' + | 'greater-than' + | 'less-than' + | 'between' + +export type ColorKey = + | 'red' + | 'orange' + | 'yellow' + | 'green' + | 'blue' + | 'purple' + | 'gray' + | 'neutral' + +export interface GroupByItem { + field_id: string + direction: GroupDirection +} + +export interface ConditionalFormatRule { + field_id: string + operator: ConditionalOperator + value: string + color_key: ColorKey + /** Default true for WCAG 1.4.1 — color alone is not enough. */ + bold: boolean + /** Toggle rule without deleting it. Disabled rules never match. */ + enabled: boolean +} + +/** + * View config sub-shape consumed by U5. The full View.config is + * `Record` (other keys: filters / sort / hidden_fields); + * U5 only owns `group_by` and `conditional_formatting`. + */ +export interface ViewConfigU5 { + group_by?: GroupByItem[] + conditional_formatting?: ConditionalFormatRule[] +} + +/** + * Aggregated values for a group's number-type fields. + * `sum` / `avg` are omitted (undefined) for non-number fields or empty groups. + */ +export interface GroupAggregation { + count: number + sum?: number + avg?: number +} + +/** + * A node in the grouping tree. Root-level nodes correspond to distinct + * values of the first group_by field; their `children` correspond to the + * second group_by field, and so on. Leaf nodes (depth === group_by.length) + * hold the actual records. + */ +export interface GroupNode { + /** Field value that defines this group (e.g. "已完成"). Empty string for null/undefined values. */ + key: string + /** Field id of the group_by level this node represents. */ + fieldId: string + /** Sort direction of this level (for label display / future sort-aware rendering). */ + direction: GroupDirection + /** Depth in the tree (0 = root level). */ + depth: number + /** Records belonging to this group (leaf-level) or all descendant records (intermediate). */ + records: IBitableRecord[] + /** Child groups for the next group_by level. Empty for leaf nodes. */ + children: GroupNode[] + /** Number-field aggregations for this group's records (SUM/AVG). Keyed by field id. */ + aggregations: Record +} + +// --------------------------------------------------------------------------- +// Constants +// --------------------------------------------------------------------------- + +/** Max 3 group_by levels (matches Feishu / Twenty UX + backend MAX_GROUP_BY_FIELDS). */ +export const MAX_GROUP_BY_FIELDS = 3 + +/** Color key → CSS var name. Background variant (paired with dark text). */ +const COLOR_KEY_TO_BG_VAR: Record = { + red: 'var(--bitable-cf-red-bg)', + orange: 'var(--bitable-cf-orange-bg)', + yellow: 'var(--bitable-cf-yellow-bg)', + green: 'var(--bitable-cf-green-bg)', + blue: 'var(--bitable-cf-blue-bg)', + purple: 'var(--bitable-cf-purple-bg)', + gray: 'var(--bitable-cf-gray-bg)', + neutral: 'var(--bitable-cf-neutral-bg)', +} + +/** Color key → CSS var name. Foreground variant (text/border on light bg). */ +const COLOR_KEY_TO_FG_VAR: Record = { + red: 'var(--bitable-cf-red-fg)', + orange: 'var(--bitable-cf-orange-fg)', + yellow: 'var(--bitable-cf-yellow-fg)', + green: 'var(--bitable-cf-green-fg)', + blue: 'var(--bitable-cf-blue-fg)', + purple: 'var(--bitable-cf-purple-fg)', + gray: 'var(--bitable-cf-gray-fg)', + neutral: 'var(--bitable-cf-neutral-fg)', +} + +/** All 8 color keys — used by the editor to render swatches. */ +export const ALL_COLOR_KEYS: ColorKey[] = [ + 'red', + 'orange', + 'yellow', + 'green', + 'blue', + 'purple', + 'gray', + 'neutral', +] + +/** All 7 operators — used by the editor to populate the operator dropdown. */ +export const ALL_OPERATORS: ConditionalOperator[] = [ + 'equals', + 'not-equals', + 'contains', + 'is-empty', + 'greater-than', + 'less-than', + 'between', +] + +/** Operator display labels (Chinese). */ +export const OPERATOR_LABELS: Record = { + equals: '等于', + 'not-equals': '不等于', + contains: '包含', + 'is-empty': '为空', + 'greater-than': '大于', + 'less-than': '小于', + between: '介于', +} + +/** Color display labels (Chinese). */ +export const COLOR_KEY_LABELS: Record = { + red: '红色', + orange: '橙色', + yellow: '黄色', + green: '绿色', + blue: '蓝色', + purple: '紫色', + gray: '灰色', + neutral: '中性', +} + +// --------------------------------------------------------------------------- +// Conditional format rule matching +// --------------------------------------------------------------------------- + +/** + * Map a color key to its CSS variable for cell background. + * + * Returns the empty string for unknown keys (defensive — the validator + * rejects unknown keys at the API, but a stale local config might still + * hold a retired key. Empty string falls back to default cell bg.) + */ +export function colorKeyToBgCssVar(colorKey: string): string { + return COLOR_KEY_TO_BG_VAR[colorKey as ColorKey] ?? '' +} + +/** Map a color key to its CSS variable for text/border (foreground variant). */ +export function colorKeyToFgCssVar(colorKey: string): string { + return COLOR_KEY_TO_FG_VAR[colorKey as ColorKey] ?? '' +} + +/** + * Compare a cell value against a single rule. + * + * Type coercion: + * - numbers (field_type === 'number' or runtime number) compared numerically + * for greater-than / less-than / between + * - everything else compared as strings + * - `is-empty` matches null / undefined / '' / [] (empty array for multiselect) + * + * `between` parses `value` as "min,max" (comma-separated). Whitespace tolerated. + */ +export function matchConditionalFormatRule( + value: unknown, + rule: ConditionalFormatRule, +): boolean { + if (!rule.enabled) return false + + switch (rule.operator) { + case 'is-empty': + return isEmptyValue(value) + + case 'equals': + return stringifyValue(value) === rule.value + + case 'not-equals': + return stringifyValue(value) !== rule.value + + case 'contains': + return stringifyValue(value).includes(rule.value) + + case 'greater-than': + return compareNumeric(value, rule.value) > 0 + + case 'less-than': + return compareNumeric(value, rule.value) < 0 + + case 'between': { + const [minStr, maxStr] = parseBetweenRange(rule.value) + if (minStr == null || maxStr == null) return false + const min = Number(minStr) + const max = Number(maxStr) + const v = toNumber(value) + if (v == null || !Number.isFinite(v)) return false + return v >= min && v <= max + } + + default: + return false + } +} + +/** + * Find the first matching rule for a cell value. + * + * Rules are evaluated in array order — first match wins (matches the + * spec's "首条匹配 wins" contract and the user's reordering intent). + * + * Returns null if no rule matches or the rule list is empty. + */ +export function findFirstMatchingRule( + value: unknown, + rules: ConditionalFormatRule[] | undefined, +): ConditionalFormatRule | null { + return rules?.find((rule) => matchConditionalFormatRule(value, rule)) ?? null +} + +// --------------------------------------------------------------------------- +// Grouping tree construction +// --------------------------------------------------------------------------- + +/** + * Build the nested grouping tree from records. + * + * - Records are bucketed by their value of the first group_by field, + * then recursively by each subsequent field. + * - Direction (asc/desc) is recorded on each node for label rendering; + * node ordering within a level follows direction (asc: empty-key last). + * - Leaf nodes (depth === group_by.length) hold the actual records. + * - Aggregations are computed for every node (count + sum/avg for number fields). + * + * Returns an empty array if group_by is empty or records is empty. + */ +export function computeGroupingLevels( + records: IBitableRecord[], + groupBy: GroupByItem[] | undefined, + fields: IBitableField[] = [], +): GroupNode[] { + if (!groupBy || groupBy.length === 0 || records.length === 0) return [] + + const numberFieldIds = new Set( + fields.filter((f) => f.field_type === 'number').map((f) => f.id), + ) + + return buildGroupLevel(records, groupBy, 0, numberFieldIds) +} + +function buildGroupLevel( + records: IBitableRecord[], + groupBy: GroupByItem[], + depth: number, + numberFieldIds: Set, +): GroupNode[] { + const item = groupBy[depth] + if (!item) { + // Should not happen — leaf level is depth === groupBy.length, handled below. + return [] + } + + // Bucket records by their value of this field. + const buckets = new Map() + for (const rec of records) { + const key = stringifyValue(rec.values[item.field_id]) + const existing = buckets.get(key) + if (existing) { + existing.push(rec) + } else { + buckets.set(key, [rec]) + } + } + + // Build nodes, applying direction sort. + const keys = Array.from(buckets.keys()) + sortGroupKeys(keys, item.direction) + + return keys.map((key) => { + const groupRecords = buckets.get(key)! + const isLeaf = depth + 1 === groupBy.length + const children = isLeaf + ? [] + : buildGroupLevel(groupRecords, groupBy, depth + 1, numberFieldIds) + return { + key, + fieldId: item.field_id, + direction: item.direction, + depth, + records: groupRecords, + children, + aggregations: aggregateGroup(groupRecords, numberFieldIds), + } + }) +} + +/** + * Sort group keys within a level by direction. + * + * Ascending: numeric values first (sorted numerically), then strings + * (sorted lexicographically), empty string last. + * Descending: reverse of the above (empty string still last — empty is + * always last regardless of direction, matches Feishu UX). + */ +function sortGroupKeys(keys: string[], direction: GroupDirection): void { + const numericKeys: { value: number; key: string }[] = [] + const stringKeys: string[] = [] + for (const k of keys) { + if (k === '') continue + const n = Number(k) + // Number.isFinite excludes NaN/Infinity; '' already skipped above. + if (Number.isFinite(n)) { + numericKeys.push({ value: n, key: k }) + } else { + stringKeys.push(k) + } + } + numericKeys.sort((a, b) => a.value - b.value) + stringKeys.sort((a, b) => a.localeCompare(b)) + + const ordered = [ + ...numericKeys.map((n) => n.key), + ...stringKeys, + ] + // Empty-key group is always last (regardless of direction). + if (keys.includes('')) ordered.push('') + + if (direction === 'desc') { + // Reverse non-empty keys, keep empty at the end. + const emptyTail = ordered[ordered.length - 1] === '' ? ordered.pop() : undefined + ordered.reverse() + if (emptyTail !== undefined) ordered.push(emptyTail) + } + + // Mutate in place (caller passes the keys array). + keys.length = 0 + keys.push(...ordered) +} + +/** + * Compute count + sum/avg for every number field in the group. + * + * Sum/avg are computed only for number-type fields (per the spec). Other + * field types only contribute to count. Null/undefined values are skipped + * in the numeric aggregation but still counted in the record count. + */ +export function aggregateGroup( + records: IBitableRecord[], + numberFieldIds: Set, +): Record { + const result: Record = {} + if (records.length === 0) return result + + for (const fieldId of numberFieldIds) { + let sum = 0 + let count = 0 + for (const rec of records) { + const v = toNumber(rec.values[fieldId]) + if (v != null && Number.isFinite(v)) { + sum += v + count++ + } + } + if (count > 0) { + result[fieldId] = { + count: records.length, + sum, + avg: sum / count, + } + } else { + result[fieldId] = { count: records.length } + } + } + return result +} + +// --------------------------------------------------------------------------- +// Config feature flags +// --------------------------------------------------------------------------- + +export function isGroupingEnabled(config: ViewConfigU5 | undefined): boolean { + return !!config && Array.isArray(config.group_by) && config.group_by.length > 0 +} + +export function isConditionalFormattingEnabled( + config: ViewConfigU5 | undefined, +): boolean { + return ( + !!config && + Array.isArray(config.conditional_formatting) && + config.conditional_formatting.length > 0 + ) +} + +// --------------------------------------------------------------------------- +// Coercion helpers (internal) +// --------------------------------------------------------------------------- + +function isEmptyValue(v: unknown): boolean { + if (v == null) return true + if (typeof v === 'string') return v === '' + if (Array.isArray(v)) return v.length === 0 + return false +} + +function stringifyValue(v: unknown): string { + if (v == null) return '' + if (typeof v === 'string') return v + if (typeof v === 'number' || typeof v === 'boolean') return String(v) + if (Array.isArray(v)) return v.map(stringifyValue).join(', ') + // ponytail: objects (e.g. attachment metadata) — JSON-stringify for equals/contains. + // Ceiling: deep object equality is not supported; users should use is-empty for objects. + try { + return JSON.stringify(v) + } catch { + return String(v) + } +} + +function toNumber(v: unknown): number | null { + if (typeof v === 'number') return Number.isFinite(v) ? v : null + if (typeof v === 'string') { + const trimmed = v.trim() + if (trimmed === '') return null + const n = Number(trimmed) + return !Number.isNaN(n) && Number.isFinite(n) ? n : null + } + return null +} + +function compareNumeric(value: unknown, ruleValue: string): number { + const a = toNumber(value) + const b = toNumber(ruleValue) + if (a == null || b == null) return 0 // non-numeric → no match for gt/lt + return a - b +} + +/** + * Parse a "min,max" range string for the `between` operator. + * + * Tolerates whitespace around the comma. Returns [null, null] if the + * format is wrong or either side is non-numeric. + */ +function parseBetweenRange(value: string): [string | null, string | null] { + const parts = value.split(',') + if (parts.length !== 2) return [null, null] + const minStr = parts[0].trim() + const maxStr = parts[1].trim() + if (minStr === '' || maxStr === '') return [null, null] + if (Number.isNaN(Number(minStr)) || Number.isNaN(Number(maxStr))) return [null, null] + return [minStr, maxStr] +} + +// --------------------------------------------------------------------------- +// ponytail self-check: run a smoke check that the matcher + grouper work on +// a tiny fixture. Loaded eagerly on import in dev builds to catch logic +// regressions early. Trivial logic — no framework, no fixtures. +// --------------------------------------------------------------------------- + +function _selfCheck(): void { + // Conditional format — equals matches + const r1 = matchConditionalFormatRule('done', { + field_id: 'f1', + operator: 'equals', + value: 'done', + color_key: 'green', + bold: true, + enabled: true, + }) + if (!r1) throw new Error('groupingRulesUtils self-check: equals failed') + + // First-match-wins + const rules: ConditionalFormatRule[] = [ + { + field_id: 'f1', + operator: 'equals', + value: 'done', + color_key: 'green', + bold: true, + enabled: true, + }, + { + field_id: 'f1', + operator: 'equals', + value: 'done', + color_key: 'red', + bold: true, + enabled: true, + }, + ] + const first = findFirstMatchingRule('done', rules) + if (first?.color_key !== 'green') { + throw new Error('groupingRulesUtils self-check: first-match-wins failed') + } + + // Disabled rule never matches + const disabledMatch = matchConditionalFormatRule('done', { + field_id: 'f1', + operator: 'equals', + value: 'done', + color_key: 'green', + bold: true, + enabled: false, + }) + if (disabledMatch) { + throw new Error('groupingRulesUtils self-check: disabled rule matched') + } + + // Color var mapping + if (colorKeyToBgCssVar('red') !== 'var(--bitable-cf-red-bg)') { + throw new Error('groupingRulesUtils self-check: colorKeyToBgCssVar failed') + } +} + +// Run the self-check once on module load. Wrapped in try/catch so a +// production failure doesn't crash the whole app — the error is logged. +if (typeof window !== 'undefined') { + try { + _selfCheck() + } catch (e) { + // eslint-disable-next-line no-console + console.error('[groupingRulesUtils] self-check failed:', e) + } +} diff --git a/src/agentkit/server/frontend/src/helpers/recordDrawerUtils.ts b/src/agentkit/server/frontend/src/helpers/recordDrawerUtils.ts new file mode 100644 index 0000000..9652793 --- /dev/null +++ b/src/agentkit/server/frontend/src/helpers/recordDrawerUtils.ts @@ -0,0 +1,169 @@ +/** + * RecordDetailDrawer pure helpers (U3 / R2). + * + * Pure functions — no Vue, no store, no I/O. Safe to unit-test in isolation. + * + * Responsibilities: + * - formatFieldValue: text/number/date/select/multiselect/formula/lookup → display string + * - getAttachmentFileList / getImageThumbnailList: normalize attachment/image + * field values (which may be IAttachmentMeta[] or stale legacy shapes) into + * a uniform {name, url} / {url, alt} list for the drawer. + * - getRecordTitle: first text-type field value, fallback "未命名记录". + * - isFieldEditable: user-owned fields are editable; agent-owned + formula + + * lookup are read-only (formula/lookup are computed, not user data). + * - getDrawerWidth: '100vw' on mobile, else 480px (≤10 fields) / 640px (>10). + * + * ponytail: width returns a CSS var() reference, not a hardcoded px value, so + * the design token in bitable-tokens.css stays the single source of truth. + * Ceiling: the field-count threshold (10) is hardcoded per KTD4 spec; if the + * token name or threshold changes, update both here and the spec. + */ + +import type { FieldType, IAttachmentMeta, IBitableField, IBitableRecord } from '@/api/bitable' + +const DRAWER_FIELD_COUNT_THRESHOLD = 10 + +export interface IAttachmentFileView { + name: string + url: string + size?: number +} + +export interface IImageView { + url: string + alt?: string +} + +function isEmptyValue(v: unknown): boolean { + return v == null || v === '' || (Array.isArray(v) && v.length === 0) +} + +/** + * Format a field value for read-only display in the drawer. + * + * - text/number/formula/lookup: coerce to string, empty → '—' + * - date: pass through (ISO string); the drawer may format further, but we + * return the raw stored value so callers can apply a consistent formatter. + * ponytail: no dayjs dependency here — the drawer renders the ISO string + * directly to avoid pulling a date lib into a pure helper. Upgrade path: + * if a shared date formatter exists, call it from the drawer component. + * - select/multiselect: return label joined by ' / '; empty → '—' + * - attachment/image: return `${n} 个文件` summary (the drawer renders the + * file list separately via getAttachmentFileList / getImageThumbnailList) + */ +export function formatFieldValue( + value: unknown, + fieldType: FieldType, + options?: { labelOf?: (v: string) => string }, +): string { + if (isEmptyValue(value)) return '—' + + switch (fieldType) { + case 'text': + case 'number': + case 'formula': + case 'lookup': + return String(value) + case 'date': + return typeof value === 'string' ? value : String(value) + case 'select': { + const v = value as string + return options?.labelOf ? options.labelOf(v) : v + } + case 'multiselect': { + const arr = value as string[] + const labels = arr.map((v) => (options?.labelOf ? options.labelOf(v) : v)) + return labels.join(' / ') + } + case 'attachment': + case 'image': { + const arr = value as IAttachmentMeta[] + return `${arr.length} 个文件` + } + default: + return String(value) + } +} + +/** + * Normalize an attachment field value into a uniform file view list. + * + * Accepts IAttachmentMeta[] (the canonical shape). Defensively tolerates + * legacy / malformed entries (missing url → skipped, missing filename → + * fallback to stored_name or '未命名文件'). + */ +export function getAttachmentFileList(value: unknown): IAttachmentFileView[] { + if (!Array.isArray(value)) return [] + const out: IAttachmentFileView[] = [] + for (const raw of value) { + if (!raw || typeof raw !== 'object') continue + const m = raw as Partial + if (!m.url) continue + out.push({ + name: m.filename || m.stored_name || '未命名文件', + url: m.url, + size: typeof m.size === 'number' ? m.size : undefined, + }) + } + return out +} + +/** + * Normalize an image field value into a uniform thumbnail view list. + * + * Reuses getAttachmentFileList logic; image thumbnails use the same + * IAttachmentMeta shape (url + filename). `alt` falls back to filename. + */ +export function getImageThumbnailList(value: unknown): IImageView[] { + return getAttachmentFileList(value).map((f) => ({ + url: f.url, + alt: f.name, + })) +} + +/** + * Return the record's title — the value of the first text-type field, or + * "未命名记录" if none exists / value is empty. + */ +export function getRecordTitle(record: IBitableRecord, fields: IBitableField[]): string { + const firstText = fields.find((f) => f.field_type === 'text') + if (!firstText) return '未命名记录' + const v = record.values[firstText.id] + if (isEmptyValue(v)) return '未命名记录' + return String(v) +} + +/** + * A field is editable in the drawer iff: + * - owner === 'user' (agent-owned columns are managed by the backend / agents) + * - field_type is NOT formula (computed) or lookup (derived from another table) + * + * attachment/image are NOT inline-editable from the drawer in P0 (file upload + * flow is out of scope for U3 — ponytail: ceiling noted, upgrade path = add + * an upload control in a follow-up U-ID). + */ +export function isFieldEditable(field: IBitableField): boolean { + if (field.owner !== 'user') return false + if (field.field_type === 'formula' || field.field_type === 'lookup') return false + if (field.field_type === 'attachment' || field.field_type === 'image') return false + return true +} + +/** + * Compute the drawer width. + * + * @param fieldCount number of fields rendered in the body + * @param isMobile from useResponsiveBreakpoint — full-screen overlay when true + * @returns a CSS value string. On desktop/tablet, returns a var() reference + * to --bitable-drawer-width (≤10 fields) or --bitable-drawer-width-wide + * (>10 fields). On mobile, returns '100vw'. + */ +export function getDrawerWidth(fieldCount: number, isMobile: boolean): string { + if (isMobile) return '100vw' + return fieldCount > DRAWER_FIELD_COUNT_THRESHOLD + ? 'var(--bitable-drawer-width-wide)' + : 'var(--bitable-drawer-width)' +} + +// ponytail self-check: width threshold + var() reference. The two var() names +// must match bitable-tokens.css exactly. Trivial string logic, no test file. diff --git a/src/agentkit/server/frontend/src/helpers/viewSwitcherUtils.ts b/src/agentkit/server/frontend/src/helpers/viewSwitcherUtils.ts new file mode 100644 index 0000000..c0f720a --- /dev/null +++ b/src/agentkit/server/frontend/src/helpers/viewSwitcherUtils.ts @@ -0,0 +1,65 @@ +/** + * View type metadata for the ViewSwitcher dropdown (U4 / R3). + * + * Pure data + lookup — no Vue, no store, no I/O. Safe to unit-test in + * isolation. + * + * v1 ships only `grid`; the other four types (kanban/gallery/gantt/form) are + * rendered as disabled menu items with a "规划中" tooltip so users can see the + * roadmap without hitting a dead-end click. The "规划中" string is hardcoded + * per spec (no i18n lookup, no config) — it is a temporary placeholder that + * gets replaced per-type when each view ships. + * + * ponytail: icon is a Vue component ref (Ant Design icon), typed as `Component` + * so the dropdown can render it via ``. Ceiling: + * the list is static; adding a new view type means appending here + flipping + * `disabled` to false when implemented. + */ + +import type { Component } from 'vue' +import { + TableOutlined, + AppstoreOutlined, + PictureOutlined, + BarChartOutlined, + FormOutlined, +} from '@ant-design/icons-vue' +import type { ViewType } from '@/api/bitable' + +export interface ViewTypeMeta { + /** Backend view_type discriminator (matches ViewType union + ViewType enum). */ + viewType: ViewType + /** Chinese label shown in the dropdown. */ + label: string + /** Ant Design icon component rendered before the label. */ + icon: Component + /** Disabled = not yet implemented in v1; shown greyed-out with tooltip. */ + disabled: boolean + /** Tooltip shown on hover for disabled items (hardcoded "规划中"). */ + tooltip?: string +} + +/** + * Ordered view type list for the "新建视图" dropdown. + * + * Order matches the U4 spec: grid (enabled) → kanban / gallery / gantt / form + * (disabled, "规划中"). Only `grid` is creatable in v1. + */ +export const VIEW_TYPE_LIST: ViewTypeMeta[] = [ + { viewType: 'grid', label: '表格', icon: TableOutlined, disabled: false }, + { viewType: 'kanban', label: '看板', icon: AppstoreOutlined, disabled: true, tooltip: '规划中' }, + { viewType: 'gallery', label: '画廊', icon: PictureOutlined, disabled: true, tooltip: '规划中' }, + { viewType: 'gantt', label: '甘特', icon: BarChartOutlined, disabled: true, tooltip: '规划中' }, + { viewType: 'form', label: '表单', icon: FormOutlined, disabled: true, tooltip: '规划中' }, +] + +/** + * Look up the metadata for a given view type. + * + * Falls back to `grid` (the only enabled v1 type) for unknown values — this + * keeps the UI robust against a stale/forward-incompatible view_type returned + * by the backend after an upgrade. + */ +export function getViewTypeMeta(viewType: ViewType): ViewTypeMeta { + return VIEW_TYPE_LIST.find((m) => m.viewType === viewType) ?? VIEW_TYPE_LIST[0] +} diff --git a/src/agentkit/server/frontend/src/main.ts b/src/agentkit/server/frontend/src/main.ts index 36f3be5..f1ce076 100644 --- a/src/agentkit/server/frontend/src/main.ts +++ b/src/agentkit/server/frontend/src/main.ts @@ -2,6 +2,7 @@ import { createApp } from 'vue' import { createPinia } from 'pinia' import 'ant-design-vue/dist/reset.css' import './styles' +import './styles/bitable-tokens.css' import App from './App.vue' import router from './router' import { useAuthStore } from './stores/auth' diff --git a/src/agentkit/server/frontend/src/stores/bitable.ts b/src/agentkit/server/frontend/src/stores/bitable.ts index 1f40ef2..df9331e 100644 --- a/src/agentkit/server/frontend/src/stores/bitable.ts +++ b/src/agentkit/server/frontend/src/stores/bitable.ts @@ -36,6 +36,16 @@ export const useBitableStore = defineStore('bitable', () => { const nextCursor = ref(null) const recalcPendingCount = ref(0) + // U3: RecordDetailDrawer state. `currentRecordId` is the drawer's open + // signal — non-null means the drawer is open. `currentRecord` is the + // fully-hydrated record fetched by record_id (so the drawer sees agent + // columns even if the grid view hides them). + const currentRecordId = ref(null) + const currentRecord = ref(null) + const recordDetailLoading = ref(false) + const recordDetailError = ref(null) + const recordDetailNotFound = ref(false) + // Polling timer for formula recalc status let _pollTimer: ReturnType | null = null const POLL_INTERVAL = 2000 // 2s per plan @@ -317,10 +327,10 @@ export const useBitableStore = defineStore('bitable', () => { } } - /** Update an existing field */ + /** Update an existing field (U2: field_type added for inline type change) */ async function updateField( fieldId: string, - data: { name?: string; config?: Record }, + data: { name?: string; field_type?: FieldType; config?: Record }, ): Promise { try { const resp = await bitableApi.updateField(fieldId, data) @@ -391,6 +401,130 @@ export const useBitableStore = defineStore('bitable', () => { } } + // --- U3: Record detail drawer --- + + /** + * Open the record detail drawer for a given record id. + * + * Sets the drawer open signal and triggers an async fetch of the full + * record (by record_id) so the drawer sees all fields including agent- + * owned columns that may be hidden in the grid view. + * + * ponytail: fetch is fire-and-forget — `currentRecord` is null until the + * request resolves, which the drawer renders as a skeleton (LoadingState). + * Ceiling: no debounce; rapid row-clicks fire one fetch per click. The + * drawer's watch on `currentRecordId` short-circuits stale fetches by + * checking the id still matches when the response arrives. + */ + function openRecordDetail(recordId: string): void { + if (currentRecordId.value === recordId) return + currentRecordId.value = recordId + currentRecord.value = null + recordDetailLoading.value = true + recordDetailError.value = null + recordDetailNotFound.value = false + void fetchRecordDetail(recordId) + } + + /** Close the drawer and clear all drawer state. */ + function closeRecordDetail(): void { + currentRecordId.value = null + currentRecord.value = null + recordDetailLoading.value = false + recordDetailError.value = null + recordDetailNotFound.value = false + } + + /** + * Fetch a single record by id. + * + * The bitable list endpoint doesn't expose a single-record GET, so this + * reuses `listRecords` and searches the result by id. 404 / empty result → + * notFound state; network error → error state (drawer shows ErrorState + + * retry). + * + * ponytail: fast path — the record is in the currently-loaded page + * (`records.value`), no network round-trip. Slow path — re-query the first + * 100 records and search. Ceiling: a record on a later page (cursor > 100) + * would 404-false. Acceptable for P0 because the drawer is opened from a + * visible grid row, so the record is always in the loaded page. Upgrade + * path: add GET /records/{id} when cross-page detail access is needed. + */ + async function fetchRecordDetail(recordId: string): Promise { + if (!currentTable.value) { + recordDetailLoading.value = false + recordDetailError.value = '未选择数据表' + return + } + recordDetailLoading.value = true + recordDetailError.value = null + recordDetailNotFound.value = false + try { + let found: IBitableRecord | null = null + // Fast path: record is in the currently-loaded page. + const local = records.value.find((r) => r.id === recordId) + if (local) { + found = local + } else if (currentTable.value) { + // Slow path: re-query the first 100 records and search by id. + // (See function docstring — ceiling noted.) + const resp = await bitableApi.listRecords(currentTable.value.id, { limit: 100 }) + found = (resp.records || []).find((r) => r.id === recordId) ?? null + } + + // Stale-response guard: if the user clicked another row while this + // fetch was in flight, drop the result. + if (currentRecordId.value !== recordId) return + + if (!found) { + recordDetailNotFound.value = true + } else { + currentRecord.value = found + } + } catch (err) { + if (currentRecordId.value !== recordId) return + recordDetailError.value = err instanceof Error ? err.message : '加载记录详情失败' + } finally { + if (currentRecordId.value === recordId) { + recordDetailLoading.value = false + } + } + } + + /** + * Save user-edited field values from the drawer. + * + * Merges the edited user-owned fields with the EXISTING agent-owned field + * values from `currentRecord` before PATCHing. This preserves agent + * columns: the backend's update_record_values does a full-replace of the + * values dict, so we send the agent columns back as-is rather than + * dropping them. (Spec: "upsert 保留 agent 列" — implemented client-side + * to avoid adding a new backend endpoint in U3.) + * + * Returns true on success. On success, both `currentRecord` and the + * matching entry in `records` are updated with the server response. + */ + async function updateRecordFields( + recordId: string, + editedFields: Record, + ): Promise { + const base = currentRecord.value?.values ?? {} + const merged: Record = { ...base, ...editedFields } + try { + const resp = await bitableApi.updateRecord(recordId, merged) + currentRecord.value = resp.record + const idx = records.value.findIndex((r) => r.id === recordId) + if (idx >= 0) records.value[idx] = resp.record + return true + } catch (err) { + notification.error({ + message: '保存失败', + description: err instanceof Error ? err.message : String(err), + }) + return false + } + } + // --- View management (U5c) --- /** Create a new view for the current table */ @@ -442,6 +576,32 @@ export const useBitableStore = defineStore('bitable', () => { } } + /** + * U5: merge group_by + conditional_formatting into the view's existing + * config and PATCH it. Preserves other config keys (filters / sort / + * hidden_fields) — only the U5 sub-keys are replaced. + * + * The backend route layer validates the U5 sub-keys via Pydantic and + * returns 422 on invalid input (caught here as a notification). + */ + async function updateViewConfig( + viewId: string, + u5Config: { group_by?: unknown[]; conditional_formatting?: unknown[] }, + ): Promise { + const existing = views.value.find((v) => v.id === viewId) + // Merge: start from existing config, overwrite only the U5 keys present. + const mergedConfig: Record = { + ...(existing?.config ?? {}), + } + if ('group_by' in u5Config) { + mergedConfig.group_by = u5Config.group_by + } + if ('conditional_formatting' in u5Config) { + mergedConfig.conditional_formatting = u5Config.conditional_formatting + } + await updateView(viewId, { config: mergedConfig }) + } + /** Switch to a view — applies its config to the records query */ async function switchView(viewId: string): Promise { const view = views.value.find((v) => v.id === viewId) @@ -450,6 +610,41 @@ export const useBitableStore = defineStore('bitable', () => { await refreshRecords() } + /** + * Delete a view (U6: R15a). Removes it from local state on success. + * If the deleted view was active, switches to the first remaining view. + * Backend rejects deleting the last view of a table with 409 Conflict. + * Returns true on success, false on error (notification shown). + */ + async function deleteView(viewId: string): Promise { + try { + await bitableApi.deleteView(viewId) + const wasActive = currentView.value?.id === viewId + views.value = views.value.filter((v) => v.id !== viewId) + if (wasActive) { + // Switch to the first remaining view, if any. + currentView.value = views.value[0] ?? null + await refreshRecords() + } + return true + } catch (err) { + const apiErr = err as { status?: number } + // 409 = last view of the table — backend forbids deletion. + if (apiErr.status === 409) { + notification.warning({ + message: '无法删除', + description: '至少保留一个视图,不能删除最后一个视图。', + }) + } else { + notification.error({ + message: '删除视图失败', + description: err instanceof Error ? err.message : String(err), + }) + } + return false + } + } + // --- Formula recalc polling (R7) --- /** Start polling for formula recalc status */ @@ -521,6 +716,12 @@ export const useBitableStore = defineStore('bitable', () => { error, nextCursor, recalcPendingCount, + // U3: record detail drawer state + currentRecordId, + currentRecord, + recordDetailLoading, + recordDetailError, + recordDetailNotFound, // Getters formulaFields, hasFormulaFields, @@ -543,9 +744,16 @@ export const useBitableStore = defineStore('bitable', () => { deleteField, hideField, refreshRecords, + // U3: record detail drawer actions + openRecordDetail, + closeRecordDetail, + fetchRecordDetail, + updateRecordFields, createView, updateView, + updateViewConfig, switchView, + deleteView, stopPolling, } }) diff --git a/src/agentkit/server/frontend/src/styles/bitable-tokens.css b/src/agentkit/server/frontend/src/styles/bitable-tokens.css new file mode 100644 index 0000000..2a50902 --- /dev/null +++ b/src/agentkit/server/frontend/src/styles/bitable-tokens.css @@ -0,0 +1,77 @@ +/** + * Bitable Design Tokens — CSS Custom Properties + * + * 用 `--bitable-*` 前缀避免与 Ant Design Vue 4 全局 token 冲突。 + * 颜色/间距/圆角/字号 4 类 token + 抽屉宽度。Phase 2 (U2-U5) 所有 + * bitable 组件必须引用本文件的 var(),禁止硬编码 hex。 + * + * 颜色 token 映射到全局 tokens.css(已支持暗色主题),bitable 组件 + * 不直接维护暗色覆写,跟随全局主题切换。 + * + * 条件格式 8 色(--bitable-cf-*):每色提供 -bg(浅底)+ -fg(深字)配对。 + * -bg 用于单元格/chip 背景(配深色文本,对比度 ~12:1) + * -fg 用于文本/边框/图标(在白底上对比度 ≥4.5:1,WCAG AA) + * 颜色语义 key: red | orange | yellow | green | blue | purple | gray | neutral + */ +:root { + /* ── 颜色:基础语义(映射全局 token,跟随暗色主题) ── */ + --bitable-color-primary: var(--color-primary, #1a1a1a); + --bitable-color-bg: var(--bg-primary, #ffffff); + --bitable-color-bg-secondary: var(--bg-secondary, #fbfbfa); + --bitable-color-bg-tertiary: var(--bg-tertiary, #f7f7f5); + --bitable-color-text: var(--text-primary, #1a1a1a); + --bitable-color-text-secondary: var(--text-secondary, #4a4a4a); + --bitable-color-text-tertiary: var(--text-tertiary, #6b6b6a); + --bitable-color-text-placeholder: var(--text-placeholder, #9b9b9a); + --bitable-color-border: var(--border-color, #ededec); + --bitable-color-border-hover: var(--border-color-hover, #dfdfde); + --bitable-color-border-split: var(--border-color-split, #f2f2f0); + + /* ── 颜色:条件格式 8 色(语义 key → bg/fg 配对) ── */ + /* red */ + --bitable-cf-red-bg: #fee2e2; + --bitable-cf-red-fg: #b91c1c; + /* orange */ + --bitable-cf-orange-bg: #ffedd5; + --bitable-cf-orange-fg: #9a3412; + /* yellow(深金以保证 AA) */ + --bitable-cf-yellow-bg: #fef9c3; + --bitable-cf-yellow-fg: #a16207; + /* green */ + --bitable-cf-green-bg: #dcfce7; + --bitable-cf-green-fg: #15803d; + /* blue */ + --bitable-cf-blue-bg: #dbeafe; + --bitable-cf-blue-fg: #1d4ed8; + /* purple */ + --bitable-cf-purple-bg: #f3e8ff; + --bitable-cf-purple-fg: #7e22ce; + /* gray */ + --bitable-cf-gray-bg: #f3f4f6; + --bitable-cf-gray-fg: #374151; + /* neutral(与 gray 区分:更纯的中性灰) */ + --bitable-cf-neutral-bg: #f5f5f4; + --bitable-cf-neutral-fg: #404040; + + /* ── 间距 ── */ + --bitable-spacing-xs: 4px; + --bitable-spacing-sm: 8px; + --bitable-spacing-md: 12px; + --bitable-spacing-lg: 16px; + --bitable-spacing-xl: 24px; + + /* ── 圆角 ── */ + --bitable-radius-sm: 4px; + --bitable-radius-md: 6px; + --bitable-radius-lg: 8px; + + /* ── 字号 ── */ + --bitable-font-xs: 12px; + --bitable-font-sm: 13px; + --bitable-font-md: 14px; + --bitable-font-lg: 16px; + + /* ── 抽屉宽度(KTD4: ≤10 字段 480px,>10 字段 640px) ── */ + --bitable-drawer-width: 480px; + --bitable-drawer-width-wide: 640px; +} diff --git a/src/agentkit/server/frontend/src/views/BitableFileDetailView.vue b/src/agentkit/server/frontend/src/views/BitableFileDetailView.vue index 6c3b845..acac10b 100644 --- a/src/agentkit/server/frontend/src/views/BitableFileDetailView.vue +++ b/src/agentkit/server/frontend/src/views/BitableFileDetailView.vue @@ -45,7 +45,7 @@
- +

请选择左侧的数据表,或点击 + 新建数据表

@@ -63,9 +63,11 @@
@@ -127,7 +129,7 @@ import { SettingOutlined, } from '@ant-design/icons-vue' import { useBitableStore } from '@/stores/bitable' -import type { IBitableField } from '@/api/bitable' +import type { IBitableField, ViewType } from '@/api/bitable' import TableViewList from '@/components/bitable/TableViewList.vue' import BitableGrid from '@/components/bitable/BitableGrid.vue' import TableCreateModal from '@/components/bitable/TableCreateModal.vue' @@ -143,6 +145,9 @@ const store = useBitableStore() const createModalOpen = ref(false) const fieldPanelOpen = ref(false) const viewConfigOpen = ref(false) +// U4: createView POST in-flight flag — disables the "新建视图" button + shows +// a spinner to prevent duplicate submits. +const viewCreating = ref(false) const fileId = computed(() => (route.params.fileId as string) ?? '') const tableId = computed(() => (route.params.tableId as string) ?? '') @@ -225,8 +230,16 @@ function handleSwitchView(viewId: string): void { store.switchView(viewId) } -async function handleCreateView(): Promise { - // ponytail: simple prompt for view name; full create modal is overkill for v1 +// U6: delete the active view. The ViewSwitcher's a-popconfirm already asked +// for confirmation; the store handles the 409 last-view case with a warning. +async function handleDeleteView(viewId: string): Promise { + await store.deleteView(viewId) +} + +async function handleCreateView(viewType: ViewType = 'grid'): Promise { + // ponytail: simple prompt for view name; full create modal is overkill for v1. + // viewType comes from the ViewSwitcher dropdown (U4) — defaults to 'grid' + // for backward compatibility with any direct caller. let name = '' AModal.confirm({ title: '新建视图', @@ -239,7 +252,12 @@ async function handleCreateView(): Promise { }), onOk: async () => { if (!name.trim()) return - await store.createView(name.trim(), 'grid') + viewCreating.value = true + try { + await store.createView(name.trim(), viewType) + } finally { + viewCreating.value = false + } }, }) } @@ -280,40 +298,40 @@ async function handleDeleteField(field: IBitableField): Promise { height: 100vh; width: 100vw; overflow: hidden; - background: var(--bg-primary, #fff); + background: var(--bitable-color-bg); } .bitable-file-detail-view__topbar { display: flex; align-items: center; justify-content: space-between; - padding: 8px 16px; - border-bottom: 1px solid var(--border-color, #f0f0f0); + padding: var(--bitable-spacing-sm) var(--bitable-spacing-lg); + border-bottom: 1px solid var(--bitable-color-border); flex-shrink: 0; } .bitable-file-detail-view__topbar-left { display: flex; align-items: center; - gap: 8px; + gap: var(--bitable-spacing-sm); } .bitable-file-detail-view__topbar-right { display: flex; align-items: center; - gap: 8px; + gap: var(--bitable-spacing-sm); } .bitable-file-detail-view__icon { font-size: 20px; - color: var(--color-primary, #1a1a1a); + color: var(--bitable-color-primary); display: inline-flex; align-items: center; } .bitable-file-detail-view__title { font-weight: 600; - font-size: 16px; + font-size: var(--bitable-font-lg); } .bitable-file-detail-view__body { @@ -342,28 +360,28 @@ async function handleDeleteField(field: IBitableField): Promise { align-items: center; justify-content: center; height: 100%; - gap: 16px; - color: var(--text-placeholder, #bfbfbf); + gap: var(--bitable-spacing-lg); + color: var(--bitable-color-text-placeholder); } .bitable-file-detail-view__grid-header { display: flex; align-items: baseline; - gap: 12px; - padding: 12px 16px; - border-bottom: 1px solid var(--border-color, #f0f0f0); + gap: var(--bitable-spacing-md); + padding: var(--bitable-spacing-md) var(--bitable-spacing-lg); + border-bottom: 1px solid var(--bitable-color-border); flex-shrink: 0; } .bitable-file-detail-view__table-name { margin: 0; - font-size: 16px; + font-size: var(--bitable-font-lg); font-weight: 600; } .bitable-file-detail-view__field-count { - font-size: 12px; - color: var(--text-secondary, #8c8c8c); + font-size: var(--bitable-font-xs); + color: var(--bitable-color-text-secondary); } .bitable-file-detail-view__grid-container { @@ -375,8 +393,8 @@ async function handleDeleteField(field: IBitableField): Promise { .bitable-file-detail-view__load-more { display: flex; justify-content: center; - padding: 8px; - border-top: 1px solid var(--border-color, #f0f0f0); + padding: var(--bitable-spacing-sm); + border-top: 1px solid var(--bitable-color-border); flex-shrink: 0; } diff --git a/src/agentkit/server/frontend/src/views/BitableFileListView.vue b/src/agentkit/server/frontend/src/views/BitableFileListView.vue index 55bfd10..8151fe6 100644 --- a/src/agentkit/server/frontend/src/views/BitableFileListView.vue +++ b/src/agentkit/server/frontend/src/views/BitableFileListView.vue @@ -189,16 +189,16 @@ function handleDelete(file: IBitableFile): void { height: 100vh; width: 100vw; overflow: hidden; - background: var(--bg-secondary, #fafafa); + background: var(--bitable-color-bg-secondary); } .bitable-file-list-view__topbar { display: flex; align-items: center; justify-content: space-between; - padding: 8px 16px; - background: var(--bg-primary, #fff); - border-bottom: 1px solid var(--border-color, #f0f0f0); + padding: var(--bitable-spacing-sm) var(--bitable-spacing-lg); + background: var(--bitable-color-bg); + border-bottom: 1px solid var(--bitable-color-border); flex-shrink: 0; } @@ -248,7 +248,7 @@ function handleDelete(file: IBitableFile): void { } .bitable-file-list-view__option-icon { - font-size: 16px; - color: var(--color-primary, #1a1a1a); + font-size: var(--bitable-font-lg); + color: var(--bitable-color-primary); } diff --git a/src/agentkit/server/routes/bitable.py b/src/agentkit/server/routes/bitable.py index d2d8b2c..63072af 100644 --- a/src/agentkit/server/routes/bitable.py +++ b/src/agentkit/server/routes/bitable.py @@ -28,7 +28,12 @@ from fastapi.responses import FileResponse from pydantic import BaseModel, Field from agentkit.bitable.models import FieldOwner, FieldType, ViewType -from agentkit.bitable.service import BitableService, FieldDependencyError +from agentkit.bitable.service import ( + BitableService, + FieldDependencyError, + LastViewDeletionError, +) +from agentkit.bitable.view_config import ViewConfigValidationError, validate_view_config from agentkit.server.auth.dependencies import get_current_user logger = logging.getLogger(__name__) @@ -668,6 +673,16 @@ async def update_view( if existing is None: raise HTTPException(status_code=404, detail="View not found") await _check_table_ownership(service, existing.table_id, user) + # U5: validate group_by / conditional_formatting sub-keys before persisting. + # Other config keys (filters / sort / hidden_fields) pass through unchanged. + if body.config is not None: + try: + validate_view_config(body.config) + except ViewConfigValidationError as exc: + raise HTTPException( + status_code=422, + detail={"message": str(exc), "errors": exc.errors}, + ) from exc kwargs = body.model_dump(exclude_none=True) view = await service.update_view(view_id, **kwargs) if view is None: @@ -675,6 +690,33 @@ async def update_view( return {"success": True, "view": view.model_dump(mode="json")} +@router.delete("/views/{view_id}", status_code=204) +async def delete_view( + view_id: str, + request: Request, + user: dict = Depends(require_bitable_auth), +) -> None: + """Delete a view (U6). + + 404-before-403: ownership is checked via ``_check_table_ownership`` so a + non-owner gets 404 (not 403) — never disclosing existence. Last-view + protection returns 409 Conflict. X-Internal-Token bypasses ownership + (KTD11) via ``require_bitable_auth``. + """ + service = _get_service(request) + existing = await service.get_view(view_id) + if existing is None: + raise HTTPException(status_code=404, detail="View not found") + await _check_table_ownership(service, existing.table_id, user) + try: + deleted = await service.delete_view(view_id) + except LastViewDeletionError as e: + raise HTTPException(status_code=409, detail=str(e)) from e + if not deleted: + raise HTTPException(status_code=404, detail="View not found") + return None + + # --------------------------------------------------------------------------- # File upload / download (U6: attachment & image fields) # --------------------------------------------------------------------------- diff --git a/src/agentkit/server/static/index.html b/src/agentkit/server/static/index.html index c9a7f66..bbbad35 100644 --- a/src/agentkit/server/static/index.html +++ b/src/agentkit/server/static/index.html @@ -5,8 +5,8 @@ Fischer AgentKit - - + +
diff --git a/src/agentkit/tools/bitable_tool.py b/src/agentkit/tools/bitable_tool.py index 86250ac..6365155 100644 --- a/src/agentkit/tools/bitable_tool.py +++ b/src/agentkit/tools/bitable_tool.py @@ -5,7 +5,8 @@ Implements KTD5 (REST API boundary even when co-deployed) and KTD11 the bitable REST API; it never imports BitableService directly. Actions: create_table, import_excel, import_database, collect_api, - upsert_records, query_records. + upsert_records, query_records, create_view, update_view, + update_field, delete_view. Batch chunking: upsert and import operations send at most ``BATCH_SIZE`` records per HTTP request. On partial failure, the result includes @@ -45,7 +46,8 @@ class BitableTool(Tool): "Create and manage bitable (multi-dimensional spreadsheet) tables, " "ingest data from Excel files, databases, or API responses, and " "query records. Actions: create_table, import_excel, " - "import_database, collect_api, upsert_records, query_records." + "import_database, collect_api, upsert_records, query_records, " + "create_view, update_view, update_field, delete_view." ), input_schema={ "type": "object", @@ -59,6 +61,10 @@ class BitableTool(Tool): "collect_api", "upsert_records", "query_records", + "create_view", + "update_view", + "update_field", + "delete_view", ], "description": "Bitable operation to perform.", }, @@ -89,7 +95,7 @@ class BitableTool(Tool): }, "table_id": { "type": "string", - "description": "Target bitable table ID (collect_api, upsert_records, query_records).", + "description": "Target bitable table ID (collect_api, upsert_records, query_records, create_view).", }, "records": { "type": "array", @@ -115,6 +121,31 @@ class BitableTool(Tool): "type": "integer", "description": "Max records to return (query_records).", }, + "view_id": { + "type": "string", + "description": "View ID (update_view, delete_view).", + }, + "view_type": { + "type": "string", + "enum": ["grid", "kanban", "gantt", "gallery", "form"], + "description": "View type (create_view). Defaults to grid.", + }, + "field_id": { + "type": "string", + "description": "Field ID (update_field).", + }, + "name": { + "type": "string", + "description": "Name for a view or field (create_view, update_view, update_field).", + }, + "type": { + "type": "string", + "description": "Field type for update_field (e.g. text, number, date).", + }, + "config": { + "type": "object", + "description": "View/field config dict (create_view, update_view, update_field).", + }, }, "required": ["action"], }, @@ -148,6 +179,10 @@ class BitableTool(Tool): "collect_api": self._collect_api, "upsert_records": self._upsert_records, "query_records": self._query_records, + "create_view": self._create_view, + "update_view": self._update_view, + "update_field": self._update_field, + "delete_view": self._delete_view, } handler = handlers.get(action) if handler is None: @@ -483,3 +518,68 @@ class BitableTool(Tool): "records": data["records"], "next_cursor": data.get("next_cursor"), } + + # ------------------------------------------------------------------ + # View & field CRUD (U6: agent parity with REST endpoints) + # ------------------------------------------------------------------ + + async def _create_view(self, **kwargs) -> dict[str, object]: + table_id = kwargs.get("table_id") + name = kwargs.get("name") + if not table_id: + return {"success": False, "error": "Missing required field: table_id"} + if not name: + return {"success": False, "error": "Missing required field: name"} + view_type = kwargs.get("view_type") or "grid" + config = kwargs.get("config") or {} + client = await self._get_client() + resp = await client.post( + f"/tables/{table_id}/views", + json={"name": name, "view_type": view_type, "config": config}, + ) + resp.raise_for_status() + return {"success": True, "view": resp.json()["view"]} + + async def _update_view(self, **kwargs) -> dict[str, object]: + view_id = kwargs.get("view_id") + if not view_id: + return {"success": False, "error": "Missing required field: view_id"} + payload: dict[str, object] = {} + if kwargs.get("name") is not None: + payload["name"] = kwargs["name"] + if kwargs.get("config") is not None: + payload["config"] = kwargs["config"] + client = await self._get_client() + resp = await client.patch(f"/views/{view_id}", json=payload) + resp.raise_for_status() + return {"success": True, "view": resp.json()["view"]} + + async def _update_field(self, **kwargs) -> dict[str, object]: + field_id = kwargs.get("field_id") + if not field_id: + return {"success": False, "error": "Missing required field: field_id"} + payload: dict[str, object] = {} + if kwargs.get("name") is not None: + payload["name"] = kwargs["name"] + # ponytail: PATCH /fields/{id} is backed by UpdateFieldRequest which + # currently accepts only name + config. `type` is forwarded as + # `field_type` so the tool is forward-compatible once the request + # model adds it; today it is silently ignored by Pydantic (extra=ignore). + if kwargs.get("type") is not None: + payload["field_type"] = kwargs["type"] + if kwargs.get("config") is not None: + payload["config"] = kwargs["config"] + client = await self._get_client() + resp = await client.patch(f"/fields/{field_id}", json=payload) + resp.raise_for_status() + return {"success": True, "field": resp.json()["field"]} + + async def _delete_view(self, **kwargs) -> dict[str, object]: + view_id = kwargs.get("view_id") + if not view_id: + return {"success": False, "error": "Missing required field: view_id"} + client = await self._get_client() + resp = await client.delete(f"/views/{view_id}") + resp.raise_for_status() + # 204 No Content has an empty body; report a stable success shape. + return {"success": True, "deleted": True} diff --git a/tests/unit/bitable/test_bitable_tool.py b/tests/unit/bitable/test_bitable_tool.py index b8b20b4..1f885fd 100644 --- a/tests/unit/bitable/test_bitable_tool.py +++ b/tests/unit/bitable/test_bitable_tool.py @@ -483,3 +483,236 @@ def test_transform_records_missing_keys() -> None: field_mapping={"a": "fld_a"}, # b is not mapped ) assert result == [{"fld_a": 1}] + + +# --------------------------------------------------------------------------- +# U6: View & field CRUD actions (create_view, update_view, update_field, +# delete_view) — agent parity with the REST API. +# --------------------------------------------------------------------------- + + +def test_action_enum_has_10_actions() -> None: + """input_schema.action.enum lists all 10 actions (6 original + 4 new).""" + tool = BitableTool(base_url="http://test/api/v1/bitable") + actions = tool.input_schema["properties"]["action"]["enum"] + assert len(actions) == 10 + for new_action in ("create_view", "update_view", "update_field", "delete_view"): + assert new_action in actions + + +def test_execute_handlers_dict_has_10_actions() -> None: + """execute() handlers dict contains all 10 action keys (KTD10).""" + import re + + src = open( + "src/agentkit/tools/bitable_tool.py", + encoding="utf-8", + ).read() + handlers_match = re.search(r"handlers\s*=\s*\{([^}]*)\}", src, re.DOTALL) + handler_keys = re.findall(r'"([a-z_]+)":\s*self\._', handlers_match.group(1)) + assert len(handler_keys) == 10 + for new_action in ("create_view", "update_view", "update_field", "delete_view"): + assert new_action in handler_keys + + +async def test_create_view_action(tool: BitableTool) -> None: + """create_view action POSTs /tables/{id}/views with name + view_type + config.""" + result = await tool.execute(action="create_table", table_name="VC") + table_id = result["table"]["id"] + + resp = await tool.execute( + action="create_view", + table_id=table_id, + name="Kanban Plan", + view_type="kanban", + config={"group_by": [{"field_id": "fld_x", "direction": "asc"}]}, + ) + assert resp["success"] is True + assert resp["view"]["name"] == "Kanban Plan" + assert resp["view"]["view_type"] == "kanban" + + +async def test_create_view_defaults_to_grid(tool: BitableTool) -> None: + """create_view without view_type defaults to grid.""" + result = await tool.execute(action="create_table", table_name="VG") + table_id = result["table"]["id"] + resp = await tool.execute(action="create_view", table_id=table_id, name="Default") + assert resp["success"] is True + assert resp["view"]["view_type"] == "grid" + + +async def test_create_view_missing_table_id(tool: BitableTool) -> None: + """Missing table_id → error.""" + resp = await tool.execute(action="create_view", name="x") + assert resp["success"] is False + assert "table_id" in resp["error"] + + +async def test_update_view_action(tool: BitableTool) -> None: + """update_view action PATCHes /views/{id} with name + config.""" + result = await tool.execute(action="create_table", table_name="VU") + table_id = result["table"]["id"] + view_id = ( + await tool.execute(action="create_view", table_id=table_id, name="Old") + )["view"]["id"] + + resp = await tool.execute( + action="update_view", + view_id=view_id, + name="Renamed", + config={"group_by": [{"field_id": "fld_a", "direction": "asc"}]}, + ) + assert resp["success"] is True + assert resp["view"]["name"] == "Renamed" + + +async def test_update_view_missing_view_id(tool: BitableTool) -> None: + """Missing view_id → error.""" + resp = await tool.execute(action="update_view", name="x") + assert resp["success"] is False + assert "view_id" in resp["error"] + + +async def test_update_field_action(tool: BitableTool) -> None: + """update_field action PATCHes /fields/{id} (equivalent to REST PATCH /fields).""" + result = await tool.execute(action="create_table", table_name="FU") + table_id = result["table"]["id"] + client = await tool._get_client() + field_id = ( + await client.post( + f"/tables/{table_id}/fields", + json={"name": "col", "field_type": "text", "owner": "user"}, + ) + ).json()["field"]["id"] + + resp = await tool.execute( + action="update_field", + field_id=field_id, + name="renamed_col", + config={"description": "updated"}, + ) + assert resp["success"] is True + assert resp["field"]["name"] == "renamed_col" + + +async def test_update_field_missing_field_id(tool: BitableTool) -> None: + """Missing field_id → error.""" + resp = await tool.execute(action="update_field", name="x") + assert resp["success"] is False + assert "field_id" in resp["error"] + + +async def test_delete_view_action(tool: BitableTool) -> None: + """delete_view action DELETEs /views/{id}; last-view protection applies.""" + result = await tool.execute(action="create_table", table_name="VD") + table_id = result["table"]["id"] + v1 = (await tool.execute(action="create_view", table_id=table_id, name="v1"))["view"]["id"] + await tool.execute(action="create_view", table_id=table_id, name="v2") + + resp = await tool.execute(action="delete_view", view_id=v1) + assert resp["success"] is True + assert resp["deleted"] is True + + +async def test_delete_view_action_409_on_last_view(tool: BitableTool) -> None: + """delete_view on the last view → HTTP 409 surfaced as error.""" + result = await tool.execute(action="create_table", table_name="VL") + table_id = result["table"]["id"] + only = (await tool.execute(action="create_view", table_id=table_id, name="only"))["view"]["id"] + + resp = await tool.execute(action="delete_view", view_id=only) + assert resp["success"] is False + assert "409" in resp["error"] + + +async def test_delete_view_missing_view_id(tool: BitableTool) -> None: + """Missing view_id → error.""" + resp = await tool.execute(action="delete_view") + assert resp["success"] is False + assert "view_id" in resp["error"] + + +async def test_create_view_with_r3_r4_config(tool: BitableTool) -> None: + """create_view forwards group_by + conditional_formatting config (R3/R4 parity).""" + result = await tool.execute(action="create_table", table_name="R34") + table_id = result["table"]["id"] + client = await tool._get_client() + # Create a field so group_by can reference a real field id. + fid = ( + await client.post( + f"/tables/{table_id}/fields", + json={"name": "status", "field_type": "select", "owner": "user"}, + ) + ).json()["field"]["id"] + + resp = await tool.execute( + action="create_view", + table_id=table_id, + name="GroupedView", + config={ + "group_by": [{"field_id": fid, "direction": "asc"}], + "conditional_formatting": [ + { + "field_id": fid, + "operator": "equals", + "value": "done", + "color_key": "green", + } + ], + }, + ) + assert resp["success"] is True + cfg = resp["view"]["config"] + assert len(cfg["group_by"]) == 1 + assert cfg["group_by"][0]["field_id"] == fid + assert cfg["conditional_formatting"][0]["color_key"] == "green" + + +# --------------------------------------------------------------------------- +# X-Internal-Token transparent passthrough on the 4 new actions (KTD11) +# --------------------------------------------------------------------------- + + +async def test_new_actions_internal_token_passthrough( + tool_real_auth: BitableTool, +) -> None: + """X-Internal-Token bypasses ownership for all 4 new actions (KTD11).""" + # create_table (existing action) — establishes an admin-owned table. + result = await tool_real_auth.execute(action="create_table", table_name="TokenT") + table_id = result["table"]["id"] + + # create_view via the new action. + v = await tool_real_auth.execute( + action="create_view", table_id=table_id, name="tv1" + ) + assert v["success"] is True + view_id = v["view"]["id"] + + # update_view via the new action. + uv = await tool_real_auth.execute(action="update_view", view_id=view_id, name="tv1-renamed") + assert uv["success"] is True + + # update_field: create a field first via the tool's HTTP client, then update. + client = await tool_real_auth._get_client() + fid = ( + await client.post( + f"/tables/{table_id}/fields", + json={"name": "c", "field_type": "text", "owner": "user"}, + ) + ).json()["field"]["id"] + uf = await tool_real_auth.execute(action="update_field", field_id=fid, name="c2") + assert uf["success"] is True + + # delete_view: add a second view so last-view protection doesn't block. + await tool_real_auth.execute(action="create_view", table_id=table_id, name="tv2") + dv = await tool_real_auth.execute(action="delete_view", view_id=view_id) + assert dv["success"] is True + + +async def test_delete_view_internal_token_404_when_missing( + tool_real_auth: BitableTool, +) -> None: + """X-Internal-Token calling delete_view on a non-existent view → 404 (no silent success).""" + resp = await tool_real_auth.execute(action="delete_view", view_id="no-such-view") + assert resp["success"] is False + assert "404" in resp["error"] diff --git a/tests/unit/bitable/test_conditional_formatting.py b/tests/unit/bitable/test_conditional_formatting.py new file mode 100644 index 0000000..7e8664b --- /dev/null +++ b/tests/unit/bitable/test_conditional_formatting.py @@ -0,0 +1,420 @@ +"""Tests for bitable view config conditional formatting validation (U5 / R4). + +Covers all 7 operators + 8 color keys. Layered like test_grouping.py: +1. Pure Pydantic validation (no DB) — always runs +2. Route-level 422 via fake service (no DB) — always runs +3. Round-trip via real PG service — skipped if PG unavailable +""" + +from __future__ import annotations + +from typing import Any + +import httpx +import pytest +from fastapi import FastAPI +from httpx import ASGITransport +from pydantic import ValidationError + +from agentkit.bitable.models import View, ViewType +from agentkit.bitable.view_config import ( + ColorKey, + ConditionalFormatRule, + ConditionalOperator, + ViewConfigSchema, + ViewConfigValidationError, + validate_view_config, +) +from agentkit.server.routes import bitable as bitable_routes +from agentkit.server.routes.bitable import require_bitable_auth + +TEST_USER_ID = "test-user-id" + +# Exhaustive lists — kept in sync with the Literal definitions in view_config.py. +ALL_OPERATORS: list[ConditionalOperator] = [ + "equals", + "not-equals", + "contains", + "is-empty", + "greater-than", + "less-than", + "between", +] +ALL_COLOR_KEYS: list[ColorKey] = [ + "red", + "orange", + "yellow", + "green", + "blue", + "purple", + "gray", + "neutral", +] + + +def _rule( + *, + field_id: str = "f1", + operator: ConditionalOperator = "equals", + value: str = "v", + color_key: ColorKey = "red", + bold: bool = True, + enabled: bool = True, +) -> dict[str, object]: + return { + "field_id": field_id, + "operator": operator, + "value": value, + "color_key": color_key, + "bold": bold, + "enabled": enabled, + } + + +# --------------------------------------------------------------------------- +# 1. Pure Pydantic validation — always runs +# --------------------------------------------------------------------------- + + +@pytest.mark.parametrize("op", ALL_OPERATORS) +def test_validate_accepts_each_operator(op: ConditionalOperator) -> None: + """All 7 operators accepted (spec test scenario 8).""" + config = {"conditional_formatting": [_rule(operator=op)]} + validate_view_config(config) # no raise + + +@pytest.mark.parametrize("color", ALL_COLOR_KEYS) +def test_validate_accepts_each_color_key(color: ColorKey) -> None: + """All 8 color keys accepted.""" + config = {"conditional_formatting": [_rule(color_key=color)]} + validate_view_config(config) # no raise + + +def test_validate_rejects_invalid_operator() -> None: + """Operator not in the 7-key whitelist is rejected.""" + config = {"conditional_formatting": [_rule(operator="starts-with")]} # type: ignore[dict-item] + with pytest.raises(ViewConfigValidationError): + validate_view_config(config) + + +def test_validate_rejects_invalid_color_key() -> None: + """color_key not in the 8-color whitelist is rejected.""" + config = {"conditional_formatting": [_rule(color_key="pink")]} # type: ignore[dict-item] + with pytest.raises(ViewConfigValidationError): + validate_view_config(config) + + +def test_validate_rejects_missing_field_id() -> None: + """field_id is required.""" + rule = _rule() + del rule["field_id"] + config = {"conditional_formatting": [rule]} + with pytest.raises(ViewConfigValidationError): + validate_view_config(config) + + +def test_validate_rejects_missing_operator() -> None: + """operator is required.""" + rule = _rule() + del rule["operator"] + config = {"conditional_formatting": [rule]} + with pytest.raises(ViewConfigValidationError): + validate_view_config(config) + + +def test_validate_rejects_missing_color_key() -> None: + """color_key is required.""" + rule = _rule() + del rule["color_key"] + config = {"conditional_formatting": [rule]} + with pytest.raises(ViewConfigValidationError): + validate_view_config(config) + + +def test_validate_value_defaults_to_empty_string() -> None: + """value is optional, defaults to '' (UI sends '' for is-empty).""" + rule = _rule() + del rule["value"] + parsed = ConditionalFormatRule.model_validate(rule) + assert parsed.value == "" + + +def test_validate_bold_defaults_to_true() -> None: + """bold defaults to True (WCAG 1.4.1 — color alone is not enough).""" + rule = _rule() + del rule["bold"] + parsed = ConditionalFormatRule.model_validate(rule) + assert parsed.bold is True + + +def test_validate_enabled_defaults_to_true() -> None: + """enabled defaults to True.""" + rule = _rule() + del rule["enabled"] + parsed = ConditionalFormatRule.model_validate(rule) + assert parsed.enabled is True + + +def test_validate_rejects_extra_keys_in_rule() -> None: + """extra='forbid' on ConditionalFormatRule — unknown keys rejected.""" + rule = _rule() + rule["extra_key"] = "oops" + config = {"conditional_formatting": [rule]} + with pytest.raises(ViewConfigValidationError): + validate_view_config(config) + + +def test_validate_accepts_empty_conditional_formatting_list() -> None: + """Empty rule list is valid (no rules = no coloring).""" + validate_view_config({"conditional_formatting": []}) + + +def test_validate_accepts_multiple_rules() -> None: + """Multiple rules accepted (first-match-wins is enforced client-side).""" + config = { + "conditional_formatting": [ + _rule(field_id="f1", operator="equals", value="a", color_key="red"), + _rule(field_id="f2", operator="greater-than", value="10", color_key="green"), + _rule(field_id="f3", operator="is-empty", color_key="gray"), + ] + } + validate_view_config(config) # no raise + + +def test_validate_ignores_non_u5_keys_with_cf_present() -> None: + """filters / sort / hidden_fields pass through when cf is also present.""" + config = { + "filters": [{"field_id": "f1", "op": "weird-op"}], + "hidden_fields": ["f2"], + "conditional_formatting": [_rule()], + } + validate_view_config(config) # no raise + + +def test_schema_round_trips_conditional_formatting() -> None: + """ViewConfigSchema round-trips a CF config without loss.""" + raw = { + "group_by": [], + "conditional_formatting": [ + { + "field_id": "f1", + "operator": "between", + "value": "10,20", + "color_key": "blue", + "bold": False, + "enabled": True, + } + ], + } + schema = ViewConfigSchema.model_validate(raw) + assert len(schema.conditional_formatting) == 1 + rule = schema.conditional_formatting[0] + assert rule.operator == "between" + assert rule.color_key == "blue" + assert rule.bold is False + dumped = schema.model_dump() + assert dumped["conditional_formatting"][0]["value"] == "10,20" + + +def test_rule_model_rejects_invalid_operator_directly() -> None: + """ConditionalFormatRule rejects invalid operator at construction.""" + with pytest.raises(ValidationError): + ConditionalFormatRule( + field_id="f1", + operator="starts-with", # type: ignore[arg-type] + value="v", + color_key="red", + ) + + +def test_rule_model_rejects_invalid_color_key_directly() -> None: + """ConditionalFormatRule rejects invalid color_key at construction.""" + with pytest.raises(ValidationError): + ConditionalFormatRule( + field_id="f1", + operator="equals", + value="v", + color_key="pink", # type: ignore[arg-type] + ) + + +def test_constants_match_spec() -> None: + """7 operators + 8 color keys (spec invariant).""" + assert len(ALL_OPERATORS) == 7 + assert len(ALL_COLOR_KEYS) == 8 + + +# --------------------------------------------------------------------------- +# 2. Route-level 422 — uses a fake service (no DB) +# --------------------------------------------------------------------------- + + +def _make_fake_view(view_id: str = "v1", table_id: str = "t1") -> View: + return View( + id=view_id, + table_id=table_id, + name="Test View", + view_type=ViewType.grid, + config={}, + ) + + +class _FakeService: + """Minimal service stub — same shape as test_grouping.py's stub.""" + + def __init__(self, *, existing_view: View | None = None) -> None: + self._existing = existing_view + + async def get_view(self, view_id: str) -> View | None: + if self._existing is None or self._existing.id != view_id: + return None + return self._existing + + async def get_table(self, table_id: str) -> Any: + class _T: + owner_user_id = TEST_USER_ID + + return _T() + + async def update_view(self, view_id: str, **kwargs: object) -> View | None: + if self._existing is None: + return None + config = kwargs.get("config") + if isinstance(config, dict): + self._existing = self._existing.model_copy(update={"config": config}) + return self._existing + + +@pytest.fixture +def fake_service_app() -> FastAPI: + app = FastAPI() + app.state.bitable_service = _FakeService(existing_view=_make_fake_view()) + app.include_router(bitable_routes.router, prefix="/api/v1") + app.dependency_overrides[require_bitable_auth] = lambda: { + "user_id": TEST_USER_ID, + "username": "testuser", + "role": "member", + } + return app + + +@pytest.fixture +async def fake_client(fake_service_app: FastAPI) -> httpx.AsyncClient: + transport = ASGITransport(app=fake_service_app) + async with httpx.AsyncClient(transport=transport, base_url="http://test") as c: + yield c + + +async def test_patch_view_with_valid_cf_returns_200( + fake_client: httpx.AsyncClient, +) -> None: + """Valid conditional_formatting is accepted and persisted.""" + config = { + "conditional_formatting": [ + { + "field_id": "f1", + "operator": "equals", + "value": "done", + "color_key": "green", + } + ] + } + resp = await fake_client.patch( + "/api/v1/bitable/views/v1", + json={"config": config}, + ) + assert resp.status_code == 200, resp.text + body = resp.json() + assert body["view"]["config"]["conditional_formatting"] == config["conditional_formatting"] + + +async def test_patch_view_with_invalid_operator_returns_422( + fake_client: httpx.AsyncClient, +) -> None: + """Invalid operator returns 422.""" + resp = await fake_client.patch( + "/api/v1/bitable/views/v1", + json={ + "config": { + "conditional_formatting": [ + {"field_id": "f1", "operator": "starts-with", "value": "x", "color_key": "red"} + ] + } + }, + ) + assert resp.status_code == 422 + + +async def test_patch_view_with_invalid_color_key_returns_422( + fake_client: httpx.AsyncClient, +) -> None: + """Invalid color_key returns 422.""" + resp = await fake_client.patch( + "/api/v1/bitable/views/v1", + json={ + "config": { + "conditional_formatting": [ + {"field_id": "f1", "operator": "equals", "value": "x", "color_key": "pink"} + ] + } + }, + ) + assert resp.status_code == 422 + + +async def test_patch_view_with_missing_field_id_returns_422( + fake_client: httpx.AsyncClient, +) -> None: + """Missing field_id in a CF rule returns 422.""" + resp = await fake_client.patch( + "/api/v1/bitable/views/v1", + json={ + "config": { + "conditional_formatting": [{"operator": "equals", "value": "x", "color_key": "red"}] + } + }, + ) + assert resp.status_code == 422 + + +# --------------------------------------------------------------------------- +# 3. Round-trip via real PG — skipped if PG unavailable +# --------------------------------------------------------------------------- + + +@pytest.mark.postgres +async def test_round_trip_conditional_formatting_via_real_service(bitable_service) -> None: + """PATCH config with conditional_formatting → GET view returns same.""" + from agentkit.bitable.service import BitableService + + assert isinstance(bitable_service, BitableService) + table = await bitable_service.create_table(name="U5 CF Round-Trip") + view = await bitable_service.create_view(table.id, name="V1") + + cf_rules = [ + { + "field_id": "f1", + "operator": "equals", + "value": "done", + "color_key": "green", + "bold": True, + "enabled": True, + }, + { + "field_id": "f2", + "operator": "between", + "value": "10,20", + "color_key": "blue", + "bold": False, + "enabled": False, + }, + ] + config = {"conditional_formatting": cf_rules} + validate_view_config(config) + updated = await bitable_service.update_view(view.id, config=config) + assert updated is not None + assert updated.config.get("conditional_formatting") == cf_rules + + fetched = await bitable_service.get_view(view.id) + assert fetched is not None + assert fetched.config.get("conditional_formatting") == cf_rules diff --git a/tests/unit/bitable/test_grouping.py b/tests/unit/bitable/test_grouping.py new file mode 100644 index 0000000..77e3dd4 --- /dev/null +++ b/tests/unit/bitable/test_grouping.py @@ -0,0 +1,319 @@ +"""Tests for bitable view config grouping validation (U5 / R4). + +Layered tests: +1. Pure Pydantic validation via ``validate_view_config`` (no DB needed — always runs) +2. Route-level 422 via a mocked BitableService (no DB needed — always runs) +3. Round-trip via real PG service (skipped if PG unavailable) + +The validator lives in ``agentkit.bitable.view_config`` and is called from +the PATCH /views route before ``service.update_view`` is invoked. +""" + +from __future__ import annotations + +from typing import Any + +import httpx +import pytest +from fastapi import FastAPI +from httpx import ASGITransport +from pydantic import ValidationError + +from agentkit.bitable.models import View, ViewType +from agentkit.bitable.view_config import ( + MAX_GROUP_BY_FIELDS, + GroupByItem, + ViewConfigSchema, + ViewConfigValidationError, + validate_view_config, +) +from agentkit.server.routes import bitable as bitable_routes +from agentkit.server.routes.bitable import require_bitable_auth + +TEST_USER_ID = "test-user-id" + + +# --------------------------------------------------------------------------- +# 1. Pure Pydantic validation — always runs (no DB) +# --------------------------------------------------------------------------- + + +def _gb(field_id: str, direction: str = "asc") -> dict[str, str]: + return {"field_id": field_id, "direction": direction} + + +def test_validate_accepts_empty_config() -> None: + """Empty / None config is valid (no group_by to check).""" + validate_view_config(None) + validate_view_config({}) + validate_view_config({"filters": []}) # non-U5 keys ignored + + +def test_validate_accepts_one_to_three_group_by_fields() -> None: + """Spec: max 3 levels. 1, 2, 3 all accepted.""" + for n in (1, 2, 3): + config = {"group_by": [_gb(f"f{i}") for i in range(n)]} + validate_view_config(config) # no raise + + +def test_validate_rejects_more_than_three_group_by_fields() -> None: + """group_by with >3 fields raises ValidationError (422 at the route).""" + config = {"group_by": [_gb(f"f{i}") for i in range(MAX_GROUP_BY_FIELDS + 1)]} + with pytest.raises(ViewConfigValidationError): + validate_view_config(config) + + +def test_validate_rejects_invalid_direction() -> None: + """direction must be 'asc' or 'desc' (Literal).""" + config = {"group_by": [_gb("f1", "sideways")]} + with pytest.raises(ViewConfigValidationError): + validate_view_config(config) + + +def test_validate_rejects_missing_field_id() -> None: + """field_id is required.""" + config = {"group_by": [{"direction": "asc"}]} + with pytest.raises(ViewConfigValidationError): + validate_view_config(config) + + +def test_validate_rejects_empty_field_id() -> None: + """field_id must be non-empty (min_length=1).""" + config = {"group_by": [_gb("")]} + with pytest.raises(ViewConfigValidationError): + validate_view_config(config) + + +def test_validate_rejects_extra_keys_in_group_by_item() -> None: + """extra='forbid' on GroupByItem — unknown keys rejected.""" + config = {"group_by": [{"field_id": "f1", "direction": "asc", "color": "red"}]} + with pytest.raises(ViewConfigValidationError): + validate_view_config(config) + + +def test_validate_defaults_direction_to_asc() -> None: + """direction is optional, defaults to 'asc'.""" + item = GroupByItem(field_id="f1") + assert item.direction == "asc" + + +def test_validate_passes_through_non_u5_keys() -> None: + """filters / sort / hidden_fields are NOT validated here — pass through.""" + config = { + "filters": [{"field_id": "f1", "op": "weird-op", "value": None}], + "sort": {"field": "f1", "order": "asc"}, + "hidden_fields": ["f2"], + "group_by": [_gb("f1")], + } + validate_view_config(config) # no raise — non-U5 keys ignored + + +def test_schema_max_length_constant() -> None: + """MAX_GROUP_BY_FIELDS is 3 (matches spec / Feishu / Twenty UX).""" + assert MAX_GROUP_BY_FIELDS == 3 + + +def test_schema_round_trips_through_model() -> None: + """ViewConfigSchema.model_validate round-trips a valid config.""" + raw = { + "group_by": [{"field_id": "f1", "direction": "asc"}], + "conditional_formatting": [], + } + schema = ViewConfigSchema.model_validate(raw) + assert len(schema.group_by) == 1 + assert schema.group_by[0].field_id == "f1" + dumped = schema.model_dump() + assert dumped["group_by"][0]["direction"] == "asc" + + +def test_validation_error_carries_structured_errors() -> None: + """ViewConfigValidationError.errors is the Pydantic error list (for 422 body).""" + config = {"group_by": [_gb("")]} + with pytest.raises(ViewConfigValidationError) as exc_info: + validate_view_config(config) + assert isinstance(exc_info.value.errors, list) + assert len(exc_info.value.errors) > 0 + + +def test_validation_error_is_value_error_subclass() -> None: + """Subclasses ValueError so existing handlers pick it up.""" + config = {"group_by": [_gb("")]} + with pytest.raises(ValueError): + validate_view_config(config) + + +# --------------------------------------------------------------------------- +# 2. Route-level 422 — uses a fake service (no DB needed) +# --------------------------------------------------------------------------- + + +def _make_fake_view(view_id: str = "v1", table_id: str = "t1") -> View: + return View( + id=view_id, + table_id=table_id, + name="Test View", + view_type=ViewType.grid, + config={}, + ) + + +class _FakeService: + """Minimal service stub for route-level tests. + + Only implements the methods the PATCH /views route touches: + ``get_view`` (existence + ownership check) and ``update_view``. + """ + + def __init__(self, *, existing_view: View | None = None) -> None: + self._existing = existing_view + self.updated_config: dict[str, object] | None = None + self.update_called = False + + async def get_view(self, view_id: str) -> View | None: + if self._existing is None or self._existing.id != view_id: + return None + return self._existing + + async def get_table(self, table_id: str) -> Any: + # Ownership check passes — return a table owned by the test user. + class _T: + owner_user_id = TEST_USER_ID + + return _T() + + async def update_view(self, view_id: str, **kwargs: object) -> View | None: + self.update_called = True + self.updated_config = kwargs.get("config") # type: ignore[assignment] + if self._existing is None: + return None + config = kwargs.get("config") + if isinstance(config, dict): + self._existing = self._existing.model_copy(update={"config": config}) + return self._existing + + +@pytest.fixture +def fake_service_app() -> FastAPI: + """App with a fake service on state — bypasses PG entirely.""" + app = FastAPI() + app.state.bitable_service = _FakeService(existing_view=_make_fake_view()) + app.include_router(bitable_routes.router, prefix="/api/v1") + app.dependency_overrides[require_bitable_auth] = lambda: { + "user_id": TEST_USER_ID, + "username": "testuser", + "role": "member", + } + return app + + +@pytest.fixture +async def fake_client(fake_service_app: FastAPI) -> httpx.AsyncClient: + transport = ASGITransport(app=fake_service_app) + async with httpx.AsyncClient(transport=transport, base_url="http://test") as c: + yield c + + +async def test_patch_view_with_valid_group_by_returns_200( + fake_client: httpx.AsyncClient, +) -> None: + """Valid group_by is accepted and persisted (round-trip at the route layer).""" + config = {"group_by": [{"field_id": "f1", "direction": "asc"}]} + resp = await fake_client.patch( + "/api/v1/bitable/views/v1", + json={"config": config}, + ) + assert resp.status_code == 200, resp.text + body = resp.json() + assert body["success"] is True + assert body["view"]["config"]["group_by"] == config["group_by"] + + +async def test_patch_view_with_too_many_group_by_returns_422( + fake_client: httpx.AsyncClient, +) -> None: + """group_by with >3 fields returns 422 (not 500).""" + config = {"group_by": [{"field_id": f"f{i}"} for i in range(4)]} + resp = await fake_client.patch( + "/api/v1/bitable/views/v1", + json={"config": config}, + ) + assert resp.status_code == 422 + body = resp.json() + assert "errors" in body["detail"] + + +async def test_patch_view_with_invalid_direction_returns_422( + fake_client: httpx.AsyncClient, +) -> None: + """Invalid direction value returns 422.""" + resp = await fake_client.patch( + "/api/v1/bitable/views/v1", + json={"config": {"group_by": [{"field_id": "f1", "direction": "sideways"}]}}, + ) + assert resp.status_code == 422 + + +async def test_patch_view_with_non_u5_config_returns_200( + fake_client: httpx.AsyncClient, +) -> None: + """Non-U5 config keys (filters / sort / hidden_fields) pass through unchanged.""" + resp = await fake_client.patch( + "/api/v1/bitable/views/v1", + json={"config": {"hidden_fields": ["f1", "f2"]}}, + ) + assert resp.status_code == 200, resp.text + + +# --------------------------------------------------------------------------- +# 3. Round-trip via real PG — skipped if PG unavailable +# --------------------------------------------------------------------------- + + +@pytest.mark.postgres +async def test_round_trip_group_by_via_real_service(bitable_service) -> None: + """PATCH config with group_by → GET view returns same group_by. + + Requires PostgreSQL. Verifies the config dict survives the JSONB + round-trip and the route-layer validation does not strip fields. + """ + from agentkit.bitable.service import BitableService + + assert isinstance(bitable_service, BitableService) + table = await bitable_service.create_table(name="U5 Round-Trip") + view = await bitable_service.create_view(table.id, name="V1") + + config = { + "group_by": [ + {"field_id": "f1", "direction": "asc"}, + {"field_id": "f2", "direction": "desc"}, + ], + } + # Validator accepts the config (no exception). + validate_view_config(config) + # Service persists it as-is. + updated = await bitable_service.update_view(view.id, config=config) + assert updated is not None + assert updated.config.get("group_by") == config["group_by"] + + # GET view returns the same group_by. + fetched = await bitable_service.get_view(view.id) + assert fetched is not None + assert fetched.config.get("group_by") == config["group_by"] + + +# --------------------------------------------------------------------------- +# Direct schema construction (covers Literal type errors at the model layer) +# --------------------------------------------------------------------------- + + +def test_group_by_item_rejects_invalid_direction_via_model() -> None: + """Constructing GroupByItem with an invalid direction raises ValidationError.""" + with pytest.raises(ValidationError): + GroupByItem(field_id="f1", direction="sideways") # type: ignore[arg-type] + + +def test_view_config_schema_max_length_enforced() -> None: + """ViewConfigSchema.group_by max_length=3 enforced at the model level.""" + too_many = [{"field_id": f"f{i}"} for i in range(MAX_GROUP_BY_FIELDS + 1)] + with pytest.raises(ValidationError): + ViewConfigSchema.model_validate({"group_by": too_many}) diff --git a/tests/unit/bitable/test_routes.py b/tests/unit/bitable/test_routes.py index 8b4e09d..84cd239 100644 --- a/tests/unit/bitable/test_routes.py +++ b/tests/unit/bitable/test_routes.py @@ -53,6 +53,23 @@ def unauth_app(bitable_service: BitableService) -> FastAPI: return app +INTERNAL_TOKEN = "internal-token-routes-abc" + + +@pytest.fixture +def internal_app(bitable_service: BitableService) -> FastAPI: + """App with X-Internal-Token configured and NO auth override. + + Exercises the real ``require_bitable_auth`` path: the internal token + yields a synthetic admin user that bypasses ownership (KTD11). + """ + app = FastAPI() + app.state.bitable_service = bitable_service + app.state.bitable_internal_token = INTERNAL_TOKEN + app.include_router(bitable_routes.router, prefix="/api/v1") + return app + + @pytest.fixture def no_service_app() -> FastAPI: """App without bitable_service on state — simulates uninitialized subsystem.""" @@ -77,6 +94,18 @@ async def unauth_client(unauth_app: FastAPI) -> httpx.AsyncClient: yield c +@pytest.fixture +async def internal_client(internal_app: FastAPI) -> httpx.AsyncClient: + """Client that sends X-Internal-Token (real auth path, no override).""" + transport = ASGITransport(app=internal_app) + async with httpx.AsyncClient( + transport=transport, + base_url="http://test", + headers={"X-Internal-Token": INTERNAL_TOKEN}, + ) as c: + yield c + + @pytest.fixture async def no_service_client(no_service_app: FastAPI) -> httpx.AsyncClient: transport = ASGITransport(app=no_service_app) @@ -497,6 +526,86 @@ async def test_update_view(client: httpx.AsyncClient) -> None: assert resp.json()["view"]["name"] == "New" +# --------------------------------------------------------------------------- +# DELETE /views/{view_id} (U6) +# --------------------------------------------------------------------------- + + +async def test_delete_view_success(client: httpx.AsyncClient) -> None: + """DELETE an existing view with >=2 views → 204 No Content.""" + table_id = (await client.post("/api/v1/bitable/tables", json={"name": "T"})).json()["table"][ + "id" + ] + # Create 2 views so the last-view protection does not trigger. + v1 = ( + await client.post(f"/api/v1/bitable/tables/{table_id}/views", json={"name": "v1"}) + ).json()["view"]["id"] + await client.post(f"/api/v1/bitable/tables/{table_id}/views", json={"name": "v2"}) + resp = await client.delete(f"/api/v1/bitable/views/{v1}") + assert resp.status_code == 204 + assert resp.content == b"" + # Confirm it's gone. + views = (await client.get(f"/api/v1/bitable/tables/{table_id}/views")).json()["views"] + assert all(v["id"] != v1 for v in views) + + +async def test_delete_view_404_when_missing(client: httpx.AsyncClient) -> None: + """DELETE a non-existent view → 404.""" + resp = await client.delete("/api/v1/bitable/views/nonexistent-view-id") + assert resp.status_code == 404 + + +async def test_delete_view_404_when_not_owner( + client: httpx.AsyncClient, bitable_service: BitableService +) -> None: + """DELETE a view on a table owned by another user → 404 (not 403). + + Pattern 4 (IDOR): existence is never disclosed to a non-owner. + """ + # Table owned by a different user. + other_table = await bitable_service.create_table(name="Other", owner_user_id="other-user") + view = await bitable_service.create_view(other_table.id, name="v") + resp = await client.delete(f"/api/v1/bitable/views/{view.id}") + assert resp.status_code == 404 + + +async def test_delete_view_409_when_last_view(client: httpx.AsyncClient) -> None: + """DELETE the last remaining view of a table → 409 Conflict.""" + table_id = (await client.post("/api/v1/bitable/tables", json={"name": "T"})).json()["table"][ + "id" + ] + only_view = ( + await client.post(f"/api/v1/bitable/tables/{table_id}/views", json={"name": "only"}) + ).json()["view"]["id"] + resp = await client.delete(f"/api/v1/bitable/views/{only_view}") + assert resp.status_code == 409 + # The view is still present. + views = (await client.get(f"/api/v1/bitable/tables/{table_id}/views")).json()["views"] + assert len(views) == 1 + + +async def test_delete_view_internal_token_passthrough(internal_client: httpx.AsyncClient) -> None: + """X-Internal-Token bypasses ownership: DELETE succeeds on another user's table.""" + # Create a table as the internal admin user; ownership is bypassed. + table_id = ( + await internal_client.post("/api/v1/bitable/tables", json={"name": "InternalT"}) + ).json()["table"]["id"] + v1 = ( + await internal_client.post(f"/api/v1/bitable/tables/{table_id}/views", json={"name": "v1"}) + ).json()["view"]["id"] + await internal_client.post(f"/api/v1/bitable/tables/{table_id}/views", json={"name": "v2"}) + resp = await internal_client.delete(f"/api/v1/bitable/views/{v1}") + assert resp.status_code == 204 + + +async def test_delete_view_internal_token_404_when_missing( + internal_client: httpx.AsyncClient, +) -> None: + """X-Internal-Token still gets 404 for a non-existent view (no silent success).""" + resp = await internal_client.delete("/api/v1/bitable/views/does-not-exist") + assert resp.status_code == 404 + + # --------------------------------------------------------------------------- # Formula validation (U5b) # ---------------------------------------------------------------------------