diff --git a/src/agentkit/bitable/view_config.py b/src/agentkit/bitable/view_config.py new file mode 100644 index 0000000..1f5d278 --- /dev/null +++ b/src/agentkit/bitable/view_config.py @@ -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--{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 diff --git a/src/agentkit/server/frontend/components.d.ts b/src/agentkit/server/frontend/components.d.ts index 2daf35a..5db51bb 100644 --- a/src/agentkit/server/frontend/components.d.ts +++ b/src/agentkit/server/frontend/components.d.ts @@ -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'] diff --git a/src/agentkit/server/frontend/e2e/bitable-grouping.spec.ts b/src/agentkit/server/frontend/e2e/bitable-grouping.spec.ts new file mode 100644 index 0000000..8fcbaab --- /dev/null +++ b/src/agentkit/server/frontend/e2e/bitable-grouping.spec.ts @@ -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 +} + +interface IView { + id: string + name: string + config: Record +} + +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, +): Promise { + 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 { + 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, +): Promise { + 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 { + 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') + }) +}) diff --git a/src/agentkit/server/frontend/src/components/bitable/BitableGrid.vue b/src/agentkit/server/frontend/src/components/bitable/BitableGrid.vue index 824473e..780b29d 100644 --- a/src/agentkit/server/frontend/src/components/bitable/BitableGrid.vue +++ b/src/agentkit/server/frontend/src/components/bitable/BitableGrid.vue @@ -1,104 +1,134 @@ + + + + diff --git a/src/agentkit/server/frontend/src/components/bitable/GroupingEditor.vue b/src/agentkit/server/frontend/src/components/bitable/GroupingEditor.vue new file mode 100644 index 0000000..1b4d183 --- /dev/null +++ b/src/agentkit/server/frontend/src/components/bitable/GroupingEditor.vue @@ -0,0 +1,234 @@ + + + + + diff --git a/src/agentkit/server/frontend/src/components/bitable/ViewConfigPanel.vue b/src/agentkit/server/frontend/src/components/bitable/ViewConfigPanel.vue index 8205228..5849c1c 100644 --- a/src/agentkit/server/frontend/src/components/bitable/ViewConfigPanel.vue +++ b/src/agentkit/server/frontend/src/components/bitable/ViewConfigPanel.vue @@ -2,8 +2,8 @@ @@ -61,6 +61,28 @@ 保存隐藏配置 + + + + +
+ 保存分组 +
+
+ + + + +
+ 保存条件格式 +
+
@@ -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 | null>(null) @@ -110,13 +147,31 @@ const hiddenFieldIds = ref( (props.view?.config?.hidden_fields as string[]) ?? [], ) -// Reset when view changes +// U5: group_by + conditional_formatting local working copies. +const groupByItems = ref(extractGroupBy(props.view)) +const cfRules = ref(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 { } 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 { + if (!props.view) return + await store.updateViewConfig(props.view.id, { + group_by: groupByItems.value, + conditional_formatting: cfRules.value, + }) +} + +async function saveConditionalFormat(): Promise { + if (!props.view) return + await store.updateViewConfig(props.view.id, { + group_by: groupByItems.value, + conditional_formatting: cfRules.value, + }) +}