| 1 | import { ChevronRight, Inbox, ListOrdered, Plus } from "lucide-react"; |
| 2 | import { type DragEvent, useEffect, useMemo, useState } from "react"; |
| 3 | import { Link, redirect, useFetcher } from "react-router"; |
| 4 | |
| 5 | import type { PlanGroup, WorkItem } from "@g1t/contracts"; |
| 6 | |
| 7 | import type { Route } from "./+types/plan"; |
| 8 | import { CapBar, PlanRow, PlanRowMenu, type RowDrag } from "../../../components/plan"; |
| 9 | import { ButtonLink } from "../../../components/ui"; |
| 10 | import { Alert } from "../../../components/ui/alert"; |
| 11 | import { Button } from "../../../components/ui/button"; |
| 12 | import { Card } from "../../../components/ui/card"; |
| 13 | import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "../../../components/ui/collapsible"; |
| 14 | import { cn } from "../../../lib/cn"; |
| 15 | import { page } from "../../../lib/meta"; |
| 16 | import { isFailed, part } from "../../../lib/partial"; |
| 17 | import { |
| 18 | type Move, |
| 19 | type OnIt, |
| 20 | type PlanView, |
| 21 | applyMove, |
| 22 | blockersToRead, |
| 23 | capState, |
| 24 | dropPosition, |
| 25 | listRedirect, |
| 26 | moreText, |
| 27 | moveForm, |
| 28 | moveFrom, |
| 29 | onItems, |
| 30 | planInput, |
| 31 | planPath, |
| 32 | setAsidePath, |
| 33 | triagePath, |
| 34 | waitingOn, |
| 35 | } from "../../../lib/plan"; |
| 36 | import { useRefreshWhile } from "../../../lib/refresh"; |
| 37 | import { workPages } from "../../../lib/repo-live"; |
| 38 | import { work } from "../../../lib/services.server"; |
| 39 | import { assertSameOrigin, getViewer, requireUser, roleIn, unwrap } from "../../../lib/session.server"; |
| 40 | import { useWorkspaceLive } from "../../../lib/use-repo-live"; |
| 41 | import { canItem } from "../../../lib/work-access"; |
| 42 | import { newItemPath, shortPath, workPath } from "../../../lib/work-items"; |
| 43 | |
| 44 | export function meta({ params, ...args }: Route.MetaArgs) { |
| 45 | return page(args, { title: `Plan · Work · ${params.owner} · g1t` }); |
| 46 | } |
| 47 | |
| 48 | export async function loader({ params, context, request }: Route.LoaderArgs) { |
| 49 | const viewer = getViewer(context); |
| 50 | const workspace = params.owner.toLowerCase(); |
| 51 | const query = new URL(request.url).searchParams; |
| 52 | // `-/work` with the list's filters is an old link to the list. |
| 53 | const moved = listRedirect(workspace, query); |
| 54 | if (moved) throw redirect(moved); |
| 55 | const all = query.get("all") === "1"; |
| 56 | const member = roleIn(viewer, workspace) != null; |
| 57 | // The plan is what the page is; who is on each item is a part each. |
| 58 | const [plan, claims, live] = await Promise.all([ |
| 59 | work.getPlan(workspace, viewer, { all }), |
| 60 | part(work.listClaims(workspace, viewer, { state: "active" }), "Claims"), |
| 61 | part(work.listWorkspaceSessions(workspace, viewer, { shown: "live", sessionsOnly: true, limit: 200 }), "Live sessions"), |
| 62 | ]); |
| 63 | const { workspace: _, ...shown } = unwrap(plan); |
| 64 | const rows = [...shown.now, ...shown.next]; |
| 65 | // What each waits on: the open ones in the plan are known; the rest are looked up, a few at most. |
| 66 | const open = new Set(rows.map((item) => item.number)); |
| 67 | const looked = await Promise.all( |
| 68 | blockersToRead(rows, open).map((number) => |
| 69 | work |
| 70 | .getWorkItem(workspace, number, viewer) |
| 71 | .then((found) => (found.ok && found.value.item.state === "closed" ? number : null)) |
| 72 | .catch(() => null), |
| 73 | ), |
| 74 | ); |
| 75 | const sessions = isFailed(live) || !live.ok ? [] : live.value.sessions; |
| 76 | return { |
| 77 | workspace, |
| 78 | member, |
| 79 | signedIn: viewer != null, |
| 80 | all, |
| 81 | plan: shown, |
| 82 | triageTotal: shown.triageTotal, |
| 83 | on: onItems(rows, isFailed(claims) || !claims.ok ? [] : claims.value, sessions), |
| 84 | closedBlockers: looked.filter((number): number is number => number != null), |
| 85 | // Anyone with Triage places work and ranks Next and Later; Now takes |
| 86 | // the right to manage milestones, which the work service checks again. |
| 87 | canPlan: canItem(viewer, workspace, null, "triage"), |
| 88 | canNow: canItem(viewer, workspace, null, "manage_labels"), |
| 89 | canOpen: viewer != null && member, |
| 90 | unavailable: [claims, live].filter(isFailed).map((failed) => failed.what), |
| 91 | live: sessions.length, |
| 92 | }; |
| 93 | } |
| 94 | |
| 95 | export async function action({ request, params, context }: Route.ActionArgs) { |
| 96 | assertSameOrigin(request); |
| 97 | const user = requireUser(context, request); |
| 98 | const workspace = params.owner.toLowerCase(); |
| 99 | const form = await request.formData(); |
| 100 | const number = Number(form.get("number")); |
| 101 | if (!Number.isInteger(number) || number < 1) return { error: "Choose a work item to move.", number: null }; |
| 102 | const input = planInput(form); |
| 103 | if (typeof input === "string") return { error: input, number }; |
| 104 | const result = await work.planWorkItem(user, workspace, number, input); |
| 105 | return result.ok ? { error: null, number } : { error: result.error.message, number }; |
| 106 | } |
| 107 | |
| 108 | /** A list a row can be dropped into. */ |
| 109 | type Lane = "now" | "next" | "later"; |
| 110 | |
| 111 | type Dragging = { item: WorkItem; from: Lane }; |
| 112 | type Over = { lane: Lane; index: number }; |
| 113 | |
| 114 | /** |
| 115 | * Work's Plan: Now, what the workspace has taken on, against its cap; |
| 116 | * Next, ranked, the top taken first; Later, grouped by repository; and how |
| 117 | * many are set aside. Rows move by dragging within and between Now and |
| 118 | * Next (and onto Later), or from each row's menu, which is how a keyboard |
| 119 | * or a phone moves them. |
| 120 | */ |
| 121 | export default function PlanPage({ loaderData }: Route.ComponentProps) { |
| 122 | const { workspace, member, canPlan, canNow, on, all } = loaderData; |
| 123 | const fetcher = useFetcher<typeof action>(); |
| 124 | useWorkspaceLive({ workspace, member, match: workPages.list() }); |
| 125 | // Sessions go idle and end with no event to say so. |
| 126 | useRefreshWhile(loaderData.live > 0, 30_000); |
| 127 | |
| 128 | // What a move from a Later group brings with it, to show it where it lands before the answer. |
| 129 | const [carried, setCarried] = useState<WorkItem | null>(null); |
| 130 | const pending = fetcher.formData ? moveFrom(fetcher.formData) : null; |
| 131 | const plan: PlanView = pending ? applyMove(loaderData.plan, pending, carried?.number === pending.number ? carried : undefined) : loaderData.plan; |
| 132 | const closed = useMemo(() => new Set(loaderData.closedBlockers), [loaderData.closedBlockers]); |
| 133 | const [error, setError] = useState<string | null>(null); |
| 134 | useEffect(() => { |
| 135 | if (fetcher.state === "idle" && fetcher.data) setError(fetcher.data.error); |
| 136 | }, [fetcher.state, fetcher.data]); |
| 137 | |
| 138 | const submit = (move: Move, item?: WorkItem) => { |
| 139 | setError(null); |
| 140 | setCarried(item ?? null); |
| 141 | fetcher.submit(moveForm(move), { method: "post" }); |
| 142 | }; |
| 143 | |
| 144 | // Dragging: what is held, and the gap it would land in. |
| 145 | const [dragging, setDragging] = useState<Dragging | null>(null); |
| 146 | const [over, setOver] = useState<Over | null>(null); |
| 147 | const lists: Record<"now" | "next", number[]> = { now: plan.now.map((item) => item.number), next: plan.next.map((item) => item.number) }; |
| 148 | const accepts = (lane: Lane) => { |
| 149 | if (!dragging) return false; |
| 150 | // Into or out of Now, or within it, takes the right to manage milestones. |
| 151 | if ((lane === "now" || dragging.from === "now") && !canNow) return false; |
| 152 | return !(lane === "later" && dragging.from === "later"); |
| 153 | }; |
| 154 | const drop = (lane: Lane, index: number) => { |
| 155 | const held = dragging; |
| 156 | setDragging(null); |
| 157 | setOver(null); |
| 158 | if (!held || !accepts(lane)) return; |
| 159 | if (lane === "later") { |
| 160 | submit({ number: held.item.number, horizon: "later" }, held.item); |
| 161 | return; |
| 162 | } |
| 163 | const position = dropPosition(lists[lane], index, held.item.number); |
| 164 | if (!position) return; |
| 165 | submit({ number: held.item.number, horizon: lane, ...position }, held.item); |
| 166 | }; |
| 167 | const rowDrag = (lane: Lane, item: WorkItem, index: number): RowDrag | undefined => { |
| 168 | if (!canPlan) return undefined; |
| 169 | const draggable = item.horizon !== "now" || canNow; |
| 170 | return { |
| 171 | draggable, |
| 172 | dragging: dragging?.item.number === item.number, |
| 173 | edge: |
| 174 | over?.lane === lane && lane !== "later" |
| 175 | ? over.index === index |
| 176 | ? "above" |
| 177 | : over.index === index + 1 && index === lists[lane].length - 1 |
| 178 | ? "below" |
| 179 | : null |
| 180 | : null, |
| 181 | onDragStart: (event) => { |
| 182 | event.dataTransfer.effectAllowed = "move"; |
| 183 | event.dataTransfer.setData("text/plain", `#${item.number}`); |
| 184 | setDragging({ item, from: lane }); |
| 185 | }, |
| 186 | onDragEnd: () => { |
| 187 | setDragging(null); |
| 188 | setOver(null); |
| 189 | }, |
| 190 | onDragOver: (event) => { |
| 191 | if (!accepts(lane)) return; |
| 192 | event.preventDefault(); |
| 193 | event.stopPropagation(); |
| 194 | if (lane === "later") return setOver({ lane, index: 0 }); |
| 195 | const box = event.currentTarget.getBoundingClientRect(); |
| 196 | const at = index + (event.clientY > box.top + box.height / 2 ? 1 : 0); |
| 197 | if (over?.lane !== lane || over.index !== at) setOver({ lane, index: at }); |
| 198 | }, |
| 199 | onDrop: (event) => { |
| 200 | event.preventDefault(); |
| 201 | event.stopPropagation(); |
| 202 | drop(lane, over?.lane === lane ? over.index : index); |
| 203 | }, |
| 204 | }; |
| 205 | }; |
| 206 | const laneDrop = (lane: Lane) => ({ |
| 207 | onDragOver: (event: DragEvent<HTMLElement>) => { |
| 208 | if (!accepts(lane)) return; |
| 209 | event.preventDefault(); |
| 210 | const end = lane === "later" ? 0 : lists[lane].length; |
| 211 | if (over?.lane !== lane) setOver({ lane, index: end }); |
| 212 | }, |
| 213 | onDragLeave: (event: DragEvent<HTMLElement>) => { |
| 214 | if (!event.currentTarget.contains(event.relatedTarget as Node | null) && over?.lane === lane) setOver(null); |
| 215 | }, |
| 216 | onDrop: (event: DragEvent<HTMLElement>) => { |
| 217 | event.preventDefault(); |
| 218 | drop(lane, over?.lane === lane ? over.index : lane === "later" ? 0 : lists[lane].length); |
| 219 | }, |
| 220 | }); |
| 221 | |
| 222 | const cap = capState(plan.now.length, loaderData.plan.nowCap); |
| 223 | const nextMore = moreText(plan.next.length, plan.nextTotal); |
| 224 | const row = (lane: "now" | "next", item: WorkItem, index: number, rank: number) => ( |
| 225 | <PlanRow |
| 226 | key={item.id} |
| 227 | item={item} |
| 228 | rank={rank} |
| 229 | workspace={workspace} |
| 230 | on={on[item.number] as OnIt | undefined} |
| 231 | waiting={waitingOn(item, closed)} |
| 232 | drag={rowDrag(lane, item, index)} |
| 233 | menu={canPlan ? <PlanRowMenu item={item} list={lists[lane]} canNow={canNow} onMove={(move) => submit(move, item)} /> : undefined} |
| 234 | /> |
| 235 | ); |
| 236 | |
| 237 | return ( |
| 238 | <div> |
| 239 | <div className="flex flex-wrap items-center justify-between gap-3"> |
| 240 | <div className="min-w-0"> |
| 241 | <h1 className="flex items-center gap-2 text-lg font-semibold tracking-tight"> |
| 242 | <ListOrdered size={17} className="text-accent" /> |
| 243 | Plan |
| 244 | </h1> |
| 245 | <p className="mt-1 text-sm text-muted"> |
| 246 | Now is what the workspace has taken on. Next is ranked, and the top is taken first. Later is wanted, and waits. |
| 247 | {!canPlan && loaderData.signedIn && " You can read the plan; placing work takes the Triage role."} |
| 248 | </p> |
| 249 | </div> |
| 250 | {loaderData.canOpen && ( |
| 251 | <ButtonLink to={newItemPath(workspace)} className="max-sm:h-10"> |
| 252 | <Plus size={15} /> |
| 253 | New work item |
| 254 | </ButtonLink> |
| 255 | )} |
| 256 | </div> |
| 257 | |
| 258 | {loaderData.unavailable.length > 0 && ( |
| 259 | <p className="mt-3 text-xs text-warn">{loaderData.unavailable.join(" and ")} could not be loaded just now, so who is on each item may be missing.</p> |
| 260 | )} |
| 261 | {error && ( |
| 262 | <Alert role="alert" className="mt-3 flex items-start justify-between gap-3"> |
| 263 | <span>{error}</span> |
| 264 | <Button variant="ghost" size="xs" onClick={() => setError(null)} className="-my-1 text-danger"> |
| 265 | Dismiss |
| 266 | </Button> |
| 267 | </Alert> |
| 268 | )} |
| 269 | {plan.triageTotal > 0 && ( |
| 270 | <Link |
| 271 | to={triagePath(workspace)} |
| 272 | className="mt-4 flex items-center gap-2 rounded-lg border border-line px-3 py-2 text-sm text-muted transition-colors hover:border-line-strong hover:text-fg" |
| 273 | > |
| 274 | <Inbox size={15} className="shrink-0 text-accent" /> |
| 275 | <span className="min-w-0 grow"> |
| 276 | {plan.triageTotal === 1 ? "1 new work item waits" : `${plan.triageTotal.toLocaleString("en-US")} new work items wait`} in Triage to be placed. |
| 277 | </span> |
| 278 | <ChevronRight size={15} className="shrink-0" /> |
| 279 | </Link> |
| 280 | )} |
| 281 | |
| 282 | <div className="mt-5 grid gap-4 lg:grid-cols-[minmax(0,1.6fr)_minmax(0,1fr)]"> |
| 283 | <Card className="min-w-0 self-start"> |
| 284 | <section |
| 285 | aria-labelledby="plan-now" |
| 286 | {...laneDrop("now")} |
| 287 | className={cn("rounded-t-xl", over?.lane === "now" && plan.now.length === 0 && "bg-accent/5")} |
| 288 | > |
| 289 | <header className="flex flex-wrap items-center gap-x-2 gap-y-1 px-4 pt-3 pb-2"> |
| 290 | <h2 id="plan-now" className="text-sm font-semibold"> |
| 291 | Now |
| 292 | </h2> |
| 293 | <span className="text-xs text-faint">what the workspace is on</span> |
| 294 | <span className="ml-auto"> |
| 295 | <CapBar cap={cap} /> |
| 296 | </span> |
| 297 | </header> |
| 298 | {cap.over && <p className="px-4 pb-2 text-xs text-warn">Now is past its cap of {cap.cap}. Finish something, or move one back to Next.</p>} |
| 299 | {plan.now.length === 0 ? ( |
| 300 | <p className="mx-4 mb-3 rounded-lg border border-dashed border-line px-3 py-4 text-center text-xs text-faint"> |
| 301 | {canNow ? "Nothing in Now. Move what you're taking on from Next." : "Nothing in Now yet."} |
| 302 | </p> |
| 303 | ) : ( |
| 304 | <ol className="px-2 pb-2">{plan.now.map((item, index) => row("now", item, index, index + 1))}</ol> |
| 305 | )} |
| 306 | </section> |
| 307 | <section |
| 308 | aria-labelledby="plan-next" |
| 309 | {...laneDrop("next")} |
| 310 | className={cn("border-t border-line", over?.lane === "next" && plan.next.length === 0 && "bg-accent/5")} |
| 311 | > |
| 312 | <header className="flex flex-wrap items-center gap-x-2 gap-y-1 px-4 pt-3 pb-2"> |
| 313 | <h2 id="plan-next" className="text-sm font-semibold"> |
| 314 | Next |
| 315 | </h2> |
| 316 | <span className="text-xs text-faint">{canPlan && <span className="max-sm:hidden">drag to rank · </span>}the top is taken first</span> |
| 317 | <span className="ml-auto font-mono text-xs tabular-nums text-faint">{plan.nextTotal}</span> |
| 318 | </header> |
| 319 | {plan.next.length === 0 ? ( |
| 320 | <p className="mx-4 mb-3 rounded-lg border border-dashed border-line px-3 py-4 text-center text-xs text-faint"> |
| 321 | {canPlan ? "Nothing in Next. Place work here from Triage or Later, in the order it should be taken." : "Nothing in Next yet."} |
| 322 | </p> |
| 323 | ) : ( |
| 324 | <ol className="px-2 pb-2">{plan.next.map((item, index) => row("next", item, index, plan.now.length + index + 1))}</ol> |
| 325 | )} |
| 326 | {nextMore && !all && ( |
| 327 | <div className="px-4 pb-3"> |
| 328 | <ButtonLink to={planPath(workspace, "all=1")} variant="outline" size="sm" className="w-full border-dashed" preventScrollReset> |
| 329 | Show all {plan.nextTotal.toLocaleString("en-US")} |
| 330 | </ButtonLink> |
| 331 | </div> |
| 332 | )} |
| 333 | {all && plan.nextTotal > 10 && ( |
| 334 | <div className="px-4 pb-3"> |
| 335 | <ButtonLink to={planPath(workspace)} variant="ghost" size="sm" className="w-full" preventScrollReset> |
| 336 | Show the top 10 |
| 337 | </ButtonLink> |
| 338 | </div> |
| 339 | )} |
| 340 | </section> |
| 341 | </Card> |
| 342 | |
| 343 | <div className="grid min-w-0 content-start gap-4"> |
| 344 | <Card asChild className={cn("min-w-0", over?.lane === "later" && "border-accent/60 bg-accent/5")}> |
| 345 | <section aria-labelledby="plan-later" {...laneDrop("later")}> |
| 346 | <header className="flex flex-wrap items-center gap-x-2 gap-y-1 px-4 pt-3 pb-2"> |
| 347 | <h2 id="plan-later" className="text-sm font-semibold"> |
| 348 | Later |
| 349 | </h2> |
| 350 | <span className="text-xs text-faint">by repository</span> |
| 351 | <span className="ml-auto font-mono text-xs tabular-nums text-faint">{plan.laterTotal}</span> |
| 352 | </header> |
| 353 | {plan.later.length === 0 ? ( |
| 354 | <p className="mx-4 mb-3 rounded-lg border border-dashed border-line px-3 py-4 text-center text-xs text-faint"> |
| 355 | {canPlan ? "Nothing in Later. Drop what you want but won't take soon here." : "Nothing in Later."} |
| 356 | </p> |
| 357 | ) : ( |
| 358 | <ul className="px-2 pb-2"> |
| 359 | {plan.later.map((group) => ( |
| 360 | <LaterGroup |
| 361 | key={group.repo ?? ""} |
| 362 | group={group} |
| 363 | workspace={workspace} |
| 364 | canPlan={canPlan} |
| 365 | canNow={canNow} |
| 366 | hidden={pending?.number ?? null} |
| 367 | onMove={submit} |
| 368 | onDrag={(item) => setDragging(item ? { item, from: "later" } : null)} |
| 369 | /> |
| 370 | ))} |
| 371 | </ul> |
| 372 | )} |
| 373 | </section> |
| 374 | </Card> |
| 375 | <Card className="flex items-center gap-3 px-4 py-3"> |
| 376 | <h2 className="text-sm font-semibold">Set aside</h2> |
| 377 | {plan.setAsideTotal > 0 ? ( |
| 378 | <Link to={setAsidePath(workspace)} className="ml-auto text-sm text-muted hover:text-fg"> |
| 379 | {plan.setAsideTotal.toLocaleString("en-US")} <span className="text-faint">in All items</span> |
| 380 | </Link> |
| 381 | ) : ( |
| 382 | <span className="ml-auto text-sm text-faint">None</span> |
| 383 | )} |
| 384 | </Card> |
| 385 | <p className="px-1 text-xs text-faint"> |
| 386 | Set aside keeps what nobody means to do soon out of every view but search. Nothing is deleted: move one back from its page. |
| 387 | </p> |
| 388 | </div> |
| 389 | </div> |
| 390 | </div> |
| 391 | ); |
| 392 | } |
| 393 | |
| 394 | /** |
| 395 | * One repository's Later, collapsed to its count; opened, its items (at |
| 396 | * most 100), read when it opens, each with its menu, and draggable to Now |
| 397 | * or Next. |
| 398 | */ |
| 399 | function LaterGroup({ |
| 400 | group, |
| 401 | workspace, |
| 402 | canPlan, |
| 403 | canNow, |
| 404 | hidden, |
| 405 | onMove, |
| 406 | onDrag, |
| 407 | }: { |
| 408 | group: PlanGroup; |
| 409 | workspace: string; |
| 410 | canPlan: boolean; |
| 411 | canNow: boolean; |
| 412 | /** One being moved just now, left out until the plan says where it went. */ |
| 413 | hidden: number | null; |
| 414 | onMove: (move: Move, item: WorkItem) => void; |
| 415 | onDrag: (item: WorkItem | null) => void; |
| 416 | }) { |
| 417 | const [open, setOpen] = useState(false); |
| 418 | const fetcher = useFetcher<{ items: WorkItem[]; more: boolean } | { error: string }>(); |
| 419 | const url = `${workPath(workspace, "plan/later")}?${new URLSearchParams({ repo: group.repo ?? "" }).toString()}`; |
| 420 | // Read when it opens, and again when its count changes while it is open. |
| 421 | useEffect(() => { |
| 422 | if (open) fetcher.load(url); |
| 423 | // eslint-disable-next-line react-hooks/exhaustive-deps |
| 424 | }, [open, url, group.count]); |
| 425 | const data = fetcher.data; |
| 426 | const items = data && "items" in data ? data.items.filter((item) => item.number !== hidden) : null; |
| 427 | const name = group.repo ? shortPath(group.repo, workspace) : "Filed in the workspace"; |
| 428 | return ( |
| 429 | <li> |
| 430 | <Collapsible open={open} onOpenChange={setOpen}> |
| 431 | <CollapsibleTrigger className="flex w-full items-center gap-2 rounded-md px-2 py-2 text-left hover:bg-raised/60"> |
| 432 | <ChevronRight size={14} className="shrink-0" /> |
| 433 | <span className={cn("min-w-0 grow truncate", group.repo ? "font-mono text-[0.8125rem]" : "text-sm")}>{name}</span> |
| 434 | <span className="font-mono text-xs tabular-nums text-faint">{group.count}</span> |
| 435 | </CollapsibleTrigger> |
| 436 | <CollapsibleContent> |
| 437 | {items == null ? ( |
| 438 | data && "error" in data ? ( |
| 439 | <p className="px-3 py-2 text-xs text-danger">{data.error}</p> |
| 440 | ) : ( |
| 441 | <p className="px-3 py-2 text-xs text-faint">Loading…</p> |
| 442 | ) |
| 443 | ) : items.length === 0 ? ( |
| 444 | <p className="px-3 py-2 text-xs text-faint">Nothing here now.</p> |
| 445 | ) : ( |
| 446 | <ul className="pl-4"> |
| 447 | {items.map((item) => ( |
| 448 | <PlanRow |
| 449 | key={item.id} |
| 450 | item={item} |
| 451 | rank={null} |
| 452 | workspace={workspace} |
| 453 | on={undefined} |
| 454 | waiting={[]} |
| 455 | drag={ |
| 456 | canPlan |
| 457 | ? { |
| 458 | draggable: true, |
| 459 | dragging: false, |
| 460 | edge: null, |
| 461 | onDragStart: (event) => { |
| 462 | event.dataTransfer.effectAllowed = "move"; |
| 463 | event.dataTransfer.setData("text/plain", `#${item.number}`); |
| 464 | onDrag(item); |
| 465 | }, |
| 466 | onDragEnd: () => onDrag(null), |
| 467 | onDragOver: () => {}, |
| 468 | onDrop: () => {}, |
| 469 | } |
| 470 | : undefined |
| 471 | } |
| 472 | menu={canPlan ? <PlanRowMenu item={item} list={null} canNow={canNow} onMove={(move) => onMove(move, item)} /> : undefined} |
| 473 | /> |
| 474 | ))} |
| 475 | </ul> |
| 476 | )} |
| 477 | {data && "more" in data && data.more && ( |
| 478 | <p className="px-3 pb-2 text-xs text-faint">Showing the first 100 of {group.count.toLocaleString("en-US")}.</p> |
| 479 | )} |
| 480 | </CollapsibleContent> |
| 481 | </Collapsible> |
| 482 | </li> |
| 483 | ); |
| 484 | } |