feat: Bitable P0 UX Polish + Agent Parity #23
|
|
@ -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,
|
||||
})
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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<{
|
||||
views: IBitableView[]
|
||||
activeViewId: string | null
|
||||
}>()
|
||||
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>
|
||||
|
|
|
|||
|
|
@ -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]
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
},
|
||||
})
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in New Issue