diff --git a/apps/web/features/issues/components/board-card.tsx b/apps/web/features/issues/components/board-card.tsx index fe5bdc79..b47a3aeb 100644 --- a/apps/web/features/issues/components/board-card.tsx +++ b/apps/web/features/issues/components/board-card.tsx @@ -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 ( -
-
- - {issue.id.slice(0, 8)} -
-

{issue.title}

-
-
- {issue.assignee_type && issue.assignee_id && ( - +
+ {/* Priority + label */} + {showPriority && ( +
+ + + {priorityCfg.label} + +
+ )} + + {/* Title */} +

+ {issue.title} +

+ + {/* Description */} + {showDescription && ( +

+ {issue.description} +

+ )} + + {/* Bottom: avatar + date */} + {showBottom && ( +
+
+ {showAssignee && ( + + )} +
+ {showDueDate && ( + + + {formatDate(issue.due_date!)} + )}
- {issue.due_date && ( - - - {formatDate(issue.due_date)} - - )} -
+ )}
); } diff --git a/apps/web/features/issues/components/board-column.tsx b/apps/web/features/issues/components/board-column.tsx index 3ca99c87..15d91b64 100644 --- a/apps/web/features/issues/components/board-column.tsx +++ b/apps/web/features/issues/components/board-column.tsx @@ -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 = 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 ( -
-
+
+
{/* Left: icon + label + count */}
- {cfg.label} - {issues.length} + {cfg.label} + + {issues.length} +
{/* Right: add + menu */} @@ -73,13 +116,15 @@ export function BoardColumn({
- {issues.map((issue) => ( - - ))} + + {sortedIssues.map((issue) => ( + + ))} + {issues.length === 0 && (

No issues diff --git a/apps/web/features/issues/components/board-view.tsx b/apps/web/features/issues/components/board-view.tsx index fd4ae304..38a1d2fd 100644 --- a/apps/web/features/issues/components/board-view.tsx +++ b/apps/web/features/issues/components/board-view.tsx @@ -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(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(null); @@ -40,6 +78,17 @@ export function BoardView({ }) ); + // Pre-sort issues by position per status for position calculations + const issuesByStatus = useMemo(() => { + const map: Record = {}; + 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} > -

+
{visibleStatuses.map((status) => ( i.status === status)} /> ))} + + {hiddenStatuses.length > 0 && ( + + )}
{activeIssue ? ( -
+
) : null} @@ -101,3 +197,64 @@ export function BoardView({ ); } + +function HiddenColumnsPanel({ + hiddenStatuses, + issues, +}: { + hiddenStatuses: IssueStatus[]; + issues: Issue[]; +}) { + return ( +
+
+ + Hidden columns + +
+
+ {hiddenStatuses.map((status) => { + const cfg = STATUS_CONFIG[status]; + const count = issues.filter((i) => i.status === status).length; + return ( +
+
+ + {cfg.label} +
+
+ {count} + + + + + } + /> + + + useIssueViewStore.getState().showStatus(status) + } + > + + Show column + + + +
+
+ ); + })} +
+
+ ); +} diff --git a/apps/web/features/issues/components/issues-header.tsx b/apps/web/features/issues/components/issues-header.tsx index 82aba1f1..c7fe7cb8 100644 --- a/apps/web/features/issues/components/issues-header.tsx +++ b/apps/web/features/issues/components/issues-header.tsx @@ -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 -) { - 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 (
@@ -51,7 +69,8 @@ export function IssuesHeader() { - {viewMode === "board" ? : } + {viewMode === "board" ? : } + {viewMode === "board" ? "Board" : "List"} } /> @@ -70,79 +89,200 @@ export function IssuesHeader() { - {/* Status filter */} - - + - {formatFilterLabel("Status", statusFilters, STATUS_CONFIG)} - + } /> - - - Status - - useIssueViewStore.setState({ statusFilters: [] }) - } - > - All - - - - - {ALL_STATUSES.map((s) => ( - toggleStatusFilter(s)} - > - - {STATUS_CONFIG[s].label} - - ))} - - - + + {/* Status */} +
+ + Status + +
+ {ALL_STATUSES.map((s) => ( + + ))} +
+
- {/* Priority filter */} - - + + Priority + +
+ {PRIORITY_ORDER.map((p) => ( + + ))} +
+
+ + {/* Reset */} + {hasActiveFilters && ( +
+ +
+ )} + + + + {/* Display settings */} + + - {formatFilterLabel("Priority", priorityFilters, PRIORITY_CONFIG)} - + } /> - - - Priority - - useIssueViewStore.setState({ priorityFilters: [] }) - } - > - All - - - - - {PRIORITY_ORDER.map((p) => ( - togglePriorityFilter(p)} + + {/* Ordering section */} +
+ + Ordering + +
+ + + {sortLabel} + + + } + /> + + {SORT_OPTIONS.map((opt) => ( + setSortBy(opt.value)} + > + {opt.label} + + ))} + + + +
+
+ + {/* Card properties section */} +
+ + Card properties + +
+ {CARD_PROPERTY_OPTIONS.map((opt) => ( + + ))} +
+
+
+
diff --git a/apps/web/features/issues/components/issues-page.tsx b/apps/web/features/issues/components/issues-page.tsx index 5b261b0a..bf975483 100644 --- a/apps/web/features/issues/components/issues-page.tsx +++ b/apps/web/features/issues/components/issues-page.tsx @@ -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() {
-
+
{Array.from({ length: 5 }).map((_, i) => (
@@ -109,7 +124,9 @@ export function IssuesPage() { {viewMode === "board" ? ( ) : ( diff --git a/apps/web/features/issues/stores/view-store.ts b/apps/web/features/issues/stores/view-store.ts index ce63f518..7a914249 100644 --- a/apps/web/features/issues/stores/view-store.ts +++ b/apps/web/features/issues/stores/view-store.ts @@ -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()( @@ -24,6 +55,14 @@ export const useIssueViewStore = create()( 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()( ? 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()( viewMode: state.viewMode, statusFilters: state.statusFilters, priorityFilters: state.priorityFilters, + sortBy: state.sortBy, + sortDirection: state.sortDirection, + cardProperties: state.cardProperties, }), } )