Radish alpha
r
Radicle web interface
Radicle
Git (anonymous pull)
Log in to clone via SSH
Add error handling for issue creation and commenting
Sebastian Martinez committed 2 years ago
commit 0cd42908e544ed27bb185006cbcb7d2e2504e60b
parent 3e797a2d0805645585897b570c68eb9c3c79f2ee
4 files changed +191 -28
added src/views/projects/Cob/ErrorModal.svelte
@@ -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>
modified src/views/projects/Issue.svelte
@@ -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";
    }
  }

modified tests/e2e/project/issues.spec.ts
@@ -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();
+
});
modified tests/support/fixtures.ts
@@ -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;