Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 10 additions & 5 deletions packages/plugin-kanban/src/KanbanImpl.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -133,7 +133,7 @@ function SortableCard({ card, onCardClick, conditionalFormatting }: { card: Kanb
<div ref={setNodeRef} style={style} {...attributes} {...listeners} role="listitem" aria-label={card.title}
onClick={() => onCardClick?.(card)}
>
<Card className="mb-2 cursor-grab active:cursor-grabbing border-border bg-card/60 hover:border-primary/40 hover:shadow-lg hover:shadow-primary/10 transition-all duration-300 group touch-manipulation" style={cardStyles}>
<Card className="mb-2 cursor-grab active:cursor-grabbing border-border border-l-4 border-l-primary/40 bg-card/60 hover:border-primary/40 hover:shadow-lg hover:shadow-primary/10 transition-all duration-300 group touch-manipulation" style={cardStyles}>
{card.coverImage && (
<div className="w-full h-32 overflow-hidden rounded-t-lg">
<img
Expand Down Expand Up @@ -260,14 +260,14 @@ function KanbanColumnView({
column.className
)}
>
<div className="p-3 sm:p-4 border-b border-border/50 bg-muted/20">
<div className="p-3 sm:p-4 border-b border-border/50 bg-muted/30 rounded-t-lg">
<div className="flex items-center justify-between">
<h3 id={`kanban-col-${column.id}`} className="font-mono text-xs sm:text-sm font-semibold tracking-wider text-primary/90 uppercase truncate">{column.title}</h3>
<div className="flex items-center gap-2">
<span className="font-mono text-xs text-muted-foreground" aria-label={`${safeCards.length} cards${column.limit ? ` of ${column.limit} maximum` : ''}`}>
<Badge variant="secondary" className="text-xs font-mono tabular-nums">

Copilot AI Feb 18, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Swapping the card count from a <span> with a descriptive aria-label to a <Badge> drops the accessible context (screen readers will likely announce just the number). Add an aria-label to the Badge (e.g., "N cards" / "N of limit maximum") or include visually-hidden text so the meaning is preserved.

Suggested change
<Badge variant="secondary" className="text-xs font-mono tabular-nums">
<Badge
variant="secondary"
className="text-xs font-mono tabular-nums"
aria-label={
column.limit
? `${safeCards.length} of ${column.limit} cards`
: `${safeCards.length} cards`
}
>

Copilot uses AI. Check for mistakes.
{safeCards.length}
{column.limit && ` / ${column.limit}`}
</span>
</Badge>
{isLimitExceeded && (
<Badge variant="destructive" className="text-xs">
Full
Expand All @@ -282,6 +282,11 @@ function KanbanColumnView({
strategy={verticalListSortingStrategy}
>
<div className="space-y-2" role="list" aria-label={`${column.title} cards`}>
{safeCards.length === 0 && (
<div className="flex flex-col items-center justify-center py-8 text-muted-foreground/50">

Copilot AI Feb 18, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The container is declared role="list", but the empty-state placeholder inserted when there are no cards is a plain <div> without role="listitem". For ARIA list semantics, children should be listitems; consider giving the placeholder role="listitem" (or adjusting the roles/structure) to keep the markup valid for assistive tech.

Suggested change
<div className="flex flex-col items-center justify-center py-8 text-muted-foreground/50">
<div
role="listitem"
className="flex flex-col items-center justify-center py-8 text-muted-foreground/50"
>

Copilot uses AI. Check for mistakes.
<span className="text-xs font-mono">No cards</span>
</div>
)}
{safeCards.map((card) => (
<SortableCard key={card.id} card={card} onCardClick={onCardClick} conditionalFormatting={conditionalFormatting} />
))}
Expand Down Expand Up @@ -567,7 +572,7 @@ function KanbanBoardInner({ columns, onCardMove, onCardClick, className, dnd, qu
</div>
) : (
/* Standard flat layout */
<div className={cn("flex gap-3 sm:gap-4 overflow-x-auto snap-x snap-mandatory p-2 sm:p-4 [-webkit-overflow-scrolling:touch]", className)} role="region" aria-label="Kanban board">
<div className={cn("flex gap-3 sm:gap-4 overflow-x-auto snap-x snap-mandatory p-2 sm:p-4 bg-muted/10 rounded-lg [-webkit-overflow-scrolling:touch]", className)} role="region" aria-label="Kanban board">
{boardColumns.map((column) => (
<KanbanColumnView
key={column.id}
Expand Down
35 changes: 25 additions & 10 deletions packages/plugin-kanban/src/ObjectKanban.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -122,16 +122,31 @@ export const ObjectKanban: React.FC<ObjectKanbanProps> = ({
}
}

// Default to 'name'
const finalTitleField = titleField || 'name';

return rawData.map(item => ({
...item,
// Ensure id exists
id: item.id || item._id,
// Map title
title: item[finalTitleField] || item.title || 'Untitled',
}));
// Common title field names to try as fallback
const TITLE_FALLBACK_FIELDS = ['name', 'title', 'subject', 'label', 'display_name'];

return rawData.map(item => {
// If a specific title field was configured, try it first
let resolvedTitle = titleField ? item[titleField] : undefined;

// Fallback: try common field names
if (!resolvedTitle) {
for (const field of TITLE_FALLBACK_FIELDS) {
if (item[field]) {
resolvedTitle = item[field];
break;
}
}
}

return {
...item,
// Ensure id exists
id: item.id || item._id,
// Map title
title: resolvedTitle || 'Untitled',
};
});
}, [rawData, schema, objectDef]);

// Generate columns if missing but groupBy is present
Expand Down
93 changes: 93 additions & 0 deletions packages/plugin-kanban/src/__tests__/ObjectKanbanTitle.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,93 @@
/**
* ObjectUI
* Copyright (c) 2024-present ObjectStack Inc.
*
* This source code is licensed under the MIT license found in the
* LICENSE file in the root directory of this source tree.
*/

import { describe, it, expect, vi } from 'vitest';
import { renderHook } from '@testing-library/react';
import { useMemo } from 'react';

/**
* Tests for the title resolution fallback chain in ObjectKanban.
*
Comment on lines +9 to +15

Copilot AI Feb 18, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Unused imports (vi, renderHook, useMemo) in this test file add noise and trigger @typescript-eslint/no-unused-vars warnings. Please remove them (or use them if intended).

Suggested change
import { describe, it, expect, vi } from 'vitest';
import { renderHook } from '@testing-library/react';
import { useMemo } from 'react';
/**
* Tests for the title resolution fallback chain in ObjectKanban.
*
import { describe, it, expect } from 'vitest';
/**
* Tests for the title resolution fallback chain in ObjectKanban.
*
* Tests for the title resolution fallback chain in ObjectKanban.
*

Copilot uses AI. Check for mistakes.
* The effectiveData logic tries fields in this order:
* 1. Explicit cardTitle / titleField from schema
* 2. objectDef.titleFormat (e.g. "{subject}")
* 3. objectDef.NAME_FIELD_KEY
* 4. Fallback chain: name → title → subject → label → display_name
* 5. 'Untitled'
Comment on lines +16 to +21

Copilot AI Feb 18, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The block comment claims the production title resolution order includes objectDef.titleFormat and objectDef.NAME_FIELD_KEY, but the resolveTitle helper below doesn’t implement or test those branches. Either update the comment to match what’s actually covered here, or extend the helper/tests to include the objectDef-based resolution to avoid misleading documentation.

Suggested change
* The effectiveData logic tries fields in this order:
* 1. Explicit cardTitle / titleField from schema
* 2. objectDef.titleFormat (e.g. "{subject}")
* 3. objectDef.NAME_FIELD_KEY
* 4. Fallback chain: name title subject label display_name
* 5. 'Untitled'
* This helper covers the data-driven title resolution used by ObjectKanban
* after any higher-level schema or object definition logic has been applied.
*
* The logic tested here tries fields in this order:
* 1. Explicit cardTitle / titleField from schema
* 2. Fallback chain: name title subject label display_name
* 3. 'Untitled'

Copilot uses AI. Check for mistakes.
*/

// Extract the title resolution logic from ObjectKanban to test it in isolation
const TITLE_FALLBACK_FIELDS = ['name', 'title', 'subject', 'label', 'display_name'];

function resolveTitle(item: Record<string, any>, titleField?: string): string {
let resolvedTitle = titleField ? item[titleField] : undefined;

if (!resolvedTitle) {
for (const field of TITLE_FALLBACK_FIELDS) {
if (item[field]) {
resolvedTitle = item[field];
break;
}
}
}

return resolvedTitle || 'Untitled';
}
Comment on lines +24 to +40

Copilot AI Feb 18, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

These tests re-implement the title resolution logic locally instead of exercising the actual ObjectKanban implementation. That means a future regression in ObjectKanban.tsx could still leave this test suite passing. Consider extracting the resolver (and TITLE_FALLBACK_FIELDS) into a shared exported utility that both ObjectKanban and this test import, or test effectiveData via rendering ObjectKanban with a minimal schema/dataSource setup.

Copilot uses AI. Check for mistakes.

describe('ObjectKanban title resolution', () => {
it('uses explicit titleField when value exists', () => {
const item = { id: '1', custom_title: 'My Custom Title', name: 'Fallback Name' };
expect(resolveTitle(item, 'custom_title')).toBe('My Custom Title');
});

it('falls back to common fields when titleField value is empty', () => {
const item = { id: '1', custom_title: '', name: 'Name Field' };
expect(resolveTitle(item, 'custom_title')).toBe('Name Field');
});

it('resolves name field first in fallback chain', () => {
const item = { id: '1', name: 'Name Value', title: 'Title Value', subject: 'Subject Value' };
expect(resolveTitle(item)).toBe('Name Value');
});

it('resolves title field second in fallback chain', () => {
const item = { id: '1', title: 'Title Value', subject: 'Subject Value' };
expect(resolveTitle(item)).toBe('Title Value');
});

it('resolves subject field third in fallback chain', () => {
const item = { id: '1', subject: 'Subject Value', label: 'Label Value' };
expect(resolveTitle(item)).toBe('Subject Value');
});

it('resolves label field fourth in fallback chain', () => {
const item = { id: '1', label: 'Label Value', display_name: 'Display Name' };
expect(resolveTitle(item)).toBe('Label Value');
});

it('resolves display_name field fifth in fallback chain', () => {
const item = { id: '1', display_name: 'Display Name' };
expect(resolveTitle(item)).toBe('Display Name');
});

it('falls back to Untitled when no common fields exist', () => {
const item = { id: '1', status: 'open', priority: 'high' };
expect(resolveTitle(item)).toBe('Untitled');
});

it('skips falsy field values in fallback chain', () => {
const item = { id: '1', name: '', title: null, subject: 'Bug Report' };
expect(resolveTitle(item)).toBe('Bug Report');
});

it('handles todo_task objects with subject field', () => {
// This is the exact scenario from the bug report
const todoTask = { id: '1', status: 'in_progress', subject: 'Fix login bug', priority: 'high' };
expect(resolveTitle(todoTask)).toBe('Fix login bug');
});
});