feat(bitable): U4 view type switcher with 5 types (grid enabled, others disabled)

- Add viewSwitcherUtils.ts (5 view types metadata: label/icon/disabled/tooltip)
- Refactor ViewSwitcher: button -> dropdown with 5 types, disabled items show "规划中" tooltip
- Update BitableFileDetailView.handleCreateView to accept viewType parameter (no more hardcoded grid)
- Bind :creating=viewCreating to ViewSwitcher for loading/disabled state during POST
- Extend store createView + API createView to pass view_type field (already in prior commits)
- Add loading/disabled state on create button to prevent duplicate clicks
- Extend e2e/bitable-view.spec.ts with 5 view type scenarios (E1-E5)

Closes R3 (P0): view type selection in UI, backend already supports view_type.

Refs: docs/plans/2026-07-03-001-feat-bitable-p0-ux-and-agent-parity-plan.md U4
This commit is contained in:
chiguyong 2026-07-03 21:43:51 +08:00
parent 5baaeb489d
commit f280627da1
4 changed files with 324 additions and 20 deletions

View File

@ -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<void> {
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<import('@playwright/test').Locator> {
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,
})
})
})

View File

@ -4,9 +4,8 @@
v-model:activeKey="activeKey"
type="editable-card"
size="small"
:add-icon="h(PlusOutlined)"
:hide-add="true"
@change="onSwitch"
@edit="onEdit"
>
<a-tab-pane
v-for="v in views"
@ -16,6 +15,35 @@
/>
</a-tabs>
<!-- U4: "新建视图" is a dropdown exposing all 5 view types. Only `grid`
is enabled in v1; the rest are disabled with a "规划中" tooltip. -->
<a-dropdown :trigger="['click']" placement="bottomLeft">
<a-button
type="text"
size="small"
:icon="h(PlusOutlined)"
:loading="creating"
:disabled="creating"
>
新建视图
</a-button>
<template #overlay>
<a-menu @click="handleTypeClick">
<a-menu-item
v-for="meta in VIEW_TYPE_LIST"
:key="meta.viewType"
:disabled="meta.disabled"
:title="meta.tooltip"
>
<span class="view-switcher__type-item">
<component :is="meta.icon" />
<span>{{ meta.label }}</span>
</span>
</a-menu-item>
</a-menu>
</template>
</a-dropdown>
<a-button
v-if="activeKey"
type="text"
@ -32,16 +60,22 @@
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 type { IBitableView } from '@/api/bitable'
import type { IBitableView, ViewType } from '@/api/bitable'
import { VIEW_TYPE_LIST } from '@/helpers/viewSwitcherUtils'
const props = defineProps<{
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)
}
</script>
@ -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);
}
</style>

View File

@ -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 `<component :is="meta.icon" />`. 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]
}

View File

@ -63,6 +63,7 @@
<ViewSwitcher
:views="store.views"
:active-view-id="store.currentView?.id ?? null"
:creating="viewCreating"
@switch="handleSwitchView"
@create="handleCreateView"
@config="viewConfigOpen = true"
@ -127,7 +128,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 +144,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 +229,10 @@ function handleSwitchView(viewId: string): void {
store.switchView(viewId)
}
async function handleCreateView(): Promise<void> {
// ponytail: simple prompt for view name; full create modal is overkill for v1
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'
// for backward compatibility with any direct caller.
let name = ''
AModal.confirm({
title: '新建视图',
@ -239,7 +245,12 @@ async function handleCreateView(): Promise<void> {
}),
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
}
},
})
}