From 682a406d852061d9f447078570486dfb8a2a734d Mon Sep 17 00:00:00 2001 From: Brandon Oxendine Date: Thu, 5 Nov 2026 16:45:15 +0100 Subject: [PATCH] desktop: Add the board view MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Every issue and patch of a repository shows on its board, in a column derived from its state until someone moves it. Cards move by drag and drop or from their menu, closing columns close and reopen issues for those allowed to, and a drop that isn't allowed says why. Changes wait 2.5 seconds and are written as one op. Patches link to issues with a keyword and a full reference. Boards stay hidden until turned on under Settings → Labs. Co-authored-by: Rūdolfs Ošiņš --- desktop/src/App.svelte | 3 + desktop/src/components/BoardCard.svelte | 345 ++++ desktop/src/components/BoardCardMenu.svelte | 109 ++ .../src/components/BoardDisplayMenu.svelte | 191 ++ desktop/src/components/BoardFilterMenu.svelte | 164 ++ desktop/src/components/PriorityIcon.svelte | 51 + desktop/src/components/SidebarRepoList.svelte | 20 +- desktop/src/lib/board.ts | 685 +++++++ desktop/src/lib/boardActions.ts | 44 + desktop/src/lib/boardDrag.svelte.ts | 413 +++++ desktop/src/lib/boardQueue.svelte.ts | 165 ++ desktop/src/lib/boardView.ts | 342 ++++ desktop/src/lib/labs.ts | 16 + desktop/src/lib/router.ts | 16 +- desktop/src/lib/router/definitions.ts | 4 + desktop/src/modals/AddBoardCard.svelte | 227 +++ desktop/src/modals/BoardColumns.svelte | 355 ++++ desktop/src/modals/BoardName.svelte | 116 ++ desktop/src/modals/ConfirmSeedCard.svelte | 54 + desktop/src/modals/CreateBoard.svelte | 68 + desktop/src/modals/CreateIssue.svelte | 20 +- desktop/src/modals/Settings.svelte | 26 + desktop/src/views/repo/Board.svelte | 1613 +++++++++++++++++ desktop/src/views/repo/router.ts | 73 + desktop/tests/e2e/repo/board.spec.ts | 543 ++++++ desktop/tests/unit/board.test.ts | 990 ++++++++++ desktop/tests/unit/boardQueue.test.ts | 259 +++ desktop/tests/unit/boardView.test.ts | 371 ++++ desktop/tests/unit/repoRouter.test.ts | 12 + desktop/tests/unit/router.test.ts | 20 + 30 files changed, 7305 insertions(+), 10 deletions(-) create mode 100644 desktop/src/components/BoardCard.svelte create mode 100644 desktop/src/components/BoardCardMenu.svelte create mode 100644 desktop/src/components/BoardDisplayMenu.svelte create mode 100644 desktop/src/components/BoardFilterMenu.svelte create mode 100644 desktop/src/components/PriorityIcon.svelte create mode 100644 desktop/src/lib/board.ts create mode 100644 desktop/src/lib/boardActions.ts create mode 100644 desktop/src/lib/boardDrag.svelte.ts create mode 100644 desktop/src/lib/boardQueue.svelte.ts create mode 100644 desktop/src/lib/boardView.ts create mode 100644 desktop/src/lib/labs.ts create mode 100644 desktop/src/modals/AddBoardCard.svelte create mode 100644 desktop/src/modals/BoardColumns.svelte create mode 100644 desktop/src/modals/BoardName.svelte create mode 100644 desktop/src/modals/ConfirmSeedCard.svelte create mode 100644 desktop/src/modals/CreateBoard.svelte create mode 100644 desktop/src/views/repo/Board.svelte create mode 100644 desktop/tests/e2e/repo/board.spec.ts create mode 100644 desktop/tests/unit/board.test.ts create mode 100644 desktop/tests/unit/boardQueue.test.ts create mode 100644 desktop/tests/unit/boardView.test.ts diff --git a/desktop/src/App.svelte b/desktop/src/App.svelte index ae7663568..da015c7b1 100644 --- a/desktop/src/App.svelte +++ b/desktop/src/App.svelte @@ -42,6 +42,7 @@ import Auth from "@app/views/auth/Auth.svelte"; import CreateIdentity from "@app/views/auth/CreateIdentity.svelte"; import InboxView from "@app/views/Inbox.svelte"; + import Board from "@app/views/repo/Board.svelte"; import Identity from "@app/views/repo/Identity.svelte"; import Issue from "@app/views/repo/Issue.svelte"; import Issues from "@app/views/repo/Issues.svelte"; @@ -269,6 +270,8 @@ {:else if $activeRouteStore.resource === "guide"} + {:else if $activeRouteStore.resource === "repo.board"} + {:else if $activeRouteStore.resource === "repo.home"} {:else if $activeRouteStore.resource === "repo.commits"} diff --git a/desktop/src/components/BoardCard.svelte b/desktop/src/components/BoardCard.svelte new file mode 100644 index 000000000..7a2669522 --- /dev/null +++ b/desktop/src/components/BoardCard.svelte @@ -0,0 +1,345 @@ + + + + +{#snippet assignees()} + {@const people = itemAssignees(item)} + {#if people.length > 0} +
+ +
+ {/if} +{/snippet} + +{#snippet labels()} + {#each displayLabels(itemLabels(item)) as label} + {label} + {/each} +{/snippet} + +{#snippet patchChips()} + {#if item.kind === "issue"} + {#each item.patches as patch (patch.id)} + + {/each} + {/if} +{/snippet} + +{#snippet repoChip()} + {#if foreign} + + {repoName(item.card.rid)} + + {/if} + {#if item.kind === "unresolved"} + + {seeding ? "Seeding…" : "Click to seed this repository"} + + {/if} +{/snippet} + +{#snippet closedNote()} + {#if item.kind === "issue" && item.showsClosed} + + Closed + + {/if} +{/snippet} + +{#snippet commentCount()} + {@const comments = itemComments(item)} + {#if comments > 0 && shows("comments")} + + {comments} + + {/if} +{/snippet} + +{#snippet typeIcon()} + {#if item.kind === "issue"} + + + + {:else if item.kind === "patch"} + + + + {/if} +{/snippet} + +{#snippet lock()} + {#if locked} + + {/if} +{/snippet} + +{#if layout === "card"} +
+ {@render typeIcon()} + {#if shows("id")} + {shortId(item.card.oid)} + {/if} + {#if shows("id") && shows("created")} + · + {/if} + {#if shows("created")} + + {formatTimestamp(timestamp)} + + {/if} + {#if shows("assignees")}{@render assignees()}{/if} + {@render lock()} +
+
{itemTitle(item)}
+
+ {#if item.kind !== "unresolved" && shows("priority")} + p.id === priority)?.label} + style:display="flex"> + + + {/if} + {#if shows("repository")}{@render repoChip()}{/if} + {#if shows("patches")}{@render patchChips()}{/if} + {#if shows("labels")}{@render labels()}{/if} + {@render commentCount()} + {@render closedNote()} +
+{:else} + {#if item.kind !== "unresolved" && shows("priority")} + + {/if} + {#if shows("id")} + {shortId(item.card.oid)} + {/if} + {@render typeIcon()} +
+ {itemTitle(item)} + {#if shows("patches")}{@render patchChips()}{/if} + {#if shows("repository")}{@render repoChip()}{/if} +
+ {@render lock()} + {@render closedNote()} +
+ {#if column !== undefined} + {column} + {/if} + {#if shows("labels")}{@render labels()}{/if} +
+ {#if shows("comments")} +
{@render commentCount()}
+ {/if} + {#if shows("assignees")} +
+ {@render assignees()} +
+ {/if} + {#if shows("created")} + + {formatTimestamp(timestamp)} + + {/if} +{/if} diff --git a/desktop/src/components/BoardCardMenu.svelte b/desktop/src/components/BoardCardMenu.svelte new file mode 100644 index 000000000..80f2b2f2c --- /dev/null +++ b/desktop/src/components/BoardCardMenu.svelte @@ -0,0 +1,109 @@ + + + + + + {#if showMove} + + {#each targets as column (column.id)} + + {/each} + {/if} + {#if canRemove} + {#if showMove} + + {/if} + + {/if} + {#if canPrioritize} + {#if showMove || canRemove} + + {/if} + + {#each PRIORITIES as priority (priority.id)} + + {/each} + {/if} + diff --git a/desktop/src/components/BoardDisplayMenu.svelte b/desktop/src/components/BoardDisplayMenu.svelte new file mode 100644 index 000000000..399bc8d9c --- /dev/null +++ b/desktop/src/components/BoardDisplayMenu.svelte @@ -0,0 +1,191 @@ + + + + + + +
+ Show completed + Show canceled + Show empty columns +
+ +
+ Display +
+ {#each PROPERTIES as property (property.id)} + + {/each} +
+
+ + + diff --git a/desktop/src/components/BoardFilterMenu.svelte b/desktop/src/components/BoardFilterMenu.svelte new file mode 100644 index 000000000..ff275aac6 --- /dev/null +++ b/desktop/src/components/BoardFilterMenu.svelte @@ -0,0 +1,164 @@ + + + + + diff --git a/desktop/src/components/PriorityIcon.svelte b/desktop/src/components/PriorityIcon.svelte new file mode 100644 index 000000000..f6e868868 --- /dev/null +++ b/desktop/src/components/PriorityIcon.svelte @@ -0,0 +1,51 @@ + + + diff --git a/desktop/src/components/SidebarRepoList.svelte b/desktop/src/components/SidebarRepoList.svelte index 2872c987c..4c15c3c7f 100644 --- a/desktop/src/components/SidebarRepoList.svelte +++ b/desktop/src/components/SidebarRepoList.svelte @@ -74,6 +74,7 @@ invoke, writeToClipboard, } from "@app/lib/invoke"; + import { labs } from "@app/lib/labs"; import { entityUrl } from "@app/lib/mentions"; import { show } from "@app/lib/modal"; import { repoListScope } from "@app/lib/repoListScope"; @@ -534,6 +535,10 @@ return $activeRoute.resource === "repo.home" && activeRid() === rid; } + function isBoard(rid: string): boolean { + return $activeRoute.resource === "repo.board" && activeRid() === rid; + } + function isIssues(rid: string): boolean { return ( ($activeRoute.resource === "repo.issues" || @@ -1183,6 +1188,19 @@
+ {#if labs.boards} + {@render subItem( + repo.name, + router.routeToPath({ + resource: "repo.board", + rid: repo.rid, + }), + "dashboard", + "Boards", + isBoard(repo.rid), + undefined, + )} + {/if} {@render subItem( repo.name, router.routeToPath({ @@ -1225,7 +1243,7 @@ {#snippet subItem( repoName: string, href: string, - icon: "branch" | "issue" | "patch" | "parcel", + icon: "branch" | "dashboard" | "issue" | "patch" | "parcel", label: string, active: boolean, count: number | undefined, diff --git a/desktop/src/lib/board.ts b/desktop/src/lib/board.ts new file mode 100644 index 000000000..b22cc394c --- /dev/null +++ b/desktop/src/lib/board.ts @@ -0,0 +1,685 @@ +import type { Author } from "@bindings/cob/Author"; +import type { Board } from "@bindings/cob/board/Board"; +import type { Card } from "@bindings/cob/board/Card"; +import type { Column } from "@bindings/cob/board/Column"; +import type { PatchLink } from "@bindings/cob/board/PatchLink"; +import type { Placement } from "@bindings/cob/board/Placement"; +import type { Issue } from "@bindings/cob/issue/Issue"; +import type { State } from "@bindings/cob/issue/State"; +import type { Patch } from "@bindings/cob/patch/Patch"; + +import { publicKeyFromDid } from "@app/lib/utils"; + +export type Priority = "urgent" | "high" | "medium" | "low" | "none"; + +export type BoardView = "board" | "list"; + +export const ISSUE_TYPE = "xyz.radicle.issue"; +export const PATCH_TYPE = "xyz.radicle.patch"; + +// The evaluator's limits, in UTF-8 bytes. +export const MAX_NAME_LENGTH = 100; +export const MAX_COLUMNS = 50; +export const MAX_COLUMN_ID_LENGTH = 64; +export const MAX_POSITION_LENGTH = 64; + +export function utf8Length(text: string): number { + return new TextEncoder().encode(text).length; +} + +export function validName(name: string): boolean { + const length = utf8Length(name.trim()); + return length > 0 && length <= MAX_NAME_LENGTH; +} + +// Mirrors `radicle_board::default_columns`. +export const DEFAULT_COLUMNS: Column[] = [ + { id: "backlog", name: "Backlog" }, + { id: "todo", name: "Todo" }, + { id: "in-progress", name: "In Progress" }, + { id: "in-review", name: "In Review" }, + { id: "done", name: "Done", closes: "solved" }, + { id: "canceled", name: "Canceled", closes: "other" }, +]; + +export const PRIORITIES: { id: Priority; label: string }[] = [ + { id: "urgent", label: "Urgent" }, + { id: "high", label: "High" }, + { id: "medium", label: "Medium" }, + { id: "low", label: "Low" }, + { id: "none", label: "No priority" }, +]; + +const PRIORITY_PREFIX = "priority:"; + +export const PRIORITY_RANK: Record = { + urgent: 0, + high: 1, + medium: 2, + low: 3, + none: 4, +}; + +export function priorityOf(labels: string[]): Priority { + const label = labels.find(l => l.startsWith(PRIORITY_PREFIX)); + const value = label?.slice(PRIORITY_PREFIX.length); + return PRIORITIES.find(p => p.id === value && p.id !== "none")?.id ?? "none"; +} + +export function labelsForPriority( + labels: string[], + priority: Priority, +): string[] { + const rest = labels.filter(l => !l.startsWith(PRIORITY_PREFIX)); + return priority === "none" + ? rest + : [...rest, `${PRIORITY_PREFIX}${priority}`]; +} + +export function displayLabels(labels: string[]): string[] { + return labels.filter(l => !l.startsWith(PRIORITY_PREFIX)); +} + +interface ItemBase { + key: string; + card: Card; + placement?: Placement; + column: string; + // The stored position when the card is placed in `column`, otherwise one + // derived from the card itself. + position: string; +} + +export type BoardItem = + | (ItemBase & { + kind: "issue"; + issue: Issue; + patches: Patch[]; + // Closed, but shown in an open column because the board has no closing + // column. + showsClosed: boolean; + }) + | (ItemBase & { kind: "patch"; patch: Patch }) + | (ItemBase & { kind: "unresolved" }); + +export type IssueItem = Extract; + +export type ForeignCard = + | { kind: "issue"; issue: Issue } + | { kind: "patch"; patch: Patch } + | { kind: "unresolved" }; + +type Unpositioned = BoardItem extends infer T + ? T extends BoardItem + ? Omit + : never + : never; + +export function cardKey(card: Card): string { + return `${card.rid}/${card.typeName}/${card.oid}`; +} + +export function cardFor(rid: string, typeName: string, oid: string): Card { + return { rid, typeName, oid }; +} + +export function itemTitle(item: Unpositioned): string { + if (item.kind === "issue") return item.issue.title; + if (item.kind === "patch") return item.patch.title; + return "Unresolved card"; +} + +export function itemLabels(item: Unpositioned): string[] { + if (item.kind === "issue") return item.issue.labels; + if (item.kind === "patch") return item.patch.labels; + return []; +} + +export function itemTimestamp(item: Unpositioned): number { + if (item.kind === "issue") return item.issue.timestamp; + if (item.kind === "patch") return item.patch.timestamp; + return item.placement?.movedAt ?? 0; +} + +export function itemAssignees(item: Unpositioned): Author[] { + if (item.kind === "issue") return item.issue.assignees; + if (item.kind === "patch") return item.patch.assignees; + return []; +} + +export function itemComments(item: Unpositioned): number { + if (item.kind === "issue") return item.issue.commentCount; + if (item.kind === "patch") return item.patch.commentCount; + return 0; +} + +function openColumn(columns: Column[]): string { + return (columns.find(c => !c.closes) ?? columns[0]).id; +} + +function closingColumn( + columns: Column[], + reason: "solved" | "other", + placed: Column | undefined, +): string | undefined { + if (placed?.closes === reason) return placed.id; + return (columns.find(c => c.closes === reason) ?? columns.find(c => c.closes)) + ?.id; +} + +function hasColumn(columns: Column[], id: string): boolean { + return columns.some(c => c.id === id); +} + +// The issue's state decides where it shows: the stored placement counts only +// while it agrees with the state. +export function issueColumn( + issue: Issue, + patches: Patch[], + placement: Placement | undefined, + columns: Column[], +): { column: string; showsClosed: boolean } { + const placed = columns.find(c => c.id === placement?.column); + if (issue.state.status === "closed") { + const column = closingColumn(columns, issue.state.reason, placed); + return column + ? { column, showsClosed: false } + : { column: openColumn(columns), showsClosed: true }; + } + if (placed && !placed.closes) { + return { column: placed.id, showsClosed: false }; + } + if (!placed) { + if ( + patches.some(p => p.state.status === "open") && + hasColumn(columns, "in-review") + ) { + return { column: "in-review", showsClosed: false }; + } + if ( + patches.some(p => p.state.status === "draft") && + hasColumn(columns, "in-progress") + ) { + return { column: "in-progress", showsClosed: false }; + } + } + return { column: openColumn(columns), showsClosed: false }; +} + +export function patchColumn( + patch: Patch, + placement: Placement | undefined, + columns: Column[], +): string { + const placed = columns.find(c => c.id === placement?.column); + if (patch.state.status === "merged") { + return closingColumn(columns, "solved", placed) ?? openColumn(columns); + } + if (patch.state.status === "archived") { + return closingColumn(columns, "other", placed) ?? openColumn(columns); + } + return placed && !placed.closes ? placed.id : openColumn(columns); +} + +function stateFor(column: Column): State { + return column.closes + ? { status: "closed", reason: column.closes } + : { status: "open" }; +} + +// The state only changes when the move crosses between open and closing +// columns, or between closing columns with different reasons, as seen from +// the column the card is shown in. Reordering a closed issue shown in an open +// column never rewrites it. +export function stateAfterMove( + item: IssueItem, + target: Column, + columns: Column[], +): State { + const from = columns.find(c => c.id === item.column); + return from?.closes === target.closes ? item.issue.state : stateFor(target); +} + +// Mirrors the evaluator's rule for closing columns, and on top of that +// refuses drops after which the card wouldn't show in `target`. +export function accepts( + item: BoardItem, + target: Column, + columns: Column[], + canClose: boolean, + rid: string, +): boolean { + return refusal(item, target, columns, canClose, rid) === undefined; +} + +// Why `item` can't be dropped in `target`, or `undefined` if it can. +export function refusal( + item: BoardItem, + target: Column, + columns: Column[], + canClose: boolean, + rid: string, +): string | undefined { + if (item.kind === "unresolved") return undefined; + if (item.kind === "patch" && item.patch.state.status === "merged") { + return "Merged patches can't be moved"; + } + if (item.kind === "patch" && item.patch.state.status === "archived") { + return "Archived patches can't be moved"; + } + if (target.closes && item.kind === "patch") { + return "Patches can't go in a column that closes issues"; + } + const placed: Placement = { + card: item.card, + column: target.id, + position: "", + movedBy: { did: "" }, + movedAt: 0, + }; + if (item.kind === "patch") { + return patchColumn(item.patch, placed, columns) === target.id + ? undefined + : "This patch can't go in this column"; + } + if (!canClose) { + // Mirrors the evaluator: others may only move a card between columns + // that do the same thing to issues, judged by where the card is stored. + const stored = columns.find(c => c.id === item.placement?.column)?.closes; + const shown = columns.find(c => c.id === item.column)?.closes; + if (target.closes && !stored) { + return "Only delegates and the author can close this issue"; + } + if (target.closes && stored !== target.closes) { + return "Only delegates and the author can change how this issue was closed"; + } + if (!target.closes && (stored || shown)) { + return "Only delegates and the author can reopen this issue"; + } + } + const foreign = item.card.rid !== rid; + const state = + canClose && !foreign + ? stateAfterMove(item, target, columns) + : item.issue.state; + if ( + issueColumn({ ...item.issue, state }, item.patches, placed, columns) + .column === target.id + ) { + return undefined; + } + return foreign + ? "Issues from other repositories can't be closed or reopened here" + : "This issue can't go in this column"; +} + +const DIGITS = "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz"; +const TIME_WIDTH = 8; +const TIME_MAX = DIGITS.length ** TIME_WIDTH - 1; + +function base62(value: number, width: number): string { + let out = ""; + let rest = Math.max(0, Math.floor(value)); + for (let i = 0; i < width; i++) { + out = DIGITS[rest % DIGITS.length] + out; + rest = Math.floor(rest / DIGITS.length); + } + return out; +} + +// A position for a card nobody has placed in this column: by priority, then +// newest first, then card. Every peer derives the same one, so a card dropped +// between two unplaced cards gets a stored position that keeps it there. +function derivedPosition(item: Unpositioned): string { + const labels = itemLabels(item); + const timestamp = itemTimestamp(item); + const rank = DIGITS[1 + PRIORITY_RANK[priorityOf(labels)]]; + return `U${rank}${base62(TIME_MAX - timestamp, TIME_WIDTH)}${item.card.oid.slice(0, 8)}1`; +} + +export function groupItems( + rid: string, + board: Board | undefined, + issues: Issue[], + patches: Patch[], + links: PatchLink[], + foreign: Map = new Map(), +): Record { + const columns = board?.columns ?? DEFAULT_COLUMNS; + const placements = new Map(); + for (const placement of board?.cards ?? []) { + placements.set(cardKey(placement.card), placement); + } + + const linkedPatches = new Set(); + const patchesByIssue = new Map(); + const patchById = new Map(patches.map(p => [p.id, p])); + const issueIds = new Set(issues.map(i => i.id)); + for (const link of links) { + const patch = patchById.get(link.patch); + if (!patch) continue; + for (const issue of link.issues.filter(id => issueIds.has(id))) { + linkedPatches.add(patch.id); + patchesByIssue.set(issue, [...(patchesByIssue.get(issue) ?? []), patch]); + } + } + + const items: Unpositioned[] = []; + for (const issue of issues) { + const card = cardFor(rid, ISSUE_TYPE, issue.id); + const key = cardKey(card); + const placement = placements.get(key); + const linked = patchesByIssue.get(issue.id) ?? []; + items.push({ + kind: "issue", + key, + card, + placement, + issue, + patches: linked, + ...issueColumn(issue, linked, placement, columns), + }); + } + for (const patch of patches) { + if (linkedPatches.has(patch.id)) continue; + const card = cardFor(rid, PATCH_TYPE, patch.id); + const key = cardKey(card); + const placement = placements.get(key); + items.push({ + kind: "patch", + key, + card, + placement, + patch, + column: patchColumn(patch, placement, columns), + }); + } + + for (const placement of board?.cards ?? []) { + if (placement.card.rid === rid) continue; + const key = cardKey(placement.card); + const resolved = foreign.get(key) ?? { kind: "unresolved" }; + const base = { key, card: placement.card, placement }; + if (resolved.kind === "issue") { + items.push({ + ...base, + kind: "issue", + issue: resolved.issue, + patches: [], + ...issueColumn(resolved.issue, [], placement, columns), + }); + } else if (resolved.kind === "patch") { + items.push({ + ...base, + kind: "patch", + patch: resolved.patch, + column: patchColumn(resolved.patch, placement, columns), + }); + } else { + items.push({ + ...base, + kind: "unresolved", + column: columns.some(c => c.id === placement.column) + ? placement.column + : openColumn(columns), + }); + } + } + + const groups: Record = Object.fromEntries( + columns.map(c => [c.id, []]), + ); + for (const item of items) { + const position = + item.placement?.column === item.column + ? item.placement.position + : derivedPosition(item); + groups[item.column].push({ ...item, position } as BoardItem); + } + for (const list of Object.values(groups)) { + list.sort((a, b) => + a.position < b.position + ? -1 + : a.position > b.position + ? 1 + : a.key < b.key + ? -1 + : a.key > b.key + ? 1 + : 0, + ); + } + return groups; +} + +export interface Move { + item: BoardItem; + column: string; + index: number; +} + +export function applyMove( + groups: Record, + move: Move | undefined, +): Record { + if (!move) return groups; + const result = Object.fromEntries( + Object.entries(groups).map(([id, list]) => [ + id, + list.filter(i => i.key !== move.item.key), + ]), + ); + const target = [...(result[move.column] ?? [])]; + target.splice(Math.min(move.index, target.length), 0, move.item); + result[move.column] = target; + return result; +} + +// Mirrors `radicle_board::position::is_valid`. +function isValidPosition(position: string): boolean { + return ( + position.length > 0 && + [...position].every(c => DIGITS.includes(c)) && + !position.endsWith("0") + ); +} + +// Mirrors `radicle_board::position::between`, including giving up at the +// length limit. +export function between(before?: string, after?: string): string | undefined { + if (before !== undefined && !isValidPosition(before)) return undefined; + if (after !== undefined && !isValidPosition(after)) return undefined; + if (before !== undefined && after !== undefined && before >= after) { + return undefined; + } + + const low = before ?? ""; + let high = after; + let key = ""; + for (let i = 0; i < MAX_POSITION_LENGTH; i++) { + const lo = i < low.length ? DIGITS.indexOf(low[i]) : 0; + const hi = + high === undefined + ? DIGITS.length + : i < high.length + ? DIGITS.indexOf(high[i]) + : 0; + if (hi > lo + 1) { + return key + DIGITS[Math.floor((lo + hi) / 2)]; + } + key += DIGITS[lo]; + if (hi === lo + 1) high = undefined; + } + return undefined; +} + +export interface Bounds { + before?: string; + after?: string; +} + +// Mirrors `radicle_board::position::neighbours`. +export function neighbourPositions(list: BoardItem[], index: number): Bounds { + const before = list[index - 1]?.position; + const after = list[index]?.position; + if (before !== undefined && after !== undefined && before >= after) { + return { + before, + after: list.slice(index).find(i => i.position > before)?.position, + }; + } + return { before, after }; +} + +// The position has no value when the keys around `index` are packed to the +// length limit; the card then stays where it was rather than going elsewhere. +export function dropPosition( + list: BoardItem[], + index: number, +): { bounds: Bounds; position?: string } { + const bounds = neighbourPositions(list, index); + return { bounds, position: between(bounds.before, bounds.after) }; +} + +export function isMe(did: string, publicKey: string): boolean { + return publicKeyFromDid(did) === publicKey; +} + +export interface QueuedChange { + rid: string; + board: string; + opts: { announce: boolean }; + card: Card; + change: + { kind: "move"; column: string; position: string } | { kind: "remove" }; + // The state the card's issue moves to with it, if it changes. + state?: State; +} + +export function sameState(a: State, b: State): boolean { + return ( + a.status === b.status && + (a.status !== "closed" || (b.status === "closed" && a.reason === b.reason)) + ); +} + +export function overlayBoard( + board: Board | undefined, + queued: QueuedChange[], + movedBy: string, + movedAt: number, +): Board | undefined { + if (!board) return board; + const entries = queued.filter(q => q.board === board.id); + if (entries.length === 0) return board; + const keys = new Set(entries.map(q => cardKey(q.card))); + const cards = board.cards.filter(p => !keys.has(cardKey(p.card))); + for (const q of entries) { + if (q.change.kind !== "move") continue; + cards.push({ + card: q.card, + column: q.change.column, + position: q.change.position, + movedBy: { did: movedBy }, + movedAt, + }); + } + return { ...board, cards }; +} + +// Later entries win, so pass changes being written before the ones still +// queued. +export function overlayIssues( + rid: string, + issues: Issue[], + queued: QueuedChange[], +): Issue[] { + const states = new Map( + queued + .filter(q => q.state && q.card.rid === rid) + .map(q => [q.card.oid, q.state!]), + ); + if (states.size === 0) return issues; + return issues.map(i => { + const state = states.get(i.id); + return state ? { ...i, state } : i; + }); +} + +// Where the card stands as far as new changes are concerned: where the change +// being written puts it, otherwise where the committed board has it. +export function baselinePlacement( + board: Board | undefined, + card: Card, + inFlight: QueuedChange | undefined, +): { column: string; position: string } | undefined { + const key = cardKey(card); + if ( + inFlight && + cardKey(inFlight.card) === key && + inFlight.board === board?.id + ) { + return inFlight.change.kind === "move" ? inFlight.change : undefined; + } + return board?.cards.find(p => cardKey(p.card) === key); +} + +// `bounds` are the positions the card was dropped between; without a manual +// ordering any spot in the column will do. +export function isUnchanged( + entry: QueuedChange, + placed: { column: string; position: string } | undefined, + manual: boolean, + bounds: Bounds = {}, +): boolean { + const change = entry.change; + if (change.kind === "remove") return placed === undefined; + return ( + placed !== undefined && + placed.column === change.column && + (!manual || + ((bounds.before === undefined || bounds.before < placed.position) && + (bounds.after === undefined || placed.position < bounds.after))) && + entry.state === undefined + ); +} + +export interface Batch { + rid: string; + board: string; + opts: { announce: boolean }; + entries: QueuedChange[]; + moves: { card: Card; column: string; position: string }[]; + removes: Card[]; +} + +export function batches(queued: QueuedChange[]): Batch[] { + const result: Batch[] = []; + for (const entry of queued) { + let batch = result.find( + b => b.rid === entry.rid && b.board === entry.board, + ); + if (!batch) { + batch = { + rid: entry.rid, + board: entry.board, + opts: entry.opts, + entries: [], + moves: [], + removes: [], + }; + result.push(batch); + } + batch.entries.push(entry); + if (entry.change.kind === "move") { + batch.moves.push({ + card: entry.card, + column: entry.change.column, + position: entry.change.position, + }); + } else { + batch.removes.push(entry.card); + } + } + return result; +} diff --git a/desktop/src/lib/boardActions.ts b/desktop/src/lib/boardActions.ts new file mode 100644 index 000000000..f4c6b6d96 --- /dev/null +++ b/desktop/src/lib/boardActions.ts @@ -0,0 +1,44 @@ +import type { Board } from "@bindings/cob/board/Board"; + +import { get } from "svelte/store"; + +import { nodeRunning } from "@app/lib/events"; +import { invoke } from "@app/lib/invoke"; +import { show } from "@app/lib/modal"; +import * as router from "@app/lib/router"; + +import { announce } from "@app/components/AnnounceSwitch.svelte"; +import CreateBoard from "@app/modals/CreateBoard.svelte"; + +export const DEFAULT_BOARD_NAME = "Board"; + +// A board is only ever created on request, and after confirmation. The +// first board is simply "Board"; later ones are numbered from the count. +export async function promptNewBoard(rid: string, repoName: string) { + const boards = await invoke("list_boards", { rid }); + const opts = () => ({ announce: get(nodeRunning) && get(announce) }); + + show({ + component: CreateBoard, + props: { + title: boards.length === 0 ? "Create board" : "New board", + repoName, + name: + boards.length === 0 + ? DEFAULT_BOARD_NAME + : `${DEFAULT_BOARD_NAME} ${boards.length + 1}`, + confirm: async (name: string) => { + const created = await invoke("create_board", { + rid, + name, + opts: opts(), + }); + await router.push({ + resource: "repo.board", + rid, + board: created.id, + }); + }, + }, + }); +} diff --git a/desktop/src/lib/boardDrag.svelte.ts b/desktop/src/lib/boardDrag.svelte.ts new file mode 100644 index 000000000..cf669dc4f --- /dev/null +++ b/desktop/src/lib/boardDrag.svelte.ts @@ -0,0 +1,413 @@ +import type { Column } from "@bindings/cob/board/Column"; + +import { tick } from "svelte"; + +import type { BoardItem } from "@app/lib/board"; + +const DRAG_THRESHOLD = 4; +const LANDING_MS = 160; +const NUDGE_MAX = 24; + +export interface DragHost { + // The element the cards are dropped into; its columns carry `data-column` + // and its cards `data-key`. + readonly container: HTMLElement | undefined; + // Columns side by side, else stacked groups. + readonly horizontal: boolean; + readonly draggable: boolean; + readonly columns: Column[]; + readonly canReorderColumns: boolean; + canMove(item: BoardItem): boolean; + canDrop(item: BoardItem, column: string): boolean; + sameSpot(item: BoardItem, column: string, index: number): boolean; + drop(item: BoardItem, column: string, index: number): void; + reorderColumns(columns: Column[]): void; +} + +interface Grab { + item: BoardItem; + offsetX: number; + offsetY: number; + width: number; + height: number; +} + +export type Drag = Grab & { + over: string | undefined; + index: number | undefined; + // The column under the pointer when it won't take the card. + refused: string | undefined; +}; + +interface LockedGrab { + item: BoardItem; + x: number; + y: number; + left: number; + top: number; + width: number; +} + +export type Nudge = LockedGrab & { dx: number; dy: number; releasing: boolean }; + +interface ColumnGrab { + id: string; + x: number; + active: boolean; +} + +function layoutRect(el: Element): { x: number; y: number; rect: DOMRect } { + const rect = el.getBoundingClientRect(); + const matrix = new DOMMatrix(getComputedStyle(el).transform); + return { x: rect.left - matrix.m41, y: rect.top - matrix.m42, rect }; +} + +function rubber(distance: number): number { + return ( + Math.sign(distance) * + NUDGE_MAX * + (1 - Math.exp(-Math.abs(distance) / (NUDGE_MAX * 3))) + ); +} + +// Cards are dragged with a floating copy; a card the viewer can't move +// follows the pointer a little and springs back instead. Column titles drag +// the column among the visible ones. +export class BoardDrag { + #host: DragHost; + #pending: (Grab & { x: number; y: number }) | undefined; + #drag: Drag | undefined = $state(); + #pointer = $state({ x: 0, y: 0 }); + #landing: { x: number; y: number } | undefined = $state(); + #suppressClick = false; + #scrollFrame: number | undefined; + #lockedGrab: LockedGrab | undefined; + #nudge: Nudge | undefined = $state(); + #columnGrab: ColumnGrab | undefined = $state(); + #columnPreview: Column[] | undefined = $state(); + + constructor(host: DragHost) { + this.#host = host; + } + + get current(): Drag | undefined { + return this.#drag; + } + + get pointer(): { x: number; y: number } { + return this.#pointer; + } + + get landing(): { x: number; y: number } | undefined { + return this.#landing; + } + + get nudge(): Nudge | undefined { + return this.#nudge; + } + + get columnPreview(): Column[] | undefined { + return this.#columnPreview; + } + + get liftedColumn(): string | undefined { + return this.#columnPreview && this.#columnGrab?.id; + } + + // Whether the click that ends a gesture should be ignored. + get suppressClick(): boolean { + return this.#suppressClick; + } + + #swallowNextClick() { + this.#suppressClick = true; + setTimeout(() => (this.#suppressClick = false)); + } + + onCardPointerDown(event: PointerEvent, item: BoardItem) { + if (event.button !== 0 || this.#landing || !this.#host.draggable) return; + const rect = (event.currentTarget as HTMLElement).getBoundingClientRect(); + if (!this.#host.canMove(item)) { + this.#lockedGrab = { + item, + x: event.clientX, + y: event.clientY, + left: rect.left, + top: rect.top, + width: rect.width, + }; + return; + } + this.#pending = { + item, + x: event.clientX, + y: event.clientY, + offsetX: event.clientX - rect.left, + offsetY: event.clientY - rect.top, + width: rect.width, + height: rect.height, + }; + } + + onTitlePointerDown(event: PointerEvent, column: Column) { + if (!this.#host.canReorderColumns || event.button !== 0) return; + this.#columnGrab = { id: column.id, x: event.clientX, active: false }; + } + + onPointerMove(event: PointerEvent) { + if (this.#moveColumn(event)) return; + if (this.#lockedGrab) { + const dx = event.clientX - this.#lockedGrab.x; + const dy = event.clientY - this.#lockedGrab.y; + if (this.#nudge || Math.hypot(dx, dy) > DRAG_THRESHOLD) { + this.#nudge = { + ...this.#lockedGrab, + dx: rubber(dx), + dy: rubber(dy), + releasing: false, + }; + } + return; + } + if (this.#landing) return; + this.#pointer = { x: event.clientX, y: event.clientY }; + if (this.#drag) { + this.#updateOver(); + if (this.#scrollFrame === undefined) { + this.#scrollFrame = requestAnimationFrame(() => this.#autoScroll()); + } + } else if ( + this.#pending && + Math.hypot( + event.clientX - this.#pending.x, + event.clientY - this.#pending.y, + ) > DRAG_THRESHOLD + ) { + this.#drag = { + ...this.#pending, + over: undefined, + index: undefined, + refused: undefined, + }; + this.#pending = undefined; + this.#updateOver(); + } + } + + onPointerUp() { + if (this.#endColumn(false)) return; + this.#releaseNudge(); + this.#pending = undefined; + void this.#land(false); + } + + onPointerCancel() { + this.#releaseNudge(); + this.#pending = undefined; + if (!this.#landing) this.#drag = undefined; + } + + // Whether an Escape press ended a gesture. + onEscape(event: KeyboardEvent): boolean { + if (this.#columnGrab) { + event.preventDefault(); + this.#endColumn(true); + return true; + } + if (this.#nudge) { + this.#releaseNudge(); + return true; + } + if (this.#drag || this.#pending) { + event.preventDefault(); + event.stopPropagation(); + this.#pending = undefined; + void this.#land(true); + return true; + } + return false; + } + + #releaseNudge() { + this.#lockedGrab = undefined; + if (!this.#nudge) return; + this.#swallowNextClick(); + const key = this.#nudge.item.key; + this.#nudge = { ...this.#nudge, dx: 0, dy: 0, releasing: true }; + setTimeout(() => { + if (this.#nudge?.item.key === key && this.#nudge.releasing) { + this.#nudge = undefined; + } + }, 260); + } + + #columnAt(x: number, y: number): string | undefined { + const container = this.#host.container; + const bounds = container?.getBoundingClientRect(); + if (!container || !bounds || y < bounds.top || y > bounds.bottom) { + return undefined; + } + let nearest: { column: string; distance: number } | undefined; + for (const el of container.querySelectorAll("[data-column]")) { + const rect = el.getBoundingClientRect(); + const [pos, start, end] = this.#host.horizontal + ? [x, rect.left, rect.right] + : [y, rect.top, rect.bottom]; + const distance = pos < start ? start - pos : pos > end ? pos - end : 0; + if (!nearest || distance < nearest.distance) { + nearest = { column: el.dataset.column!, distance }; + } + } + return nearest?.column; + } + + // The number of other cards in `column` whose middle is above the pointer, + // ignoring any flip animation still moving them. + #indexAt(column: string, y: number, key: string): number { + const el = this.#host.container?.querySelector( + `[data-column="${CSS.escape(column)}"]`, + ); + if (!el) return 0; + let index = 0; + for (const card of el.querySelectorAll("[data-key]")) { + if (card.dataset.key === key) continue; + const { y: top, rect } = layoutRect(card); + if (top + rect.height / 2 < y) index++; + } + return index; + } + + // Only reassigning on a change keeps the columns from re-rendering on every + // pointer move, which would restart the cards' flip animations mid-flight. + #updateOver() { + if (!this.#drag) return; + const column = this.#columnAt(this.#pointer.x, this.#pointer.y); + const allowed = + column !== undefined && this.#host.canDrop(this.#drag.item, column); + const over = allowed ? column : undefined; + const refused = allowed ? undefined : column; + const index = + over === undefined + ? undefined + : this.#indexAt(over, this.#pointer.y, this.#drag.item.key); + if ( + over !== this.#drag.over || + index !== this.#drag.index || + refused !== this.#drag.refused + ) { + this.#drag = { ...this.#drag, over, index, refused }; + } + } + + #autoScroll() { + this.#scrollFrame = undefined; + const container = this.#host.container; + if (!this.#drag || this.#landing || !container || !this.#host.horizontal) { + return; + } + const rect = container.getBoundingClientRect(); + const edge = 80; + const speed = + this.#pointer.x > rect.right - edge + ? 12 + : this.#pointer.x < rect.left + edge + ? -12 + : 0; + if (speed === 0) return; + container.scrollLeft += speed; + this.#updateOver(); + this.#scrollFrame = requestAnimationFrame(() => this.#autoScroll()); + } + + // The column a card just left keeps its placeholder while it fades out, so + // the slot is looked up in the column the card lands in. + #slotPosition(): { x: number; y: number } | undefined { + if (!this.#drag) return undefined; + const column = this.#drag.over ?? this.#drag.item.column; + const el = this.#host.container?.querySelector( + `[data-column="${CSS.escape(column)}"] .placeholder`, + ); + if (!el) return undefined; + const { x, y } = layoutRect(el); + return { x, y }; + } + + async #land(cancel: boolean) { + if (!this.#drag || this.#landing) return; + this.#swallowNextClick(); + + if (cancel) { + this.#drag = { + ...this.#drag, + over: undefined, + index: undefined, + refused: undefined, + }; + await tick(); + } + const slot = this.#slotPosition(); + if (slot) { + this.#landing = slot; + await new Promise(resolve => setTimeout(resolve, LANDING_MS)); + } + + const { item, over, index } = this.#drag; + if ( + !cancel && + over !== undefined && + index !== undefined && + !this.#host.sameSpot(item, over, index) + ) { + this.#host.drop(item, over, index); + } + this.#drag = undefined; + this.#landing = undefined; + } + + // Moves the dragged column among the visible ones as the pointer crosses + // their middles; hidden columns keep their place in the full order. + #moveColumn(event: PointerEvent): boolean { + const grab = this.#columnGrab; + if (!grab) return false; + const columns = this.#host.columns; + if (!grab.active) { + if (Math.abs(event.clientX - grab.x) < DRAG_THRESHOLD) return true; + grab.active = true; + this.#columnPreview = [...columns]; + } + const others = [ + ...(this.#host.container?.querySelectorAll( + "[data-column]", + ) ?? []), + ].filter(el => el.dataset.column !== grab.id); + let index = 0; + for (const el of others) { + const { x, rect } = layoutRect(el); + if (x + rect.width / 2 < event.clientX) index++; + } + const order = (this.#columnPreview ?? columns).filter( + c => c.id !== grab.id, + ); + const dragged = columns.find(c => c.id === grab.id)!; + const anchor = others[index - 1]?.dataset.column; + const at = anchor ? order.findIndex(c => c.id === anchor) + 1 : 0; + const next = [...order.slice(0, at), dragged, ...order.slice(at)]; + if (next.some((c, i) => c.id !== this.#columnPreview?.[i]?.id)) { + this.#columnPreview = next; + } + return true; + } + + #endColumn(cancel: boolean): boolean { + if (!this.#columnGrab) return false; + const { active } = this.#columnGrab; + this.#columnGrab = undefined; + const next = this.#columnPreview; + this.#columnPreview = undefined; + if (active) { + this.#swallowNextClick(); + if (!cancel && next) this.#host.reorderColumns(next); + } + return true; + } +} diff --git a/desktop/src/lib/boardQueue.svelte.ts b/desktop/src/lib/boardQueue.svelte.ts new file mode 100644 index 000000000..0c886a80a --- /dev/null +++ b/desktop/src/lib/boardQueue.svelte.ts @@ -0,0 +1,165 @@ +import type { Board } from "@bindings/cob/board/Board"; +import type { Card } from "@bindings/cob/board/Card"; +import type { State } from "@bindings/cob/issue/State"; + +import { SvelteMap } from "svelte/reactivity"; + +import type { Batch, Bounds, QueuedChange } from "@app/lib/board"; +import { + baselinePlacement, + batches, + cardKey, + isUnchanged, +} from "@app/lib/board"; + +export const FLUSH_DELAY_MS = 2500; + +export interface BoardWriter { + // Writes a batch as one operation and returns the cards whose changes the + // backend rejected. + update(batch: Batch, announce: boolean): Promise; + setState( + rid: string, + oid: string, + state: State, + announce: boolean, + ): Promise; + // The committed board or issues of `rid` changed underneath the queue. + reload(rid: string): Promise; + error(message: string): void; +} + +function errorMessage(error: unknown): string { + return error instanceof Error ? error.message : String(error); +} + +// Changes wait until nothing has changed for a moment, so a burst becomes one +// operation and one update of the signed refs. Queued changes are shown on +// top of the committed board until written; entries being written keep their +// issue state until the issue's own write lands. +export class BoardQueue { + #queued = new SvelteMap(); + #writing = new SvelteMap(); + #writer: BoardWriter; + #delay: number; + #timer: ReturnType | undefined; + #flushing: Promise = Promise.resolve(); + + constructor(writer: BoardWriter, delay = FLUSH_DELAY_MS) { + this.#writer = writer; + this.#delay = delay; + } + + get queued(): QueuedChange[] { + return [...this.#queued.values()]; + } + + get writing(): QueuedChange[] { + return [...this.#writing.values()]; + } + + inFlight(key: string): QueuedChange | undefined { + return this.#writing.get(key); + } + + // A change that leaves the card where it stands, such as a card moved away + // and back, drops the queued change instead of adding one. + enqueue( + entry: QueuedChange, + committed: Board | undefined, + manual: boolean, + bounds?: Bounds, + ) { + const key = cardKey(entry.card); + const placed = baselinePlacement( + committed, + entry.card, + this.#writing.get(key), + ); + if (isUnchanged(entry, placed, manual, bounds)) { + this.#queued.delete(key); + } else { + this.#queued.set(key, entry); + } + clearTimeout(this.#timer); + this.#timer = setTimeout(() => void this.flush(), this.#delay); + } + + flush(announce = true): Promise { + clearTimeout(this.#timer); + this.#timer = undefined; + const write = () => this.#writeQueued(announce); + this.#flushing = this.#flushing.then(write, write); + return this.#flushing; + } + + // Entries changed again while they were being written stay queued. + #settle(entries: QueuedChange[]) { + for (const entry of entries) { + const key = cardKey(entry.card); + if (this.#queued.get(key) === entry) this.#queued.delete(key); + } + } + + async #writeQueued(announce: boolean) { + for (const batch of batches(this.queued)) { + const { rid, entries } = batch; + for (const entry of entries) + this.#writing.set(cardKey(entry.card), entry); + const announcing = announce && batch.opts.announce; + + try { + let rejected: string[]; + try { + const cards = await this.#writer.update(batch, announcing); + rejected = cards.map(cardKey); + if (cards.length === 1) { + this.#writer.error( + "One move wasn't saved because you don't have permission.", + ); + } else if (cards.length > 1) { + this.#writer.error( + `${cards.length} moves weren't saved because you don't have permission.`, + ); + } + } catch (error) { + console.error("Saving the board failed", error); + this.#writer.error( + `The board couldn't be saved: ${errorMessage(error)}`, + ); + this.#settle(entries); + await this.#writer.reload(rid); + continue; + } + this.#settle(entries); + + const lifecycle = entries.filter( + e => e.state && !rejected.includes(cardKey(e.card)), + ); + let changed = false; + for (const [index, entry] of lifecycle.entries()) { + try { + await this.#writer.setState( + rid, + entry.card.oid, + entry.state!, + announcing && index === lifecycle.length - 1, + ); + changed = true; + } catch (error) { + console.error("Changing the issue's state failed", error); + this.#writer.error( + `An issue's state couldn't be changed: ${errorMessage(error)}`, + ); + } + } + if (changed || rejected.length > 0) await this.#writer.reload(rid); + } finally { + for (const entry of entries) { + const key = cardKey(entry.card); + if (this.#writing.get(key) === entry) this.#writing.delete(key); + } + } + } + } +} diff --git a/desktop/src/lib/boardView.ts b/desktop/src/lib/boardView.ts new file mode 100644 index 000000000..c158c92b7 --- /dev/null +++ b/desktop/src/lib/boardView.ts @@ -0,0 +1,342 @@ +import type { Column } from "@bindings/cob/board/Column"; +import type { Issue } from "@bindings/cob/issue/Issue"; +import type { Patch } from "@bindings/cob/patch/Patch"; + +import { z } from "zod"; + +import type { BoardItem } from "@app/lib/board"; +import { + displayLabels, + itemAssignees, + itemLabels, + itemTimestamp, + itemTitle, + PRIORITIES, + PRIORITY_RANK, + priorityOf, +} from "@app/lib/board"; + +// How a viewer arranges and filters a board. Kept per board in this browser +// only. + +export const PROPERTIES = [ + { id: "id", label: "ID" }, + { id: "priority", label: "Priority" }, + { id: "assignees", label: "Assignees" }, + { id: "labels", label: "Labels" }, + { id: "created", label: "Created" }, + { id: "patches", label: "Patches" }, + { id: "comments", label: "Comments" }, + { id: "repository", label: "Repository" }, +] as const; + +export type Property = (typeof PROPERTIES)[number]["id"]; + +const propertySchema = z.enum([ + "id", + "priority", + "assignees", + "labels", + "created", + "patches", + "comments", + "repository", +]); + +export const GROUPINGS = [ + { id: "status", label: "Status" }, + { id: "assignee", label: "Assignee" }, + { id: "priority", label: "Priority" }, + { id: "label", label: "Label" }, + { id: "none", label: "No grouping" }, +] as const; + +export const ORDERINGS = [ + { id: "manual", label: "Manual" }, + { id: "priority", label: "Priority" }, + { id: "created", label: "Created" }, + { id: "title", label: "Title" }, +] as const; + +export type Grouping = (typeof GROUPINGS)[number]["id"]; +export type Ordering = (typeof ORDERINGS)[number]["id"]; + +const filtersSchema = z.object({ + types: z.array(z.enum(["issue", "patch"])), + assignees: z.array(z.string()), + priorities: z.array(z.string()), + labels: z.array(z.string()), +}); + +export type BoardFilters = z.infer; + +const schema = z.object({ + grouping: z.enum(["status", "assignee", "priority", "label", "none"]), + ordering: z.enum(["manual", "priority", "created", "title"]), + descending: z.boolean(), + showCompleted: z.boolean(), + showCanceled: z.boolean(), + showEmpty: z.boolean(), + properties: z.array(propertySchema), + filters: filtersSchema, + view: z.enum(["board", "list"]), +}); + +export type BoardViewSettings = z.infer; + +export function defaultView(): BoardViewSettings { + return { + grouping: "status", + ordering: "manual", + descending: false, + showCompleted: true, + showCanceled: false, + showEmpty: true, + properties: [ + "id", + "priority", + "assignees", + "labels", + "created", + "patches", + "comments", + "repository", + ], + filters: { types: [], assignees: [], priorities: [], labels: [] }, + view: "board", + }; +} + +const KEY = "boardView"; + +function readAll(): Record { + try { + const raw = globalThis.localStorage?.getItem(KEY); + const parsed: unknown = raw ? JSON.parse(raw) : {}; + return parsed && typeof parsed === "object" + ? (parsed as Record) + : {}; + } catch { + return {}; + } +} + +function keyOf(rid: string, board: string | undefined): string { + return board ? `${rid}/${board}` : rid; +} + +// A board with nothing saved yet starts from what was saved for its +// repository, which is where settings lived before boards had their own. +export function loadView( + rid: string, + board: string | undefined, +): BoardViewSettings { + const all = readAll(); + const parsed = schema.partial().safeParse(all[keyOf(rid, board)] ?? all[rid]); + return parsed.success ? { ...defaultView(), ...parsed.data } : defaultView(); +} + +export function saveView( + rid: string, + board: string | undefined, + settings: BoardViewSettings, +) { + try { + globalThis.localStorage?.setItem( + KEY, + JSON.stringify({ ...readAll(), [keyOf(rid, board)]: settings }), + ); + } catch { + // Storage can be unavailable; the view still works for this session. + } +} + +export function activeFilterCount(filters: BoardFilters): number { + return ( + filters.types.length + + filters.assignees.length + + filters.priorities.length + + filters.labels.length + ); +} + +export function matchesFilters( + item: BoardItem, + filters: BoardFilters, + search: string, +): boolean { + const assignees = itemAssignees(item); + const { types, assignees: people, priorities, labels } = filters; + if ( + types.length > 0 && + (item.kind === "unresolved" || !types.includes(item.kind)) + ) { + return false; + } + if ( + people.length > 0 && + !( + (people.includes("none") && assignees.length === 0) || + assignees.some(a => people.includes(a.did)) + ) + ) { + return false; + } + if ( + priorities.length > 0 && + !priorities.includes(priorityOf(itemLabels(item))) + ) { + return false; + } + if ( + labels.length > 0 && + !displayLabels(itemLabels(item)).some(l => labels.includes(l)) + ) { + return false; + } + const query = search.trim().toLowerCase(); + if (!query) return true; + return ( + itemTitle(item).toLowerCase().includes(query) || + item.card.oid.startsWith(query) || + itemLabels(item).some(l => l.toLowerCase().includes(query)) || + assignees.some(a => a.alias?.toLowerCase().includes(query)) + ); +} + +export function compareItems( + a: BoardItem, + b: BoardItem, + ordering: Ordering, + descending: boolean, +): number { + let order = 0; + if (ordering === "priority") { + order = + PRIORITY_RANK[priorityOf(itemLabels(a))] - + PRIORITY_RANK[priorityOf(itemLabels(b))] || + itemTimestamp(b) - itemTimestamp(a); + } else if (ordering === "created") { + order = itemTimestamp(b) - itemTimestamp(a); + } else if (ordering === "title") { + order = itemTitle(a).localeCompare(itemTitle(b)); + } + return descending ? -order : order; +} + +// Board order, or the chosen ordering on top of it. +export function arrange( + groups: Record, + ordering: Ordering, + descending: boolean, +): Record { + if (ordering === "manual") return groups; + return Object.fromEntries( + Object.entries(groups).map(([id, list]) => [ + id, + [...list].sort((a, b) => compareItems(a, b, ordering, descending)), + ]), + ); +} + +export function visibleColumns( + columns: Column[], + settings: Pick< + BoardViewSettings, + "showCanceled" | "showCompleted" | "showEmpty" + >, + groups: Record, + dragging: boolean, +): Column[] { + return columns.filter( + c => + (settings.showCanceled || c.closes !== "other") && + (settings.showCompleted || c.closes !== "solved") && + (settings.showEmpty || dragging || (groups[c.id]?.length ?? 0) > 0), + ); +} + +export interface Person { + did: string; + name: string; +} + +// Everyone assigned to anything, filtered or not, so the filter menu doesn't +// change shape as filters are picked. +export function assigneeOptions(issues: Issue[], patches: Patch[]): Person[] { + const seen: Record = {}; + for (const a of [...issues, ...patches].flatMap(i => i.assignees)) { + seen[a.did] = a.alias ?? `${a.did.slice(8, 16)}…`; + } + return Object.entries(seen).map(([did, name]) => ({ did, name })); +} + +export function labelOptions(issues: Issue[], patches: Patch[]): string[] { + return [ + ...new Set( + [...issues.map(i => i.labels), ...patches.map(p => p.labels)].flatMap( + displayLabels, + ), + ), + ].sort(); +} + +export interface ListGroup { + id: string; + name: string; + items: BoardItem[]; +} + +// Grouping by status follows the columns; the other groupings regroup the +// cards of the visible columns. +export function listGroups( + settings: Pick, + columns: Column[], + groups: Record, + people: Person[], + labels: string[], +): ListGroup[] { + const shown = columns.flatMap(c => groups[c.id] ?? []); + const byKey = ( + entries: { id: string; name: string }[], + keyOf: (item: BoardItem) => string, + ) => + entries + .map(e => ({ + ...e, + items: shown.filter(item => keyOf(item) === e.id), + })) + .filter(g => settings.showEmpty || g.items.length > 0); + + switch (settings.grouping) { + case "status": + return columns.map(c => ({ + id: c.id, + name: c.name, + items: groups[c.id] ?? [], + })); + case "priority": + return byKey( + PRIORITIES.map(p => ({ id: p.id, name: p.label })), + item => priorityOf(itemLabels(item)), + ); + case "assignee": + return byKey( + [ + ...people.map(p => ({ id: p.did, name: p.name })), + { id: "none", name: "No assignee" }, + ], + item => itemAssignees(item)[0]?.did ?? "none", + ); + case "label": + return byKey( + [ + ...labels.map(l => ({ id: l, name: l })), + { id: "none", name: "No label" }, + ], + item => displayLabels(itemLabels(item))[0] ?? "none", + ); + default: + return [{ id: "all", name: "All cards", items: shown }]; + } +} diff --git a/desktop/src/lib/labs.ts b/desktop/src/lib/labs.ts new file mode 100644 index 000000000..4cb1fd40b --- /dev/null +++ b/desktop/src/lib/labs.ts @@ -0,0 +1,16 @@ +import { z } from "zod"; + +import useLocalStorage from "@app/lib/useLocalStorage.svelte"; + +const boards = useLocalStorage("labs.boards", z.boolean(), false); + +// Experimental features, off until turned on in Settings. +export const labs = { + get boards(): boolean { + return boards.value; + }, + + set boards(value: boolean) { + boards.value = value; + }, +}; diff --git a/desktop/src/lib/router.ts b/desktop/src/lib/router.ts index 2ba46de13..1ea53bc16 100644 --- a/desktop/src/lib/router.ts +++ b/desktop/src/lib/router.ts @@ -2,6 +2,7 @@ import { repoRouteToPath, repoUrlToRoute } from "@app/views/repo/router"; import { on } from "svelte/events"; import { get, writable } from "svelte/store"; +import { labs } from "@app/lib/labs"; import * as mutexExecutor from "@app/lib/mutexExecutor"; import type { LoadedRoute, Route } from "@app/lib/router/definitions"; import { loadRoute } from "@app/lib/router/definitions"; @@ -126,9 +127,15 @@ const loadExecutor = mutexExecutor.create(); async function navigate( action: "push" | "replace", - newRoute: Route, + route: Route, fragment?: string, ): Promise { + // Boards are a Labs feature; while it's off a board link shows the + // repository instead, and the address says so. + const hidden = route.resource === "repo.board" && !labs.boards; + const newRoute: Route = hidden + ? { resource: "repo.home", rid: route.rid } + : route; const historyNav = pendingHistoryNavigation; pendingHistoryNavigation = false; isLoading.set(true); @@ -150,7 +157,11 @@ async function navigate( updateNavigationState(index, true); } else if (action === "replace") { const index = historyIndex(); - window.history.replaceState(stamp(newRoute, index), ""); + window.history.replaceState( + stamp(newRoute, index), + "", + hidden ? path : undefined, + ); updateNavigationState(index); } else { updateNavigationState(historyIndex()); @@ -232,6 +243,7 @@ export function routeToPath(route: Route): string { } else if (route.resource === "guide") { return "/guide"; } else if ( + route.resource === "repo.board" || route.resource === "repo.home" || route.resource === "repo.commits" || route.resource === "repo.commit" || diff --git a/desktop/src/lib/router/definitions.ts b/desktop/src/lib/router/definitions.ts index ad6fde19e..1f8b10d6f 100644 --- a/desktop/src/lib/router/definitions.ts +++ b/desktop/src/lib/router/definitions.ts @@ -4,6 +4,7 @@ import type { Config } from "@bindings/config/Config"; import type { RepoSummary } from "@bindings/repo/RepoSummary"; import { + loadBoard, loadIdentity, loadIssue, loadIssues, @@ -59,6 +60,7 @@ export function isLoadedRepoRoute( route: LoadedRoute, ): route is LoadedRepoRoute { return ( + route.resource === "repo.board" || route.resource === "repo.home" || route.resource === "repo.commits" || route.resource === "repo.commit" || @@ -109,6 +111,8 @@ export async function loadRoute( return loadInbox(); } else if (route.resource === "guide") { return loadGuide(); + } else if (route.resource === "repo.board") { + return loadBoard(route); } else if (route.resource === "repo.home") { return loadRepoHome(route); } else if (route.resource === "repo.commits") { diff --git a/desktop/src/modals/AddBoardCard.svelte b/desktop/src/modals/AddBoardCard.svelte new file mode 100644 index 000000000..be8b6afec --- /dev/null +++ b/desktop/src/modals/AddBoardCard.svelte @@ -0,0 +1,227 @@ + + + + + diff --git a/desktop/src/modals/BoardColumns.svelte b/desktop/src/modals/BoardColumns.svelte new file mode 100644 index 000000000..df46a0bb2 --- /dev/null +++ b/desktop/src/modals/BoardColumns.svelte @@ -0,0 +1,355 @@ + + + + + (dragging = undefined)} + onpointercancel={() => (dragging = undefined)} /> + + + + +{#if dnd.current} + {@const drag = dnd.current} + {@const landing = dnd.landing} +
+ {@render content(drag.item, view === "board" ? "card" : "row")} + {#if drag.refused && !landing} +
+ + {refusalFor(drag.item, drag.refused)} +
+ {/if} +
+{/if} + +{#if dnd.nudge} + {@const nudge = dnd.nudge} +
+ {@render content(nudge.item, view === "board" ? "card" : "row")} +
+{/if} + +{#if spaceMenu} + (spaceMenu = undefined)}> + + {#if isDelegate} + + + + + {/if} + +{/if} + +{#if boardMenu} + (boardMenu = undefined)}> + {#if boards.length > 1} + + {#each boards as b (b.id)} + + {/each} + {/if} + {#if isDelegate} + {#if boards.length > 1} + + {/if} + + {#if boards.length > 1} + + {/if} + + + {/if} + +{/if} + +{#if menu} + {@const item = menu.item} + moveToColumn(item, column)} + onRemove={() => removeCard(item)} + onPriority={priority => void setPriority(item, priority)} + onclose={() => (menu = undefined)} /> +{/if} diff --git a/desktop/src/views/repo/router.ts b/desktop/src/views/repo/router.ts index 501ff652c..3795bba18 100644 --- a/desktop/src/views/repo/router.ts +++ b/desktop/src/views/repo/router.ts @@ -1,3 +1,5 @@ +import type { Board } from "@bindings/cob/board/Board"; +import type { PatchLink } from "@bindings/cob/board/PatchLink"; import type { Action as IssueAction } from "@bindings/cob/issue/Action"; import type { Issue } from "@bindings/cob/issue/Issue"; import type { Operation } from "@bindings/cob/Operation"; @@ -19,6 +21,8 @@ import type { Readme } from "@bindings/repo/Readme"; import type { RepoInfo } from "@bindings/repo/RepoInfo"; import type { Tree } from "@bindings/source/Tree"; +import type { BoardView } from "@app/lib/board"; +import { loadView } from "@app/lib/boardView"; import { cachedGetCommitDiff, cachedGetDiffText, @@ -131,6 +135,29 @@ export interface LoadedRepoIssueRoute { }; } +export interface RepoBoardRoute { + resource: "repo.board"; + rid: string; + // Left out, the board opens in the view it was last left in. + view?: BoardView; + board?: string; +} + +export interface LoadedRepoBoardRoute { + resource: "repo.board"; + params: { + repo: RepoInfo; + config: Config; + boards: Board[]; + issues: Issue[]; + patches: Patch[]; + links: PatchLink[]; + view: BoardView; + selected?: string; + sidebarData: SidebarData; + }; +} + export interface RepoIssuesRoute { resource: "repo.issues"; rid: string; @@ -229,6 +256,7 @@ export interface LoadedRepoReleaseRoute { } export type RepoRoute = + | RepoBoardRoute | RepoHomeRoute | RepoCommitsRoute | RepoCommitRoute @@ -240,6 +268,7 @@ export type RepoRoute = | RepoReleaseRoute | RepoReleasesRoute; export type LoadedRepoRoute = + | LoadedRepoBoardRoute | LoadedRepoHomeRoute | LoadedRepoCommitsRoute | LoadedRepoCommitRoute @@ -628,6 +657,40 @@ export async function loadRelease( }; } +export async function loadBoard( + route: RepoBoardRoute, +): Promise { + const [sidebarData, repo, boards, issues, patches, links] = await Promise.all( + [ + loadSidebarData(), + invoke("repo_by_id", { rid: route.rid }), + invoke("list_boards", { rid: route.rid }), + invoke>("list_issues", { + rid: route.rid, + status: "all", + }), + invoke>("list_patches", { rid: route.rid }), + invoke("board_links", { rid: route.rid }), + ], + ); + + return { + resource: "repo.board", + params: { + sidebarData, + config: sidebarData.config, + repo, + boards, + issues: issues.content, + patches: patches.content, + links, + view: + route.view ?? loadView(route.rid, route.board ?? boards[0]?.id).view, + selected: route.board, + }, + }; +} + export function repoRouteToPath(route: RepoRoute): string { const pathSegments = ["/repos", route.rid]; const searchParams = new URLSearchParams(); @@ -658,6 +721,11 @@ export function repoRouteToPath(route: RepoRoute): string { return [...pathSegments, "commits", route.commit].join("/"); } else if (route.resource === "repo.identity") { return [...pathSegments, "identity"].join("/"); + } else if (route.resource === "repo.board") { + const url = [...pathSegments, "board"].join("/"); + if (route.view) searchParams.set("view", route.view); + if (route.board) searchParams.set("board", route.board); + return searchParams.size > 0 ? `${url}?${searchParams}` : url; } else if (route.resource === "repo.issue") { let url = [...pathSegments, "issues", route.issue].join("/"); searchParams.set("status", route.status); @@ -754,6 +822,11 @@ export function repoUrlToRoute( }; } else if (resource === "identity") { return { resource: "repo.identity", rid }; + } else if (resource === "board") { + const param = searchParams.get("view"); + const view = param === "list" || param === "board" ? param : undefined; + const board = searchParams.get("board") ?? undefined; + return { resource: "repo.board", rid, view, board }; } else if (resource === "issues") { const idOrAction = segments.shift(); if (idOrAction) { diff --git a/desktop/tests/e2e/repo/board.spec.ts b/desktop/tests/e2e/repo/board.spec.ts new file mode 100644 index 000000000..b077ce9c6 --- /dev/null +++ b/desktop/tests/e2e/repo/board.spec.ts @@ -0,0 +1,543 @@ +import type { Browser, Page } from "@playwright/test"; +import type { RadiclePeer } from "@tests/support/peerManager.js"; + +import * as Fs from "node:fs/promises"; +import * as Path from "node:path"; + +import * as patch from "@tests/support/cobs/patch.js"; +import { + clone, + createCollaborators, + createProject, +} from "@tests/support/collaboration.js"; +import { + expect, + goto, + reload, + test, + useBackend, + waitForCommand, +} from "@tests/support/fixtures.js"; + +const TITLE = "The notes are too quiet"; + +test.beforeEach(async ({ page }) => { + await page.addInitScript(() => localStorage.setItem("labs.boards", "true")); +}); + +const WRITES = [ + "/create_board", + "/update_board", + "/rename_board", + "/set_board_columns", + "/edit_issue", + "/seed", +]; + +function column(page: Page, id: string) { + return page.locator(`[data-column="${id}"]`); +} + +function card(page: Page, title = TITLE) { + return page.locator(".card").filter({ hasText: title }); +} + +function modal(page: Page) { + return page.locator("[data-modal-content]"); +} + +async function moveTo(page: Page, columnName: string, title = TITLE) { + await card(page, title).click({ button: "right" }); + await page.getByRole("menuitem", { name: columnName, exact: true }).click(); +} + +// Counts the writes the page sends from now on. +function countWrites(page: Page): () => number { + let writes = 0; + page.on("request", request => { + if (WRITES.includes(new URL(request.url()).pathname)) writes++; + }); + return () => writes; +} + +async function openBoardMenu(page: Page) { + await page.getByRole("button", { name: "Board actions" }).click(); +} + +// Confirms the "Create board" prompt the page shows for a repository without +// a board. +async function createBoard(page: Page) { + await waitForCommand(page, "create_board", async () => { + await page.getByRole("button", { name: "Create board" }).first().click(); + await page + .getByRole("button", { name: "icon-dashboard Create", exact: true }) + .click(); + }); + await expect(page.getByText("This repository has no board yet.")).toHaveCount( + 0, + ); +} + +// Puts the open issue of the repository "other" on the board, through the +// board menu. +async function addCardFromOther(page: Page) { + await openBoardMenu(page); + await page + .getByRole("menuitem", { name: "Add card from another repository" }) + .click(); + await modal(page).locator(".option").filter({ hasText: "other" }).click(); + await waitForCommand(page, "update_board", () => + modal(page).locator(".option").filter({ hasText: TITLE }).click(), + ); +} + +// The card from the repository `rid`, told apart by its repository chip. +function cardFrom(page: Page, rid: string) { + return page + .locator(".card") + .filter({ has: page.locator(`[title="${rid}"]`) }); +} + +// A page talking to `peer`, in its own browser context so a test can look at +// the same repository as two people. +async function pageFor(browser: Browser, peer: RadiclePeer): Promise { + const context = await browser.newContext(); + const page = await context.newPage(); + await page.addInitScript(() => { + localStorage.setItem("appFirstLaunch", "false"); + localStorage.setItem("labs.boards", "true"); + }); + await useBackend(page, peer); + return page; +} + +// A patch by `peer` that links to `issueId` with a keyword and the issue's +// full reference. +async function linkingPatch( + peer: RadiclePeer, + rid: string, + repoFolder: string, + issueId: string, +) { + const file = Path.join(repoFolder, "notes.txt"); + return patch.create( + peer, + ["Turn the notes up"], + "feature/louder", + () => Fs.writeFile(file, "ONE\ntwo\nthree\nfour\nfive\n"), + ["Turn the notes up", `Fixes ${rid}/cob/xyz.radicle.issue/${issueId}`], + { cwd: repoFolder }, + ); +} + +test("boards stay hidden until turned on in Labs", async ({ page, peer }) => { + await page.addInitScript(() => localStorage.removeItem("labs.boards")); + const { rid } = await createProject(peer); + await page.goto(`/repos/${rid}/board`); + await expect(page.locator("[data-column]")).toHaveCount(0); + await expect(page.getByText("This repository has no board yet.")).toHaveCount( + 0, + ); + await expect(page.getByRole("link", { name: /Boards$/ })).toHaveCount(0); + + await page.getByRole("button", { name: "Settings" }).click(); + await page + .locator(".row") + .filter({ hasText: "Arrange issues and patches on a board" }) + .getByRole("button", { name: "On" }) + .click(); + await page.keyboard.press("Escape"); + await page.getByRole("link", { name: /Boards$/ }).click(); + await expect( + page.getByText("This repository has no board yet."), + ).toBeVisible(); +}); + +test("creating a board asks for confirmation", async ({ page, peer }) => { + const { rid } = await createProject(peer); + await page.goto(`/repos/${rid}/board`); + await expect( + page.getByText("This repository has no board yet."), + ).toBeVisible(); + await expect(card(page)).toHaveCount(0); + const writes = countWrites(page); + + await page.getByRole("button", { name: "Create board" }).first().click(); + await expect(modal(page).getByText("Create board")).toBeVisible(); + await page.getByRole("button", { name: "Cancel" }).click(); + await expect( + page.getByText("This repository has no board yet."), + ).toBeVisible(); + expect(writes()).toBe(0); + + await createBoard(page); + await reload(page); + await expect(page.getByText("This repository has no board yet.")).toHaveCount( + 0, + ); + await expect(card(page)).not.toHaveAttribute("title", /.+/); +}); + +test("a moved card is saved after a pause", async ({ page, peer }) => { + const { rid } = await createProject(peer); + await page.goto(`/repos/${rid}/board`); + await createBoard(page); + await expect(column(page, "backlog").getByText(TITLE)).toBeVisible(); + + await waitForCommand(page, "update_board", () => moveTo(page, "Todo")); + await reload(page); + + await expect(column(page, "todo").getByText(TITLE)).toBeVisible(); +}); + +test("moves that cancel out aren't written", async ({ page, peer }) => { + const { rid } = await createProject(peer); + await page.goto(`/repos/${rid}/board`); + await createBoard(page); + await waitForCommand(page, "update_board", () => moveTo(page, "Todo")); + + const writes = countWrites(page); + await moveTo(page, "In Progress"); + await expect(column(page, "in-progress").getByText(TITLE)).toBeVisible(); + await moveTo(page, "Todo"); + await expect(column(page, "todo").getByText(TITLE)).toBeVisible(); + // Longer than the pause before a write. + await page.waitForTimeout(4000); + + expect(writes()).toBe(0); +}); + +test("a pending move is saved when leaving the board", async ({ + page, + peer, +}) => { + const { rid } = await createProject(peer); + await page.goto(`/repos/${rid}/board`); + await createBoard(page); + await expect(column(page, "backlog").getByText(TITLE)).toBeVisible(); + + await waitForCommand(page, "update_board", async () => { + await moveTo(page, "Todo"); + await page.getByRole("link", { name: "Issues" }).click(); + }); + await goto(page, `/repos/${rid}/board`); + + await expect(column(page, "todo").getByText(TITLE)).toBeVisible(); +}); + +test("a board can be renamed and its columns edited", async ({ + page, + peer, +}) => { + const { rid } = await createProject(peer); + await page.goto(`/repos/${rid}/board`); + await createBoard(page); + + await openBoardMenu(page); + await page.getByRole("menuitem", { name: "Edit columns" }).click(); + await waitForCommand(page, "set_board_columns", async () => { + await page.getByLabel("Column name").first().fill("Inbox"); + await page.getByRole("button", { name: "Save" }).click(); + }); + await expect(column(page, "backlog").getByText("Inbox")).toBeVisible(); + + // A second board gets the picker and the rename entry. + await waitForCommand(page, "create_board", async () => { + await openBoardMenu(page); + await page.getByRole("menuitem", { name: "Create board" }).click(); + await page + .getByRole("button", { name: "icon-dashboard Create", exact: true }) + .click(); + }); + await expect(page.getByRole("button", { name: "Board 2" })).toBeVisible(); + + await page.getByRole("button", { name: "Board 2" }).click(); + await page.getByRole("menuitem", { name: "Rename board" }).click(); + await waitForCommand(page, "rename_board", async () => { + await page.getByPlaceholder("Board name").fill("Roadmap"); + await page.getByRole("button", { name: "Rename" }).click(); + }); + await expect(page.getByRole("button", { name: "Roadmap" })).toBeVisible(); + + await reload(page); + await expect(page.getByRole("button", { name: "Roadmap" })).toBeVisible(); + await page.getByRole("button", { name: "Roadmap" }).click(); + await page.getByRole("menuitem", { name: "Board", exact: true }).click(); + await expect(column(page, "backlog").getByText("Inbox")).toBeVisible(); +}); + +test("a delegate closes an issue by moving it to Done and reopens it by moving it out", async ({ + page, + peer, +}) => { + const { rid } = await createProject(peer); + await page.goto(`/repos/${rid}/board`); + await createBoard(page); + + await waitForCommand(page, "edit_issue", () => moveTo(page, "Done")); + await goto(page, `/repos/${rid}/issues?status=closed`); + await expect(page.getByText(TITLE)).toBeVisible(); + + await goto(page, `/repos/${rid}/board`); + await expect(column(page, "done").getByText(TITLE)).toBeVisible(); + await waitForCommand(page, "edit_issue", () => moveTo(page, "Todo")); + await goto(page, `/repos/${rid}/issues?status=open`); + await expect(page.getByText(TITLE)).toBeVisible(); + + await goto(page, `/repos/${rid}/board`); + await expect(column(page, "todo").getByText(TITLE)).toBeVisible(); +}); + +test("an assignee moves between open columns only", async ({ + page, + browser, + peerManager, +}) => { + const { bob, eve } = await createCollaborators(peerManager); + const { rid, repoFolder, issueId } = await createProject(bob); + await bob.rad( + ["issue", "assign", issueId, "--add", `did:key:${eve.nodeId}`], + { cwd: repoFolder }, + ); + await bob.startHttpd(); + await useBackend(page, bob); + await page.goto(`/repos/${rid}/board`); + await createBoard(page); + + await clone(eve, rid); + await eve.startHttpd(); + const evePage = await pageFor(browser, eve); + await evePage.goto(`/repos/${rid}/board`); + await expect(card(evePage)).not.toHaveAttribute("title", /.+/); + + await card(evePage).click({ button: "right" }); + await expect(evePage.getByRole("menuitem", { name: "Todo" })).toBeVisible(); + await expect(evePage.getByRole("menuitem", { name: "Done" })).toHaveCount(0); + await expect(evePage.getByRole("menuitem", { name: "Canceled" })).toHaveCount( + 0, + ); + await waitForCommand(evePage, "update_board", () => + evePage.getByRole("menuitem", { name: "Todo", exact: true }).click(), + ); + await reload(evePage); + await expect(column(evePage, "todo").getByText(TITLE)).toBeVisible(); +}); + +test("a contributor can't create a board", async ({ page, peerManager }) => { + const { bob, eve } = await createCollaborators(peerManager); + const { rid } = await createProject(bob); + await clone(eve, rid); + await eve.startHttpd(); + await useBackend(page, eve); + + await page.goto(`/repos/${rid}/board`); + + await expect( + page.getByText("This repository has no board yet."), + ).toBeVisible(); + await expect(page.getByText("A delegate can create one.")).toBeVisible(); + await expect(page.getByRole("button", { name: "Create board" })).toHaveCount( + 0, + ); + await expect(card(page)).toHaveCount(0); +}); + +test("a linked patch shows as a chip on the issue's card", async ({ + page, + peer, +}) => { + const { rid, repoFolder, issueId } = await createProject(peer); + const patchId = await linkingPatch(peer, rid, repoFolder, issueId); + await page.goto(`/repos/${rid}/board`); + await createBoard(page); + + const chip = card(page).locator(".patch-chip"); + await expect(chip).toHaveText(patchId.slice(0, 7)); + await expect(column(page, "in-review").getByText(TITLE)).toBeVisible(); + await expect( + page.locator(".card").filter({ hasText: "Turn the notes up" }), + ).toHaveCount(0); +}); + +test("opening a board writes nothing", async ({ page, peer }) => { + const { rid, repoFolder, issueId } = await createProject(peer); + await linkingPatch(peer, rid, repoFolder, issueId); + await page.goto(`/repos/${rid}/board`); + await createBoard(page); + await waitForCommand(page, "update_board", () => moveTo(page, "Todo")); + + // A merged linked patch used to close the issue on the next visit. + await patch.merge(peer, "main", "feature/louder", { cwd: repoFolder }); + const writes = countWrites(page); + await reload(page); + await expect(column(page, "todo").getByText(TITLE)).toBeVisible(); + await page.waitForTimeout(4000); + + expect(writes()).toBe(0); + await goto(page, `/repos/${rid}/issues?status=open`); + await expect(page.getByText(TITLE)).toBeVisible(); +}); + +test("seeding a card's repository asks for confirmation", async ({ + page, + browser, + peerManager, +}) => { + const { bob, eve } = await createCollaborators(peerManager); + const { rid } = await createProject(bob); + await createProject(bob, "other"); + await bob.startHttpd(); + await useBackend(page, bob); + await page.goto(`/repos/${rid}/board`); + await createBoard(page); + + await openBoardMenu(page); + await page + .getByRole("menuitem", { name: "Add card from another repository" }) + .click(); + await modal(page).locator(".option").filter({ hasText: "other" }).click(); + await waitForCommand(page, "update_board", () => + modal(page).locator(".option").filter({ hasText: TITLE }).click(), + ); + + await clone(eve, rid); + await eve.startHttpd(); + const evePage = await pageFor(browser, eve); + await evePage.goto(`/repos/${rid}/board`); + const unresolved = evePage.locator(".card").filter({ + hasText: "Click to seed this repository", + }); + await expect(unresolved).toBeVisible(); + const writes = countWrites(evePage); + + await unresolved.click(); + await expect(modal(evePage).getByText("Seed repository")).toBeVisible(); + await evePage.getByRole("button", { name: "Cancel" }).click(); + await expect(unresolved).toBeVisible(); + expect(writes()).toBe(0); +}); + +test("a card from another repository can be added", async ({ page, peer }) => { + const { rid } = await createProject(peer); + const { rid: other } = await createProject(peer, "other"); + await page.goto(`/repos/${rid}/board`); + await createBoard(page); + + await addCardFromOther(page); + await reload(page); + + await expect(column(page, "backlog").locator(".card")).toHaveCount(3); + await expect(cardFrom(page, other)).toHaveCount(1); + await expect(cardFrom(page, other)).toContainText(TITLE); + await expect(cardFrom(page, other)).toContainText("other"); +}); + +test("a card can be removed from the board", async ({ page, peer }) => { + const { rid } = await createProject(peer); + const { rid: other } = await createProject(peer, "other"); + await page.goto(`/repos/${rid}/board`); + await createBoard(page); + await addCardFromOther(page); + await expect(cardFrom(page, other)).toBeVisible(); + + await cardFrom(page, other).click({ button: "right" }); + await waitForCommand(page, "update_board", () => + page.getByRole("menuitem", { name: "Remove from board" }).click(), + ); + await reload(page); + + await expect(cardFrom(page, other)).toHaveCount(0); + await expect(column(page, "backlog").getByText(TITLE)).toBeVisible(); +}); + +test("a priority can be set from the card menu", async ({ page, peer }) => { + const { rid } = await createProject(peer); + await page.goto(`/repos/${rid}/board`); + await createBoard(page); + await expect(card(page).locator('[title="No priority"]')).toBeVisible(); + + await card(page).click({ button: "right" }); + await waitForCommand(page, "edit_issue", () => + page.getByRole("menuitem", { name: "High" }).click(), + ); + await expect(card(page).locator('[title="High"]')).toBeVisible(); + + await reload(page); + await expect(card(page).locator('[title="High"]')).toBeVisible(); +}); + +test("reordering a closed issue doesn't change its state", async ({ + page, + peer, +}) => { + const { rid, repoFolder, issueId } = await createProject(peer); + // Closed as "other", which the board then has no column for. + await peer.rad(["issue", "state", issueId, "--closed"], { cwd: repoFolder }); + await page.goto(`/repos/${rid}/board`); + await createBoard(page); + await openBoardMenu(page); + await page.getByRole("menuitem", { name: "Edit columns" }).click(); + await waitForCommand(page, "set_board_columns", async () => { + await page.getByTitle("Delete column").last().click(); + await page.getByRole("button", { name: "Save" }).click(); + }); + await expect(column(page, "done").getByText(TITLE)).toBeVisible(); + + let lifecycle = 0; + page.on("request", request => { + if (new URL(request.url()).pathname === "/edit_issue") lifecycle++; + }); + // The column the card is in carries a checkmark, so its name isn't exact. + await waitForCommand(page, "update_board", async () => { + await card(page).click({ button: "right" }); + await page.getByRole("menuitem", { name: "Done" }).click(); + }); + await page.waitForTimeout(1000); + + expect(lifecycle).toBe(0); + await goto(page, `/repos/${rid}/issues?status=closed`); + await expect(page.getByText(TITLE)).toBeVisible(); + await goto(page, `/repos/${rid}/board`); + await expect(column(page, "done").getByText(TITLE)).toBeVisible(); +}); + +test("a write the backend rejects shows the error notice", async ({ + page, + peer, +}) => { + const { rid } = await createProject(peer); + await page.goto(`/repos/${rid}/board`); + await createBoard(page); + await page.route("**/update_board", route => + route.fulfill({ + status: 500, + contentType: "application/json", + body: JSON.stringify({ code: "BoardError", message: "Not saved" }), + }), + ); + + await moveTo(page, "Todo"); + + await expect( + page.getByText("The board couldn't be saved: Not saved"), + ).toBeVisible(); + await expect(column(page, "backlog").getByText(TITLE)).toBeVisible(); +}); + +test("view settings survive a reload", async ({ page, peer }) => { + const { rid } = await createProject(peer); + await page.goto(`/repos/${rid}/board`); + await createBoard(page); + await expect(column(page, "done")).toBeVisible(); + + await page.getByRole("button", { name: "Display" }).click(); + // The styled box covers the native checkbox, so click its label. + await page.getByText("Show completed", { exact: true }).click(); + await expect(page.getByLabel("Show completed")).not.toBeChecked(); + await expect(column(page, "done")).toHaveCount(0); + await page.getByRole("button", { name: "Display" }).click(); + + await reload(page); + await expect(column(page, "backlog")).toBeVisible(); + await expect(column(page, "done")).toHaveCount(0); +}); diff --git a/desktop/tests/unit/board.test.ts b/desktop/tests/unit/board.test.ts new file mode 100644 index 000000000..9364348f5 --- /dev/null +++ b/desktop/tests/unit/board.test.ts @@ -0,0 +1,990 @@ +import type { Board } from "@bindings/cob/board/Board"; +import type { Card } from "@bindings/cob/board/Card"; +import type { Column } from "@bindings/cob/board/Column"; +import type { Placement } from "@bindings/cob/board/Placement"; +import type { Issue } from "@bindings/cob/issue/Issue"; +import type { Patch } from "@bindings/cob/patch/Patch"; + +import { describe, expect, test } from "vitest"; + +import type { BoardItem, QueuedChange } from "@app/lib/board"; +import { + accepts, + applyMove, + baselinePlacement, + batches, + between, + cardKey, + DEFAULT_COLUMNS, + displayLabels, + dropPosition, + groupItems, + issueColumn, + isUnchanged, + labelsForPriority, + MAX_NAME_LENGTH, + MAX_POSITION_LENGTH, + neighbourPositions, + overlayBoard, + overlayIssues, + patchColumn, + priorityOf, + refusal, + sameState, + stateAfterMove, + validName, +} from "@app/lib/board"; + +import positions from "../../crates/radicle-board/tests/fixtures/positions.json"; + +const RID = "rad:z4V1sjrXqjvFdnCUbxPFqd5p4DtH5"; +const OTHER = "rad:z3gqcJUoA1n9HaHKufZs5FCSGazv5"; +const ME = "did:key:z6MkkfM3tPXNPrPevKr3uSiQtHPuwnNhu2yUVjgd2jXVsVz5"; + +function issue( + id: string, + state: Issue["state"] = { status: "open" }, + extra: Partial = {}, +): Issue { + return { + id, + state, + labels: [], + timestamp: 1000, + title: id, + ...extra, + } as Issue; +} + +function patch( + id: string, + status: Patch["state"]["status"] = "open", + extra: Partial = {}, +): Patch { + return { + id, + state: { status }, + labels: [], + timestamp: 1000, + title: id, + ...extra, + } as Patch; +} + +function issueCard(oid: string, rid = RID): Card { + return { rid, typeName: "xyz.radicle.issue", oid }; +} + +function patchCard(oid: string): Card { + return { rid: RID, typeName: "xyz.radicle.patch", oid }; +} + +function placement(card: Card, column: string, position = "V"): Placement { + return { card, column, position, movedBy: { did: ME }, movedAt: 1 }; +} + +function board(cards: Placement[], columns = DEFAULT_COLUMNS): Board { + return { id: "board-1", name: "Board", createdAt: 0, columns, cards }; +} + +function columnsOf( + groups: Record, +): Record { + return Object.fromEntries( + Object.entries(groups).flatMap(([column, items]) => + items.map(item => [item.card.oid, column]), + ), + ); +} + +const NO_CLOSING: Column[] = [ + { id: "todo", name: "Todo" }, + { id: "doing", name: "Doing" }, +]; + +describe("issueColumn", () => { + const closed = (reason: "solved" | "other") => + issue("i", { status: "closed", reason }); + const placed = (column: string) => placement(issueCard("i"), column); + + test("a closed issue placed in a closing column for its reason stays", () => { + expect( + issueColumn(closed("other"), [], placed("canceled"), DEFAULT_COLUMNS), + ).toEqual({ column: "canceled", showsClosed: false }); + }); + + test("a closed issue placed elsewhere shows in the column for its reason", () => { + expect( + issueColumn(closed("solved"), [], placed("todo"), DEFAULT_COLUMNS), + ).toEqual({ column: "done", showsClosed: false }); + expect( + issueColumn(closed("solved"), [], placed("canceled"), DEFAULT_COLUMNS), + ).toEqual({ column: "done", showsClosed: false }); + expect( + issueColumn(closed("other"), [], undefined, DEFAULT_COLUMNS), + ).toEqual({ column: "canceled", showsClosed: false }); + }); + + test("without a column for its reason a closed issue takes any closing column", () => { + const columns: Column[] = [ + { id: "todo", name: "Todo" }, + { id: "done", name: "Done", closes: "solved" }, + ]; + expect(issueColumn(closed("other"), [], placed("todo"), columns)).toEqual({ + column: "done", + showsClosed: false, + }); + }); + + test("without any closing column a closed issue shows in the first column with a badge", () => { + expect( + issueColumn(closed("solved"), [], placed("doing"), NO_CLOSING), + ).toEqual({ column: "todo", showsClosed: true }); + }); + + test("an open issue placed in an open column stays", () => { + expect( + issueColumn(issue("i"), [], placed("in-progress"), DEFAULT_COLUMNS), + ).toEqual({ column: "in-progress", showsClosed: false }); + }); + + test("an open issue placed in a closing column shows in the first open column", () => { + expect( + issueColumn(issue("i"), [patch("p")], placed("done"), DEFAULT_COLUMNS), + ).toEqual({ column: "backlog", showsClosed: false }); + }); + + test("an unplaced open issue follows its linked patches", () => { + expect( + issueColumn(issue("i"), [patch("p", "open")], undefined, DEFAULT_COLUMNS), + ).toEqual({ column: "in-review", showsClosed: false }); + expect( + issueColumn( + issue("i"), + [patch("p", "draft")], + undefined, + DEFAULT_COLUMNS, + ), + ).toEqual({ column: "in-progress", showsClosed: false }); + expect( + issueColumn( + issue("i"), + [patch("p", "merged")], + undefined, + DEFAULT_COLUMNS, + ), + ).toEqual({ column: "backlog", showsClosed: false }); + expect(issueColumn(issue("i"), [], undefined, DEFAULT_COLUMNS)).toEqual({ + column: "backlog", + showsClosed: false, + }); + }); + + test("a linked patch doesn't move an issue to a column the board lacks", () => { + expect( + issueColumn(issue("i"), [patch("p", "open")], undefined, NO_CLOSING), + ).toEqual({ column: "todo", showsClosed: false }); + }); +}); + +describe("patchColumn", () => { + const placed = (column: string) => placement(patchCard("p"), column); + + test("merged and archived patches show in the closing columns", () => { + expect(patchColumn(patch("p", "merged"), undefined, DEFAULT_COLUMNS)).toBe( + "done", + ); + expect( + patchColumn(patch("p", "archived"), placed("todo"), DEFAULT_COLUMNS), + ).toBe("canceled"); + expect(patchColumn(patch("p", "merged"), undefined, NO_CLOSING)).toBe( + "todo", + ); + }); + + test("an open patch placed in a closing column shows in the first open column", () => { + expect(patchColumn(patch("p"), placed("done"), DEFAULT_COLUMNS)).toBe( + "backlog", + ); + expect(patchColumn(patch("p"), placed("in-review"), DEFAULT_COLUMNS)).toBe( + "in-review", + ); + expect(patchColumn(patch("p", "draft"), undefined, DEFAULT_COLUMNS)).toBe( + "backlog", + ); + }); +}); + +describe("groupItems", () => { + test("a card shown away from its placement sorts like an unplaced card", () => { + const a = issueCard("a"); + const c = issueCard("c"); + const groups = groupItems( + RID, + board([placement(a, "todo", "A"), placement(c, "done", "A")]), + [ + issue("a", { status: "closed", reason: "solved" }), + issue("b", { status: "closed", reason: "solved" }, { timestamp: 2000 }), + issue("c", { status: "closed", reason: "solved" }), + ], + [], + [], + ); + // `c` keeps its stored spot; `a` and `b` are placed as unplaced cards, + // newest first. + expect(groups.done.map(i => i.card.oid)).toEqual(["c", "b", "a"]); + expect(groups.todo).toEqual([]); + }); + + test("a closed issue with no closing column shows a badge", () => { + const groups = groupItems( + RID, + board([], NO_CLOSING), + [issue("a", { status: "closed", reason: "other" })], + [], + [], + ); + const [item] = groups.todo; + expect(item.kind === "issue" && item.showsClosed).toBe(true); + }); + + test("a linked patch shows on its issue's card, not as its own", () => { + const groups = groupItems( + RID, + undefined, + [issue("a")], + [patch("p", "open"), patch("q", "open")], + [ + { patch: "p", issues: ["a"] }, + { patch: "q", issues: ["missing"] }, + ], + ); + expect(columnsOf(groups)).toEqual({ a: "in-review", q: "backlog" }); + const [item] = groups["in-review"]; + expect(item.kind === "issue" && item.patches.map(p => p.id)).toEqual(["p"]); + }); + + test("a linked patch never changes an issue's column from its state", () => { + const groups = groupItems( + RID, + undefined, + [issue("a")], + [patch("p", "merged")], + [{ patch: "p", issues: ["a"] }], + ); + expect(columnsOf(groups)).toEqual({ a: "backlog" }); + }); + + test("foreign and unresolved cards take their placement", () => { + const foreign = issueCard("f", "rad:z3gqcJUoA1n9HaHKufZs5FCSGazv5"); + const unknown = issueCard("u", "rad:z3gqcJUoA1n9HaHKufZs5FCSGazv5"); + const groups = groupItems( + RID, + board([placement(foreign, "todo"), placement(unknown, "gone")]), + [], + [], + [], + new Map([ + [ + `${foreign.rid}/${foreign.typeName}/f`, + { kind: "issue", issue: issue("f") }, + ], + ]), + ); + expect(columnsOf(groups)).toEqual({ f: "todo", u: "backlog" }); + expect(groups.backlog[0].kind).toBe("unresolved"); + }); +}); + +type IssueItem = Extract; + +function issueItem(overrides: Partial = {}): IssueItem { + return { + kind: "issue", + key: "k", + card: issueCard("i"), + column: "todo", + position: "V", + issue: issue("i"), + patches: [], + showsClosed: false, + ...overrides, + }; +} + +function patchItem( + status: Patch["state"]["status"], + column: string, +): BoardItem { + return { + kind: "patch", + key: "p", + card: patchCard("p"), + column, + position: "V", + patch: patch("p", status), + }; +} + +describe("stateAfterMove", () => { + const columns = DEFAULT_COLUMNS; + const by = (id: string) => columns.find(c => c.id === id)!; + const solved: Issue["state"] = { status: "closed", reason: "solved" }; + const other: Issue["state"] = { status: "closed", reason: "other" }; + const closed = (state: Issue["state"], column: string) => + issueItem({ issue: issue("i", state), column }); + + test("into a closing column closes with its reason", () => { + expect(stateAfterMove(issueItem(), by("done"), columns)).toEqual(solved); + expect(stateAfterMove(issueItem(), by("canceled"), columns)).toEqual(other); + }); + + test("out of a closing column reopens", () => { + expect(stateAfterMove(closed(solved, "done"), by("todo"), columns)).toEqual( + { status: "open" }, + ); + }); + + test("between closing columns with different reasons changes the reason", () => { + expect( + stateAfterMove(closed(solved, "done"), by("canceled"), columns), + ).toEqual(other); + }); + + test("reordering within a column keeps the state", () => { + expect(stateAfterMove(closed(solved, "done"), by("done"), columns)).toBe( + closed(solved, "done").issue.state, + ); + expect(stateAfterMove(issueItem(), by("todo"), columns)).toEqual({ + status: "open", + }); + }); + + test("between open columns keeps the state", () => { + expect(stateAfterMove(issueItem(), by("in-progress"), columns)).toEqual({ + status: "open", + }); + }); + + test("a closed issue shown in an open column stays closed", () => { + const noClosing = closed(solved, "todo"); + expect( + stateAfterMove( + noClosing, + NO_CLOSING.find(c => c.id === "todo")!, + [...NO_CLOSING], + ), + ).toEqual(solved); + }); + + test("a closed issue shown in a column closing for another reason keeps its reason", () => { + const solvedOnly: Column[] = [ + { id: "todo", name: "Todo" }, + { id: "done", name: "Done", closes: "solved" }, + ]; + expect( + stateAfterMove(closed(other, "done"), solvedOnly[1], solvedOnly), + ).toEqual(other); + }); +}); + +describe("refusal", () => { + const columns = DEFAULT_COLUMNS; + const by = (id: string) => columns.find(c => c.id === id)!; + const closed = issue("i", { status: "closed", reason: "solved" }); + + test("says why a patch can't move", () => { + expect( + refusal(patchItem("merged", "done"), by("todo"), columns, true, RID), + ).toBe("Merged patches can't be moved"); + expect( + refusal( + patchItem("archived", "canceled"), + by("todo"), + columns, + true, + RID, + ), + ).toBe("Archived patches can't be moved"); + expect( + refusal(patchItem("open", "todo"), by("done"), columns, true, RID), + ).toBe("Patches can't go in a column that closes issues"); + expect( + refusal(patchItem("open", "todo"), by("in-review"), columns, true, RID), + ).toBeUndefined(); + }); + + test("says who may close or reopen an issue", () => { + expect(refusal(issueItem(), by("done"), columns, false, RID)).toBe( + "Only delegates and the author can close this issue", + ); + expect( + refusal( + issueItem({ column: "done", issue: closed }), + by("todo"), + columns, + false, + RID, + ), + ).toBe("Only delegates and the author can reopen this issue"); + expect( + refusal(issueItem(), by("in-progress"), columns, false, RID), + ).toBeUndefined(); + }); + + test("lets others reorder within a closing column but not change the reason", () => { + const inDone = issueItem({ + column: "done", + issue: closed, + placement: placement(issueCard("i"), "done"), + }); + expect(refusal(inDone, by("done"), columns, false, RID)).toBeUndefined(); + expect(refusal(inDone, by("canceled"), columns, false, RID)).toBe( + "Only delegates and the author can change how this issue was closed", + ); + expect( + refusal( + issueItem({ column: "done", issue: closed }), + by("done"), + columns, + false, + RID, + ), + ).toBe("Only delegates and the author can close this issue"); + }); + + test("says that issues from other repositories keep their state", () => { + expect( + refusal( + issueItem({ card: issueCard("i", OTHER) }), + by("done"), + columns, + true, + RID, + ), + ).toBe("Issues from other repositories can't be closed or reopened here"); + }); +}); + +describe("accepts", () => { + const columns = DEFAULT_COLUMNS; + const done = columns.find(c => c.id === "done")!; + const todo = columns.find(c => c.id === "todo")!; + const inReview = columns.find(c => c.id === "in-review")!; + + test("patches never go into a closing column", () => { + expect(accepts(patchItem("open", "todo"), done, columns, true, RID)).toBe( + false, + ); + expect(accepts(patchItem("open", "todo"), todo, columns, true, RID)).toBe( + true, + ); + }); + + test("merged and archived patches stay where their state shows them", () => { + expect(accepts(patchItem("merged", "done"), todo, columns, true, RID)).toBe( + false, + ); + expect( + accepts(patchItem("archived", "canceled"), inReview, columns, true, RID), + ).toBe(false); + expect( + accepts(patchItem("draft", "todo"), inReview, columns, true, RID), + ).toBe(true); + }); + + test("with the right to close, an issue goes anywhere its state follows", () => { + expect(accepts(issueItem(), done, columns, true, RID)).toBe(true); + expect( + accepts( + issueItem({ + column: "done", + issue: issue("i", { status: "closed", reason: "solved" }), + }), + todo, + columns, + true, + RID, + ), + ).toBe(true); + }); + + test("without it, an issue moves between open columns only", () => { + expect(accepts(issueItem(), done, columns, false, RID)).toBe(false); + expect(accepts(issueItem(), todo, columns, false, RID)).toBe(true); + expect( + accepts(issueItem({ column: "done" }), todo, columns, false, RID), + ).toBe(false); + expect( + accepts( + issueItem({ placement: placement(issueCard("i"), "done") }), + todo, + columns, + false, + RID, + ), + ).toBe(false); + }); + + test("an issue from another repository keeps its state, so it only goes where that shows it", () => { + const open = issueItem({ card: issueCard("i", OTHER) }); + const closed = issueItem({ + card: issueCard("i", OTHER), + column: "done", + issue: issue("i", { status: "closed", reason: "solved" }), + }); + expect(accepts(open, done, columns, true, RID)).toBe(false); + expect(accepts(open, inReview, columns, true, RID)).toBe(true); + expect(accepts(closed, todo, columns, true, RID)).toBe(false); + expect(accepts(closed, done, columns, true, RID)).toBe(true); + }); + + test("a closed issue on a board without closing columns stays in the first column", () => { + const closed = issueItem({ + column: "todo", + issue: issue("i", { status: "closed", reason: "solved" }), + showsClosed: true, + }); + expect(accepts(closed, NO_CLOSING[1], NO_CLOSING, true, RID)).toBe(false); + expect(accepts(closed, NO_CLOSING[0], NO_CLOSING, true, RID)).toBe(true); + }); + + test("unresolved cards go anywhere", () => { + const u = { ...issueItem(), kind: "unresolved" } as BoardItem; + expect(accepts(u, done, columns, false, RID)).toBe(true); + }); +}); + +const positioned = (...positions: string[]) => + positions.map(position => ({ position }) as BoardItem); + +// The fixture is shared with `radicle_board::position`, so both +// implementations are checked against the same keys. +describe("positions", () => { + test.each(positions.between)( + "between($before, $after) is $key", + ({ before, after, key }) => { + expect(between(before ?? undefined, after ?? undefined)).toBe( + key ?? undefined, + ); + }, + ); + + test.each(positions.neighbours)( + "dropping at $index of $positions gives $key", + ({ positions, index, before, after, key }) => { + expect(dropPosition(positioned(...positions), index)).toEqual({ + bounds: { before: before ?? undefined, after: after ?? undefined }, + position: key ?? undefined, + }); + }, + ); + + test("keeps keys ordered when inserting at the ends and in the middle", () => { + const keys = [between()!]; + for (let i = 0; i < 200; i++) keys.push(between(keys.at(-1))!); + for (let i = 0; i < 200; i++) keys.unshift(between(undefined, keys[0])!); + for (let i = 0; i < 100; i++) { + keys.splice(11, 0, between(keys[10], keys[11])!); + } + + for (let i = 1; i < keys.length; i++) { + expect(keys[i - 1] < keys[i]).toBe(true); + } + expect(keys.every(k => k.length > 0 && !k.endsWith("0"))).toBe(true); + }); + + test("gives up at the length limit instead of producing a longer key", () => { + const packed = (length: number, last: string) => + `V${"0".repeat(length - 2)}${last}`; + expect( + between( + packed(MAX_POSITION_LENGTH, "1"), + packed(MAX_POSITION_LENGTH, "2"), + ), + ).toBeUndefined(); + expect( + dropPosition( + positioned( + packed(MAX_POSITION_LENGTH, "1"), + packed(MAX_POSITION_LENGTH, "2"), + ), + 1, + ).position, + ).toBeUndefined(); + expect( + between( + packed(MAX_POSITION_LENGTH - 1, "1"), + packed(MAX_POSITION_LENGTH - 1, "2"), + ), + ).toHaveLength(MAX_POSITION_LENGTH); + }); + + test("neighbourPositions is dropPosition's bounds", () => { + expect(neighbourPositions(positioned("A", "C", "C", "E"), 2)).toEqual({ + before: "C", + after: "E", + }); + }); +}); + +describe("applyMove", () => { + const groups = { + todo: [issueItem({ key: "a" }), issueItem({ key: "b" })], + done: [issueItem({ key: "c" })], + }; + + test("moves the item between and within columns", () => { + expect( + applyMove(groups, { item: groups.todo[0], column: "done", index: 1 }), + ).toEqual({ + todo: [groups.todo[1]], + done: [groups.done[0], groups.todo[0]], + }); + expect( + applyMove(groups, { item: groups.todo[0], column: "todo", index: 1 }), + ).toEqual({ todo: [groups.todo[1], groups.todo[0]], done: groups.done }); + expect(applyMove(groups, undefined)).toBe(groups); + }); + + test("clamps the index and creates a missing column", () => { + expect( + applyMove(groups, { item: groups.done[0], column: "todo", index: 9 }), + ).toEqual({ todo: [...groups.todo, groups.done[0]], done: [] }); + expect( + applyMove(groups, { item: groups.done[0], column: "new", index: 0 }).new, + ).toEqual([groups.done[0]]); + }); +}); + +describe("validName", () => { + test("counts UTF-8 bytes, trimmed", () => { + expect(validName("a".repeat(MAX_NAME_LENGTH))).toBe(true); + expect(validName("a".repeat(MAX_NAME_LENGTH + 1))).toBe(false); + expect(validName("é".repeat(MAX_NAME_LENGTH / 2))).toBe(true); + expect(validName("é".repeat(MAX_NAME_LENGTH / 2 + 1))).toBe(false); + expect(validName(` ${"a".repeat(MAX_NAME_LENGTH)} `)).toBe(true); + }); + + test("rejects empty and blank names", () => { + expect(validName("")).toBe(false); + expect(validName(" ")).toBe(false); + }); +}); + +describe("derived positions", () => { + test("unplaced cards sort by priority, then newest first, then id", () => { + const groups = groupItems( + RID, + undefined, + [ + issue("a", undefined, { timestamp: 1 }), + issue("b", undefined, { timestamp: 2 }), + issue("c", undefined, { timestamp: 2, labels: ["priority:low"] }), + issue("d", undefined, { timestamp: 1, labels: ["priority:urgent"] }), + issue("e", undefined, { timestamp: 2 }), + ], + [], + [], + ); + expect(groups.backlog.map(i => i.card.oid)).toEqual([ + "d", + "c", + "b", + "e", + "a", + ]); + }); +}); + +describe("priority helpers", () => { + test("reads the priority label and ignores unknown levels", () => { + expect(priorityOf([])).toBe("none"); + expect(priorityOf(["bug", "priority:high"])).toBe("high"); + expect(priorityOf(["priority:none"])).toBe("none"); + expect(priorityOf(["priority:critical"])).toBe("none"); + }); + + test("replaces the priority label and keeps the rest", () => { + expect(labelsForPriority(["bug", "priority:low"], "urgent")).toEqual([ + "bug", + "priority:urgent", + ]); + expect(labelsForPriority(["bug", "priority:low"], "none")).toEqual(["bug"]); + expect(labelsForPriority([], "medium")).toEqual(["priority:medium"]); + }); + + test("hides priority labels from display", () => { + expect(displayLabels(["bug", "priority:low", "docs"])).toEqual([ + "bug", + "docs", + ]); + }); +}); + +function move( + c: Card, + column: string, + position: string, + extra: Partial = {}, +): QueuedChange { + return { + rid: c.rid, + board: "board-1", + opts: { announce: true }, + card: c, + change: { kind: "move", column, position }, + ...extra, + }; +} + +function remove(c: Card, extra: Partial = {}): QueuedChange { + return { + rid: c.rid, + board: "board-1", + opts: { announce: true }, + card: c, + change: { kind: "remove" }, + ...extra, + }; +} + +describe("sameState", () => { + test("compares closed states by reason", () => { + expect( + sameState( + { status: "closed", reason: "solved" }, + { status: "closed", reason: "solved" }, + ), + ).toBe(true); + expect( + sameState( + { status: "closed", reason: "solved" }, + { status: "closed", reason: "other" }, + ), + ).toBe(false); + expect(sameState({ status: "open" }, { status: "open" })).toBe(true); + expect( + sameState({ status: "open" }, { status: "closed", reason: "other" }), + ).toBe(false); + }); +}); + +describe("overlayBoard", () => { + const a = issueCard("aaaa"); + const b = issueCard("bbbb"); + const committed = board([ + placement(a, "todo", "V"), + placement(b, "todo", "k"), + ]); + + test("returns the committed board when nothing is queued for it", () => { + expect(overlayBoard(committed, [], ME, 5)).toBe(committed); + expect( + overlayBoard( + committed, + [{ ...move(a, "done", "V"), board: "other" }], + ME, + 5, + ), + ).toBe(committed); + expect(overlayBoard(undefined, [move(a, "done", "V")], ME, 5)).toBe( + undefined, + ); + }); + + test("replaces moved cards and drops removed ones", () => { + const shown = overlayBoard( + committed, + [move(a, "done", "V"), remove(b)], + ME, + 5, + )!; + + expect(shown.cards).toEqual([ + { + card: a, + column: "done", + position: "V", + movedBy: { did: ME }, + movedAt: 5, + }, + ]); + expect(committed.cards).toHaveLength(2); + }); + + test("adds cards that aren't on the board yet", () => { + const c = issueCard("cccc", OTHER); + const shown = overlayBoard(committed, [move(c, "todo", "x")], ME, 5)!; + + expect(shown.cards.map(p => cardKey(p.card))).toEqual([ + cardKey(a), + cardKey(b), + cardKey(c), + ]); + }); +}); + +describe("overlayIssues", () => { + const issues = [issue("aaaa"), issue("bbbb")]; + + test("returns the issues unchanged when no state changes are queued", () => { + expect( + overlayIssues(RID, issues, [move(issueCard("aaaa"), "todo", "V")]), + ).toBe(issues); + }); + + test("applies queued states, later entries winning", () => { + const shown = overlayIssues(RID, issues, [ + move(issueCard("aaaa"), "done", "V", { + state: { status: "closed", reason: "solved" }, + }), + move(issueCard("aaaa"), "todo", "V", { state: { status: "open" } }), + move(issueCard("bbbb"), "done", "V", { + state: { status: "closed", reason: "solved" }, + }), + ]); + + expect(shown.map(i => i.state)).toEqual([ + { status: "open" }, + { status: "closed", reason: "solved" }, + ]); + }); + + test("ignores states for issues from other repositories", () => { + expect( + overlayIssues(RID, issues, [ + move(issueCard("aaaa", OTHER), "done", "V", { + state: { status: "closed", reason: "solved" }, + }), + ]), + ).toBe(issues); + }); +}); + +describe("baselinePlacement", () => { + const a = issueCard("aaaa"); + const committed = board([placement(a, "todo", "V")]); + + test("uses the committed placement without a write in flight", () => { + expect(baselinePlacement(committed, a, undefined)).toMatchObject({ + column: "todo", + position: "V", + }); + expect(baselinePlacement(committed, issueCard("bbbb"), undefined)).toBe( + undefined, + ); + }); + + test("uses the change being written over the committed board", () => { + expect(baselinePlacement(committed, a, move(a, "done", "k"))).toMatchObject( + { column: "done", position: "k" }, + ); + expect(baselinePlacement(committed, a, remove(a))).toBeUndefined(); + }); + + test("ignores a write in flight for another board", () => { + expect( + baselinePlacement(committed, a, { ...move(a, "done", "k"), board: "x" }), + ).toMatchObject({ column: "todo", position: "V" }); + }); +}); + +describe("isUnchanged", () => { + const a = issueCard("aaaa"); + const placed = { column: "todo", position: "V" }; + + test("a card moved back between its old neighbours", () => { + expect( + isUnchanged(move(a, "todo", "W"), placed, true, { + before: "M", + after: "k", + }), + ).toBe(true); + }); + + test("a card moved back to another spot in its column", () => { + expect( + isUnchanged(move(a, "todo", "l"), placed, true, { + before: "k", + after: "m", + }), + ).toBe(false); + expect( + isUnchanged(move(a, "todo", "l"), placed, true, { after: "M" }), + ).toBe(false); + }); + + test("any spot in the column will do without a manual ordering", () => { + expect( + isUnchanged(move(a, "todo", "l"), placed, false, { before: "k" }), + ).toBe(true); + }); + + test("a card in another column, or not on the board", () => { + expect(isUnchanged(move(a, "done", "V"), placed, false)).toBe(false); + expect(isUnchanged(move(a, "todo", "V"), undefined, false)).toBe(false); + }); + + test("a move that changes the issue's state", () => { + expect( + isUnchanged( + move(a, "todo", "V", { state: { status: "open" } }), + placed, + false, + ), + ).toBe(false); + }); + + test("removing a card that isn't on the board", () => { + expect(isUnchanged(remove(a), undefined, false)).toBe(true); + expect(isUnchanged(remove(a), placed, false)).toBe(false); + }); +}); + +describe("batches", () => { + test("groups changes by repository and board", () => { + const a = issueCard("aaaa"); + const b = issueCard("bbbb"); + const c = issueCard("cccc", OTHER); + const result = batches([ + move(a, "todo", "V"), + remove(b), + { ...move(b, "todo", "k"), board: "board-2" }, + move(c, "done", "V"), + ]); + + expect( + result.map(({ rid, board, moves, removes }) => ({ + rid, + board, + moves, + removes, + })), + ).toEqual([ + { + rid: RID, + board: "board-1", + moves: [{ card: a, column: "todo", position: "V" }], + removes: [b], + }, + { + rid: RID, + board: "board-2", + moves: [{ card: b, column: "todo", position: "k" }], + removes: [], + }, + { + rid: OTHER, + board: "board-1", + moves: [{ card: c, column: "done", position: "V" }], + removes: [], + }, + ]); + expect(result[0].entries).toHaveLength(2); + }); + + test("nothing queued is nothing to write", () => { + expect(batches([])).toEqual([]); + }); +}); diff --git a/desktop/tests/unit/boardQueue.test.ts b/desktop/tests/unit/boardQueue.test.ts new file mode 100644 index 000000000..d57cb24b7 --- /dev/null +++ b/desktop/tests/unit/boardQueue.test.ts @@ -0,0 +1,259 @@ +import type { Board } from "@bindings/cob/board/Board"; +import type { Card } from "@bindings/cob/board/Card"; + +import { afterEach, beforeEach, describe, expect, test, vi } from "vitest"; + +import type { QueuedChange } from "@app/lib/board"; +import type { BoardWriter } from "@app/lib/boardQueue.svelte"; +import { BoardQueue, FLUSH_DELAY_MS } from "@app/lib/boardQueue.svelte"; + +const RID = "rad:z4V1sjrXqjvFdnCUbxPFqd5p4DtH5"; +const ME = "did:key:z6MkkfM3tPXNPrPevKr3uSiQtHPuwnNhu2yUVjgd2jXVsVz5"; + +function card(oid: string): Card { + return { rid: RID, typeName: "xyz.radicle.issue", oid }; +} + +function board(cards: [Card, string, string][]): Board { + return { + id: "board-1", + name: "Board", + createdAt: 0, + columns: [ + { id: "todo", name: "Todo" }, + { id: "done", name: "Done", closes: "solved" }, + ], + cards: cards.map(([c, column, position]) => ({ + card: c, + column, + position, + movedBy: { did: ME }, + movedAt: 1, + })), + }; +} + +function move( + c: Card, + column: string, + position: string, + extra: Partial = {}, +): QueuedChange { + return { + rid: RID, + board: "board-1", + opts: { announce: true }, + card: c, + change: { kind: "move", column, position }, + ...extra, + }; +} + +const a = card("aaaa"); +const b = card("bbbb"); +const committed = board([[a, "todo", "V"]]); + +function setup(rejected: Card[] = []) { + const writer = { + update: vi.fn(async () => rejected), + setState: vi.fn().mockResolvedValue(), + reload: vi.fn().mockResolvedValue(), + error: vi.fn(), + }; + return { writer, queue: new BoardQueue(writer) }; +} + +beforeEach(() => { + vi.useFakeTimers(); + vi.spyOn(console, "error").mockReturnValue(); +}); + +afterEach(() => { + vi.useRealTimers(); + vi.restoreAllMocks(); +}); + +describe("BoardQueue", () => { + test("writes once nothing has changed for the delay", async () => { + const { writer, queue } = setup(); + queue.enqueue(move(a, "done", "V"), committed, true); + await vi.advanceTimersByTimeAsync(FLUSH_DELAY_MS - 1); + expect(writer.update).not.toHaveBeenCalled(); + + await vi.advanceTimersByTimeAsync(1); + expect(writer.update).toHaveBeenCalledTimes(1); + expect(queue.queued).toEqual([]); + expect(queue.writing).toEqual([]); + }); + + test("a later change restarts the delay and joins the batch", async () => { + const { writer, queue } = setup(); + queue.enqueue(move(a, "done", "V"), committed, true); + await vi.advanceTimersByTimeAsync(FLUSH_DELAY_MS - 100); + queue.enqueue(move(b, "todo", "k"), committed, true); + await vi.advanceTimersByTimeAsync(FLUSH_DELAY_MS - 100); + expect(writer.update).not.toHaveBeenCalled(); + + await vi.advanceTimersByTimeAsync(100); + expect(writer.update).toHaveBeenCalledTimes(1); + expect(writer.update.mock.calls[0][0].moves).toEqual([ + { card: a, column: "done", position: "V" }, + { card: b, column: "todo", position: "k" }, + ]); + }); + + test("the last change to a card replaces the earlier ones", async () => { + const { writer, queue } = setup(); + queue.enqueue(move(a, "done", "V"), committed, true); + queue.enqueue(move(a, "done", "k"), committed, true); + expect(queue.queued).toHaveLength(1); + await queue.flush(); + + expect(writer.update).toHaveBeenCalledTimes(1); + expect(writer.update.mock.calls[0][0].moves).toEqual([ + { card: a, column: "done", position: "k" }, + ]); + }); + + test("a change that cancels out isn't written", async () => { + const { writer, queue } = setup(); + queue.enqueue(move(a, "done", "V"), committed, true); + queue.enqueue(move(a, "todo", "W"), committed, true, { + before: "M", + after: "k", + }); + expect(queue.queued).toEqual([]); + await vi.advanceTimersByTimeAsync(FLUSH_DELAY_MS); + + expect(writer.update).not.toHaveBeenCalled(); + }); + + test("a change made while its card is being written stays queued", async () => { + const { writer, queue } = setup(); + let finish: ((cards: Card[]) => void) | undefined; + writer.update.mockImplementationOnce( + () => new Promise(resolve => (finish = resolve)), + ); + const first = move(a, "done", "V"); + queue.enqueue(first, committed, true); + const flushed = queue.flush(); + await Promise.resolve(); + expect(queue.writing).toEqual([first]); + expect( + queue.inFlight( + "rad:z4V1sjrXqjvFdnCUbxPFqd5p4DtH5/xyz.radicle.issue/aaaa", + ), + ).toBe(first); + + // Moving it back to where the committed board has it is still a change, + // because the write in flight is the baseline now. + const second = move(a, "todo", "V"); + queue.enqueue(second, committed, true); + expect(queue.queued).toEqual([second]); + + finish?.([]); + await flushed; + expect(queue.writing).toEqual([]); + expect(queue.queued).toEqual([second]); + + await queue.flush(); + expect(writer.update).toHaveBeenCalledTimes(2); + expect(writer.update.mock.calls[1][0].entries).toEqual([second]); + expect(queue.queued).toEqual([]); + }); + + test("flushing without announcing writes quietly", async () => { + const { writer, queue } = setup(); + queue.enqueue(move(a, "done", "V"), committed, true); + await queue.flush(false); + expect(writer.update.mock.calls[0][1]).toBe(false); + + queue.enqueue(move(a, "done", "k"), committed, true); + await queue.flush(); + expect(writer.update.mock.calls[1][1]).toBe(true); + + queue.enqueue( + move(a, "done", "V", { opts: { announce: false } }), + committed, + true, + ); + await queue.flush(); + expect(writer.update.mock.calls[2][1]).toBe(false); + }); + + test("flushing cancels the pending write and runs it now", async () => { + const { writer, queue } = setup(); + queue.enqueue(move(a, "done", "V"), committed, true); + await queue.flush(); + expect(writer.update).toHaveBeenCalledTimes(1); + + await vi.advanceTimersByTimeAsync(FLUSH_DELAY_MS); + expect(writer.update).toHaveBeenCalledTimes(1); + }); + + test("states follow the board write, announced once", async () => { + const { writer, queue } = setup(); + const solved = { status: "closed", reason: "solved" } as const; + queue.enqueue(move(a, "done", "V", { state: solved }), committed, true); + queue.enqueue(move(b, "done", "k", { state: solved }), committed, true); + await queue.flush(); + + expect(writer.setState.mock.calls).toEqual([ + [RID, "aaaa", solved, false], + [RID, "bbbb", solved, true], + ]); + expect(writer.reload).toHaveBeenCalledWith(RID); + expect(writer.error).not.toHaveBeenCalled(); + }); + + test("a rejected card keeps its issue's state and shows an error", async () => { + const { writer, queue } = setup([a]); + const solved = { status: "closed", reason: "solved" } as const; + queue.enqueue(move(a, "done", "V", { state: solved }), committed, true); + queue.enqueue(move(b, "done", "k", { state: solved }), committed, true); + await queue.flush(); + + expect(writer.setState.mock.calls).toEqual([[RID, "bbbb", solved, true]]); + expect(writer.error).toHaveBeenCalledWith( + "One move wasn't saved because you don't have permission.", + ); + expect(writer.reload).toHaveBeenCalledTimes(1); + expect(queue.queued).toEqual([]); + expect(queue.writing).toEqual([]); + }); + + test("a failed board write drops the batch and reloads", async () => { + const { writer, queue } = setup(); + writer.update.mockRejectedValueOnce(new Error("boom")); + queue.enqueue( + move(a, "done", "V", { state: { status: "closed", reason: "solved" } }), + committed, + true, + ); + await queue.flush(); + + expect(writer.error).toHaveBeenCalledWith( + "The board couldn't be saved: boom", + ); + expect(writer.setState).not.toHaveBeenCalled(); + expect(writer.reload).toHaveBeenCalledWith(RID); + expect(queue.queued).toEqual([]); + expect(queue.writing).toEqual([]); + }); + + test("a failed state change is reported without a reload", async () => { + const { writer, queue } = setup(); + writer.setState.mockRejectedValueOnce(new Error("nope")); + queue.enqueue( + move(a, "done", "V", { state: { status: "closed", reason: "solved" } }), + committed, + true, + ); + await queue.flush(); + + expect(writer.error).toHaveBeenCalledWith( + "An issue's state couldn't be changed: nope", + ); + expect(writer.reload).not.toHaveBeenCalled(); + }); +}); diff --git a/desktop/tests/unit/boardView.test.ts b/desktop/tests/unit/boardView.test.ts new file mode 100644 index 000000000..9c83a8927 --- /dev/null +++ b/desktop/tests/unit/boardView.test.ts @@ -0,0 +1,371 @@ +import type { Issue } from "@bindings/cob/issue/Issue"; +import type { Patch } from "@bindings/cob/patch/Patch"; + +import { beforeEach, describe, expect, test } from "vitest"; + +import type { BoardItem } from "@app/lib/board"; +import { DEFAULT_COLUMNS } from "@app/lib/board"; +import { + activeFilterCount, + arrange, + assigneeOptions, + compareItems, + defaultView, + labelOptions, + listGroups, + loadView, + matchesFilters, + saveView, + visibleColumns, +} from "@app/lib/boardView"; + +const RID = "rad:z4V1sjrXqjvFdnCUbxPFqd5p4DtH5"; + +beforeEach(() => { + localStorage.clear(); +}); + +describe("boardView storage", () => { + test("starts from the defaults when nothing is stored", () => { + expect(loadView(RID, "board-1")).toEqual(defaultView()); + }); + + test("round-trips saved settings per board", () => { + const settings = { + ...defaultView(), + grouping: "priority" as const, + view: "list" as const, + filters: { + types: ["issue" as const], + assignees: [], + priorities: [], + labels: [], + }, + }; + saveView(RID, "board-1", settings); + expect(loadView(RID, "board-1")).toEqual(settings); + expect(loadView(RID, "board-2")).toEqual(defaultView()); + }); + + test("a board without settings starts from its repository's", () => { + saveView(RID, undefined, { ...defaultView(), showEmpty: false }); + expect(loadView(RID, "board-1").showEmpty).toBe(false); + }); + + test("falls back to the defaults on invalid stored values", () => { + localStorage.setItem("boardView", "not json"); + expect(loadView(RID, "board-1")).toEqual(defaultView()); + + localStorage.setItem( + "boardView", + JSON.stringify({ [`${RID}/board-1`]: { grouping: "nope", view: 3 } }), + ); + expect(loadView(RID, "board-1")).toEqual(defaultView()); + + localStorage.setItem("boardView", JSON.stringify([1, 2])); + expect(loadView(RID, "board-1")).toEqual(defaultView()); + }); + + test("fills in fields missing from stored settings", () => { + localStorage.setItem( + "boardView", + JSON.stringify({ [`${RID}/board-1`]: { ordering: "title" } }), + ); + expect(loadView(RID, "board-1")).toEqual({ + ...defaultView(), + ordering: "title", + }); + }); +}); + +test("activeFilterCount counts every chosen filter", () => { + expect(activeFilterCount(defaultView().filters)).toBe(0); + expect( + activeFilterCount({ + types: ["issue"], + assignees: ["a", "b"], + priorities: [], + labels: ["bug"], + }), + ).toBe(4); +}); + +function item( + oid: string, + extra: Partial = {}, + column = "todo", +): BoardItem { + const issue = { + id: oid, + title: oid, + labels: [], + assignees: [], + timestamp: 1000, + state: { status: "open" }, + ...extra, + } as unknown as Issue; + return { + kind: "issue", + key: oid, + card: { rid: RID, typeName: "xyz.radicle.issue", oid }, + column, + position: "V", + issue, + patches: [], + showsClosed: false, + }; +} + +function patchItem(oid: string, extra: Partial = {}): BoardItem { + const patch = { + id: oid, + title: oid, + labels: [], + assignees: [], + timestamp: 1000, + state: { status: "open" }, + ...extra, + } as unknown as Patch; + return { + kind: "patch", + key: oid, + card: { rid: RID, typeName: "xyz.radicle.patch", oid }, + column: "todo", + position: "V", + patch, + }; +} + +const unresolved: BoardItem = { + kind: "unresolved", + key: "u", + card: { rid: RID, typeName: "xyz.radicle.issue", oid: "uuuu" }, + column: "todo", + position: "V", +}; + +const alice = { did: "did:key:alice", alias: "alice" }; +const bob = { did: "did:key:bob" }; + +describe("matchesFilters", () => { + const none = defaultView().filters; + + test("everything matches without filters", () => { + expect(matchesFilters(item("a"), none, "")).toBe(true); + expect(matchesFilters(unresolved, none, "")).toBe(true); + }); + + test("by type, which unresolved cards never have", () => { + const filters = { ...none, types: ["patch" as const] }; + expect(matchesFilters(patchItem("p"), filters, "")).toBe(true); + expect(matchesFilters(item("a"), filters, "")).toBe(false); + expect(matchesFilters(unresolved, filters, "")).toBe(false); + }); + + test("by assignee, including nobody", () => { + const assigned = item("a", { assignees: [alice] }); + expect( + matchesFilters(assigned, { ...none, assignees: [alice.did] }, ""), + ).toBe(true); + expect( + matchesFilters(assigned, { ...none, assignees: [bob.did] }, ""), + ).toBe(false); + expect( + matchesFilters(item("b"), { ...none, assignees: ["none"] }, ""), + ).toBe(true); + expect( + matchesFilters(assigned, { ...none, assignees: ["none", bob.did] }, ""), + ).toBe(false); + }); + + test("by priority and label", () => { + const high = item("a", { labels: ["priority:high", "bug"] }); + expect(matchesFilters(high, { ...none, priorities: ["high"] }, "")).toBe( + true, + ); + expect(matchesFilters(high, { ...none, priorities: ["none"] }, "")).toBe( + false, + ); + expect(matchesFilters(high, { ...none, labels: ["bug"] }, "")).toBe(true); + expect( + matchesFilters(high, { ...none, labels: ["priority:high"] }, ""), + ).toBe(false); + }); + + test("by search over title, id, labels and aliases", () => { + const a = item("abcdef", { + title: "Fix the Thing", + labels: ["Docs"], + assignees: [alice], + }); + expect(matchesFilters(a, none, " thing ")).toBe(true); + expect(matchesFilters(a, none, "abc")).toBe(true); + expect(matchesFilters(a, none, "cde")).toBe(false); + expect(matchesFilters(a, none, "doc")).toBe(true); + expect(matchesFilters(a, none, "ALICE")).toBe(true); + expect(matchesFilters(a, none, "bob")).toBe(false); + }); +}); + +describe("compareItems", () => { + const urgent = item("u", { labels: ["priority:urgent"], timestamp: 1 }); + const low = item("l", { labels: ["priority:low"], timestamp: 3 }); + const plain = item("p", { title: "Alpha", timestamp: 2 }); + + test("by priority, then newest first", () => { + expect(compareItems(urgent, low, "priority", false)).toBeLessThan(0); + expect(compareItems(plain, low, "priority", false)).toBeGreaterThan(0); + expect(compareItems(low, plain, "priority", true)).toBeGreaterThan(0); + const older = item("o", { labels: ["priority:low"], timestamp: 1 }); + expect(compareItems(low, older, "priority", false)).toBeLessThan(0); + }); + + test("by creation and title", () => { + expect(compareItems(low, urgent, "created", false)).toBeLessThan(0); + expect(compareItems(low, urgent, "created", true)).toBeGreaterThan(0); + expect(compareItems(plain, low, "title", false)).toBeLessThan(0); + }); + + test("arrange keeps board order when manual", () => { + const groups = { todo: [low, urgent], done: [] }; + expect(arrange(groups, "manual", false)).toBe(groups); + expect(arrange(groups, "priority", false).todo).toEqual([urgent, low]); + expect(groups.todo).toEqual([low, urgent]); + }); +}); + +describe("visibleColumns", () => { + const groups = { backlog: [item("a")] }; + const all = { showCanceled: true, showCompleted: true, showEmpty: true }; + + test("hides closing columns and empty ones by setting", () => { + expect(visibleColumns(DEFAULT_COLUMNS, all, groups, false)).toEqual( + DEFAULT_COLUMNS, + ); + expect( + visibleColumns( + DEFAULT_COLUMNS, + { ...all, showCanceled: false, showCompleted: false }, + groups, + false, + ).map(c => c.id), + ).toEqual(["backlog", "todo", "in-progress", "in-review"]); + expect( + visibleColumns( + DEFAULT_COLUMNS, + { ...all, showEmpty: false }, + groups, + false, + ).map(c => c.id), + ).toEqual(["backlog"]); + }); + + test("shows empty columns while dragging", () => { + expect( + visibleColumns( + DEFAULT_COLUMNS, + { ...all, showEmpty: false }, + groups, + true, + ), + ).toEqual(DEFAULT_COLUMNS); + }); +}); + +describe("listGroups", () => { + const columns = DEFAULT_COLUMNS.slice(0, 2); + const a = item("a", { labels: ["bug", "priority:high"], assignees: [alice] }); + const b = item("b", {}, "backlog"); + const groups = { + backlog: [b], + todo: [a], + done: [item("hidden", {}, "done")], + }; + const people = [{ did: alice.did, name: "alice" }]; + + test("by status follows the columns", () => { + expect( + listGroups( + { grouping: "status", showEmpty: true }, + columns, + groups, + [], + [], + ), + ).toEqual([ + { id: "backlog", name: "Backlog", items: [b] }, + { id: "todo", name: "Todo", items: [a] }, + ]); + }); + + test("other groupings regroup the visible columns' cards", () => { + expect( + listGroups( + { grouping: "priority", showEmpty: false }, + columns, + groups, + [], + [], + ), + ).toEqual([ + { id: "high", name: "High", items: [a] }, + { id: "none", name: "No priority", items: [b] }, + ]); + expect( + listGroups( + { grouping: "assignee", showEmpty: false }, + columns, + groups, + people, + [], + ), + ).toEqual([ + { id: alice.did, name: "alice", items: [a] }, + { id: "none", name: "No assignee", items: [b] }, + ]); + expect( + listGroups( + { grouping: "label", showEmpty: true }, + columns, + groups, + [], + ["bug", "docs"], + ), + ).toEqual([ + { id: "bug", name: "bug", items: [a] }, + { id: "docs", name: "docs", items: [] }, + { id: "none", name: "No label", items: [b] }, + ]); + expect( + listGroups( + { grouping: "none", showEmpty: false }, + columns, + groups, + [], + [], + ), + ).toEqual([{ id: "all", name: "All cards", items: [b, a] }]); + }); +}); + +describe("filter options", () => { + const issues = [ + { assignees: [alice], labels: ["bug", "priority:low"] }, + { assignees: [bob], labels: ["docs"] }, + ] as unknown as Issue[]; + const patches = [ + { assignees: [alice], labels: ["bug"] }, + ] as unknown as Patch[]; + + test("lists each assignee once, by alias or shortened key", () => { + expect(assigneeOptions(issues, patches)).toEqual([ + { did: alice.did, name: "alice" }, + { did: bob.did, name: "bob…" }, + ]); + expect(assigneeOptions([], [])).toEqual([]); + }); + + test("lists display labels once, sorted", () => { + expect(labelOptions(issues, patches)).toEqual(["bug", "docs"]); + }); +}); diff --git a/desktop/tests/unit/repoRouter.test.ts b/desktop/tests/unit/repoRouter.test.ts index 08872fb40..7c2c8b1ab 100644 --- a/desktop/tests/unit/repoRouter.test.ts +++ b/desktop/tests/unit/repoRouter.test.ts @@ -41,6 +41,18 @@ describe("repoRouteToPath and repoUrlToRoute round trip", () => { ], ["/repos/RID/commits/SHA", { resource: "repo.commit", rid, commit: sha }], ["/repos/RID/identity", { resource: "repo.identity", rid }], + [ + "/repos/RID/board", + { resource: "repo.board", rid, view: undefined, board: undefined }, + ], + [ + "/repos/RID/board?view=board", + { resource: "repo.board", rid, view: "board", board: undefined }, + ], + [ + "/repos/RID/board?board=abc", + { resource: "repo.board", rid, view: undefined, board: "abc" }, + ], [ "/repos/RID/issues?status=all", { resource: "repo.issues", rid, status: "all" }, diff --git a/desktop/tests/unit/router.test.ts b/desktop/tests/unit/router.test.ts index 608e98bcd..0c1e0d707 100644 --- a/desktop/tests/unit/router.test.ts +++ b/desktop/tests/unit/router.test.ts @@ -1,6 +1,7 @@ import { get } from "svelte/store"; import { afterEach, beforeEach, describe, expect, test, vi } from "vitest"; +import { labs } from "@app/lib/labs"; import type { Route } from "@app/lib/router"; import { activeUnloadedRouteStore, @@ -25,6 +26,7 @@ async function resolve(path: string) { } beforeEach(() => { + labs.boards = true; vi.stubGlobal("origin", window.location.origin); vi.spyOn(console, "error").mockReturnValue(undefined); }); @@ -33,6 +35,16 @@ afterEach(() => { vi.restoreAllMocks(); }); +test("board links show the repository while boards are off", async () => { + labs.boards = false; + expect(await resolve(`/repos/${rid}/board?view=list`)).toEqual({ + resource: "repo.home", + rid, + }); + expect(window.location.pathname).toBe(`/repos/${rid}/home`); + expect(window.location.search).toBe(""); +}); + describe("routeToPath and loadFromLocation round trip", () => { test.each<[string, Route]>([ ["/inbox", { resource: "inbox" }], @@ -47,6 +59,14 @@ describe("routeToPath and loadFromLocation round trip", () => { { resource: "repo.commit", rid, commit: sha }, ], [`/repos/${rid}/identity`, { resource: "repo.identity", rid }], + [ + `/repos/${rid}/board`, + { resource: "repo.board", rid, view: undefined, board: undefined }, + ], + [ + `/repos/${rid}/board?view=list&board=abc`, + { resource: "repo.board", rid, view: "list", board: "abc" }, + ], [ `/repos/${rid}/issues?status=closed`, { resource: "repo.issues", rid, status: "closed" },