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 (
+
+ );
+}
/**
* 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 (
+