feat(bitable): U5 R4 grouping (max 3 fields) + conditional formatting (7 operators)

- GroupingEditor: multi-select field picker (max 3), per-level direction
  toggle, reorder buttons, "已知限制:不支持跨分组多选" note, empty state
- ConditionalFormatEditor: per-rule enable/field/operator/value/color/bold,
  8 color keys, WCAG 1.4.1 bold default true, first-match-wins footer legend
- BitableGrid: unified section rendering (grouped/ungrouped via single
  vxe-grid declaration), group headers as separate divs (CF only on data
  cells), CF via row-config.className, multi-grid instance map for refresh
- groupingRulesUtils: pure functions for CF matching (7 operators), group
  tree builder, SUM/AVG aggregation, CSS var mappers, self-check on load
- view_config.py: Pydantic v2 validation (MAX_GROUP_BY_FIELDS=3, 7
  operators, 8 color keys, extra="forbid" on sub-models)
- routes/bitable.py: validate_view_config on PATCH (HTTP 422 on error)
- stores/bitable.ts: updateViewConfig action (merges U5 sub-keys, preserves
  filters/sort/hidden_fields)
- ViewConfigPanel: grouping + conditional-format tabs
- E2E: 8 scenarios (G1-G8: single/multi grouping, collapse/expand, CF
  equals/between, combined, aggregation)
- Tests: 54 unit tests (19 grouping + 35 CF), 2 PG-marked skipped
This commit is contained in:
chiguyong 2026-07-03 22:33:18 +08:00
parent f280627da1
commit e931fbef2d
13 changed files with 2885 additions and 120 deletions

View File

@ -0,0 +1,130 @@
"""Pydantic validation models for bitable view config substructures (U5).
The ``View.config`` JSONB column holds arbitrary JSON. To keep grouping +
conditional formatting well-formed, the route layer validates the
``group_by`` and ``conditional_formatting`` sub-keys against the schemas
defined here before persisting. Other config keys (filters / sort /
hidden_fields) are left untouched this validation is additive.
ponytail: the validator is a pure function over a dict; no DB access, no
side effects. The route converts ``ValidationError`` to HTTP 422.
"""
from __future__ import annotations
from typing import Literal
from pydantic import BaseModel, ConfigDict, Field, ValidationError
# 7 operators supported by the conditional format rule matcher in
# groupingRulesUtils.ts. Kept in sync via test_conditional_formatting.py.
ConditionalOperator = Literal[
"equals",
"not-equals",
"contains",
"is-empty",
"greater-than",
"less-than",
"between",
]
# 8 color keys mapped to --bitable-cf-<key>-{bg,fg} tokens in
# bitable-tokens.css. Adding a new color requires a token + a Literal entry.
ColorKey = Literal[
"red",
"orange",
"yellow",
"green",
"blue",
"purple",
"gray",
"neutral",
]
GroupDirection = Literal["asc", "desc"]
# U5 spec: max 3 group_by levels. Matches Feishu / Twenty UX.
MAX_GROUP_BY_FIELDS = 3
class GroupByItem(BaseModel):
"""A single grouping level — field + sort direction."""
model_config = ConfigDict(extra="forbid")
field_id: str = Field(min_length=1, max_length=64)
direction: GroupDirection = "asc"
class ConditionalFormatRule(BaseModel):
"""A single conditional formatting rule.
``bold`` defaults to True for WCAG 1.4.1 (color-blind accessibility)
color alone must not be the only visual cue, so we also bold the text
unless the user explicitly disables it.
"""
model_config = ConfigDict(extra="forbid")
field_id: str = Field(min_length=1, max_length=64)
operator: ConditionalOperator
# value is required even for is-empty (the matcher ignores it, but the
# schema keeps the field uniform — UI sends "" for is-empty).
value: str = Field(default="", max_length=512)
color_key: ColorKey
bold: bool = True
enabled: bool = True
class ViewConfigSchema(BaseModel):
"""Validated sub-structure of View.config for U5.
Only the ``group_by`` and ``conditional_formatting`` keys are validated;
other config keys (filters / sort / hidden_fields) pass through unchanged.
"""
model_config = ConfigDict(extra="ignore")
group_by: list[GroupByItem] = Field(default_factory=list, max_length=MAX_GROUP_BY_FIELDS)
conditional_formatting: list[ConditionalFormatRule] = Field(default_factory=list)
class ViewConfigValidationError(ValueError):
"""Raised when View.config fails U5 schema validation.
Subclass of ValueError so existing ``except ValueError`` handlers in the
route layer pick it up. Carries the structured error detail for the 422
response body.
"""
def __init__(self, message: str, errors: list[dict[str, object]]) -> None:
super().__init__(message)
self.errors = errors
def validate_view_config(config: dict[str, object] | None) -> None:
"""Validate the U5 sub-keys of a View.config dict in place.
Raises ``ViewConfigValidationError`` if ``group_by`` or
``conditional_formatting`` are present but malformed. Absent keys are
treated as empty lists (no validation needed).
Other config keys (filters / sort / hidden_fields) are NOT validated
here they have their own loose shape and remain unchanged.
"""
if not config:
return
if "group_by" not in config and "conditional_formatting" not in config:
return
try:
ViewConfigSchema.model_validate(
{
"group_by": config.get("group_by", []),
"conditional_formatting": config.get("conditional_formatting", []),
}
)
except ValidationError as exc:
raise ViewConfigValidationError(
"Invalid view config (group_by / conditional_formatting)",
exc.errors(),
) from exc

View File

@ -90,6 +90,7 @@ declare module 'vue' {
CollaborationGraphCard: typeof import('./src/components/chat/messages/CollaborationGraphCard.vue')['default'] CollaborationGraphCard: typeof import('./src/components/chat/messages/CollaborationGraphCard.vue')['default']
ColumnHeaderMenu: typeof import('./src/components/bitable/ColumnHeaderMenu.vue')['default'] ColumnHeaderMenu: typeof import('./src/components/bitable/ColumnHeaderMenu.vue')['default']
CommandHistory: typeof import('./src/components/terminal/CommandHistory.vue')['default'] CommandHistory: typeof import('./src/components/terminal/CommandHistory.vue')['default']
ConditionalFormatEditor: typeof import('./src/components/bitable/ConditionalFormatEditor.vue')['default']
ConditionNode: typeof import('./src/components/workflow/ConditionNode.vue')['default'] ConditionNode: typeof import('./src/components/workflow/ConditionNode.vue')['default']
ContextPill: typeof import('./src/components/chat/ContextPill.vue')['default'] ContextPill: typeof import('./src/components/chat/ContextPill.vue')['default']
DashboardOverview: typeof import('./src/components/evolution/DashboardOverview.vue')['default'] DashboardOverview: typeof import('./src/components/evolution/DashboardOverview.vue')['default']
@ -102,6 +103,7 @@ declare module 'vue' {
DocumentsTab: typeof import('./src/components/layout/tabs/DocumentsTab.vue')['default'] DocumentsTab: typeof import('./src/components/layout/tabs/DocumentsTab.vue')['default']
DocumentUpload: typeof import('./src/components/kb/DocumentUpload.vue')['default'] DocumentUpload: typeof import('./src/components/kb/DocumentUpload.vue')['default']
ErrorCard: typeof import('./src/components/chat/messages/ErrorCard.vue')['default'] ErrorCard: typeof import('./src/components/chat/messages/ErrorCard.vue')['default']
ErrorState: typeof import('./src/components/bitable/ErrorState.vue')['default']
EventBadge: typeof import('./src/components/calendar/EventBadge.vue')['default'] EventBadge: typeof import('./src/components/calendar/EventBadge.vue')['default']
EventEditor: typeof import('./src/components/calendar/EventEditor.vue')['default'] EventEditor: typeof import('./src/components/calendar/EventEditor.vue')['default']
ExperiencePanel: typeof import('./src/components/evolution/ExperiencePanel.vue')['default'] ExperiencePanel: typeof import('./src/components/evolution/ExperiencePanel.vue')['default']
@ -110,6 +112,7 @@ declare module 'vue' {
ExpertTeamView: typeof import('./src/components/chat/ExpertTeamView.vue')['default'] ExpertTeamView: typeof import('./src/components/chat/ExpertTeamView.vue')['default']
FieldConfigForm: typeof import('./src/components/bitable/FieldConfigForm.vue')['default'] FieldConfigForm: typeof import('./src/components/bitable/FieldConfigForm.vue')['default']
FieldManagePanel: typeof import('./src/components/bitable/FieldManagePanel.vue')['default'] FieldManagePanel: typeof import('./src/components/bitable/FieldManagePanel.vue')['default']
FieldTypeIcon: typeof import('./src/components/bitable/FieldTypeIcon.vue')['default']
FileAttachment: typeof import('./src/components/chat/messages/FileAttachment.vue')['default'] FileAttachment: typeof import('./src/components/chat/messages/FileAttachment.vue')['default']
FileCard: typeof import('./src/components/bitable/FileCard.vue')['default'] FileCard: typeof import('./src/components/bitable/FileCard.vue')['default']
FileCreateModal: typeof import('./src/components/bitable/FileCreateModal.vue')['default'] FileCreateModal: typeof import('./src/components/bitable/FileCreateModal.vue')['default']
@ -117,12 +120,15 @@ declare module 'vue' {
FileTree: typeof import('./src/components/code/FileTree.vue')['default'] FileTree: typeof import('./src/components/code/FileTree.vue')['default']
FilterBuilder: typeof import('./src/components/bitable/FilterBuilder.vue')['default'] FilterBuilder: typeof import('./src/components/bitable/FilterBuilder.vue')['default']
FlowCanvas: typeof import('./src/components/workflow/FlowCanvas.vue')['default'] FlowCanvas: typeof import('./src/components/workflow/FlowCanvas.vue')['default']
GroupingEditor: typeof import('./src/components/bitable/GroupingEditor.vue')['default']
IconNav: typeof import('./src/components/layout/IconNav.vue')['default'] IconNav: typeof import('./src/components/layout/IconNav.vue')['default']
ImageCell: typeof import('./src/components/bitable/ImageCell.vue')['default'] ImageCell: typeof import('./src/components/bitable/ImageCell.vue')['default']
InlineFieldConfigurator: typeof import('./src/components/bitable/InlineFieldConfigurator.vue')['default']
InvitationManager: typeof import('./src/components/calendar/InvitationManager.vue')['default'] InvitationManager: typeof import('./src/components/calendar/InvitationManager.vue')['default']
KBSettings: typeof import('./src/components/kb/KBSettings.vue')['default'] KBSettings: typeof import('./src/components/kb/KBSettings.vue')['default']
KnowledgeTab: typeof import('./src/components/layout/tabs/KnowledgeTab.vue')['default'] KnowledgeTab: typeof import('./src/components/layout/tabs/KnowledgeTab.vue')['default']
ListView: typeof import('./src/components/calendar/ListView.vue')['default'] ListView: typeof import('./src/components/calendar/ListView.vue')['default']
LoadingState: typeof import('./src/components/bitable/LoadingState.vue')['default']
MentionDropdown: typeof import('./src/components/chat/MentionDropdown.vue')['default'] MentionDropdown: typeof import('./src/components/chat/MentionDropdown.vue')['default']
MessageShell: typeof import('./src/components/chat/messages/MessageShell.vue')['default'] MessageShell: typeof import('./src/components/chat/messages/MessageShell.vue')['default']
MetricsChart: typeof import('./src/components/evolution/MetricsChart.vue')['default'] MetricsChart: typeof import('./src/components/evolution/MetricsChart.vue')['default']
@ -137,6 +143,7 @@ declare module 'vue' {
PlanVisualization: typeof import('./src/components/chat/PlanVisualization.vue')['default'] PlanVisualization: typeof import('./src/components/chat/PlanVisualization.vue')['default']
PropertyPanel: typeof import('./src/components/workflow/PropertyPanel.vue')['default'] PropertyPanel: typeof import('./src/components/workflow/PropertyPanel.vue')['default']
QuadrantPanel: typeof import('./src/components/layout/QuadrantPanel.vue')['default'] QuadrantPanel: typeof import('./src/components/layout/QuadrantPanel.vue')['default']
RecordDetailDrawer: typeof import('./src/components/bitable/RecordDetailDrawer.vue')['default']
ReminderConfig: typeof import('./src/components/calendar/ReminderConfig.vue')['default'] ReminderConfig: typeof import('./src/components/calendar/ReminderConfig.vue')['default']
ReviewResultCard: typeof import('./src/components/chat/messages/ReviewResultCard.vue')['default'] ReviewResultCard: typeof import('./src/components/chat/messages/ReviewResultCard.vue')['default']
RightPanel: typeof import('./src/components/layout/RightPanel.vue')['default'] RightPanel: typeof import('./src/components/layout/RightPanel.vue')['default']

View File

@ -0,0 +1,432 @@
/**
* E2E tests for Bitable grouping + conditional formatting (U5 / R4).
*
* Flow: login create file create table seed records via API
* PATCH view config with group_by / conditional_formatting
* reload grid assert group headers + CF row coloring.
*
* ponytail: setup goes through the REST API (page.evaluate fetch) rather
* than UI clicks setup is not the thing under test. Ceiling: API path
* couples the test to the /views PATCH contract; if that contract shifts,
* setup breaks (acceptable the same contract is exercised by the UI).
*
* Requires: running backend with PostgreSQL. Skips gracefully if unreachable.
*/
import { test, expect, type Page } from '@playwright/test'
import { TEST_USER, clearAuth, waitForServer, API_BASE } from './helpers'
interface IField {
id: string
name: string
field_type: string
owner: string
}
interface IRecord {
id: string
values: Record<string, unknown>
}
interface IView {
id: string
name: string
config: Record<string, unknown>
}
const API_BASE_STR = API_BASE
async function loginAndCreateTable(
page: Page,
fileName: string,
tableName: string,
): Promise<{ tableId: string; fields: IField[]; viewId: string }> {
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 })
// Create file
await page.getByRole('button', { name: /新建文件/ }).click()
await page.getByPlaceholder('请输入文件名').fill(fileName)
await page.getByRole('button', { name: /确\s*定/ }).click()
await expect(page).toHaveURL(/\/bitable\/[^/]+/, { timeout: 10_000 })
// Create table
await page.locator('.table-view-list__header .ant-btn').click()
await page.getByPlaceholder('请输入表名').fill(tableName)
await page.getByRole('button', { name: /确\s*定/ }).click()
await expect(page.locator('.bitable-file-detail-view__table-name')).toContainText(
tableName,
{ timeout: 10_000 },
)
// Fetch table id + fields + default view via API
const { tableId, fields, viewId } = await page.evaluate(async (apiBase: string) => {
const token = localStorage.getItem('agentkit.access_token') ?? ''
const tablesResp = await fetch(`${apiBase}/bitable/tables`, {
headers: { Authorization: `Bearer ${token}` },
})
const tablesJson = (await tablesResp.json()) as { tables: { id: string }[] }
const table = tablesJson.tables[0]
const fieldsResp = await fetch(`${apiBase}/bitable/tables/${table.id}/fields`, {
headers: { Authorization: `Bearer ${token}` },
})
const fieldsJson = (await fieldsResp.json()) as { fields: IField[] }
const viewsResp = await fetch(`${apiBase}/bitable/tables/${table.id}/views`, {
headers: { Authorization: `Bearer ${token}` },
})
const viewsJson = (await viewsResp.json()) as { views: IView[] }
return {
tableId: table.id,
fields: fieldsJson.fields,
viewId: viewsJson.views[0]?.id ?? '',
}
}, API_BASE_STR)
return { tableId, fields, viewId }
}
async function createRecordViaApi(
page: Page,
tableId: string,
values: Record<string, unknown>,
): Promise<IRecord> {
return await page.evaluate(
async ({ tableId, values, apiBase }) => {
const token = localStorage.getItem('agentkit.access_token') ?? ''
const resp = await fetch(`${apiBase}/bitable/tables/${tableId}/records`, {
method: 'POST',
headers: {
Authorization: `Bearer ${token}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({ records: [values] }),
})
const json = (await resp.json()) as { records: IRecord[] }
return json.records[0]
},
{ tableId, values, apiBase: API_BASE_STR },
)
}
async function createFieldViaApi(
page: Page,
tableId: string,
name: string,
fieldType: string,
owner: string = 'user',
): Promise<IField> {
return await page.evaluate(
async ({ tableId, name, fieldType, owner, apiBase }) => {
const token = localStorage.getItem('agentkit.access_token') ?? ''
const resp = await fetch(`${apiBase}/bitable/tables/${tableId}/fields`, {
method: 'POST',
headers: {
Authorization: `Bearer ${token}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({ name, field_type: fieldType, owner }),
})
const json = (await resp.json()) as { field: IField }
return json.field
},
{ tableId, name, fieldType, owner, apiBase: API_BASE_STR },
)
}
async function updateViewConfig(
page: Page,
viewId: string,
config: Record<string, unknown>,
): Promise<void> {
await page.evaluate(
async ({ viewId, config, apiBase }) => {
const token = localStorage.getItem('agentkit.access_token') ?? ''
await fetch(`${apiBase}/bitable/views/${viewId}`, {
method: 'PATCH',
headers: {
Authorization: `Bearer ${token}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({ config }),
})
},
{ viewId, config, apiBase: API_BASE_STR },
)
}
async function reloadGrid(page: Page): Promise<void> {
await page.reload()
await expect(page.locator('.bitable-grid-scope')).toBeVisible({ timeout: 15_000 })
}
test.describe('Bitable Grouping + Conditional Formatting E2E (U5)', () => {
test.beforeAll(async () => {
try {
await waitForServer(undefined, 5_000)
} catch {
test.skip(true, 'Backend not running — skipping grouping E2E')
}
})
test('G1: group by one field renders group headers with value + count', async ({ page }) => {
const { tableId, fields, viewId } = await loginAndCreateTable(page, 'E2E分组G1', '测试表')
const titleField = fields.find((f) => f.field_type === 'text')!
const statusField = await createFieldViaApi(page, tableId, '状态', 'select')
await createRecordViaApi(page, tableId, { [titleField.id]: '记录A', [statusField.id]: '进行中' })
await createRecordViaApi(page, tableId, { [titleField.id]: '记录B', [statusField.id]: '已完成' })
await createRecordViaApi(page, tableId, { [titleField.id]: '记录C', [statusField.id]: '进行中' })
await updateViewConfig(page, viewId, {
group_by: [{ field_id: statusField.id, direction: 'asc' }],
})
await reloadGrid(page)
// Group headers appear
const headers = page.locator('.bitable-grid-scope__group-header')
await expect(headers).toHaveCount(2, { timeout: 10_000 })
// The "进行中" group has 2 records
const headerInProgress = headers.filter({ hasText: '进行中' })
await expect(headerInProgress).toBeVisible()
await expect(headerInProgress).toContainText('2 条')
// The "已完成" group has 1 record
const headerDone = headers.filter({ hasText: '已完成' })
await expect(headerDone).toBeVisible()
await expect(headerDone).toContainText('1 条')
})
test('G2: clicking a group header collapses its records', async ({ page }) => {
const { tableId, fields, viewId } = await loginAndCreateTable(page, 'E2E分组G2', '测试表')
const titleField = fields.find((f) => f.field_type === 'text')!
const statusField = await createFieldViaApi(page, tableId, '状态', 'select')
await createRecordViaApi(page, tableId, { [titleField.id]: '记录A', [statusField.id]: '进行中' })
await createRecordViaApi(page, tableId, { [titleField.id]: '记录B', [statusField.id]: '已完成' })
await updateViewConfig(page, viewId, {
group_by: [{ field_id: statusField.id, direction: 'asc' }],
})
await reloadGrid(page)
const headerInProgress = page.locator('.bitable-grid-scope__group-header').first()
await expect(headerInProgress).toBeVisible({ timeout: 10_000 })
// Count vxe-table body rows before collapse (should have data rows)
const rowsBefore = await page.locator('.vxe-body--row').count()
expect(rowsBefore).toBeGreaterThan(0)
// Click to collapse
await headerInProgress.click()
// After collapse, the caret shows ▸ (collapsed indicator)
await expect(headerInProgress.locator('.bitable-grid-scope__caret')).toContainText('▸')
})
test('G3: clicking a collapsed group header expands its records', async ({ page }) => {
const { tableId, fields, viewId } = await loginAndCreateTable(page, 'E2E分组G3', '测试表')
const titleField = fields.find((f) => f.field_type === 'text')!
const statusField = await createFieldViaApi(page, tableId, '状态', 'select')
await createRecordViaApi(page, tableId, { [titleField.id]: '记录A', [statusField.id]: '进行中' })
await createRecordViaApi(page, tableId, { [titleField.id]: '记录B', [statusField.id]: '已完成' })
await updateViewConfig(page, viewId, {
group_by: [{ field_id: statusField.id, direction: 'asc' }],
})
await reloadGrid(page)
const header = page.locator('.bitable-grid-scope__group-header').first()
await expect(header).toBeVisible({ timeout: 10_000 })
// Collapse
await header.click()
await expect(header.locator('.bitable-grid-scope__caret')).toContainText('▸')
// Expand
await header.click()
await expect(header.locator('.bitable-grid-scope__caret')).toContainText('▾')
})
test('G4: multi-level grouping (2 fields) renders nested headers', async ({ page }) => {
const { tableId, fields, viewId } = await loginAndCreateTable(page, 'E2E分组G4', '测试表')
const titleField = fields.find((f) => f.field_type === 'text')!
const statusField = await createFieldViaApi(page, tableId, '状态', 'select')
const priorityField = await createFieldViaApi(page, tableId, '优先级', 'select')
await createRecordViaApi(page, tableId, {
[titleField.id]: '记录A',
[statusField.id]: '进行中',
[priorityField.id]: '高',
})
await createRecordViaApi(page, tableId, {
[titleField.id]: '记录B',
[statusField.id]: '进行中',
[priorityField.id]: '低',
})
await createRecordViaApi(page, tableId, {
[titleField.id]: '记录C',
[statusField.id]: '已完成',
[priorityField.id]: '高',
})
await updateViewConfig(page, viewId, {
group_by: [
{ field_id: statusField.id, direction: 'asc' },
{ field_id: priorityField.id, direction: 'asc' },
],
})
await reloadGrid(page)
// 2 top-level groups (进行中, 已完成) + 3 sub-groups (高, 低, 高) = 5 headers
const headers = page.locator('.bitable-grid-scope__group-header')
await expect(headers).toHaveCount(5, { timeout: 10_000 })
// Sub-group headers have more padding (depth=1)
const subHeaders = headers.filter({ hasText: '高' })
await expect(subHeaders.first()).toBeVisible()
})
test('G5: conditional format "equals" colors matching rows', async ({ page }) => {
const { tableId, fields, viewId } = await loginAndCreateTable(page, 'E2E条件格式G5', '测试表')
const titleField = fields.find((f) => f.field_type === 'text')!
const statusField = await createFieldViaApi(page, tableId, '状态', 'select')
await createRecordViaApi(page, tableId, { [titleField.id]: '记录A', [statusField.id]: '已完成' })
await createRecordViaApi(page, tableId, { [titleField.id]: '记录B', [statusField.id]: '进行中' })
await updateViewConfig(page, viewId, {
conditional_formatting: [
{
field_id: statusField.id,
operator: 'equals',
value: '已完成',
color_key: 'green',
bold: true,
enabled: true,
},
],
})
await reloadGrid(page)
// At least one row should have the green CF class
const greenRows = page.locator('.bitable-cf--green')
await expect(greenRows.first()).toBeVisible({ timeout: 10_000 })
// The green row should also be bold
const greenBoldRows = page.locator('.bitable-cf--green.bitable-cf--bold')
await expect(greenBoldRows.first()).toBeVisible()
})
test('G6: conditional format "between" colors rows in numeric range', async ({ page }) => {
const { tableId, fields, viewId } = await loginAndCreateTable(page, 'E2E条件格式G6', '测试表')
const titleField = fields.find((f) => f.field_type === 'text')!
const numberField = await createFieldViaApi(page, tableId, '数值', 'number')
await createRecordViaApi(page, tableId, { [titleField.id]: '记录A', [numberField.id]: 50 })
await createRecordViaApi(page, tableId, { [titleField.id]: '记录B', [numberField.id]: 150 })
await createRecordViaApi(page, tableId, { [titleField.id]: '记录C', [numberField.id]: 250 })
await updateViewConfig(page, viewId, {
conditional_formatting: [
{
field_id: numberField.id,
operator: 'between',
value: '100,200',
color_key: 'yellow',
bold: false,
enabled: true,
},
],
})
await reloadGrid(page)
// Only the row with value 150 should be colored yellow
const yellowRows = page.locator('.bitable-cf--yellow')
await expect(yellowRows).toHaveCount(1, { timeout: 10_000 })
})
test('G7: group + CF combined — CF only on data cells, not group headers', async ({ page }) => {
const { tableId, fields, viewId } = await loginAndCreateTable(page, 'E2E组合G7', '测试表')
const titleField = fields.find((f) => f.field_type === 'text')!
const statusField = await createFieldViaApi(page, tableId, '状态', 'select')
await createRecordViaApi(page, tableId, { [titleField.id]: '记录A', [statusField.id]: '已完成' })
await createRecordViaApi(page, tableId, { [titleField.id]: '记录B', [statusField.id]: '进行中' })
await createRecordViaApi(page, tableId, { [titleField.id]: '记录C', [statusField.id]: '已完成' })
await updateViewConfig(page, viewId, {
group_by: [{ field_id: statusField.id, direction: 'asc' }],
conditional_formatting: [
{
field_id: statusField.id,
operator: 'equals',
value: '已完成',
color_key: 'green',
bold: true,
enabled: true,
},
],
})
await reloadGrid(page)
// Group headers exist
const headers = page.locator('.bitable-grid-scope__group-header')
await expect(headers).toHaveCount(2, { timeout: 10_000 })
// Group headers must NOT have CF classes
const coloredHeaders = headers.filter({
has: page.locator('.bitable-cf--green'),
})
await expect(coloredHeaders).toHaveCount(0)
// Data rows DO have CF classes (the "已完成" group's rows are green)
const greenDataRows = page.locator('.vxe-body--row.bitable-cf--green')
await expect(greenDataRows.first()).toBeVisible({ timeout: 10_000 })
})
test('G8: group header shows SUM + AVG aggregation for number fields', async ({ page }) => {
const { tableId, fields, viewId } = await loginAndCreateTable(page, 'E2E聚合G8', '测试表')
const titleField = fields.find((f) => f.field_type === 'text')!
const statusField = await createFieldViaApi(page, tableId, '状态', 'select')
const amountField = await createFieldViaApi(page, tableId, '金额', 'number')
await createRecordViaApi(page, tableId, {
[titleField.id]: '记录A',
[statusField.id]: '进行中',
[amountField.id]: 100,
})
await createRecordViaApi(page, tableId, {
[titleField.id]: '记录B',
[statusField.id]: '进行中',
[amountField.id]: 200,
})
await createRecordViaApi(page, tableId, {
[titleField.id]: '记录C',
[statusField.id]: '已完成',
[amountField.id]: 50,
})
await updateViewConfig(page, viewId, {
group_by: [{ field_id: statusField.id, direction: 'asc' }],
})
await reloadGrid(page)
// The "进行中" group header should show aggregation: 合计 300 · 均值 150
const headerInProgress = page
.locator('.bitable-grid-scope__group-header')
.filter({ hasText: '进行中' })
await expect(headerInProgress).toBeVisible({ timeout: 10_000 })
await expect(headerInProgress).toContainText('合计')
await expect(headerInProgress).toContainText('300')
await expect(headerInProgress).toContainText('均值')
await expect(headerInProgress).toContainText('150')
})
})

View File

@ -1,104 +1,134 @@
<template> <template>
<div class="bitable-grid-scope"> <div class="bitable-grid-scope">
<vxe-grid <!-- U5: Unified section rendering grouping disabled produces a single
ref="gridRef" data section (node=null); grouping enabled produces interleaved
:data="rows" header + data sections. The vxe-grid declaration + all slots are
:columns="gridColumns" written ONCE here (no duplication). -->
:height="height" <template v-for="(section, idx) in groupSections" :key="`sec_${idx}`">
:loading="loading" <!-- Group header (grouping mode only) -->
:row-config="{ keyField: '_recordId' }" <div
:column-config="{ resizable: true }" v-if="section.type === 'header'"
:virtual-y-config="{ enabled: true, gt: 60 }" class="bitable-grid-scope__group-header"
:virtual-x-config="{ enabled: true, gt: 20 }" :style="{ paddingLeft: `${section.node.depth * 24 + 8}px` }"
:edit-config="{ @click="toggleGroup(section.node)"
trigger: 'click',
mode: 'cell',
showStatus: true,
autoClear: false,
}"
@edit-closed="onEditClosed"
@cell-click="onCellClick"
>
<template #empty>
<a-empty :description="emptyText" />
</template>
<!-- Custom cell renderers for attachment/image fields (U6) -->
<template
v-for="f in attachmentFields"
:key="f.id"
#[`cell_${f.id}`]="{ row }"
> >
<AttachmentCell <span class="bitable-grid-scope__caret">{{ isCollapsed(section.node) ? '▸' : '▾' }}</span>
v-if="f.field_type === 'attachment'" <span class="bitable-grid-scope__group-key">{{ section.node.key || '(空)' }}</span>
:files="(row[f.id] as IAttachmentMeta[] | null | undefined)" <span class="bitable-grid-scope__group-count">{{ section.node.records.length }} </span>
/> <template v-for="(agg, fid) in section.node.aggregations" :key="fid">
<ImageCell <span class="bitable-grid-scope__group-agg">
v-else-if="f.field_type === 'image'" {{ fieldName(String(fid)) }}<template v-if="agg.sum != null">合计 {{ formatNum(agg.sum) }}</template><template v-if="agg.avg != null"> · 均值 {{ formatNum(agg.avg) }}</template>
:images="(row[f.id] as IAttachmentMeta[] | null | undefined)" </span>
/> </template>
</template> </div>
<!-- Column header dropdown menus (U4) + inline field config (U2) -->
<template <!-- Data section: vxe-grid for this group's records (or all records) -->
v-for="f in fields" <div
:key="`hdr_${f.id}`" v-else
#[`header_${f.id}`] v-show="section.node === null || !isCollapsed(section.node)"
class="bitable-grid-scope__group-grid"
> >
<a-popover <vxe-grid
:open="editingFieldId === f.id" :ref="(el: unknown) => onGridRef(idx, el)"
:trigger="[]" :data="section.node ? rowsForGroup(section.node) : rows"
placement="bottomLeft" :columns="gridColumns"
overlay-class-name="bitable-inline-config-popover" :height="groupingEnabled ? 'auto' : height"
:overlay-style="{ width: '340px' }" :loading="loading"
:row-config="rowConfig"
:column-config="{ resizable: true }"
:virtual-y-config="{ enabled: !groupingEnabled, gt: 60 }"
:virtual-x-config="{ enabled: true, gt: 20 }"
:edit-config="{
trigger: 'click',
mode: 'cell',
showStatus: true,
autoClear: false,
}"
@edit-closed="onEditClosed"
@cell-click="onCellClick"
> >
<ColumnHeaderMenu <template #empty>
:field="f" <a-empty :description="emptyText" />
@edit-inline="startInlineEdit(f.id)" </template>
@open-batch-panel="emit('config-field', $event)" <!-- Custom cell renderers for attachment/image fields (U6) -->
@hide="emit('hide-field', $event)" <template
@delete="emit('delete-field', $event)" v-for="f in attachmentFields"
/> :key="f.id"
<template #content> #[`cell_${f.id}`]="{ row }"
<InlineFieldConfigurator >
v-if="editingFieldId === f.id" <AttachmentCell
:field="f" v-if="f.field_type === 'attachment'"
@saved="onInlineSaved" :files="(row[f.id] as IAttachmentMeta[] | null | undefined)"
@cancel="onInlineCancel" />
<ImageCell
v-else-if="f.field_type === 'image'"
:images="(row[f.id] as IAttachmentMeta[] | null | undefined)"
/> />
</template> </template>
</a-popover> <!-- Column header dropdown menus (U4) + inline field config (U2) -->
</template> <template
<!-- Select / Multiselect edit slots (U5) --> v-for="f in fields"
<template :key="`hdr_${f.id}`"
v-for="f in selectFields" #[`header_${f.id}`]
:key="`edit_${f.id}`" >
#[`edit_${f.id}`]="{ row }" <a-popover
> :open="editingFieldId === f.id"
<SelectCellEditor :trigger="[]"
:model-value="(row[f.id] as string | string[] | null | undefined)" placement="bottomLeft"
:options="(f.config.options as ISelectOption[] | string[] | undefined)" overlay-class-name="bitable-inline-config-popover"
:multiple="f.field_type === 'multiselect'" :overlay-style="{ width: '340px' }"
@update:model-value="row[f.id] = $event" >
/> <ColumnHeaderMenu
</template> :field="f"
<!-- Select / Multiselect display slots (U5) --> @edit-inline="startInlineEdit(f.id)"
<template @open-batch-panel="emit('config-field', $event)"
v-for="f in selectFields" @hide="emit('hide-field', $event)"
:key="`cell_sel_${f.id}`" @delete="emit('delete-field', $event)"
#[`cell_sel_${f.id}`]="{ row }" />
> <template #content>
<SelectDisplay <InlineFieldConfigurator
:value="(row[f.id] as string | string[] | null | undefined)" v-if="editingFieldId === f.id"
:options="(f.config.options as ISelectOption[] | string[] | undefined)" :field="f"
:multiple="f.field_type === 'multiselect'" @saved="onInlineSaved"
/> @cancel="onInlineCancel"
</template> />
<!-- Add-field column header --> </template>
<template #header__add_field> </a-popover>
<div class="bitable-grid-scope__add-col" @click="emit('add-field')"> </template>
<PlusOutlined /> 新增字段 <!-- Select / Multiselect edit slots (U5) -->
</div> <template
</template> v-for="f in selectFields"
</vxe-grid> :key="`edit_${f.id}`"
#[`edit_${f.id}`]="{ row }"
>
<SelectCellEditor
:model-value="(row[f.id] as string | string[] | null | undefined)"
:options="(f.config.options as ISelectOption[] | string[] | undefined)"
:multiple="f.field_type === 'multiselect'"
@update:model-value="row[f.id] = $event"
/>
</template>
<!-- Select / Multiselect display slots (U5) -->
<template
v-for="f in selectFields"
:key="`cell_sel_${f.id}`"
#[`cell_sel_${f.id}`]="{ row }"
>
<SelectDisplay
:value="(row[f.id] as string | string[] | null | undefined)"
:options="(f.config.options as ISelectOption[] | string[] | undefined)"
:multiple="f.field_type === 'multiselect'"
/>
</template>
<!-- Add-field column header -->
<template #header__add_field>
<div class="bitable-grid-scope__add-col" @click="emit('add-field')">
<PlusOutlined /> 新增字段
</div>
</template>
</vxe-grid>
</div>
</template>
<!-- U3: Record detail drawer opened by clicking the row's seq cell. <!-- U3: Record detail drawer opened by clicking the row's seq cell.
Rendered here so the grid is self-contained; reads state from the Rendered here so the grid is self-contained; reads state from the
@ -133,6 +163,14 @@ import SelectDisplay from './SelectDisplay.vue'
import InlineFieldConfigurator from './InlineFieldConfigurator.vue' import InlineFieldConfigurator from './InlineFieldConfigurator.vue'
import RecordDetailDrawer from './RecordDetailDrawer.vue' import RecordDetailDrawer from './RecordDetailDrawer.vue'
import type { ISelectOption } from './SelectCellEditor.vue' import type { ISelectOption } from './SelectCellEditor.vue'
import {
computeGroupingLevels,
matchConditionalFormatRule,
type GroupByItem,
type ConditionalFormatRule,
type GroupNode,
type ViewConfigU5,
} from '@/helpers/groupingRulesUtils'
type GridRow = Record<string, unknown> & { _rowId: string; _recordId: string } type GridRow = Record<string, unknown> & { _rowId: string; _recordId: string }
type GridColumn = NonNullable<VxeGridProps['columns']>[number] type GridColumn = NonNullable<VxeGridProps['columns']>[number]
@ -160,23 +198,156 @@ const emit = defineEmits<{
(e: 'delete-field', field: IBitableField): void (e: 'delete-field', field: IBitableField): void
}>() }>()
const gridRef = ref<InstanceType<typeof VxeGrid> | null>(null) // Track vxe-grid instances across sections (for refreshColumn on inline save).
// ponytail: Map keyed by section idx simplest stable approach for a v-for.
// Ceiling: if sections reorder (e.g. via sort), idx-based keys may stale;
// upgrade path: key by groupNodeKey instead.
const gridInstanceMap = new Map<number, InstanceType<typeof VxeGrid>>()
function onGridRef(idx: number, el: unknown): void {
if (el) {
gridInstanceMap.set(idx, el as InstanceType<typeof VxeGrid>)
} else {
gridInstanceMap.delete(idx)
}
}
// U3: bitable store used only for the record detail drawer (currentRecordId // U3: bitable store used for the record detail drawer (currentRecordId) AND
// + closeRecordDetail). The grid itself remains a controlled component // U5: reading the current view's group_by + conditional_formatting config.
// (fields/records via props); the drawer is the one side-effect surface. // ponytail: reading view config from the store avoids adding a new prop.
// Ceiling: BitableGrid is coupled to store.currentView shape; if reused
// outside the bitable detail view, pass viewConfig as a prop instead.
const store = useBitableStore() const store = useBitableStore()
// U5: Grouping + Conditional Formatting
const viewConfig = computed<ViewConfigU5>(() => {
const config = store.currentView?.config as ViewConfigU5 | undefined
return config ?? {}
})
const groupByItems = computed<GroupByItem[]>(() => {
const raw = viewConfig.value.group_by
if (!Array.isArray(raw)) return []
return raw as GroupByItem[]
})
const cfRules = computed<ConditionalFormatRule[]>(() => {
const raw = viewConfig.value.conditional_formatting
if (!Array.isArray(raw)) return []
return raw as ConditionalFormatRule[]
})
const groupingEnabled = computed(
() => groupByItems.value.length > 0 && props.records.length > 0,
)
// Build the nested group tree. Uses store.fields (all fields, not just
// visible ones) so number-field aggregations work even when the field is
// hidden by the view's hidden_fields config.
const groupTree = computed<GroupNode[]>(() => {
if (!groupingEnabled.value) return []
return computeGroupingLevels(props.records, groupByItems.value, store.fields)
})
type GroupSection =
| { type: 'header'; node: GroupNode }
| { type: 'data'; node: GroupNode | null }
// Flatten the group tree into header + data sections. When grouping is
// disabled, returns a single data section with node=null (the grid renders
// all records in one block, preserving the original non-grouped UX).
const groupSections = computed<GroupSection[]>(() => {
if (!groupingEnabled.value) {
return [{ type: 'data', node: null }]
}
return flattenGroupTree(groupTree.value)
})
function flattenGroupTree(nodes: GroupNode[]): GroupSection[] {
const result: GroupSection[] = []
for (const node of nodes) {
result.push({ type: 'header', node })
if (node.children.length > 0) {
// Intermediate node: recurse into children (no direct data grid).
result.push(...flattenGroupTree(node.children))
} else {
// Leaf node: data section with this group's records.
result.push({ type: 'data', node })
}
}
return result
}
// Collapse state keyed by `${depth}:${fieldId}:${key}` for tree-wide uniqueness.
// Default expanded (empty Set = nothing collapsed).
const collapsedKeys = ref<Set<string>>(new Set())
function groupNodeKey(node: GroupNode): string {
return `${node.depth}:${node.fieldId}:${node.key}`
}
function isCollapsed(node: GroupNode): boolean {
return collapsedKeys.value.has(groupNodeKey(node))
}
function toggleGroup(node: GroupNode): void {
const key = groupNodeKey(node)
const next = new Set(collapsedKeys.value)
if (next.has(key)) {
next.delete(key)
} else {
next.add(key)
}
collapsedKeys.value = next
}
// Map a group's records to grid rows (same shape as the `rows` computed).
function rowsForGroup(node: GroupNode): GridRow[] {
return node.records.map((r) => ({
_rowId: r.id,
_recordId: r.id,
...r.values,
}))
}
// CF row class first matching rule colors the entire row.
// Group headers are separate <div> elements (not vxe-grid rows), so they
// are naturally unaffected by CF (: ).
function rowClassName({ row }: { row: unknown; rowIndex: number }): string {
if (cfRules.value.length === 0) return ''
const gridRow = row as GridRow
for (const rule of cfRules.value) {
if (!rule.enabled) continue
const value = gridRow[rule.field_id]
if (matchConditionalFormatRule(value, rule)) {
const classes = [`bitable-cf--${rule.color_key}`]
if (rule.bold) classes.push('bitable-cf--bold')
return classes.join(' ')
}
}
return ''
}
// row-config object passed to vxe-grid as :row-config. Includes the CF
// className function so vxe-table calls it for every data row.
const rowConfig = computed(() => ({
keyField: '_recordId',
className: rowClassName as unknown as string,
}))
function fieldName(fieldId: string): string {
return store.fields.find((f) => f.id === fieldId)?.name ?? fieldId
}
function formatNum(n: number): string {
return Number.isInteger(n) ? String(n) : n.toFixed(2)
}
// End U5
// U3: clicking the row's seq cell (#) opens the record detail drawer. // U3: clicking the row's seq cell (#) opens the record detail drawer.
// ponytail: data cells are NOT triggers vxe-table's edit-config trigger:'click'
// already owns the click-to-edit behaviour on data cells, so using the seq
// column avoids a conflict. Ceiling: users must click the row number to open
// the drawer (clicking a data cell enters edit mode instead). This matches
// Feishu/Twenty's row-number detail affordance. Upgrade path: add a
// dedicated expand affordance if user testing shows discoverability issues.
const onCellClick: VxeGridEvents.CellClick = (params) => { const onCellClick: VxeGridEvents.CellClick = (params) => {
const { row, column } = params const { row, column } = params
// column.type === 'seq' identifies the row-number column.
if (!column || column.type !== 'seq') return if (!column || column.type !== 'seq') return
const recordId = (row as GridRow)._recordId const recordId = (row as GridRow)._recordId
if (!recordId) return if (!recordId) return
@ -187,11 +358,7 @@ function onDrawerRetry(recordId: string): void {
store.fetchRecordDetail(recordId) store.fetchRecordDetail(recordId)
} }
// U2: inline field config only one column edits at a time. null = none open. // U2: inline field config only one column edits at a time.
// The InlineFieldConfigurator renders inside an a-popover anchored to the
// column header (see header slot above). The store mutation (store.updateField)
// updates store.fields -> parent visibleFields -> this component's `fields`
// prop, so the grid re-renders the new label/column config on the next frame.
const editingFieldId = ref<string | null>(null) const editingFieldId = ref<string | null>(null)
function startInlineEdit(fieldId: string): void { function startInlineEdit(fieldId: string): void {
@ -200,9 +367,8 @@ function startInlineEdit(fieldId: string): void {
function onInlineSaved(_field: IBitableField): void { function onInlineSaved(_field: IBitableField): void {
editingFieldId.value = null editingFieldId.value = null
// Refresh vxe-table column rendering so a type change (e.g. text -> number) // Refresh all grid instances so a type change picks up the new editRender.
// picks up the new editRender. Label updates flow through props naturally. gridInstanceMap.forEach((g) => g?.refreshColumn?.())
gridRef.value?.refreshColumn?.()
} }
function onInlineCancel(): void { function onInlineCancel(): void {
@ -354,9 +520,11 @@ const onEditClosed: VxeGridEvents.EditClosed = (params) => {
}) })
} }
// Expose grid ref for parent (e.g. to refresh) // Expose refresh for parent (refreshes all grid instances)
defineExpose({ defineExpose({
refresh: () => gridRef.value?.refreshColumn(), refresh: () => {
gridInstanceMap.forEach((g) => g?.refreshColumn?.())
},
}) })
</script> </script>
@ -408,4 +576,115 @@ defineExpose({
.bitable-grid-scope__add-col:hover { .bitable-grid-scope__add-col:hover {
color: var(--bitable-color-primary); 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 <div> 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;
}
</style> </style>

View File

@ -0,0 +1,261 @@
<template>
<div class="cf-editor">
<!-- Empty state (Open Question P2) -->
<a-empty
v-if="modelValue.length === 0"
description="暂无规则(点击添加规则开始着色)"
/>
<template v-else>
<div
v-for="(rule, idx) in modelValue"
:key="idx"
class="cf-editor__rule"
:class="{ 'cf-editor__rule--disabled': !rule.enabled }"
>
<!-- Enable/disable toggle (left rail) -->
<a-switch
:checked="rule.enabled"
size="small"
@update:checked="onFieldChange(idx, { enabled: Boolean($event) })"
/>
<!-- Field select -->
<a-select
:value="rule.field_id"
size="small"
placeholder="字段"
style="width: 120px"
@update:value="onFieldChange(idx, { field_id: String($event) })"
>
<a-select-option
v-for="f in fields"
:key="f.id"
:value="f.id"
>
{{ f.name }}
</a-select-option>
</a-select>
<!-- Operator select -->
<a-select
:value="rule.operator"
size="small"
placeholder="运算符"
style="width: 100px"
@update:value="onOperatorChange(idx, String($event))"
>
<a-select-option
v-for="op in ALL_OPERATORS"
:key="op"
:value="op"
>
{{ OPERATOR_LABELS[op] }}
</a-select-option>
</a-select>
<!-- Value input (hidden for is-empty) -->
<a-input
v-if="rule.operator !== 'is-empty'"
:value="rule.value"
size="small"
:placeholder="valuePlaceholder(rule.operator)"
style="width: 120px"
@update:value="onFieldChange(idx, { value: String($event) })"
/>
<!-- Color select (with swatch) -->
<a-select
:value="rule.color_key"
size="small"
style="width: 100px"
@update:value="onColorChange(idx, String($event))"
>
<a-select-option
v-for="ck in ALL_COLOR_KEYS"
:key="ck"
:value="ck"
>
<span class="cf-editor__color-option">
<span
class="cf-editor__color-swatch"
:style="{ background: colorKeyToBgCssVar(ck), color: colorKeyToFgCssVar(ck) }"
>
A
</span>
<span>{{ COLOR_KEY_LABELS[ck] }}</span>
</span>
</a-select-option>
</a-select>
<!-- Bold toggle (WCAG 1.4.1 default on) -->
<a-tooltip title="同时加粗文本WCAG 1.4.1 色盲可感知)">
<a-button
size="small"
:type="rule.bold ? 'primary' : 'default'"
:icon="h(BoldOutlined)"
@click="onFieldChange(idx, { bold: !rule.bold })"
/>
</a-tooltip>
<!-- Delete rule -->
<a-button
size="small"
danger
:icon="h(DeleteOutlined)"
@click="removeRule(idx)"
/>
</div>
</template>
<!-- Add rule + color legend -->
<div class="cf-editor__footer">
<a-button
size="small"
type="dashed"
:icon="h(PlusOutlined)"
@click="addRule"
>
添加规则
</a-button>
<span class="cf-editor__legend">
规则按顺序优先首条匹配生效
</span>
</div>
</div>
</template>
<script setup lang="ts">
import { h } from 'vue'
import {
Select as ASelect,
Input as AInput,
Button as AButton,
Switch as ASwitch,
Empty as AEmpty,
Tooltip as ATooltip,
} from 'ant-design-vue'
import {
PlusOutlined,
DeleteOutlined,
BoldOutlined,
} from '@ant-design/icons-vue'
import type { IBitableField } from '@/api/bitable'
import {
ALL_COLOR_KEYS,
ALL_OPERATORS,
COLOR_KEY_LABELS,
OPERATOR_LABELS,
colorKeyToBgCssVar,
colorKeyToFgCssVar,
type ColorKey,
type ConditionalFormatRule,
type ConditionalOperator,
} from '@/helpers/groupingRulesUtils'
const props = defineProps<{
modelValue: ConditionalFormatRule[]
fields: IBitableField[]
}>()
const emit = defineEmits<{
(e: 'update:modelValue', value: ConditionalFormatRule[]): void
}>()
function emitUpdate(rules: ConditionalFormatRule[]): void {
emit('update:modelValue', rules)
}
function addRule(): void {
// Default new rule: equals / red / bold=true / enabled=true on first field.
const firstField = props.fields[0]
const newRule: ConditionalFormatRule = {
field_id: firstField?.id ?? '',
operator: 'equals',
value: '',
color_key: 'red',
bold: true,
enabled: true,
}
emitUpdate([...props.modelValue, newRule])
}
function removeRule(idx: number): void {
emitUpdate(props.modelValue.filter((_, i) => i !== idx))
}
function onFieldChange(
idx: number,
patch: Partial<ConditionalFormatRule>,
): void {
const next = props.modelValue.map((r, i) => (i === idx ? { ...r, ...patch } : r))
emitUpdate(next)
}
function onOperatorChange(idx: number, op: string): void {
// Cast through unknown because the select emits a generic string.
const operator = op as ConditionalOperator
onFieldChange(idx, { operator })
}
function onColorChange(idx: number, ck: string): void {
const colorKey = ck as ColorKey
onFieldChange(idx, { color_key: colorKey })
}
function valuePlaceholder(operator: ConditionalOperator): string {
if (operator === 'between') return 'min,max'
return '值'
}
</script>
<style scoped>
.cf-editor {
display: flex;
flex-direction: column;
gap: var(--bitable-spacing-sm);
}
.cf-editor__rule {
display: flex;
align-items: center;
gap: var(--bitable-spacing-xs);
flex-wrap: wrap;
padding: var(--bitable-spacing-xs) 0;
border-bottom: 1px solid var(--bitable-color-border-split);
}
.cf-editor__rule--disabled {
opacity: 0.55;
}
.cf-editor__color-option {
display: inline-flex;
align-items: center;
gap: var(--bitable-spacing-xs);
}
.cf-editor__color-swatch {
display: inline-flex;
align-items: center;
justify-content: center;
width: 20px;
height: 20px;
border-radius: var(--bitable-radius-sm);
font-weight: 700;
font-size: var(--bitable-font-xs);
/* Background + color set inline from tokens via colorKeyTo{Bg,Fg}CssVar */
}
.cf-editor__footer {
display: flex;
align-items: center;
gap: var(--bitable-spacing-md);
margin-top: var(--bitable-spacing-sm);
}
.cf-editor__legend {
font-size: var(--bitable-font-xs);
color: var(--bitable-color-text-tertiary);
}
</style>

View File

@ -0,0 +1,234 @@
<template>
<div class="grouping-editor">
<!-- Known limitation note (Open Question P3) -->
<p class="grouping-editor__note">已知限制不支持跨分组多选</p>
<!-- Empty state (Open Question P2) -->
<a-empty
v-if="fields.length < 1"
description="暂无可分组字段(请先创建字段)"
/>
<template v-else>
<a-select
v-model:value="selectedFieldIds"
mode="multiple"
:max-tag-count="MAX_GROUP_BY_FIELDS"
placeholder="选择分组字段(最多 3 个)"
style="width: 100%"
@change="onSelectionChange"
>
<a-select-option
v-for="f in selectableFields"
:key="f.id"
:value="f.id"
:disabled="
!selectedFieldIds.includes(f.id) &&
selectedFieldIds.length >= MAX_GROUP_BY_FIELDS
"
>
{{ f.name }}
</a-select-option>
</a-select>
<!-- Group-by levels with direction toggle + reorder -->
<div
v-for="(item, idx) in modelValue"
:key="item.field_id"
class="grouping-editor__level"
>
<span class="grouping-editor__level-index">{{ idx + 1 }}.</span>
<span class="grouping-editor__level-name">{{ fieldName(item.field_id) }}</span>
<a-radio-group
:value="item.direction"
size="small"
@update:value="onDirectionChange(idx, $event)"
>
<a-radio-button value="asc">升序</a-radio-button>
<a-radio-button value="desc">降序</a-radio-button>
</a-radio-group>
<a-button
size="small"
:disabled="idx === 0"
:icon="h(ArrowUpOutlined)"
@click="moveUp(idx)"
/>
<a-button
size="small"
:disabled="idx === modelValue.length - 1"
:icon="h(ArrowDownOutlined)"
@click="moveDown(idx)"
/>
<a-button
size="small"
danger
:icon="h(DeleteOutlined)"
@click="removeField(idx)"
/>
</div>
<p
v-if="modelValue.length === MAX_GROUP_BY_FIELDS"
class="grouping-editor__max-hint"
>
已达最大分组层数{{ MAX_GROUP_BY_FIELDS }}
</p>
</template>
</div>
</template>
<script setup lang="ts">
import { ref, watch, computed, h } from 'vue'
import {
Select as ASelect,
Empty as AEmpty,
Button as AButton,
} from 'ant-design-vue'
import { ArrowUpOutlined, ArrowDownOutlined, DeleteOutlined } from '@ant-design/icons-vue'
import type { IBitableField } from '@/api/bitable'
import {
MAX_GROUP_BY_FIELDS,
type GroupByItem,
type GroupDirection,
} from '@/helpers/groupingRulesUtils'
const props = defineProps<{
modelValue: GroupByItem[]
fields: IBitableField[]
}>()
const emit = defineEmits<{
(e: 'update:modelValue', value: GroupByItem[]): void
}>()
// Local copy of the selected field ids derived from modelValue but
// kept as a separate ref because a-select multiple mode wants string[].
const selectedFieldIds = ref<string[]>(
props.modelValue.map((item) => item.field_id),
)
// Sync local selection when the parent's modelValue changes externally
// (e.g. after a successful PATCH reloads the view config).
watch(
() => props.modelValue,
(newVal) => {
const newIds = newVal.map((item) => item.field_id)
if (
newIds.length !== selectedFieldIds.value.length ||
!newIds.every((id, i) => id === selectedFieldIds.value[i])
) {
selectedFieldIds.value = newIds
}
},
{ deep: true },
)
// All fields are selectable EXCEPT formula / lookup / attachment / image
// these have opaque values that don't form meaningful groups. Number/text/
// date/select/multiselect all group by their scalar value.
const selectableFields = computed(() =>
props.fields.filter((f) => {
return (
f.field_type !== 'formula' &&
f.field_type !== 'lookup' &&
f.field_type !== 'attachment' &&
f.field_type !== 'image'
)
}),
)
function fieldName(fieldId: string): string {
return props.fields.find((f) => f.id === fieldId)?.name ?? fieldId
}
function emitUpdate(items: GroupByItem[]): void {
emit('update:modelValue', items)
}
function onSelectionChange(value: unknown): void {
// a-select multiple emits SelectValue (string | number | Array | undefined);
// narrow to string[] mode="multiple" always yields an array in practice.
const newIds = Array.isArray(value) ? value.map((v) => String(v)) : []
// Preserve direction for existing items; default new items to 'asc'.
const existing = new Map(props.modelValue.map((item) => [item.field_id, item]))
const next: GroupByItem[] = newIds.map((id) => {
const old = existing.get(id)
return old ?? { field_id: id, direction: 'asc' as GroupDirection }
})
emitUpdate(next)
}
function onDirectionChange(idx: number, direction: GroupDirection): void {
const next = props.modelValue.map((item, i) =>
i === idx ? { ...item, direction } : item,
)
emitUpdate(next)
}
function moveUp(idx: number): void {
if (idx === 0) return
const next = [...props.modelValue]
;[next[idx - 1], next[idx]] = [next[idx], next[idx - 1]]
emitUpdate(next)
}
function moveDown(idx: number): void {
if (idx === props.modelValue.length - 1) return
const next = [...props.modelValue]
;[next[idx], next[idx + 1]] = [next[idx + 1], next[idx]]
emitUpdate(next)
}
function removeField(idx: number): void {
const next = props.modelValue.filter((_, i) => i !== idx)
// Also clear from local selection so the a-select re-enables the option.
selectedFieldIds.value = next.map((item) => item.field_id)
emitUpdate(next)
}
</script>
<style scoped>
.grouping-editor {
display: flex;
flex-direction: column;
gap: var(--bitable-spacing-md);
}
.grouping-editor__note {
font-size: var(--bitable-font-xs);
color: var(--bitable-color-text-tertiary);
margin: 0;
padding: var(--bitable-spacing-xs) var(--bitable-spacing-sm);
background: var(--bitable-color-bg-tertiary);
border-radius: var(--bitable-radius-sm);
}
.grouping-editor__level {
display: flex;
align-items: center;
gap: var(--bitable-spacing-sm);
padding: var(--bitable-spacing-xs) 0;
border-bottom: 1px solid var(--bitable-color-border-split);
}
.grouping-editor__level-index {
font-weight: 600;
color: var(--bitable-color-text-secondary);
min-width: 24px;
}
.grouping-editor__level-name {
flex: 1;
font-size: var(--bitable-font-sm);
color: var(--bitable-color-text);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.grouping-editor__max-hint {
font-size: var(--bitable-font-xs);
color: var(--bitable-color-text-tertiary);
margin: 0;
}
</style>

View File

@ -2,8 +2,8 @@
<a-drawer <a-drawer
:open="open" :open="open"
title="视图配置" title="视图配置"
placement="right" :placement="drawerPlacement"
:width="520" :width="drawerWidth"
@close="handleClose" @close="handleClose"
> >
<a-tabs v-model:activeKey="activeTab"> <a-tabs v-model:activeKey="activeTab">
@ -61,6 +61,28 @@
<a-button type="primary" @click="saveHidden">保存隐藏配置</a-button> <a-button type="primary" @click="saveHidden">保存隐藏配置</a-button>
</div> </div>
</a-tab-pane> </a-tab-pane>
<!-- U5: Grouping tab -->
<a-tab-pane key="grouping" tab="分组">
<GroupingEditor
v-model="groupByItems"
:fields="fields"
/>
<div class="view-config-panel__actions">
<a-button type="primary" @click="saveGrouping">保存分组</a-button>
</div>
</a-tab-pane>
<!-- U5: Conditional formatting tab -->
<a-tab-pane key="conditional-format" tab="条件格式">
<ConditionalFormatEditor
v-model="cfRules"
:fields="fields"
/>
<div class="view-config-panel__actions">
<a-button type="primary" @click="saveConditionalFormat">保存条件格式</a-button>
</div>
</a-tab-pane>
</a-tabs> </a-tabs>
</a-drawer> </a-drawer>
</template> </template>
@ -76,7 +98,14 @@ import {
} from 'ant-design-vue' } from 'ant-design-vue'
import type { IBitableField, IBitableView } from '@/api/bitable' import type { IBitableField, IBitableView } from '@/api/bitable'
import { useBitableStore } from '@/stores/bitable' import { useBitableStore } from '@/stores/bitable'
import { useResponsiveBreakpoint } from '@/composables/useResponsiveBreakpoint'
import FilterBuilder from './FilterBuilder.vue' 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<{ const props = defineProps<{
open: boolean open: boolean
@ -90,6 +119,14 @@ const emit = defineEmits<{
const store = useBitableStore() 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 activeTab = ref('filter')
const filterRef = ref<InstanceType<typeof FilterBuilder> | null>(null) const filterRef = ref<InstanceType<typeof FilterBuilder> | null>(null)
@ -110,13 +147,31 @@ const hiddenFieldIds = ref<string[]>(
(props.view?.config?.hidden_fields as string[]) ?? [], (props.view?.config?.hidden_fields as string[]) ?? [],
) )
// Reset when view changes // U5: group_by + conditional_formatting local working copies.
const groupByItems = ref<GroupByItem[]>(extractGroupBy(props.view))
const cfRules = ref<ConditionalFormatRule[]>(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( watch(
() => props.view?.id, () => props.view?.id,
() => { () => {
sortFieldId.value = (props.view?.config?.sort as { field?: string })?.field ?? '' sortFieldId.value = (props.view?.config?.sort as { field?: string })?.field ?? ''
sortOrder.value = (props.view?.config?.sort as { order?: string })?.order ?? 'asc' sortOrder.value = (props.view?.config?.sort as { order?: string })?.order ?? 'asc'
hiddenFieldIds.value = (props.view?.config?.hidden_fields as string[]) ?? [] hiddenFieldIds.value = (props.view?.config?.hidden_fields as string[]) ?? []
groupByItems.value = extractGroupBy(props.view)
cfRules.value = extractConditionalFormat(props.view)
}, },
) )
@ -151,6 +206,25 @@ async function saveHidden(): Promise<void> {
} }
await store.updateView(props.view.id, { config }) 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).
async function saveGrouping(): Promise<void> {
if (!props.view) return
await store.updateViewConfig(props.view.id, {
group_by: groupByItems.value,
conditional_formatting: cfRules.value,
})
}
async function saveConditionalFormat(): Promise<void> {
if (!props.view) return
await store.updateViewConfig(props.view.id, {
group_by: groupByItems.value,
conditional_formatting: cfRules.value,
})
}
</script> </script>
<style scoped> <style scoped>

View File

@ -0,0 +1,571 @@
/**
* 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-<key>-{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<string, unknown>` (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<string, GroupAggregation>
}
// ---------------------------------------------------------------------------
// 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<ColorKey, string> = {
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<ColorKey, string> = {
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<ConditionalOperator, string> = {
equals: '等于',
'not-equals': '不等于',
contains: '包含',
'is-empty': '为空',
'greater-than': '大于',
'less-than': '小于',
between: '介于',
}
/** Color display labels (Chinese). */
export const COLOR_KEY_LABELS: Record<ColorKey, string> = {
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 {
if (!rules || rules.length === 0) return null
for (const rule of rules) {
if (matchConditionalFormatRule(value, rule)) return rule
}
return 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<string>,
): 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<string, IBitableRecord[]>()
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)
if (k !== '' && !Number.isNaN(n) && 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<string>,
): Record<string, GroupAggregation> {
const result: Record<string, GroupAggregation> = {}
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)
}
}

View File

@ -576,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<void> {
const existing = views.value.find((v) => v.id === viewId)
// Merge: start from existing config, overwrite only the U5 keys present.
const mergedConfig: Record<string, unknown> = {
...(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 */ /** Switch to a view — applies its config to the records query */
async function switchView(viewId: string): Promise<void> { async function switchView(viewId: string): Promise<void> {
const view = views.value.find((v) => v.id === viewId) const view = views.value.find((v) => v.id === viewId)
@ -690,6 +716,7 @@ export const useBitableStore = defineStore('bitable', () => {
updateRecordFields, updateRecordFields,
createView, createView,
updateView, updateView,
updateViewConfig,
switchView, switchView,
stopPolling, stopPolling,
} }

View File

@ -29,6 +29,7 @@ from pydantic import BaseModel, Field
from agentkit.bitable.models import FieldOwner, FieldType, ViewType from agentkit.bitable.models import FieldOwner, FieldType, ViewType
from agentkit.bitable.service import BitableService, FieldDependencyError from agentkit.bitable.service import BitableService, FieldDependencyError
from agentkit.bitable.view_config import ViewConfigValidationError, validate_view_config
from agentkit.server.auth.dependencies import get_current_user from agentkit.server.auth.dependencies import get_current_user
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@ -668,6 +669,16 @@ async def update_view(
if existing is None: if existing is None:
raise HTTPException(status_code=404, detail="View not found") raise HTTPException(status_code=404, detail="View not found")
await _check_table_ownership(service, existing.table_id, user) 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) kwargs = body.model_dump(exclude_none=True)
view = await service.update_view(view_id, **kwargs) view = await service.update_view(view_id, **kwargs)
if view is None: if view is None:

View File

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

View File

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

View File

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