From df2279d183d6cf605aefde70005ea7f5e825eb57 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 17 Oct 2026 13:17:34 +0000 Subject: [PATCH 1/6] feat(app-shell,plugin-kanban): a card on a kanban leaf's Studio canvas selects the board's card in the view's properties (objectui#12725, part) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit On an object leaf whose list view renders as a kanban board, a card click on the Studio canvas selects "the card": the pillar's one selection gains a { kind: 'card' } member, so the slot switches to 属性, Esc and an empty canvas click clear it, and the status line names it. The click and the pointer press are taken in the capture phase, so the record does not open and no drag starts; a control inside a card keeps its job. The channel is a Studio-internal StudioCanvasCardSelectionContext beside the published props; StudioCanvasPreviewProps and index.ts are unchanged. ObjectListViewInspector gains a 看板卡片 section on a kanban view. Each control writes one spec-declared key: kanban.columns, kanban.titleField, navigation.mode and kanban.groupByField. No colour control. SortableCard carries one additive data-kanban-card attribute with the card's record id, so the canvas can tell which card a click landed on and outline it. Claude-Session: https://claude.ai/code/session_01HRnFN6Rtqk2WxPiK6qBhwq Co-authored-by: Claude --- .changeset/12725-kanban-card-panel.md | 10 + .../src/views/metadata-admin/i18n.ts | 35 +++ .../studio-design/ObjectListViewInspector.tsx | 247 +++++++++++++++++- .../studio-design/StudioDesignSurface.tsx | 31 ++- .../studio-design/studio-canvas-preview.tsx | 81 +++++- packages/plugin-kanban/src/KanbanImpl.tsx | 3 + 6 files changed, 400 insertions(+), 7 deletions(-) create mode 100644 .changeset/12725-kanban-card-panel.md diff --git a/.changeset/12725-kanban-card-panel.md b/.changeset/12725-kanban-card-panel.md new file mode 100644 index 0000000000..b0ab5f75ab --- /dev/null +++ b/.changeset/12725-kanban-card-panel.md @@ -0,0 +1,10 @@ +--- +'@object-ui/app-shell': minor +'@object-ui/plugin-kanban': patch +--- + +In Studio's Interfaces pillar, a click on a card of a kanban view's board selects the board's card, and a 看板卡片 (Kanban card) section in the view's properties edits it (objectui#12725, target 2: clicking a canvas element switches the right slot to 属性 for that element). + +- **Select, not open.** On an `object` leaf whose list view renders as a kanban board, a click on a card inside the Studio canvas switches the slot to 属性 with the 看板卡片 section, outlines the clicked card, and the status line reads 「已选中:看板卡片 · Esc 取消选中」. There the click does not open the record, and a press on a card does not start a drag. A button or link drawn inside a card keeps its click. Esc, or a click on empty canvas, clears the selection; the slot keeps the view's properties. +- **The 看板卡片 section** shows only on a kanban view and writes keys the spec declares, each control its own key: the card fields (`kanban.columns`, ordered), the title (`kanban.titleField`; 记录名称 removes the key and the card shows the record's display name), click to open (`navigation.mode`, over the spec's navigation modes; 默认 removes the key) and the group-by field (`kanban.groupByField`). The spec requires the group-by field and the card fields on a kanban block, so on a view with no block the title and the card fields wait until a group-by field is picked. There is no colour control. +- **The board outside Studio is unchanged:** a click opens the record and a drag moves the card. Each card now carries `data-kanban-card` with its record id, which the Studio canvas reads to tell which card was clicked; markup only, no prop or export changes. A canvas registered over the default (`registerStudioCanvasPreview`) gets no card selection. diff --git a/packages/app-shell/src/views/metadata-admin/i18n.ts b/packages/app-shell/src/views/metadata-admin/i18n.ts index e134cf24c2..ff116a3883 100644 --- a/packages/app-shell/src/views/metadata-admin/i18n.ts +++ b/packages/app-shell/src/views/metadata-admin/i18n.ts @@ -3380,6 +3380,24 @@ const ENGINE_STRINGS_EN: Record = { 'engine.studio.inspector.listView.addSort': '+ Add sort…', 'engine.studio.inspector.listView.sortAsc': '{field} ascending', 'engine.studio.inspector.listView.sortDesc': '{field} descending', + // objectui#12725 — a kanban view's card section: a card click on the canvas selects it. + 'engine.studio.inspector.listView.kanbanCard': 'Kanban card', + 'engine.studio.inspector.listView.kanbanCard.intro': 'What each card on the board shows, and what a click on a card opens.', + 'engine.studio.inspector.listView.kanbanCard.groupByFirst': 'Pick the group-by field first: the card settings are saved with it.', + 'engine.studio.inspector.listView.kanbanCard.fields': 'Card fields', + 'engine.studio.inspector.listView.kanbanCard.title': 'Title', + 'engine.studio.inspector.listView.kanbanCard.titleRecordName': 'Record name', + 'engine.studio.inspector.listView.kanbanCard.open': 'Click to open', + 'engine.studio.inspector.listView.kanbanCard.openDefault': 'Default', + 'engine.studio.inspector.listView.kanbanCard.mode.page': 'Record page', + 'engine.studio.inspector.listView.kanbanCard.mode.drawer': 'Side drawer', + 'engine.studio.inspector.listView.kanbanCard.mode.modal': 'Dialog', + 'engine.studio.inspector.listView.kanbanCard.mode.split': 'Split view', + 'engine.studio.inspector.listView.kanbanCard.mode.popover': 'Popover', + 'engine.studio.inspector.listView.kanbanCard.mode.newWindow': 'New window', + 'engine.studio.inspector.listView.kanbanCard.mode.none': "Don't open", + 'engine.studio.inspector.listView.kanbanCard.groupBy': 'Group by field', + 'engine.studio.inspector.listView.kanbanCard.pickField': 'Pick a field', // objectui#11823 (steps 2 and 3) — the Interfaces pillar's New dashboard / New report / New page. 'engine.studio.interfaces.create.menuTitle': 'Create a dashboard, a report or a page in this package', 'engine.studio.interfaces.create.dashboard': 'New dashboard', @@ -6652,6 +6670,23 @@ const ENGINE_STRINGS_ZH: Record = { 'engine.studio.inspector.listView.addSort': '+ 添加排序…', 'engine.studio.inspector.listView.sortAsc': '{field} 升序', 'engine.studio.inspector.listView.sortDesc': '{field} 降序', + 'engine.studio.inspector.listView.kanbanCard': '看板卡片', + 'engine.studio.inspector.listView.kanbanCard.intro': '看板上每张卡片显示什么,点一张卡片时打开什么。', + 'engine.studio.inspector.listView.kanbanCard.groupByFirst': '先选分组字段:卡片设置随它一起保存。', + 'engine.studio.inspector.listView.kanbanCard.fields': '卡片字段', + 'engine.studio.inspector.listView.kanbanCard.title': '标题', + 'engine.studio.inspector.listView.kanbanCard.titleRecordName': '记录名称', + 'engine.studio.inspector.listView.kanbanCard.open': '点击打开', + 'engine.studio.inspector.listView.kanbanCard.openDefault': '默认', + 'engine.studio.inspector.listView.kanbanCard.mode.page': '记录页', + 'engine.studio.inspector.listView.kanbanCard.mode.drawer': '侧边抽屉', + 'engine.studio.inspector.listView.kanbanCard.mode.modal': '弹窗', + 'engine.studio.inspector.listView.kanbanCard.mode.split': '分栏', + 'engine.studio.inspector.listView.kanbanCard.mode.popover': '浮层', + 'engine.studio.inspector.listView.kanbanCard.mode.newWindow': '新窗口', + 'engine.studio.inspector.listView.kanbanCard.mode.none': '不打开', + 'engine.studio.inspector.listView.kanbanCard.groupBy': '分组字段', + 'engine.studio.inspector.listView.kanbanCard.pickField': '选择字段', // objectui#11823 (step 2) — the Interfaces pillar's New dashboard / New report. 'engine.studio.interfaces.create.menuTitle': '在本包中新建仪表盘、报表或页面', 'engine.studio.interfaces.create.dashboard': '新建仪表盘', diff --git a/packages/app-shell/src/views/studio-design/ObjectListViewInspector.tsx b/packages/app-shell/src/views/studio-design/ObjectListViewInspector.tsx index ab4c680d38..4671778070 100644 --- a/packages/app-shell/src/views/studio-design/ObjectListViewInspector.tsx +++ b/packages/app-shell/src/views/studio-design/ObjectListViewInspector.tsx @@ -45,10 +45,16 @@ import { ChevronDown, Loader2 } from 'lucide-react'; import { Badge, Button, + cn, Label, Popover, PopoverContent, PopoverTrigger, + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, SortBuilder, type SortItem, } from '@object-ui/components'; @@ -57,7 +63,7 @@ import { useAuth } from '@object-ui/auth'; import { resolveViewId } from '@object-ui/core'; import { extractDraftBody, type MetadataClient } from '@object-ui/data-objectstack'; import { stripReadDecorations } from '@objectstack/spec/kernel'; -import { resolveI18nLabel, type I18nLabel } from '@objectstack/spec/ui'; +import { NavigationModeSchema, resolveI18nLabel, type I18nLabel } from '@objectstack/spec/ui'; import { InspectorReorderButtons, InspectorShell } from '../metadata-admin/inspectors/_shared.js'; import { FieldsListEditor } from '../metadata-admin/previews/FieldsListEditor.js'; import { useObjectFields } from '../metadata-admin/previews/useObjectFields.js'; @@ -428,7 +434,10 @@ function selectedColumnIndex(id: string | undefined): number | null { return m ? Number(m[1]) : null; } -/** A selection in the panel's column list, as `FieldsListEditor` spells it. */ +/** + * A selection in the panel: a row of its column list, as `FieldsListEditor` + * spells it, or a kanban view's card (`kind: 'card'`, objectui#12725). + */ export interface ListViewColumnSelection { kind: string; id: string; @@ -463,17 +472,237 @@ export function listViewColumnFields(listView: ObjectListViewDraft): string[] { return Array.isArray(columns) ? columns.map((c) => colFieldName(c) ?? '') : []; } +/** + * objectui#12725 — the selection of a kanban view's card, made by a click on + * a card of the board: `id` is the clicked card's record id, the one the + * canvas outlines. Its properties are the board's card, not that record's. + */ +export function listViewCardSelection(id: string, label: string): ListViewColumnSelection { + return { kind: 'card', id, label }; +} + +/** The record id of the card a selection names, or `null` when it names no card. */ +export function listViewCardIdOf(selection: ListViewColumnSelection | null | undefined): string | null { + return selection?.kind === 'card' ? selection.id : null; +} + +/** + * objectui#12725 — whether the view the panel edits renders as a kanban board + * (its list body's `type`), the one view whose cards a canvas click selects. + * `false` while the view is loading, and for an item that is not a list view. + */ +export function listViewIsKanban(listView: ObjectListViewDraft): boolean { + const { target, row, notList, seedColumns, loadedFor, targetKey } = listView; + if (!target || notList || loadedFor !== targetKey) return false; + return listBodyOf(row ?? newListViewItem(target, seedColumns)).type === 'kanban'; +} + const noop = (): void => {}; const SORT_ID = 'studio-list-view-sort'; const FILTER_ID = 'studio-list-view-filter'; +const KANBAN_GROUP_ID = 'studio-list-view-kanban-group'; +const KANBAN_TITLE_ID = 'studio-list-view-kanban-title'; +const KANBAN_OPEN_ID = 'studio-list-view-kanban-open'; +const KANBAN_FIELDS_LABEL_ID = 'studio-list-view-kanban-fields'; +/** The Select value for "no key written": a Select item cannot carry `''`. */ +const UNSET = '__unset__'; + +/** The copy of each `navigation.mode` the spec declares (`NavigationModeSchema`). */ +const NAVIGATION_MODE_LABEL_KEYS: Record = { + page: 'engine.studio.inspector.listView.kanbanCard.mode.page', + drawer: 'engine.studio.inspector.listView.kanbanCard.mode.drawer', + modal: 'engine.studio.inspector.listView.kanbanCard.mode.modal', + split: 'engine.studio.inspector.listView.kanbanCard.mode.split', + popover: 'engine.studio.inspector.listView.kanbanCard.mode.popover', + new_window: 'engine.studio.inspector.listView.kanbanCard.mode.newWindow', + none: 'engine.studio.inspector.listView.kanbanCard.mode.none', +}; + +/** + * objectui#12725 — the 看板卡片 section of a kanban view: what each card on + * the board shows and what a click on one opens, as the StudioRailProps + * artboard draws it. Each control writes one key the spec declares, and only + * that key: + * + * - card fields → `kanban.columns` (`KanbanConfigSchema.columns`, "Fields to + * show on cards"), ordered; + * - title → `kanban.titleField`, optional: empty removes the key, and the + * card shows the record's display name, as the spec describes; + * - click to open → `navigation.mode` (`NavigationModeSchema`); the default + * removes the key; + * - group by → `kanban.groupByField`, required by the spec. Every field of + * the object is offered: the spec's check refuses only a padded name, and + * a field's name is never padded. + * + * `KanbanConfigSchema` is strict and requires `groupByField` and `columns`, + * so the block is born by picking the group-by field (with `columns: []`, on + * which the board shows the view's own first columns), and the title and the + * card fields wait for it. ⛔ No colour control: no reader in `plugin-kanban` + * draws a list view's `rowColor` on a card. + */ +function KanbanCardSection({ + body, + writeBody, + fields, + fieldCatalog, + readOnly, + locale, + selected, +}: { + body: Row; + writeBody: (next: Row) => void; + fields: ReadonlyArray<{ name: string; label?: string }>; + fieldCatalog: LoadState; + readOnly: boolean; + locale: string; + selected: boolean; +}): React.ReactElement { + const ref = React.useRef(null); + React.useEffect(() => { + if (selected) ref.current?.scrollIntoView?.({ block: 'nearest' }); + }, [selected]); + const kanban = isPlainObject(body.kanban) ? body.kanban : null; + const navigation = isPlainObject(body.navigation) ? body.navigation : null; + const groupBy = typeof kanban?.groupByField === 'string' ? kanban.groupByField : ''; + const titleField = typeof kanban?.titleField === 'string' ? kanban.titleField : ''; + const mode = typeof navigation?.mode === 'string' ? navigation.mode : ''; + const writeKanban = (next: Row) => writeBody({ ...body, kanban: next }); + const fieldItems = (current: string) => ( + <> + {current && !fields.some((f) => f.name === current) && ( + + {current} + + )} + {fields.map((f) => ( + + + {f.label || f.name} + {f.name} + + + ))} + + ); + const FieldsWidget = WIDGETS['field-multi']; + return ( +
+
+

{t('engine.studio.inspector.listView.kanbanCard', locale)}

+

+ {t('engine.studio.inspector.listView.kanbanCard.intro', locale)} +

+
+ {!kanban && ( +

+ {t('engine.studio.inspector.listView.kanbanCard.groupByFirst', locale)} +

+ )} +
+

+ {t('engine.studio.inspector.listView.kanbanCard.fields', locale)} +

+ { + if (kanban && Array.isArray(next)) writeKanban({ ...kanban, columns: next.map(String) }); + }} + /> +
+
+ + +
+
+ + +
+
+ + +
+
+ ); +} /** * The panel itself: Columns (add, remove, reorder — the Studio's own column * manager, `FieldsListEditor`), Filter (the runtime `FilterBuilder`, through * the designer's `filter-builder` widget, which writes the spec's * `ViewFilterRule[]`) and Sort (the runtime `SortBuilder`, in a popover the - * width the builder needs, written as the spec's `{ field, order }[]`). + * width the builder needs, written as the spec's `{ field, order }[]`). On a + * kanban view, the kanban card section comes first (`KanbanCardSection`). */ export function ObjectListViewInspector({ listView, @@ -596,6 +825,18 @@ export function ObjectListViewInspector({ {tFormat('engine.studio.inspector.listView.notCreated', locale, { view: target.viewId })}

)} + {body.type === 'kanban' && ( + // objectui#12725 — a kanban view's card: a card click on the canvas selects it. + + )}
(() => { + if (!kanbanLeaf || !listViewObject) return null; + return { + objectName: listViewObject, + selected: selectedCardId, + onSelect: (id) => setSelection(listViewCardSelection(id, cardLabel)), + }; + }, [kanbanLeaf, listViewObject, selectedCardId, cardLabel, setSelection]); // nav editing — patch appDraft.navigation, then save/publish the App overlay const onNavPatch = React.useCallback((patch: Record) => { @@ -4399,9 +4420,10 @@ export function InterfacesPillar({ ? tFormat('engine.studio.if.canvasHint.selected', locale, { label: String(selectionLabel) }) : slot?.mode === 'copilot' && // objectui#12725 — an `object` leaf's headers are elements too, while the panel lists its - // columns, on the default canvas: the one that reports a header click. + // columns, and so are a kanban view's cards, on the default canvas: the one that reports + // a header or a card click. ((Preview && designing && !StudioCanvas && !isSourcePage) || - (StudioCanvas === StudioObjectRecordsCanvas && canvasColumns)) + (StudioCanvas === StudioObjectRecordsCanvas && (canvasColumns || canvasCards))) ? t('engine.studio.if.canvasHint.pick', locale) : null; const canvasStatusEl = current ? ( @@ -4621,7 +4643,10 @@ export function InterfacesPillar({ {/* objectui#12725 — and the panel's column selection, which a header click on the default canvas makes. */} - + {/* objectui#12725 — and, on a kanban view, its card selection. */} + + + diff --git a/packages/app-shell/src/views/studio-design/studio-canvas-preview.tsx b/packages/app-shell/src/views/studio-design/studio-canvas-preview.tsx index 61dc60a0e7..076f62d975 100644 --- a/packages/app-shell/src/views/studio-design/studio-canvas-preview.tsx +++ b/packages/app-shell/src/views/studio-design/studio-canvas-preview.tsx @@ -162,6 +162,30 @@ export interface StudioCanvasColumnSelection { export const StudioCanvasColumnSelectionContext = React.createContext(null); +/** + * The card selection of the kanban view the Properties panel edits + * (objectui#12725): a click on a card of the board selects "the card", whose + * properties are the panel's kanban card section (the board's card, not one + * record), and the clicked card is outlined. + * + * ⛔ Studio-internal, for the reason {@link StudioCanvasNavEntry} is: a + * context beside the published props, never a member of + * {@link StudioCanvasPreviewProps}. The Interfaces pillar provides it while + * its panel holds a kanban view of the open leaf. A canvas registered over the + * default does not read it, and one rendered outside the pillar sees `null`; + * either way a card click opens the record as the running app's does. + */ +export interface StudioCanvasCardSelection { + /** The object the board lists. Cards are read only on this object's canvas. */ + objectName: string; + /** The record id of the card outlined, or `null` with none selected. */ + selected: string | null; + /** Select the card; `id` is the clicked card's record id. */ + onSelect: (id: string) => void; +} + +export const StudioCanvasCardSelectionContext = React.createContext(null); + /** * Which of the package's objects have only a draft (a pending draft, no * published row), as the Interfaces pillar knows them under the publish the @@ -332,11 +356,66 @@ function ObjectRecordsGrid({ name }: StudioCanvasPreviewProps) { // a different mount; a different slice of the same list is a refetch. return ( - + + + ); } +/** + * objectui#12725 — the canvas half of selecting a kanban view's card: a click + * on a card of the board ({@link StudioCanvasCardSelectionContext}) selects + * the card and does not open the record. The board names each card's record + * on its element (`data-kanban-card`, `SortableCard`). Both the click and the + * pointer press are taken in the capture phase, before the card's own handlers + * see them: the click would open the record, and the press is where the + * board's drag sensor starts a move. A control drawn inside a card keeps its + * job. + * + * Drawn always, with no box, for the reason {@link ColumnHeaderSelect} is. + */ +function KanbanCardSelect({ objectName, children }: { objectName: string; children: React.ReactNode }) { + const channel = React.useContext(StudioCanvasCardSelectionContext); + const cards = channel && channel.objectName === objectName ? channel : null; + const scope = React.useId(); + const cardOf = (e: React.SyntheticEvent): Element | null => { + if (!cards || !(e.target instanceof Element)) return null; + const card = e.target.closest('[data-kanban-card]'); + if (!card || !e.currentTarget.contains(card)) return null; + const control = e.target.closest('button, input, a, [role="checkbox"]'); + if (control && card.contains(control)) return null; + return card; + }; + const onPointerDownCapture = (e: React.PointerEvent) => { + if (cardOf(e)) e.stopPropagation(); + }; + const onClickCapture = (e: React.MouseEvent) => { + const id = cardOf(e)?.getAttribute('data-kanban-card'); + if (!cards || !id) return; + e.stopPropagation(); + e.preventDefault(); + cards.onSelect(id); + }; + return ( +
+ {cards?.selected ? ( + // The clicked card is outlined, as the StudioRailProps artboard draws + // a selected element. A style rule, for the reason the column's is: + // the board owns its cards and redraws them. + + ) : null} + {children} +
+ ); +} + /** * objectui#12725 — the canvas half of selecting a column: a click on a header * the panel lists ({@link StudioCanvasColumnSelectionContext}) selects that diff --git a/packages/plugin-kanban/src/KanbanImpl.tsx b/packages/plugin-kanban/src/KanbanImpl.tsx index 4570766afa..af7b074249 100644 --- a/packages/plugin-kanban/src/KanbanImpl.tsx +++ b/packages/plugin-kanban/src/KanbanImpl.tsx @@ -245,6 +245,9 @@ function SortableCard({ card, onCardClick, conditionalFormatting, objectFields } return (
onCardClick?.(card, e)} > From 0d2a4ed3cf3692ef257936bc433b4c733f496aec Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 17 Oct 2026 13:22:59 +0000 Subject: [PATCH 2/6] test(app-shell): pin the kanban card selection and the kanban card section (objectui#12725, part) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Through the mounted surface with the real board: a card click switches the slot to 属性, outlines the card and names it on the status line, and neither opens the record nor lets the press reach the card; Esc and an empty-canvas click clear it. Each of the four controls writes exactly its spec key and the saved body parses as ListViewSchema; a view with no kanban block is born by the group-by field. Controls: a grid leaf has no section, the board outside Studio opens a record and takes the press, a canvas registered over the default gets no channel. Claude-Session: https://claude.ai/code/session_01HRnFN6Rtqk2WxPiK6qBhwq Co-authored-by: Claude --- ...Surface.kanbanCardSelection-12725.test.tsx | 510 ++++++++++++++++++ 1 file changed, 510 insertions(+) create mode 100644 packages/app-shell/src/views/studio-design/StudioDesignSurface.kanbanCardSelection-12725.test.tsx diff --git a/packages/app-shell/src/views/studio-design/StudioDesignSurface.kanbanCardSelection-12725.test.tsx b/packages/app-shell/src/views/studio-design/StudioDesignSurface.kanbanCardSelection-12725.test.tsx new file mode 100644 index 0000000000..d2d5a258f0 --- /dev/null +++ b/packages/app-shell/src/views/studio-design/StudioDesignSurface.kanbanCardSelection-12725.test.tsx @@ -0,0 +1,510 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * objectui#12725 — target 2 on a kanban view leaf: a click on a canvas element + * switches the right slot to 属性 for that element. On a kanban board the + * element is "the card", and its properties are the panel's kanban card + * section (`ObjectListViewInspector`). + * + * Pinned here, through the mounted surface with its right slot: + * + * - a click on a card switches the slot to 属性 with the kanban card section, + * outlines the clicked card, names the selection on the status line, and + * neither opens the record nor lets a press reach the card's drag sensor; + * - Esc, and a click on empty canvas, clear the card selection, and the slot + * keeps showing the view's properties; + * - each of the section's four controls writes exactly its spec key, and the + * saved list body parses as the spec's `ListViewSchema`; + * - CONTROLS: a grid leaf shows no kanban card section; the board outside + * Studio opens a record on click and its cards take the press; a canvas + * registered over the default gets no channel, so a card click opens the + * record and the slot keeps the copilot. The column selection on a grid + * leaf stays pinned by `leafColumnSelection-12725`. + * + * The canvas is the real `StudioObjectRecordsCanvas`; `object-view` is a stub + * that draws the real kanban board (`KanbanRenderer`) for a kanban view and a + * plain table for any other. The copilot is the stub `rightSlot-12725` uses. + */ + +import '@testing-library/jest-dom/vitest'; +import * as React from 'react'; +import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest'; +import { cleanup, fireEvent, render, screen, waitFor, within } from '@testing-library/react'; +import { MemoryRouter, Route, Routes } from 'react-router-dom'; +import { ComponentRegistry } from '@object-ui/core'; +import { MetadataCtx, type MetadataContextValue } from '@object-ui/react'; +import { ListViewSchema } from '@objectstack/spec/ui'; +import '@object-ui/components'; +import { KanbanRenderer } from '@object-ui/plugin-kanban'; +// The board's lazy chunk, loaded at module scope (AGENTS.md, test discipline). +import '../../../../plugin-kanban/src/KanbanImpl'; + +const PACKAGE_ID = 'com.acme.app'; +const NAV = [ + { id: 'nav_board', type: 'object', label: 'Board', objectName: 'showcase_task', viewName: 'board' }, + { id: 'nav_tasks', type: 'object', label: 'Tasks', objectName: 'showcase_task' }, + { id: 'nav_bare', type: 'object', label: 'Bare board', objectName: 'showcase_task', viewName: 'bare' }, +]; + +const OBJECT_DEF = { + name: 'showcase_task', + label: 'Task', + fields: { + title: { name: 'title', label: 'Title', type: 'text' }, + status: { name: 'status', label: 'Status', type: 'text' }, + priority: { name: 'priority', label: 'Priority', type: 'text' }, + owner: { name: 'owner', label: 'Owner', type: 'text' }, + }, +}; + +/** The kanban view the board entry lands on. */ +const BOARD_VIEW = { + name: 'showcase_task.board', + object: 'showcase_task', + viewKind: 'list', + label: 'Board', + config: { + type: 'kanban', + data: { provider: 'object', object: 'showcase_task' }, + columns: ['title', 'status'], + kanban: { groupByField: 'status', columns: ['priority'] }, + }, +}; + +/** A kanban view with no `kanban` block: the board picks its own lanes. */ +const BARE_VIEW = { + name: 'showcase_task.bare', + object: 'showcase_task', + viewKind: 'list', + label: 'Bare board', + config: { + type: 'kanban', + data: { provider: 'object', object: 'showcase_task' }, + columns: ['title', 'status'], + }, +}; + +/** The object's default list view: a grid. */ +const DEFAULT_VIEW = { + name: 'showcase_task.default', + object: 'showcase_task', + viewKind: 'list', + label: 'All tasks', + isDefault: true, + config: { + type: 'grid', + data: { provider: 'object', object: 'showcase_task' }, + columns: [{ field: 'title' }, { field: 'status' }], + }, +}; + +const LANES = [ + { id: 'open', title: 'Open' }, + { id: 'done', title: 'Done' }, +]; +const ROWS = [ + { id: 'r1', title: 'Bob', status: 'open', priority: 'high' }, + { id: 'r2', title: 'Ada', status: 'open', priority: 'low' }, + { id: 'r3', title: 'Cyd', status: 'done', priority: 'low' }, +]; + +/** What a card click asks the runtime to open. */ +const openRecord = vi.fn(); + +vi.mock('sonner', () => ({ + toast: { error: vi.fn(), success: vi.fn(), info: vi.fn(), warning: vi.fn(), dismiss: vi.fn() }, + Toaster: () => null, +})); + +const mockClient = { + save: vi.fn(async () => ({ success: true, version: 'v1', state: 'draft' })), + list: vi.fn(async (type: string) => { + if (type === 'app') return [{ name: 'acme_app', label: 'Acme' }]; + if (type === 'object') return [{ name: 'showcase_task', label: 'Task' }]; + return []; + }), + listDrafts: vi.fn(async () => []), + listTypes: vi.fn(async () => ({ entries: [] })), + layered: vi.fn(async (type: string, name: string) => { + if (type === 'app') return { effective: { name: 'acme_app', label: 'Acme', navigation: NAV } }; + if (type === 'object') return { effective: OBJECT_DEF, code: OBJECT_DEF }; + if (type === 'view' && name === BOARD_VIEW.name) { + return { code: null, overlay: BOARD_VIEW, overlayScope: 'env', effective: BOARD_VIEW }; + } + if (type === 'view' && name === BARE_VIEW.name) { + return { code: null, overlay: BARE_VIEW, overlayScope: 'env', effective: BARE_VIEW }; + } + if (type === 'view' && name === DEFAULT_VIEW.name) { + return { code: null, overlay: DEFAULT_VIEW, overlayScope: 'env', effective: DEFAULT_VIEW }; + } + return { effective: { name } }; + }), + getDraft: vi.fn(async () => null), + get: vi.fn(async (type: string) => (type === 'object' ? OBJECT_DEF : undefined)), + references: vi.fn(async () => []), + withPreviewDrafts() { + return this; + }, +}; + +vi.mock('../metadata-admin/useMetadata', async (importOriginal) => { + const actual = await importOriginal>(); + return { + ...actual, + useMetadataClient: () => mockClient, + useMetadataTypes: () => ({ loading: false, error: null, entries: [] }), + }; +}); + +vi.mock('@object-ui/react', async (importOriginal) => { + const actual = await importOriginal>(); + return { ...actual, useAdapter: () => ({}) }; +}); + +vi.mock('../../components/SuggestedBindingsPanel', () => ({ SuggestedBindingsPanel: () => null })); +vi.mock('../metadata-admin/AccessExplainPanel', () => ({ AccessExplainPanel: () => null })); +vi.mock('../../preview/DraftChangesPanel', () => ({ DraftChangesPanel: () => null })); + +vi.mock('./StudioAiCopilot', () => ({ + StudioChatDock: ({ onStatusChange }: { onStatusChange?: (s: { available: boolean; planPending: boolean }) => void }) => { + React.useEffect(() => { + onStatusChange?.({ available: true, planPending: false }); + }, [onStatusChange]); + return
conversation
; + }, +})); + +import { StudioDesignSurface } from './StudioDesignSurface'; +import { registerStudioCanvasPreview, StudioObjectRecordsCanvas } from './studio-canvas-preview'; +import { t, tFormat } from '../metadata-admin/i18n'; + +/** The real board, lanes by the view's group-by field, a card click opening the record. */ +function Board({ groupBy }: { groupBy: string }) { + return ( + openRecord(card.id) }} + /> + ); +} + +/** Stands in for `plugin-view`'s `object-view`: the board for a kanban view, a table otherwise. */ +function ObjectViewStub({ schema }: { schema: Record }) { + const views = (schema.listViews ?? {}) as Record; + const view = views[String(schema.defaultListView)]; + if (view?.type === 'kanban') return ; + return ( + + + {ROWS.map((r) => ( + + + + ))} + +
{r.title}
+ ); +} + +const METADATA: MetadataContextValue = { + apps: [], + objects: [OBJECT_DEF], + dashboards: [], + reports: [], + pages: [], + loading: false, + error: null, + refresh: async () => {}, + invalidate: () => {}, + ensureType: async () => [], + getItem: async () => null, + getItemsByType: () => [], +}; + +const answer = (body: unknown) => ({ ok: true, status: 200, json: async () => body }); + +beforeAll(() => { + ComponentRegistry.register('object-view', ObjectViewStub as never, { namespace: 'plugin-view' }); +}); + +beforeEach(() => { + window.sessionStorage.clear(); + openRecord.mockClear(); + mockClient.save.mockClear(); + vi.stubGlobal( + 'fetch', + vi.fn(async (input: unknown) => + String(input) === '/api/v1/packages' + ? answer({ success: true, data: { packages: [{ manifest: { id: PACKAGE_ID, name: 'Acme' }, writable: true }] } }) + : answer([]), + ), + ); + vi.stubGlobal('matchMedia', (query: string) => ({ + matches: false, + media: query, + onchange: null, + addEventListener: () => {}, + removeEventListener: () => {}, + addListener: () => {}, + removeListener: () => {}, + dispatchEvent: () => false, + })); +}); + +afterEach(() => { + cleanup(); + vi.unstubAllGlobals(); + registerStudioCanvasPreview('object', StudioObjectRecordsCanvas); +}); + +function renderSurface() { + render( + + + + } /> + + + , + ); +} + +const slot = () => screen.getByTestId('studio-right-slot'); +const slotProps = () => screen.getByTestId('studio-slot-props'); +const canvas = () => screen.getByTestId('if-canvas-main'); +const cardSection = () => within(slotProps()).queryByTestId('list-view-kanban-card'); + +/** A card of the board on the canvas, by its title. */ +async function card(title: string): Promise { + const label = await within(canvas()).findByText(title, undefined, { timeout: 4000 }); + return label.closest('[data-kanban-card]') as HTMLElement; +} + +/** The record id of the card the canvas outlines, or `null`. */ +function outlinedCard(): string | null { + const rule = within(canvas()).getByTestId('studio-canvas-cards').querySelector('style')?.textContent ?? ''; + return /data-kanban-card="([^"]+)"/.exec(rule)?.[1] ?? null; +} + +/** Open the rail's nth `object · showcase_task` entry (0: the board, 1: the default grid, 2: the bare board). */ +async function openLeaf(index: 0 | 1 | 2) { + renderSurface(); + const leaves = await screen.findAllByTitle('object · showcase_task', undefined, { timeout: 4000 }); + fireEvent.click(leaves[index]); + await waitFor(() => expect(within(slotProps()).getByTestId('list-view-columns')).toBeInTheDocument(), { + timeout: 8000, + }); + await waitFor(() => expect(slot()).toHaveAttribute('data-mode', 'copilot')); +} + +async function openBoardLeaf() { + await openLeaf(0); + await waitFor(() => expect(cardSection()).not.toBeNull(), { timeout: 4000 }); + await card('Bob'); +} + +/** The list body of the newest saved draft of the board view. */ +function lastSavedBoardBody(viewName: string = BOARD_VIEW.name): Record | null { + const calls = mockClient.save.mock.calls as unknown as Array<[string, string, Record]>; + const saved = calls.filter((c) => c[0] === 'view' && c[1] === viewName).at(-1); + return saved ? (saved[2].config as Record) : null; +} + +/** Pick an option in one of the section's selects, as the shared `Select` is driven. */ +async function pick(trigger: HTMLElement, option: string | RegExp) { + fireEvent.keyDown(trigger, { key: 'ArrowDown' }); + fireEvent.click(await screen.findByRole('option', { name: option })); +} + +/** The board view's saved body must parse as the spec's list view. */ +function expectSpecValid(body: Record) { + const parsed = ListViewSchema.safeParse(body); + expect(parsed.success, parsed.success ? '' : JSON.stringify(parsed.error.issues)).toBe(true); +} + +describe("a card on a kanban leaf's canvas selects the board's card (objectui#12725)", () => { + it('a card click switches the slot to 属性 with the kanban card section, outlines the card, and opens nothing', async () => { + await openBoardLeaf(); + expect(screen.getByTestId('if-canvas-hint')).toHaveTextContent(t('engine.studio.if.canvasHint.pick', 'en')); + expect(cardSection()).not.toHaveAttribute('data-selected'); + const bob = await card('Bob'); + // A press on the card reaches no drag sensor of the board's. + const press = vi.fn(); + bob.addEventListener('pointerdown', press); + + fireEvent.pointerDown(bob); + fireEvent.click(bob); + + await waitFor(() => expect(slot()).toHaveAttribute('data-mode', 'props')); + expect(cardSection()).toBeVisible(); + expect(cardSection()).toHaveAttribute('data-selected', 'true'); + expect(outlinedCard()).toBe('r1'); + expect(screen.getByTestId('if-canvas-hint')).toHaveTextContent( + tFormat('engine.studio.if.canvasHint.selected', 'en', { + label: t('engine.studio.inspector.listView.kanbanCard', 'en'), + }), + ); + expect(openRecord).not.toHaveBeenCalled(); + expect(press).not.toHaveBeenCalled(); + // The copilot stays, folded to the strip at the slot's foot. + expect(slot().lastElementChild).toBe(screen.getByTestId('studio-slot-copilot-strip')); + + // Another card moves the outline; still nothing opens. + fireEvent.click(await card('Cyd')); + await waitFor(() => expect(outlinedCard()).toBe('r3')); + expect(openRecord).not.toHaveBeenCalled(); + }); + + it("Esc clears the card selection, and the slot keeps the view's properties", async () => { + await openBoardLeaf(); + fireEvent.click(await card('Ada')); + await waitFor(() => expect(outlinedCard()).toBe('r2')); + + fireEvent.keyDown(document.body, { key: 'Escape' }); + + await waitFor(() => expect(outlinedCard()).toBeNull()); + expect(slot()).toHaveAttribute('data-mode', 'props'); + expect(cardSection()).toBeVisible(); + expect(cardSection()).not.toHaveAttribute('data-selected'); + expect(screen.queryByTestId('if-canvas-hint')).toBeNull(); + }); + + it('a click on empty canvas clears the card selection', async () => { + await openBoardLeaf(); + fireEvent.click(await card('Ada')); + await waitFor(() => expect(outlinedCard()).toBe('r2')); + + fireEvent.click(canvas()); + + await waitFor(() => expect(outlinedCard()).toBeNull()); + expect(slot()).toHaveAttribute('data-mode', 'props'); + expect(cardSection()).not.toHaveAttribute('data-selected'); + }); +}); + +describe('the kanban card section writes only its spec keys (objectui#12725)', () => { + /** Open the board leaf and select a card, so the slot shows the section. */ + async function openCardProps() { + await openBoardLeaf(); + fireEvent.click(await card('Bob')); + await waitFor(() => expect(slot()).toHaveAttribute('data-mode', 'props')); + } + + /** Every key of the saved body but the ones named, and every key of its kanban block but the ones named. */ + function unchangedApart(body: Record, keys: string[], kanbanKeys: string[] = []) { + const strip = (o: Record, drop: string[]) => + Object.fromEntries(Object.entries(o).filter(([k]) => !drop.includes(k))); + expect(strip(body, [...keys, 'kanban'])).toEqual(strip(BOARD_VIEW.config, [...keys, 'kanban'])); + expect(strip(body.kanban as Record, kanbanKeys)).toEqual( + strip(BOARD_VIEW.config.kanban, kanbanKeys), + ); + } + + it('card fields write kanban.columns, in order', async () => { + await openCardProps(); + const fields = within(cardSection() as HTMLElement).getByTestId('list-view-kanban-fields'); + await pick(within(fields).getByRole('combobox'), /Owner/); + + await waitFor(() => expect(lastSavedBoardBody()).not.toBeNull(), { timeout: 4000 }); + const body = lastSavedBoardBody()!; + expect((body.kanban as Record).columns).toEqual(['priority', 'owner']); + unchangedApart(body, [], ['columns']); + expectSpecValid(body); + }); + + it('the title writes kanban.titleField, and the record name removes it', async () => { + await openCardProps(); + const title = () => within(cardSection() as HTMLElement).getByLabelText(t('engine.studio.inspector.listView.kanbanCard.title', 'en')); + await pick(title(), /Title/); + + await waitFor(() => expect((lastSavedBoardBody()?.kanban as Record | undefined)?.titleField).toBe('title'), { + timeout: 4000, + }); + unchangedApart(lastSavedBoardBody()!, [], ['titleField']); + expectSpecValid(lastSavedBoardBody()!); + + await pick(title(), t('engine.studio.inspector.listView.kanbanCard.titleRecordName', 'en')); + await waitFor(() => expect(lastSavedBoardBody()?.kanban).toEqual(BOARD_VIEW.config.kanban), { timeout: 4000 }); + expectSpecValid(lastSavedBoardBody()!); + }); + + it('click to open writes navigation.mode, over the spec modes', async () => { + await openCardProps(); + const open = within(cardSection() as HTMLElement).getByLabelText(t('engine.studio.inspector.listView.kanbanCard.open', 'en')); + await pick(open, t('engine.studio.inspector.listView.kanbanCard.mode.drawer', 'en')); + + await waitFor(() => expect(lastSavedBoardBody()?.navigation).toEqual({ mode: 'drawer' }), { timeout: 4000 }); + unchangedApart(lastSavedBoardBody()!, ['navigation']); + expectSpecValid(lastSavedBoardBody()!); + }); + + it('the group-by field writes kanban.groupByField', async () => { + await openCardProps(); + const group = within(cardSection() as HTMLElement).getByLabelText(t('engine.studio.inspector.listView.kanbanCard.groupBy', 'en')); + await pick(group, /Priority/); + + await waitFor( + () => expect((lastSavedBoardBody()?.kanban as Record | undefined)?.groupByField).toBe('priority'), + { timeout: 4000 }, + ); + unchangedApart(lastSavedBoardBody()!, [], ['groupByField']); + expectSpecValid(lastSavedBoardBody()!); + }); +}); + +describe('a kanban view with no kanban block (objectui#12725)', () => { + it('the title and the card fields wait for the group-by field, which writes the block with no card fields', async () => { + await openLeaf(2); + await waitFor(() => expect(cardSection()).not.toBeNull(), { timeout: 4000 }); + fireEvent.click(await card('Bob')); + await waitFor(() => expect(slot()).toHaveAttribute('data-mode', 'props')); + const section = cardSection() as HTMLElement; + expect(within(section).getByTestId('list-view-kanban-group-first')).toBeVisible(); + expect(within(section).getByLabelText(t('engine.studio.inspector.listView.kanbanCard.title', 'en'))).toBeDisabled(); + expect(within(section).queryByRole('combobox', { name: t('engine.form.addField', 'en') })).toBeNull(); + + await pick(within(section).getByLabelText(t('engine.studio.inspector.listView.kanbanCard.groupBy', 'en')), /Priority/); + + await waitFor(() => expect(lastSavedBoardBody(BARE_VIEW.name)).not.toBeNull(), { timeout: 4000 }); + const body = lastSavedBoardBody(BARE_VIEW.name)!; + expect(body).toEqual({ ...BARE_VIEW.config, kanban: { groupByField: 'priority', columns: [] } }); + expectSpecValid(body); + await waitFor(() => expect(within(section).getByLabelText(t('engine.studio.inspector.listView.kanbanCard.title', 'en'))).toBeEnabled()); + }); +}); + +describe('CONTROLS (objectui#12725)', () => { + it('a grid leaf shows no kanban card section, and its canvas has no card channel', async () => { + await openLeaf(1); + await within(canvas()).findByText('Bob'); + expect(cardSection()).toBeNull(); + expect(within(canvas()).getByTestId('studio-canvas-cards').querySelector('style')).toBeNull(); + }); + + it('outside Studio, a card click opens the record, and the card takes the press', async () => { + render(); + const bob = (await screen.findByText('Bob', undefined, { timeout: 4000 })).closest('[data-kanban-card]') as HTMLElement; + expect(bob).toHaveAttribute('data-kanban-card', 'r1'); + const press = vi.fn(); + bob.addEventListener('pointerdown', press); + + fireEvent.pointerDown(bob); + fireEvent.click(bob); + + expect(press).toHaveBeenCalledTimes(1); + expect(openRecord).toHaveBeenCalledWith('r1'); + }); + + it('a canvas registered over the default gets no channel: a card click opens the record and the slot keeps the copilot', async () => { + registerStudioCanvasPreview('object', () => ); + await openLeaf(0); + const bob = (await within(canvas()).findByText('Bob', undefined, { timeout: 4000 })).closest('[data-kanban-card]') as HTMLElement; + expect(within(canvas()).queryByTestId('studio-canvas-cards')).toBeNull(); + // Nor does the canvas promise a click it cannot answer. + expect(screen.queryByTestId('if-canvas-hint')).toBeNull(); + + fireEvent.click(bob); + + expect(openRecord).toHaveBeenCalledWith('r1'); + expect(slot()).toHaveAttribute('data-mode', 'copilot'); + }); +}); From 0316759b97de0f0bc05f8c2da36bd85f7c4ceeee Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 17 Oct 2026 13:40:27 +0000 Subject: [PATCH 3/6] test(app-shell): a control drawn inside a kanban card keeps its click on the Studio canvas (objectui#12725, part) Claude-Session: https://claude.ai/code/session_01HRnFN6Rtqk2WxPiK6qBhwq Co-authored-by: Claude --- ...Surface.kanbanCardSelection-12725.test.tsx | 31 ++++++++++++++++++- 1 file changed, 30 insertions(+), 1 deletion(-) diff --git a/packages/app-shell/src/views/studio-design/StudioDesignSurface.kanbanCardSelection-12725.test.tsx b/packages/app-shell/src/views/studio-design/StudioDesignSurface.kanbanCardSelection-12725.test.tsx index d2d5a258f0..fbf6ad797b 100644 --- a/packages/app-shell/src/views/studio-design/StudioDesignSurface.kanbanCardSelection-12725.test.tsx +++ b/packages/app-shell/src/views/studio-design/StudioDesignSurface.kanbanCardSelection-12725.test.tsx @@ -110,6 +110,8 @@ const ROWS = [ /** What a card click asks the runtime to open. */ const openRecord = vi.fn(); +/** What the control drawn inside a card does. */ +const cardControl = vi.fn(); vi.mock('sonner', () => ({ toast: { error: vi.fn(), success: vi.fn(), info: vi.fn(), warning: vi.fn(), dismiss: vi.fn() }, @@ -191,7 +193,20 @@ function Board({ groupBy }: { groupBy: string }) { function ObjectViewStub({ schema }: { schema: Record }) { const views = (schema.listViews ?? {}) as Record; const view = views[String(schema.defaultListView)]; - if (view?.type === 'kanban') return ; + if (view?.type === 'kanban') { + return ( + <> + + {/* A card drawn with a control in it, in the board's markup (`SortableCard`). */} +
+ Eve + +
+ + ); + } return ( @@ -229,6 +244,7 @@ beforeAll(() => { beforeEach(() => { window.sessionStorage.clear(); openRecord.mockClear(); + cardControl.mockClear(); mockClient.save.mockClear(); vi.stubGlobal( 'fetch', @@ -354,6 +370,19 @@ describe("a card on a kanban leaf's canvas selects the board's card (objectui#12 expect(openRecord).not.toHaveBeenCalled(); }); + it('a control drawn inside a card keeps its click, and selects nothing', async () => { + await openBoardLeaf(); + + fireEvent.click(within(canvas()).getByRole('button', { name: 'Act' })); + + expect(cardControl).toHaveBeenCalledTimes(1); + expect(outlinedCard()).toBeNull(); + expect(slot()).toHaveAttribute('data-mode', 'copilot'); + // The card itself still selects. + fireEvent.click(within(canvas()).getByText('Eve')); + await waitFor(() => expect(outlinedCard()).toBe('r9')); + }); + it("Esc clears the card selection, and the slot keeps the view's properties", async () => { await openBoardLeaf(); fireEvent.click(await card('Ada')); From e5f07fbbde47949b5567729b789be5adb8364e98 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 17 Oct 2026 14:50:55 +0000 Subject: [PATCH 4/6] feat(app-shell): Enter and Space on a focused kanban card select it on the Studio canvas (objectui#12725, part) objectui#12888 made a focused card open on Enter and on Space's release, as a click does. On the Studio canvas the keys now do what the click does there: KanbanCardSelect takes keydown Enter and keyup Space in the capture phase on the card element itself and selects the card, and holds Space's press so the board does not scroll and the board's own handler does not run. A key on a control inside a card stays that control's. Outside Studio nothing changes. Pins: Enter selects, Space selects on release, a key on a control inside a card is not taken, and the CONTROL that Enter opens the record outside Studio. The fixture card follows the board's new list item and button nesting. Claude-Session: https://claude.ai/code/session_01HRnFN6Rtqk2WxPiK6qBhwq Co-authored-by: Claude --- ...Surface.kanbanCardSelection-12725.test.tsx | 64 +++++++++++++++++-- .../studio-design/studio-canvas-preview.tsx | 18 ++++++ 2 files changed, 77 insertions(+), 5 deletions(-) diff --git a/packages/app-shell/src/views/studio-design/StudioDesignSurface.kanbanCardSelection-12725.test.tsx b/packages/app-shell/src/views/studio-design/StudioDesignSurface.kanbanCardSelection-12725.test.tsx index fbf6ad797b..7f31ac2775 100644 --- a/packages/app-shell/src/views/studio-design/StudioDesignSurface.kanbanCardSelection-12725.test.tsx +++ b/packages/app-shell/src/views/studio-design/StudioDesignSurface.kanbanCardSelection-12725.test.tsx @@ -198,11 +198,13 @@ function ObjectViewStub({ schema }: { schema: Record }) { <> {/* A card drawn with a control in it, in the board's markup (`SortableCard`). */} -
- Eve - +
+
+ Eve + +
); @@ -383,6 +385,48 @@ describe("a card on a kanban leaf's canvas selects the board's card (objectui#12 await waitFor(() => expect(outlinedCard()).toBe('r9')); }); + it('Enter on a focused card selects it, and opens nothing', async () => { + await openBoardLeaf(); + const bob = await card('Bob'); + bob.focus(); + + const notPrevented = fireEvent.keyDown(bob, { key: 'Enter' }); + + expect(notPrevented).toBe(false); + await waitFor(() => expect(slot()).toHaveAttribute('data-mode', 'props')); + expect(outlinedCard()).toBe('r1'); + expect(cardSection()).toHaveAttribute('data-selected', 'true'); + expect(openRecord).not.toHaveBeenCalled(); + }); + + it("Space on a focused card selects it on the release, and its press does not scroll or open", async () => { + await openBoardLeaf(); + const ada = await card('Ada'); + ada.focus(); + + expect(fireEvent.keyDown(ada, { key: ' ' })).toBe(false); + expect(outlinedCard()).toBeNull(); + expect(fireEvent.keyUp(ada, { key: ' ' })).toBe(false); + + await waitFor(() => expect(slot()).toHaveAttribute('data-mode', 'props')); + expect(outlinedCard()).toBe('r2'); + expect(openRecord).not.toHaveBeenCalled(); + }); + + it('a key on a control drawn inside a card is that control\'s, and selects nothing', async () => { + await openBoardLeaf(); + const act = within(canvas()).getByRole('button', { name: 'Act' }); + const own = vi.fn(); + act.addEventListener('keydown', own); + + expect(fireEvent.keyDown(act, { key: 'Enter' })).toBe(true); + fireEvent.keyUp(act, { key: ' ' }); + + expect(own).toHaveBeenCalledTimes(1); + expect(outlinedCard()).toBeNull(); + expect(slot()).toHaveAttribute('data-mode', 'copilot'); + }); + it("Esc clears the card selection, and the slot keeps the view's properties", async () => { await openBoardLeaf(); fireEvent.click(await card('Ada')); @@ -523,6 +567,16 @@ describe('CONTROLS (objectui#12725)', () => { expect(openRecord).toHaveBeenCalledWith('r1'); }); + it('outside Studio, Enter on a focused card opens the record (objectui#12888)', async () => { + render(); + const bob = (await screen.findByText('Bob', undefined, { timeout: 4000 })).closest('[data-kanban-card]') as HTMLElement; + bob.focus(); + + fireEvent.keyDown(bob, { key: 'Enter' }); + + expect(openRecord).toHaveBeenCalledWith('r1'); + }); + it('a canvas registered over the default gets no channel: a card click opens the record and the slot keeps the copilot', async () => { registerStudioCanvasPreview('object', () => ); await openLeaf(0); diff --git a/packages/app-shell/src/views/studio-design/studio-canvas-preview.tsx b/packages/app-shell/src/views/studio-design/studio-canvas-preview.tsx index 076f62d975..7b40a8a52a 100644 --- a/packages/app-shell/src/views/studio-design/studio-canvas-preview.tsx +++ b/packages/app-shell/src/views/studio-design/studio-canvas-preview.tsx @@ -373,6 +373,12 @@ function ObjectRecordsGrid({ name }: StudioCanvasPreviewProps) { * board's drag sensor starts a move. A control drawn inside a card keeps its * job. * + * A focused card opens on Enter and on Space's release, as a click does + * (objectui#12888), so the keys are taken in the capture phase too: on the + * Studio canvas Enter, and Space's release, select the card, and Space's press + * is held so the board does not scroll. Only a key on the card element itself, + * the board's own guard: a key on something inside the card stays its own. + * * Drawn always, with no box, for the reason {@link ColumnHeaderSelect} is. */ function KanbanCardSelect({ objectName, children }: { objectName: string; children: React.ReactNode }) { @@ -390,6 +396,16 @@ function KanbanCardSelect({ objectName, children }: { objectName: string; childr const onPointerDownCapture = (e: React.PointerEvent) => { if (cardOf(e)) e.stopPropagation(); }; + const onKeyCapture = (e: React.KeyboardEvent) => { + if (!cards || !(e.target instanceof Element) || !e.target.matches('[data-kanban-card]')) return; + if (!e.currentTarget.contains(e.target)) return; + const selects = e.type === 'keydown' ? e.key === 'Enter' : e.key === ' '; + if (!selects && !(e.type === 'keydown' && e.key === ' ')) return; + e.stopPropagation(); + e.preventDefault(); + const id = e.target.getAttribute('data-kanban-card'); + if (selects && id) cards.onSelect(id); + }; const onClickCapture = (e: React.MouseEvent) => { const id = cardOf(e)?.getAttribute('data-kanban-card'); if (!cards || !id) return; @@ -404,6 +420,8 @@ function KanbanCardSelect({ objectName, children }: { objectName: string; childr data-studio-canvas-cards={scope} onPointerDownCapture={onPointerDownCapture} onClickCapture={onClickCapture} + onKeyDownCapture={onKeyCapture} + onKeyUpCapture={onKeyCapture} > {cards?.selected ? ( // The clicked card is outlined, as the StudioRailProps artboard draws From 14078da6e305d80e3e50c7600a39daa61211b505 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 17 Oct 2026 14:56:12 +0000 Subject: [PATCH 5/6] test(app-shell): neither half of Space reaches a kanban card's own handlers on the Studio canvas (objectui#12725, part) Claude-Session: https://claude.ai/code/session_01HRnFN6Rtqk2WxPiK6qBhwq Co-authored-by: Claude --- .../StudioDesignSurface.kanbanCardSelection-12725.test.tsx | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/packages/app-shell/src/views/studio-design/StudioDesignSurface.kanbanCardSelection-12725.test.tsx b/packages/app-shell/src/views/studio-design/StudioDesignSurface.kanbanCardSelection-12725.test.tsx index 7f31ac2775..2c708f4b3c 100644 --- a/packages/app-shell/src/views/studio-design/StudioDesignSurface.kanbanCardSelection-12725.test.tsx +++ b/packages/app-shell/src/views/studio-design/StudioDesignSurface.kanbanCardSelection-12725.test.tsx @@ -403,6 +403,10 @@ describe("a card on a kanban leaf's canvas selects the board's card (objectui#12 await openBoardLeaf(); const ada = await card('Ada'); ada.focus(); + // The board's own key handlers sit on the card: neither key reaches them. + const own = vi.fn(); + ada.addEventListener('keydown', own); + ada.addEventListener('keyup', own); expect(fireEvent.keyDown(ada, { key: ' ' })).toBe(false); expect(outlinedCard()).toBeNull(); @@ -411,6 +415,7 @@ describe("a card on a kanban leaf's canvas selects the board's card (objectui#12 await waitFor(() => expect(slot()).toHaveAttribute('data-mode', 'props')); expect(outlinedCard()).toBe('r2'); expect(openRecord).not.toHaveBeenCalled(); + expect(own).not.toHaveBeenCalled(); }); it('a key on a control drawn inside a card is that control\'s, and selects nothing', async () => { From 9bce2a9102e6c4017238aa28adb41691ac480b94 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 17 Oct 2026 15:13:25 +0000 Subject: [PATCH 6/6] docs(changeset): the kanban card selection takes Enter and Space too (objectui#12725, part) Claude-Session: https://claude.ai/code/session_01HRnFN6Rtqk2WxPiK6qBhwq Co-authored-by: Claude --- .changeset/12725-kanban-card-panel.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.changeset/12725-kanban-card-panel.md b/.changeset/12725-kanban-card-panel.md index b0ab5f75ab..79ec349f67 100644 --- a/.changeset/12725-kanban-card-panel.md +++ b/.changeset/12725-kanban-card-panel.md @@ -5,6 +5,6 @@ In Studio's Interfaces pillar, a click on a card of a kanban view's board selects the board's card, and a 看板卡片 (Kanban card) section in the view's properties edits it (objectui#12725, target 2: clicking a canvas element switches the right slot to 属性 for that element). -- **Select, not open.** On an `object` leaf whose list view renders as a kanban board, a click on a card inside the Studio canvas switches the slot to 属性 with the 看板卡片 section, outlines the clicked card, and the status line reads 「已选中:看板卡片 · Esc 取消选中」. There the click does not open the record, and a press on a card does not start a drag. A button or link drawn inside a card keeps its click. Esc, or a click on empty canvas, clears the selection; the slot keeps the view's properties. +- **Select, not open.** On an `object` leaf whose list view renders as a kanban board, a click on a card inside the Studio canvas switches the slot to 属性 with the 看板卡片 section, outlines the clicked card, and the status line reads 「已选中:看板卡片 · Esc 取消选中」. There the click does not open the record, and a press on a card does not start a drag. Enter, or Space, on a focused card selects it in the same way (a card opens on those keys elsewhere). A button or link drawn inside a card keeps its click. Esc, or a click on empty canvas, clears the selection; the slot keeps the view's properties. - **The 看板卡片 section** shows only on a kanban view and writes keys the spec declares, each control its own key: the card fields (`kanban.columns`, ordered), the title (`kanban.titleField`; 记录名称 removes the key and the card shows the record's display name), click to open (`navigation.mode`, over the spec's navigation modes; 默认 removes the key) and the group-by field (`kanban.groupByField`). The spec requires the group-by field and the card fields on a kanban block, so on a view with no block the title and the card fields wait until a group-by field is picked. There is no colour control. - **The board outside Studio is unchanged:** a click opens the record and a drag moves the card. Each card now carries `data-kanban-card` with its record id, which the Studio canvas reads to tell which card was clicked; markup only, no prop or export changes. A canvas registered over the default (`registerStudioCanvasPreview`) gets no card selection.