diff --git a/src/agentkit/server/frontend/e2e/bitable-view.spec.ts b/src/agentkit/server/frontend/e2e/bitable-view.spec.ts index c15f572..da1c4c4 100644 --- a/src/agentkit/server/frontend/e2e/bitable-view.spec.ts +++ b/src/agentkit/server/frontend/e2e/bitable-view.spec.ts @@ -72,3 +72,192 @@ test.describe('Bitable View E2E', () => { await expect(newButton).toBeVisible({ timeout: 10_000 }) }) }) + +// --------------------------------------------------------------------------- +// U4 (R3): View type switcher — "新建视图" exposes 5 view types; only `grid` +// is enabled in v1, the rest are disabled with a "规划中" tooltip. +// +// These tests require a running backend (to create a file + table so the +// ViewSwitcher renders). They skip gracefully if the backend is down, mirroring +// the B1-B3 suite. The POST /views request is intercepted and mocked so the +// view-creation assertion is deterministic and does not depend on backend +// view persistence. +// --------------------------------------------------------------------------- + +test.describe('Bitable View Type Switcher E2E (U4)', () => { + test.beforeAll(async () => { + try { + await waitForServer(undefined, 5_000) + } catch { + test.skip(true, 'Backend not running — skipping view type switcher E2E') + } + }) + + /** + * Log in, create a bitable file + table via the UI, and wait for the + * ViewSwitcher ("新建视图" button) to render. Returns once the grid header + * is visible so callers can interact with the view switcher. + * + * Each test uses a unique file name to avoid collisions with parallel runs. + */ + async function openFileDetailWithTable(page: Page, label: string): Promise { + await loginAndOpenBitable(page) + + // Create a file — opens the file detail view. + await page.getByRole('button', { name: /新建文件/ }).click() + await page.getByPlaceholder('请输入文件名').fill(`U4-${label}`) + await page.getByRole('button', { name: /确\s*定/ }).click() + await expect(page).toHaveURL(/\/bitable\/[^/]+/, { timeout: 10_000 }) + + // Create a table — required for the ViewSwitcher to render. + await page.locator('.table-view-list__header .ant-btn').click() + await expect(page.getByText('新建数据表')).toBeVisible({ timeout: 5_000 }) + await page.getByPlaceholder('请输入表名').fill(`U4表-${label}`) + await page.getByRole('button', { name: /确\s*定/ }).click() + + // Wait for the grid header (and thus the ViewSwitcher) to render. + await expect(page.locator('.bitable-file-detail-view__table-name')).toContainText( + `U4表-${label}`, + { timeout: 10_000 }, + ) + } + + /** Open the "新建视图" dropdown and return the menu item locator list. */ + async function openViewTypeDropdown(page: Page): Promise { + await page.getByRole('button', { name: /新建视图/ }).click() + // The dropdown overlay menu items. + return page.locator('.ant-dropdown-menu-item') + } + + test('E1: "新建视图" dropdown exposes all 5 view types', async ({ page }) => { + await openFileDetailWithTable(page, 'E1-types') + + const items = await openViewTypeDropdown(page) + await expect(items).toHaveCount(5, { timeout: 5_000 }) + + // Labels in spec order: 表格 / 看板 / 画廊 / 甘特 / 表单 + const labels = await items.allTextContents() + expect(labels.map((t) => t.trim())).toEqual(['表格', '看板', '画廊', '甘特', '表单']) + }) + + test('E2: kanban/gallery/gantt/form are disabled with "规划中" tooltip', async ({ page }) => { + await openFileDetailWithTable(page, 'E2-disabled') + + const items = await openViewTypeDropdown(page) + + // The 4 unimplemented types are disabled and carry title="规划中". + for (const label of ['看板', '画廊', '甘特', '表单']) { + const item = items.filter({ hasText: label }) + await expect(item).toHaveClass(/ant-dropdown-menu-item-disabled/, { timeout: 5_000 }) + await expect(item).toHaveAttribute('title', '规划中') + } + + // grid is enabled and has no "规划中" title. + const gridItem = items.filter({ hasText: '表格' }) + await expect(gridItem).not.toHaveClass(/ant-dropdown-menu-item-disabled/) + await expect(gridItem).not.toHaveAttribute('title', '规划中') + }) + + test('E3: selecting grid sends POST /views with view_type=grid', async ({ page }) => { + await openFileDetailWithTable(page, 'E3-create-grid') + + let capturedBody: { name?: string; view_type?: string } | null = null + await page.route('**/api/v1/bitable/tables/*/views', (route) => { + if (route.request().method() !== 'POST') return route.continue() + capturedBody = route.request().postDataJSON() + const name = capturedBody?.name ?? '测试视图' + return route.fulfill({ + status: 201, + contentType: 'application/json', + body: JSON.stringify({ + success: true, + view: { + id: 'view-e3-mock', + table_id: 'tbl-e3', + name, + view_type: 'grid', + config: {}, + created_at: new Date().toISOString(), + }, + }), + }) + }) + + const items = await openViewTypeDropdown(page) + await items.filter({ hasText: '表格' }).click() + + // Name modal (AModal.confirm) appears — fill + confirm. + const nameInput = page.getByPlaceholder('请输入视图名称') + await expect(nameInput).toBeVisible({ timeout: 5_000 }) + await nameInput.fill('网格视图E3') + await page.getByRole('button', { name: /确\s*定/ }).click() + + // Assert the POST body carried view_type=grid (no longer hardcoded elsewhere). + await expect + .poll(async () => capturedBody, { timeout: 5_000 }) + .toMatchObject({ name: '网格视图E3', view_type: 'grid' }) + }) + + test('E4: clicking a disabled type does not open the name modal or fire POST', async ({ page }) => { + await openFileDetailWithTable(page, 'E4-disabled-click') + + let postFired = false + await page.route('**/api/v1/bitable/tables/*/views', (route) => { + if (route.request().method() === 'POST') { + postFired = true + return route.fulfill({ + status: 201, + contentType: 'application/json', + body: JSON.stringify({ success: true, view: { id: 'x', table_id: 'x', name: 'x', view_type: 'kanban', config: {}, created_at: '' } }), + }) + } + return route.continue() + }) + + const items = await openViewTypeDropdown(page) + // Click the disabled "看板" item — antd will not emit a click event for it. + await items.filter({ hasText: '看板' }).click({ force: true }) + + // The name modal must NOT appear, and no POST /views must have fired. + await expect(page.getByPlaceholder('请输入视图名称')).not.toBeVisible({ timeout: 1_500 }) + expect(postFired).toBe(false) + }) + + test('E5: created grid view is added to the view tab list (round-trip contract)', async ({ page }) => { + await openFileDetailWithTable(page, 'E5-roundtrip') + + await page.route('**/api/v1/bitable/tables/*/views', (route) => { + if (route.request().method() !== 'POST') return route.continue() + const body = route.request().postDataJSON() ?? {} + return route.fulfill({ + status: 201, + contentType: 'application/json', + body: JSON.stringify({ + success: true, + view: { + id: 'view-e5-mock', + table_id: 'tbl-e5', + name: body.name ?? 'E5视图', + view_type: body.view_type ?? 'grid', + config: {}, + created_at: new Date().toISOString(), + }, + }), + }) + }) + + const items = await openViewTypeDropdown(page) + await items.filter({ hasText: '表格' }).click() + + const nameInput = page.getByPlaceholder('请输入视图名称') + await expect(nameInput).toBeVisible({ timeout: 5_000 }) + await nameInput.fill('E5网格视图') + await page.getByRole('button', { name: /确\s*定/ }).click() + + // The mock response is pushed into the store; a new tab with the view + // name appears in the ViewSwitcher tabs. + await expect(page.locator('.ant-tabs-tab', { hasText: 'E5网格视图' })).toBeVisible({ + timeout: 5_000, + }) + }) +}) diff --git a/src/agentkit/server/frontend/src/components/bitable/ViewSwitcher.vue b/src/agentkit/server/frontend/src/components/bitable/ViewSwitcher.vue index 4b33441..ac8b21a 100644 --- a/src/agentkit/server/frontend/src/components/bitable/ViewSwitcher.vue +++ b/src/agentkit/server/frontend/src/components/bitable/ViewSwitcher.vue @@ -4,9 +4,8 @@ v-model:activeKey="activeKey" type="editable-card" size="small" - :add-icon="h(PlusOutlined)" + :hide-add="true" @change="onSwitch" - @edit="onEdit" > + + + + 新建视图 + + + + () +const props = withDefaults( + defineProps<{ + views: IBitableView[] + activeViewId: string | null + /** True while a createView POST is in flight — disables + shows spinner. */ + creating?: boolean + }>(), + { creating: false }, +) const emit = defineEmits<{ (e: 'switch', viewId: string): void - (e: 'create'): void + (e: 'create', viewType: ViewType): void (e: 'config'): void }>() @@ -59,11 +93,10 @@ function onSwitch(key: string | number): void { emit('switch', String(key)) } -function onEdit(_targetKey: unknown, action: 'add' | 'remove'): void { - if (action === 'add') { - emit('create') - } - // remove is disabled (closable=false) — no-op +// 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 { + emit('create', key as ViewType) } @@ -71,13 +104,19 @@ function onEdit(_targetKey: unknown, action: 'add' | 'remove'): void { .view-switcher { display: flex; align-items: center; - gap: 8px; - padding: 0 16px; - border-bottom: 1px solid var(--border-color, #f0f0f0); + gap: var(--bitable-spacing-sm); + padding: 0 var(--bitable-spacing-lg); + border-bottom: 1px solid var(--bitable-color-border); } .view-switcher :deep(.ant-tabs) { flex: 1; min-width: 0; } + +.view-switcher__type-item { + display: inline-flex; + align-items: center; + gap: var(--bitable-spacing-xs); +} 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/views/BitableFileDetailView.vue b/src/agentkit/server/frontend/src/views/BitableFileDetailView.vue index 8185e96..1fc72a2 100644 --- a/src/agentkit/server/frontend/src/views/BitableFileDetailView.vue +++ b/src/agentkit/server/frontend/src/views/BitableFileDetailView.vue @@ -63,6 +63,7 @@ (route.params.fileId as string) ?? '') const tableId = computed(() => (route.params.tableId as string) ?? '') @@ -225,8 +229,10 @@ 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 +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 +245,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 + } }, }) }