feat: Bitable P0 UX Polish + Agent Parity #23
|
|
@ -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
|
||||
|
|
@ -90,6 +90,7 @@ declare module 'vue' {
|
|||
CollaborationGraphCard: typeof import('./src/components/chat/messages/CollaborationGraphCard.vue')['default']
|
||||
ColumnHeaderMenu: typeof import('./src/components/bitable/ColumnHeaderMenu.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']
|
||||
ContextPill: typeof import('./src/components/chat/ContextPill.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']
|
||||
DocumentUpload: typeof import('./src/components/kb/DocumentUpload.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']
|
||||
EventEditor: typeof import('./src/components/calendar/EventEditor.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']
|
||||
FieldConfigForm: typeof import('./src/components/bitable/FieldConfigForm.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']
|
||||
FileCard: typeof import('./src/components/bitable/FileCard.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']
|
||||
FilterBuilder: typeof import('./src/components/bitable/FilterBuilder.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']
|
||||
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']
|
||||
KBSettings: typeof import('./src/components/kb/KBSettings.vue')['default']
|
||||
KnowledgeTab: typeof import('./src/components/layout/tabs/KnowledgeTab.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']
|
||||
MessageShell: typeof import('./src/components/chat/messages/MessageShell.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']
|
||||
PropertyPanel: typeof import('./src/components/workflow/PropertyPanel.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']
|
||||
ReviewResultCard: typeof import('./src/components/chat/messages/ReviewResultCard.vue')['default']
|
||||
RightPanel: typeof import('./src/components/layout/RightPanel.vue')['default']
|
||||
|
|
|
|||
|
|
@ -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')
|
||||
})
|
||||
})
|
||||
|
|
@ -1,14 +1,42 @@
|
|||
<template>
|
||||
<div class="bitable-grid-scope">
|
||||
<!-- U5: Unified section rendering — grouping disabled produces a single
|
||||
data section (node=null); grouping enabled produces interleaved
|
||||
header + data sections. The vxe-grid declaration + all slots are
|
||||
written ONCE here (no duplication). -->
|
||||
<template v-for="(section, idx) in groupSections" :key="`sec_${idx}`">
|
||||
<!-- Group header (grouping mode only) -->
|
||||
<div
|
||||
v-if="section.type === 'header'"
|
||||
class="bitable-grid-scope__group-header"
|
||||
:style="{ paddingLeft: `${section.node.depth * 24 + 8}px` }"
|
||||
@click="toggleGroup(section.node)"
|
||||
>
|
||||
<span class="bitable-grid-scope__caret">{{ isCollapsed(section.node) ? '▸' : '▾' }}</span>
|
||||
<span class="bitable-grid-scope__group-key">{{ section.node.key || '(空)' }}</span>
|
||||
<span class="bitable-grid-scope__group-count">{{ section.node.records.length }} 条</span>
|
||||
<template v-for="(agg, fid) in section.node.aggregations" :key="fid">
|
||||
<span class="bitable-grid-scope__group-agg">
|
||||
{{ fieldName(String(fid)) }}:<template v-if="agg.sum != null">合计 {{ formatNum(agg.sum) }}</template><template v-if="agg.avg != null"> · 均值 {{ formatNum(agg.avg) }}</template>
|
||||
</span>
|
||||
</template>
|
||||
</div>
|
||||
|
||||
<!-- Data section: vxe-grid for this group's records (or all records) -->
|
||||
<div
|
||||
v-else
|
||||
v-show="section.node === null || !isCollapsed(section.node)"
|
||||
class="bitable-grid-scope__group-grid"
|
||||
>
|
||||
<vxe-grid
|
||||
ref="gridRef"
|
||||
:data="rows"
|
||||
:ref="(el: unknown) => onGridRef(idx, el)"
|
||||
:data="section.node ? rowsForGroup(section.node) : rows"
|
||||
:columns="gridColumns"
|
||||
:height="height"
|
||||
:height="groupingEnabled ? 'auto' : height"
|
||||
:loading="loading"
|
||||
:row-config="{ keyField: '_recordId' }"
|
||||
:row-config="rowConfig"
|
||||
:column-config="{ resizable: true }"
|
||||
:virtual-y-config="{ enabled: true, gt: 60 }"
|
||||
:virtual-y-config="{ enabled: !groupingEnabled, gt: 60 }"
|
||||
:virtual-x-config="{ enabled: true, gt: 20 }"
|
||||
:edit-config="{
|
||||
trigger: 'click',
|
||||
|
|
@ -99,6 +127,8 @@
|
|||
</div>
|
||||
</template>
|
||||
</vxe-grid>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- U3: Record detail drawer — opened by clicking the row's seq cell.
|
||||
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 RecordDetailDrawer from './RecordDetailDrawer.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 GridColumn = NonNullable<VxeGridProps['columns']>[number]
|
||||
|
|
@ -160,23 +198,156 @@ const emit = defineEmits<{
|
|||
(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
|
||||
// + closeRecordDetail). The grid itself remains a controlled component
|
||||
// (fields/records via props); the drawer is the one side-effect surface.
|
||||
// U3: bitable store — used for the record detail drawer (currentRecordId) AND
|
||||
// U5: reading the current view's group_by + conditional_formatting config.
|
||||
// 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()
|
||||
|
||||
// ── 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.
|
||||
// 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 { row, column } = params
|
||||
// column.type === 'seq' identifies the row-number column.
|
||||
if (!column || column.type !== 'seq') return
|
||||
const recordId = (row as GridRow)._recordId
|
||||
if (!recordId) return
|
||||
|
|
@ -187,11 +358,7 @@ function onDrawerRetry(recordId: string): void {
|
|||
store.fetchRecordDetail(recordId)
|
||||
}
|
||||
|
||||
// U2: inline field config — only one column edits at a time. null = none open.
|
||||
// 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.
|
||||
// U2: inline field config — only one column edits at a time.
|
||||
const editingFieldId = ref<string | null>(null)
|
||||
|
||||
function startInlineEdit(fieldId: string): void {
|
||||
|
|
@ -200,9 +367,8 @@ function startInlineEdit(fieldId: string): void {
|
|||
|
||||
function onInlineSaved(_field: IBitableField): void {
|
||||
editingFieldId.value = null
|
||||
// Refresh vxe-table column rendering so a type change (e.g. text -> number)
|
||||
// picks up the new editRender. Label updates flow through props naturally.
|
||||
gridRef.value?.refreshColumn?.()
|
||||
// Refresh all grid instances so a type change picks up the new editRender.
|
||||
gridInstanceMap.forEach((g) => g?.refreshColumn?.())
|
||||
}
|
||||
|
||||
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({
|
||||
refresh: () => gridRef.value?.refreshColumn(),
|
||||
refresh: () => {
|
||||
gridInstanceMap.forEach((g) => g?.refreshColumn?.())
|
||||
},
|
||||
})
|
||||
</script>
|
||||
|
||||
|
|
@ -408,4 +576,115 @@ defineExpose({
|
|||
.bitable-grid-scope__add-col:hover {
|
||||
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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
|
|
@ -2,8 +2,8 @@
|
|||
<a-drawer
|
||||
:open="open"
|
||||
title="视图配置"
|
||||
placement="right"
|
||||
:width="520"
|
||||
:placement="drawerPlacement"
|
||||
:width="drawerWidth"
|
||||
@close="handleClose"
|
||||
>
|
||||
<a-tabs v-model:activeKey="activeTab">
|
||||
|
|
@ -61,6 +61,28 @@
|
|||
<a-button type="primary" @click="saveHidden">保存隐藏配置</a-button>
|
||||
</div>
|
||||
</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-drawer>
|
||||
</template>
|
||||
|
|
@ -76,7 +98,14 @@ import {
|
|||
} from 'ant-design-vue'
|
||||
import type { IBitableField, IBitableView } from '@/api/bitable'
|
||||
import { useBitableStore } from '@/stores/bitable'
|
||||
import { useResponsiveBreakpoint } from '@/composables/useResponsiveBreakpoint'
|
||||
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<{
|
||||
open: boolean
|
||||
|
|
@ -90,6 +119,14 @@ const emit = defineEmits<{
|
|||
|
||||
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 filterRef = ref<InstanceType<typeof FilterBuilder> | null>(null)
|
||||
|
||||
|
|
@ -110,13 +147,31 @@ const hiddenFieldIds = ref<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(
|
||||
() => props.view?.id,
|
||||
() => {
|
||||
sortFieldId.value = (props.view?.config?.sort as { field?: string })?.field ?? ''
|
||||
sortOrder.value = (props.view?.config?.sort as { order?: string })?.order ?? 'asc'
|
||||
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 })
|
||||
}
|
||||
|
||||
// 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>
|
||||
|
||||
<style scoped>
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
@ -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 */
|
||||
async function switchView(viewId: string): Promise<void> {
|
||||
const view = views.value.find((v) => v.id === viewId)
|
||||
|
|
@ -690,6 +716,7 @@ export const useBitableStore = defineStore('bitable', () => {
|
|||
updateRecordFields,
|
||||
createView,
|
||||
updateView,
|
||||
updateViewConfig,
|
||||
switchView,
|
||||
stopPolling,
|
||||
}
|
||||
|
|
|
|||
|
|
@ -29,6 +29,7 @@ from pydantic import BaseModel, Field
|
|||
|
||||
from agentkit.bitable.models import FieldOwner, FieldType, ViewType
|
||||
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
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
|
@ -668,6 +669,16 @@ async def update_view(
|
|||
if existing is None:
|
||||
raise HTTPException(status_code=404, detail="View not found")
|
||||
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)
|
||||
view = await service.update_view(view_id, **kwargs)
|
||||
if view is None:
|
||||
|
|
|
|||
|
|
@ -5,8 +5,8 @@
|
|||
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Fischer AgentKit</title>
|
||||
<script type="module" crossorigin src="/assets/index-N9Dybwcy.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="/assets/index-BgFZbme0.css">
|
||||
<script type="module" crossorigin src="/assets/index-Bxc86Kve.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="/assets/index-Ls4ZdRZM.css">
|
||||
</head>
|
||||
<body>
|
||||
<div id="app"></div>
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
@ -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})
|
||||
Loading…
Reference in New Issue