Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
171 changes: 112 additions & 59 deletions src/components/BoardColumn.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
import { useState } from 'react'
import AddIcon from '@mui/icons-material/Add'
import DeleteOutlineIcon from '@mui/icons-material/DeleteOutlined'
import DragIndicatorIcon from '@mui/icons-material/DragIndicator'
import Box from '@mui/material/Box'
import Button from '@mui/material/Button'
import Chip from '@mui/material/Chip'
import IconButton from '@mui/material/IconButton'
Expand All @@ -9,6 +11,7 @@ import Stack from '@mui/material/Stack'
import Tooltip from '@mui/material/Tooltip'
import Typography from '@mui/material/Typography'
import { useBoardContext } from '../context/boardContext'
import { useColumnReorder } from '../hooks/useColumnReorder'
import { useTaskDropTarget } from '../hooks/useTaskDropTarget'
import { CREATE_STATUS, FALLBACK_STATUS, isCoreColumn, visibleTasks } from '../lib/board'
import { ConfirmDialog } from './ConfirmDialog'
Expand All @@ -19,9 +22,25 @@ import type { Column } from '../types'
export function BoardColumn({ column }: { column: Column }) {
const { board, createTask, dispatch } = useBoardContext()
const { isOver, dropProps } = useTaskDropTarget(column.status)
const { dragging, isOver: reordering, columnProps, handleProps } = useColumnReorder(column)
const [confirmingDelete, setConfirmingDelete] = useState(false)
const tasks = visibleTasks(board.tasks, column.status)

const position = board.columns.findIndex((candidate) => candidate.id === column.id)

// Keyboard reordering, since a drag reaches neither a keyboard nor a touch
// screen. Swapping with a neighbour is the same move as dropping onto it.
const shiftBy = (offset: number) => {
const target = board.columns[position + offset]
if (target) dispatch({ type: 'move_column', id: column.id, targetId: target.id })
}

const handleGripKeyDown = (event: React.KeyboardEvent) => {
if (event.key !== 'ArrowLeft' && event.key !== 'ArrowRight') return
event.preventDefault()
shiftBy(event.key === 'ArrowLeft' ? -1 : 1)
}

// Named rather than hardcoded, so the confirmation cannot promise the wrong
// destination if FALLBACK_STATUS ever changes.
const fallbackLabel =
Expand All @@ -36,77 +55,111 @@ export function BoardColumn({ column }: { column: Column }) {
)

return (
<Paper
variant="outlined"
{...dropProps}
// The drag wrapper stays outside the task drop area, so the two drop
// targets nest instead of competing for the same handlers.
<Box
{...columnProps}
sx={{
flex: '1 0 280px',
maxWidth: 400,
display: 'flex',
flexDirection: 'column',
bgcolor: isOver ? 'action.selected' : 'action.hover',
borderColor: isOver ? 'primary.main' : 'divider',
borderStyle: isOver ? 'dashed' : 'solid',
p: 1.5,
transition: 'background-color 120ms, border-color 120ms',
borderRadius: 1,
opacity: dragging ? 0.4 : 1,
outline: '2px solid',
outlineColor: reordering ? 'primary.main' : 'transparent',
outlineOffset: 3,
transition: 'outline-color 120ms, opacity 120ms',
}}
>
<Stack
direction="row"
sx={{ alignItems: 'center', justifyContent: 'space-between', px: 0.5, pb: 1.5 }}
<Paper
variant="outlined"
{...dropProps}
sx={{
display: 'flex',
flexDirection: 'column',
bgcolor: isOver ? 'action.selected' : 'action.hover',
borderColor: isOver ? 'primary.main' : 'divider',
borderStyle: isOver ? 'dashed' : 'solid',
p: 1.5,
transition: 'background-color 120ms, border-color 120ms',
}}
>
<Stack direction="row" spacing={1} sx={{ alignItems: 'center' }}>
<Typography variant="subtitle2" sx={{ fontWeight: 700, letterSpacing: 0.3 }}>
{column.label}
</Typography>
<Chip size="small" label={tasks.length} />
</Stack>
<Stack
direction="row"
sx={{ alignItems: 'center', justifyContent: 'space-between', px: 0.5, pb: 1.5 }}
>
<Stack direction="row" spacing={0.5} sx={{ alignItems: 'center', minWidth: 0 }}>
{/* The only place a column drag can start, and the keyboard route
to the same move. */}
<Tooltip title="Drag to reorder, or use the arrow keys">
<IconButton
size="small"
// The position is part of the name so an arrow-key move is
// announced on the grip that still holds focus.
aria-label={`Reorder ${column.label} column, position ${position + 1} of ${board.columns.length}`}
{...handleProps}
onKeyDown={handleGripKeyDown}
sx={{
cursor: 'grab',
color: 'text.disabled',
'&:hover': { color: 'text.secondary' },
'&:active': { cursor: 'grabbing' },
}}
>
<DragIndicatorIcon fontSize="small" />
</IconButton>
</Tooltip>
<Typography variant="subtitle2" noWrap sx={{ fontWeight: 700, letterSpacing: 0.3 }}>
{column.label}
</Typography>
<Chip size="small" label={tasks.length} sx={{ ml: 0.5 }} />
</Stack>

{addButton}
{addButton}

{/* Only user-added columns can be deleted; the three core statuses are fixed. */}
{!isCoreColumn(column) && (
<Tooltip title="Delete column">
<IconButton
size="small"
aria-label={`Delete ${column.label} column`}
onClick={() => setConfirmingDelete(true)}
sx={{ '&:hover': { color: 'error.main' } }}
>
<DeleteOutlineIcon fontSize="small" />
</IconButton>
</Tooltip>
{/* Only user-added columns can be deleted; the three core statuses are fixed. */}
{!isCoreColumn(column) && (
<Tooltip title="Delete column">
<IconButton
size="small"
aria-label={`Delete ${column.label} column`}
onClick={() => setConfirmingDelete(true)}
sx={{ '&:hover': { color: 'error.main' } }}
>
<DeleteOutlineIcon fontSize="small" />
</IconButton>
</Tooltip>
)}
</Stack>

{tasks.length === 0 ? (
<EmptyState
title={isOver ? 'Drop to move here' : 'Nothing here'}
description={
canAdd ? 'Add a task, or drag one here.' : 'Drag a task here to change its status.'
}
/>
) : (
<Stack spacing={1.5}>
{tasks.map((task) => (
<TaskCard key={task.id} task={task} />
))}
</Stack>
)}
</Stack>

{tasks.length === 0 ? (
<EmptyState
title={isOver ? 'Drop to move here' : 'Nothing here'}
<ConfirmDialog
open={confirmingDelete}
title="Delete this column?"
description={
canAdd ? 'Add a task, or drag one here.' : 'Drag a task here to change its status.'
tasks.length === 0
? `"${column.label}" will be removed from the board.`
: `"${column.label}" will be removed, and its ${tasks.length} ${
tasks.length === 1 ? 'task moves' : 'tasks move'
} back to ${fallbackLabel}.`
}
onConfirm={() => dispatch({ type: 'delete_column', id: column.id })}
onClose={() => setConfirmingDelete(false)}
/>
) : (
<Stack spacing={1.5}>
{tasks.map((task) => (
<TaskCard key={task.id} task={task} />
))}
</Stack>
)}

<ConfirmDialog
open={confirmingDelete}
title="Delete this column?"
description={
tasks.length === 0
? `"${column.label}" will be removed from the board.`
: `"${column.label}" will be removed, and its ${tasks.length} ${
tasks.length === 1 ? 'task moves' : 'tasks move'
} back to ${fallbackLabel}.`
}
onConfirm={() => dispatch({ type: 'delete_column', id: column.id })}
onClose={() => setConfirmingDelete(false)}
/>
</Paper>
</Paper>
</Box>
)
}
77 changes: 77 additions & 0 deletions src/hooks/useColumnReorder.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,77 @@
import { useRef, useState } from 'react'
import { useBoardContext } from '../context/boardContext'
import { COLUMN_DRAG_MIME } from '../lib/dnd'
import type { Column } from '../types'

/**
* Reordering by drag makes every column both a source and a target, so one hook
* owns both halves rather than splitting them the way the task hooks do.
*
* Spread `columnProps` on the column root and `handleProps` on its grip. The
* root sits outside the task drop area: a card dragged over a column is ignored
* here and handled there, and neither payload can trigger the other's move.
*
* The grip is the draggable element, not the root. `useTaskDrag` instead arms a
* draggable root from its handle, which is fine for a card but not here: the
* root is an ancestor of every card, so an armed root would catch a card's own
* dragstart as it bubbles and staple a column payload onto it. Dragging from
* the grip means that event never reaches this hook and there is no latch to
* leave set.
*/
export function useColumnReorder(column: Column) {
const { dispatch } = useBoardContext()
const rootRef = useRef<HTMLDivElement>(null)
const [dragging, setDragging] = useState(false)
const [isOver, setIsOver] = useState(false)

const columnProps = {
ref: rootRef,
onDragOver: (event: React.DragEvent) => {
// Only `types` is readable mid-drag. Anything else — a card, a file — is
// left alone to bubble down to whichever target does want it.
if (!event.dataTransfer.types.includes(COLUMN_DRAG_MIME)) return
event.preventDefault()
event.dataTransfer.dropEffect = 'move'
setIsOver(true)
},
onDragLeave: (event: React.DragEvent) => {
// dragleave bubbles, so crossing between a column's own children fires it
// too. Leaving for somewhere still inside this column is not leaving.
if (event.currentTarget.contains(event.relatedTarget as Node | null)) return
setIsOver(false)
},
onDrop: (event: React.DragEvent) => {
// Empty for a card drop, which the task target has already handled.
const id = event.dataTransfer.getData(COLUMN_DRAG_MIME)
if (!id) return
event.preventDefault()
setIsOver(false)
dispatch({ type: 'move_column', id, targetId: column.id })
},
}

const handleProps = {
draggable: true,
onDragStart: (event: React.DragEvent) => {
event.dataTransfer.setData(COLUMN_DRAG_MIME, column.id)
event.dataTransfer.effectAllowed = 'move'
// Drag the column, not the grip icon that started it, held at the point
// it was grabbed so the ghost stays under the cursor.
const root = rootRef.current
if (root) {
const bounds = root.getBoundingClientRect()
event.dataTransfer.setDragImage(
root,
event.clientX - bounds.left,
event.clientY - bounds.top,
)
}
setDragging(true)
},
onDragEnd: () => setDragging(false),
}

// The dragged column sits under the cursor the whole way, and dropping it on
// itself does nothing — highlighting it would promise a move that never lands.
return { dragging, isOver: isOver && !dragging, columnProps, handleProps }
}
39 changes: 38 additions & 1 deletion src/lib/board.test.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,13 @@
import { describe, expect, it } from 'vitest'
import type { BoardState, Task } from '../types'
import { boardReducer, createEmptyBoard, displayTitle, searchTasks, UNTITLED_LABEL } from './board'
import {
boardReducer,
createEmptyBoard,
displayTitle,
searchTasks,
UNTITLED_LABEL,
visibleTasks,
} from './board'

function boardWith(tasks: Task[]): BoardState {
return { ...createEmptyBoard(), tasks }
Expand Down Expand Up @@ -57,6 +64,36 @@ describe('boardReducer', () => {

expect(boardReducer(next, { type: 'delete_column', id: next.columns[0].id })).toBe(next)
})

it('reorders any column, core ones included, and ignores a move that lands nowhere', () => {
const state = createEmptyBoard()
const [todo, inProgress, done] = state.columns

// Dragged onto Done, Todo takes its place and the others close up.
const next = boardReducer(state, { type: 'move_column', id: todo.id, targetId: done.id })
expect(next.columns.map((column) => column.status)).toEqual(['in_progress', 'done', 'todo'])

// Same object back, so a drop on itself or on nothing costs no re-render.
expect(boardReducer(next, { type: 'move_column', id: todo.id, targetId: todo.id })).toBe(next)
expect(boardReducer(next, { type: 'move_column', id: todo.id, targetId: 'ghost' })).toBe(next)
expect(boardReducer(next, { type: 'move_column', id: 'ghost', targetId: inProgress.id })).toBe(
next,
)
})

it('leaves every task untouched when columns move, so search and lists are unaffected', () => {
// Order is layout. Anything reading tasks joins on `status`, which a move
// never rewrites — a reordered board answers exactly as it did before.
const tasks = [task({ id: 'a' }), task({ id: 'b', title: 'Ship it', status: 'done' })]
const state = boardWith(tasks)
const [todo, , done] = state.columns

const next = boardReducer(state, { type: 'move_column', id: done.id, targetId: todo.id })

expect(next.tasks).toBe(state.tasks)
expect(searchTasks(next.tasks, 'ship').map((t) => t.id)).toEqual(['b'])
expect(visibleTasks(next.tasks, 'todo').map((t) => t.id)).toEqual(['a'])
})
})

describe('searchTasks', () => {
Expand Down
32 changes: 32 additions & 0 deletions src/lib/board.ts
Original file line number Diff line number Diff line change
Expand Up @@ -128,6 +128,27 @@ export function createColumn(label: string): Column {
}
}

/**
* Reorders columns by dropping one onto another: the dragged column takes the
* target's place and the rest close up around it.
*
* Column order is presentation only. Tasks join their column on `status`, never
* on position, so nothing outside the board's layout reads this order.
*
* Returns the original array for a move that changes nothing, which is what
* lets the reducer hand back the same state and skip the re-render.
*/
export function moveColumn(columns: Column[], id: string, targetId: string): Column[] {
const from = columns.findIndex((column) => column.id === id)
const to = columns.findIndex((column) => column.id === targetId)
if (from === -1 || to === -1 || from === to) return columns

const next = [...columns]
const [moved] = next.splice(from, 1)
next.splice(to, 0, moved)
return next
}

const newestFirst = (a: Task, b: Task) => b.created_at.localeCompare(a.created_at)

/** Tasks in one column, newest first. */
Expand Down Expand Up @@ -167,6 +188,8 @@ export type BoardAction =
| { type: 'delete_task'; id: string }
| { type: 'add_column'; label: string }
| { type: 'delete_column'; id: string }
/** Reorder: `id` takes `targetId`'s place. Layout only — no task is touched. */
| { type: 'move_column'; id: string; targetId: string }

const hasStatus = (state: BoardState, status: Status) =>
state.columns.some((column) => column.status === status)
Expand Down Expand Up @@ -243,5 +266,14 @@ export function boardReducer(state: BoardState, action: BoardAction): BoardState
),
}
}

/**
* Every column moves, core ones included: deletion is restricted because it
* would strand tasks, but position carries no meaning to strand.
*/
case 'move_column': {
const columns = moveColumn(state.columns, action.id, action.targetId)
return columns === state.columns ? state : { ...state, columns }
}
}
}
Loading