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:
parent
1b505c3a21
commit
7cd9110628
6 changed files with 590 additions and 130 deletions
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
/>
|
||||
) : (
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
}),
|
||||
}
|
||||
)
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue