'use client' import { dropTargetForElements } from '@atlaskit/pragmatic-drag-and-drop/element/adapter' import { memo, useCallback, useEffect, useRef, useState, type ReactNode } from 'react' import { TagIcon } from '../../icons-v2-generated/shopping/tag-icon' import { cn } from '../../../utils/cn' import { attachClosestEdge } from '@atlaskit/pragmatic-drag-and-drop-hitbox/closest-edge' import { autoScrollColumn } from '../../../utils/auto-scroll-ancestors' import { BoardColumnHeader } from './board-column-header' import { useDropAim } from './drop-aim' import { DROP_LINE_ATTRIBUTE, nearestCardIn } from './lane-geometry' import { tintOnDark } from './color-utils' import { TicketCard } from './ticket-card' import { TicketCardSkeleton } from './ticket-card-skeleton' import { TICKET_ID_ATTRIBUTE, useLaneScrollAnchor } from './use-lane-scroll-anchor' import type { BoardColumnDef, BoardTicket } from './types' export interface BoardColumnProps { column: BoardColumnDef collapsed?: boolean /** Takes the id so the board can pass one stable handler to every lane. */ onToggleCollapse: (columnId: string) => void onAddTicket?: (columnId: string) => void onArchive?: (columnId: string) => void getTicketHref?: (ticketId: string) => string renderAssignSlot?: (ticket: BoardTicket) => ReactNode onApprove?: (ticketId: string, requestId?: string) => void | Promise onReject?: (ticketId: string, requestId?: string) => void | Promise onLoadMore?: (columnId: string) => void loadMoreRootMargin?: string joinLeft?: boolean joinRight?: boolean } /** Memoized: `Board` rebuilds only the two columns a drag touches, so the * untouched ones keep their object identity and can skip the render entirely. * Without this, every pointer move that shifts a card re-renders all lanes. */ export const BoardColumn = memo(function BoardColumn({ column, collapsed = false, onToggleCollapse, onAddTicket, onArchive, getTicketHref, renderAssignSlot, onApprove, onReject, onLoadMore, loadMoreRootMargin = '200px 0px', joinLeft = false, joinRight = false, }: BoardColumnProps) { // The drop zone is the WHOLE lane, header included — not just the list. A // target that stops at the list leaves dead strips (the header, the padding) // where the pointer is over a column yet hits nothing. Collapsed lanes stay // out: they render no list to drop into. // // A card sitting inside this lane is a drop target too, and Pragmatic reports // the innermost one first — so the lane only ever answers for the gaps, and // "which card" never has to be guessed from rect overlap. const laneRef = useRef(null) const [isOver, setIsOver] = useState(false) useEffect(() => { const element = laneRef.current if (!element || collapsed || column.dropDisabled) return return dropTargetForElements({ element, canDrop: ({ source }) => { if (source.data.type !== 'ticket') return false const from = source.data.columnId if (from === column.id) return true return !column.allowedFromColumns || column.allowedFromColumns.includes(String(from)) }, // A drop that lands between cards — most of the lane, once a hovered card // has opened the room the preview is drawn in — answers with the nearest // card and the edge of it the pointer is on, exactly as that card would // have. Reporting a bare lane instead sent the ticket to the BOTTOM of the // column, which is what made small moves look like they had not worked and // large ones like the only thing that did. getData: ({ input, element: lane, source }) => { const nearest = nearestCardIn(lane, input.clientY, String(source.data.ticketId)) const ticketId = nearest?.dataset.ticketId // An empty lane, or one whose only card is the one being dragged: the // drop appends, which is the only place it could go. if (!nearest || !ticketId) return { type: 'column', columnId: column.id } return attachClosestEdge( { type: 'ticket', ticketId, columnId: column.id }, { input, element: nearest, allowedEdges: ['top', 'bottom'] }, ) }, onDragEnter: () => setIsOver(true), onDragLeave: () => setIsOver(false), onDrop: () => setIsOver(false), }) }, [column.id, column.dropDisabled, column.allowedFromColumns, collapsed]) // The header's own prop stays zero-arg; the id is bound here, where it is // already known, rather than by the board building one closure per lane. const handleToggleCollapse = useCallback(() => onToggleCollapse(column.id), [onToggleCollapse, column.id]) return (
onAddTicket(column.id) : undefined} onArchive={!collapsed && column.archivable && onArchive ? () => onArchive(column.id) : undefined} /> {!collapsed && ( <>
)}
) }) interface ColumnBodyProps { column: BoardColumnDef getTicketHref?: (ticketId: string) => string renderAssignSlot?: (ticket: BoardTicket) => ReactNode onApprove?: (ticketId: string, requestId?: string) => void | Promise onReject?: (ticketId: string, requestId?: string) => void | Promise onLoadMore?: (columnId: string) => void loadMoreRootMargin: string } function ColumnBody({ column, getTicketHref, renderAssignSlot, onApprove, onReject, onLoadMore, loadMoreRootMargin, }: ColumnBodyProps) { const scrollRef = useRef(null) const sentinelRef = useRef(null) useLaneScrollAnchor(scrollRef, column.tickets) // Dragging near the top or bottom of a long lane scrolls THIS lane, and only // this one — the reach past the lane's edges is vertical, so a pointer below // the board is below exactly one column. The page's own scrollers are // registered once by the board, not once per lane: registering them here gave // `
` one auto-scroller per column, all pulling at the same time. useEffect(() => { const element = scrollRef.current if (!element) return return autoScrollColumn(element) }, []) const loadMoreRef = useRef(onLoadMore) loadMoreRef.current = onLoadMore const columnIdRef = useRef(column.id) columnIdRef.current = column.id useEffect(() => { if (!column.hasMore || column.isLoadingMore) return const sentinel = sentinelRef.current const root = scrollRef.current if (!sentinel || !root || !loadMoreRef.current) return const observer = new IntersectionObserver( entries => { if (entries.some(e => e.isIntersecting)) { loadMoreRef.current?.(columnIdRef.current) } }, { root, rootMargin: loadMoreRootMargin }, ) observer.observe(sentinel) return () => observer.disconnect() }, [column.hasMore, column.isLoadingMore, loadMoreRootMargin]) return ( // `overflow-anchor:none` hands scroll anchoring to `useLaneScrollAnchor` // wholesale. The browser's own anchoring is unusable here — it silently // skips transformed elements, so it works between drags and does nothing // during one — and leaving both on double-corrects every insertion.
{column.isLoading ? ( ) : column.tickets.length === 0 ? ( ) : ( column.tickets.map(t => ( )) )} {column.isLoadingMore && !column.isLoading && } {column.hasMore &&
}
) } function SkeletonStack({ count = 4 }: { count?: number }) { // Position IS the identity here: the rows are interchangeable placeholders in // a fixed-length list, so the index is the correct key. Random keys remounted // every row whenever this re-rendered with a different `count`. return ( <> {Array.from({ length: count }, (_, i) => ( ))} ) } /** Also carries the insertion line when a drop is aimed here, since the lane has * no card to hang it off. */ function EmptyState({ columnId }: { columnId: string }) { const aim = useDropAim() if (aim && aim.columnId === columnId) { return
} return (

No tickets here

Drag a ticket here or change its status to move it to this column

) }