Kanban board
A team's board in an app shell: four columns with counts, In progress limited to three, and cards with a label, a due date and an owner. Cards drag from anywhere, and the column under the pointer opens a space where the card will land. A click opens a card beside the board (a link), and every move saves, so the link shows the board as it was left. The ⋯ menu has Assign to me and Archive, which post the form on a static page
@* A team's board: four columns, each a ListView zone drawn as Cards, in a DropZoneGroup that holds
every card. A card drags from anywhere on it (a long press on touch) and opens beside the board
on a click (ItemHref). Its ⋯ menu has Assign to me, Archive and the moves under Move. In progress
holds three at most. Every move saves: on a static page the form posts after each one
(SubmitOnMove) and Save stores it; on an interactive one OnMove does. So a card's link, which
loads the page again, shows the board as it was left. *@
NavigationManager Navigation
Microsoft.AspNetCore.Http
<AppShell>
<AppHeader>
<AppBrand Icon="Lucide.SquareKanban" Label="Acme Projects" />
<Box Grow />
<Avatar Name="Me" />
</AppHeader>
<AppMain>
<Form Model="this" FormName="kanban" OnValidSubmit="Save">
<Stack Gap="5" Padding="new() { Base = 4, Md = 6 }">
<Row Gap="3" Align="Align.Center" Wrap>
<Stack Gap="0">
<Heading Level="1" Size="TextSize.Xl2">Spring conference</Heading>
<TextBlock Size="TextSize.Sm" Color="Color.Muted">Board.Count cards · Board.Count(p => p.Zone == "done") done</TextBlock>
</Stack>
<Spacer />
if (_archived.Count > 0)
{
if (RendererInfo.IsInteractive)
{
<Button Variant="ButtonVariant.Ghost" Size="ControlSize.Sm" StartIcon="Lucide.ArchiveRestore" OnClick="RestoreAll">Restore _archived.Count archived</Button>
}
else
{
<Button Variant="ButtonVariant.Ghost" Size="ControlSize.Sm" StartIcon="Lucide.ArchiveRestore" Submit Name="act" Value="restore">Restore _archived.Count archived</Button>
}
}
<AvatarGroup Label="Team" Size="AvatarSize.Sm">
foreach (var name in Team)
{
<Avatar Name="name" />
}
</AvatarGroup>
</Row>
<DropZoneGroup TItem="KanbanCard" @bind-Items="_cards" ItemKey="c => c.Id" ZoneOf="c => c.Column" OnMove="Moved"
Name="board" Order="Order" SubmitOnMove>
<Grid Columns="new() { Base = 1, Sm = 2, Xl = 4 }" Gap="3">
foreach (var column in Columns)
{
var count = Board.Count(p => p.Zone == column.Id);
<Stack Gap="3" Padding="3" Bg="Color.SurfaceSunken" Radius="Radius.Lg" Border="1" BorderColor="Color.Transparent">
<Row Gap="2" Align="Align.Center">
<Heading Level="2" Size="TextSize.Sm">column.Name</Heading>
<Badge Size="BadgeSize.Sm" Pill>(column.Limit is { } limit ? $"{count} / {limit}" : count)</Badge>
</Row>
<ListView Zone="column.Id" AriaLabel="column.Name" Cards DragWholeItem MaxItems="column.Limit" ItemLabel="c => c.Title"
ItemHref="c => CardHref(c)" ItemCurrent="c => c.Id == Opened" EmptyText="No cards" Size="ControlSize.Sm" Context="card">
<ChildContent>
<Stack Gap="2">
<Tag Label="card.Label" Status="StatusOf(card.Label)" Size="ControlSize.Sm" />
<TextBlock Size="TextSize.Sm" Weight="FontWeight.Medium">card.Title</TextBlock>
<Row Gap="2" Align="Align.Center">
<Icon Name="Lucide.Calendar" Size="IconSize.Sm" Color="Color.Muted" />
<TextBlock Size="TextSize.Xs" Color="Color.Muted">card.Due</TextBlock>
<Spacer />
<Avatar Name="card.Owner" Size="AvatarSize.Xs" />
</Row>
</Stack>
</ChildContent>
<ItemMenu>
<MenuItem Icon="Lucide.ExternalLink" Href="CardHref(card)" target="_blank">Open in a new tab</MenuItem>
if (RendererInfo.IsInteractive)
{
<MenuItem Icon="Lucide.UserCheck" Disabled="card.Owner == Me" OnClick="() => card.Owner = Me">Assign to me</MenuItem>
<MenuDivider />
<MenuItem Icon="Lucide.Archive" Accent="Accent.Danger" OnClick="() => Archive(card.Id)">Archive</MenuItem>
}
else
{
<MenuItem Icon="Lucide.UserCheck" Disabled="card.Owner == Me" Submit Name="act" Value="($"assign:{card.Id}")">Assign to me</MenuItem>
<MenuDivider />
<MenuItem Icon="Lucide.Archive" Accent="Accent.Danger" Submit Name="act" Value="($"archive:{card.Id}")">Archive</MenuItem>
}
</ItemMenu>
</ListView>
</Stack>
}
</Grid>
</DropZoneGroup>
</Stack>
</Form>
<AppAside Width="Length.Rem(20)" Breakpoint="Breakpoint.Lg" SmallScreen="AsideSmallScreen.Below" Label="Card" Padding="new() { Base = 4, Md = 5 }">
if (Board.FirstOrDefault(p => p.Item.Id == Opened) is { Item: not null } shown)
{
<Stack Gap="4">
<Stack Gap="2" Align="Align.Start">
<Tag Label="shown.Item.Label" Status="StatusOf(shown.Item.Label)" Size="ControlSize.Sm" />
<Heading Level="2" Size="TextSize.Lg">shown.Item.Title</Heading>
</Stack>
<DescriptionList>
<DescriptionItem Term="Column">Columns.FirstOrDefault(c => c.Id == shown.Zone)?.Name</DescriptionItem>
<DescriptionItem Term="Owner">shown.Item.Owner</DescriptionItem>
<DescriptionItem Term="Due">shown.Item.Due</DescriptionItem>
</DescriptionList>
<TextBlock Size="TextSize.Sm" Color="Color.Muted">shown.Item.Notes</TextBlock>
</Stack>
}
else
{
<TextBlock Size="TextSize.Sm" Color="Color.Muted">Choose a card to see it here.</TextBlock>
}
</AppAside>
</AppMain>
</AppShell>
{
[SupplyParameterFromQuery(Name = "card")] private string? Opened { get; set; }
[SupplyParameterFromForm(FormName = "kanban", Name = "board")]
private ListOrder? Order { get; set; }
// A menu item or button that posted: assign:{id}, archive:{id} or restore.
[SupplyParameterFromForm(FormName = "kanban", Name = "act")]
private string? Act { get; set; }
private const string Me = "Grace Hopper";
// The board as shown and saved: the posted order and columns on a static page (checked, as
// they're user input), else the cards.
private IReadOnlyList<ListPlace<KanbanCard>> Board => Order is null
? _cards.Select(c => new ListPlace<KanbanCard>(c, c.Column, 0)).ToList()
: Checked(Order.Arrange(_cards, c => c.Id, c => c.Column));
private string CardHref(KanbanCard card) => Navigation.GetUriWithQueryParameter("card", card.Id);
// An interactive page: the card takes its new column here, where an app would save it.
private void Moved(ListMoveEventArgs<KanbanCard> move) => move.Item.Column = move.ToZone!;
// A static page: a move posts the board, a menu item its act. An app would save Order.Arrange
// to its database here, checking In progress's limit again, since posted columns are user input.
private void Save()
{
switch (Act?.Split(':', 2))
{
case ["assign", var id] when _cards.FirstOrDefault(c => c.Id == id) is { } card:
card.Owner = Me;
break;
case ["archive", var id]:
Archive(id);
break;
case ["restore"]:
RestoreAll();
break;
}
Store(Board);
}
private void Archive(string id)
{
if (_cards.FirstOrDefault(c => c.Id == id) is { } card)
{
_cards = _cards.Where(c => c != card).ToList();
_archived = [.. _archived, card];
}
}
// Each archived card goes back to its column, or to Backlog when that column is full.
private void RestoreAll()
{
foreach (var card in _archived)
{
if (Columns.First(c => c.Id == card.Column).Limit is { } limit && Board.Count(p => p.Zone == card.Column) >= limit)
{
card.Column = "backlog";
}
_cards = [.. _cards, card];
}
_archived = [];
}
// A posted column no board has goes to Backlog; over a column's limit, the cards that were in
// it already stay, and the ones moved in go back where they were.
private static IReadOnlyList<ListPlace<KanbanCard>> Checked(IReadOnlyList<ListPlace<KanbanCard>> places)
{
var board = places.Select(p => Columns.Any(c => c.Id == p.Zone) ? p : p with { Zone = "backlog" }).ToList();
foreach (var column in Columns.Where(c => c.Limit is not null))
{
var room = column.Limit!.Value - board.Count(p => p.Zone == column.Id && p.Item.Column == column.Id);
foreach (var moved in board.Where(p => p.Zone == column.Id && p.Item.Column != column.Id).Skip(Math.Max(room, 0)).ToList())
{
board[board.IndexOf(moved)] = moved with { Zone = moved.Item.Column };
}
}
return board;
}
private static Status StatusOf(string label) => label switch
{
"Design" => Status.Info,
"Content" => Status.Success,
"Venue" => Status.Warning,
_ => Status.Neutral,
};
}