kaneo (minimalist kanban) fork to experiment adding a tangled integration github.com/usekaneo/kaneo
0
fork

Configure Feed

Select the types of activity you want to include in your feed.

Merge pull request #1062 from ONREZA/feat/sorting-views

feat(web): add sorting controls to board, backlog, and list views

authored by

Andrej and committed by
GitHub
5d237da9 d86c1e9e

+351 -31
+16 -1
apps/api/src/task/controllers/create-task.ts
··· 1 - import { and, eq } from "drizzle-orm"; 1 + import { and, eq, max } from "drizzle-orm"; 2 2 import { HTTPException } from "hono/http-exception"; 3 3 import db from "../../database"; 4 4 import { columnTable, taskTable, userTable } from "../../database/schema"; ··· 36 36 ), 37 37 }); 38 38 39 + const [maxPositionResult] = await db 40 + .select({ maxPosition: max(taskTable.position) }) 41 + .from(taskTable) 42 + .where( 43 + and( 44 + eq(taskTable.projectId, projectId), 45 + column?.id 46 + ? eq(taskTable.columnId, column.id) 47 + : eq(taskTable.status, status || "to-do"), 48 + ), 49 + ); 50 + 51 + const nextPosition = (maxPositionResult?.maxPosition ?? 0) + 1; 52 + 39 53 const [createdTask] = await db 40 54 .insert(taskTable) 41 55 .values({ ··· 48 62 description: description || "", 49 63 priority: priority || "", 50 64 number: nextTaskNumber + 1, 65 + position: nextPosition, 51 66 }) 52 67 .returning(); 53 68
+4 -4
apps/api/src/task/controllers/get-next-task-number.ts
··· 1 - import { count, eq } from "drizzle-orm"; 1 + import { eq, max } from "drizzle-orm"; 2 2 import db from "../../database"; 3 3 import { taskTable } from "../../database/schema"; 4 4 5 5 async function getNextTaskNumber(projectId: string) { 6 - const [task] = await db 7 - .select({ count: count() }) 6 + const [result] = await db 7 + .select({ maxNumber: max(taskTable.number) }) 8 8 .from(taskTable) 9 9 .where(eq(taskTable.projectId, projectId)); 10 10 11 - return task ? task.count : 0; 11 + return result?.maxNumber ?? 0; 12 12 } 13 13 14 14 export default getNextTaskNumber;
+21 -5
apps/web/src/components/backlog-list-view/index.tsx
··· 36 36 37 37 type BacklogListViewProps = { 38 38 project?: ProjectWithTasks; 39 + disableDragDrop?: boolean; 39 40 }; 40 41 41 - function BacklogListView({ project }: BacklogListViewProps) { 42 + function BacklogListView({ 43 + project, 44 + disableDragDrop = false, 45 + }: BacklogListViewProps) { 42 46 const { mutate: updateTask } = useUpdateTask(); 43 47 const { setProject } = useProjectStore(); 44 48 const { ··· 114 118 115 119 const sensors = useSensors( 116 120 useSensor(MouseSensor, { 117 - activationConstraint: { distance: 8 }, 121 + activationConstraint: { distance: disableDragDrop ? 999999 : 8 }, 118 122 }), 119 123 useSensor(TouchSensor, { 120 124 activationConstraint: { 121 - delay: 200, 125 + delay: disableDragDrop ? 999999 : 200, 122 126 tolerance: 8, 123 127 }, 124 128 }), ··· 232 236 finalTasks.forEach((t, index) => { 233 237 updateTask({ 234 238 ...t, 235 - position: index + 1, 239 + position: index, 236 240 }); 237 241 }); 238 242 } else { ··· 253 257 updateTask({ 254 258 ...t, 255 259 status: targetSection, 256 - position: index + 1, 260 + position: index, 261 + }); 262 + }); 263 + 264 + const sourceTasks = 265 + activeTask.status === "planned" 266 + ? draft.plannedTasks || [] 267 + : draft.archivedTasks || []; 268 + 269 + sourceTasks.forEach((t, index) => { 270 + updateTask({ 271 + ...t, 272 + position: index, 257 273 }); 258 274 }); 259 275 }
+8
apps/web/src/components/board/board-toolbar.tsx
··· 1 1 import { Filter, PanelsTopLeft, Rows3, X } from "lucide-react"; 2 2 import type { ReactNode } from "react"; 3 + import SortControl from "@/components/common/sort-control"; 3 4 import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar"; 4 5 import { 5 6 DropdownMenu, ··· 17 18 import type { BoardFilters } from "@/hooks/use-task-filters"; 18 19 import { getColumnIcon } from "@/lib/column"; 19 20 import { getPriorityIcon } from "@/lib/priority"; 21 + import type { SortConfig } from "@/lib/sort-tasks"; 20 22 import type { ProjectWithTasks } from "@/types/project"; 21 23 22 24 type WorkspaceLabel = { ··· 49 51 workspaceLabels: WorkspaceLabel[]; 50 52 viewMode: "board" | "list"; 51 53 setViewMode: (mode: "board" | "list") => void; 54 + sort: SortConfig; 55 + onSortChange: (sort: SortConfig) => void; 52 56 }; 53 57 54 58 function CheckSlot({ checked }: { checked: boolean }) { ··· 131 135 workspaceLabels, 132 136 viewMode, 133 137 setViewMode, 138 + sort, 139 + onSortChange, 134 140 }: BoardToolbarProps) { 135 141 const selectedStatusIds = filters.status ?? []; 136 142 const selectedPriorityIds = filters.priority ?? []; ··· 502 508 )} 503 509 </DropdownMenuContent> 504 510 </DropdownMenu> 511 + 512 + <SortControl sort={sort} onSortChange={onSortChange} /> 505 513 506 514 {selectedStatusIds.length > 0 && ( 507 515 <ActiveFilterChip
+145
apps/web/src/components/common/sort-control.tsx
··· 1 + import { ArrowDownAZ, ArrowUpAZ } from "lucide-react"; 2 + import { 3 + DropdownMenu, 4 + DropdownMenuContent, 5 + DropdownMenuGroup, 6 + DropdownMenuItem, 7 + DropdownMenuLabel, 8 + DropdownMenuSeparator, 9 + DropdownMenuTrigger, 10 + } from "@/components/ui/menu"; 11 + import type { SortConfig, SortDirection, SortField } from "@/lib/sort-tasks"; 12 + 13 + type SortControlProps = { 14 + sort: SortConfig; 15 + onSortChange: (sort: SortConfig) => void; 16 + }; 17 + 18 + const sortFields: { field: SortField; label: string }[] = [ 19 + { field: "position", label: "Manual (position)" }, 20 + { field: "createdAt", label: "Created date" }, 21 + { field: "priority", label: "Priority" }, 22 + { field: "dueDate", label: "Due date" }, 23 + { field: "title", label: "Title" }, 24 + { field: "number", label: "Task number" }, 25 + ]; 26 + 27 + function CheckSlot({ checked }: { checked: boolean }) { 28 + return ( 29 + <span 30 + className={`inline-flex size-4 shrink-0 items-center justify-center rounded-[4px] border ${ 31 + checked 32 + ? "border-primary bg-primary text-primary-foreground" 33 + : "border-input bg-background" 34 + }`} 35 + > 36 + {checked ? "\u2713" : null} 37 + </span> 38 + ); 39 + } 40 + 41 + export default function SortControl({ sort, onSortChange }: SortControlProps) { 42 + const isActive = sort.field !== "position"; 43 + const activeLabel = sortFields.find((f) => f.field === sort.field)?.label; 44 + 45 + const handleFieldChange = (field: SortField) => { 46 + if (field === "position" || field === sort.field) { 47 + onSortChange({ field: "position", direction: "asc" }); 48 + } else { 49 + const defaultDirection: SortDirection = 50 + field === "priority" ? "desc" : "asc"; 51 + onSortChange({ field, direction: defaultDirection }); 52 + } 53 + }; 54 + 55 + const toggleDirection = () => { 56 + onSortChange({ 57 + ...sort, 58 + direction: sort.direction === "asc" ? "desc" : "asc", 59 + }); 60 + }; 61 + 62 + return ( 63 + <div className="inline-flex items-center gap-1"> 64 + <DropdownMenu> 65 + <DropdownMenuTrigger 66 + render={ 67 + <button 68 + type="button" 69 + className={`inline-flex h-7 items-center gap-1.5 rounded-md border px-2.5 text-xs font-medium outline-none ring-0 ${ 70 + isActive 71 + ? "border-primary/30 bg-primary/10 text-primary hover:bg-primary/15" 72 + : "border-border bg-background text-foreground hover:bg-accent/60" 73 + }`} 74 + /> 75 + } 76 + > 77 + {sort.direction === "asc" ? ( 78 + <ArrowUpAZ className="h-3 w-3" /> 79 + ) : ( 80 + <ArrowDownAZ className="h-3 w-3" /> 81 + )} 82 + {isActive ? activeLabel : "Sort"} 83 + </DropdownMenuTrigger> 84 + <DropdownMenuContent className="w-48" align="start"> 85 + <DropdownMenuGroup> 86 + <DropdownMenuLabel className="text-[11px] uppercase tracking-wide"> 87 + Sort By 88 + </DropdownMenuLabel> 89 + </DropdownMenuGroup> 90 + <DropdownMenuSeparator /> 91 + {sortFields.map(({ field, label }) => ( 92 + <DropdownMenuItem 93 + key={field} 94 + onClick={() => handleFieldChange(field)} 95 + className="h-8 rounded-md text-sm" 96 + > 97 + <CheckSlot checked={sort.field === field} /> 98 + {label} 99 + </DropdownMenuItem> 100 + ))} 101 + 102 + {isActive && ( 103 + <> 104 + <DropdownMenuSeparator /> 105 + <DropdownMenuGroup> 106 + <DropdownMenuLabel className="text-[11px] uppercase tracking-wide"> 107 + Direction 108 + </DropdownMenuLabel> 109 + </DropdownMenuGroup> 110 + <DropdownMenuItem 111 + onClick={() => onSortChange({ ...sort, direction: "asc" })} 112 + className="h-8 rounded-md text-sm" 113 + > 114 + <CheckSlot checked={sort.direction === "asc"} /> 115 + Ascending 116 + </DropdownMenuItem> 117 + <DropdownMenuItem 118 + onClick={() => onSortChange({ ...sort, direction: "desc" })} 119 + className="h-8 rounded-md text-sm" 120 + > 121 + <CheckSlot checked={sort.direction === "desc"} /> 122 + Descending 123 + </DropdownMenuItem> 124 + </> 125 + )} 126 + </DropdownMenuContent> 127 + </DropdownMenu> 128 + 129 + {isActive && ( 130 + <button 131 + type="button" 132 + onClick={toggleDirection} 133 + className="inline-flex h-7 w-7 items-center justify-center rounded-md border border-border bg-background text-foreground hover:bg-accent/60" 134 + title={sort.direction === "asc" ? "Ascending" : "Descending"} 135 + > 136 + {sort.direction === "asc" ? ( 137 + <ArrowUpAZ className="h-3 w-3" /> 138 + ) : ( 139 + <ArrowDownAZ className="h-3 w-3" /> 140 + )} 141 + </button> 142 + )} 143 + </div> 144 + ); 145 + }
+13 -8
apps/web/src/components/kanban-board/index.tsx
··· 28 28 29 29 type KanbanBoardProps = { 30 30 project: ProjectWithTasks; 31 + disableDragDrop?: boolean; 31 32 }; 32 33 33 - function KanbanBoard({ project }: KanbanBoardProps) { 34 + function KanbanBoard({ project, disableDragDrop = false }: KanbanBoardProps) { 34 35 const queryClient = useQueryClient(); 35 36 const { setProject } = useProjectStore(); 36 37 const { ··· 90 91 91 92 const sensors = useSensors( 92 93 useSensor(MouseSensor, { 93 - activationConstraint: { distance: 8 }, 94 + activationConstraint: { distance: disableDragDrop ? 999999 : 8 }, 94 95 }), 95 96 useSensor(TouchSensor, { 96 97 activationConstraint: { 97 - delay: 250, 98 + delay: disableDragDrop ? 999999 : 250, 98 99 tolerance: 10, 99 100 }, 100 101 }), ··· 154 155 destinationColumn.tasks.splice(destinationIndex, 0, task); 155 156 156 157 destinationColumn.tasks.forEach((t, index) => { 157 - updateTask({ ...t, position: index + 1 }); 158 + updateTask({ ...t, position: index }); 158 159 }); 159 160 160 161 queryClient.invalidateQueries({ 161 162 queryKey: ["projects", project.workspaceId], 162 163 }); 163 164 } else { 164 - const updatedTask = { ...task, status: destinationColumn.id }; 165 + task.status = destinationColumn.id; 165 166 const destinationIndex = 166 167 overId === destinationColumn.id 167 168 ? destinationColumn.tasks.length 168 - : destinationColumn.tasks.findIndex((t) => t.id === overId); 169 + : destinationColumn.tasks.findIndex((t) => t.id === overId) + 1; 169 170 170 - destinationColumn.tasks.splice(destinationIndex + 1, 0, updatedTask); 171 + destinationColumn.tasks.splice(destinationIndex, 0, task); 171 172 172 173 destinationColumn.tasks.forEach((t, index) => { 173 - updateTask({ ...t, position: index + 1 }); 174 + updateTask({ ...t, status: destinationColumn.id, position: index }); 175 + }); 176 + 177 + sourceColumn.tasks.forEach((t, index) => { 178 + updateTask({ ...t, position: index }); 174 179 }); 175 180 } 176 181 });
+19 -6
apps/web/src/components/list-view/index.tsx
··· 37 37 38 38 type ListViewProps = { 39 39 project: ProjectWithTasks; 40 + disableDragDrop?: boolean; 40 41 }; 41 42 42 - function ListView({ project }: ListViewProps) { 43 + function ListView({ project, disableDragDrop = false }: ListViewProps) { 43 44 const { setProject } = useProjectStore(); 44 45 const { 45 46 setAvailableTasks, ··· 112 113 113 114 const sensors = useSensors( 114 115 useSensor(MouseSensor, { 115 - activationConstraint: { distance: 8 }, 116 + activationConstraint: { distance: disableDragDrop ? 999999 : 8 }, 116 117 }), 117 118 useSensor(TouchSensor, { 118 119 activationConstraint: { 119 - delay: 200, 120 + delay: disableDragDrop ? 999999 : 200, 120 121 tolerance: 8, 121 122 }, 122 123 }), ··· 194 195 updateTask({ 195 196 ...t, 196 197 status: destinationColumn.id, 197 - position: index + 1, 198 + position: index, 198 199 }); 199 200 }); 200 201 } else { 201 202 task.status = destinationColumn.id; 202 - destinationColumn.tasks.push(task); 203 + const destinationIndex = 204 + overId === destinationColumn.id 205 + ? destinationColumn.tasks.length 206 + : destinationColumn.tasks.findIndex((t) => t.id === overId) + 1; 207 + 208 + destinationColumn.tasks.splice(destinationIndex, 0, task); 203 209 204 210 destinationColumn.tasks.forEach((t, index) => { 205 211 updateTask({ 206 212 ...t, 207 213 status: destinationColumn.id, 208 - position: index + 1, 214 + position: index, 215 + }); 216 + }); 217 + 218 + sourceColumn.tasks.forEach((t, index) => { 219 + updateTask({ 220 + ...t, 221 + position: index, 209 222 }); 210 223 }); 211 224 }
+1 -1
apps/web/src/hooks/queries/task/use-get-tasks.ts
··· 5 5 return useQuery({ 6 6 queryKey: ["tasks", projectId], 7 7 queryFn: () => getTasks(projectId), 8 - refetchInterval: 5000, 8 + refetchInterval: 30000, 9 9 enabled: !!projectId, 10 10 }); 11 11 }
+72
apps/web/src/lib/sort-tasks.ts
··· 1 + import type Task from "@/types/task"; 2 + 3 + export type SortField = 4 + | "position" 5 + | "createdAt" 6 + | "priority" 7 + | "dueDate" 8 + | "title" 9 + | "number"; 10 + 11 + export type SortDirection = "asc" | "desc"; 12 + 13 + export type SortConfig = { 14 + field: SortField; 15 + direction: SortDirection; 16 + }; 17 + 18 + const priorityOrder: Record<string, number> = { 19 + urgent: 4, 20 + high: 3, 21 + medium: 2, 22 + low: 1, 23 + }; 24 + 25 + function getPriorityValue(priority: string | null): number { 26 + if (!priority) return 0; 27 + return priorityOrder[priority] ?? 0; 28 + } 29 + 30 + export function sortTasks(tasks: Task[], config: SortConfig): Task[] { 31 + if (config.field === "position") { 32 + return tasks; 33 + } 34 + 35 + const sorted = [...tasks].sort((a, b) => { 36 + let comparison = 0; 37 + 38 + switch (config.field) { 39 + case "priority": { 40 + comparison = 41 + getPriorityValue(a.priority) - getPriorityValue(b.priority); 42 + break; 43 + } 44 + case "dueDate": { 45 + const aDate = a.dueDate ? new Date(a.dueDate).getTime() : 0; 46 + const bDate = b.dueDate ? new Date(b.dueDate).getTime() : 0; 47 + if (!a.dueDate && !b.dueDate) comparison = 0; 48 + else if (!a.dueDate) comparison = 1; 49 + else if (!b.dueDate) comparison = -1; 50 + else comparison = aDate - bDate; 51 + break; 52 + } 53 + case "createdAt": { 54 + comparison = 55 + new Date(a.createdAt).getTime() - new Date(b.createdAt).getTime(); 56 + break; 57 + } 58 + case "title": { 59 + comparison = a.title.localeCompare(b.title); 60 + break; 61 + } 62 + case "number": { 63 + comparison = (a.number ?? 0) - (b.number ?? 0); 64 + break; 65 + } 66 + } 67 + 68 + return config.direction === "asc" ? comparison : -comparison; 69 + }); 70 + 71 + return sorted; 72 + }
+23 -2
apps/web/src/routes/_layout/_authenticated/dashboard/workspace/$workspaceId/project/$projectId/backlog.tsx
··· 5 5 import { useCallback, useEffect, useMemo, useState } from "react"; 6 6 import BacklogListView from "@/components/backlog-list-view"; 7 7 import ProjectLayout from "@/components/common/project-layout"; 8 + import SortControl from "@/components/common/sort-control"; 8 9 import PageTitle from "@/components/page-title"; 9 10 import CreateTaskModal from "@/components/shared/modals/create-task-modal"; 10 11 import TaskDetailsSheet from "@/components/task/task-details-sheet"; ··· 28 29 import { useGetActiveWorkspaceUsers } from "@/hooks/queries/workspace-users/use-get-active-workspace-users"; 29 30 import { useRegisterShortcuts } from "@/hooks/use-keyboard-shortcuts"; 30 31 import { getPriorityIcon } from "@/lib/priority"; 32 + import type { SortConfig } from "@/lib/sort-tasks"; 33 + import { sortTasks } from "@/lib/sort-tasks"; 31 34 import { toast } from "@/lib/toast"; 32 35 import useProjectStore from "@/store/project"; 33 36 import { useUserPreferencesStore } from "@/store/user-preferences"; ··· 54 57 const { project, setProject } = useProjectStore(); 55 58 const [isTaskModalOpen, setIsTaskModalOpen] = useState(false); 56 59 const { mutate: updateTask } = useUpdateTask(); 60 + const [sort, setSort] = useState<SortConfig>({ 61 + field: "position", 62 + direction: "asc", 63 + }); 57 64 58 65 const { data: users } = useGetActiveWorkspaceUsers(workspaceId); 59 66 const { data: workspaceLabels = [] } = useGetLabelsByWorkspace(workspaceId); ··· 278 285 } 279 286 }; 280 287 288 + const sortedProject = useMemo(() => { 289 + if (!filteredProject || sort.field === "position") return filteredProject; 290 + return { 291 + ...filteredProject, 292 + plannedTasks: sortTasks(filteredProject.plannedTasks || [], sort), 293 + archivedTasks: sortTasks(filteredProject.archivedTasks || [], sort), 294 + }; 295 + }, [filteredProject, sort]); 296 + 281 297 const handleMoveAllPlannedToTodo = () => { 282 298 if (!project) return; 283 299 ··· 464 480 </Button> 465 481 ))} 466 482 483 + <SortControl sort={sort} onSortChange={setSort} /> 484 + 467 485 <DropdownMenu> 468 486 <DropdownMenuTrigger 469 487 render={ ··· 615 633 </div> 616 634 617 635 <div className="flex-1 overflow-hidden bg-card h-full"> 618 - {filteredProject ? ( 619 - <BacklogListView project={filteredProject} /> 636 + {sortedProject ? ( 637 + <BacklogListView 638 + project={sortedProject} 639 + disableDragDrop={sort.field !== "position"} 640 + /> 620 641 ) : ( 621 642 <div className="flex h-full items-center justify-center"> 622 643 <div className="text-center space-y-4">
+29 -4
apps/web/src/routes/_layout/_authenticated/dashboard/workspace/$workspaceId/project/$projectId/board.tsx
··· 1 1 import { createFileRoute, useNavigate } from "@tanstack/react-router"; 2 2 import { Search } from "lucide-react"; 3 - import { useCallback, useEffect, useState } from "react"; 3 + import { useCallback, useEffect, useMemo, useState } from "react"; 4 4 import BoardToolbar from "@/components/board/board-toolbar"; 5 5 import ProjectLayout from "@/components/common/project-layout"; 6 6 import KanbanBoard from "@/components/kanban-board"; ··· 15 15 import { useGetActiveWorkspaceUsers } from "@/hooks/queries/workspace-users/use-get-active-workspace-users"; 16 16 import { useRegisterShortcuts } from "@/hooks/use-keyboard-shortcuts"; 17 17 import { useTaskFiltersWithLabelsSupport } from "@/hooks/use-task-filters-with-labels-support"; 18 + import type { SortConfig } from "@/lib/sort-tasks"; 19 + import { sortTasks } from "@/lib/sort-tasks"; 18 20 import useProjectStore from "@/store/project"; 19 21 import { useUserPreferencesStore } from "@/store/user-preferences"; 20 22 ··· 44 46 const [isBoardSearchVisible, setIsBoardSearchVisible] = useState(false); 45 47 const [boardSearchInput, setBoardSearchInput] = 46 48 useState<HTMLInputElement | null>(null); 49 + const [sort, setSort] = useState<SortConfig>({ 50 + field: "position", 51 + direction: "asc", 52 + }); 47 53 48 54 const { data: users } = useGetActiveWorkspaceUsers(workspaceId); 49 55 const { data: workspaceLabels = [] } = useGetLabelsByWorkspace(workspaceId); ··· 115 121 clearFilters, 116 122 } = useTaskFiltersWithLabelsSupport(project, projectId, boardSearchQuery); 117 123 124 + const sortedProject = useMemo(() => { 125 + if (!filteredProject || sort.field === "position") return filteredProject; 126 + return { 127 + ...filteredProject, 128 + columns: filteredProject.columns.map((column) => ({ 129 + ...column, 130 + tasks: sortTasks(column.tasks, sort), 131 + })), 132 + }; 133 + }, [filteredProject, sort]); 134 + 118 135 const boardHeaderSearch = isBoardSearchMounted ? ( 119 136 <div 120 137 className={`relative w-[240px] origin-top transition-all duration-180 ease-out ${ ··· 167 184 workspaceLabels={workspaceLabels} 168 185 viewMode={viewMode} 169 186 setViewMode={setViewMode} 187 + sort={sort} 188 + onSortChange={setSort} 170 189 /> 171 190 172 191 <div className="flex h-full flex-1 overflow-hidden bg-background"> 173 - {filteredProject ? ( 192 + {sortedProject ? ( 174 193 viewMode === "board" ? ( 175 - <KanbanBoard project={filteredProject} /> 194 + <KanbanBoard 195 + project={sortedProject} 196 + disableDragDrop={sort.field !== "position"} 197 + /> 176 198 ) : ( 177 - <ListView project={filteredProject} /> 199 + <ListView 200 + project={sortedProject} 201 + disableDragDrop={sort.field !== "position"} 202 + /> 178 203 ) 179 204 ) : ( 180 205 <div className="flex h-full items-center justify-center">