diff --git a/.changeset/12725-kanban-card-panel.md b/.changeset/12725-kanban-card-panel.md
new file mode 100644
index 0000000000..79ec349f67
--- /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. 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.
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 (
+
+ );
+}
/**
* 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.
+
+ )}