mirror of
https://seed.radicle.dev/rad:z4V1sjrXqjvFdnCUbxPFqd5p4DtH5.git
synced 2026-10-18 19:30:27 +08:00
Align issue page design with patch page
Rebuild the issue page as a single column matching the patch page: the state dropdown sits next to the title, with author, issue ID, labels and assignees in a row beneath it. Let issue authors change the state, not only delegates. Add a delete_issue command so authors can delete their own issues. Share the delete confirmation and ID chip with the patch page.
This commit is contained in:
@@ -38,6 +38,16 @@ pub fn edit_issue(
|
||||
ctx.edit_issue(rid, cob_id, action, opts)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn delete_issue(
|
||||
ctx: tauri::State<AppState>,
|
||||
rid: identity::RepoId,
|
||||
cob_id: git::Oid,
|
||||
opts: types::cobs::CobOptions,
|
||||
) -> Result<(), Error> {
|
||||
ctx.delete_issue(rid, cob_id, opts)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub(crate) async fn list_issues(
|
||||
ctx: tauri::State<'_, AppState>,
|
||||
|
||||
@@ -46,6 +46,7 @@ pub fn run() {
|
||||
cob::get_embed,
|
||||
cob::issue::activity_by_issue,
|
||||
cob::issue::comment_threads_by_issue_id,
|
||||
cob::issue::delete_issue,
|
||||
cob::issue::create_issue,
|
||||
cob::issue::edit_issue,
|
||||
cob::issue::issue_by_id,
|
||||
|
||||
@@ -164,4 +164,37 @@ pub trait IssuesMut: Profile {
|
||||
|
||||
Ok::<_, Error>(cobs::issue::Issue::new(issue.id(), &issue, &aliases))
|
||||
}
|
||||
|
||||
/// Remove an issue COB. Equivalent to `rad issue delete`.
|
||||
///
|
||||
/// Only the COB ref under our own namespace is dropped. On an issue we did
|
||||
/// not author we hold no such ref, so this returns `Ok` having deleted
|
||||
/// nothing — while still evicting the cache entry, hiding the issue until
|
||||
/// the next rebuild. Callers must restrict this to our own issues.
|
||||
fn delete_issue(
|
||||
&self,
|
||||
rid: identity::RepoId,
|
||||
cob_id: git::Oid,
|
||||
opts: cobs::CobOptions,
|
||||
) -> Result<(), Error> {
|
||||
let profile = self.profile();
|
||||
let mut node = Node::new(profile.home().socket_from_env());
|
||||
let repo = profile.storage.repository(rid)?;
|
||||
let signer = profile.signer()?;
|
||||
|
||||
// Remove via the cache-backed store so the issue is dropped from both
|
||||
// the git refs and the COB cache that listings read from; otherwise the
|
||||
// deleted issue keeps showing up in the issue list.
|
||||
let mut issues = profile.issues_mut(&repo, &signer)?;
|
||||
issues.remove(&cob_id.into())?;
|
||||
drop(issues);
|
||||
|
||||
if opts.announce()
|
||||
&& let Err(e) = node.announce_refs_for(rid, [profile.public_key])
|
||||
{
|
||||
log::error!("Not able to announce changes: {}", e)
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
@@ -116,6 +116,7 @@ pub fn router(ctx: Context) -> Router {
|
||||
.route("/edit_issue", post(edit_issue_handler))
|
||||
.route("/issue_by_id", post(issue_handler))
|
||||
.route("/comment_threads_by_issue_id", post(issue_threads_handler))
|
||||
.route("/delete_issue", post(delete_issue_handler))
|
||||
.route("/list_patches", post(patches_handler))
|
||||
.route("/patch_by_id", post(patch_handler))
|
||||
.route("/revisions_by_patch", post(revision_handler))
|
||||
@@ -759,6 +760,23 @@ async fn create_patch_review_handler(
|
||||
Ok::<_, Error>(Json(review_id))
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
struct DeleteIssueBody {
|
||||
pub rid: identity::RepoId,
|
||||
pub cob_id: git::Oid,
|
||||
pub opts: CobOptions,
|
||||
}
|
||||
|
||||
async fn delete_issue_handler(
|
||||
State(ctx): State<Context>,
|
||||
Json(DeleteIssueBody { rid, cob_id, opts }): Json<DeleteIssueBody>,
|
||||
) -> impl IntoResponse {
|
||||
ctx.delete_issue(rid, cob_id, opts)?;
|
||||
|
||||
Ok::<_, Error>(Json(()))
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
struct DeletePatchBody {
|
||||
|
||||
@@ -0,0 +1,114 @@
|
||||
<script lang="ts">
|
||||
import Button from "@app/components/Button.svelte";
|
||||
import Icon from "@app/components/Icon.svelte";
|
||||
import Popover, { closeFocused } from "@app/components/Popover.svelte";
|
||||
|
||||
interface Props {
|
||||
noun: string;
|
||||
onDelete: () => Promise<void>;
|
||||
}
|
||||
|
||||
const { noun, onDelete }: Props = $props();
|
||||
|
||||
let expanded = $state(false);
|
||||
let deleting = $state(false);
|
||||
|
||||
async function confirm() {
|
||||
deleting = true;
|
||||
try {
|
||||
await onDelete();
|
||||
} finally {
|
||||
deleting = false;
|
||||
closeFocused();
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style>
|
||||
.confirm-delete {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.75rem;
|
||||
padding: 0.75rem;
|
||||
min-width: 16rem;
|
||||
max-width: 24rem;
|
||||
border: 1px solid var(--color-border-subtle);
|
||||
border-radius: var(--border-radius-sm);
|
||||
background-color: var(--color-surface-canvas);
|
||||
}
|
||||
.confirm-delete-text {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
color: var(--color-text-primary);
|
||||
}
|
||||
.confirm-delete-note {
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
.confirm-delete-actions {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
.confirm-delete-button {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
height: 2rem;
|
||||
padding: 0 0.75rem;
|
||||
border: 0;
|
||||
border-radius: var(--border-radius-sm);
|
||||
background-color: var(--color-feedback-error-fill);
|
||||
color: var(--color-text-on-brand);
|
||||
cursor: pointer;
|
||||
transition: background-color 0.1s ease;
|
||||
}
|
||||
.confirm-delete-button:hover:not(:disabled),
|
||||
.confirm-delete-button:focus-visible:not(:disabled) {
|
||||
background-color: var(--color-feedback-error-fill-hover);
|
||||
}
|
||||
.confirm-delete-button:active:not(:disabled) {
|
||||
background-color: var(--color-feedback-error-fill-active);
|
||||
}
|
||||
.confirm-delete-button:disabled {
|
||||
cursor: default;
|
||||
opacity: 0.6;
|
||||
}
|
||||
</style>
|
||||
|
||||
<Popover popoverPadding="0" placement="bottom-end" bind:expanded>
|
||||
{#snippet toggle(onclick)}
|
||||
<Button
|
||||
variant="naked"
|
||||
{onclick}
|
||||
active={expanded}
|
||||
title={`Delete ${noun} from your node`}>
|
||||
<Icon name="trash" />
|
||||
<span class="global-hide-on-medium-desktop-down">Delete</span>
|
||||
</Button>
|
||||
{/snippet}
|
||||
{#snippet popover()}
|
||||
<div class="confirm-delete">
|
||||
<div class="confirm-delete-text">
|
||||
<div class="txt-body-m-medium">Delete this {noun} from your node?</div>
|
||||
<div class="confirm-delete-note txt-body-m-regular">
|
||||
Only your copy is removed. You won't be able to restore it here, and
|
||||
peers who have already replicated the {noun} keep theirs.
|
||||
</div>
|
||||
</div>
|
||||
<div class="confirm-delete-actions">
|
||||
<Button variant="outline" disabled={deleting} onclick={closeFocused}>
|
||||
Cancel
|
||||
</Button>
|
||||
<button
|
||||
type="button"
|
||||
class="confirm-delete-button txt-body-m-medium"
|
||||
disabled={deleting}
|
||||
onclick={confirm}>
|
||||
<Icon name="trash" />
|
||||
{deleting ? "Deleting…" : "Delete"}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
{/snippet}
|
||||
</Popover>
|
||||
@@ -0,0 +1,88 @@
|
||||
<script lang="ts">
|
||||
import debounce from "lodash/debounce";
|
||||
|
||||
import { writeToClipboard } from "@app/lib/invoke";
|
||||
import { formatOid } from "@app/lib/utils";
|
||||
|
||||
import Icon from "@app/components/Icon.svelte";
|
||||
|
||||
interface Props {
|
||||
id: string;
|
||||
label: string;
|
||||
grouped?: boolean;
|
||||
}
|
||||
|
||||
const { id, label, grouped = false }: Props = $props();
|
||||
|
||||
let copied = $state(false);
|
||||
const resetCopied = debounce(() => {
|
||||
copied = false;
|
||||
}, 1000);
|
||||
|
||||
async function copy() {
|
||||
await writeToClipboard(id);
|
||||
copied = true;
|
||||
resetCopied();
|
||||
}
|
||||
</script>
|
||||
|
||||
<style>
|
||||
.id-chip {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.375rem;
|
||||
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-tertiary);
|
||||
font: var(--txt-body-m-regular);
|
||||
cursor: pointer;
|
||||
}
|
||||
.id-chip.grouped {
|
||||
height: 100%;
|
||||
border: 0;
|
||||
border-radius: 0;
|
||||
background: none;
|
||||
}
|
||||
.id-chip:hover,
|
||||
.id-chip:focus-visible {
|
||||
background-color: var(--color-surface-subtle);
|
||||
color: var(--color-text-primary);
|
||||
}
|
||||
.value {
|
||||
font: var(--txt-code-regular);
|
||||
}
|
||||
.icon-default,
|
||||
.icon-hover {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
}
|
||||
.icon-hover {
|
||||
display: none;
|
||||
}
|
||||
.id-chip:hover .icon-default,
|
||||
.id-chip:focus-visible .icon-default {
|
||||
display: none;
|
||||
}
|
||||
.id-chip:hover .icon-hover,
|
||||
.id-chip:focus-visible .icon-hover {
|
||||
display: inline-flex;
|
||||
}
|
||||
</style>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
class="id-chip"
|
||||
class:grouped
|
||||
title={copied ? "Copied to clipboard" : `Copy ${label}`}
|
||||
onclick={copy}>
|
||||
{#if copied}
|
||||
<Icon name="checkmark" />
|
||||
{:else}
|
||||
<span class="icon-default"><Icon name="hash" /></span>
|
||||
<span class="icon-hover"><Icon name="copy" /></span>
|
||||
{/if}
|
||||
<span class="value">{formatOid(id)}</span>
|
||||
</button>
|
||||
@@ -2,12 +2,14 @@
|
||||
import type { Author } from "@bindings/cob/Author";
|
||||
import type { Action } from "@bindings/cob/issue/Action";
|
||||
|
||||
export type FlattenedIssueOperation = Action & {
|
||||
id: string;
|
||||
author: Author;
|
||||
timestamp: number;
|
||||
previous?: Action;
|
||||
};
|
||||
export type FlattenedIssueOperation =
|
||||
| (Action & {
|
||||
id: string;
|
||||
author: Author;
|
||||
timestamp: number;
|
||||
previous?: Action;
|
||||
})
|
||||
| { type: "opened"; id: string; author: Author; timestamp: number };
|
||||
</script>
|
||||
|
||||
<script lang="ts">
|
||||
@@ -16,10 +18,12 @@
|
||||
authorForNodeId,
|
||||
formatTimestamp,
|
||||
issueStatusColor,
|
||||
issueStatusIcon,
|
||||
pluralize,
|
||||
} from "@app/lib/utils";
|
||||
|
||||
import Icon from "@app/components/Icon.svelte";
|
||||
import Label from "@app/components/Label.svelte";
|
||||
import NodeId from "@app/components/NodeId.svelte";
|
||||
|
||||
interface Props {
|
||||
@@ -46,153 +50,158 @@
|
||||
padding: 0.375rem 0.5rem;
|
||||
min-height: 2.5rem;
|
||||
}
|
||||
.icon {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex-shrink: 0;
|
||||
width: 1rem;
|
||||
}
|
||||
.wrapper {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: 0.25rem;
|
||||
min-width: 0;
|
||||
flex: 1 1 0;
|
||||
}
|
||||
.icon {
|
||||
padding-top: 0;
|
||||
}
|
||||
.summary-line {
|
||||
.summary {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.375rem;
|
||||
flex: 1 1 0;
|
||||
min-width: 0;
|
||||
gap: 0.25rem;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
color: var(--color-text-primary);
|
||||
}
|
||||
/* Ellipsis has to sit on a child: it does not apply to the anonymous flex
|
||||
items a flex container makes of its text nodes. */
|
||||
.summary-text {
|
||||
min-width: 0;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
.timestamp {
|
||||
flex-shrink: 0;
|
||||
color: var(--color-text-quaternary);
|
||||
}
|
||||
</style>
|
||||
|
||||
{#if op.type === "lifecycle"}
|
||||
{#snippet timestamp()}
|
||||
<span class="timestamp" title={absoluteTimestamp(op.timestamp)}>
|
||||
{formatTimestamp(op.timestamp)}
|
||||
</span>
|
||||
{/snippet}
|
||||
|
||||
{#if op.type === "opened"}
|
||||
<div class="timeline-item txt-body-m-regular">
|
||||
<div class="icon" style:color={issueStatusColor[op.state.status]}>
|
||||
{#if op.state.status === "open"}
|
||||
<Icon name="issue" />
|
||||
{:else}
|
||||
<Icon name="issue-closed" />
|
||||
{/if}
|
||||
<div class="icon" style:color={issueStatusColor.open}>
|
||||
<Icon name={issueStatusIcon.open} />
|
||||
</div>
|
||||
<div class="wrapper">
|
||||
{#if !hideAuthor}<NodeId {...authorForNodeId(op.author)} />{/if}
|
||||
<div class="summary-line">
|
||||
{#if op.state.status === "closed"}
|
||||
closed issue as {op.state.reason}
|
||||
{:else if op.state.status === "open"}
|
||||
reopened issue
|
||||
{/if}
|
||||
</div>
|
||||
<div class="timestamp" title={absoluteTimestamp(op.timestamp)}>
|
||||
{formatTimestamp(op.timestamp)}
|
||||
</div>
|
||||
<span class="summary">
|
||||
<span class="summary-text">opened this issue</span>
|
||||
</span>
|
||||
{@render timestamp()}
|
||||
</div>
|
||||
</div>
|
||||
{:else if op.type === "lifecycle"}
|
||||
<div class="timeline-item txt-body-m-regular">
|
||||
<div class="icon" style:color={issueStatusColor[op.state.status]}>
|
||||
<Icon name={issueStatusIcon[op.state.status]} />
|
||||
</div>
|
||||
<div class="wrapper">
|
||||
{#if !hideAuthor}<NodeId {...authorForNodeId(op.author)} />{/if}
|
||||
<span class="summary">
|
||||
<span class="summary-text">
|
||||
{#if op.state.status === "closed"}
|
||||
closed this issue as {op.state.reason}
|
||||
{:else}
|
||||
reopened this issue
|
||||
{/if}
|
||||
</span>
|
||||
</span>
|
||||
{@render timestamp()}
|
||||
</div>
|
||||
</div>
|
||||
{:else if op.type === "label"}
|
||||
<div class="timeline-item txt-body-m-regular">
|
||||
<div class="icon">
|
||||
<Icon name="label" />
|
||||
</div>
|
||||
<div class="icon"><Icon name="label" /></div>
|
||||
<div class="wrapper">
|
||||
{#if !hideAuthor}<NodeId {...authorForNodeId(op.author)} />{/if}
|
||||
<div class="summary-line">
|
||||
<span class="summary">
|
||||
{#if op.previous && op.previous.type === op.type}
|
||||
{@const changed = itemDiff(op.previous?.labels ?? [], op.labels)}
|
||||
{#if changed.added.length || changed.removed.length}
|
||||
{#if changed.added.length}
|
||||
added {pluralize("label", changed.removed.length)}
|
||||
{#each changed.added as label}
|
||||
<b>{label}</b>
|
||||
{/each}
|
||||
{/if}
|
||||
{#if changed.removed.length}
|
||||
{#if changed.added.length}
|
||||
<span class="summary-text">
|
||||
added {pluralize("label", changed.added.length)}
|
||||
</span>
|
||||
{#each changed.added as label}<Label {label} />{/each}
|
||||
{/if}
|
||||
{#if changed.removed.length}
|
||||
<span class="summary-text">
|
||||
removed {pluralize("label", changed.removed.length)}
|
||||
{#each changed.removed as label}
|
||||
<b>{label}</b>
|
||||
{/each}
|
||||
{/if}
|
||||
</span>
|
||||
{#each changed.removed as label}<Label {label} />{/each}
|
||||
{/if}
|
||||
{:else}
|
||||
added {pluralize("label", op.labels.length)}
|
||||
{#each op.labels as label}
|
||||
<b>{label}</b>
|
||||
{/each}
|
||||
<span class="summary-text">
|
||||
added {pluralize("label", op.labels.length)}
|
||||
</span>
|
||||
{#each op.labels as label}<Label {label} />{/each}
|
||||
{/if}
|
||||
</div>
|
||||
<div class="timestamp" title={absoluteTimestamp(op.timestamp)}>
|
||||
{formatTimestamp(op.timestamp)}
|
||||
</div>
|
||||
</span>
|
||||
{@render timestamp()}
|
||||
</div>
|
||||
</div>
|
||||
{:else if op.type === "assign"}
|
||||
<div class="timeline-item txt-body-m-regular">
|
||||
<div class="icon">
|
||||
<Icon name="avatar-incognito" />
|
||||
</div>
|
||||
<div class="icon"><Icon name="avatar-incognito" /></div>
|
||||
<div class="wrapper">
|
||||
{#if !hideAuthor}<NodeId {...authorForNodeId(op.author)} />{/if}
|
||||
<div class="summary-line">
|
||||
<span class="summary">
|
||||
{#if op.previous && op.previous.type === op.type}
|
||||
{@const changed = itemDiff<Author>(
|
||||
op.previous?.assignees ?? [],
|
||||
op.assignees,
|
||||
)}
|
||||
{#if changed.added.length || changed.removed.length}
|
||||
{#if changed.added.length}
|
||||
assigned
|
||||
{#each changed.added as assignee}
|
||||
<NodeId {...authorForNodeId(assignee)} />
|
||||
{/each}
|
||||
{/if}
|
||||
{#if changed.removed.length}
|
||||
unassigned
|
||||
{#each changed.removed as assignee}
|
||||
<NodeId {...authorForNodeId(assignee)} />
|
||||
{/each}
|
||||
{/if}
|
||||
{#if changed.added.length}
|
||||
<span class="summary-text">assigned</span>
|
||||
{#each changed.added as assignee}
|
||||
<NodeId {...authorForNodeId(assignee)} />
|
||||
{/each}
|
||||
{/if}
|
||||
{#if changed.removed.length}
|
||||
<span class="summary-text">unassigned</span>
|
||||
{#each changed.removed as assignee}
|
||||
<NodeId {...authorForNodeId(assignee)} />
|
||||
{/each}
|
||||
{/if}
|
||||
{:else}
|
||||
assigned
|
||||
<span class="summary-text">assigned</span>
|
||||
{#each op.assignees as assignee}
|
||||
<NodeId {...authorForNodeId(assignee)} />
|
||||
{/each}
|
||||
{/if}
|
||||
</div>
|
||||
<div class="timestamp" title={absoluteTimestamp(op.timestamp)}>
|
||||
{formatTimestamp(op.timestamp)}
|
||||
</div>
|
||||
</span>
|
||||
{@render timestamp()}
|
||||
</div>
|
||||
</div>
|
||||
{:else if op.type === "edit"}
|
||||
{#if op.previous && op.previous.type === op.type}
|
||||
<div class="timeline-item txt-body-m-regular">
|
||||
<div class="icon">
|
||||
<Icon name="edit" />
|
||||
</div>
|
||||
<div class="icon"><Icon name="edit" /></div>
|
||||
<div class="wrapper">
|
||||
{#if !hideAuthor}<NodeId {...authorForNodeId(op.author)} />{/if}
|
||||
<div class="summary-line">
|
||||
<span class="summary-text">
|
||||
changed title <s>{op.previous.title}</s>
|
||||
→ {op.title}
|
||||
<span class="summary">
|
||||
<span
|
||||
class="summary-text"
|
||||
title={`changed the title from “${op.previous.title}” to “${op.title}”`}>
|
||||
changed the title from “{op.previous.title}” to “{op.title}”
|
||||
</span>
|
||||
</div>
|
||||
<div class="timestamp" title={absoluteTimestamp(op.timestamp)}>
|
||||
{formatTimestamp(op.timestamp)}
|
||||
</div>
|
||||
</span>
|
||||
{@render timestamp()}
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
@@ -0,0 +1,135 @@
|
||||
<script lang="ts">
|
||||
import type { Author } from "@bindings/cob/Author";
|
||||
import type { Reaction } from "@bindings/cob/Reaction";
|
||||
import type { Embed } from "@bindings/cob/thread/Embed";
|
||||
|
||||
import ExtendedTextarea from "@app/components/ExtendedTextarea.svelte";
|
||||
import Icon from "@app/components/Icon.svelte";
|
||||
import Markdown from "@app/components/Markdown.svelte";
|
||||
import { closeFocused } from "@app/components/Popover.svelte";
|
||||
import Reactions from "@app/components/Reactions.svelte";
|
||||
import ReactionSelector from "@app/components/ReactionSelector.svelte";
|
||||
|
||||
interface Props {
|
||||
rid: string;
|
||||
body: string;
|
||||
reactions: Reaction[];
|
||||
currentUserNid?: string;
|
||||
allowedToEdit: boolean;
|
||||
editComment: (body: string, embeds: Embed[]) => Promise<void>;
|
||||
reactOnComment: (authors: Author[], reaction: string) => Promise<void>;
|
||||
}
|
||||
|
||||
const {
|
||||
rid,
|
||||
body,
|
||||
reactions,
|
||||
currentUserNid,
|
||||
allowedToEdit,
|
||||
editComment,
|
||||
reactOnComment,
|
||||
}: Props = $props();
|
||||
|
||||
let editing = $state(false);
|
||||
</script>
|
||||
|
||||
<style>
|
||||
.description {
|
||||
position: relative;
|
||||
margin: 1rem 0 1.5rem;
|
||||
}
|
||||
.description-body {
|
||||
position: relative;
|
||||
}
|
||||
.description-body:has(.body-actions) {
|
||||
padding-right: 4rem;
|
||||
}
|
||||
.body-actions {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
right: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.125rem;
|
||||
}
|
||||
.body-action {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: none;
|
||||
border: none;
|
||||
padding: 0.25rem;
|
||||
border-radius: var(--border-radius-sm);
|
||||
cursor: pointer;
|
||||
color: var(--color-text-tertiary);
|
||||
opacity: 0;
|
||||
transition: opacity 150ms ease;
|
||||
}
|
||||
.description:hover .body-action,
|
||||
.description:focus-within .body-action,
|
||||
.body-action:focus-visible {
|
||||
opacity: 1;
|
||||
}
|
||||
.body-action:hover,
|
||||
.body-action:focus-visible {
|
||||
color: var(--color-text-primary);
|
||||
background-color: var(--color-surface-subtle);
|
||||
}
|
||||
.reactions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
margin-top: 0.5rem;
|
||||
}
|
||||
</style>
|
||||
|
||||
<div class="description">
|
||||
{#if editing}
|
||||
<ExtendedTextarea
|
||||
{rid}
|
||||
{body}
|
||||
focus
|
||||
submitCaption="Save"
|
||||
submit={async ({ comment, embeds }) => {
|
||||
await editComment(comment, Array.from(embeds.values()));
|
||||
editing = false;
|
||||
}}
|
||||
close={() => (editing = false)} />
|
||||
{:else}
|
||||
<div class="description-body txt-body-m-regular">
|
||||
{#if body.trim() !== ""}
|
||||
<Markdown {rid} breaks content={body} />
|
||||
{:else}
|
||||
<span style:color="var(--color-text-tertiary)">No description</span>
|
||||
{/if}
|
||||
<div class="body-actions">
|
||||
<div class="body-action">
|
||||
<ReactionSelector
|
||||
placement="bottom-end"
|
||||
{reactions}
|
||||
select={async ({ authors, emoji }) => {
|
||||
try {
|
||||
await reactOnComment(authors, emoji);
|
||||
} finally {
|
||||
closeFocused();
|
||||
}
|
||||
}} />
|
||||
</div>
|
||||
{#if allowedToEdit}
|
||||
<button
|
||||
type="button"
|
||||
class="body-action"
|
||||
title="Edit description"
|
||||
onclick={() => (editing = true)}>
|
||||
<Icon name="edit" />
|
||||
</button>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
{#if reactions.length > 0}
|
||||
<div class="reactions">
|
||||
<Reactions handleReaction={reactOnComment} {currentUserNid} {reactions} />
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
@@ -0,0 +1,108 @@
|
||||
<script lang="ts">
|
||||
import type { Author } from "@bindings/cob/Author";
|
||||
import type { Issue } from "@bindings/cob/issue/Issue";
|
||||
import type { Config } from "@bindings/config/Config";
|
||||
import type { RepoInfo } from "@bindings/repo/RepoInfo";
|
||||
|
||||
import { nodeRunning } from "@app/lib/events";
|
||||
import { invoke } from "@app/lib/invoke";
|
||||
import * as roles from "@app/lib/roles";
|
||||
import { authorForNodeId } from "@app/lib/utils";
|
||||
|
||||
import { announce } from "@app/components/AnnounceSwitch.svelte";
|
||||
import AssigneeInput from "@app/components/AssigneeInput.svelte";
|
||||
import IdChip from "@app/components/IdChip.svelte";
|
||||
import LabelInput from "@app/components/LabelInput.svelte";
|
||||
import NodeId from "@app/components/NodeId.svelte";
|
||||
|
||||
interface Props {
|
||||
config: Config;
|
||||
issue: Issue;
|
||||
repo: RepoInfo;
|
||||
reload: () => Promise<void>;
|
||||
}
|
||||
|
||||
const { config, issue, repo, reload }: Props = $props();
|
||||
|
||||
const isDelegate = $derived(
|
||||
!!roles.isDelegate(
|
||||
config.publicKey,
|
||||
repo.delegates.map(delegate => delegate.did),
|
||||
),
|
||||
);
|
||||
|
||||
let labelSaveInProgress: boolean = $state(false);
|
||||
let assigneesSaveInProgress: boolean = $state(false);
|
||||
|
||||
async function saveLabels(labels: string[]) {
|
||||
try {
|
||||
labelSaveInProgress = true;
|
||||
await invoke("edit_issue", {
|
||||
rid: repo.rid,
|
||||
cobId: issue.id,
|
||||
action: { type: "label", labels },
|
||||
opts: { announce: $nodeRunning && $announce },
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("Editing labels failed", error);
|
||||
} finally {
|
||||
labelSaveInProgress = false;
|
||||
await reload();
|
||||
}
|
||||
}
|
||||
|
||||
async function saveAssignees(assignees: Author[]) {
|
||||
try {
|
||||
assigneesSaveInProgress = true;
|
||||
await invoke("edit_issue", {
|
||||
rid: repo.rid,
|
||||
cobId: issue.id,
|
||||
action: { type: "assign", assignees },
|
||||
opts: { announce: $nodeRunning && $announce },
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("Editing assignees failed", error);
|
||||
} finally {
|
||||
assigneesSaveInProgress = false;
|
||||
await reload();
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style>
|
||||
.meta-row {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
font: var(--txt-body-m-regular);
|
||||
}
|
||||
.author-chip {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
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-tertiary);
|
||||
font: var(--txt-body-m-regular);
|
||||
}
|
||||
</style>
|
||||
|
||||
<div class="meta-row">
|
||||
<div class="author-chip" title="Issue author">
|
||||
<NodeId {...authorForNodeId(issue.author)} />
|
||||
</div>
|
||||
<IdChip id={issue.id} label="issue ID" />
|
||||
<LabelInput
|
||||
allowedToEdit={isDelegate}
|
||||
labels={issue.labels}
|
||||
submitInProgress={labelSaveInProgress}
|
||||
save={saveLabels} />
|
||||
<AssigneeInput
|
||||
allowedToEdit={isDelegate}
|
||||
assignees={issue.assignees}
|
||||
submitInProgress={assigneesSaveInProgress}
|
||||
save={saveAssignees} />
|
||||
</div>
|
||||
@@ -4,9 +4,12 @@
|
||||
import capitalize from "lodash/capitalize";
|
||||
import isEqual from "lodash/isEqual";
|
||||
|
||||
import { issueStatusBackgroundColor, issueStatusColor } from "@app/lib/utils";
|
||||
import {
|
||||
issueStatusBackgroundColor,
|
||||
issueStatusColor,
|
||||
issueStatusIcon,
|
||||
} from "@app/lib/utils";
|
||||
|
||||
import Button from "@app/components/Button.svelte";
|
||||
import DropdownList from "@app/components/DropdownList.svelte";
|
||||
import DropdownListItem from "@app/components/DropdownListItem.svelte";
|
||||
import Icon from "@app/components/Icon.svelte";
|
||||
@@ -22,83 +25,105 @@
|
||||
const { selectedState, onSelect, disabled = false }: Props = $props();
|
||||
|
||||
let popoverExpanded: boolean = $state(false);
|
||||
|
||||
function label(state: State): string {
|
||||
return state.status === "closed"
|
||||
? `${capitalize(state.status)} as ${state.reason}`
|
||||
: capitalize(state.status);
|
||||
}
|
||||
</script>
|
||||
|
||||
<Popover
|
||||
popoverPadding="0"
|
||||
placement="bottom-start"
|
||||
bind:expanded={popoverExpanded}>
|
||||
{#snippet toggle(onclick)}
|
||||
<Button
|
||||
variant="outline"
|
||||
{disabled}
|
||||
{onclick}
|
||||
active={popoverExpanded}
|
||||
title={disabled
|
||||
? "Only delegates can change the issue state"
|
||||
: undefined}>
|
||||
<span
|
||||
class="global-chip"
|
||||
style:padding="0"
|
||||
style:margin-left="-0.25rem"
|
||||
style:color={disabled
|
||||
? undefined
|
||||
: issueStatusColor[selectedState.status]}
|
||||
style:background-color={disabled
|
||||
? undefined
|
||||
: issueStatusBackgroundColor[selectedState.status]}>
|
||||
<Icon
|
||||
name={selectedState.status === "open"
|
||||
? "issue"
|
||||
: `issue-${selectedState.status}`} />
|
||||
</span>
|
||||
<span style:color={disabled ? undefined : "var(--color-text-secondary)"}>
|
||||
{capitalize(selectedState.status)}
|
||||
{selectedState.status === "closed" ? `as ${selectedState.reason}` : ""}
|
||||
</span>
|
||||
<Icon name={popoverExpanded ? "chevron-up" : "chevron-down"} />
|
||||
</Button>
|
||||
{/snippet}
|
||||
{#snippet popover()}
|
||||
<div
|
||||
style:border="1px solid var(--color-border-subtle)"
|
||||
style:border-radius="var(--border-radius-sm)"
|
||||
style:display="flex"
|
||||
style:gap="0.5rem"
|
||||
style:align-items="center"
|
||||
style:background-color="var(--color-surface-canvas)">
|
||||
<DropdownList
|
||||
items={[
|
||||
{ status: "open" },
|
||||
{ status: "closed", reason: "solved" },
|
||||
{ status: "closed", reason: "other" },
|
||||
] as State[]}>
|
||||
{#snippet item(state)}
|
||||
<DropdownListItem
|
||||
selected={isEqual(selectedState, state)}
|
||||
styleGap="0.5rem"
|
||||
onclick={() => {
|
||||
onSelect(state);
|
||||
closeFocused();
|
||||
}}>
|
||||
<span
|
||||
class="global-chip"
|
||||
style:padding="0"
|
||||
style:margin-left="-0.5rem"
|
||||
style:color={issueStatusColor[state.status]}
|
||||
style:background-color={issueStatusBackgroundColor[state.status]}>
|
||||
<Icon
|
||||
name={state.status === "open"
|
||||
? "issue"
|
||||
: `issue-${state.status}`} />
|
||||
</span>
|
||||
<span style:color="var(--color-text-secondary)">
|
||||
{capitalize(state.status)}
|
||||
{state.status === "closed" ? `as ${state.reason}` : ""}
|
||||
</span>
|
||||
</DropdownListItem>
|
||||
{/snippet}
|
||||
</DropdownList>
|
||||
</div>
|
||||
{/snippet}
|
||||
</Popover>
|
||||
<style>
|
||||
.status-button,
|
||||
.status-chip {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.375rem;
|
||||
height: 2rem;
|
||||
padding: 0 0.5rem;
|
||||
border-radius: var(--border-radius-sm);
|
||||
font: var(--txt-body-m-regular);
|
||||
white-space: nowrap;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.status-button {
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
}
|
||||
.status-button:hover,
|
||||
.status-button.active {
|
||||
filter: brightness(0.95);
|
||||
}
|
||||
</style>
|
||||
|
||||
{#if disabled}
|
||||
<span
|
||||
class="status-chip"
|
||||
style:color={issueStatusColor[selectedState.status]}
|
||||
style:background-color={issueStatusBackgroundColor[selectedState.status]}
|
||||
title="Only the issue author and delegates can change the issue state">
|
||||
<Icon name={issueStatusIcon[selectedState.status]} />
|
||||
<span>{label(selectedState)}</span>
|
||||
</span>
|
||||
{:else}
|
||||
<Popover
|
||||
popoverPadding="0"
|
||||
placement="bottom-start"
|
||||
bind:expanded={popoverExpanded}>
|
||||
{#snippet toggle(onclick)}
|
||||
<button
|
||||
type="button"
|
||||
class="status-button"
|
||||
class:active={popoverExpanded}
|
||||
style:color={issueStatusColor[selectedState.status]}
|
||||
style:background-color={issueStatusBackgroundColor[
|
||||
selectedState.status
|
||||
]}
|
||||
{onclick}>
|
||||
<Icon name={issueStatusIcon[selectedState.status]} />
|
||||
<span>{label(selectedState)}</span>
|
||||
<Icon name={popoverExpanded ? "chevron-up" : "chevron-down"} />
|
||||
</button>
|
||||
{/snippet}
|
||||
{#snippet popover()}
|
||||
<div
|
||||
style:border="1px solid var(--color-border-subtle)"
|
||||
style:border-radius="var(--border-radius-sm)"
|
||||
style:display="flex"
|
||||
style:gap="0.5rem"
|
||||
style:align-items="center"
|
||||
style:background-color="var(--color-surface-canvas)">
|
||||
<DropdownList
|
||||
items={[
|
||||
{ status: "open" },
|
||||
{ status: "closed", reason: "solved" },
|
||||
{ status: "closed", reason: "other" },
|
||||
] as State[]}>
|
||||
{#snippet item(state)}
|
||||
<DropdownListItem
|
||||
selected={isEqual(selectedState, state)}
|
||||
styleGap="0.5rem"
|
||||
onclick={() => {
|
||||
onSelect(state);
|
||||
closeFocused();
|
||||
}}>
|
||||
<span
|
||||
class="global-chip"
|
||||
style:padding="0"
|
||||
style:margin-left="-0.5rem"
|
||||
style:color={issueStatusColor[state.status]}
|
||||
style:background-color={issueStatusBackgroundColor[
|
||||
state.status
|
||||
]}>
|
||||
<Icon name={issueStatusIcon[state.status]} />
|
||||
</span>
|
||||
<span style:color="var(--color-text-secondary)">
|
||||
{label(state)}
|
||||
</span>
|
||||
</DropdownListItem>
|
||||
{/snippet}
|
||||
</DropdownList>
|
||||
</div>
|
||||
{/snippet}
|
||||
</Popover>
|
||||
{/if}
|
||||
|
||||
@@ -8,10 +8,8 @@
|
||||
import type { RepoInfo } from "@bindings/repo/RepoInfo";
|
||||
import type { RepoRefs } from "@bindings/repo/RepoRefs";
|
||||
|
||||
import debounce from "lodash/debounce";
|
||||
|
||||
import { nodeRunning } from "@app/lib/events";
|
||||
import { invoke, writeToClipboard } from "@app/lib/invoke";
|
||||
import { invoke } from "@app/lib/invoke";
|
||||
import type { ReviewEntry } from "@app/lib/reviewSummary";
|
||||
import {
|
||||
entriesFromRevisions,
|
||||
@@ -24,7 +22,6 @@
|
||||
import {
|
||||
authorForNodeId,
|
||||
defaultBranch,
|
||||
formatOid,
|
||||
pluralize,
|
||||
publicKeyFromDid,
|
||||
unqualifyBranch,
|
||||
@@ -36,6 +33,7 @@
|
||||
import DropdownList from "@app/components/DropdownList.svelte";
|
||||
import DropdownListItem from "@app/components/DropdownListItem.svelte";
|
||||
import Icon from "@app/components/Icon.svelte";
|
||||
import IdChip from "@app/components/IdChip.svelte";
|
||||
import LabelInput from "@app/components/LabelInput.svelte";
|
||||
import NodeId from "@app/components/NodeId.svelte";
|
||||
import Popover, { closeFocused } from "@app/components/Popover.svelte";
|
||||
@@ -100,16 +98,6 @@
|
||||
});
|
||||
}
|
||||
|
||||
let patchIdCopied = $state(false);
|
||||
const resetPatchIdCopied = debounce(() => {
|
||||
patchIdCopied = false;
|
||||
}, 1000);
|
||||
async function copyPatchId() {
|
||||
await writeToClipboard(patch.id);
|
||||
patchIdCopied = true;
|
||||
resetPatchIdCopied();
|
||||
}
|
||||
|
||||
let labelSaveInProgress: boolean = $state(false);
|
||||
let assigneesSaveInProgress: boolean = $state(false);
|
||||
|
||||
@@ -292,7 +280,6 @@
|
||||
align-items: center;
|
||||
color: var(--color-text-brand);
|
||||
}
|
||||
.patch-id-chip,
|
||||
.target-branch-chip,
|
||||
.author-chip {
|
||||
display: inline-flex;
|
||||
@@ -316,22 +303,12 @@
|
||||
color: var(--color-text-tertiary);
|
||||
font: var(--txt-body-m-regular);
|
||||
}
|
||||
.target-group .patch-id-chip,
|
||||
.target-group .target-branch-chip {
|
||||
height: 100%;
|
||||
border: 0;
|
||||
border-radius: 0;
|
||||
background: none;
|
||||
}
|
||||
.patch-id-chip {
|
||||
gap: 0.375rem;
|
||||
cursor: pointer;
|
||||
}
|
||||
.patch-id-chip:hover,
|
||||
.patch-id-chip:focus-visible {
|
||||
background-color: var(--color-surface-subtle);
|
||||
color: var(--color-text-primary);
|
||||
}
|
||||
.target-arrow {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
@@ -345,26 +322,6 @@
|
||||
background-color: var(--color-surface-subtle);
|
||||
color: var(--color-text-primary);
|
||||
}
|
||||
.patch-id-value {
|
||||
font: var(--txt-code-regular);
|
||||
}
|
||||
/* Patch icon by default, copy icon on hover, checkmark on click. */
|
||||
.pid-icon-default,
|
||||
.pid-icon-hover {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
}
|
||||
.pid-icon-hover {
|
||||
display: none;
|
||||
}
|
||||
.patch-id-chip:hover .pid-icon-default,
|
||||
.patch-id-chip:focus-visible .pid-icon-default {
|
||||
display: none;
|
||||
}
|
||||
.patch-id-chip:hover .pid-icon-hover,
|
||||
.patch-id-chip:focus-visible .pid-icon-hover {
|
||||
display: inline-flex;
|
||||
}
|
||||
</style>
|
||||
|
||||
<div class="meta-row">
|
||||
@@ -372,19 +329,7 @@
|
||||
<NodeId {...authorForNodeId(patch.author)} />
|
||||
</div>
|
||||
<div class="target-group">
|
||||
<button
|
||||
type="button"
|
||||
class="patch-id-chip"
|
||||
title={patchIdCopied ? "Copied to clipboard" : "Copy patch ID"}
|
||||
onclick={copyPatchId}>
|
||||
{#if patchIdCopied}
|
||||
<Icon name="checkmark" />
|
||||
{:else}
|
||||
<span class="pid-icon-default"><Icon name="hash" /></span>
|
||||
<span class="pid-icon-hover"><Icon name="copy" /></span>
|
||||
{/if}
|
||||
<span class="patch-id-value">{formatOid(patch.id)}</span>
|
||||
</button>
|
||||
<IdChip id={patch.id} label="patch ID" grouped />
|
||||
{#if targetBranch}
|
||||
<span class="target-arrow" title={targetBranchCaption}>
|
||||
<Icon name="arrow-right" />
|
||||
|
||||
@@ -173,6 +173,11 @@ export const issueStatusBackgroundColor: Record<
|
||||
closed: "var(--color-surface-closed)",
|
||||
};
|
||||
|
||||
export const issueStatusIcon = {
|
||||
open: "issue",
|
||||
closed: "issue-closed",
|
||||
} as const satisfies Record<Issue["state"]["status"], string>;
|
||||
|
||||
export const issueStatusLabel: Record<Issue["state"]["status"], string> = {
|
||||
open: "Open Issues",
|
||||
closed: "Closed Issues",
|
||||
|
||||
+106
-172
@@ -8,35 +8,31 @@
|
||||
import type { Config } from "@bindings/config/Config";
|
||||
import type { RepoInfo } from "@bindings/repo/RepoInfo";
|
||||
|
||||
import partial from "lodash/partial";
|
||||
|
||||
import { nodeRunning } from "@app/lib/events";
|
||||
import { invoke } from "@app/lib/invoke";
|
||||
import { show } from "@app/lib/modal";
|
||||
import * as roles from "@app/lib/roles";
|
||||
import * as router from "@app/lib/router";
|
||||
import {
|
||||
issueStatusBackgroundColor,
|
||||
issueStatusColor,
|
||||
issueStatusIcon,
|
||||
issueStatusLabel,
|
||||
publicKeyFromDid,
|
||||
} from "@app/lib/utils";
|
||||
|
||||
import { announce } from "@app/components/AnnounceSwitch.svelte";
|
||||
import AssigneeInput from "@app/components/AssigneeInput.svelte";
|
||||
import Button from "@app/components/Button.svelte";
|
||||
import CommentComponent from "@app/components/Comment.svelte";
|
||||
import ConfirmDeleteButton from "@app/components/ConfirmDeleteButton.svelte";
|
||||
import Discussion, {
|
||||
type ActivityItem,
|
||||
} from "@app/components/Discussion.svelte";
|
||||
import EditableTitle from "@app/components/EditableTitle.svelte";
|
||||
import Icon from "@app/components/Icon.svelte";
|
||||
import Id from "@app/components/Id.svelte";
|
||||
import IssueActivityItem, {
|
||||
type FlattenedIssueOperation,
|
||||
} from "@app/components/IssueActivityItem.svelte";
|
||||
import IssueDescription from "@app/components/IssueDescription.svelte";
|
||||
import IssueMetadata from "@app/components/IssueMetadata.svelte";
|
||||
import IssueStateButton from "@app/components/IssueStateButton.svelte";
|
||||
import LabelInput from "@app/components/LabelInput.svelte";
|
||||
import ScrollArea from "@app/components/ScrollArea.svelte";
|
||||
import ShareButton from "@app/components/ShareButton.svelte";
|
||||
import Topbar from "@app/components/Topbar.svelte";
|
||||
@@ -56,8 +52,17 @@
|
||||
let { repo, issue, activity, config, threads }: Props = $props();
|
||||
/* eslint-enable prefer-const */
|
||||
|
||||
let labelSaveInProgress: boolean = $state(false);
|
||||
let assigneesSaveInProgress: boolean = $state(false);
|
||||
const canEditIssue = $derived(
|
||||
roles.isDelegateOrAuthor(
|
||||
config.publicKey,
|
||||
repo.delegates.map(delegate => delegate.did),
|
||||
issue.author.did,
|
||||
),
|
||||
);
|
||||
|
||||
const isOwnIssue = $derived(
|
||||
publicKeyFromDid(issue.author.did) === config.publicKey,
|
||||
);
|
||||
|
||||
const activityItems: ActivityItem<FlattenedIssueOperation>[] = $derived.by(
|
||||
() => {
|
||||
@@ -72,6 +77,18 @@
|
||||
]);
|
||||
const tracker: Partial<Record<Action["type"], Action>> = {};
|
||||
const items: ActivityItem<FlattenedIssueOperation>[] = [];
|
||||
const openedTimestamp =
|
||||
issue.body?.edits[0]?.timestamp ?? issue.timestamp;
|
||||
items.push({
|
||||
key: `${issue.id}:opened`,
|
||||
timestamp: openedTimestamp,
|
||||
data: {
|
||||
type: "opened",
|
||||
id: issue.id,
|
||||
author: issue.author,
|
||||
timestamp: openedTimestamp,
|
||||
},
|
||||
});
|
||||
activity.forEach(operation => {
|
||||
operation.actions.forEach((action, actionIndex) => {
|
||||
if (skipped.has(action.type)) {
|
||||
@@ -115,46 +132,6 @@
|
||||
},
|
||||
);
|
||||
|
||||
async function saveLabels(labels: string[]) {
|
||||
try {
|
||||
labelSaveInProgress = true;
|
||||
await invoke("edit_issue", {
|
||||
rid: repo.rid,
|
||||
cobId: issue.id,
|
||||
action: {
|
||||
type: "label",
|
||||
labels,
|
||||
},
|
||||
opts: { announce: $nodeRunning && $announce },
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("Editing labels failed", error);
|
||||
} finally {
|
||||
labelSaveInProgress = false;
|
||||
await reload();
|
||||
}
|
||||
}
|
||||
|
||||
async function saveAssignees(assignees: Author[]) {
|
||||
try {
|
||||
assigneesSaveInProgress = true;
|
||||
await invoke("edit_issue", {
|
||||
rid: repo.rid,
|
||||
cobId: issue.id,
|
||||
action: {
|
||||
type: "assign",
|
||||
assignees,
|
||||
},
|
||||
opts: { announce: $nodeRunning && $announce },
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("Editing assignees failed", error);
|
||||
} finally {
|
||||
assigneesSaveInProgress = false;
|
||||
await reload();
|
||||
}
|
||||
}
|
||||
|
||||
async function reload() {
|
||||
[issue, activity, threads] = await Promise.all([
|
||||
invoke<Issue>("issue_by_id", {
|
||||
@@ -276,6 +253,23 @@
|
||||
}
|
||||
}
|
||||
|
||||
async function deleteIssue() {
|
||||
try {
|
||||
await invoke("delete_issue", {
|
||||
rid: repo.rid,
|
||||
cobId: issue.id,
|
||||
opts: { announce: $nodeRunning && $announce },
|
||||
});
|
||||
void router.push({
|
||||
resource: "repo.issues",
|
||||
rid: repo.rid,
|
||||
status: issue.state.status,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("Deleting issue failed", error);
|
||||
}
|
||||
}
|
||||
|
||||
async function saveState(state: Issue["state"]) {
|
||||
try {
|
||||
await invoke("edit_issue", {
|
||||
@@ -305,6 +299,7 @@
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.375rem;
|
||||
min-width: 0;
|
||||
}
|
||||
.breadcrumb-link {
|
||||
cursor: pointer;
|
||||
@@ -317,59 +312,41 @@
|
||||
.breadcrumb-link:hover {
|
||||
color: var(--color-text-primary);
|
||||
}
|
||||
.content {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 22rem;
|
||||
.breadcrumb-title {
|
||||
color: var(--color-text-primary);
|
||||
font: var(--txt-body-m-medium);
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.main {
|
||||
padding: 1.5rem 2rem;
|
||||
padding: 1.5rem 6rem;
|
||||
min-width: 0;
|
||||
max-width: 80rem;
|
||||
margin: 0 auto;
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr);
|
||||
grid-template-areas:
|
||||
"title"
|
||||
"meta"
|
||||
"content";
|
||||
}
|
||||
.title {
|
||||
grid-area: title;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
margin-top: 1.5rem;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
.status-chip {
|
||||
padding: 0;
|
||||
height: 2rem;
|
||||
width: 2rem;
|
||||
flex-shrink: 0;
|
||||
.meta-bar {
|
||||
grid-area: meta;
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
.issue-body {
|
||||
margin: 1rem 0;
|
||||
position: relative;
|
||||
}
|
||||
.sidebar {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
border-left: 1px solid var(--color-border-subtle);
|
||||
height: 100%;
|
||||
padding: 1.5rem 1rem;
|
||||
}
|
||||
.sidebar-section {
|
||||
padding: 0.5rem;
|
||||
font: var(--txt-body-m-regular);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
}
|
||||
@media (max-width: 1349.98px) {
|
||||
.content {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
.sidebar {
|
||||
order: -1;
|
||||
border-left: none;
|
||||
border-bottom: 1px solid var(--color-border-subtle);
|
||||
flex-direction: row;
|
||||
align-items: flex-start;
|
||||
}
|
||||
.sidebar-section {
|
||||
flex: 1;
|
||||
}
|
||||
.content {
|
||||
grid-area: content;
|
||||
min-width: 0;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -377,7 +354,7 @@
|
||||
<div class="page">
|
||||
<Topbar>
|
||||
<div class="breadcrumb">
|
||||
<Icon name={issue.state.status === "open" ? "issue" : "issue-closed"} />
|
||||
<Icon name={issueStatusIcon[issue.state.status]} />
|
||||
<button
|
||||
class="breadcrumb-link"
|
||||
onclick={() =>
|
||||
@@ -389,9 +366,16 @@
|
||||
{issueStatusLabel[issue.state.status]}
|
||||
</button>
|
||||
<Icon name="chevron-right" />
|
||||
<Id id={issue.id} clipboard={issue.id} label="issue ID" />
|
||||
<span class="breadcrumb-title">{issue.title}</span>
|
||||
</div>
|
||||
<div style:margin-left="auto" style:display="flex" style:gap="0.5rem">
|
||||
<div
|
||||
style:margin-left="auto"
|
||||
style:display="flex"
|
||||
style:gap="0.5rem"
|
||||
style:z-index="40">
|
||||
{#if isOwnIssue}
|
||||
<ConfirmDeleteButton noun="issue" onDelete={deleteIssue} />
|
||||
{/if}
|
||||
<ShareButton
|
||||
explorerPath={`${repo.rid}/issues/${issue.id}`}
|
||||
id={issue.id}
|
||||
@@ -412,53 +396,35 @@
|
||||
</Topbar>
|
||||
|
||||
<ScrollArea style="flex: 1; min-height: 0;">
|
||||
<div class="content">
|
||||
<div class="main">
|
||||
<div class="title">
|
||||
<div
|
||||
class="global-chip status-chip"
|
||||
style:color={issueStatusColor[issue.state.status]}
|
||||
style:background-color={issueStatusBackgroundColor[
|
||||
issue.state.status
|
||||
]}>
|
||||
<Icon
|
||||
name={issue.state.status === "open"
|
||||
? "issue"
|
||||
: "issue-closed"} />
|
||||
</div>
|
||||
<EditableTitle
|
||||
{updateTitle}
|
||||
allowedToEdit={roles.isDelegateOrAuthor(
|
||||
config.publicKey,
|
||||
repo.delegates.map(delegate => delegate.did),
|
||||
issue.author.did,
|
||||
)}
|
||||
title={issue.title}
|
||||
cobId={issue.id} />
|
||||
</div>
|
||||
<div class="main">
|
||||
<div class="title">
|
||||
<IssueStateButton
|
||||
selectedState={issue.state}
|
||||
onSelect={saveState}
|
||||
disabled={!canEditIssue} />
|
||||
<EditableTitle
|
||||
{updateTitle}
|
||||
allowedToEdit={canEditIssue}
|
||||
title={issue.title}
|
||||
cobId={issue.id} />
|
||||
</div>
|
||||
|
||||
<div class="meta-bar">
|
||||
<IssueMetadata {config} {issue} {repo} {reload} />
|
||||
</div>
|
||||
|
||||
<div class="content">
|
||||
{#if issue.body}
|
||||
<div class="issue-body">
|
||||
<CommentComponent
|
||||
rid={repo.rid}
|
||||
currentUserNid={config.publicKey}
|
||||
id={issue.id}
|
||||
lastEdit={issue.body.edits.length > 1
|
||||
? issue.body.edits.at(-1)
|
||||
: undefined}
|
||||
author={issue.body.author}
|
||||
caption="opened"
|
||||
reactions={issue.body.reactions}
|
||||
timestamp={issue.body.edits.slice(-1)[0].timestamp}
|
||||
body={issue.body.edits.slice(-1)[0].body}
|
||||
editComment={roles.isDelegateOrAuthor(
|
||||
config.publicKey,
|
||||
repo.delegates.map(delegate => delegate.did),
|
||||
issue.body.author.did,
|
||||
) && partial(editComment, issue.body.id)}
|
||||
reactOnComment={partial(reactOnComment, issue.body.id)}>
|
||||
</CommentComponent>
|
||||
</div>
|
||||
{@const body = issue.body}
|
||||
<IssueDescription
|
||||
rid={repo.rid}
|
||||
body={body.edits.slice(-1)[0].body}
|
||||
reactions={body.reactions}
|
||||
currentUserNid={config.publicKey}
|
||||
allowedToEdit={!!canEditIssue}
|
||||
editComment={(text, embeds) => editComment(body.id, text, embeds)}
|
||||
reactOnComment={(authors, reaction) =>
|
||||
reactOnComment(body.id, authors, reaction)} />
|
||||
{/if}
|
||||
|
||||
{#snippet renderActivity(
|
||||
@@ -482,38 +448,6 @@
|
||||
{renderActivity}
|
||||
authorOf={op => op.author} />
|
||||
</div>
|
||||
|
||||
<div class="sidebar">
|
||||
<div class="sidebar-section">
|
||||
<IssueStateButton
|
||||
selectedState={issue.state}
|
||||
onSelect={saveState}
|
||||
disabled={!roles.isDelegate(
|
||||
config.publicKey,
|
||||
repo.delegates.map(d => d.did),
|
||||
)} />
|
||||
</div>
|
||||
<div class="sidebar-section">
|
||||
<LabelInput
|
||||
allowedToEdit={!!roles.isDelegate(
|
||||
config.publicKey,
|
||||
repo.delegates.map(delegate => delegate.did),
|
||||
)}
|
||||
labels={issue.labels}
|
||||
submitInProgress={labelSaveInProgress}
|
||||
save={saveLabels} />
|
||||
</div>
|
||||
<div class="sidebar-section">
|
||||
<AssigneeInput
|
||||
allowedToEdit={!!roles.isDelegate(
|
||||
config.publicKey,
|
||||
repo.delegates.map(delegate => delegate.did),
|
||||
)}
|
||||
assignees={issue.assignees}
|
||||
submitInProgress={assigneesSaveInProgress}
|
||||
save={saveAssignees} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</ScrollArea>
|
||||
</div>
|
||||
|
||||
+2
-105
@@ -43,6 +43,7 @@
|
||||
import { announce } from "@app/components/AnnounceSwitch.svelte";
|
||||
import Button from "@app/components/Button.svelte";
|
||||
import CheckoutPatchButton from "@app/components/CheckoutPatchButton.svelte";
|
||||
import ConfirmDeleteButton from "@app/components/ConfirmDeleteButton.svelte";
|
||||
import DraftReviewBar from "@app/components/DraftReviewBar.svelte";
|
||||
import DropdownList from "@app/components/DropdownList.svelte";
|
||||
import DropdownListItem from "@app/components/DropdownListItem.svelte";
|
||||
@@ -577,11 +578,7 @@
|
||||
};
|
||||
});
|
||||
|
||||
let deleteMenuExpanded = $state(false);
|
||||
let deleting = $state(false);
|
||||
async function deletePatch() {
|
||||
if (deleting) return;
|
||||
deleting = true;
|
||||
try {
|
||||
await invoke("delete_patch", {
|
||||
rid: repo.rid,
|
||||
@@ -595,9 +592,6 @@
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("Deleting patch failed", error);
|
||||
} finally {
|
||||
deleting = false;
|
||||
deleteMenuExpanded = false;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
@@ -613,54 +607,6 @@
|
||||
align-items: center;
|
||||
gap: 0.375rem;
|
||||
}
|
||||
.confirm-delete {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.75rem;
|
||||
padding: 0.75rem;
|
||||
min-width: 16rem;
|
||||
/* Without a cap the prompt lays itself out on one line and spans the
|
||||
window. */
|
||||
max-width: 24rem;
|
||||
}
|
||||
.confirm-delete-text {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
color: var(--color-text-primary);
|
||||
}
|
||||
.confirm-delete-note {
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
.confirm-delete-actions {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
.confirm-delete-button {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
height: 2rem;
|
||||
padding: 0 0.75rem;
|
||||
border: 0;
|
||||
border-radius: var(--border-radius-sm);
|
||||
background-color: var(--color-feedback-error-fill);
|
||||
color: var(--color-text-on-brand);
|
||||
cursor: pointer;
|
||||
transition: background-color 0.1s ease;
|
||||
}
|
||||
.confirm-delete-button:hover:not(:disabled),
|
||||
.confirm-delete-button:focus-visible:not(:disabled) {
|
||||
background-color: var(--color-feedback-error-fill-hover);
|
||||
}
|
||||
.confirm-delete-button:active:not(:disabled) {
|
||||
background-color: var(--color-feedback-error-fill-active);
|
||||
}
|
||||
.confirm-delete-button:disabled {
|
||||
cursor: default;
|
||||
opacity: 0.6;
|
||||
}
|
||||
.breadcrumb-link {
|
||||
cursor: pointer;
|
||||
background: none;
|
||||
@@ -1002,56 +948,7 @@
|
||||
a second Delete up here would be one word for two different
|
||||
targets. -->
|
||||
{#if isOwnPatch && !currentReview}
|
||||
<Popover
|
||||
popoverPadding="0"
|
||||
placement="bottom-end"
|
||||
bind:expanded={deleteMenuExpanded}>
|
||||
{#snippet toggle(onclick)}
|
||||
<Button
|
||||
variant="naked"
|
||||
{onclick}
|
||||
active={deleteMenuExpanded}
|
||||
title="Delete patch from your node">
|
||||
<Icon name="trash" />
|
||||
<span class="global-hide-on-medium-desktop-down">Delete</span>
|
||||
</Button>
|
||||
{/snippet}
|
||||
{#snippet popover()}
|
||||
<div
|
||||
style:border="1px solid var(--color-border-subtle)"
|
||||
style:border-radius="var(--border-radius-sm)"
|
||||
style:background-color="var(--color-surface-canvas)">
|
||||
<div class="confirm-delete">
|
||||
<div class="confirm-delete-text">
|
||||
<div class="txt-body-m-medium">
|
||||
Delete this patch from your node?
|
||||
</div>
|
||||
<div class="confirm-delete-note txt-body-m-regular">
|
||||
Only your copy is removed. You won't be able to restore it
|
||||
here, and peers who have already replicated the patch keep
|
||||
theirs.
|
||||
</div>
|
||||
</div>
|
||||
<div class="confirm-delete-actions">
|
||||
<Button
|
||||
variant="outline"
|
||||
disabled={deleting}
|
||||
onclick={() => (deleteMenuExpanded = false)}>
|
||||
Cancel
|
||||
</Button>
|
||||
<button
|
||||
type="button"
|
||||
class="confirm-delete-button txt-body-m-medium"
|
||||
disabled={deleting}
|
||||
onclick={deletePatch}>
|
||||
<Icon name="trash" />
|
||||
{deleting ? "Deleting…" : "Delete"}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/snippet}
|
||||
</Popover>
|
||||
<ConfirmDeleteButton noun="patch" onDelete={deletePatch} />
|
||||
{/if}
|
||||
<ShareButton
|
||||
explorerPath={`${repo.rid}/patches/${patch.id}`}
|
||||
|
||||
@@ -12,7 +12,7 @@ test("correct order of threads", async ({ page }) => {
|
||||
await page.getByRole("link", { name: "cobs" }).click();
|
||||
await page.getByRole("link", { name: "Issues" }).click();
|
||||
await page.getByText("This title has **markdown**").click();
|
||||
const body = page.locator(".issue-body");
|
||||
const body = page.locator(".description-body");
|
||||
await expect(body.getByText("This is a description")).toBeVisible();
|
||||
|
||||
const topLevelComments = await page.locator(".comments").all();
|
||||
@@ -43,7 +43,9 @@ test("creation of top level comments", async ({ page }) => {
|
||||
);
|
||||
await page.getByRole("button", { name: /^Save/ }).click();
|
||||
await expect(
|
||||
page.getByText("Make sure that comment creation is working"),
|
||||
page.getByRole("button", {
|
||||
name: "Make sure that comment creation is working",
|
||||
}),
|
||||
).toBeVisible();
|
||||
await expect(
|
||||
page.getByText(
|
||||
|
||||
Reference in New Issue
Block a user