Add editing to issue/patch descriptions, comments and replies

Also does some refactoring for `action`, `editable` and other variables
we were using which weren't very clear

Signed-off-by: Sebastian Martinez <me@sebastinez.dev>
This commit is contained in:
Sebastian Martinez
2023-10-26 16:27:54 +02:00
parent 9530af9ada
commit 5e5f7fc12f
22 changed files with 707 additions and 319 deletions
+2 -1
View File
@@ -1,3 +1,4 @@
import type { Embed } from "@app/lib/file.js";
import type { Commit, Commits } from "./project/commit.js";
import type { Fetcher, RequestOptions } from "./fetcher.js";
import type {
@@ -365,7 +366,7 @@ export class Client {
title: string;
description: string;
assignees: string[];
embeds: { name: string; content: string }[];
embeds: Embed[];
labels: string[];
},
authToken: string,
+1
View File
@@ -2,6 +2,7 @@ import type { z } from "zod";
import { array, number, object, string, tuple } from "zod";
export type Comment = z.infer<typeof commentSchema>;
export type Embed = z.infer<typeof commentSchema>["embeds"][0];
export const commentSchema = object({
id: string(),
+9 -3
View File
@@ -1,3 +1,4 @@
import type { Embed } from "@app/lib/file.js";
import type { Comment } from "./comment.js";
import type { ZodSchema } from "zod";
import { array, boolean, literal, object, string, union } from "zod";
@@ -59,10 +60,15 @@ export type IssueUpdateAction =
| {
type: "comment";
body: string;
embeds: { name: string; content: string }[];
replyTo: string;
embeds?: Embed[];
replyTo?: string;
}
| {
type: "comment.edit";
id: string;
body: string;
embeds: Embed[];
}
| { type: "comment.edit"; id: string; body: string }
| { type: "comment.redact"; id: string }
| {
type: "comment.react";
+7
View File
@@ -1,3 +1,4 @@
import type { Embed } from "@app/lib/file.js";
import type { Comment } from "./comment.js";
import type { ZodSchema, z } from "zod";
@@ -162,12 +163,15 @@ export type PatchUpdateAction =
review: string;
body: string;
location: CodeLocation;
replyTo?: string;
embeds?: Embed[];
}
| {
type: "review.comment.edit";
review: string;
comment: string;
body: string;
embeds: Embed[];
}
| {
type: "review.comment.redact";
@@ -188,6 +192,8 @@ export type PatchUpdateAction =
type: "revision.comment";
revision: string;
body: string;
embeds?: Embed[];
location?: CodeLocation;
replyTo?: string;
}
| {
@@ -195,6 +201,7 @@ export type PatchUpdateAction =
revision: string;
comment: string;
body: string;
embeds: Embed[];
}
| {
type: "revision.comment.redact";
+53 -18
View File
@@ -1,29 +1,35 @@
<script lang="ts" strictEvents>
import { createEventDispatcher } from "svelte";
import { createEventDispatcher, tick } from "svelte";
import { httpdStore } from "@app/lib/httpd";
import { authenticated } from "@app/lib/httpd";
import * as utils from "@app/lib/utils";
import ExtendedTextarea from "@app/components/ExtendedTextarea.svelte";
import IconButton from "@app/components/IconButton.svelte";
import IconSmall from "@app/components/IconSmall.svelte";
import Markdown from "@app/components/Markdown.svelte";
import NodeId from "./NodeId.svelte";
import NodeId from "@app/components/NodeId.svelte";
import ReactionSelector from "@app/components/ReactionSelector.svelte";
import Reactions from "@app/components/Reactions.svelte";
import Textarea from "@app/components/Textarea.svelte";
export let id: string | undefined = undefined;
export let authorId: string;
export let authorAlias: string | undefined = undefined;
export let body: string;
export let reactions: [string, string][];
export let action: "create" | "view" = "view";
export let caption = "commented";
export let rawPath: string;
export let timestamp: number;
export let isReply: boolean = false;
export let isLastReply: boolean = false;
// TODO: Remove flag once `radicle-httpd` fixes embed editing
export let disableEdit: boolean = false;
let editInProgress = false;
const dispatch = createEventDispatcher<{
react: { nids: string[]; id: string; reaction: string };
edit: string;
}>();
$: groupedReactions = reactions?.reduce(
@@ -64,13 +70,18 @@
}
.timestamp {
color: var(--color-fill-gray);
margin-left: auto;
font-size: var(--font-size-small);
}
.header-right {
display: flex;
margin-left: auto;
gap: 0.5rem;
}
.card-body {
word-wrap: break-word;
font-size: var(--font-size-small);
padding-left: 2rem;
padding-right: 2rem;
}
.actions {
display: flex;
@@ -80,6 +91,10 @@
padding-left: 2rem;
height: 22px;
}
.edit-buttons {
display: flex;
gap: 0.25rem;
}
.reply .card-body,
.reply .actions {
padding-left: 1rem;
@@ -108,30 +123,50 @@
</div>
<NodeId nodeId={authorId} alias={authorAlias} />
{caption}
<div class="timestamp" title={utils.absoluteTimestamp(timestamp)}>
{utils.formatTimestamp(timestamp)}
<div class="header-right">
{#if id && $authenticated && !editInProgress && !disableEdit}
<div class="edit-buttons">
<IconButton
title="edit comment"
on:click={() => (editInProgress = true)}>
<IconSmall name={"edit"} />
</IconButton>
</div>
{/if}
<div class="timestamp" title={utils.absoluteTimestamp(timestamp)}>
{utils.formatTimestamp(timestamp)}
</div>
</div>
</div>
</div>
<div class="card-body">
{#if action === "create"}
<Textarea
resizable
bind:value={body}
on:submit
placeholder="Leave a comment" />
{#if editInProgress}
<ExtendedTextarea
{body}
enableAttachments
submitCaption="Save"
placeholder="Leave your description"
on:submit={({ detail: { comment } }) => {
editInProgress = false;
dispatch("edit", comment);
}}
on:close={async () => {
body = body;
await tick();
editInProgress = false;
}} />
{:else if body.trim() === ""}
<span class="txt-missing">No description</span>
{:else}
<Markdown {rawPath} content={body} />
{/if}
</div>
{#if (id && $httpdStore.state === "authenticated") || (id && groupedReactions.size > 0)}
{#if (id && $authenticated) || (id && groupedReactions.size > 0)}
<div class="actions">
{#if id && $httpdStore.state === "authenticated"}
{#if id && $authenticated}
<ReactionSelector
nid={$httpdStore.session.publicKey}
nid={$authenticated.session.publicKey}
reactions={groupedReactions}
on:select={event => {
if (id) {
@@ -141,7 +176,7 @@
{/if}
{#if id && groupedReactions.size > 0}
<Reactions
clickable={$httpdStore.state === "authenticated"}
clickable={Boolean($authenticated)}
reactions={groupedReactions}
on:remove={event => {
if (id) {
+62
View File
@@ -0,0 +1,62 @@
<script lang="ts">
import type { Embed } from "@app/lib/file";
import { createEventDispatcher } from "svelte";
import ExtendedTextarea from "@app/components/ExtendedTextarea.svelte";
const dispatch = createEventDispatcher<{
submit: {
comment: string;
embeds: Embed[];
};
}>();
export let body: string | undefined = undefined;
export let placeholder: string | undefined = undefined;
export let submitCaption: string | undefined = undefined;
export let enableAttachments: boolean = false;
export let inline: boolean = false;
export let focus: boolean = false;
export let submitInProgress: boolean = false;
let active: boolean = false;
</script>
<style>
.inactive {
box-shadow: 0 0 0 1px var(--color-border-hint);
border-radius: var(--border-radius-small);
padding: 0.5rem 0.75rem;
background-color: var(--color-background-dip);
font-size: var(--font-size-small);
color: var(--color-fill-gray);
cursor: text;
}
.inactive:hover {
box-shadow: 0 0 0 1px var(--color-border-default);
}
</style>
{#if active}
<ExtendedTextarea
{inline}
{placeholder}
{submitCaption}
{submitInProgress}
{focus}
{body}
{enableAttachments}
on:close={() => (active = false)}
on:submit={event => {
active = false;
dispatch("submit", event.detail);
}} />
{:else}
<!-- svelte-ignore a11y-click-events-have-key-events -->
<div
class="inactive"
role="button"
tabindex="0"
on:click={() => (active = true)}>
{placeholder}
</div>
{/if}
@@ -1,5 +1,5 @@
<script lang="ts" strictEvents>
import type { Embed } from "@app/lib/file";
import type { Embed, EmbedWithOid } from "@app/lib/file";
import { createEventDispatcher } from "svelte";
@@ -11,31 +11,34 @@
import Button from "./Button.svelte";
import IconSmall from "./IconSmall.svelte";
import Loading from "./Loading.svelte";
import Markdown from "./Markdown.svelte";
import Radio from "./Radio.svelte";
import Textarea from "./Textarea.svelte";
export let enableAttachments: boolean = false;
export let placeholder: string = "Leave your comment";
export let submitCaption: string = "Comment";
export let focus: boolean = false;
export let inline: boolean = false;
export let body: string = "";
export let submitInProgress: boolean = false;
let commentBody: string = "";
let active: boolean = false;
let preview: boolean = false;
let newEmbeds: Embed[] = [];
let newEmbeds: EmbedWithOid[] = [];
let selectionStart = 0;
let selectionEnd = 0;
const dispatch = createEventDispatcher<{
submit: { comment: string; embeds: Embed[] };
close: null;
click: null;
}>();
function submit() {
dispatch("submit", { comment: commentBody, embeds: newEmbeds });
dispatch("submit", { comment: body, embeds: newEmbeds });
preview = false;
newEmbeds = [];
active = false;
}
const MAX_BLOB_SIZE = 4_194_304;
@@ -73,9 +76,9 @@
},
];
const embedText = `![${embed.name}](${embed.oid})\n`;
commentBody = commentBody
body = body
.slice(0, selectionStart)
.concat(embedText, commentBody.slice(selectionEnd));
.concat(embedText, body.slice(selectionEnd));
selectionStart += embedText.length;
selectionEnd = selectionStart;
}),
@@ -93,6 +96,7 @@
flex-direction: column;
align-items: flex-start;
gap: 1rem;
width: 100%;
}
.inline {
border: 0;
@@ -120,90 +124,71 @@
margin-left: 1px;
margin-top: 1px;
}
.inactive {
box-shadow: 0 0 0 1px var(--color-border-hint);
border-radius: var(--border-radius-small);
padding: 0.5rem 0.75rem;
background-color: var(--color-background-dip);
font-size: var(--font-size-small);
color: var(--color-fill-gray);
cursor: text;
}
.inactive:hover {
box-shadow: 0 0 0 1px var(--color-border-default);
}
</style>
{#if active}
<div class="comment-section" class:inline>
<Radio>
<div class="comment-section" class:inline>
<Radio>
<Button
styleBorderRadius="0"
variant={!preview ? "secondary" : "gray"}
on:click={() => {
preview = false;
}}>
<IconSmall name="edit" />
Edit
</Button>
<Button
styleBorderRadius="0"
disabled={body === ""}
variant={preview ? "secondary" : "gray"}
on:click={() => {
preview = true;
}}>
<IconSmall name="eye-open" />
Preview
</Button>
</Radio>
{#if preview}
<div class="preview">
<Markdown content={body} embeds={newEmbeds} />
</div>
{:else}
<Textarea
on:drop={handleFileDrop}
bind:selectionEnd
bind:selectionStart
{focus}
on:submit={submit}
bind:value={body}
{placeholder} />
{/if}
<div class="actions">
{#if !preview}
<div class="caption">
Markdown supported. {#if enableAttachments}Drop attachments into the
text area.{/if} Press {utils.isMac() ? "⌘" : "ctrl"}↵ to submit.
</div>
{/if}
<div class="buttons">
<Button
styleBorderRadius="0"
variant={!preview ? "secondary" : "gray"}
disabled={submitInProgress}
variant="outline"
on:click={() => {
preview = false;
dispatch("close");
}}>
<IconSmall name="edit" />
Edit
Cancel
</Button>
<Button
styleBorderRadius="0"
disabled={commentBody === ""}
variant={preview ? "secondary" : "gray"}
on:click={() => {
preview = true;
}}>
<IconSmall name="eye-open" />
Preview
variant="secondary"
disabled={!body || submitInProgress}
on:click={submit}>
{#if submitInProgress}
<Loading small noDelay />
{:else}
{submitCaption}
{/if}
</Button>
</Radio>
{#if preview}
<div class="preview">
<Markdown content={commentBody} embeds={newEmbeds} />
</div>
{:else}
<Textarea
on:drop={handleFileDrop}
bind:selectionEnd
bind:selectionStart
{focus}
on:submit={submit}
bind:value={commentBody}
{placeholder} />
{/if}
<div class="actions">
{#if !preview}
<div class="caption">
Markdown supported. {#if enableAttachments}Drop attachments into the
text area.{/if} Press {utils.isMac() ? "⌘" : "ctrl"}↵ to submit.
</div>
{/if}
<div class="buttons">
<Button
variant="outline"
on:click={() => {
preview = false;
active = false;
}}>
Cancel
</Button>
<Button variant="secondary" disabled={!commentBody} on:click={submit}>
Comment
</Button>
</div>
</div>
</div>
{:else}
<!-- svelte-ignore a11y-click-events-have-key-events -->
<div
class="inactive"
role="button"
tabindex="0"
on:click={() => {
commentBody = "";
active = true;
dispatch("click");
}}>
{placeholder}
</div>
{/if}
</div>
+17 -10
View File
@@ -1,6 +1,9 @@
<script lang="ts">
import Loading from "./Loading.svelte";
export let title: string | undefined = undefined;
export let ariaLabel: string | undefined = undefined;
export let loading: boolean = false;
</script>
<style>
@@ -23,13 +26,17 @@
}
</style>
<!-- svelte-ignore a11y-click-events-have-key-events -->
<div
role="button"
tabindex="0"
aria-label={ariaLabel}
{title}
class="button"
on:click>
<slot />
</div>
{#if loading}
<Loading small noDelay />
{:else}
<!-- svelte-ignore a11y-click-events-have-key-events -->
<div
role="button"
tabindex="0"
aria-label={ariaLabel}
{title}
class="button"
on:click>
<slot />
</div>
{/if}
+4 -2
View File
@@ -1,4 +1,6 @@
<script lang="ts">
import type { EmbedWithOid } from "@app/lib/file";
import dompurify from "dompurify";
import matter from "@radicle/gray-matter";
import { afterUpdate } from "svelte";
@@ -15,7 +17,7 @@
canonicalize,
isCommit,
} from "@app/lib/utils";
import { mimes, type Embed } from "@app/lib/file";
import { mimes } from "@app/lib/file";
export let content: string;
// If present, resolve all relative links with respect to this URL
@@ -24,7 +26,7 @@
export let rawPath: string | undefined = undefined;
// If present, means we are in a preview context,
// use this for image previews instead of /raw URLs.
export let embeds: Embed[] | undefined = undefined;
export let embeds: EmbedWithOid[] | undefined = undefined;
$: doc = matter(content);
$: frontMatter = Object.entries(doc.data).filter(
+10 -3
View File
@@ -6,12 +6,12 @@
import * as utils from "@app/lib/utils";
import CommentComponent from "@app/components/Comment.svelte";
import CommentTextarea from "./CommentTextarea.svelte";
import CommentToggleInput from "@app/components/CommentToggleInput.svelte";
import IconSmall from "./IconSmall.svelte";
export let thread: { root: Comment; replies: Comment[] };
export let rawPath: string;
export let enableAttachments: boolean;
export let enableAttachments: boolean = false;
async function toggleReply() {
// This tick allows the DOM to update before scrolling.
@@ -28,6 +28,7 @@
embeds: { name: string; content: string }[];
body: string;
};
editComment: { id: string; body: string };
react: { nids: string[]; commentId: string | undefined; reaction: string };
cancel: never;
}>();
@@ -70,7 +71,10 @@
authorAlias={root.author.alias}
reactions={root.reactions}
timestamp={root.timestamp}
disableEdit={root.embeds.length > 0}
body={root.body}
on:edit={event =>
dispatch("editComment", { id: root.id, body: event.detail })}
on:react>
<IconSmall name="chat" slot="icon" />
</CommentComponent>
@@ -88,14 +92,17 @@
isLastReply={replies[replies.length - 1] === reply}
reactions={reply.reactions}
timestamp={reply.timestamp}
disableEdit={reply.embeds.length > 0}
body={reply.body}
on:edit={event =>
dispatch("editComment", { id: reply.id, body: event.detail })}
on:react />
{/each}
</div>
{/if}
{#if $httpdStore.state === "authenticated"}
<div id={`reply-${root.id}`} class="reply">
<CommentTextarea
<CommentToggleInput
inline
placeholder="Reply to comment"
on:click={toggleReply}
+4 -4
View File
@@ -1,8 +1,10 @@
export interface Embed {
oid: string;
name: string;
content: string;
}
export interface EmbedWithOid extends Embed {
oid: string;
}
async function parseGitOid(bytes: Uint8Array): Promise<string> {
// Create the header
@@ -99,9 +101,7 @@ const mimes: Record<string, string> = {
zip: "application/zip",
};
async function embed(
file: File,
): Promise<{ oid: string; name: string; content: string }> {
async function embed(file: File): Promise<EmbedWithOid> {
const bytes = new Uint8Array(await file.arrayBuffer());
const oid = await parseGitOid(bytes);
const content = await base64String(file);
+8
View File
@@ -3,6 +3,7 @@ import { withTimeout, Mutex, E_CANCELED, E_TIMEOUT } from "async-mutex";
import { HttpdClient } from "@httpd-client";
import { config } from "@app/lib/config";
import { isLocal } from "@app/lib/utils";
export interface Session {
id: string;
@@ -23,6 +24,13 @@ const HTTPD_CUSTOM_PORT_KEY = "httpdCustomPort";
const store = writable<HttpdState>({ state: "stopped" });
export const httpdStore = derived(store, s => s);
export const authenticated = derived(store, s =>
s.state === "authenticated" ? s : undefined,
);
export const authenticatedLocal = derived(
store,
s => (hostname: string) => s.state === "authenticated" && isLocal(hostname),
);
export const api = new HttpdClient({
hostname: "127.0.0.1",
+12 -14
View File
@@ -11,8 +11,9 @@
const dispatch = createEventDispatcher<{ save: string[] }>();
export let action: "create" | "edit" | "view";
export let editInProgress: boolean = false;
export let mode: "readWrite" | "readOnly" = "readOnly";
export let hideEditIcon: boolean = false;
export let locallyAuthenticated: boolean = false;
export let assignees: string[] = [];
let updatedAssignees: string[] = assignees;
@@ -47,7 +48,7 @@
if (valid && assignee) {
updatedAssignees = [...updatedAssignees, assignee];
inputValue = "";
if (action === "create") {
if (mode !== "readOnly") {
dispatch("save", updatedAssignees);
}
}
@@ -55,7 +56,7 @@
function removeAssignee(assignee: string) {
updatedAssignees = updatedAssignees.filter(x => x !== assignee);
if (action === "create") {
if (mode !== "readOnly") {
dispatch("save", updatedAssignees);
}
}
@@ -93,13 +94,13 @@
<div>
<div class="header">
<span>Assignees</span>
{#if action === "edit"}
{#if locallyAuthenticated && !hideEditIcon}
<div class="actions">
{#if editInProgress}
{#if mode !== "readOnly"}
<IconButton
on:click={() => {
dispatch("save", updatedAssignees);
editInProgress = !editInProgress;
mode = "readOnly";
}}>
<IconSmall name="checkmark" />
</IconButton>
@@ -107,15 +108,12 @@
on:click={() => {
updatedAssignees = assignees;
inputValue = "";
editInProgress = !editInProgress;
mode = "readOnly";
}}>
<IconSmall name="cross" />
</IconButton>
{:else}
<IconButton
on:click={() => {
editInProgress = !editInProgress;
}}>
<IconButton on:click={() => (mode = "readWrite")}>
<IconSmall name="edit" />
</IconButton>
{/if}
@@ -123,7 +121,7 @@
{/if}
</div>
<div class="body">
{#if editInProgress || action === "create"}
{#if locallyAuthenticated && mode === "readWrite"}
{#each updatedAssignees as assignee}
<Badge variant="neutral">
<div class="assignee">
@@ -152,7 +150,7 @@
{/each}
{/if}
</div>
{#if editInProgress || action === "create"}
{#if locallyAuthenticated && mode === "readWrite"}
<div style:margin-bottom="1rem" style:margin-top="1rem">
<TextInput
{valid}
+17 -12
View File
@@ -8,14 +8,15 @@
import TextInput from "@app/components/TextInput.svelte";
import IconButton from "@app/components/IconButton.svelte";
export let action: "create" | "edit" | "view" = "view";
export let locallyAuthenticated: boolean = false;
export let preview: boolean = false;
export let mode: "readWrite" | "readOnly" = "readOnly";
export let id: string | undefined = undefined;
export let title: string = "";
export let submitInProgress: boolean = false;
const oldTitle = title;
const dispatch = createEventDispatcher<{ editTitle: string }>();
$: editable = action === "create" ? true : false;
</script>
<style>
@@ -65,15 +66,15 @@
<div class="header">
<div class="summary">
{#if editable}
{#if locallyAuthenticated && !preview && mode === "readWrite"}
<div><slot name="icon" /></div>
<TextInput
placeholder="Title"
bind:value={title}
showKeyHint={action === "edit"}
showKeyHint={mode === "readWrite" && Boolean(id)}
on:submit={() => {
if (action === "edit") {
editable = !editable;
if (mode === "readWrite") {
mode = "readOnly";
dispatch("editTitle", title);
}
}} />
@@ -85,30 +86,34 @@
{:else}
<span class="txt-missing">No title</span>
{/if}
{#if action === "edit"}
<!-- When creating a new COB id is undefined -->
{#if locallyAuthenticated && id}
<div class="edit-buttons">
{#if editable}
{#if mode === "readWrite"}
<IconButton
title="save title"
loading={submitInProgress}
on:click={() => {
editable = !editable;
mode = "readOnly";
dispatch("editTitle", title);
}}>
<IconSmall name={"checkmark"} />
</IconButton>
<IconButton
title="dismiss changes"
loading={submitInProgress}
on:click={() => {
title = oldTitle;
editable = !editable;
mode = "readOnly";
}}>
<IconSmall name={"cross"} />
</IconButton>
{:else}
<IconButton
title="edit title"
loading={submitInProgress}
on:click={() => {
editable = !editable;
mode = "readWrite";
dispatch("editTitle", title);
}}>
<IconSmall name={"edit"} />
+3 -1
View File
@@ -1,8 +1,10 @@
<script lang="ts" strictEvents>
import type { Embed } from "@app/lib/file";
import Badge from "@app/components/Badge.svelte";
import Clipboard from "@app/components/Clipboard.svelte";
export let embeds: { name: string; content: string }[] = [];
export let embeds: Embed[] = [];
</script>
<style>
+12 -15
View File
@@ -8,8 +8,9 @@
const dispatch = createEventDispatcher<{ save: string[] }>();
export let action: "create" | "edit" | "view" = "view";
export let editInProgress: boolean = false;
export let hideEditIcon: boolean = false;
export let mode: "readWrite" | "readOnly" = "readOnly";
export let locallyAuthenticated: boolean = false;
export let labels: string[] = [];
let updatedLabels: string[] = labels;
@@ -41,7 +42,7 @@
if (valid && sanitizedValue) {
updatedLabels = [...updatedLabels, sanitizedValue];
inputValue = "";
if (action === "create") {
if (mode === "readWrite") {
dispatch("save", updatedLabels);
}
}
@@ -49,7 +50,7 @@
function removeLabel(label: string) {
updatedLabels = updatedLabels.filter(x => x !== label);
if (action === "create") {
if (mode === "readWrite") {
dispatch("save", updatedLabels);
}
}
@@ -81,14 +82,14 @@
<div>
<div class="metadata-section-header">
<span>Labels</span>
{#if action === "edit"}
{#if locallyAuthenticated && !hideEditIcon}
<div class="actions">
{#if editInProgress}
{#if mode === "readWrite"}
<IconButton
title="save labels"
on:click={() => {
dispatch("save", updatedLabels);
editInProgress = !editInProgress;
mode = "readOnly";
}}>
<IconSmall name="checkmark" />
</IconButton>
@@ -97,16 +98,12 @@
on:click={() => {
updatedLabels = labels;
inputValue = "";
editInProgress = !editInProgress;
mode = "readOnly";
}}>
<IconSmall name="cross" />
</IconButton>
{:else}
<IconButton
title="edit labels"
on:click={() => {
editInProgress = !editInProgress;
}}>
<IconButton title="edit labels" on:click={() => (mode = "readWrite")}>
<IconSmall name="edit" />
</IconButton>
{/if}
@@ -114,7 +111,7 @@
{/if}
</div>
<div class="metadata-section-body">
{#if editInProgress || action === "create"}
{#if locallyAuthenticated && mode === "readWrite"}
{#each updatedLabels as label}
<Badge variant="neutral">
<div aria-label="chip" class="label">{label}</div>
@@ -135,7 +132,7 @@
{/each}
{/if}
</div>
{#if editInProgress || action === "create"}
{#if locallyAuthenticated && mode === "readWrite"}
<div style:margin-bottom="2rem" style:margin-top="1rem">
<TextInput
{valid}
+1 -1
View File
@@ -385,9 +385,9 @@
{#if element.type === "thread"}
<div class="connector" />
<Thread
enableAttachments={false}
rawPath={utils.getRawBasePath(projectId, baseUrl, projectHead)}
thread={element.inner}
on:editComment
on:react
on:reply />
{:else if element.type === "merge"}
+181 -79
View File
@@ -4,23 +4,26 @@
import type { IssueUpdateAction } from "@httpd-client/lib/project/issue";
import type { Session } from "@app/lib/httpd";
import { isEqual } from "lodash";
import { isEqual, uniqBy } from "lodash";
import * as modal from "@app/lib/modal";
import * as router from "@app/lib/router";
import * as utils from "@app/lib/utils";
import { HttpdClient } from "@httpd-client";
import { ResponseError } from "@httpd-client/lib/fetcher";
import { httpdStore } from "@app/lib/httpd";
import { authenticated, authenticatedLocal } from "@app/lib/httpd";
import AssigneeInput from "@app/views/projects/Cob/AssigneeInput.svelte";
import Badge from "@app/components/Badge.svelte";
import CobHeader from "@app/views/projects/Cob/CobHeader.svelte";
import CobStateButton from "@app/views/projects/Cob/CobStateButton.svelte";
import CommentTextarea from "@app/components/CommentTextarea.svelte";
import CommentToggleInput from "@app/components/CommentToggleInput.svelte";
import Embeds from "@app/views/projects/Cob/Embeds.svelte";
import ErrorModal from "@app/modals/ErrorModal.svelte";
import ExtendedTextarea from "@app/components/ExtendedTextarea.svelte";
import Icon from "@app/components/Icon.svelte";
import IconButton from "@app/components/IconButton.svelte";
import IconSmall from "@app/components/IconSmall.svelte";
import LabelInput from "./Cob/LabelInput.svelte";
import Layout from "./Layout.svelte";
import Markdown from "@app/components/Markdown.svelte";
@@ -37,11 +40,6 @@
const rawPath = utils.getRawBasePath(project.id, baseUrl, project.head);
const api = new HttpdClient(baseUrl);
let action: "edit" | "view";
$: action =
$httpdStore.state === "authenticated" && utils.isLocal(baseUrl.hostname)
? "edit"
: "view";
const items: [string, IssueState][] = [
["Reopen issue", { status: "open" }],
["Close issue as solved", { status: "closed", reason: "solved" }],
@@ -49,23 +47,18 @@
];
async function createReply({
detail: reply,
detail: { body, embeds, id },
}: CustomEvent<{
id: string;
embeds: { name: string; content: string }[];
embeds: Embed[];
body: string;
}>) {
if ($httpdStore.state === "authenticated" && reply.body.trim().length > 0) {
if ($authenticated && body.trim().length > 0) {
const status = await updateIssue(
project.id,
issue.id,
{
type: "comment",
body: reply.body,
embeds: reply.embeds,
replyTo: reply.id,
},
$httpdStore.session,
{ type: "comment", body, embeds, replyTo: id },
$authenticated.session,
api,
);
if (status === "success") {
@@ -74,18 +67,15 @@
}
}
async function createComment(body: string, embeds: Embed[]) {
if ($httpdStore.state === "authenticated" && body.trim().length > 0) {
async function createComment({
detail: { comment, embeds },
}: CustomEvent<{ comment: string; embeds: Embed[] }>) {
if ($authenticated && comment.trim().length > 0) {
const status = await updateIssue(
project.id,
issue.id,
{
type: "comment",
body,
embeds: embeds,
replyTo: issue.id,
},
$httpdStore.session,
{ type: "comment", body: comment, embeds, replyTo: issue.id },
$authenticated.session,
api,
);
if (status === "success") {
@@ -94,6 +84,57 @@
}
}
async function editComment(id: string, body: string) {
if ($authenticated && body.trim().length > 0) {
try {
if (issue.id === id) {
saveDescriptionInProgress = true;
} else {
saveCommentInProgress = true;
}
const status = await updateIssue(
project.id,
issue.id,
{
type: "comment.edit",
id,
body,
embeds: embeds[id],
},
$authenticated.session,
api,
);
if (status === "success") {
issue = await refreshIssue(project.id, issue, api);
} else {
// Reassigning issue.discussion overwrites the changed comment in Comment
issue.discussion = issue.discussion;
}
} catch (error) {
if (error instanceof Error) {
modal.show({
component: ErrorModal,
props: {
title: "Issue comment editing failed",
subtitle: [
"There was an error while updating the issue.",
"Check your radicle-httpd logs for details.",
],
error: {
message: error.message,
stack: error.stack,
},
},
});
}
} finally {
editingIssueDescription = false;
saveDescriptionInProgress = false;
saveCommentInProgress = false;
}
}
}
async function handleReaction({
nids,
id,
@@ -103,7 +144,7 @@
id: string;
reaction: string;
}) {
if ($httpdStore.state === "authenticated") {
if ($authenticated) {
try {
const status = await updateIssue(
project.id,
@@ -112,9 +153,11 @@
type: "comment.react",
id,
reaction,
active: nids.includes($httpdStore.session.publicKey) ? false : true,
active: nids.includes($authenticated.session.publicKey)
? false
: true,
},
$httpdStore.session,
$authenticated.session,
api,
);
if (status === "success") {
@@ -127,22 +170,40 @@
}
async function editTitle({ detail: title }: CustomEvent<string>) {
if (
$httpdStore.state === "authenticated" &&
title.trim().length > 0 &&
title !== issue.title
) {
const status = await updateIssue(
project.id,
issue.id,
{ type: "edit", title },
$httpdStore.session,
api,
);
if (status === "success") {
issue = await refreshIssue(project.id, issue, api);
if ($authenticated && title.trim().length > 0 && title !== issue.title) {
try {
saveTitleInProgress = true;
const status = await updateIssue(
project.id,
issue.id,
{ type: "edit", title },
$authenticated.session,
api,
);
if (status === "success") {
issue = await refreshIssue(project.id, issue, api);
}
issue.title = issue.title;
} catch (error) {
if (error instanceof Error) {
modal.show({
component: ErrorModal,
props: {
title: "Issue title editing failed",
subtitle: [
"There was an error while updating the issue.",
"Check your radicle-httpd logs for details.",
],
error: {
message: error.message,
stack: error.stack,
},
},
});
}
} finally {
saveTitleInProgress = false;
}
issue.title = issue.title;
} else {
// Reassigning issue.title overwrites the invalid title in IssueHeader
issue.title = issue.title;
@@ -150,39 +211,36 @@
}
async function saveLabels({ detail: labels }: CustomEvent<string[]>) {
if ($httpdStore.state === "authenticated") {
if ($authenticated) {
if (isEqual(issue.labels, labels)) {
return;
}
const status = await updateIssue(
project.id,
issue.id,
{
type: "label",
labels: labels,
},
$httpdStore.session,
{ type: "label", labels },
$authenticated.session,
api,
);
if (status === "success") {
issue = await refreshIssue(project.id, issue, api);
} else {
// Reassigning issue overwrites the label changes.
issue = issue;
}
}
}
async function saveAssignees({ detail: assignees }: CustomEvent<string[]>) {
if ($httpdStore.state === "authenticated") {
if ($authenticated) {
if (isEqual(issue.assignees, assignees)) {
return;
}
const status = await updateIssue(
project.id,
issue.id,
{
type: "assign",
assignees: assignees,
},
$httpdStore.session,
{ type: "assign", assignees },
$authenticated.session,
api,
);
if (status === "success") {
@@ -192,12 +250,12 @@
}
async function saveStatus({ detail: state }: CustomEvent<IssueState>) {
if ($httpdStore.state === "authenticated") {
if ($authenticated) {
const status = await updateIssue(
project.id,
issue.id,
{ type: "lifecycle", state },
$httpdStore.session,
$authenticated.session,
api,
);
if (status === "success") {
@@ -289,14 +347,16 @@
}
const issueDescription = issue.discussion[0];
let editingIssueDescription = false;
$: embeds = issue.discussion.reduce(
(acc, comment) => {
acc.push(...comment.embeds);
acc[comment.id] = comment.embeds;
return acc;
},
[] as { name: string; content: string }[],
{} as Record<string, Embed[]>,
);
$: uniqueEmbeds = uniqBy(Object.values(embeds).flat(), "content");
$: selectedItem = issue.state.status === "closed" ? items[0] : items[1];
$: threads = issue.discussion
.filter(
@@ -316,6 +376,10 @@
(acc, [nid, emoji]) => acc.set(emoji, [...(acc.get(emoji) ?? []), nid]),
new Map<string, string[]>(),
);
let saveDescriptionInProgress = false;
let saveTitleInProgress = false;
let saveCommentInProgress = false;
</script>
<style>
@@ -354,6 +418,12 @@
height: 22px;
margin-top: 1rem;
}
.markdown {
display: flex;
flex-direction: row;
align-items: flex-start;
justify-content: space-between;
}
.open {
color: var(--color-fill-success);
}
@@ -376,9 +446,10 @@
<div class="issue">
<div style="display: flex; flex-direction: column; gap: 1.5rem;">
<CobHeader
{action}
locallyAuthenticated={$authenticatedLocal(baseUrl.hostname)}
id={issue.id}
title={issue.title}
submitInProgress={saveTitleInProgress}
on:editTitle={editTitle}>
<svelte:fragment slot="icon">
<div
@@ -401,21 +472,47 @@
{/if}
</svelte:fragment>
<div slot="description">
<Markdown
content={issue.discussion[0].body}
rawPath={utils.getRawBasePath(project.id, baseUrl, project.head)} />
{#if $authenticatedLocal(baseUrl.hostname) && editingIssueDescription}
<ExtendedTextarea
enableAttachments
body={issue.discussion[0].body}
submitCaption="Save"
submitInProgress={saveDescriptionInProgress}
placeholder="Leave a description"
on:close={() => (editingIssueDescription = false)}
on:submit={async ({ detail: { comment } }) => {
void editComment(issue.id, comment);
}} />
{:else}
<div class="markdown">
<Markdown
content={issue.discussion[0].body}
rawPath={utils.getRawBasePath(
project.id,
baseUrl,
project.head,
)} />
<!-- TODO: Remove if statement once `radicle-httpd` fixes embed editing -->
{#if issue.discussion[0].embeds.length === 0}
<IconButton
title="edit description"
on:click={() => (editingIssueDescription = true)}>
<IconSmall name={"edit"} />
</IconButton>
{/if}
</div>
{/if}
<div class="reactions">
{#if $httpdStore.state === "authenticated"}
{#if $authenticated}
<ReactionSelector
nid={$httpdStore.session.publicKey}
nid={$authenticated.session.publicKey}
reactions={issueReactions}
on:select={async event => {
await handleReaction({ ...event.detail, id: issue.id });
}} />
on:select={event =>
handleReaction({ ...event.detail, id: issue.id })} />
{/if}
{#if issueReactions.size > 0}
<Reactions
clickable={$httpdStore.state === "authenticated"}
clickable={Boolean($authenticated)}
reactions={issueReactions}
on:remove={event =>
handleReaction({ ...event.detail, id: issue.id })} />
@@ -436,17 +533,19 @@
enableAttachments
{thread}
{rawPath}
on:editComment={({ detail: { id, body } }) =>
editComment(id, body)}
on:reply={createReply}
on:react={event => handleReaction(event.detail)} />
{/each}
</div>
{/if}
{#if $httpdStore.state === "authenticated"}
<CommentTextarea
{#if $authenticated}
<CommentToggleInput
placeholder="Leave your comment"
enableAttachments
on:submit={async event => {
await createComment(event.detail.comment, event.detail.embeds);
}} />
submitInProgress={saveCommentInProgress}
on:submit={createComment} />
<div style:display="flex">
<CobStateButton
items={items.filter(([, state]) => !isEqual(state, issue.state))}
@@ -458,11 +557,14 @@
</div>
<div class="metadata">
<AssigneeInput
{action}
locallyAuthenticated={$authenticatedLocal(baseUrl.hostname)}
assignees={issue.assignees}
on:save={saveAssignees} />
<LabelInput {action} labels={issue.labels} on:save={saveLabels} />
<Embeds {embeds} />
<LabelInput
locallyAuthenticated={$authenticatedLocal(baseUrl.hostname)}
labels={issue.labels}
on:save={saveLabels} />
<Embeds embeds={uniqueEmbeds} />
</div>
</div>
</Layout>
+19 -18
View File
@@ -1,12 +1,12 @@
<script lang="ts">
import type { BaseUrl, Project } from "@httpd-client";
import type { EmbedWithOid } from "@app/lib/file";
import * as modal from "@app/lib/modal";
import * as router from "@app/lib/router";
import * as utils from "@app/lib/utils";
import { HttpdClient } from "@httpd-client";
import { embed, type Embed } from "@app/lib/file";
import { httpdStore } from "@app/lib/httpd";
import { embed } from "@app/lib/file";
import { authenticatedLocal, httpdStore } from "@app/lib/httpd";
import AssigneeInput from "@app/views/projects/Cob/AssigneeInput.svelte";
import AuthenticationErrorModal from "@app/modals/AuthenticationErrorModal.svelte";
@@ -25,17 +25,10 @@
export let project: Project;
export let tracking: boolean;
let newEmbeds: Embed[] = [];
let newEmbeds: EmbedWithOid[] = [];
let preview: boolean = false;
let selectionStart = 0;
let selectionEnd = 0;
let preview: boolean = false;
let action: "create" | "view";
$: action =
$httpdStore.state === "authenticated" &&
utils.isLocal(baseUrl.hostname) &&
!preview
? "create"
: "view";
let issueTitle = "";
let issueText = "";
@@ -155,19 +148,23 @@
{@const session = $httpdStore.session}
<div class="form">
<div class="editor">
<CobHeader {action} bind:title={issueTitle}>
<CobHeader
mode="readWrite"
{preview}
locallyAuthenticated={$authenticatedLocal(baseUrl.hostname)}
bind:title={issueTitle}>
<svelte:fragment slot="icon">
<div class="open">
<Icon name="issue" />
</div>
</svelte:fragment>
<svelte:fragment slot="state">
{#if action === "view"}
{#if preview}
<Badge size="small" variant="positive">open</Badge>
{/if}
</svelte:fragment>
<svelte:fragment slot="description">
{#if action === "create"}
{#if !preview}
<Textarea
bind:selectionStart
bind:selectionEnd
@@ -186,7 +183,7 @@
{/if}
</svelte:fragment>
<div class="author" slot="author">
{#if action === "view"}
{#if preview}
opened by <NodeId
nodeId={$httpdStore.session.publicKey}
alias={$httpdStore.session.alias} /> now
@@ -211,11 +208,15 @@
</div>
<div class="metadata">
<AssigneeInput
{action}
hideEditIcon
mode="readWrite"
locallyAuthenticated={$authenticatedLocal(baseUrl.hostname)}
on:save={({ detail: updatedAssignees }) =>
(assignees = updatedAssignees)} />
<LabelInput
{action}
hideEditIcon
mode="readWrite"
locallyAuthenticated={$authenticatedLocal(baseUrl.hostname)}
on:save={({ detail: updatedLabels }) => (labels = updatedLabels)} />
</div>
</div>
+2 -3
View File
@@ -1,11 +1,10 @@
<script lang="ts">
import type { BaseUrl, Issue, IssueState, Project } from "@httpd-client";
import * as utils from "@app/lib/utils";
import { HttpdClient } from "@httpd-client";
import { ISSUES_PER_PAGE } from "./router";
import { closeFocused } from "@app/components/Popover.svelte";
import { httpdStore } from "@app/lib/httpd";
import { authenticatedLocal } from "@app/lib/httpd";
import Button from "@app/components/Button.svelte";
import DropdownList from "@app/components/DropdownList.svelte";
@@ -124,7 +123,7 @@
</Link>
</DropdownList>
</Popover>
{#if $httpdStore.state === "authenticated" && utils.isLocal(baseUrl.hostname)}
{#if $authenticatedLocal(baseUrl.hostname)}
<div style="margin-left: auto;">
<Link
route={{
+213 -50
View File
@@ -36,6 +36,7 @@
<script lang="ts">
import type { BaseUrl, Patch, PatchUpdateAction } from "@httpd-client";
import type { Embed } from "@app/lib/file";
import type { PatchView } from "./router";
import type { Route } from "@app/lib/router";
import type { Session } from "@app/lib/httpd";
@@ -46,27 +47,33 @@
import * as utils from "@app/lib/utils";
import { HttpdClient } from "@httpd-client";
import { capitalize, isEqual } from "lodash";
import { httpdStore } from "@app/lib/httpd";
import {
authenticated,
authenticatedLocal,
httpdStore,
} from "@app/lib/httpd";
import Badge from "@app/components/Badge.svelte";
import Button from "@app/components/Button.svelte";
import Changeset from "@app/views/projects/Changeset.svelte";
import CobHeader from "@app/views/projects/Cob/CobHeader.svelte";
import CobStateButton from "@app/views/projects/Cob/CobStateButton.svelte";
import CommentTextarea from "@app/components/CommentTextarea.svelte";
import CommentToggleInput from "@app/components/CommentToggleInput.svelte";
import CommitTeaser from "@app/views/projects/Commit/CommitTeaser.svelte";
import DropdownList from "@app/components/DropdownList.svelte";
import DropdownListItem from "@app/components/DropdownList/DropdownListItem.svelte";
import ErrorModal from "@app/modals/ErrorModal.svelte";
import ExtendedTextarea from "@app/components/ExtendedTextarea.svelte";
import Icon from "@app/components/Icon.svelte";
import IconButton from "@app/components/IconButton.svelte";
import IconSmall from "@app/components/IconSmall.svelte";
import LabelInput from "@app/views/projects/Cob/LabelInput.svelte";
import Layout from "@app/views/projects/Layout.svelte";
import Link from "@app/components/Link.svelte";
import Markdown from "@app/components/Markdown.svelte";
import Popover, { closeFocused } from "@app/components/Popover.svelte";
import NodeId from "@app/components/NodeId.svelte";
import Placeholder from "@app/components/Placeholder.svelte";
import Popover, { closeFocused } from "@app/components/Popover.svelte";
import Radio from "@app/components/Radio.svelte";
import RevisionComponent from "@app/views/projects/Cob/Revision.svelte";
@@ -84,10 +91,99 @@
["Convert to draft", { status: "draft" }],
];
async function editTitle({ detail: title }: CustomEvent<string>) {
if ($authenticated && title.trim().length > 0 && title !== patch.title) {
try {
saveTitleInProgress = true;
const status = await updatePatch(
project.id,
patch.id,
{
type: "edit",
title,
target: "delegates",
},
$authenticated.session,
api,
);
if (status === "success") {
patch = await api.project.getPatchById(project.id, patch.id);
}
} catch (error) {
if (error instanceof Error) {
modal.show({
component: ErrorModal,
props: {
title: "Patch title editing failed",
subtitle: [
"There was an error while updating the issue.",
"Check your radicle-httpd logs for details.",
],
error: {
message: error.message,
stack: error.stack,
},
},
});
}
} finally {
saveTitleInProgress = false;
}
}
}
async function editDescription({
detail: { comment: description },
}: CustomEvent<{ comment: string; embeds: Embed[] }>) {
if (
$authenticated &&
description.trim().length > 0 &&
description !== patch.title
) {
try {
saveDescriptionInProgress = true;
const status = await updatePatch(
project.id,
patch.id,
{
type: "revision.edit",
revision: patch.id,
description,
},
$authenticated.session,
api,
);
if (status === "success") {
editingDescription = false;
patch = await api.project.getPatchById(project.id, patch.id);
}
} catch (error) {
if (error instanceof Error) {
modal.show({
component: ErrorModal,
props: {
title: "Patch description editing failed",
subtitle: [
"There was an error while updating the issue.",
"Check your radicle-httpd logs for details.",
],
error: {
message: error.message,
stack: error.stack,
},
},
});
}
} finally {
saveDescriptionInProgress = false;
}
}
}
async function createReply({
detail: reply,
}: CustomEvent<{ id: string; body: string }>) {
if ($httpdStore.state === "authenticated" && reply.body.trim().length > 0) {
if ($authenticated && reply.body.trim().length > 0) {
const status = await updatePatch(
project.id,
patch.id,
@@ -97,7 +193,7 @@
body: reply.body,
replyTo: reply.id,
},
$httpdStore.session,
$authenticated.session,
api,
);
if (status === "success") {
@@ -115,7 +211,7 @@
reaction: string;
}>,
) {
if ($httpdStore.state === "authenticated") {
if ($authenticated) {
const status = await updatePatch(
project.id,
patch.id,
@@ -124,9 +220,11 @@
revision: revisionId,
comment: id,
reaction,
active: nids.includes($httpdStore.session.publicKey) ? false : true,
active: nids.includes($authenticated.session.publicKey)
? false
: true,
},
$httpdStore.session,
$authenticated.session,
api,
);
if (status === "success") {
@@ -135,15 +233,16 @@
}
}
async function createComment(commentBody: string) {
if (
$httpdStore.state === "authenticated" &&
commentBody.trim().length > 0
) {
if ($authenticated && commentBody.trim().length > 0) {
const status = await updatePatch(
project.id,
patch.id,
{ type: "revision.comment", body: commentBody, revision: revisionId },
$httpdStore.session,
{
type: "revision.comment",
body: commentBody,
revision: revisionId,
},
$authenticated.session,
api,
);
if (status === "success") {
@@ -151,13 +250,37 @@
}
}
}
async function editComment({
detail: { id, body },
}: CustomEvent<{ id: string; body: string }>) {
if ($authenticated && body.trim().length > 0) {
const status = await updatePatch(
project.id,
patch.id,
{
type: "revision.comment.edit",
comment: id,
body,
revision: revisionId,
embeds: [],
},
$authenticated.session,
api,
);
if (status === "success") {
patch = await api.project.getPatchById(project.id, patch.id);
}
}
}
async function saveStatus({ detail: state }: CustomEvent<PatchState>) {
if ($httpdStore.state === "authenticated" && state.status !== "merged") {
if ($authenticated && state.status !== "merged") {
const status = await updatePatch(
project.id,
patch.id,
{ type: "lifecycle", state },
$httpdStore.session,
$authenticated.session,
api,
);
if (status === "success") {
@@ -170,22 +293,9 @@
}
}
}
function badgeColor(status: string): ComponentProps<Badge>["variant"] {
if (status === "draft") {
return "foreground";
} else if (status === "open") {
return "positive";
} else if (status === "archived") {
return "caution";
} else if (status === "merged") {
return "primary";
} else {
return "foreground";
}
}
async function saveLabels({ detail: labels }: CustomEvent<string[]>) {
if ($httpdStore.state === "authenticated") {
if ($authenticated) {
if (isEqual(patch.labels, labels)) {
return;
}
@@ -200,7 +310,7 @@
project.id,
revision,
{ type: "label", labels: labels },
$httpdStore.session,
$authenticated.session,
api,
);
if (status === "success") {
@@ -237,11 +347,19 @@
}
}
$: action = (
$httpdStore.state === "authenticated" && utils.isLocal(baseUrl.hostname)
? "edit"
: "view"
) as "edit" | "view";
function badgeColor(status: string): ComponentProps<Badge>["variant"] {
if (status === "draft") {
return "foreground";
} else if (status === "open") {
return "positive";
} else if (status === "archived") {
return "caution";
} else if (status === "merged") {
return "primary";
} else {
return "foreground";
}
}
type Tab = "activity" | "changes";
@@ -292,6 +410,7 @@
return patchReviews;
}
let editingDescription = false;
let revisionId: string;
$: if (view.name === "diff") {
revisionId = patch.revisions[patch.revisions.length - 1].id;
@@ -299,6 +418,8 @@
revisionId = view.revision;
}
$: description = patch.revisions[0].description;
$: newDescription = description;
$: patchReviews = computeReviews(patch);
$: selectedItem = patch.state.status === "open" ? items[1] : items[0];
$: timelineTuple = patch.revisions.map<
@@ -349,6 +470,9 @@
})),
].sort((a, b) => a.timestamp - b.timestamp),
]);
let saveDescriptionInProgress = false;
let saveTitleInProgress = false;
</script>
<style>
@@ -417,6 +541,16 @@
.review-reject {
color: var(--color-foreground-red);
}
.revision-description {
display: flex;
width: 100%;
}
.edit-buttons {
display: flex;
margin-left: auto;
margin-top: auto;
margin-bottom: auto;
}
.diff-button-range {
font-family: var(--font-family-monospace);
font-weight: var(--font-weight-bold);
@@ -444,7 +578,12 @@
<Layout {baseUrl} {project} {tracking} activeTab="patches">
<div class="patch">
<div>
<CobHeader id={patch.id} title={patch.title}>
<CobHeader
id={patch.id}
title={patch.title}
locallyAuthenticated={$authenticatedLocal(baseUrl.hostname)}
submitInProgress={saveTitleInProgress}
on:editTitle={editTitle}>
<svelte:fragment slot="icon">
<div
class="state"
@@ -461,17 +600,36 @@
</Badge>
</svelte:fragment>
<svelte:fragment slot="description">
{#if patch.revisions[0].description}
<Markdown
content={patch.revisions[0].description}
rawPath={utils.getRawBasePath(
project.id,
baseUrl,
patch.revisions[0].id,
)} />
{:else}
<span class="txt-missing">No description available</span>
{/if}
<div class="revision-description">
{#if $authenticatedLocal(baseUrl.hostname) && editingDescription}
<ExtendedTextarea
body={newDescription}
submitCaption="Save"
submitInProgress={saveDescriptionInProgress}
placeholder="Leave your description"
on:close={() => (editingDescription = false)}
on:submit={editDescription} />
{:else if description}
<Markdown
content={description}
rawPath={utils.getRawBasePath(
project.id,
baseUrl,
patch.revisions[0].id,
)} />
{:else}
<span class="txt-missing">No description available</span>
{/if}
{#if $authenticatedLocal(baseUrl.hostname) && !editingDescription}
<div class="edit-buttons">
<IconButton
title="edit description"
on:click={() => (editingDescription = true)}>
<IconSmall name={"edit"} />
</IconButton>
</div>
{/if}
</div>
</svelte:fragment>
<div class="author" slot="author">
opened by <NodeId
@@ -610,6 +768,7 @@
projectHead={project.head}
{...revision}
first={index === 0}
on:editComment={editComment}
on:react={event => handleReaction(revisionId, event)}
on:reply={createReply}
patchId={patch.id}
@@ -619,7 +778,8 @@
{#if index === patch.revisions.length - 1}
{#if $httpdStore.state === "authenticated" && view.name === "activity"}
<div class="connector" />
<CommentTextarea
<CommentToggleInput
placeholder="Leave your comment"
on:submit={async event => {
await createComment(event.detail.comment);
}} />
@@ -689,7 +849,10 @@
{/each}
</div>
</div>
<LabelInput {action} labels={patch.labels} on:save={saveLabels} />
<LabelInput
locallyAuthenticated={$authenticatedLocal(baseUrl.hostname)}
labels={patch.labels}
on:save={saveLabels} />
</div>
</div>
</Layout>
+1 -1
View File
@@ -115,7 +115,7 @@ test("navigate line numbers", async ({ page }) => {
// Check that we go back to the Markdown view when navigating to a different
// file.
await page.getByRole("link", { name: "footnotes.md" }).click();
await expect(page.getByRole("button", { name: "Plain" })).toHaveClass(
await expect(page.getByRole("button", { name: "Markdown" })).toHaveClass(
/secondary/,
);
});