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 ( +
+
+

{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. + + )}
({ + 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 ( + <> + + {/* A card drawn with a control in it, in the board's markup (`SortableCard`). */} +
+
+ Eve + +
+
+ + ); + } + 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(); + cardControl.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('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('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(); + // 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(); + expect(fireEvent.keyUp(ada, { key: ' ' })).toBe(false); + + 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 () => { + 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')); + 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('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); + 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'); + }); +}); diff --git a/packages/app-shell/src/views/studio-design/StudioDesignSurface.tsx b/packages/app-shell/src/views/studio-design/StudioDesignSurface.tsx index b2d4434323..53b8a4b89b 100644 --- a/packages/app-shell/src/views/studio-design/StudioDesignSurface.tsx +++ b/packages/app-shell/src/views/studio-design/StudioDesignSurface.tsx @@ -165,19 +165,24 @@ import { } from '../metadata-admin/preview-registry.js'; import { getStudioCanvasPreview, + StudioCanvasCardSelectionContext, StudioCanvasColumnSelectionContext, StudioCanvasDraftOnlyObjectsContext, StudioCanvasListViewContext, StudioCanvasNavEntryContext, StudioObjectRecordsCanvas, + type StudioCanvasCardSelection, type StudioCanvasColumnSelection, type StudioCanvasDraftOnlyObjects, type StudioCanvasNavEntry, } from './studio-canvas-preview.js'; import { + listViewCardIdOf, + listViewCardSelection, listViewColumnFields, listViewColumnIndexOf, listViewColumnSelection, + listViewIsKanban, ObjectListViewInspector, useObjectListViewDraft, type DraftOnlyObjects, @@ -3926,6 +3931,22 @@ export function InterfacesPillar({ }, }; }, [columnFieldsKey, listViewObject, selectedColumnIndex, setSelection]); + // objectui#12725 — and on a kanban view, a click on a card selects the + // board's card (`StudioCanvasCardSelectionContext`): the same one + // selection, `{ kind: 'card' }`, so the slot, Esc and the empty-canvas + // click treat it as they treat a column. Its properties are the panel's + // kanban card section. + const kanbanLeaf = current?.type === 'object' && listViewIsKanban(listView); + const selectedCardId = listViewCardIdOf(selection); + const cardLabel = t('engine.studio.inspector.listView.kanbanCard', locale); + const canvasCards = React.useMemo(() => { + 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..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 @@ -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,84 @@ 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. + * + * 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 }) { + 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 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; + 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 e5e8881574..fd69d44f46 100644 --- a/packages/plugin-kanban/src/KanbanImpl.tsx +++ b/packages/plugin-kanban/src/KanbanImpl.tsx @@ -263,6 +263,9 @@ function SortableCard({ card, onCardClick, conditionalFormatting, objectFields } // on the focusable element, where it is read.
onCardClick?.(card, e)} onKeyDown={(e) => { // A key on something inside the card (a link a field renders) is that