desktop: Add the board view

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ņš <rudolfs@osins.org>
This commit is contained in:
Brandon Oxendine
2026-11-06 18:21:33 +01:00
committed by Rūdolfs Ošiņš
co-authored by Rūdolfs Ošiņš
parent 3c6b455da0
commit 682a406d85
30 changed files with 7305 additions and 10 deletions
+3
View File
@@ -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 @@
<InboxView {...$activeRouteStore.params} />
{:else if $activeRouteStore.resource === "guide"}
<GuideView {...$activeRouteStore.params} />
{:else if $activeRouteStore.resource === "repo.board"}
<Board {...$activeRouteStore.params} />
{:else if $activeRouteStore.resource === "repo.home"}
<RepoHome {...$activeRouteStore.params} />
{:else if $activeRouteStore.resource === "repo.commits"}
+345
View File
@@ -0,0 +1,345 @@
<script lang="ts">
import type { Issue } from "@bindings/cob/issue/Issue";
import type { Patch } from "@bindings/cob/patch/Patch";
import type { BoardItem } from "@app/lib/board";
import {
displayLabels,
itemAssignees,
itemComments,
itemLabels,
itemTimestamp,
itemTitle,
PRIORITIES,
priorityOf,
} from "@app/lib/board";
import type { Property } from "@app/lib/boardView";
import { absoluteTimestamp, formatTimestamp } from "@app/lib/utils";
import AvatarStack from "@app/components/AvatarStack.svelte";
import Icon from "@app/components/Icon.svelte";
import PriorityIcon from "@app/components/PriorityIcon.svelte";
interface Props {
item: BoardItem;
layout: "card" | "row";
properties: Property[];
locked: boolean;
rid: string;
repoName: (rid: string) => string;
seeding: boolean;
column?: string;
onOpenPatch: (patch: Patch) => void;
}
const {
item,
layout,
properties,
locked,
rid,
repoName,
seeding,
column,
onOpenPatch,
}: Props = $props();
const foreign = $derived(item.card.rid !== rid);
const priority = $derived(priorityOf(itemLabels(item)));
const timestamp = $derived(itemTimestamp(item));
function shows(property: Property): boolean {
return properties.includes(property);
}
function shortId(id: string) {
return id.slice(0, 7);
}
const patchIcon: Record<
Patch["state"]["status"],
"patch" | "patch-draft" | "patch-archived" | "patch-merged"
> = {
draft: "patch-draft",
open: "patch",
archived: "patch-archived",
merged: "patch-merged",
};
function issueState(issue: Issue): string {
if (issue.state.status === "open") return "Open";
return issue.state.reason === "solved"
? "Closed as solved"
: "Closed as other";
}
function patchState(patch: Patch): string {
return {
draft: "Draft",
open: "Open",
archived: "Archived",
merged: "Merged",
}[patch.state.status];
}
</script>
<style>
.card-top {
display: flex;
align-items: center;
gap: 0.5rem;
font: var(--txt-body-s-regular);
color: var(--color-text-tertiary);
}
.card-title {
font: var(--txt-body-m-regular);
color: var(--color-text-primary);
word-break: break-word;
}
.card-title.locked,
.card-top.locked > span:not(.lock) {
color: var(--color-text-secondary);
}
.card-meta {
display: flex;
align-items: center;
flex-wrap: wrap;
gap: 0.25rem;
}
.chip {
display: inline-flex;
align-items: center;
height: 1.25rem;
padding: 0 0.375rem;
border: 1px solid var(--color-border-subtle);
border-radius: var(--border-radius-sm);
color: var(--color-text-tertiary);
font: var(--txt-body-s-regular);
max-width: 10rem;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.patch-chip {
gap: 0.25rem;
background: none;
cursor: pointer;
font-family: var(--font-family-monospace, monospace);
}
.patch-chip:hover {
border-color: var(--color-border-mid);
color: var(--color-text-primary);
}
.closed {
gap: 0.25rem;
color: var(--color-text-secondary);
}
.unresolved {
font: var(--txt-body-s-regular);
color: var(--color-text-tertiary);
}
.assignees {
display: flex;
margin-left: auto;
}
.lock {
display: flex;
flex-shrink: 0;
color: var(--color-text-quaternary);
}
.card-top .lock {
margin-left: auto;
}
.card-top .assignees + .lock {
margin-left: 0;
}
.row-id {
color: var(--color-text-tertiary);
width: 4.5rem;
flex-shrink: 0;
font-family: var(--font-family-monospace, monospace);
}
.row-main {
display: flex;
align-items: center;
gap: 0.5rem;
flex: 1;
min-width: 0;
}
.row-main > *:not(.row-title) {
flex-shrink: 0;
}
.row-title {
flex: 0 1 auto;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
color: var(--color-text-primary);
}
.row-comments {
display: flex;
justify-content: flex-end;
flex-shrink: 0;
width: 3.5rem;
}
.row-date {
color: var(--color-text-tertiary);
white-space: nowrap;
width: 2.5rem;
flex-shrink: 0;
text-align: right;
}
</style>
{#snippet assignees()}
{@const people = itemAssignees(item)}
{#if people.length > 0}
<div class="assignees">
<AvatarStack {people} />
</div>
{/if}
{/snippet}
{#snippet labels()}
{#each displayLabels(itemLabels(item)) as label}
<span class="chip" title={label}>{label}</span>
{/each}
{/snippet}
{#snippet patchChips()}
{#if item.kind === "issue"}
{#each item.patches as patch (patch.id)}
<button
class="chip patch-chip"
title={`${patch.title} (${patchState(patch)})`}
onpointerdown={e => e.stopPropagation()}
onclick={e => {
e.stopPropagation();
onOpenPatch(patch);
}}>
<Icon name={patchIcon[patch.state.status]} />
{shortId(patch.id)}
</button>
{/each}
{/if}
{/snippet}
{#snippet repoChip()}
{#if foreign}
<span class="chip" style:gap="0.25rem" title={item.card.rid}>
<Icon name="repository" />{repoName(item.card.rid)}
</span>
{/if}
{#if item.kind === "unresolved"}
<span class="unresolved" title={item.card.rid}>
{seeding ? "Seeding…" : "Click to seed this repository"}
</span>
{/if}
{/snippet}
{#snippet closedNote()}
{#if item.kind === "issue" && item.showsClosed}
<span
class="chip closed"
title="This board has no column for closed issues">
<Icon name="issue-closed" />Closed
</span>
{/if}
{/snippet}
{#snippet commentCount()}
{@const comments = itemComments(item)}
{#if comments > 0 && shows("comments")}
<span class="chip" style:gap="0.25rem">
<Icon name="comment" />{comments}
</span>
{/if}
{/snippet}
{#snippet typeIcon()}
{#if item.kind === "issue"}
<span title={issueState(item.issue)} style:display="flex">
<Icon
name={item.issue.state.status === "open" ? "issue" : "issue-closed"} />
</span>
{:else if item.kind === "patch"}
<span title={patchState(item.patch)} style:display="flex">
<Icon name={patchIcon[item.patch.state.status]} />
</span>
{/if}
{/snippet}
{#snippet lock()}
{#if locked}
<span class="lock"><Icon name="lock" /></span>
{/if}
{/snippet}
{#if layout === "card"}
<div class="card-top" class:locked>
{@render typeIcon()}
{#if shows("id")}
<span>{shortId(item.card.oid)}</span>
{/if}
{#if shows("id") && shows("created")}
<span>·</span>
{/if}
{#if shows("created")}
<span title={absoluteTimestamp(timestamp)}>
{formatTimestamp(timestamp)}
</span>
{/if}
{#if shows("assignees")}{@render assignees()}{/if}
{@render lock()}
</div>
<div class="card-title" class:locked>{itemTitle(item)}</div>
<div class="card-meta">
{#if item.kind !== "unresolved" && shows("priority")}
<span
title={PRIORITIES.find(p => p.id === priority)?.label}
style:display="flex">
<PriorityIcon {priority} />
</span>
{/if}
{#if shows("repository")}{@render repoChip()}{/if}
{#if shows("patches")}{@render patchChips()}{/if}
{#if shows("labels")}{@render labels()}{/if}
{@render commentCount()}
{@render closedNote()}
</div>
{:else}
{#if item.kind !== "unresolved" && shows("priority")}
<PriorityIcon {priority} />
{/if}
{#if shows("id")}
<span class="row-id">{shortId(item.card.oid)}</span>
{/if}
{@render typeIcon()}
<div class="row-main">
<span class="row-title">{itemTitle(item)}</span>
{#if shows("patches")}{@render patchChips()}{/if}
{#if shows("repository")}{@render repoChip()}{/if}
</div>
{@render lock()}
{@render closedNote()}
<div class="card-meta">
{#if column !== undefined}
<span class="chip">{column}</span>
{/if}
{#if shows("labels")}{@render labels()}{/if}
</div>
{#if shows("comments")}
<div class="row-comments">{@render commentCount()}</div>
{/if}
{#if shows("assignees")}
<div style:width="4rem" style:display="flex">
{@render assignees()}
</div>
{/if}
{#if shows("created")}
<span class="row-date" title={absoluteTimestamp(timestamp)}>
{formatTimestamp(timestamp)}
</span>
{/if}
{/if}
+109
View File
@@ -0,0 +1,109 @@
<script lang="ts">
import type { Column } from "@bindings/cob/board/Column";
import type { BoardItem, Priority } from "@app/lib/board";
import { itemLabels, PRIORITIES, priorityOf } from "@app/lib/board";
import ContextMenu from "@app/components/ContextMenu.svelte";
import Icon from "@app/components/Icon.svelte";
import PriorityIcon from "@app/components/PriorityIcon.svelte";
interface Props {
item: BoardItem;
x: number;
y: number;
target: HTMLElement;
targets: Column[];
canRemove: boolean;
canPrioritize: boolean;
onMove: (column: string) => void;
onRemove: () => void;
onPriority: (priority: Priority) => void;
onclose: () => void;
}
const {
item,
x,
y,
target,
targets,
canRemove,
canPrioritize,
onMove,
onRemove,
onPriority,
onclose,
}: Props = $props();
const showMove = $derived(targets.length > 0);
const currentPriority = $derived(priorityOf(itemLabels(item)));
</script>
<style>
.check {
margin-left: auto;
color: var(--color-text-tertiary);
}
.menu-heading {
padding: 0.25rem 0.75rem;
font: var(--txt-body-s-regular);
color: var(--color-text-tertiary);
}
</style>
<ContextMenu {x} {y} {target} {onclose}>
{#if showMove}
<div class="menu-heading">Move to</div>
{#each targets as column (column.id)}
<button
class="menu-item"
role="menuitem"
onclick={() => {
onMove(column.id);
onclose();
}}>
{column.name}
{#if item.column === column.id}
<span class="check"><Icon name="checkmark" /></span>
{/if}
</button>
{/each}
{/if}
{#if canRemove}
{#if showMove}
<div class="menu-separator"></div>
{/if}
<button
class="menu-item"
role="menuitem"
onclick={() => {
onRemove();
onclose();
}}>
<Icon name="trash" />Remove from board
</button>
{/if}
{#if canPrioritize}
{#if showMove || canRemove}
<div class="menu-separator"></div>
{/if}
<div class="menu-heading">Priority</div>
{#each PRIORITIES as priority (priority.id)}
<button
class="menu-item"
role="menuitem"
onclick={() => {
onPriority(priority.id);
onclose();
}}>
<PriorityIcon priority={priority.id} />
{priority.label}
{#if currentPriority === priority.id}
<span class="check"><Icon name="checkmark" /></span>
{/if}
</button>
{/each}
{/if}
</ContextMenu>
@@ -0,0 +1,191 @@
<script lang="ts">
import type { BoardView } from "@app/lib/board";
import type { BoardViewSettings, Property } from "@app/lib/boardView";
import {
defaultView,
GROUPINGS,
ORDERINGS,
PROPERTIES,
} from "@app/lib/boardView";
import Checkbox from "@app/components/Checkbox.svelte";
import Icon from "@app/components/Icon.svelte";
import SegmentedSwitch from "@app/components/SegmentedSwitch.svelte";
import Select from "@app/components/Select.svelte";
interface Props {
view: BoardView;
settings: BoardViewSettings;
onView: (view: BoardView) => void;
}
/* eslint-disable prefer-const */
let { view, settings = $bindable(), onView }: Props = $props();
/* eslint-enable prefer-const */
function toggleProperty(property: Property) {
settings.properties = settings.properties.includes(property)
? settings.properties.filter(p => p !== property)
: [...settings.properties, property];
}
function reset() {
const { filters } = settings;
settings = { ...defaultView(), filters };
}
</script>
<style>
.menu {
width: 20rem;
display: flex;
flex-direction: column;
border: 1px solid var(--color-border-subtle);
border-radius: var(--border-radius-md);
background-color: var(--color-surface-canvas);
font: var(--txt-body-m-regular);
color: var(--color-text-secondary);
}
.section {
display: flex;
flex-direction: column;
gap: 0.625rem;
padding: 0.875rem 1rem;
}
.section + .section {
border-top: 1px solid var(--color-border-subtle);
}
.row {
display: flex;
align-items: center;
justify-content: space-between;
gap: 0.5rem;
min-height: 2rem;
}
.controls {
display: flex;
align-items: center;
gap: 0.25rem;
}
.row.disabled {
color: var(--color-text-disabled);
}
.direction {
display: flex;
align-items: center;
justify-content: center;
width: 2rem;
height: 2rem;
padding: 0;
border: 0;
border-radius: var(--border-radius-sm);
background: none;
color: var(--color-text-tertiary);
cursor: pointer;
}
.direction:hover {
background-color: var(--color-surface-subtle);
color: var(--color-text-primary);
}
.heading {
color: var(--color-text-primary);
}
.chips {
display: flex;
flex-wrap: wrap;
gap: 0.375rem;
}
.chip {
padding: 0.25rem 0.625rem;
border: 1px solid var(--color-border-subtle);
border-radius: var(--border-radius-sm);
background: none;
color: var(--color-text-tertiary);
font: var(--txt-body-m-regular);
cursor: pointer;
}
.chip.on {
background-color: var(--color-surface-subtle);
color: var(--color-text-primary);
}
.footer {
display: flex;
justify-content: flex-end;
padding: 0.625rem 1rem;
border-top: 1px solid var(--color-border-subtle);
}
.reset {
border: 0;
background: none;
color: var(--color-text-secondary);
font: var(--txt-body-m-regular);
cursor: pointer;
}
.reset:hover {
color: var(--color-text-primary);
}
</style>
<div class="menu">
<div class="section">
<SegmentedSwitch
options={[
{ value: "board", label: "Columns", icon: "dashboard" },
{ value: "list", label: "List", icon: "menu" },
]}
value={view}
onchange={onView} />
<div class="row" class:disabled={view !== "list"}>
Grouping
<Select
label="Grouping"
title={view === "list" ? undefined : "Grouping applies to the list"}
disabled={view !== "list"}
options={GROUPINGS.map(g => ({ value: g.id, label: g.label }))}
bind:value={settings.grouping} />
</div>
<div class="row">
Ordering
<div class="controls">
{#if settings.ordering !== "manual"}
<button
class="direction"
title={settings.descending ? "Descending" : "Ascending"}
aria-label="Reverse the order"
onclick={() => (settings.descending = !settings.descending)}>
<Icon name={settings.descending ? "arrow-down" : "arrow-up"} />
</button>
{/if}
<Select
label="Ordering"
options={ORDERINGS.map(o => ({ value: o.id, label: o.label }))}
bind:value={settings.ordering} />
</div>
</div>
</div>
<div class="section">
<Checkbox bind:checked={settings.showCompleted}>Show completed</Checkbox>
<Checkbox bind:checked={settings.showCanceled}>Show canceled</Checkbox>
<Checkbox bind:checked={settings.showEmpty}>Show empty columns</Checkbox>
</div>
<div class="section">
<span class="heading">Display</span>
<div class="chips">
{#each PROPERTIES as property (property.id)}
<button
class="chip"
class:on={settings.properties.includes(property.id)}
aria-pressed={settings.properties.includes(property.id)}
onclick={() => toggleProperty(property.id)}>
{property.label}
</button>
{/each}
</div>
</div>
<div class="footer">
<button class="reset" onclick={reset}>Reset</button>
</div>
</div>
@@ -0,0 +1,164 @@
<script lang="ts">
import { PRIORITIES } from "@app/lib/board";
import type { BoardFilters } from "@app/lib/boardView";
import { activeFilterCount } from "@app/lib/boardView";
import Checkbox from "@app/components/Checkbox.svelte";
import PriorityIcon from "@app/components/PriorityIcon.svelte";
interface Props {
filters: BoardFilters;
people: { did: string; name: string }[];
labels: string[];
me: string;
}
/* eslint-disable prefer-const */
let { filters = $bindable(), people, labels, me }: Props = $props();
/* eslint-enable prefer-const */
type ListKey = keyof BoardFilters;
function has(key: ListKey, value: string): boolean {
return (filters[key] as string[]).includes(value);
}
function toggle(key: ListKey, value: string) {
const list = filters[key] as string[];
filters = {
...filters,
[key]: list.includes(value)
? list.filter(v => v !== value)
: [...list, value],
};
}
function clear() {
filters = { types: [], assignees: [], priorities: [], labels: [] };
}
</script>
<style>
.menu {
width: 18rem;
max-height: 32rem;
overflow-y: auto;
display: flex;
flex-direction: column;
border: 1px solid var(--color-border-subtle);
border-radius: var(--border-radius-md);
background-color: var(--color-surface-canvas);
font: var(--txt-body-m-regular);
color: var(--color-text-primary);
}
.section {
display: flex;
flex-direction: column;
gap: 0.5rem;
padding: 0.75rem 1rem;
}
.section + .section {
border-top: 1px solid var(--color-border-subtle);
}
.heading {
font: var(--txt-body-s-regular);
color: var(--color-text-tertiary);
}
.option {
display: inline-flex;
align-items: center;
gap: 0.375rem;
}
.footer {
display: flex;
justify-content: flex-end;
padding: 0.625rem 1rem;
border-top: 1px solid var(--color-border-subtle);
}
.clear {
border: 0;
background: none;
color: var(--color-text-secondary);
font: var(--txt-body-m-regular);
cursor: pointer;
}
.clear:disabled {
color: var(--color-text-disabled);
cursor: default;
}
.clear:hover:not(:disabled) {
color: var(--color-text-primary);
}
</style>
<div class="menu">
<div class="section">
<span class="heading">Type</span>
<Checkbox
checked={has("types", "issue")}
onchange={() => toggle("types", "issue")}>
Issues
</Checkbox>
<Checkbox
checked={has("types", "patch")}
onchange={() => toggle("types", "patch")}>
Patches
</Checkbox>
</div>
<div class="section">
<span class="heading">Assignee</span>
<Checkbox
checked={has("assignees", me)}
onchange={() => toggle("assignees", me)}>
Me
</Checkbox>
<Checkbox
checked={has("assignees", "none")}
onchange={() => toggle("assignees", "none")}>
Unassigned
</Checkbox>
{#each people.filter(p => p.did !== me) as person (person.did)}
<Checkbox
checked={has("assignees", person.did)}
onchange={() => toggle("assignees", person.did)}>
{person.name}
</Checkbox>
{/each}
</div>
<div class="section">
<span class="heading">Priority</span>
{#each PRIORITIES as priority (priority.id)}
<Checkbox
checked={has("priorities", priority.id)}
onchange={() => toggle("priorities", priority.id)}>
<span class="option">
<PriorityIcon priority={priority.id} />{priority.label}
</span>
</Checkbox>
{/each}
</div>
<div class="section">
<span class="heading">Label</span>
{#each labels as label (label)}
<Checkbox
checked={has("labels", label)}
onchange={() => toggle("labels", label)}>
{label}
</Checkbox>
{:else}
<Checkbox disabled>No labels yet</Checkbox>
{/each}
</div>
<div class="footer">
<button
class="clear"
disabled={activeFilterCount(filters) === 0}
onclick={clear}>
Clear filters
</button>
</div>
</div>
@@ -0,0 +1,51 @@
<script lang="ts">
import type { Priority } from "@app/lib/board";
interface Props {
priority: Priority;
}
const { priority }: Props = $props();
const COLUMNS = [0, 5, 10];
const ROWS = [10, 5, 0];
const level = $derived(
{ urgent: 3, high: 3, medium: 2, low: 1, none: 0 }[priority],
);
// A staircase of pixels: column i is i + 1 pixels tall. Urgent fills the
// whole 3x3 grid instead so it reads as a different shape, not just a colour.
const pixels = $derived(
COLUMNS.flatMap((x, col) =>
ROWS.filter((_, row) => priority === "urgent" || row <= col).map(y => ({
x,
y,
filled: priority === "urgent" || col < level,
})),
),
);
const fill = $derived(
priority === "urgent"
? "var(--color-feedback-error-fill)"
: "var(--color-text-secondary)",
);
</script>
<svg
width="14"
height="14"
viewBox="0 0 14 14"
shape-rendering="crispEdges"
style:flex-shrink="0"
aria-hidden="true">
{#each pixels as pixel (`${pixel.x}-${pixel.y}`)}
<rect
x={pixel.x}
y={pixel.y}
width="4"
height="4"
fill={pixel.filled ? fill : "var(--color-surface-strong)"} />
{/each}
</svg>
+19 -1
View File
@@ -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 @@
<div
class="sub-items"
transition:slide={{ duration: ANIMATION_DURATION_MS }}>
{#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,
+685
View File
@@ -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<Priority, number> = {
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<BoardItem, { kind: "issue" }>;
export type ForeignCard =
| { kind: "issue"; issue: Issue }
| { kind: "patch"; patch: Patch }
| { kind: "unresolved" };
type Unpositioned = BoardItem extends infer T
? T extends BoardItem
? Omit<T, "position">
: 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<string, ForeignCard> = new Map(),
): Record<string, BoardItem[]> {
const columns = board?.columns ?? DEFAULT_COLUMNS;
const placements = new Map<string, Placement>();
for (const placement of board?.cards ?? []) {
placements.set(cardKey(placement.card), placement);
}
const linkedPatches = new Set<string>();
const patchesByIssue = new Map<string, Patch[]>();
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<string, BoardItem[]> = 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<string, BoardItem[]>,
move: Move | undefined,
): Record<string, BoardItem[]> {
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;
}
+44
View File
@@ -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<Board[]>("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<Board>("create_board", {
rid,
name,
opts: opts(),
});
await router.push({
resource: "repo.board",
rid,
board: created.id,
});
},
},
});
}
+413
View File
@@ -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<HTMLElement>("[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<HTMLElement>("[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<HTMLElement>(
`[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<HTMLElement>(
"[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;
}
}
+165
View File
@@ -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<Card[]>;
setState(
rid: string,
oid: string,
state: State,
announce: boolean,
): Promise<void>;
// The committed board or issues of `rid` changed underneath the queue.
reload(rid: string): Promise<void>;
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<string, QueuedChange>();
#writing = new SvelteMap<string, QueuedChange>();
#writer: BoardWriter;
#delay: number;
#timer: ReturnType<typeof setTimeout> | undefined;
#flushing: Promise<void> = 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<void> {
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);
}
}
}
}
}
+342
View File
@@ -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<typeof filtersSchema>;
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<typeof schema>;
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<string, unknown> {
try {
const raw = globalThis.localStorage?.getItem(KEY);
const parsed: unknown = raw ? JSON.parse(raw) : {};
return parsed && typeof parsed === "object"
? (parsed as Record<string, unknown>)
: {};
} 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<string, BoardItem[]>,
ordering: Ordering,
descending: boolean,
): Record<string, BoardItem[]> {
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<string, BoardItem[]>,
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<string, string> = {};
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<BoardViewSettings, "grouping" | "showEmpty">,
columns: Column[],
groups: Record<string, BoardItem[]>,
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 }];
}
}
+16
View File
@@ -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;
},
};
+14 -2
View File
@@ -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<void> {
// 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" ||
+4
View File
@@ -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") {
+227
View File
@@ -0,0 +1,227 @@
<script lang="ts">
import type { Card } from "@bindings/cob/board/Card";
import type { Issue } from "@bindings/cob/issue/Issue";
import type { PaginatedQuery } from "@bindings/cob/PaginatedQuery";
import type { Patch } from "@bindings/cob/patch/Patch";
import type { RepoSummary } from "@bindings/repo/RepoSummary";
import { cardFor, cardKey, ISSUE_TYPE, PATCH_TYPE } from "@app/lib/board";
import { invoke } from "@app/lib/invoke";
import { disableHide, enableHide, hide } from "@app/lib/modal";
import Button from "@app/components/Button.svelte";
import Icon from "@app/components/Icon.svelte";
import TextInput from "@app/components/TextInput.svelte";
interface Props {
repos: RepoSummary[];
onBoard: string[];
add: (card: Card) => Promise<void>;
}
const { repos, onBoard, add }: Props = $props();
// A board never names a private repository other than its own.
const candidates = $derived(repos.filter(r => !r.private));
let repo: RepoSummary | undefined = $state();
let items: { card: Card; title: string; kind: "issue" | "patch" }[] = $state(
[],
);
let loading = $state(false);
let working = $state(false);
let filter = $state("");
let error = $state<string | undefined>(undefined);
const shown = $derived(
items.filter(i =>
i.title.toLowerCase().includes(filter.trim().toLowerCase()),
),
);
async function pick(next: RepoSummary) {
repo = next;
loading = true;
error = undefined;
try {
const [issues, patches] = await Promise.all([
invoke<PaginatedQuery<Issue[]>>("list_issues", {
rid: next.rid,
status: "open",
}),
invoke<PaginatedQuery<Patch[]>>("list_patches", {
rid: next.rid,
status: "open",
}),
]);
items = [
...issues.content.map(i => ({
card: cardFor(next.rid, ISSUE_TYPE, i.id),
title: i.title,
kind: "issue" as const,
})),
...patches.content.map(p => ({
card: cardFor(next.rid, PATCH_TYPE, p.id),
title: p.title,
kind: "patch" as const,
})),
];
} catch (e) {
console.error("Loading the repository's cards failed", e);
error = "Couldn't load this repository's issues and patches.";
items = [];
} finally {
loading = false;
}
}
async function choose(card: Card) {
if (working) return;
working = true;
error = undefined;
disableHide();
try {
await add(card);
enableHide();
hide();
} catch (e) {
error =
e instanceof Error
? e.message
: ((e as { message?: string }).message ?? "Adding the card failed.");
enableHide();
} finally {
working = false;
}
}
</script>
<style>
.modal {
width: 32rem;
max-height: 32rem;
display: flex;
flex-direction: column;
border: 1px solid var(--color-border-subtle);
border-radius: var(--border-radius-lg);
background-color: var(--color-surface-canvas);
overflow: hidden;
}
.header {
display: flex;
align-items: center;
gap: 0.5rem;
padding: 0 1.5rem;
height: 3.25rem;
flex-shrink: 0;
border-bottom: 1px solid var(--color-border-subtle);
}
.title {
font: var(--txt-heading-s);
color: var(--color-text-primary);
}
.body {
display: flex;
flex-direction: column;
gap: 0.5rem;
padding: 1rem 1.5rem 1.5rem;
overflow-y: auto;
font: var(--txt-body-m-regular);
color: var(--color-text-secondary);
}
.option {
display: flex;
align-items: center;
gap: 0.5rem;
width: 100%;
min-height: 2.25rem;
padding: 0 0.5rem;
border: 0;
border-radius: var(--border-radius-sm);
background: none;
color: var(--color-text-primary);
font: var(--txt-body-m-regular);
text-align: left;
cursor: pointer;
}
.option:hover:not(:disabled) {
background-color: var(--color-surface-subtle);
}
.option:disabled {
color: var(--color-text-tertiary);
cursor: default;
}
.option .meta {
margin-left: auto;
color: var(--color-text-tertiary);
white-space: nowrap;
}
.muted {
color: var(--color-text-tertiary);
}
.error {
color: var(--color-feedback-error-text);
}
</style>
<div class="modal">
<div class="header">
{#if repo}
<Button variant="naked" onclick={() => (repo = undefined)}>
<span style:color="var(--color-text-tertiary)">
<Icon name="arrow-left" />
</span>
</Button>
{/if}
<span class="title txt-overflow">
{repo ? `Add from ${repo.name}` : "Add a card from another repository"}
</span>
<span style:margin-left="auto">
<Button variant="naked" onclick={hide}>
<span style:color="var(--color-text-tertiary)">
<Icon name="close" />
</span>
</Button>
</span>
</div>
<div class="body">
{#if !repo}
{#each candidates as candidate (candidate.rid)}
<button class="option" onclick={() => void pick(candidate)}>
<Icon name="repository" />
<span class="txt-overflow">{candidate.name}</span>
<span class="meta">{candidate.rid.slice(0, 12)}…</span>
</button>
{:else}
<span class="muted">
No other public repositories on this device. Cards from private
repositories can only go on their own repository's boards.
</span>
{/each}
{:else}
<TextInput autofocus placeholder="Filter" bind:value={filter} />
{#if loading}
<span class="muted">Loading…</span>
{:else}
{#each shown as item (cardKey(item.card))}
{@const added = onBoard.includes(cardKey(item.card))}
<button
class="option"
disabled={added || working}
onclick={() => void choose(item.card)}>
<Icon name={item.kind === "issue" ? "issue" : "patch"} />
<span class="txt-overflow">{item.title}</span>
<span class="meta">
{added ? "On the board" : item.card.oid.slice(0, 7)}
</span>
</button>
{:else}
<span class="muted">No open issues or patches.</span>
{/each}
{/if}
{/if}
{#if error}
<span class="error">{error}</span>
{/if}
</div>
</div>
+355
View File
@@ -0,0 +1,355 @@
<script lang="ts">
import type { Closes } from "@bindings/cob/board/Closes";
import type { Column } from "@bindings/cob/board/Column";
import { flip } from "svelte/animate";
import { cubicOut } from "svelte/easing";
import {
MAX_COLUMN_ID_LENGTH,
MAX_COLUMNS,
MAX_NAME_LENGTH,
validName,
} from "@app/lib/board";
import { disableHide, enableHide, hide } from "@app/lib/modal";
import Button from "@app/components/Button.svelte";
import Icon from "@app/components/Icon.svelte";
import Select from "@app/components/Select.svelte";
interface Props {
columns: Column[];
save: (columns: Column[]) => Promise<void>;
}
const { columns: initial, save }: Props = $props();
const COLUMN_KINDS: { value: Closes | ""; label: string }[] = [
{ value: "", label: "Keeps issues open" },
{ value: "solved", label: "Closes as solved" },
{ value: "other", label: "Closes as other" },
];
interface Row {
key: number;
id?: string;
name: string;
closes: Closes | "";
}
// svelte-ignore state_referenced_locally
let rows: Row[] = $state(
initial.map((c, key) => ({
key,
id: c.id,
name: c.name,
closes: c.closes ?? "",
})),
);
// svelte-ignore state_referenced_locally
let nextKey = initial.length;
let listEl: HTMLElement | undefined = $state();
let dragging: number | undefined = $state();
function startDrag(event: PointerEvent, key: number) {
if (event.button !== 0) return;
event.preventDefault();
dragging = key;
}
// Reorders as the pointer crosses a row's middle, so the rows already sit
// where they'll be saved.
function onPointerMove(event: PointerEvent) {
if (dragging === undefined || !listEl) return;
const from = rows.findIndex(r => r.key === dragging);
let to = 0;
for (const el of listEl.querySelectorAll<HTMLElement>("[data-row]")) {
if (Number(el.dataset.row) === dragging) continue;
const rect = el.getBoundingClientRect();
const matrix = new DOMMatrix(getComputedStyle(el).transform);
if (rect.top - matrix.m42 + rect.height / 2 < event.clientY) to++;
}
if (to !== from) {
const next = [...rows];
const [row] = next.splice(from, 1);
next.splice(to, 0, row);
rows = next;
}
}
let working = $state(false);
let error = $state<string | undefined>(undefined);
const valid = $derived(
rows.length > 0 &&
rows.length <= MAX_COLUMNS &&
rows.every(r => validName(r.name)),
);
// A new column's id gets a random suffix, so re-adding a column that was
// removed doesn't bring back the cards still placed in the old one.
function slug(name: string): string {
return (
name
.trim()
.toLowerCase()
.replace(/[^a-z0-9]+/g, "-")
.replace(/^-|-$/g, "")
.slice(0, MAX_COLUMN_ID_LENGTH - 9) || "column"
);
}
function suffix(): string {
const bytes = crypto.getRandomValues(new Uint8Array(4));
return Array.from(bytes, b => b.toString(16).padStart(2, "0")).join("");
}
function toColumns(): Column[] {
const taken = rows.flatMap(r => (r.id ? [r.id] : []));
return rows.map(row => {
let id = row.id;
if (!id) {
const base = slug(row.name);
do id = `${base}-${suffix()}`;
while (taken.includes(id));
taken.push(id);
}
return {
id,
name: row.name.trim(),
...(row.closes ? { closes: row.closes } : {}),
};
});
}
function move(index: number, by: number) {
const target = index + by;
if (target < 0 || target >= rows.length) return;
const next = [...rows];
[next[index], next[target]] = [next[target], next[index]];
rows = next;
}
async function submit() {
if (working || !valid) return;
working = true;
error = undefined;
disableHide();
try {
await save(toColumns());
enableHide();
hide();
} catch (e) {
error =
e instanceof Error
? e.message
: ((e as { message?: string }).message ?? "Saving failed.");
enableHide();
} finally {
working = false;
}
}
</script>
<style>
.modal {
width: 32rem;
display: flex;
flex-direction: column;
border: 1px solid var(--color-border-subtle);
border-radius: var(--border-radius-lg);
background-color: var(--color-surface-canvas);
overflow: hidden;
}
.header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 0 1.5rem;
height: 3.25rem;
border-bottom: 1px solid var(--color-border-subtle);
}
.title {
font: var(--txt-heading-s);
color: var(--color-text-primary);
}
.body {
display: flex;
flex-direction: column;
gap: 0.5rem;
padding: 1.5rem;
font: var(--txt-body-m-regular);
color: var(--color-text-secondary);
}
.rows {
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.row {
display: flex;
align-items: center;
gap: 0.5rem;
border-radius: var(--border-radius-sm);
background-color: var(--color-surface-canvas);
}
.row.dragging {
position: relative;
z-index: 1;
box-shadow: 0 6px 18px rgba(0, 0, 0, 0.15);
}
.handle {
cursor: grab;
touch-action: none;
}
.row.dragging .handle {
cursor: grabbing;
}
input {
height: 2rem;
padding: 0 0.5rem;
border: 1px solid var(--color-border-subtle);
border-radius: var(--border-radius-sm);
background-color: var(--color-surface-canvas);
color: var(--color-text-primary);
font: var(--txt-body-m-regular);
}
input {
flex: 1;
min-width: 0;
}
input:focus {
outline: none;
border-color: var(--color-border-brand);
}
.icon-button {
display: flex;
align-items: center;
justify-content: center;
width: 1.75rem;
height: 1.75rem;
padding: 0;
border: 0;
border-radius: var(--border-radius-sm);
background: none;
color: var(--color-text-tertiary);
cursor: pointer;
}
.icon-button:hover:not(:disabled) {
background-color: var(--color-surface-subtle);
color: var(--color-text-primary);
}
.icon-button:disabled {
opacity: 0.4;
cursor: default;
}
.add {
align-self: flex-start;
display: flex;
align-items: center;
gap: 0.25rem;
padding: 0.25rem 0.5rem;
border: 0;
border-radius: var(--border-radius-sm);
background: none;
color: var(--color-text-secondary);
font: var(--txt-body-m-regular);
cursor: pointer;
}
.add:hover {
background-color: var(--color-surface-subtle);
color: var(--color-text-primary);
}
.note {
color: var(--color-text-tertiary);
}
.error {
color: var(--color-feedback-error-text);
}
.actions {
display: flex;
justify-content: flex-end;
gap: 0.5rem;
padding: 0 1.5rem 1.5rem;
}
</style>
<svelte:window
onpointermove={onPointerMove}
onpointerup={() => (dragging = undefined)}
onpointercancel={() => (dragging = undefined)} />
<div class="modal">
<div class="header">
<span class="title">Edit columns</span>
<Button variant="naked" onclick={hide}>
<span style:color="var(--color-text-tertiary)">
<Icon name="close" />
</span>
</Button>
</div>
<div class="body">
<div class="rows" bind:this={listEl}>
{#each rows as row, index (row.key)}
<div
class="row"
class:dragging={dragging === row.key}
data-row={row.key}
animate:flip={{ duration: 150, easing: cubicOut }}>
<button
class="icon-button handle"
title="Drag to reorder"
aria-label="Drag to reorder, or use the arrow keys"
onpointerdown={e => startDrag(e, row.key)}
onkeydown={e => {
if (e.key === "ArrowUp" || e.key === "ArrowDown") {
e.preventDefault();
move(index, e.key === "ArrowUp" ? -1 : 1);
}
}}>
<Icon name="drag-handle" />
</button>
<input
aria-label="Column name"
placeholder="Column name"
maxlength={MAX_NAME_LENGTH}
bind:value={row.name} />
<Select
label="What the column does"
options={COLUMN_KINDS}
bind:value={row.closes} />
<button
class="icon-button"
title="Delete column"
disabled={rows.length === 1}
onclick={() => (rows = rows.filter((_, i) => i !== index))}>
<Icon name="trash" />
</button>
</div>
{/each}
</div>
<button
class="add"
disabled={rows.length >= MAX_COLUMNS}
onclick={() =>
(rows = [...rows, { key: nextKey++, name: "", closes: "" }])}>
<Icon name="plus" />Add column
</button>
<span class="note">
Cards in a deleted column go back to the first column that keeps issues
open.
</span>
{#if error}
<span class="error">{error}</span>
{/if}
</div>
<div class="actions">
<Button variant="outline" onclick={hide}>Cancel</Button>
<Button
variant="secondary"
disabled={working || !valid}
onclick={() => void submit()}>
{working ? "Saving…" : "Save"}
</Button>
</div>
</div>
+116
View File
@@ -0,0 +1,116 @@
<script lang="ts">
import { MAX_NAME_LENGTH, validName } from "@app/lib/board";
import { disableHide, enableHide, hide } from "@app/lib/modal";
import Button from "@app/components/Button.svelte";
import Icon from "@app/components/Icon.svelte";
import TextInput from "@app/components/TextInput.svelte";
interface Props {
title: string;
action: string;
name?: string;
save: (name: string) => Promise<void>;
}
const { title, action, name: initial = "", save }: Props = $props();
// svelte-ignore state_referenced_locally
let name = $state(initial);
let working = $state(false);
let error = $state<string | undefined>(undefined);
const valid = $derived(validName(name));
async function submit() {
if (working || !valid) return;
working = true;
error = undefined;
disableHide();
try {
await save(name.trim());
enableHide();
hide();
} catch (e) {
error =
e instanceof Error
? e.message
: ((e as { message?: string }).message ?? "Saving failed.");
enableHide();
} finally {
working = false;
}
}
</script>
<style>
.modal {
width: 26rem;
display: flex;
flex-direction: column;
border: 1px solid var(--color-border-subtle);
border-radius: var(--border-radius-lg);
background-color: var(--color-surface-canvas);
overflow: hidden;
}
.header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 0 1.5rem;
height: 3.25rem;
border-bottom: 1px solid var(--color-border-subtle);
}
.title {
font: var(--txt-heading-s);
color: var(--color-text-primary);
}
.body {
display: flex;
flex-direction: column;
gap: 0.75rem;
padding: 1.5rem;
}
.error {
font: var(--txt-body-m-regular);
color: var(--color-feedback-error-text);
}
.actions {
display: flex;
justify-content: flex-end;
gap: 0.5rem;
padding: 0 1.5rem 1.5rem;
}
</style>
<div class="modal">
<div class="header">
<span class="title">{title}</span>
<Button variant="naked" onclick={hide}>
<span style:color="var(--color-text-tertiary)">
<Icon name="close" />
</span>
</Button>
</div>
<div class="body">
<TextInput
autofocus
autoselect
placeholder="Board name"
maxlength={MAX_NAME_LENGTH}
onSubmit={() => void submit()}
bind:value={name} />
{#if error}
<span class="error">{error}</span>
{/if}
</div>
<div class="actions">
<Button variant="outline" onclick={hide}>Cancel</Button>
<Button
variant="secondary"
disabled={working || !valid}
onclick={() => void submit()}>
{working ? "Saving…" : action}
</Button>
</div>
</div>
+54
View File
@@ -0,0 +1,54 @@
<script lang="ts">
import Icon from "@app/components/Icon.svelte";
import ConfirmModal from "@app/modals/ConfirmModal.svelte";
interface Props {
rid: string;
confirm: () => Promise<void>;
}
const { rid, confirm }: Props = $props();
</script>
<style>
.repo {
display: flex;
align-items: center;
gap: 0.5rem;
font: var(--txt-body-m-medium);
color: var(--color-text-primary);
min-width: 0;
}
.repo-icon {
display: inline-flex;
color: var(--color-text-tertiary);
flex-shrink: 0;
}
.rid {
font: var(--txt-code-regular);
color: var(--color-text-tertiary);
word-break: break-all;
}
</style>
<ConfirmModal
title="Seed repository"
width="26rem"
icon="seed"
label="Seed"
busyLabel="Seeding…"
failure="Unable to seed the repository."
{confirm}>
{#snippet body()}
<div class="repo">
<span class="repo-icon"><Icon name="repository" /></span>
Repository of this card
</div>
<span class="rid">{rid}</span>
<span>
This card comes from a repository your node doesn't have. Seeding it makes
your node fetch, replicate and announce the repository, and shows the card
once it arrives.
</span>
{/snippet}
</ConfirmModal>
+68
View File
@@ -0,0 +1,68 @@
<script lang="ts">
import { MAX_NAME_LENGTH } from "@app/lib/board";
import Icon from "@app/components/Icon.svelte";
import TextInput from "@app/components/TextInput.svelte";
import ConfirmModal from "@app/modals/ConfirmModal.svelte";
interface Props {
title: string;
repoName: string;
name: string;
confirm: (name: string) => Promise<void>;
}
const { title, repoName, name: initial, confirm }: Props = $props();
// svelte-ignore state_referenced_locally
let name = $state(initial);
const valid = $derived(
name.trim().length > 0 && name.trim().length <= MAX_NAME_LENGTH,
);
</script>
<style>
.repo {
display: flex;
align-items: center;
gap: 0.5rem;
font: var(--txt-body-m-medium);
color: var(--color-text-primary);
min-width: 0;
}
.repo-icon {
display: inline-flex;
color: var(--color-text-tertiary);
flex-shrink: 0;
}
</style>
<ConfirmModal
{title}
width="26rem"
icon="dashboard"
label="Create"
busyLabel="Creating…"
failure="Unable to create the board."
disabled={!valid}
confirm={() => confirm(name.trim())}>
{#snippet body({ working, run })}
<div class="repo txt-overflow">
<span class="repo-icon"><Icon name="repository" /></span>
{repoName}
</div>
<span>
The board is stored in the repository, so everyone who seeds it gets the
board and sees where its cards are.
</span>
<TextInput
autofocus
autoselect
placeholder="Board name"
maxlength={MAX_NAME_LENGTH}
disabled={working}
onSubmit={run}
bind:value={name} />
{/snippet}
</ConfirmModal>
+13 -7
View File
@@ -23,9 +23,11 @@
interface Props {
repo: RepoInfo;
// Called instead of opening the new issue.
onCreated?: (issue: Issue) => void;
}
const { repo }: Props = $props();
const { repo, onCreated = undefined }: Props = $props();
let commentBox: ExtendedTextarea | undefined = $state();
let preview = $state(false);
@@ -176,12 +178,16 @@
comment,
Array.from(embeds.values()),
);
await router.push({
resource: "repo.issue",
rid: repo.rid,
issue: response.id,
status: "open",
});
if (onCreated) {
onCreated(response);
} else {
await router.push({
resource: "repo.issue",
rid: repo.rid,
issue: response.id,
status: "open",
});
}
forceHide();
} catch {
console.error("Not able to create issue.");
+26
View File
@@ -6,6 +6,7 @@
import { diffOptions } from "@app/lib/diffOptions.svelte";
import { hints } from "@app/lib/hints";
import { invoke } from "@app/lib/invoke";
import { labs } from "@app/lib/labs";
import { updateChecker } from "@app/lib/updateChecker.svelte";
import { pluralize } from "@app/lib/utils";
@@ -57,6 +58,10 @@
{ value: "char", label: "Char", title: "Highlight character changes" },
{ value: "none", label: "None", title: "Show line-level changes only" },
];
const labsOptions: Option<"on" | "off">[] = [
{ value: "on", label: "On" },
{ value: "off", label: "Off" },
];
const wordWrapOptions: Option<"on" | "off">[] = [
{ value: "on", label: "On" },
{ value: "off", label: "Off" },
@@ -114,6 +119,11 @@
.section {
border-top: 1px solid var(--color-border-subtle);
}
.section-title {
margin-bottom: -0.5rem;
font: var(--txt-heading-xs);
color: var(--color-text-primary);
}
.git {
overflow-wrap: anywhere;
}
@@ -277,6 +287,22 @@
onchange={value => (diffOptions.lineDiffType = value)} />
</div>
</div>
<div class="rows section">
<span class="section-title">Labs</span>
<div class="row">
<div class="row-label">
<span class="row-title">Boards</span>
<span class="row-description">
Arrange issues and patches on a board. Experimental: the board format
may still change.
</span>
</div>
<SegmentedSwitch
options={labsOptions}
value={labs.boards ? "on" : "off"}
onchange={value => (labs.boards = value === "on")} />
</div>
</div>
<div class="footer">
{#if updateChecker.currentVersion}
<span class="txt-selectable">
File diff suppressed because it is too large Load Diff
+73
View File
@@ -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<LoadedRepoBoardRoute> {
const [sidebarData, repo, boards, issues, patches, links] = await Promise.all(
[
loadSidebarData(),
invoke<RepoInfo>("repo_by_id", { rid: route.rid }),
invoke<Board[]>("list_boards", { rid: route.rid }),
invoke<PaginatedQuery<Issue[]>>("list_issues", {
rid: route.rid,
status: "all",
}),
invoke<PaginatedQuery<Patch[]>>("list_patches", { rid: route.rid }),
invoke<PatchLink[]>("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) {
+543
View File
@@ -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<Page> {
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);
});
+990
View File
@@ -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> = {},
): Issue {
return {
id,
state,
labels: [],
timestamp: 1000,
title: id,
...extra,
} as Issue;
}
function patch(
id: string,
status: Patch["state"]["status"] = "open",
extra: Partial<Patch> = {},
): 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<string, BoardItem[]>,
): Record<string, string> {
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<BoardItem, { kind: "issue" }>;
function issueItem(overrides: Partial<IssueItem> = {}): 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> = {},
): 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> = {}): 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([]);
});
});
+259
View File
@@ -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> = {},
): 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<BoardWriter["update"]>(async () => rejected),
setState: vi.fn<BoardWriter["setState"]>().mockResolvedValue(),
reload: vi.fn<BoardWriter["reload"]>().mockResolvedValue(),
error: vi.fn<BoardWriter["error"]>(),
};
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();
});
});
+371
View File
@@ -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<Issue> = {},
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<Patch> = {}): 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"]);
});
});
+12
View File
@@ -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" },
+20
View File
@@ -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" },