Refactor Patch actions and related components

This commit is contained in:
Sebastian Martinez
2023-11-15 09:32:12 +01:00
parent 5742f15e28
commit 664158e472
2 changed files with 315 additions and 224 deletions
+14 -8
View File
@@ -1,4 +1,5 @@
<script lang="ts">
import type { Embed } from "@httpd-client/lib/project/comment";
import type {
BaseUrl,
DiffResponse,
@@ -44,6 +45,15 @@
export let previousRevId: string | undefined = undefined;
export let previousRevOid: string | undefined = undefined;
export let first: boolean;
export let editComment:
| ((commentId: string, body: string, embeds: Embed[]) => Promise<void>)
| undefined;
export let handleReaction:
| ((commentId: string, nids: string[], reaction: string) => Promise<void>)
| undefined;
export let createReply:
| ((commentId: string, comment: string, embeds: Embed[]) => Promise<void>)
| undefined;
const api = new HttpdClient(baseUrl);
@@ -386,9 +396,9 @@
enableAttachments
rawPath={utils.getRawBasePath(projectId, baseUrl, projectHead)}
thread={element.inner}
on:editComment
on:react
on:reply />
{editComment}
{createReply}
{handleReaction} />
{:else if element.type === "merge"}
<div class="connector" />
<div class="action merge">
@@ -426,17 +436,13 @@
class:comment-review={review.verdict === null}
class:positive-review={review.verdict === "accept"}
class:negative-review={review.verdict === "reject"}>
<!-- TODO: Empty array for reactions prop is a workaround
until review comments have reactions -->
<CommentComponent
caption={formatVerdict(review.verdict)}
authorId={author}
authorAlias={review.author.alias}
reactions={[]}
timestamp={review.timestamp}
rawPath={utils.getRawBasePath(projectId, baseUrl, projectHead)}
body={review.summary ?? ""}
on:react>
body={review.summary ?? ""}>
<div slot="icon" style:color={verdictIconColor(review.verdict)}>
{#if review.verdict === "accept"}
<IconSmall name="checkmark" />
+301 -216
View File
@@ -35,23 +35,18 @@
</script>
<script lang="ts">
import type { BaseUrl, Patch, PatchUpdateAction } from "@httpd-client";
import type { BaseUrl, Patch } 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";
import type { ComponentProps } from "svelte";
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 { capitalize, isEqual } from "lodash";
import {
authenticated,
authenticatedLocal,
httpdStore,
} from "@app/lib/httpd";
import { capitalize, isEqual, partial } from "lodash";
import { httpdStore, type Session } from "@app/lib/httpd";
import Badge from "@app/components/Badge.svelte";
import Button from "@app/components/Button.svelte";
@@ -91,153 +86,165 @@
["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,
},
async function editTitle(sessionId: string, title: string) {
try {
await api.project.updatePatch(
project.id,
patch.id,
{
type: "edit",
title,
target: "delegates",
},
sessionId,
);
} 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;
},
});
}
} finally {
await refreshPatch();
}
}
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,
},
async function editDescription(sessionId: string, description: string) {
try {
await api.project.updatePatch(
project.id,
patch.id,
{
type: "revision.edit",
revision: patch.id,
description,
},
sessionId,
);
} 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;
},
});
}
} finally {
await refreshPatch();
}
}
async function createReply({
detail: reply,
}: CustomEvent<{ id: string; body: string; embeds: Embed[] }>) {
if ($authenticated && reply.body.trim().length > 0) {
const status = await updatePatch(
async function createReply(
sessionId: string,
revisionId: string,
replyTo: string,
body: string,
embeds: Embed[],
) {
try {
await api.project.updatePatch(
project.id,
patch.id,
{
type: "revision.comment",
revision: revisionId,
body: reply.body,
embeds: reply.embeds,
replyTo: reply.id,
body,
embeds,
replyTo,
},
$authenticated.session,
api,
sessionId,
);
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 comment reply creation failed",
subtitle: [
"There was an error while updating the patch.",
"Check your radicle-httpd logs for details.",
],
error: {
message: error.message,
stack: error.stack,
},
},
});
}
} finally {
await refreshPatch();
}
}
async function handleReaction(
session: Session,
revisionId: string,
{
detail: { nids, id, reaction },
}: CustomEvent<{
nids: string[];
id: string;
reaction: string;
}>,
commentId: string,
nids: string[],
reaction: string,
) {
if ($authenticated) {
const status = await updatePatch(
try {
await api.project.updatePatch(
project.id,
patch.id,
{
type: "revision.comment.react",
revision: revisionId,
comment: id,
comment: commentId,
reaction,
active: nids.includes($authenticated.session.publicKey)
? false
: true,
active: nids.includes(session.publicKey) ? false : true,
},
$authenticated.session,
api,
session.id,
);
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 comment reaction editing failed",
subtitle: [
"There was an error while updating the patch.",
"Check your radicle-httpd logs for details.",
],
error: {
message: error.message,
stack: error.stack,
},
},
});
}
} finally {
await refreshPatch();
}
}
async function createComment({
detail: { comment, embeds },
}: CustomEvent<{ comment: string; embeds: Embed[] }>) {
if ($authenticated && comment.trim().length > 0) {
const status = await updatePatch(
async function createComment(
sessionId: string,
revisionId: string,
comment: string,
embeds: Embed[],
) {
try {
await api.project.updatePatch(
project.id,
patch.id,
{
@@ -246,20 +253,39 @@
embeds,
revision: revisionId,
},
$authenticated.session,
api,
sessionId,
);
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 comment creation failed",
subtitle: [
"There was an error while updating the patch.",
"Check your radicle-httpd logs for details.",
],
error: {
message: error.message,
stack: error.stack,
},
},
});
}
} finally {
await refreshPatch();
}
}
async function editComment({
detail: { id, body, embeds },
}: CustomEvent<{ id: string; body: string; embeds: Embed[] }>) {
if ($authenticated && body.trim().length > 0) {
const status = await updatePatch(
async function editComment(
sessionId: string,
revisionId: string,
id: string,
body: string,
embeds: Embed[],
) {
try {
await api.project.updatePatch(
project.id,
patch.id,
{
@@ -269,85 +295,121 @@
revision: revisionId,
embeds,
},
$authenticated.session,
api,
sessionId,
);
if (status === "success") {
patch = await api.project.getPatchById(project.id, patch.id);
}
}
}
async function saveStatus({ detail: state }: CustomEvent<PatchState>) {
if ($authenticated && state.status !== "merged") {
const status = await updatePatch(
project.id,
patch.id,
{ type: "lifecycle", state },
$authenticated.session,
api,
);
if (status === "success") {
void router.push({
resource: "project.patch",
project: project.id,
node: baseUrl,
patch: patch.id,
});
}
}
}
async function saveLabels({ detail: labels }: CustomEvent<string[]>) {
if ($authenticated) {
if (isEqual(patch.labels, labels)) {
return;
}
let revision;
if (view.name === "diff") {
revision = patch.revisions[patch.revisions.length - 1].id;
} else {
revision = view.revision;
}
const status = await updatePatch(
project.id,
revision,
{ type: "label", labels: labels },
$authenticated.session,
api,
);
if (status === "success") {
patch = await api.project.getPatchById(project.id, patch.id);
}
}
}
export async function updatePatch(
projectId: string,
patchId: string,
action: PatchUpdateAction,
session: Session,
api: HttpdClient,
): Promise<"success" | "error"> {
try {
await api.project.updatePatch(projectId, patchId, action, session.id);
return "success";
} catch (error) {
if (error instanceof Error) {
modal.show({
component: ErrorModal,
props: {
title: "Patch editing failed",
title: "Patch comment editing failed",
subtitle: [
"There was an error while updating the patch.",
"Check your radicle-httpd logs for details.",
],
error,
error: {
message: error.message,
stack: error.stack,
},
},
});
}
return "error";
} finally {
await refreshPatch();
}
}
async function saveStatus(sessionId: string, state: PatchState) {
try {
if (state.status !== "merged") {
await api.project.updatePatch(
project.id,
patch.id,
{ type: "lifecycle", state },
sessionId,
);
}
} catch (error) {
if (error instanceof Error) {
modal.show({
component: ErrorModal,
props: {
title: "Patch status change failed",
subtitle: [
"There was an error while updating the patch.",
"Check your radicle-httpd logs for details.",
],
error: {
message: error.message,
stack: error.stack,
},
},
});
}
} finally {
void router.push({
resource: "project.patch",
project: project.id,
node: baseUrl,
patch: patch.id,
});
}
}
async function saveLabels(sessionId: string, labels: string[]) {
try {
await api.project.updatePatch(
project.id,
patch.id,
{ type: "label", labels },
sessionId,
);
} catch (error) {
if (error instanceof Error) {
modal.show({
component: ErrorModal,
props: {
title: "Patch label change failed",
subtitle: [
"There was an error while updating the patch.",
"Check your radicle-httpd logs for details.",
],
error: {
message: error.message,
stack: error.stack,
},
},
});
}
} finally {
await refreshPatch();
}
}
// Refreshes the given patch by fetching it from the server.
// If the fetch fails, the given patch is returned.
async function refreshPatch() {
try {
patch = await api.project.getPatchById(project.id, patch.id);
} catch (error) {
if (error instanceof Error) {
modal.show({
component: ErrorModal,
props: {
title: "Unable to fetch patch",
subtitle: [
"There was an error while refreshing this patch.",
"Check your radicle-httpd logs for details.",
],
error: {
message: error.message,
stack: error.stack,
},
},
});
}
} finally {
patch = patch;
}
}
@@ -415,6 +477,7 @@
}
let editingDescription = false;
let editingLabels = false;
let revisionId: string;
$: if (view.name === "diff") {
revisionId = patch.revisions[patch.revisions.length - 1].id;
@@ -474,9 +537,10 @@
})),
].sort((a, b) => a.timestamp - b.timestamp),
]);
let saveDescriptionInProgress = false;
let saveTitleInProgress = false;
$: session =
$httpdStore.state === "authenticated" && utils.isLocal(baseUrl.hostname)
? $httpdStore.session
: undefined;
</script>
<style>
@@ -585,9 +649,7 @@
<CobHeader
id={patch.id}
title={patch.title}
locallyAuthenticated={$authenticatedLocal(baseUrl.hostname)}
submitInProgress={saveTitleInProgress}
on:editTitle={editTitle}>
editTitle={session && partial(editTitle, session.id)}>
<svelte:fragment slot="icon">
<div
class="state"
@@ -605,14 +667,23 @@
</svelte:fragment>
<svelte:fragment slot="description">
<div class="revision-description">
{#if $authenticatedLocal(baseUrl.hostname) && editingDescription}
{#if session && editingDescription}
<ExtendedTextarea
body={newDescription}
submitCaption="Save"
submitInProgress={saveDescriptionInProgress}
submitInProgress={editingDescription}
placeholder="Leave your description"
on:close={() => (editingDescription = false)}
on:submit={editDescription} />
on:submit={async ({ detail: { comment } }) => {
editingDescription = true;
if (session) {
try {
await editDescription(session.id, comment);
} finally {
editingDescription = false;
}
}
}} />
{:else if description}
<Markdown
content={description}
@@ -624,7 +695,7 @@
{:else}
<span class="txt-missing">No description available</span>
{/if}
{#if $authenticatedLocal(baseUrl.hostname) && !editingDescription}
{#if session && !editingDescription}
<div class="edit-buttons">
<IconButton
title="edit description"
@@ -682,12 +753,12 @@
</Radio>
<div style:margin-left="auto">
{#if $httpdStore.state === "authenticated" && view.name === "activity"}
{#if session && view.name === "activity"}
<CobStateButton
items={items.filter(([, state]) => !isEqual(state, patch.state))}
{selectedItem}
state={patch.state}
on:saveStatus={saveStatus} />
save={session && partial(saveStatus, session.id)} />
{/if}
{#if view.name === "commits" || view.name === "changes"}
<div style="margin-left: auto;">
@@ -773,21 +844,25 @@
projectHead={project.head}
{...revision}
first={index === 0}
on:editComment={editComment}
on:react={event => handleReaction(revisionId, event)}
on:reply={createReply}
editComment={session &&
partial(editComment, session.id, revision.revisionId)}
handleReaction={session &&
partial(handleReaction, session, revision.revisionId)}
createReply={session &&
partial(createReply, session.id, revision.revisionId)}
patchId={patch.id}
patchState={patch.state}
expanded={index === patch.revisions.length - 1}
previousRevId={previousRevision?.id}
previousRevOid={previousRevision?.oid}>
{#if index === patch.revisions.length - 1}
{#if $httpdStore.state === "authenticated" && view.name === "activity"}
{#if session && view.name === "activity"}
<div class="connector" />
<CommentToggleInput
enableAttachments
placeholder="Leave your comment"
on:submit={createComment} />
submit={session &&
partial(createComment, session.id, revision.revisionId)} />
<div class="connector" />
<div style="display: flex;">
<CobStateButton
@@ -796,7 +871,7 @@
)}
{selectedItem}
state={patch.state}
on:saveStatus={saveStatus} />
save={session && partial(saveStatus, session.id)} />
</div>
{/if}
{/if}
@@ -855,9 +930,19 @@
</div>
</div>
<LabelInput
locallyAuthenticated={$authenticatedLocal(baseUrl.hostname)}
locallyAuthenticated={Boolean(session)}
submitInProgress={editingLabels}
labels={patch.labels}
on:save={saveLabels} />
on:save={async ({ detail: newLabels }) => {
if (session) {
editingLabels = true;
try {
await saveLabels(session.id, newLabels);
} finally {
editingLabels = false;
}
}
}} />
</div>
</div>
</Layout>