feat(issues): redesign kanban board with drag sorting, filters, and display settings

- Redesign card layout: priority label, description line, subtle shadow, configurable properties
- Wider columns (280px) with rounded background, count badge, and card spacing
- Add SortableContext for within-column drag reordering with fractional position indexing
- Fix collision detection to prefer card targets over column droppables for reliable down-drag
- Add hidden columns panel on right side with show/hide toggle
- Consolidate header into Filter popover (status + priority) and Display popover (ordering + card properties)
- Auto-switch to manual sort when cards are dragged to preserve drag ordering

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Jiayuan 2026-03-28 00:50:29 +08:00
parent 1b505c3a21
commit 7cd9110628
6 changed files with 590 additions and 130 deletions

View file

@ -7,6 +7,8 @@ import type { Issue } from "@/shared/types";
import { CalendarDays } from "lucide-react";
import { ActorAvatar } from "@/components/common/actor-avatar";
import { PriorityIcon } from "./priority-icon";
import { PRIORITY_CONFIG } from "@/features/issues/config";
import { useIssueViewStore, type CardProperties } from "@/features/issues/stores/view-store";
function formatDate(date: string): string {
return new Date(date).toLocaleDateString("en-US", {
@ -15,31 +17,73 @@ function formatDate(date: string): string {
});
}
export function BoardCardContent({ issue }: { issue: Issue }) {
export function BoardCardContent({
issue,
cardProperties,
}: {
issue: Issue;
cardProperties?: CardProperties;
}) {
const storeProperties = useIssueViewStore((s) => s.cardProperties);
const props = cardProperties ?? storeProperties;
const priorityCfg = PRIORITY_CONFIG[issue.priority];
const showPriority = props.priority;
const showDescription = props.description && issue.description;
const showAssignee = props.assignee && issue.assignee_type && issue.assignee_id;
const showDueDate = props.dueDate && issue.due_date;
const showBottom = showAssignee || showDueDate;
return (
<div className="rounded-lg border bg-card p-3">
<div className="flex items-center gap-1.5 text-xs text-muted-foreground">
<PriorityIcon priority={issue.priority} />
<span>{issue.id.slice(0, 8)}</span>
</div>
<p className="mt-1.5 text-sm leading-snug line-clamp-2">{issue.title}</p>
<div className="mt-2.5 flex items-center justify-between">
<div className="flex items-center gap-2">
{issue.assignee_type && issue.assignee_id && (
<ActorAvatar
actorType={issue.assignee_type}
actorId={issue.assignee_id}
size={20}
/>
<div className="rounded-lg border bg-card p-3.5 shadow-[0_1px_2px_0_rgba(0,0,0,0.03)]">
{/* Priority + label */}
{showPriority && (
<div className="flex items-center gap-1.5">
<PriorityIcon priority={issue.priority} />
<span className={`text-xs font-medium ${priorityCfg.color}`}>
{priorityCfg.label}
</span>
</div>
)}
{/* Title */}
<p className={`text-sm font-medium leading-snug line-clamp-2 ${showPriority ? "mt-2" : ""}`}>
{issue.title}
</p>
{/* Description */}
{showDescription && (
<p className="mt-1 text-xs text-muted-foreground line-clamp-1">
{issue.description}
</p>
)}
{/* Bottom: avatar + date */}
{showBottom && (
<div className="mt-3 flex items-center justify-between">
<div className="flex items-center">
{showAssignee && (
<ActorAvatar
actorType={issue.assignee_type!}
actorId={issue.assignee_id!}
size={22}
/>
)}
</div>
{showDueDate && (
<span
className={`flex items-center gap-1 text-xs ${
new Date(issue.due_date!) < new Date()
? "text-destructive"
: "text-muted-foreground"
}`}
>
<CalendarDays className="size-3" />
{formatDate(issue.due_date!)}
</span>
)}
</div>
{issue.due_date && (
<span className={`flex items-center gap-1 text-xs ${new Date(issue.due_date) < new Date() ? "text-destructive" : "text-muted-foreground"}`}>
<CalendarDays className="size-3" />
{formatDate(issue.due_date)}
</span>
)}
</div>
)}
</div>
);
}

View file

@ -1,8 +1,10 @@
"use client";
import { useMemo } from "react";
import { EyeOff, MoreHorizontal, Plus } from "lucide-react";
import { Tooltip, TooltipTrigger, TooltipContent } from "@/components/ui/tooltip";
import { useDroppable } from "@dnd-kit/core";
import { SortableContext, verticalListSortingStrategy } from "@dnd-kit/sortable";
import type { Issue, IssueStatus } from "@/shared/types";
import { Button } from "@/components/ui/button";
import {
@ -11,12 +13,39 @@ import {
DropdownMenuContent,
DropdownMenuItem,
} from "@/components/ui/dropdown-menu";
import { STATUS_CONFIG } from "@/features/issues/config";
import { STATUS_CONFIG, PRIORITY_ORDER } from "@/features/issues/config";
import { useModalStore } from "@/features/modals";
import { useIssueViewStore } from "@/features/issues/stores/view-store";
import { useIssueViewStore, type SortField, type SortDirection } from "@/features/issues/stores/view-store";
import { StatusIcon } from "./status-icon";
import { DraggableBoardCard } from "./board-card";
const PRIORITY_RANK: Record<string, number> = Object.fromEntries(
PRIORITY_ORDER.map((p, i) => [p, i])
);
function sortIssues(issues: Issue[], field: SortField, direction: SortDirection): Issue[] {
const sorted = [...issues].sort((a, b) => {
switch (field) {
case "priority":
return (PRIORITY_RANK[a.priority] ?? 99) - (PRIORITY_RANK[b.priority] ?? 99);
case "due_date": {
if (!a.due_date && !b.due_date) return 0;
if (!a.due_date) return 1;
if (!b.due_date) return -1;
return new Date(a.due_date).getTime() - new Date(b.due_date).getTime();
}
case "created_at":
return new Date(a.created_at).getTime() - new Date(b.created_at).getTime();
case "title":
return a.title.localeCompare(b.title);
case "position":
default:
return a.position - b.position;
}
});
return direction === "desc" ? sorted.reverse() : sorted;
}
export function BoardColumn({
status,
issues,
@ -26,15 +55,29 @@ export function BoardColumn({
}) {
const cfg = STATUS_CONFIG[status];
const { setNodeRef, isOver } = useDroppable({ id: status });
const sortBy = useIssueViewStore((s) => s.sortBy);
const sortDirection = useIssueViewStore((s) => s.sortDirection);
const sortedIssues = useMemo(
() => sortIssues(issues, sortBy, sortDirection),
[issues, sortBy, sortDirection]
);
const sortedIds = useMemo(
() => sortedIssues.map((i) => i.id),
[sortedIssues]
);
return (
<div className="flex w-64 shrink-0 flex-col">
<div className="mb-2 flex items-center justify-between px-1">
<div className="flex w-[280px] shrink-0 flex-col rounded-xl bg-muted/40 p-2">
<div className="mb-2 flex items-center justify-between px-1.5">
{/* Left: icon + label + count */}
<div className="flex items-center gap-2">
<StatusIcon status={status} className="h-3.5 w-3.5" />
<span className="text-xs font-medium">{cfg.label}</span>
<span className="text-xs text-muted-foreground">{issues.length}</span>
<span className="text-sm font-medium">{cfg.label}</span>
<span className="flex h-5 min-w-5 items-center justify-center rounded-full bg-muted px-1.5 text-xs text-muted-foreground">
{issues.length}
</span>
</div>
{/* Right: add + menu */}
@ -73,13 +116,15 @@ export function BoardColumn({
</div>
<div
ref={setNodeRef}
className={`min-h-[200px] flex-1 space-y-1.5 overflow-y-auto rounded-lg p-1 transition-colors ${
isOver ? "bg-accent" : ""
className={`min-h-[200px] flex-1 space-y-2 overflow-y-auto rounded-lg p-1 transition-colors ${
isOver ? "bg-accent/60" : ""
}`}
>
{issues.map((issue) => (
<DraggableBoardCard key={issue.id} issue={issue} />
))}
<SortableContext items={sortedIds} strategy={verticalListSortingStrategy}>
{sortedIssues.map((issue) => (
<DraggableBoardCard key={issue.id} issue={issue} />
))}
</SortableContext>
{issues.length === 0 && (
<p className="py-8 text-center text-xs text-muted-foreground">
No issues

View file

@ -1,6 +1,6 @@
"use client";
import { useState, useCallback } from "react";
import { useState, useCallback, useMemo } from "react";
import {
DndContext,
DragOverlay,
@ -13,24 +13,62 @@ import {
type DragStartEvent,
type DragEndEvent,
} from "@dnd-kit/core";
import { Eye, MoreHorizontal } from "lucide-react";
import type { Issue, IssueStatus } from "@/shared/types";
import { Button } from "@/components/ui/button";
import {
DropdownMenu,
DropdownMenuTrigger,
DropdownMenuContent,
DropdownMenuItem,
} from "@/components/ui/dropdown-menu";
import { ALL_STATUSES, STATUS_CONFIG } from "@/features/issues/config";
import { useIssueViewStore } from "@/features/issues/stores/view-store";
import { StatusIcon } from "./status-icon";
import { BoardColumn } from "./board-column";
import { BoardCardContent } from "./board-card";
const COLUMN_IDS = new Set<string>(ALL_STATUSES);
const kanbanCollision: CollisionDetection = (args) => {
const pointer = pointerWithin(args);
if (pointer.length > 0) return pointer;
if (pointer.length > 0) {
// Prefer card collisions over column collisions so that
// dragging down within a column finds the target card
// instead of the column droppable.
const cards = pointer.filter((c) => !COLUMN_IDS.has(c.id as string));
if (cards.length > 0) return cards;
}
// Fallback: closestCenter finds the nearest card even when
// the pointer is in a gap between cards (common when dragging down).
return closestCenter(args);
};
/** Compute a float position to place an item at `targetIndex` within `siblings`. */
function computePosition(siblings: Issue[], targetIndex: number): number {
if (siblings.length === 0) return 0;
if (targetIndex <= 0) return siblings[0]!.position - 1;
if (targetIndex >= siblings.length)
return siblings[siblings.length - 1]!.position + 1;
return (siblings[targetIndex - 1]!.position + siblings[targetIndex]!.position) / 2;
}
export function BoardView({
issues,
allIssues,
visibleStatuses,
hiddenStatuses,
onMoveIssue,
}: {
issues: Issue[];
allIssues: Issue[];
visibleStatuses: IssueStatus[];
onMoveIssue: (issueId: string, newStatus: IssueStatus) => void;
hiddenStatuses: IssueStatus[];
onMoveIssue: (
issueId: string,
newStatus: IssueStatus,
newPosition?: number
) => void;
}) {
const [activeIssue, setActiveIssue] = useState<Issue | null>(null);
@ -40,6 +78,17 @@ export function BoardView({
})
);
// Pre-sort issues by position per status for position calculations
const issuesByStatus = useMemo(() => {
const map: Record<string, Issue[]> = {};
for (const status of visibleStatuses) {
map[status] = issues
.filter((i) => i.status === status)
.sort((a, b) => a.position - b.position);
}
return map;
}, [issues, visibleStatuses]);
const handleDragStart = useCallback(
(event: DragStartEvent) => {
const issue = issues.find((i) => i.id === event.active.id);
@ -52,26 +101,66 @@ export function BoardView({
(event: DragEndEvent) => {
setActiveIssue(null);
const { active, over } = event;
if (!over) return;
if (!over || active.id === over.id) return;
const issueId = active.id as string;
let targetStatus: IssueStatus | undefined;
const currentIssue = issues.find((i) => i.id === issueId);
if (!currentIssue) return;
// Determine target status
let targetStatus: IssueStatus;
let overIsColumn = false;
if (visibleStatuses.includes(over.id as IssueStatus)) {
targetStatus = over.id as IssueStatus;
overIsColumn = true;
} else {
const targetIssue = issues.find((i) => i.id === over.id);
if (targetIssue) targetStatus = targetIssue.status;
if (!targetIssue) return;
targetStatus = targetIssue.status;
}
if (targetStatus) {
const currentIssue = issues.find((i) => i.id === issueId);
if (currentIssue && currentIssue.status !== targetStatus) {
onMoveIssue(issueId, targetStatus);
// Get sorted siblings in the target column (excluding the dragged item)
const siblings = (issuesByStatus[targetStatus] ?? []).filter(
(i) => i.id !== issueId
);
// Compute new position
let newPosition: number;
if (overIsColumn) {
// Dropped on empty area of column → append to end
newPosition = computePosition(siblings, siblings.length);
} else {
// Dropped on a specific card → insert at that card's index
const overIndex = siblings.findIndex((i) => i.id === over.id);
if (overIndex === -1) {
newPosition = computePosition(siblings, siblings.length);
} else {
const isSameColumn = currentIssue.status === targetStatus;
const overIssuePosition = siblings[overIndex]!.position;
if (isSameColumn && currentIssue.position < overIssuePosition) {
// Moving down → insert after the over card
newPosition = computePosition(siblings, overIndex + 1);
} else {
// Moving up or cross-column → insert before the over card
newPosition = computePosition(siblings, overIndex);
}
}
}
// Skip if nothing changed
if (
currentIssue.status === targetStatus &&
currentIssue.position === newPosition
) {
return;
}
onMoveIssue(issueId, targetStatus, newPosition);
},
[issues, onMoveIssue, visibleStatuses]
[issues, issuesByStatus, onMoveIssue, visibleStatuses]
);
return (
@ -81,7 +170,7 @@ export function BoardView({
onDragStart={handleDragStart}
onDragEnd={handleDragEnd}
>
<div className="flex flex-1 min-h-0 gap-3 overflow-x-auto p-4">
<div className="flex flex-1 min-h-0 gap-4 overflow-x-auto p-4">
{visibleStatuses.map((status) => (
<BoardColumn
key={status}
@ -89,11 +178,18 @@ export function BoardView({
issues={issues.filter((i) => i.status === status)}
/>
))}
{hiddenStatuses.length > 0 && (
<HiddenColumnsPanel
hiddenStatuses={hiddenStatuses}
issues={allIssues}
/>
)}
</div>
<DragOverlay>
{activeIssue ? (
<div className="w-64 rotate-1 cursor-grabbing opacity-95 shadow-md">
<div className="w-[280px] rotate-1 cursor-grabbing opacity-95 shadow-md">
<BoardCardContent issue={activeIssue} />
</div>
) : null}
@ -101,3 +197,64 @@ export function BoardView({
</DndContext>
);
}
function HiddenColumnsPanel({
hiddenStatuses,
issues,
}: {
hiddenStatuses: IssueStatus[];
issues: Issue[];
}) {
return (
<div className="flex w-[240px] shrink-0 flex-col">
<div className="mb-2 flex items-center gap-2 px-1">
<span className="text-sm font-medium text-muted-foreground">
Hidden columns
</span>
</div>
<div className="flex-1 space-y-0.5">
{hiddenStatuses.map((status) => {
const cfg = STATUS_CONFIG[status];
const count = issues.filter((i) => i.status === status).length;
return (
<div
key={status}
className="flex items-center justify-between rounded-lg px-2.5 py-2 hover:bg-muted/50"
>
<div className="flex items-center gap-2">
<StatusIcon status={status} className="h-3.5 w-3.5" />
<span className="text-sm">{cfg.label}</span>
</div>
<div className="flex items-center gap-1.5">
<span className="text-xs text-muted-foreground">{count}</span>
<DropdownMenu>
<DropdownMenuTrigger
render={
<Button
variant="ghost"
size="icon-sm"
className="rounded-full text-muted-foreground"
>
<MoreHorizontal className="size-3.5" />
</Button>
}
/>
<DropdownMenuContent align="end">
<DropdownMenuItem
onClick={() =>
useIssueViewStore.getState().showStatus(status)
}
>
<Eye className="size-3.5" />
Show column
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
</div>
);
})}
</div>
</div>
);
}

View file

@ -1,6 +1,15 @@
"use client";
import { ChevronDown, Columns3, List, Plus } from "lucide-react";
import {
ArrowDown,
ArrowUp,
ChevronDown,
Columns3,
Filter,
List,
Plus,
SlidersHorizontal,
} from "lucide-react";
import { Button } from "@/components/ui/button";
import {
DropdownMenu,
@ -12,6 +21,12 @@ import {
DropdownMenuLabel,
DropdownMenuSeparator,
} from "@/components/ui/dropdown-menu";
import {
Popover,
PopoverTrigger,
PopoverContent,
} from "@/components/ui/popover";
import { Switch } from "@/components/ui/switch";
import { useModalStore } from "@/features/modals";
import {
ALL_STATUSES,
@ -20,28 +35,31 @@ import {
PRIORITY_CONFIG,
} from "@/features/issues/config";
import { StatusIcon, PriorityIcon } from "@/features/issues/components";
import { useIssueViewStore } from "@/features/issues/stores/view-store";
function formatFilterLabel(
prefix: string,
selected: string[],
configMap: Record<string, { label: string }>
) {
if (selected.length === 0) return `${prefix}: All`;
if (selected.length === 1) {
const key = selected[0];
if (key) return `${prefix}: ${configMap[key]?.label ?? key}`;
}
return `${prefix}: ${selected.length} selected`;
}
import {
useIssueViewStore,
SORT_OPTIONS,
CARD_PROPERTY_OPTIONS,
} from "@/features/issues/stores/view-store";
export function IssuesHeader() {
const viewMode = useIssueViewStore((s) => s.viewMode);
const statusFilters = useIssueViewStore((s) => s.statusFilters);
const priorityFilters = useIssueViewStore((s) => s.priorityFilters);
const sortBy = useIssueViewStore((s) => s.sortBy);
const sortDirection = useIssueViewStore((s) => s.sortDirection);
const cardProperties = useIssueViewStore((s) => s.cardProperties);
const setViewMode = useIssueViewStore((s) => s.setViewMode);
const toggleStatusFilter = useIssueViewStore((s) => s.toggleStatusFilter);
const togglePriorityFilter = useIssueViewStore((s) => s.togglePriorityFilter);
const setSortBy = useIssueViewStore((s) => s.setSortBy);
const setSortDirection = useIssueViewStore((s) => s.setSortDirection);
const toggleCardProperty = useIssueViewStore((s) => s.toggleCardProperty);
const clearFilters = useIssueViewStore((s) => s.clearFilters);
const sortLabel =
SORT_OPTIONS.find((o) => o.value === sortBy)?.label ?? "Manual";
const hasActiveFilters =
statusFilters.length > 0 || priorityFilters.length > 0;
return (
<div className="flex h-12 shrink-0 items-center justify-between px-4">
@ -51,7 +69,8 @@ export function IssuesHeader() {
<DropdownMenuTrigger
render={
<Button variant="outline" size="sm">
{viewMode === "board" ? <Columns3 /> : <List />}
{viewMode === "board" ? <Columns3 className="size-3.5" /> : <List className="size-3.5" />}
{viewMode === "board" ? "Board" : "List"}
</Button>
}
/>
@ -70,79 +89,200 @@ export function IssuesHeader() {
</DropdownMenuContent>
</DropdownMenu>
{/* Status filter */}
<DropdownMenu>
<DropdownMenuTrigger
{/* Filter */}
<Popover>
<PopoverTrigger
render={
<Button variant="outline" size="sm" className="whitespace-nowrap text-xs">
{formatFilterLabel("Status", statusFilters, STATUS_CONFIG)}
<ChevronDown className="text-muted-foreground" />
<Button
variant="outline"
size="sm"
className={hasActiveFilters ? "border-primary/50 text-primary" : ""}
>
<Filter className="size-3.5" />
Filter
{hasActiveFilters && (
<span className="flex h-4 min-w-4 items-center justify-center rounded-full bg-primary px-1 text-[10px] font-medium text-primary-foreground">
{statusFilters.length + priorityFilters.length}
</span>
)}
</Button>
}
/>
<DropdownMenuContent align="start" className="w-auto">
<DropdownMenuGroup>
<DropdownMenuLabel>Status</DropdownMenuLabel>
<DropdownMenuItem
onClick={() =>
useIssueViewStore.setState({ statusFilters: [] })
}
>
All
</DropdownMenuItem>
</DropdownMenuGroup>
<DropdownMenuSeparator />
<DropdownMenuGroup>
{ALL_STATUSES.map((s) => (
<DropdownMenuCheckboxItem
key={s}
checked={statusFilters.length === 0 || statusFilters.includes(s)}
onCheckedChange={() => toggleStatusFilter(s)}
>
<StatusIcon status={s} className="h-3.5 w-3.5" />
{STATUS_CONFIG[s].label}
</DropdownMenuCheckboxItem>
))}
</DropdownMenuGroup>
</DropdownMenuContent>
</DropdownMenu>
<PopoverContent align="start" className="w-64 p-0">
{/* Status */}
<div className="border-b px-3 py-2.5">
<span className="text-xs font-medium text-muted-foreground">
Status
</span>
<div className="mt-1.5 space-y-0.5">
{ALL_STATUSES.map((s) => (
<label
key={s}
className="flex cursor-pointer items-center gap-2 rounded-md px-1.5 py-1 hover:bg-accent"
onClick={() => toggleStatusFilter(s)}
>
<div
className={`flex h-4 w-4 items-center justify-center rounded border ${
statusFilters.length === 0 || statusFilters.includes(s)
? "border-primary bg-primary"
: "border-input"
}`}
>
{(statusFilters.length === 0 ||
statusFilters.includes(s)) && (
<svg
viewBox="0 0 12 12"
className="h-3 w-3 text-primary-foreground"
fill="none"
stroke="currentColor"
strokeWidth="2"
>
<path d="M2 6l3 3 5-5" />
</svg>
)}
</div>
<StatusIcon status={s} className="h-3.5 w-3.5" />
<span className="text-sm">{STATUS_CONFIG[s].label}</span>
</label>
))}
</div>
</div>
{/* Priority filter */}
<DropdownMenu>
<DropdownMenuTrigger
{/* Priority */}
<div className="border-b px-3 py-2.5">
<span className="text-xs font-medium text-muted-foreground">
Priority
</span>
<div className="mt-1.5 space-y-0.5">
{PRIORITY_ORDER.map((p) => (
<label
key={p}
className="flex cursor-pointer items-center gap-2 rounded-md px-1.5 py-1 hover:bg-accent"
onClick={() => togglePriorityFilter(p)}
>
<div
className={`flex h-4 w-4 items-center justify-center rounded border ${
priorityFilters.length === 0 ||
priorityFilters.includes(p)
? "border-primary bg-primary"
: "border-input"
}`}
>
{(priorityFilters.length === 0 ||
priorityFilters.includes(p)) && (
<svg
viewBox="0 0 12 12"
className="h-3 w-3 text-primary-foreground"
fill="none"
stroke="currentColor"
strokeWidth="2"
>
<path d="M2 6l3 3 5-5" />
</svg>
)}
</div>
<PriorityIcon priority={p} />
<span className="text-sm">{PRIORITY_CONFIG[p].label}</span>
</label>
))}
</div>
</div>
{/* Reset */}
{hasActiveFilters && (
<div className="px-3 py-2">
<button
className="text-xs text-muted-foreground hover:text-foreground"
onClick={clearFilters}
>
Reset filters
</button>
</div>
)}
</PopoverContent>
</Popover>
{/* Display settings */}
<Popover>
<PopoverTrigger
render={
<Button variant="outline" size="sm" className="whitespace-nowrap text-xs">
{formatFilterLabel("Priority", priorityFilters, PRIORITY_CONFIG)}
<ChevronDown className="text-muted-foreground" />
<Button variant="outline" size="sm">
<SlidersHorizontal className="size-3.5" />
Display
</Button>
}
/>
<DropdownMenuContent align="start" className="w-auto">
<DropdownMenuGroup>
<DropdownMenuLabel>Priority</DropdownMenuLabel>
<DropdownMenuItem
onClick={() =>
useIssueViewStore.setState({ priorityFilters: [] })
}
>
All
</DropdownMenuItem>
</DropdownMenuGroup>
<DropdownMenuSeparator />
<DropdownMenuGroup>
{PRIORITY_ORDER.map((p) => (
<DropdownMenuCheckboxItem
key={p}
checked={priorityFilters.length === 0 || priorityFilters.includes(p)}
onCheckedChange={() => togglePriorityFilter(p)}
<PopoverContent align="start" className="w-64 p-0">
{/* Ordering section */}
<div className="border-b px-3 py-2.5">
<span className="text-xs font-medium text-muted-foreground">
Ordering
</span>
<div className="mt-2 flex items-center gap-1.5">
<DropdownMenu>
<DropdownMenuTrigger
render={
<Button
variant="outline"
size="sm"
className="flex-1 justify-between text-xs"
>
{sortLabel}
<ChevronDown className="size-3 text-muted-foreground" />
</Button>
}
/>
<DropdownMenuContent align="start" className="w-auto">
{SORT_OPTIONS.map((opt) => (
<DropdownMenuItem
key={opt.value}
onClick={() => setSortBy(opt.value)}
>
{opt.label}
</DropdownMenuItem>
))}
</DropdownMenuContent>
</DropdownMenu>
<Button
variant="outline"
size="icon-sm"
onClick={() =>
setSortDirection(sortDirection === "asc" ? "desc" : "asc")
}
title={sortDirection === "asc" ? "Ascending" : "Descending"}
>
<PriorityIcon priority={p} />
{PRIORITY_CONFIG[p].label}
</DropdownMenuCheckboxItem>
))}
</DropdownMenuGroup>
</DropdownMenuContent>
</DropdownMenu>
{sortDirection === "asc" ? (
<ArrowUp className="size-3.5" />
) : (
<ArrowDown className="size-3.5" />
)}
</Button>
</div>
</div>
{/* Card properties section */}
<div className="px-3 py-2.5">
<span className="text-xs font-medium text-muted-foreground">
Card properties
</span>
<div className="mt-2 space-y-2">
{CARD_PROPERTY_OPTIONS.map((opt) => (
<label
key={opt.key}
className="flex cursor-pointer items-center justify-between"
>
<span className="text-sm">{opt.label}</span>
<Switch
size="sm"
checked={cardProperties[opt.key]}
onCheckedChange={() => toggleCardProperty(opt.key)}
/>
</label>
))}
</div>
</div>
</PopoverContent>
</Popover>
</div>
<div className="flex items-center gap-2">

View file

@ -51,11 +51,26 @@ export function IssuesPage() {
return BOARD_STATUSES;
}, [statusFilters]);
const handleMoveIssue = useCallback(
(issueId: string, newStatus: IssueStatus) => {
useIssueStore.getState().updateIssue(issueId, { status: newStatus });
const hiddenStatuses = useMemo(() => {
return BOARD_STATUSES.filter((s) => !visibleStatuses.includes(s));
}, [visibleStatuses]);
api.updateIssue(issueId, { status: newStatus }).catch(() => {
const handleMoveIssue = useCallback(
(issueId: string, newStatus: IssueStatus, newPosition?: number) => {
// Auto-switch to manual sort so drag ordering is preserved
if (useIssueViewStore.getState().sortBy !== "position") {
useIssueViewStore.getState().setSortBy("position");
useIssueViewStore.getState().setSortDirection("asc");
}
const updates: Partial<{ status: IssueStatus; position: number }> = {
status: newStatus,
};
if (newPosition !== undefined) updates.position = newPosition;
useIssueStore.getState().updateIssue(issueId, updates);
api.updateIssue(issueId, updates).catch(() => {
toast.error("Failed to move issue");
api.listIssues({ limit: 200 }).then((res) => {
useIssueStore.getState().setIssues(res.issues);
@ -76,7 +91,7 @@ export function IssuesPage() {
<Skeleton className="h-5 w-24" />
<Skeleton className="h-8 w-24" />
</div>
<div className="flex flex-1 min-h-0 gap-3 overflow-x-auto p-4">
<div className="flex flex-1 min-h-0 gap-4 overflow-x-auto p-4">
{Array.from({ length: 5 }).map((_, i) => (
<div key={i} className="flex min-w-52 flex-1 flex-col gap-2">
<Skeleton className="h-4 w-20" />
@ -109,7 +124,9 @@ export function IssuesPage() {
{viewMode === "board" ? (
<BoardView
issues={issues}
allIssues={allIssues}
visibleStatuses={visibleStatuses}
hiddenStatuses={hiddenStatuses}
onMoveIssue={handleMoveIssue}
/>
) : (

View file

@ -6,16 +6,47 @@ import type { IssueStatus, IssuePriority } from "@/shared/types";
import { ALL_STATUSES, PRIORITY_ORDER } from "@/features/issues/config";
export type ViewMode = "board" | "list";
export type SortField = "position" | "priority" | "due_date" | "created_at" | "title";
export type SortDirection = "asc" | "desc";
export interface CardProperties {
priority: boolean;
description: boolean;
assignee: boolean;
dueDate: boolean;
}
export const SORT_OPTIONS: { value: SortField; label: string }[] = [
{ value: "position", label: "Manual" },
{ value: "priority", label: "Priority" },
{ value: "due_date", label: "Due date" },
{ value: "created_at", label: "Created date" },
{ value: "title", label: "Title" },
];
export const CARD_PROPERTY_OPTIONS: { key: keyof CardProperties; label: string }[] = [
{ key: "priority", label: "Priority" },
{ key: "description", label: "Description" },
{ key: "assignee", label: "Assignee" },
{ key: "dueDate", label: "Due date" },
];
interface IssueViewState {
viewMode: ViewMode;
statusFilters: IssueStatus[];
priorityFilters: IssuePriority[];
sortBy: SortField;
sortDirection: SortDirection;
cardProperties: CardProperties;
setViewMode: (mode: ViewMode) => void;
toggleStatusFilter: (status: IssueStatus) => void;
togglePriorityFilter: (priority: IssuePriority) => void;
hideStatus: (status: IssueStatus) => void;
showStatus: (status: IssueStatus) => void;
clearFilters: () => void;
setSortBy: (field: SortField) => void;
setSortDirection: (dir: SortDirection) => void;
toggleCardProperty: (key: keyof CardProperties) => void;
}
export const useIssueViewStore = create<IssueViewState>()(
@ -24,6 +55,14 @@ export const useIssueViewStore = create<IssueViewState>()(
viewMode: "board",
statusFilters: [],
priorityFilters: [],
sortBy: "position",
sortDirection: "asc",
cardProperties: {
priority: true,
description: true,
assignee: true,
dueDate: true,
},
setViewMode: (mode) => set({ viewMode: mode }),
toggleStatusFilter: (status) =>
@ -52,7 +91,22 @@ export const useIssueViewStore = create<IssueViewState>()(
? ALL_STATUSES.filter((s) => s !== status)
: state.statusFilters.filter((s) => s !== status),
})),
showStatus: (status) =>
set((state) => {
if (state.statusFilters.length === 0) return state;
const next = [...state.statusFilters, status];
return { statusFilters: next.length >= ALL_STATUSES.length ? [] : next };
}),
clearFilters: () => set({ statusFilters: [], priorityFilters: [] }),
setSortBy: (field) => set({ sortBy: field }),
setSortDirection: (dir) => set({ sortDirection: dir }),
toggleCardProperty: (key) =>
set((state) => ({
cardProperties: {
...state.cardProperties,
[key]: !state.cardProperties[key],
},
})),
}),
{
name: "multica_issues_view",
@ -60,6 +114,9 @@ export const useIssueViewStore = create<IssueViewState>()(
viewMode: state.viewMode,
statusFilters: state.statusFilters,
priorityFilters: state.priorityFilters,
sortBy: state.sortBy,
sortDirection: state.sortDirection,
cardProperties: state.cardProperties,
}),
}
)