Skip to content
Draft
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
114 changes: 91 additions & 23 deletions src/Ivy/Views/Kanban/KanbanBuilder.cs
Original file line number Diff line number Diff line change
Expand Up @@ -20,10 +20,12 @@ public class KanbanBuilder<TModel, TGroupKey>(
private Func<TModel, object>? _customCardRenderer;
private Func<Event<Ivy.Kanban, (object? CardId, TGroupKey ToColumn, int? TargetIndex)>, ValueTask>? _onMove;
private Func<TGroupKey, string>? _columnHeaderSelector;
private Func<TGroupKey, string?>? _columnIconSelector;
private object? _empty;
private Size? _width = Size.Full();
private Size? _height = Size.Full();
private Size? _columnWidth;
private TGroupKey[]? _staticColumns;

public KanbanBuilder<TModel, TGroupKey> Builder(Func<IBuilderFactory<TModel>, IBuilder<TModel>> builder)
{
Expand Down Expand Up @@ -111,6 +113,81 @@ public KanbanBuilder<TModel, TGroupKey> ColumnHeader(Func<TGroupKey, string> hea
return this;
}

/// <summary>
/// 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 <see cref="Columns"/>.
/// </summary>
public KanbanBuilder<TModel, TGroupKey> ColumnIcon(Func<TGroupKey, string?> iconSelector)
{
_columnIconSelector = iconSelector;
return this;
}

/// <summary>
/// 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.
/// </summary>
public KanbanBuilder<TModel, TGroupKey> Columns(params TGroupKey[] columns)
{
_staticColumns = columns;
return this;
}

/// <summary>
/// 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
/// <see cref="Convert.ChangeType(object, Type)"/> cannot parse.
/// </summary>
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<string>(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;
}
Comment on lines +185 to +188
}

private static string HumanizeGroupKey(TGroupKey key)
{
if (key is Enum enumValue)
Expand All @@ -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();
}
Expand Down Expand Up @@ -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)
Expand All @@ -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<Ivy.Kanban, (object?, TGroupKey, int?)>(
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<Ivy.Kanban, (object?, TGroupKey, int?)>(
e.EventName,
e.Sender,
(e.Value.CardId, convertedKey, e.Value.TargetIndex)));
}
catch
{
return ValueTask.CompletedTask;
}
return _onMove(new Event<Ivy.Kanban, (object?, TGroupKey, int?)>(
e.EventName,
e.Sender,
(e.Value.CardId, groupKey, e.Value.TargetIndex)));
})
};
}
Expand Down
9 changes: 9 additions & 0 deletions src/Ivy/Widgets/Kanban/Kanban.cs
Original file line number Diff line number Diff line change
@@ -1,6 +1,13 @@
// ReSharper disable once CheckNamespace
namespace Ivy;

/// <summary>
/// A fixed column definition for a Kanban board. When provided, columns render
/// in the given order even when they contain no cards. <paramref name="Icon"/> is
/// an optional Lucide icon name shown in the column header (e.g. "Feather").
/// </summary>
public record KanbanColumnDef(object Id, string? Name = null, int? Order = null, string? Icon = null);

/// <summary>
/// A visual board for managing tasks and workflows.
/// </summary>
Expand All @@ -16,6 +23,8 @@ internal Kanban() { }

[Prop] public Size? ColumnWidth { get; set; }

[Prop] public KanbanColumnDef[]? Columns { get; set; }

[Event] public EventHandler<Event<Kanban, (object? CardId, object? ToColumn, int? TargetIndex)>>? OnCardMove { get; set; }

public static Kanban operator |(Kanban kanban, KanbanCard child)
Expand Down
66 changes: 46 additions & 20 deletions src/frontend/src/components/ui/shadcn-io/kanban.tsx
Original file line number Diff line number Diff line change
@@ -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";
Expand All @@ -20,6 +22,7 @@ export interface Column {
id: string;
name: string;
color: string;
icon?: string;
}

interface DropTarget {
Expand Down Expand Up @@ -131,8 +134,8 @@ interface KanbanBoardProps {
export function KanbanBoard({ children, className }: KanbanBoardProps) {
return (
<div
className={cn("inline-flex min-w-full h-full bg-background flex-row", className)}
style={{ minWidth: "fit-content", maxWidth: "100%" }}
className={cn("flex w-full h-full bg-background flex-row gap-2", className)}
style={{ maxWidth: "100%" }}
>
{children}
</div>
Expand All @@ -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,
Expand Down Expand Up @@ -210,27 +222,38 @@ export function KanbanColumn({ id, name, color, width, children, className }: Ka
return (
<div
className={cn(
"bg-background rounded-lg px-0 pt-2 min-h-0 flex flex-col transition-colors",
hasExplicitWidth ? "flex-none shrink-0" : "flex-none shrink-0 min-w-70",
showDragOver && "bg-accent rounded-lg",
"bg-muted rounded-2xl px-0 pt-3 pb-1 min-h-0 flex flex-col transition-colors",
// Without an explicit width, columns share the full board width evenly:
// they shrink to fit the viewport (min-w-0 → no horizontal overflow) and
// never grow past 1600px.
hasExplicitWidth ? "flex-none shrink-0" : "flex-1 min-w-0 max-w-[100rem]",
showDragOver && "bg-accent ring-2 ring-primary/20",
className,
)}
style={{
...widthStyles,
...(hasExplicitWidth ? {} : { width: "max-content" }),
}}
style={widthStyles}
onDragOver={handleDragOver}
onDragLeave={handleDragLeave}
onDrop={handleDrop}
>
<div className="px-2">
<h3 className="font-semibold text-foreground flex items-center gap-2">
{color && <div className="size-3 rounded-full" style={{ backgroundColor: color }} />}
{name || id}
{showCounts && (
<span className="text-muted-foreground text-sm font-normal">({columnTaskCount})</span>
)}
</h3>
<div className="flex items-center gap-2 px-4 pb-1">
{icon ? (
<Icon name={icon} className="size-4 text-foreground" />
) : color ? (
<span className="size-4 rounded-full border-[1.5px]" style={{ borderColor: color }} />
) : (
<Circle className="size-4 text-muted-foreground/60" strokeWidth={1.5} />
)}
<h3 className="font-semibold text-sm text-foreground truncate">{name || id}</h3>
{showCounts && (
<span className="text-muted-foreground/70 text-sm font-normal">{columnTaskCount}</span>
)}
<button
type="button"
className="ml-auto text-muted-foreground/50 hover:text-foreground transition-colors"
aria-label="Column options"
>
<MoreHorizontal className="size-4" />
</button>
</div>
{children}
</div>
Expand All @@ -253,8 +276,11 @@ export function KanbanCards({ id, children }: KanbanCardsProps) {
const showLineIndicator = isTargetColumn && isSameColumnDrag;

return (
<ScrollArea className="flex-1 min-h-0">
<div className="flex flex-col gap-2 p-2 pt-3">
// 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.
<ScrollArea className="flex-1 min-h-0" viewportClassName="[&>div]:!block">
<div className="flex flex-col gap-2 p-3">
{columnTasks.map((task, index) => {
const isDraggedCard = task.id === draggedCardId;
const shouldShift =
Expand Down
1 change: 1 addition & 0 deletions src/frontend/src/widgets/kanban/KanbanWidget.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -71,6 +71,7 @@ export const KanbanWidget: React.FC<KanbanWidgetProps> = ({
id={column.id}
name={column.name}
color={column.color}
icon={column.icon}
width={columnWidth}
>
<KanbanCards id={column.id}>
Expand Down
12 changes: 11 additions & 1 deletion src/frontend/src/widgets/kanban/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -29,7 +39,7 @@ export interface ExtractedKanbanData {

export interface KanbanWidgetProps {
id: string;
columns?: Column[];
columns?: ProvidedColumn[];
tasks?: Task[];
events?: string[];
width?: string;
Expand Down
Loading
Loading