<!--
One card, opened.
A board answers "what is in flight"; this answers "what is this". It is the
panel GitHub opens beside its own board, and for the same reason: the card is
a title and a column, and everything else about the work — the description,
who is on it, what it is labelled, when it was closed and why — is a click
away or it is a trip to the website.
Everything the board owns is editable here: every field, the column, the
order, archiving, removal, and a draft's title and body. Everything the board
does NOT own is shown and linked, never edited: an issue's title, its state,
its labels and its comments belong to the issue, and a board that edited them
would be a second place to change one thing.
-->
<script lang="ts">
import { boards } from "../../stores/boards.svelte.js";
import {
columnField,
describeState,
itemKind,
readValue,
type Project,
type ProjectField,
type ProjectItem,
type ProjectValueWrite,
} from "../../api/types/github.js";
import AppIcon from "../layout/AppIcon.svelte";
import MarkdownPreview from "../content/MarkdownPreview.svelte";
import AnnotationsPanel from "../annotations/AnnotationsPanel.svelte";
import { formatRef } from "../../annotations/refs.js";
interface Props {
project: Project;
item: ProjectItem;
}
let { project, item }: Props = $props();
let field = $derived(columnField(project));
let kind = $derived(itemKind(item));
// Not named `state`: a variable by that name turns every `$state(...)` in
// this component into a store read of it.
let stateLabel = $derived(describeState(item));
let busy = $derived(boards.moving === item.id);
/** Editing a draft. Off until asked for: the body is read far more than written. */
let editing = $state(false);
let draftTitle = $state("");
let draftBody = $state("");
/** Which field is being typed into, and what is in the box. */
let editingField = $state<string | null>(null);
let draftValue = $state("");
let confirmRemove = $state(false);
function beginDraft() {
draftTitle = item.title;
draftBody = item.body ?? "";
editing = true;
}
async function saveDraft() {
if (await boards.editDraft(project, item, draftTitle.trim(), draftBody)) {
editing = false;
}
}
function beginField(f: ProjectField) {
editingField = f.id;
draftValue = readValue(item.field_values[f.id]);
}
/** What the typed text means for this field's kind. */
function written(f: ProjectField, raw: string): ProjectValueWrite | null | "invalid" {
const text = raw.trim();
if (text === "") return null;
switch (f.kind) {
case "number": {
const value = Number(text);
return Number.isFinite(value) ? { kind: "number", number: value } : "invalid";
}
case "date":
// GitHub's Date scalar. Refused here rather than sent, because a date
// it cannot parse comes back as a GraphQL error about a variable.
return /^\d{4}-\d{2}-\d{2}$/.test(text) ? { kind: "date", date: text } : "invalid";
default:
return { kind: "text", text: raw };
}
}
async function saveField(f: ProjectField) {
const value = written(f, draftValue);
if (value === "invalid") {
boards.error =
f.kind === "number"
? `${f.name} takes a number, and “${draftValue}” is not one`
: `${f.name} takes a date as YYYY-MM-DD`;
return;
}
if (await boards.setField(project, item, f.id, value)) editingField = null;
}
async function pickOption(f: ProjectField, optionId: string) {
const current = item.field_values[f.id];
const chosen = current?.kind === "single_select" ? current.option_id : null;
await boards.setField(
project,
item,
f.id,
optionId === "" || optionId === chosen ? null : { kind: "single_select", option_id: optionId },
);
}
/**
* Which fields the panel lists.
*
* Everything that HAS a value, plus the kinds this page can write. A board
* defines fields nothing here can fill — linked pull requests, reviewers,
* sub-issue progress — and listing those as a column of em-dashes buries
* the three fields somebody came to change.
*/
const WRITABLE = ["single_select", "text", "number", "date"];
function shown(f: ProjectField): boolean {
if (f.kind === "title") return false;
if (item.field_values[f.id]) return true;
return WRITABLE.includes(f.kind);
}
async function removeCard() {
confirmRemove = false;
await boards.remove(project, item);
}
</script>
<aside class="detail">
<header class="detail-header">
<div class="what">
<span class="chip chip--{kind === 'draft' ? 'draft' : 'kind'}">
<AppIcon
name={kind === "pull-request" ? "git-merge" : kind === "draft" ? "file-text" : "circle-alert"}
size={12}
/>
{kind === "pull-request" ? "pull request" : kind}
</span>
{#if stateLabel}
<span class="chip chip--{stateLabel.split(' ')[0]}">{stateLabel}</span>
{/if}
{#if item.archived}<span class="chip">archived</span>{/if}
</div>
<button class="quiet" title="Close" onclick={() => (boards.openItemId = null)}>
<AppIcon name="x" size={12} />
</button>
</header>
{#if editing}
<div class="edit">
<input class="field-input title-input" bind:value={draftTitle} placeholder="title" />
<textarea class="field-input body-input" bind:value={draftBody} rows="10" placeholder="description"></textarea>
<div class="button-row">
<button class="primary" disabled={busy || !draftTitle.trim()} onclick={() => void saveDraft()}>
Save
</button>
<button class="secondary" onclick={() => (editing = false)}>Cancel</button>
</div>
</div>
{:else}
<h1 class="title">
{item.title}
{#if item.number}<span class="number">#{item.number}</span>{/if}
</h1>
<div class="where subtle">
{#if item.repository}
<span>{item.repository}</span>
{/if}
{#if item.author}
<span>by {item.author.login}</span>
{/if}
{#if item.created_at}
<span>opened {new Date(item.created_at).toLocaleDateString()}</span>
{/if}
{#if item.closed_at}
<span>closed {new Date(item.closed_at).toLocaleDateString()}</span>
{/if}
</div>
<div class="button-row">
{#if item.url}
<a class="link" href={item.url} target="_blank" rel="noreferrer">
Open on GitHub <AppIcon name="link" size={10} />
</a>
{/if}
{#if item.kind === "DRAFT_ISSUE"}
<button class="secondary" onclick={beginDraft}>Edit</button>
{/if}
{#if item.comment_count > 0 && item.url}
<a class="link" href={`${item.url}#issuecomment-new`} target="_blank" rel="noreferrer">
{item.comment_count} comment{item.comment_count === 1 ? "" : "s"}
</a>
{/if}
</div>
{#if item.labels.length > 0 || item.assignees.length > 0 || item.milestone}
<div class="who">
{#each item.labels as label (label.name)}
<span class="chip" style:border-color={`#${label.color}`} title={label.description ?? label.name}>
{label.name}
</span>
{/each}
{#each item.assignees as person (person.login)}
<span class="chip person">
{#if person.avatar_url}<img src={person.avatar_url} alt="" width="16" height="16" />{/if}
{person.login}
</span>
{/each}
{#if item.milestone}
<span class="chip"><AppIcon name="pin" size={11} /> {item.milestone}</span>
{/if}
</div>
{/if}
{#if item.review_decision}
<p class="subtle review">review: {item.review_decision.toLowerCase().replace("_", " ")}</p>
{/if}
{#if item.body}
<div class="body"><MarkdownPreview content={item.body} /></div>
{:else}
<p class="empty">No description.</p>
{/if}
{/if}
<h2 class="section-title">Fields</h2>
<dl class="fields">
{#each project.fields as f (f.id)}
{#if shown(f)}
<dt>{f.name}</dt>
<dd>
{#if f.kind === "single_select"}
<select
class="field-input"
disabled={busy}
value={item.field_values[f.id]?.kind === "single_select"
? (item.field_values[f.id] as { option_id: string }).option_id
: ""}
onchange={(event) => void pickOption(f, event.currentTarget.value)}
>
<option value="">—</option>
{#each f.options as option (option.id)}
<option value={option.id}>{option.name}</option>
{/each}
</select>
{:else if f.kind === "iteration"}
<!-- An iteration is chosen from the ones the field defines, and
this page is not where they are defined. Shown, not edited. -->
<span class="value">{readValue(item.field_values[f.id]) || "—"}</span>
{:else if editingField === f.id}
<input
class="field-input"
bind:value={draftValue}
placeholder={f.kind === "date" ? "YYYY-MM-DD" : f.kind}
onkeydown={(event) => {
if (event.key === "Enter") void saveField(f);
if (event.key === "Escape") editingField = null;
}}
/>
<button class="secondary tiny" disabled={busy} onclick={() => void saveField(f)}>Set</button>
<button class="secondary tiny" onclick={() => (editingField = null)}>Cancel</button>
{:else}
<button class="value editable" onclick={() => beginField(f)} title="Click to change">
{readValue(item.field_values[f.id]) || "—"}
</button>
{/if}
</dd>
{/if}
{/each}
</dl>
<h2 class="section-title">On the board</h2>
<div class="button-row">
<button
class="secondary"
disabled={busy}
onclick={() => void boards.setArchived(project, item, !item.archived)}
>
{item.archived ? "Bring back" : "Archive"}
</button>
{#if field}
<button class="secondary" disabled={busy} onclick={() => void boards.order(project, item, null)}>
Move to top
</button>
{/if}
{#if confirmRemove}
<button class="danger" disabled={busy} onclick={() => void removeCard()}>
Remove {item.kind === "DRAFT_ISSUE" ? "and delete this draft" : "from the board"}
</button>
<button class="secondary" onclick={() => (confirmRemove = false)}>Keep</button>
{:else}
<button class="danger-outline" onclick={() => (confirmRemove = true)}>Remove…</button>
{/if}
</div>
<p class="empty">
{#if item.kind === "DRAFT_ISSUE"}
A draft lives only here, so removing it ends it.
{:else}
Removing takes the card off the board. The {kind === "pull-request"
? "pull request"
: "issue"} itself is untouched.
{/if}
</p>
<AnnotationsPanel
about={formatRef({ kind: "card", owner: project.owner, number: project.number, item: item.id })}
writeInto={null}
prefer={boards.projectsUsing(project)[0] ?? null}
/>
</aside>
<style>
/*
Layout only. Every look here — the buttons, the chips, the inputs, the
section headings — comes from the shared SCSS workbench primitives, so a
button in this panel is the same button as one in the sidebar beside it.
*/
.detail {
width: 380px;
/* Never more than a share of the window: at 900px the panel and the board
would each get half a column, and neither would be readable. */
max-width: 45%;
flex: none;
background: var(--vscode-editor-background);
border-left: 1px solid var(--vscode-panel-border, color-mix(in srgb, currentColor 20%, transparent));
padding: var(--wb-spacing-10) var(--wb-spacing-14) var(--wb-spacing-24);
overflow-y: auto;
display: flex;
flex-direction: column;
gap: var(--wb-spacing-8);
}
.detail-header {
display: flex;
align-items: center;
justify-content: space-between;
}
.what {
display: flex;
align-items: center;
gap: var(--wb-spacing-6);
flex-wrap: wrap;
}
.title {
font-size: var(--wb-font-size-15);
font-weight: 600;
margin: var(--wb-spacing-2) 0 0;
line-height: 1.35;
color: var(--vscode-editor-foreground);
}
.number {
color: var(--vscode-descriptionForeground);
font-weight: 400;
}
.where,
.who {
display: flex;
gap: var(--wb-spacing-8);
flex-wrap: wrap;
align-items: center;
font-size: var(--wb-font-size-11);
}
.review {
font-size: var(--wb-font-size-11);
margin: 0;
}
.person img {
width: var(--wb-icon-size-16);
height: var(--wb-icon-size-16);
border-radius: 50%;
}
.body {
border-top: 1px solid var(--vscode-panel-border, color-mix(in srgb, currentColor 15%, transparent));
padding-top: var(--wb-spacing-8);
font-size: var(--wb-font-size-12);
overflow-wrap: anywhere;
}
.fields {
display: grid;
grid-template-columns: auto 1fr;
gap: var(--wb-spacing-4) var(--wb-spacing-10);
margin: 0;
font-size: var(--wb-font-size-11-5);
align-items: center;
}
dt {
color: var(--vscode-descriptionForeground);
}
dd {
margin: 0;
display: flex;
gap: var(--wb-spacing-4);
align-items: center;
flex-wrap: wrap;
}
/* A value that is edited in place: it reads as text until it is hovered,
because forty of them drawn as inputs is a form, not a card. */
.value {
color: var(--vscode-editor-foreground);
}
button.value {
background: none;
border: 1px dashed transparent;
padding: var(--wb-spacing-1) var(--wb-spacing-4);
text-align: left;
cursor: pointer;
font: inherit;
color: inherit;
}
button.value:hover {
border-color: var(--vscode-panel-border, color-mix(in srgb, currentColor 40%, transparent));
}
.title-input,
.body-input {
width: 100%;
}
.body-input {
font-family: var(--wsb-font-mono);
resize: vertical;
}
.edit {
display: flex;
flex-direction: column;
gap: var(--wb-spacing-6);
}
.link {
display: inline-flex;
align-items: center;
gap: var(--wb-spacing-4);
color: var(--vscode-textLink-foreground);
font-size: var(--wb-font-size-11-5);
text-decoration: none;
}
</style>