'use client' import { combine } from '@atlaskit/pragmatic-drag-and-drop/combine' import { draggable, dropTargetForElements } from '@atlaskit/pragmatic-drag-and-drop/element/adapter' import { preserveOffsetOnSource } from '@atlaskit/pragmatic-drag-and-drop/element/preserve-offset-on-source' import { setCustomNativeDragPreview } from '@atlaskit/pragmatic-drag-and-drop/element/set-custom-native-drag-preview' import { attachClosestEdge } from '@atlaskit/pragmatic-drag-and-drop-hitbox/closest-edge' import Link from '../../../embed-shims/next-link' import { memo, useEffect, useMemo, useRef, useState, type CSSProperties, type KeyboardEvent, type MouseEvent, type ReactNode, } from 'react' import { ClockIcon, DotsLoaderIcon, LaptopIcon, Flag02Icon, MessagesIcon, UserCheckIcon, } from '../../icons-v2-generated' import { DeletedUserAvatar } from '../../ui/deleted-user-avatar' import { SquareAvatar } from '../../ui/square-avatar' import { Tag } from '../../ui/tag' import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '../../ui/tooltip' import { cn } from '../../../utils/cn' import { holdMoveDragEffect } from '../../../utils/drag-effect' import { getReadableTextColor } from '../../../utils/ods-color-utils' import { formatTicketRelativeTime, formatTicketFullTimestamp } from '../../../utils/date-utils' import { BoardTicketApproval } from './board-ticket-approval' import { useBoardLift, useDropAim } from './drop-aim' import { useIsLanding } from './pending-move' import { DROP_LINE_ATTRIBUTE } from './lane-geometry' import { TICKET_ID_ATTRIBUTE } from './use-lane-scroll-anchor' import type { BoardPriority, BoardTicket, BoardTicketActivityKind } from './types' const PRIORITY_COLOR_CLASS: Record = { low: 'text-ods-text-secondary', medium: 'text-ods-info', high: 'text-ods-warning', urgent: 'text-ods-error', } /** How faded the card is where it would land. Matches what dnd-kit left behind. */ const DRAGGED_CARD_OPACITY = 0.4 /** How solid the card under the pointer is. Jira's look: carried, not lifted off * the page entirely. */ export const DRAG_PREVIEW_OPACITY = 0.9 const MAX_VISIBLE_TAGS = 2 const MAX_VISIBLE_ASSIGNEES = 3 const ACTIVITY_DEFAULT_LABEL: Record = { 'ai-working': 'AI assistant is working', 'user-typing': 'User typing', 'waiting-external': 'Waiting for client response', stale: 'No activity', } /** Shared card shell (border / padding / bg). Same footprint for the draggable * board card and the static {@link TicketCardView}. */ const TICKET_CARD_SHELL = 'relative flex flex-col gap-[var(--spacing-system-sf)] rounded-md border border-ods-border bg-ods-bg p-[var(--spacing-system-sf)] select-none text-left' // ============================================================================= // Presentational body — the ONE ticket-card design, no drag/link/board context. // Reused by the draggable `TicketCard` AND the static `TicketCardView`. // ============================================================================= export interface TicketCardBodyProps { ticket: BoardTicket columnColor?: string renderAssignSlot?: (ticket: BoardTicket) => ReactNode /** Approval callbacks receive the request id directly (the draggable * `TicketCard` adapts its own `(ticketId, requestId)` signature onto these). */ onApprove?: (requestId?: string) => void | Promise onReject?: (requestId?: string) => void | Promise } /** The card's inner content: title + device/org, priority + assignees, tags, * timestamp, "New Message", and the approval row. Pure — driven only by props. */ export function TicketCardBody({ ticket, columnColor, renderAssignSlot, onApprove, onReject }: TicketCardBodyProps) { const showNewMessage = !!ticket.hasNewMessage && !!columnColor const newMessageTextColor = columnColor ? getReadableTextColor(columnColor) : undefined const showDeviceRow = !!(ticket.deviceHostnames?.length || ticket.organizationName) const deviceText = [ticket.deviceHostnames?.join(', '), ticket.organizationName].filter(Boolean).join(', ') const hasRightSection = !!(ticket.priority || ticket.assignees?.length || renderAssignSlot) const rightSection = hasRightSection ? (
{ticket.priority && ( )} {renderAssignSlot ? ( renderAssignSlot(ticket) ) : ticket.assignees?.length ? (
{ticket.assignees.slice(0, MAX_VISIBLE_ASSIGNEES).map(a => a.deleted ? ( ) : ( ), )} {ticket.assignees.length > MAX_VISIBLE_ASSIGNEES && (
+{ticket.assignees.length - MAX_VISIBLE_ASSIGNEES}
)}
) : null}
) : null const timestampLabel = ticket.createdAt ? formatTicketRelativeTime(ticket.createdAt) : null const tooltipLabel = ticket.createdAt ? formatTicketFullTimestamp(ticket.createdAt) : null return ( <>

{ticket.title}

{showDeviceRow && (
{deviceText}
)}
{rightSection}
{ticket.tags?.length ? : null} {timestampLabel && tooltipLabel && (

{timestampLabel}

{tooltipLabel}
)} {ticket.escalatedByUser && (
Escalated by User
)} {ticket.activity && (
{ticket.activity.kind === 'stale' ? ( ) : ( )} {ticket.activity.label ?? ACTIVITY_DEFAULT_LABEL[ticket.activity.kind]}
)} {showNewMessage && ( } className="w-fit shrink-0" style={{ backgroundColor: columnColor, color: newMessageTextColor }} /> )} {ticket.pendingApproval && ( )} ) } // ============================================================================= // Static card — the real board card design, rendered from props with NO // drag-and-drop / board context (for embeds, marketing heroes, previews). // ============================================================================= export interface TicketCardViewProps extends TicketCardBodyProps { className?: string /** Merged over the shell's own — the board uses it to fade the copy it leaves * behind and to place the one that follows the pointer. */ style?: CSSProperties } /** * The exact board `TicketCard` visual, standalone — same shell + `TicketCardBody` * as the draggable card, but registering no drag behaviour, so it renders * anywhere from static props. Use this outside a ``. */ export function TicketCardView({ className, style, ...bodyProps }: TicketCardViewProps) { const { ticket, columnColor } = bodyProps const showNewMessage = !!ticket.hasNewMessage && !!columnColor return (
) } // ============================================================================= // Draggable board card // ============================================================================= export interface TicketCardProps { ticket: BoardTicket columnId: string columnColor?: string href?: string dragDisabled?: boolean /** The owning column's drop rules. A card is a drop target in its own right, * and Pragmatic does not let a lane's `canDrop` veto its children — so the * rules have to be answered here too, or a blocked lane still accepts a drop * straight onto one of its cards. */ dropDisabled?: boolean allowedFromColumns?: string[] renderAssignSlot?: (ticket: BoardTicket) => ReactNode onApprove?: (ticketId: string, requestId?: string) => void | Promise onReject?: (ticketId: string, requestId?: string) => void | Promise } /** Memoized: a drag moves one card, but it re-renders the column lists around * it, and a board carries dozens of cards whose props did not change. Hosts * must pass stable `renderAssignSlot` / `onApprove` / `onReject` (useCallback) * for this to bite — an inline arrow re-renders every card on every keystroke * anywhere in the page. */ export const TicketCard = memo(function TicketCard({ ticket, columnId, columnColor, href, dragDisabled, dropDisabled, allowedFromColumns, renderAssignSlot, onApprove, onReject, }: TicketCardProps) { const ref = useRef(null) // Read at drag start rather than captured: the registration below is keyed on // the ticket's id, so capturing the object would hand a renamed or re-tagged // ticket to the drag — and to the preview drawn from it — as it was when the // card first mounted. const ticketRef = useRef(ticket) ticketRef.current = ticket const [isDragging, setIsDragging] = useState(false) // Set by the board for the moment after a drop in which this card is already // in its new slot and the drag preview is still gliding into it. const isLanding = useIsLanding(ticket.id) // Where the drop points, worked out once for the whole board — see // `drop-aim.ts` for why this stopped being each card's own business. const aim = useDropAim() // Matched on the LANE as well as the card, and both halves are load-bearing. // The board resolves one landing place, in one named lane; a ticket id is only // unique within a lane here, because each lane is its own query and a ticket // whose status just changed is listed by the new lane before the old one has // caught up. Matching on the id alone drew the line in both lanes at once — // including a lane that refuses the card altogether, since the aim it was // drawing came from the other one. const closestEdge = aim && aim.columnId === columnId && aim.ticketId === ticket.id ? aim.edge : null // Not lane-scoped, and deliberately: `aim.columnId` is where the card is // GOING, while the card being carried is still sitting in the lane it came // from. True for a pointer drag and for a keyboard lift alike — the aim always // names the card being moved, whichever picked it up. const isSource = isDragging || aim?.ticket.id === ticket.id const lift = useBoardLift() const handleKeyDown = (e: KeyboardEvent) => { // Space lifts; Enter is left to the link, which opens the ticket. if (e.key !== ' ' || dragDisabled || !lift) return e.preventDefault() lift(ticket.id) } // Both roles live on the same element, and both keep their state HERE. That is // the point of the whole drag stack: hovering this card re-renders this card, // not the board — nothing subscribes upward, so cost does not grow with how // many cards are mounted. useEffect(() => { const element = ref.current if (!element || dragDisabled) return const identity = { type: 'ticket' as const, ticketId: ticket.id, columnId } return combine( // Before anything else: the drag has to start as a "move", or its first // frame is drawn with the copy cursor — see `drag-effect.ts`. holdMoveDragEffect(element), draggable({ element, // Read at drag start rather than captured, so a card renamed or re-tagged // since it mounted travels as it is now. No height: it was here for a // lane that opened room for the card, and nothing opens room any more — // it was costing a forced layout read at the start of every drag to // produce a field no one read. getInitialData: () => ({ ...identity, ticket: ticketRef.current }), /** * The card under the pointer is the browser's own drag image, styled by * us. Drawing it ourselves as a positioned element was tried and has one * unfixable flaw: an element in the page cannot leave the page. Carry a * card up towards the address bar and the pointer crosses into the * browser's own chrome, where no `dragover` reaches the document — so * the card freezes at the top edge and only catches up when the drag * ends. A native drag image is moved by the browser itself and floats * over everything, which is why Jira's card does not stick there. * * A clone rather than a second React tree: this is a photograph, taken * once, and a copy of the card's own DOM is a photograph of exactly what * is on screen — assignee slot and all — with no providers to re-supply * and no chance of it rendering a loading state into the picture. */ onGenerateDragPreview: ({ nativeSetDragImage, location, source }) => { const { width } = source.element.getBoundingClientRect() setCustomNativeDragPreview({ nativeSetDragImage, // Holds the card under the same part of itself it was grabbed by. getOffset: preserveOffsetOnSource({ element: source.element, input: location.current.input }), render: ({ container }) => { const clone = source.element.cloneNode(true) as HTMLElement // The card is a flex item stretched by its lane; on its own it // would shrink to its contents. clone.style.width = `${width}px` clone.style.opacity = String(DRAG_PREVIEW_OPACITY) clone.classList.add('shadow-card-hover') container.appendChild(clone) }, }) }, onDragStart: () => setIsDragging(true), onDrop: () => setIsDragging(false), }), dropTargetForElements({ element, canDrop: ({ source }) => { if (source.data.type !== 'ticket' || source.data.ticketId === ticket.id) return false const from = String(source.data.columnId) if (from === columnId) return true if (dropDisabled) return false return !allowedFromColumns || allowedFromColumns.includes(from) }, // The edge rides along in the drop target's own data, so the board reads // "above or below this card" straight off the drop it receives. getData: ({ input, element: self }) => attachClosestEdge({ ...identity }, { input, element: self, allowedEdges: ['top', 'bottom'] }), // Sticky, so crossing the gap between two cards — which is lane padding // and belongs to no card's box — keeps answering with the card just // left, rather than handing the question to the lane for those few // pixels and back again. Both answer with the same slot (the lane // resolves the nearest card and its edge), so this is about not // re-deriving it from a different target every few pixels, NOT about // reaching room a card has opened: nothing moves during a drag here. getIsSticky: () => true, }), ) }, [ticket.id, columnId, dragDisabled, dropDisabled, allowedFromColumns]) const showNewMessage = !!ticket.hasNewMessage && !!columnColor const style: CSSProperties = {} if (showNewMessage) style.borderColor = columnColor const handleClick = (e: MouseEvent) => { if (isDragging) e.preventDefault() } // Held as one memoized element, not re-created per render. A card being // dragged over re-renders on every edge flip — it owns the hover state that // draws the preview — and the body is the expensive half: avatars, tags, // tooltips, a host's assignee picker with its own queries. Keeping the same // element object lets React bail out of that whole subtree, so only the thin // wrapper around it re-renders. const body = useMemo( () => ( onApprove(ticket.id, requestId) : undefined} onReject={onReject ? requestId => onReject(ticket.id, requestId) : undefined} /> ), [ticket, columnColor, renderAssignSlot, onApprove, onReject], ) // No transition on the margins below, and that is not a style choice: room is // opened at one card and given back at another, and a transition interrupted // part-way — which is every target change while the pointer keeps moving — // restarts from where it got to, so the two ends run at different rates and // the lane's height wanders. The rearrangement is instant instead. const cardClasses = cn(TICKET_CARD_SHELL, !dragDisabled && 'cursor-pointer') const outerProps = { ref, style, className: cardClasses, // Read by the lane's scroll anchor to keep the list still when tickets are // inserted above the viewport — see `use-lane-scroll-anchor.ts`. [TICKET_ID_ATTRIBUTE]: ticket.id, } const innerWrapperClass = 'relative z-10 flex flex-col gap-[var(--spacing-system-sf)]' if (isSource) { // Stays exactly where it is, faded. NOTHING about the lane moves during a // drag — no room opens, no slot closes — so there is no layout to keep // balanced and no height to wander. That is what makes the line below // affordable, and it is why this is also the cheap option. // // Safe to do from state: React commits this AFTER the `dragstart` handler // returns, and it is only removing the source synchronously inside that // handler that lets a browser abort the drag. style.opacity = DRAGGED_CARD_OPACITY } else if (isLanding) { // Already here, and the preview is on its way to this exact box. Hidden and // not removed: the slot it holds is what the preview is aiming at, and it is // what keeps the lane from moving again the instant it settled. style.visibility = 'hidden' } /** * Where the card would land, drawn as a line rather than as an opened gap. * * Opening a card's worth of room and drawing a copy of the card in it costs a * lane relayout plus a whole card render — avatars, tags, tooltips, the host's * assignee slot — on every edge flip, and the flips come as fast as the * pointer moves. A line is one absolutely positioned span, changes no layout, * and says the same thing. */ const insertionLine = closestEdge ? ( ) : null if (href) { return (
{insertionLine}
{body}
) } return (
{insertionLine}
) }) function TicketTagRow({ tags }: { tags: string[] }) { const visible = tags.slice(0, MAX_VISIBLE_TAGS) const hidden = tags.length - visible.length return (
{visible.map(tag => ( ))} {hidden > 0 && }
) }