Add error handling for issue creation and commenting

Signed-off-by: Sebastian Martinez <me@sebastinez.dev>
This commit is contained in:
Sebastian Martinez
2023-07-04 14:14:29 +02:00
parent 3e797a2d08
commit 0cd42908e5
4 changed files with 191 additions and 28 deletions
+17
View File
@@ -0,0 +1,17 @@
<script lang="ts">
import ErrorMessage from "@app/components/ErrorMessage.svelte";
import Modal from "@app/components/Modal.svelte";
export let title: string;
export let subtitle: string[];
export let error: Error;
</script>
<Modal {title} emoji="🚨">
<div slot="subtitle">
{@html subtitle.join("<br />")}
</div>
<div slot="body">
<ErrorMessage message={error.message} stackTrace={error.stack} />
</div>
</Modal>
+106 -28
View File
@@ -1,19 +1,23 @@
<script lang="ts">
import type { BaseUrl, Issue, IssueState } from "@httpd-client";
import type { IssueUpdateAction } from "@httpd-client/lib/project/issue";
import type { Session } from "@app/lib/httpd";
import { isEqual } from "lodash";
import * as router from "@app/lib/router";
import * as modal from "@app/lib/modal";
import * as utils from "@app/lib/utils";
import { HttpdClient } from "@httpd-client";
import { httpdStore } from "@app/lib/httpd";
import AssigneeInput from "./Cob/AssigneeInput.svelte";
import AssigneeInput from "@app/views/projects/Cob/AssigneeInput.svelte";
import Authorship from "@app/components/Authorship.svelte";
import Badge from "@app/components/Badge.svelte";
import Button from "@app/components/Button.svelte";
import CobHeader from "@app/views/projects/Cob/CobHeader.svelte";
import CobStateButton from "@app/views/projects/Cob/CobStateButton.svelte";
import ErrorModal from "@app/views/projects/Cob/ErrorModal.svelte";
import Icon from "@app/components/Icon.svelte";
import Markdown from "@app/components/Markdown.svelte";
import TagInput from "./Cob/TagInput.svelte";
@@ -42,28 +46,34 @@
detail: reply,
}: CustomEvent<{ id: string; body: string }>) {
if ($httpdStore.state === "authenticated" && reply.body.trim().length > 0) {
await api.project.updateIssue(
const status = await updateIssue(
projectId,
issue.id,
{
type: "thread",
action: { type: "comment", body: reply.body, replyTo: reply.id },
},
$httpdStore.session.id,
$httpdStore.session,
api,
);
issue = await api.project.getIssueById(projectId, issue.id);
if (status === "success") {
issue = await refreshIssue(projectId, issue, api);
}
}
}
async function createComment(body: string) {
if ($httpdStore.state === "authenticated" && body.trim().length > 0) {
await api.project.updateIssue(
const status = await updateIssue(
projectId,
issue.id,
{ type: "thread", action: { type: "comment", body } },
$httpdStore.session.id,
$httpdStore.session,
api,
);
issue = await api.project.getIssueById(projectId, issue.id);
if (status === "success") {
issue = await refreshIssue(projectId, issue, api);
}
}
}
@@ -73,13 +83,17 @@
title.trim().length > 0 &&
title !== issue.title
) {
await api.project.updateIssue(
const status = await updateIssue(
projectId,
issue.id,
{ type: "edit", title },
$httpdStore.session.id,
$httpdStore.session,
api,
);
issue = await api.project.getIssueById(projectId, issue.id);
if (status === "success") {
issue = await refreshIssue(projectId, issue, api);
}
issue.title = issue.title;
} else {
// Reassigning issue.title overwrites the invalid title in IssueHeader
issue.title = issue.title;
@@ -92,7 +106,7 @@
if (add.length === 0 && remove.length === 0) {
return;
}
await api.project.updateIssue(
const status = await updateIssue(
projectId,
issue.id,
{
@@ -100,9 +114,12 @@
add,
remove,
},
$httpdStore.session.id,
$httpdStore.session,
api,
);
issue = await api.project.getIssueById(projectId, issue.id);
if (status === "success") {
issue = await refreshIssue(projectId, issue, api);
}
}
}
@@ -115,7 +132,7 @@
if (add.length === 0 && remove.length === 0) {
return;
}
await api.project.updateIssue(
const status = await updateIssue(
projectId,
issue.id,
{
@@ -123,31 +140,92 @@
add: utils.stripDidPrefix(add),
remove: utils.stripDidPrefix(remove),
},
$httpdStore.session.id,
$httpdStore.session,
api,
);
issue = await api.project.getIssueById(projectId, issue.id);
if (status === "success") {
issue = await refreshIssue(projectId, issue, api);
}
}
}
async function saveStatus({ detail: state }: CustomEvent<IssueState>) {
if ($httpdStore.state === "authenticated") {
await api.project.updateIssue(
const status = await updateIssue(
projectId,
issue.id,
{ type: "lifecycle", state },
$httpdStore.session.id,
$httpdStore.session,
api,
);
void router.push({
resource: "projects",
params: {
id: projectId,
baseUrl,
view: {
resource: "issue",
params: { issue: issue.id },
if (status === "success") {
void router.push({
resource: "projects",
params: {
id: projectId,
baseUrl,
view: {
resource: "issue",
params: { issue: issue.id },
},
},
},
});
});
}
}
}
// Refreshes the given issue by fetching it from the server.
// If the fetch fails, the given issue is returned.
export async function refreshIssue(
projectId: string,
issue: Issue,
api: HttpdClient,
) {
try {
return await api.project.getIssueById(projectId, issue.id);
} catch (error) {
if (error instanceof Error) {
modal.show({
component: ErrorModal,
props: {
title: "Unable to fetch issue",
subtitle: [
"There was an error while refreshing this issue.",
"Check your radicle-httpd logs for details.",
],
error,
},
});
}
return issue;
}
}
export async function updateIssue(
projectId: string,
issueId: string,
action: IssueUpdateAction,
session: Session,
api: HttpdClient,
): Promise<"success" | "error"> {
try {
await api.project.updateIssue(projectId, issueId, action, session.id);
return "success";
} catch (error) {
if (error instanceof Error) {
modal.show({
component: ErrorModal,
props: {
title: "Issue editing failed",
subtitle: [
"There was an error while updating the issue.",
"Check your radicle-httpd logs for details.",
],
error,
},
});
}
return "error";
}
}
+38
View File
@@ -1,4 +1,5 @@
import { test, cobUrl, expect } from "@tests/support/fixtures.js";
import { createProject } from "@tests/support/project";
test("navigate issue listing", async ({ page }) => {
await page.goto(cobUrl);
@@ -17,3 +18,40 @@ test("navigate single issue", async ({ page }) => {
`${cobUrl}/issues/4fc727e722d3979fd2073d9b56b2751658a4ae79`,
);
});
test("test issue editing failing", async ({ page, authenticatedPeer }) => {
const { rid, projectFolder } = await createProject(
authenticatedPeer,
"issue-editing",
);
await authenticatedPeer.rad(
[
"issue",
"open",
"--title",
"This issue is going to fail",
"--description",
"Let's see",
],
{ cwd: projectFolder },
);
await page.route(
`**/v1/projects/${rid}/issues/d316f7a90a40dacbfb8728044bad50c9f71d44ba`,
route => {
if (route.request().method() !== "PATCH") {
void route.fallback();
return;
}
void route.fulfill({ status: 500 });
},
);
await page.goto(
`/seeds/127.0.0.1:8070/${rid}/issues/d316f7a90a40dacbfb8728044bad50c9f71d44ba`,
);
await page.getByPlaceholder("Leave your comment").fill("This is a comment");
await page.getByRole("button", { name: "Comment" }).click();
await expect(page.getByText("Issue editing failed")).toBeVisible();
});
+30
View File
@@ -32,6 +32,7 @@ export const test = base.extend<{
customAppConfig: boolean;
stateDir: string;
peerManager: PeerManager;
authenticatedPeer: RadiclePeer;
outputLog: Stream.Writable;
}>({
customAppConfig: [false, { option: true }],
@@ -145,6 +146,35 @@ export const test = base.extend<{
await use(peerManager);
},
authenticatedPeer: async ({ page, peerManager }, use) => {
const peer = await peerManager.startPeer({
name: "httpd",
gitOptions: gitOptions["bob"],
});
await peer.startHttpd(8070);
await peer.startNode();
await page.goto("/");
await page.getByRole("button", { name: "radicle.local" }).click();
await page.locator('input[name="port"]').fill("8070");
await page.locator('input[name="port"]').press("Enter");
const { stdout } = await peer.rad([
"web",
"--frontend",
"http://localhost:3000",
"--backend",
"http://127.0.0.1:8070",
]);
const match = stdout.trim().match(/(http:\/\/localhost:3000\/.*)$/);
if (!match) {
throw Error("Not able to parse auth url");
}
await page.goto(match[0]);
await page.getByRole("button", { name: "Close" }).click();
await use(peer);
},
// eslint-disable-next-line no-empty-pattern
stateDir: async ({}, use, testInfo) => {
const stateDir = testInfo.outputDir;