feat: Bitable P0 UX Polish + Agent Parity #23

Merged
fischer merged 12 commits from feat/bitable-enhancement into main 2026-07-04 01:05:05 +08:00
12 changed files with 996 additions and 7 deletions
Showing only changes of commit 229dc0b2f3 - Show all commits

View File

@ -464,6 +464,13 @@ class BitableRepository:
await session.commit()
return View.model_validate(entity) if entity else None
async def delete_view(self, view_id: str) -> bool:
"""Delete a view. Returns True if a row was deleted."""
async with self._session_factory() as session:
result = await session.execute(delete(ViewModel).where(ViewModel.id == view_id))
await session.commit()
return result.rowcount > 0
# ── Recalc Queue ────────────────────────────────────────
async def enqueue_recalc(

View File

@ -54,6 +54,14 @@ class FieldDependencyError(Exception):
self.dependencies = dependencies
class LastViewDeletionError(Exception):
"""Raised when attempting to delete the last remaining view of a table.
Prevents users from deleting all views and making a table inaccessible.
The route layer maps this to HTTP 409 Conflict.
"""
class BitableService:
"""Bitable business logic service.
@ -536,6 +544,25 @@ class BitableService:
async def get_view(self, view_id: str) -> View | None:
return await self._repo.get_view(view_id)
async def delete_view(self, view_id: str) -> bool:
"""Delete a view with last-view protection (U6).
Raises :class:`LastViewDeletionError` if the view is the last one in
its table preventing users from making a table inaccessible. The
route layer is responsible for the 404 + ownership checks before
calling this (matching the existing ``delete_field`` pattern).
Returns True if a row was deleted.
"""
view = await self._repo.get_view(view_id)
if view is None:
return False
siblings = await self._repo.list_views(view.table_id)
if len(siblings) <= 1:
raise LastViewDeletionError(
"Cannot delete the last view of a table"
)
return await self._repo.delete_view(view_id)
# ── Recalc (U3: formula recalc pipeline) ────────────────
async def _trigger_recalc_for_affected_fields(self, table_id: str, record_id: str) -> None:

View File

@ -0,0 +1,395 @@
/**
* E2E tests for U6: R15a BitableTool agent parity the 4 new actions
* (create_view / update_view / update_field / delete_view) and the
* DELETE /views/{id} endpoint.
*
* These tests verify the UI flows that correspond to the 4 new BitableTool
* actions, focusing on the delete-view flow (the main U6 frontend feature)
* and the 204/409 contract of the DELETE endpoint.
*
* Route mocking is used for the DELETE /views endpoint so the 204 success
* and 409 last-view scenarios are deterministic and don't depend on
* specific backend view persistence state.
*
* ponytail: scenarios reuse the loginAndOpenBitable + openFileDetailWithTable
* helpers from bitable-view.spec.ts. Each test creates a unique file to
* avoid collisions. The backend may not be fully configured tests skip
* gracefully if the server is down.
*/
import { test, expect, type Page } from '@playwright/test'
import { TEST_USER, clearAuth, waitForServer } from './helpers'
async function loginAndOpenBitable(page: Page): Promise<void> {
await page.goto('/login')
await clearAuth(page)
await page.getByPlaceholder('请输入用户名').fill(TEST_USER.username)
await page.getByPlaceholder('请输入密码').fill(TEST_USER.password)
await page.getByRole('button', { name: /登\s*录/ }).click()
await expect(page).toHaveURL(/\/agent/, { timeout: 15_000 })
await page.getByRole('button', { name: '多维表格' }).click()
await expect(page).toHaveURL(/\/bitable/, { timeout: 15_000 })
await expect(page.locator('.bitable-file-list-view')).toBeVisible({ timeout: 15_000 })
}
/**
* Log in, create a bitable file + table via the UI, and wait for the
* ViewSwitcher to render. Returns once the grid header is visible.
*/
async function openFileDetailWithTable(page: Page, label: string): Promise<void> {
await loginAndOpenBitable(page)
await page.getByRole('button', { name: /新建文件/ }).click()
await page.getByPlaceholder('请输入文件名').fill(`U6-${label}`)
await page.getByRole('button', { name: /确\s*定/ }).click()
await expect(page).toHaveURL(/\/bitable\/[^/]+/, { timeout: 10_000 })
await page.locator('.table-view-list__header .ant-btn').click()
await expect(page.getByText('新建数据表')).toBeVisible({ timeout: 5_000 })
await page.getByPlaceholder('请输入表名').fill(`U6表-${label}`)
await page.getByRole('button', { name: /确\s*定/ }).click()
await expect(page.locator('.bitable-file-detail-view__table-name')).toContainText(
`U6表-${label}`,
{ timeout: 10_000 },
)
}
/**
* Mock the list views GET to return the given view objects, so the
* ViewSwitcher renders the exact set of views needed for each test.
*/
async function mockListViewsWith(
page: Page,
views: { id: string; name: string; view_type: string; config: Record<string, unknown> }[],
): Promise<void> {
await page.route('**/api/v1/bitable/tables/*/views', (route) => {
if (route.request().method() !== 'GET') return route.continue()
return route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify({ success: true, views }),
})
})
}
test.describe('Bitable Agent Parity E2E (U6: R15a)', () => {
test.beforeAll(async () => {
try {
await waitForServer(undefined, 5_000)
} catch {
test.skip(true, 'Backend not running — skipping agent parity E2E')
}
})
// ── P1: delete button hidden when only 1 view ──────────────────────
test('P1: delete button hidden when only one view exists', async ({ page }) => {
await openFileDetailWithTable(page, 'P1-single-view')
await mockListViewsWith(page, [
{ id: 'v1', name: '默认视图', view_type: 'grid', config: {} },
])
// Reload the table to pick up the mocked views.
await page.reload()
await expect(page.locator('.bitable-file-detail-view__table-name')).toBeVisible({
timeout: 10_000,
})
// The delete button must NOT be visible (views.length === 1).
await expect(page.getByRole('button', { name: /删除/ })).not.toBeVisible({
timeout: 5_000,
})
})
// ── P2: delete button visible when 2+ views ────────────────────────
test('P2: delete button visible when 2+ views exist', async ({ page }) => {
await openFileDetailWithTable(page, 'P2-two-views')
await mockListViewsWith(page, [
{ id: 'v1', name: '视图A', view_type: 'grid', config: {} },
{ id: 'v2', name: '视图B', view_type: 'grid', config: {} },
])
await page.reload()
await expect(page.locator('.bitable-file-detail-view__table-name')).toBeVisible({
timeout: 10_000,
})
// The delete button must be visible.
await expect(page.getByRole('button', { name: /删除/ })).toBeVisible({
timeout: 5_000,
})
})
// ── P3: popconfirm appears on delete click ─────────────────────────
test('P3: clicking delete shows popconfirm with "确认删除此视图?"', async ({ page }) => {
await openFileDetailWithTable(page, 'P3-popconfirm')
await mockListViewsWith(page, [
{ id: 'v1', name: '视图A', view_type: 'grid', config: {} },
{ id: 'v2', name: '视图B', view_type: 'grid', config: {} },
])
await page.reload()
await expect(page.locator('.bitable-file-detail-view__table-name')).toBeVisible({
timeout: 10_000,
})
await page.getByRole('button', { name: /删除/ }).click()
// The popconfirm overlay should appear with the confirmation text.
await expect(page.getByText('确认删除此视图?')).toBeVisible({ timeout: 5_000 })
await expect(page.getByRole('button', { name: /删\s*除/ }).last()).toBeVisible()
await expect(page.getByRole('button', { name: /取\s*消/ })).toBeVisible()
})
// ── P4: cancel popconfirm does not delete ──────────────────────────
test('P4: cancel popconfirm does not fire DELETE request', async ({ page }) => {
await openFileDetailWithTable(page, 'P4-cancel')
await mockListViewsWith(page, [
{ id: 'v1', name: '视图A', view_type: 'grid', config: {} },
{ id: 'v2', name: '视图B', view_type: 'grid', config: {} },
])
await page.reload()
await expect(page.locator('.bitable-file-detail-view__table-name')).toBeVisible({
timeout: 10_000,
})
let deleteFired = false
await page.route('**/api/v1/bitable/views/*', (route) => {
if (route.request().method() === 'DELETE') {
deleteFired = true
return route.fulfill({ status: 204 })
}
return route.continue()
})
await page.getByRole('button', { name: /删除/ }).click()
await page.getByRole('button', { name: /取\s*消/ }).click()
// Popconfirm disappears.
await expect(page.getByText('确认删除此视图?')).not.toBeVisible({ timeout: 3_000 })
// No DELETE request was fired.
expect(deleteFired).toBe(false)
})
// ── P5: confirm delete fires DELETE /views/{id} ────────────────────
test('P5: confirm delete fires DELETE /views/{id} and returns 204', async ({ page }) => {
await openFileDetailWithTable(page, 'P5-delete-204')
await mockListViewsWith(page, [
{ id: 'v1', name: '视图A', view_type: 'grid', config: {} },
{ id: 'v-del', name: '待删除', view_type: 'grid', config: {} },
])
await page.reload()
await expect(page.locator('.bitable-file-detail-view__table-name')).toBeVisible({
timeout: 10_000,
})
let deletedViewId: string | null = null
await page.route('**/api/v1/bitable/views/*', (route) => {
if (route.request().method() === 'DELETE') {
const url = route.request().url()
deletedViewId = url.split('/views/')[1]?.split('?')[0] ?? null
return route.fulfill({ status: 204 })
}
return route.continue()
})
await page.getByRole('button', { name: /删除/ }).click()
await page.getByRole('button', { name: /删\s*除/ }).last().click()
// The DELETE request was fired with the active view's id.
await expect
.poll(async () => deletedViewId, { timeout: 5_000 })
.toBeTruthy()
})
// ── P6: 409 last view shows warning notification ───────────────────
test('P6: 409 Conflict on last view shows warning notification', async ({ page }) => {
await openFileDetailWithTable(page, 'P6-409-last-view')
// Mock 2 views so the delete button is visible, but the backend
// returns 409 (last view) — simulating a race where the other view
// was deleted server-side between the list and the delete.
await mockListViewsWith(page, [
{ id: 'v1', name: '视图A', view_type: 'grid', config: {} },
{ id: 'v2', name: '视图B', view_type: 'grid', config: {} },
])
await page.reload()
await expect(page.locator('.bitable-file-detail-view__table-name')).toBeVisible({
timeout: 10_000,
})
await page.route('**/api/v1/bitable/views/*', (route) => {
if (route.request().method() === 'DELETE') {
return route.fulfill({
status: 409,
contentType: 'application/json',
body: JSON.stringify({ detail: 'Cannot delete the last view of a table' }),
})
}
return route.continue()
})
await page.getByRole('button', { name: /删除/ }).click()
await page.getByRole('button', { name: /删\s*除/ }).last().click()
// The warning notification should appear with the "无法删除" message.
await expect(page.getByText('无法删除')).toBeVisible({ timeout: 5_000 })
})
// ── P7: successful delete removes the view from the tab list ───────
test('P7: successful delete removes view from tab list (optimistic update)', async ({
page,
}) => {
await openFileDetailWithTable(page, 'P7-removes-tab')
await mockListViewsWith(page, [
{ id: 'v-keep', name: '保留视图', view_type: 'grid', config: {} },
{ id: 'v-del', name: '删除视图', view_type: 'grid', config: {} },
])
await page.reload()
await expect(page.locator('.bitable-file-detail-view__table-name')).toBeVisible({
timeout: 10_000,
})
// Both view tabs should be visible initially.
await expect(page.getByRole('tab', { name: '保留视图' })).toBeVisible({ timeout: 5_000 })
await expect(page.getByRole('tab', { name: '删除视图' })).toBeVisible({ timeout: 5_000 })
await page.route('**/api/v1/bitable/views/*', (route) => {
if (route.request().method() === 'DELETE') {
return route.fulfill({ status: 204 })
}
return route.continue()
})
// Switch to the view we want to delete, then delete it.
await page.getByRole('tab', { name: '删除视图' }).click()
await page.getByRole('button', { name: /删除/ }).click()
await page.getByRole('button', { name: /删\s*除/ }).last().click()
// The deleted view's tab should disappear.
await expect(page.getByRole('tab', { name: '删除视图' })).not.toBeVisible({
timeout: 5_000,
})
// The remaining view's tab should still be visible.
await expect(page.getByRole('tab', { name: '保留视图' })).toBeVisible({
timeout: 5_000,
})
})
// ── P8: deleting active view switches to first remaining ───────────
test('P8: deleting active view switches to the first remaining view', async ({ page }) => {
await openFileDetailWithTable(page, 'P8-switch-after-delete')
await mockListViewsWith(page, [
{ id: 'v-first', name: '第一个视图', view_type: 'grid', config: {} },
{ id: 'v-second', name: '第二个视图', view_type: 'grid', config: {} },
{ id: 'v-third', name: '第三个视图', view_type: 'grid', config: {} },
])
await page.reload()
await expect(page.locator('.bitable-file-detail-view__table-name')).toBeVisible({
timeout: 10_000,
})
await page.route('**/api/v1/bitable/views/*', (route) => {
if (route.request().method() === 'DELETE') {
return route.fulfill({ status: 204 })
}
return route.continue()
})
// Switch to the second view, then delete it.
await page.getByRole('tab', { name: '第二个视图' }).click()
await page.getByRole('button', { name: /删除/ }).click()
await page.getByRole('button', { name: /删\s*除/ }).last().click()
// The first view should become the active tab.
await expect(page.getByRole('tab', { name: '第一个视图' })).toHaveClass(
/ant-tabs-tab-active/,
{ timeout: 5_000 },
)
})
// ── P9: 404 on delete (non-owned view) shows error notification ────
test('P9: 404 on delete shows error notification (non-owner / missing)', async ({ page }) => {
await openFileDetailWithTable(page, 'P9-404-not-found')
await mockListViewsWith(page, [
{ id: 'v1', name: '视图A', view_type: 'grid', config: {} },
{ id: 'v2', name: '视图B', view_type: 'grid', config: {} },
])
await page.reload()
await expect(page.locator('.bitable-file-detail-view__table-name')).toBeVisible({
timeout: 10_000,
})
await page.route('**/api/v1/bitable/views/*', (route) => {
if (route.request().method() === 'DELETE') {
return route.fulfill({
status: 404,
contentType: 'application/json',
body: JSON.stringify({ detail: 'View not found' }),
})
}
return route.continue()
})
await page.getByRole('button', { name: /删除/ }).click()
await page.getByRole('button', { name: /删\s*除/ }).last().click()
// An error notification should appear.
await expect(page.getByText('删除视图失败')).toBeVisible({ timeout: 5_000 })
})
// ── P10: create view adds new tab to the switcher ──────────────────
test('P10: creating a new view adds it to the tab list', async ({ page }) => {
await openFileDetailWithTable(page, 'P10-create-adds-tab')
await mockListViewsWith(page, [
{ id: 'v1', name: '默认视图', view_type: 'grid', config: {} },
])
await page.reload()
await expect(page.locator('.bitable-file-detail-view__table-name')).toBeVisible({
timeout: 10_000,
})
// Initially only 1 tab.
await expect(page.getByRole('tab', { name: '默认视图' })).toBeVisible({ timeout: 5_000 })
// Mock the POST /views response.
await page.route('**/api/v1/bitable/tables/*/views', (route) => {
if (route.request().method() === 'POST') {
const body = route.request().postDataJSON() ?? {}
return route.fulfill({
status: 201,
contentType: 'application/json',
body: JSON.stringify({
success: true,
view: {
id: 'v-new',
table_id: 'tbl',
name: body.name ?? '新视图',
view_type: 'grid',
config: {},
created_at: new Date().toISOString(),
},
}),
})
}
return route.continue()
})
// Open the "新建视图" dropdown and click "表格" (grid).
await page.getByRole('button', { name: /新建视图/ }).click()
await page.locator('.ant-dropdown-menu-item').filter({ hasText: '表格' }).click()
// Fill the name modal.
const nameInput = page.getByPlaceholder('请输入视图名称')
await expect(nameInput).toBeVisible({ timeout: 5_000 })
await nameInput.fill('新创建视图')
await page.getByRole('button', { name: /确\s*定/ }).click()
// The new view tab should appear.
await expect(page.getByRole('tab', { name: '新创建视图' })).toBeVisible({
timeout: 5_000,
})
})
})

View File

@ -383,6 +383,14 @@ class BitableApiClient extends BaseApiClient {
})
}
/**
* Delete a view (U6: R15a). Returns 204 No Content on success.
* The last view of a table cannot be deleted (backend returns 409).
*/
async deleteView(viewId: string): Promise<void> {
await this.request(`/views/${viewId}`, { method: 'DELETE' })
}
// ── File upload (U6: attachment & image) ──────────────
async uploadFile(

View File

@ -53,13 +53,40 @@
>
配置
</a-button>
<!-- U6: delete the active view. Wrapped in a-popconfirm per spec.
Disabled when only one view remains backend rejects with 409
(last view), so we preempt in the UI to avoid a wasted round-trip.
ponytail: single delete button for the active view (not per-tab)
matches the existing "配置" pattern and keeps the tab header clean. -->
<a-popconfirm
v-if="activeKey && views.length > 1"
title="确认删除此视图?"
ok-text="删除"
ok-type="danger"
cancel-text="取消"
@confirm="handleDelete"
>
<a-button
type="text"
size="small"
danger
:icon="h(DeleteOutlined)"
>
删除
</a-button>
</a-popconfirm>
</div>
</template>
<script setup lang="ts">
import { ref, watch, h } from 'vue'
import { Tabs as ATabs, Button as AButton } from 'ant-design-vue'
import { PlusOutlined, FilterOutlined } from '@ant-design/icons-vue'
import {
Tabs as ATabs,
Button as AButton,
Popconfirm as APopconfirm,
} from 'ant-design-vue'
import { PlusOutlined, FilterOutlined, DeleteOutlined } from '@ant-design/icons-vue'
import type { IBitableView, ViewType } from '@/api/bitable'
import { VIEW_TYPE_LIST } from '@/helpers/viewSwitcherUtils'
@ -77,6 +104,7 @@ const emit = defineEmits<{
(e: 'switch', viewId: string): void
(e: 'create', viewType: ViewType): void
(e: 'config'): void
(e: 'delete', viewId: string): void
}>()
// antd Tabs activeKey is string | number | undefined; bridge to/from null
@ -93,6 +121,14 @@ function onSwitch(key: string | number): void {
emit('switch', String(key))
}
// U6: emit delete for the active view. The v-if guard on the popconfirm
// already ensures activeKey is set and views.length > 1.
function handleDelete(): void {
if (activeKey.value) {
emit('delete', activeKey.value)
}
}
// a-menu only emits click for enabled items; disabled items are skipped, so
// no extra guard is needed the "" tooltip is shown via `title`.
function handleTypeClick({ key }: { key: string }): void {

View File

@ -610,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<boolean> {
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 */
@ -718,6 +753,7 @@ export const useBitableStore = defineStore('bitable', () => {
updateView,
updateViewConfig,
switchView,
deleteView,
stopPolling,
}
})

View File

@ -67,6 +67,7 @@
@switch="handleSwitchView"
@create="handleCreateView"
@config="viewConfigOpen = true"
@delete="handleDeleteView"
/>
<div class="bitable-file-detail-view__grid-container">
@ -229,6 +230,12 @@ function handleSwitchView(viewId: string): void {
store.switchView(viewId)
}
// 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<void> {
await store.deleteView(viewId)
}
async function handleCreateView(viewType: ViewType = 'grid'): Promise<void> {
// ponytail: simple prompt for view name; full create modal is overkill for v1.
// viewType comes from the ViewSwitcher dropdown (U4) defaults to 'grid'

View File

@ -28,7 +28,11 @@ 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
@ -686,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)
# ---------------------------------------------------------------------------

View File

@ -5,7 +5,7 @@
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Fischer AgentKit</title>
<script type="module" crossorigin src="/assets/index-Bxc86Kve.js"></script>
<script type="module" crossorigin src="/assets/index-CHtvprqX.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-Ls4ZdRZM.css">
</head>
<body>

View File

@ -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}

View File

@ -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"]

View File

@ -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)
# ---------------------------------------------------------------------------