mirror of
https://seed.radicle.dev/rad:z4V1sjrXqjvFdnCUbxPFqd5p4DtH5.git
synced 2026-11-15 11:20:36 +08:00
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:
co-authored by
Rūdolfs Ošiņš
parent
3c6b455da0
commit
682a406d85
@@ -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"}
|
||||
|
||||
@@ -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}
|
||||
@@ -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>
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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,
|
||||
});
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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 }];
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
},
|
||||
};
|
||||
@@ -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,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") {
|
||||
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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.");
|
||||
|
||||
@@ -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
@@ -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) {
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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([]);
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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"]);
|
||||
});
|
||||
});
|
||||
@@ -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" },
|
||||
|
||||
@@ -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" },
|
||||
|
||||
Reference in New Issue
Block a user