diff --git a/src/Ivy/Views/Kanban/KanbanBuilder.cs b/src/Ivy/Views/Kanban/KanbanBuilder.cs index f8beae4f5f..72dc44a343 100644 --- a/src/Ivy/Views/Kanban/KanbanBuilder.cs +++ b/src/Ivy/Views/Kanban/KanbanBuilder.cs @@ -20,10 +20,12 @@ public class KanbanBuilder( private Func? _customCardRenderer; private Func, ValueTask>? _onMove; private Func? _columnHeaderSelector; + private Func? _columnIconSelector; private object? _empty; private Size? _width = Size.Full(); private Size? _height = Size.Full(); private Size? _columnWidth; + private TGroupKey[]? _staticColumns; public KanbanBuilder Builder(Func, IBuilder> builder) { @@ -111,6 +113,81 @@ public KanbanBuilder ColumnHeader(Func hea return this; } + /// + /// Maps a column group key to a Lucide icon name (e.g. "Feather") shown in the + /// column header. Only applies to fixed columns declared via . + /// + public KanbanBuilder ColumnIcon(Func iconSelector) + { + _columnIconSelector = iconSelector; + return this; + } + + /// + /// Defines a fixed set of columns that always render, in the given order, + /// even when a column has no cards. Cards whose group key is not listed are + /// appended as extra columns after the fixed ones. + /// + public KanbanBuilder Columns(params TGroupKey[] columns) + { + _staticColumns = columns; + return this; + } + + /// + /// Converts the raw ToColumn payload of a card-move event (a string, number, or + /// JsonElement/JsonNode from event deserialization) back into the board's group + /// key type. Enum keys round-trip as their serialized names, which + /// cannot parse. + /// + private static bool TryConvertGroupKey(object toColumn, out TGroupKey key) + { + if (toColumn is TGroupKey typedKey) + { + key = typedKey; + return true; + } + + // Unwrap JSON payloads to a plain string/number before converting. + var raw = toColumn switch + { + System.Text.Json.JsonElement je => je.ValueKind switch + { + System.Text.Json.JsonValueKind.String => je.GetString(), + System.Text.Json.JsonValueKind.Number => je.GetRawText(), + _ => je.ToString() + }, + System.Text.Json.Nodes.JsonValue jv => jv.TryGetValue(out var s) ? s : jv.ToString(), + System.Text.Json.Nodes.JsonNode jn => jn.ToString(), + _ => toColumn.ToString() + }; + + key = default!; + if (raw == null) + return false; + + try + { + if (typeof(TGroupKey).IsEnum) + { + if (Enum.TryParse(typeof(TGroupKey), raw, ignoreCase: true, out var parsed) && + Enum.IsDefined(typeof(TGroupKey), parsed!)) + { + key = (TGroupKey)parsed!; + return true; + } + return false; + } + + key = (TGroupKey)Convert.ChangeType(raw, typeof(TGroupKey)); + return true; + } + catch + { + return false; + } + } + private static string HumanizeGroupKey(TGroupKey key) { if (key is Enum enumValue) @@ -121,7 +198,7 @@ private static string HumanizeGroupKey(TGroupKey key) public override object? Build() { - if (!records.Any()) + if (!records.Any() && _staticColumns == null) { return _empty ?? new Fragment(); } @@ -212,7 +289,14 @@ private static string HumanizeGroupKey(TGroupKey key) ShowCounts = true, Width = _width ?? Size.Full(), Height = _height ?? Size.Full(), - ColumnWidth = _columnWidth + ColumnWidth = _columnWidth, + Columns = _staticColumns? + .Select((key, index) => new KanbanColumnDef( + key, + _columnHeaderSelector != null ? _columnHeaderSelector(key) : HumanizeGroupKey(key), + index, + _columnIconSelector?.Invoke(key))) + .ToArray() }; if (_onMove != null) @@ -221,29 +305,13 @@ private static string HumanizeGroupKey(TGroupKey key) { OnCardMove = new(e => { - if (e.Value.ToColumn == null) + if (e.Value.ToColumn == null || !TryConvertGroupKey(e.Value.ToColumn, out var groupKey)) return ValueTask.CompletedTask; - if (e.Value.ToColumn is TGroupKey groupKey) - { - return _onMove(new Event( - e.EventName, - e.Sender, - (e.Value.CardId, groupKey, e.Value.TargetIndex))); - } - - try - { - var convertedKey = (TGroupKey)Convert.ChangeType(e.Value.ToColumn, typeof(TGroupKey)); - return _onMove(new Event( - e.EventName, - e.Sender, - (e.Value.CardId, convertedKey, e.Value.TargetIndex))); - } - catch - { - return ValueTask.CompletedTask; - } + return _onMove(new Event( + e.EventName, + e.Sender, + (e.Value.CardId, groupKey, e.Value.TargetIndex))); }) }; } diff --git a/src/Ivy/Widgets/Kanban/Kanban.cs b/src/Ivy/Widgets/Kanban/Kanban.cs index 020047c1a5..fbf9e2c94a 100644 --- a/src/Ivy/Widgets/Kanban/Kanban.cs +++ b/src/Ivy/Widgets/Kanban/Kanban.cs @@ -1,6 +1,13 @@ // ReSharper disable once CheckNamespace namespace Ivy; +/// +/// A fixed column definition for a Kanban board. When provided, columns render +/// in the given order even when they contain no cards. is +/// an optional Lucide icon name shown in the column header (e.g. "Feather"). +/// +public record KanbanColumnDef(object Id, string? Name = null, int? Order = null, string? Icon = null); + /// /// A visual board for managing tasks and workflows. /// @@ -16,6 +23,8 @@ internal Kanban() { } [Prop] public Size? ColumnWidth { get; set; } + [Prop] public KanbanColumnDef[]? Columns { get; set; } + [Event] public EventHandler>? OnCardMove { get; set; } public static Kanban operator |(Kanban kanban, KanbanCard child) diff --git a/src/frontend/src/components/ui/shadcn-io/kanban.tsx b/src/frontend/src/components/ui/shadcn-io/kanban.tsx index 18310d9483..8b78092c58 100644 --- a/src/frontend/src/components/ui/shadcn-io/kanban.tsx +++ b/src/frontend/src/components/ui/shadcn-io/kanban.tsx @@ -1,6 +1,8 @@ "use client"; import { createContext, useContext, useState, useCallback, useRef, ReactNode } from "react"; +import { Circle, MoreHorizontal } from "lucide-react"; +import Icon from "@/components/Icon"; import { ScrollArea } from "@/components/ui/scroll-area"; import { Separator } from "@/components/ui/separator"; import { cn } from "@/lib/utils"; @@ -20,6 +22,7 @@ export interface Column { id: string; name: string; color: string; + icon?: string; } interface DropTarget { @@ -131,8 +134,8 @@ interface KanbanBoardProps { export function KanbanBoard({ children, className }: KanbanBoardProps) { return (
{children}
@@ -143,12 +146,21 @@ interface KanbanColumnProps { id: string; name?: string; color?: string; + icon?: string; width?: string; children: ReactNode; className?: string; } -export function KanbanColumn({ id, name, color, width, children, className }: KanbanColumnProps) { +export function KanbanColumn({ + id, + name, + color, + icon, + width, + children, + className, +}: KanbanColumnProps) { const { onCardMove, data, @@ -210,27 +222,38 @@ export function KanbanColumn({ id, name, color, width, children, className }: Ka return (
-
-

- {color &&
} - {name || id} - {showCounts && ( - ({columnTaskCount}) - )} -

+
+ {icon ? ( + + ) : color ? ( + + ) : ( + + )} +

{name || id}

+ {showCounts && ( + {columnTaskCount} + )} +
{children}
@@ -253,8 +276,11 @@ export function KanbanCards({ id, children }: KanbanCardsProps) { const showLineIndicator = isTargetColumn && isSameColumnDrag; return ( - -
+ // The viewport's content wrapper defaults to display:table, which sizes to its + // content and lets wide cards overflow the fixed column width; force it to block + // so cards are laid out at the column width and truncate internally instead. + +
{columnTasks.map((task, index) => { const isDraggedCard = task.id === draggedCardId; const shouldShift = diff --git a/src/frontend/src/widgets/kanban/KanbanWidget.tsx b/src/frontend/src/widgets/kanban/KanbanWidget.tsx index cbd32fce6c..34d89b35ed 100644 --- a/src/frontend/src/widgets/kanban/KanbanWidget.tsx +++ b/src/frontend/src/widgets/kanban/KanbanWidget.tsx @@ -71,6 +71,7 @@ export const KanbanWidget: React.FC = ({ id={column.id} name={column.name} color={column.color} + icon={column.icon} width={columnWidth} > diff --git a/src/frontend/src/widgets/kanban/types.ts b/src/frontend/src/widgets/kanban/types.ts index 9dc69b23d3..9e24926ca7 100644 --- a/src/frontend/src/widgets/kanban/types.ts +++ b/src/frontend/src/widgets/kanban/types.ts @@ -6,6 +6,16 @@ export interface Column { name: string; color: string; order: number; + icon?: string; +} + +/** Column definition as serialized from the backend Kanban.Columns prop. */ +export interface ProvidedColumn { + id: string | number; + name?: string; + color?: string; + order?: number; + icon?: string; } export interface TaskWithWidgetId extends Task { @@ -29,7 +39,7 @@ export interface ExtractedKanbanData { export interface KanbanWidgetProps { id: string; - columns?: Column[]; + columns?: ProvidedColumn[]; tasks?: Task[]; events?: string[]; width?: string; diff --git a/src/frontend/src/widgets/kanban/useKanbanData.ts b/src/frontend/src/widgets/kanban/useKanbanData.ts index a630903cb7..7f7b7aa8bd 100644 --- a/src/frontend/src/widgets/kanban/useKanbanData.ts +++ b/src/frontend/src/widgets/kanban/useKanbanData.ts @@ -1,6 +1,12 @@ import React from "react"; import { Task } from "@/components/ui/shadcn-io/kanban"; -import type { Column, TaskWithWidgetId, CardData, ExtractedKanbanData } from "./types"; +import type { + Column, + ProvidedColumn, + TaskWithWidgetId, + CardData, + ExtractedKanbanData, +} from "./types"; interface WidgetNodeChild { type: string; @@ -51,13 +57,57 @@ function sortColumnKeysByBackendOrder(columnKeys: string[]): string[] { }); } +function normalizeProvidedColumns(columns: ProvidedColumn[]): Column[] { + return columns.map((column, index) => ({ + id: String(column.id), + name: column.name ?? String(column.id), + color: column.color ?? "", + order: column.order ?? index, + icon: column.icon, + })); +} + +/** + * Builds the final column list: provided (static) columns first in their + * declared order, followed by any extra columns discovered on the cards. + * Without provided columns, falls back to deriving columns from the cards. + */ +function buildColumns( + dataColumnKeys: string[], + columnNameMap: Map, + providedColumns: Column[], +): Column[] { + if (providedColumns.length > 0) { + const knownIds = new Set(providedColumns.map((c) => c.id)); + const extras = dataColumnKeys.filter((key) => !knownIds.has(key)); + return [ + ...providedColumns, + ...extras.map((key, index) => ({ + id: key, + name: columnNameMap.get(key) ?? key, + color: "", + order: providedColumns.length + index, + })), + ]; + } + + const sortedKeys = sortColumnKeysByBackendOrder(dataColumnKeys); + return sortedKeys.map((key, index) => ({ + id: key, + name: columnNameMap.get(key) ?? key, + color: "", + order: index, + })); +} + export function useKanbanData( slots: { default?: React.ReactNode[] } | undefined, tasks: Task[], - columns: Column[], + columns: ProvidedColumn[], widgetNodeChildren?: WidgetNodeChild[], ): ExtractedKanbanData { return React.useMemo(() => { + const providedColumns = normalizeProvidedColumns(columns); const kanbanChildren = (widgetNodeChildren || []).filter((c) => c.type === "Ivy.KanbanCard"); if (kanbanChildren.length > 0) { const extractedCards: CardData[] = []; @@ -87,24 +137,18 @@ export function useKanbanData( const allColumnKeys = extractColumnKeysFromCards(extractedCards); const columnNameMap = buildColumnNameMap(extractedCards); - const finalColumnKeys = sortColumnKeysByBackendOrder(allColumnKeys); - - const extractedColumns: Column[] = finalColumnKeys.map((key, index) => ({ - id: key, - name: columnNameMap.get(key) ?? key, - color: "", - order: index, - })); + const extractedColumns = buildColumns(allColumnKeys, columnNameMap, providedColumns); + const columnIndexById = new Map(extractedColumns.map((c, i) => [c.id, i])); const extractedTasks: TaskWithWidgetId[] = extractedCards.map((card) => { const column = card.columnKey || "Default"; - const columnIndex = finalColumnKeys.indexOf(column); + const columnIndex = columnIndexById.get(column) ?? 0; return { id: card.cardId, title: "", status: column, - statusOrder: columnIndex >= 0 ? columnIndex : 0, + statusOrder: columnIndex, priority: card.priority || 0, description: "", assignee: "", @@ -130,14 +174,7 @@ export function useKanbanData( const statusKeys = Array.from(statusMap.keys()); const columnNameMap = buildColumnNameMap(extractedCards); - const columnKeys = sortColumnKeysByBackendOrder(statusKeys); - - const extractedColumns: Column[] = columnKeys.map((status, index) => ({ - id: status, - name: columnNameMap.get(status) ?? status, - color: "", - order: index, - })); + const extractedColumns = buildColumns(statusKeys, columnNameMap, providedColumns); const cardToTaskMap = new Map(); tasks.forEach((task) => { @@ -161,7 +198,7 @@ export function useKanbanData( return { tasks: tasks.map((t) => ({ ...t, widgetId: t.id })), - columns, + columns: providedColumns, cards: [], }; }, [slots, tasks, columns, widgetNodeChildren]);