Radish alpha
r
Radicle web interface
Radicle
Git (anonymous pull)
Log in to clone via SSH
Add patch comment and state modifiers
Sebastian Martinez committed 2 years ago
commit 542630a7f7aab4c3ffa9d7c8052dbf0874312db4
parent aec0289f101f594c160c5b12402a4d28d6d90b15
7 files changed +213 -43
modified httpd-client/index.ts
@@ -22,6 +22,7 @@ import type {
  Merge,
  Patch,
  PatchState,
+
  PatchUpdateAction,
  Range,
  Review,
  Revision,
@@ -53,6 +54,7 @@ export type {
  Merge,
  Patch,
  PatchState,
+
  PatchUpdateAction,
  Project,
  Range,
  Remote,
modified httpd-client/lib/project/patch.ts
@@ -188,7 +188,7 @@ export type PatchUpdateAction =
      type: "revision.comment";
      revision: string;
      body: string;
-
      replyTo: string;
+
      replyTo?: string;
    }
  | {
      type: "revision.comment.edit";
modified src/views/projects/Cob/CobStateButton.svelte
@@ -1,6 +1,4 @@
<script lang="ts" strictEvents>
-
  import type { IssueState } from "@httpd-client";
-

  import Button from "@app/components/Button.svelte";
  import Dropdown from "@app/components/Dropdown.svelte";
  import DropdownItem from "@app/components/Dropdown/DropdownItem.svelte";
@@ -11,15 +9,17 @@
  import { createEventDispatcher } from "svelte";
  import { isEqual } from "lodash";

-
  export let state: IssueState;
-
  export let selectedItem: [string, IssueState];
-
  export let items: [string, IssueState][];
+
  type CobState = $$Generic;
+

+
  export let state: CobState;
+
  export let selectedItem: [string, CobState];
+
  export let items: [string, CobState][];

  const dispatch = createEventDispatcher<{
-
    saveStatus: IssueState;
+
    saveStatus: CobState;
  }>();

-
  function switchCaption(item: [string, IssueState]) {
+
  function switchCaption(item: [string, CobState]) {
    selectedItem = item;
    closeFocused();
  }
modified src/views/projects/Cob/Revision.svelte
@@ -18,8 +18,8 @@
  import Floating from "@app/components/Floating.svelte";
  import Icon from "@app/components/Icon.svelte";
  import InlineMarkdown from "@app/components/InlineMarkdown.svelte";
-
  import Markdown from "@app/components/Markdown.svelte";
  import Link from "@app/components/Link.svelte";
+
  import Markdown from "@app/components/Markdown.svelte";
  import Thread from "@app/components/Thread.svelte";

  export let baseUrl: BaseUrl;
modified src/views/projects/Patch.svelte
@@ -1,5 +1,12 @@
<script lang="ts" context="module">
-
  import type { Comment, Review, Merge, Project } from "@httpd-client";
+
  import type {
+
    Comment,
+
    Review,
+
    Merge,
+
    Project,
+
    LifecycleState,
+
    PatchState,
+
  } from "@httpd-client";

  interface Thread {
    root: Comment;
@@ -28,32 +35,39 @@
</script>

<script lang="ts">
-
  import type { BaseUrl, Patch } from "@httpd-client";
+
  import type { BaseUrl, Patch, PatchUpdateAction } from "@httpd-client";
  import type { PatchView } from "./router";
  import type { Route } from "@app/lib/router";
+
  import type { Session } from "@app/lib/httpd";
  import type { Variant } from "@app/components/Badge.svelte";

+
  import * as modal from "@app/lib/modal";
+
  import * as router from "@app/lib/router";
  import * as utils from "@app/lib/utils";
-
  import { capitalize, isEqual } from "lodash";
  import { HttpdClient } from "@httpd-client";
+
  import { capitalize, isEqual } from "lodash";
  import { httpdStore } from "@app/lib/httpd";

  import Authorship from "@app/components/Authorship.svelte";
  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 CommitTeaser from "@app/views/projects/Commit/CommitTeaser.svelte";
  import Dropdown from "@app/components/Dropdown.svelte";
  import DropdownItem from "@app/components/Dropdown/DropdownItem.svelte";
+
  import ErrorModal from "@app/views/projects/Cob/ErrorModal.svelte";
  import Floating, { closeFocused } from "@app/components/Floating.svelte";
  import Icon from "@app/components/Icon.svelte";
  import LabelInput from "@app/views/projects/Cob/LabelInput.svelte";
-
  import Layout from "./Layout.svelte";
+
  import Layout from "@app/views/projects/Layout.svelte";
  import Link from "@app/components/Link.svelte";
  import Markdown from "@app/components/Markdown.svelte";
  import Placeholder from "@app/components/Placeholder.svelte";
  import RevisionComponent from "@app/views/projects/Cob/Revision.svelte";
  import SquareButton from "@app/components/SquareButton.svelte";
+
  import Textarea from "@app/components/Textarea.svelte";

  export let baseUrl: BaseUrl;
  export let patch: Patch;
@@ -62,11 +76,17 @@

  $: api = new HttpdClient(baseUrl);

+
  const items: [string, LifecycleState][] = [
+
    ["Reopen patch", { status: "open" }],
+
    ["Archive patch", { status: "archived" }],
+
    ["Convert to draft", { status: "draft" }],
+
  ];
+

  async function createReply({
    detail: reply,
  }: CustomEvent<{ id: string; body: string }>) {
    if ($httpdStore.state === "authenticated" && reply.body.trim().length > 0) {
-
      await api.project.updatePatch(
+
      const status = await updatePatch(
        project.id,
        patch.id,
        {
@@ -75,16 +95,26 @@
          body: reply.body,
          replyTo: reply.id,
        },
-
        $httpdStore.session.id,
+
        $httpdStore.session,
+
        api,
      );
-
      patch = await api.project.getPatchById(project.id, patch.id);
+
      if (status === "success") {
+
        patch = await api.project.getPatchById(project.id, patch.id);
+
      }
    }
  }
-
  async function handleReaction({
-
    detail: { nids, id, reaction },
-
  }: CustomEvent<{ nids: string[]; id: string; reaction: string }>) {
+
  async function handleReaction(
+
    revisionId: string,
+
    {
+
      detail: { nids, id, reaction },
+
    }: CustomEvent<{
+
      nids: string[];
+
      id: string;
+
      reaction: string;
+
    }>,
+
  ) {
    if ($httpdStore.state === "authenticated") {
-
      await api.project.updatePatch(
+
      const status = await updatePatch(
        project.id,
        patch.id,
        {
@@ -94,9 +124,48 @@
          reaction,
          active: nids.includes($httpdStore.session.publicKey) ? false : true,
        },
-
        $httpdStore.session.id,
+
        $httpdStore.session,
+
        api,
+
      );
+
      if (status === "success") {
+
        patch = await api.project.getPatchById(project.id, patch.id);
+
      }
+
    }
+
  }
+
  async function createComment() {
+
    if (
+
      $httpdStore.state === "authenticated" &&
+
      commentBody.trim().length > 0
+
    ) {
+
      const status = await updatePatch(
+
        project.id,
+
        patch.id,
+
        { type: "revision.comment", body: commentBody, revision: revisionId },
+
        $httpdStore.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") {
+
      const status = await updatePatch(
+
        project.id,
+
        patch.id,
+
        { type: "lifecycle", state },
+
        $httpdStore.session,
+
        api,
      );
-
      patch = await api.project.getPatchById(project.id, patch.id);
+
      if (status === "success") {
+
        void router.push({
+
          resource: "project.patch",
+
          project: project.id,
+
          node: baseUrl,
+
          patch: patch.id,
+
        });
+
      }
    }
  }
  function badgeColor(status: string): Variant {
@@ -125,20 +194,52 @@
      } else {
        revision = view.revision;
      }
-
      await api.project.updatePatch(
+
      const status = await updatePatch(
        project.id,
        revision,
        { type: "label", labels: labels },
-
        $httpdStore.session.id,
+
        $httpdStore.session,
+
        api,
      );
-
      patch = await api.project.getPatchById(project.id, patch.id);
+
      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",
+
            subtitle: [
+
              "There was an error while updating the patch.",
+
              "Check your radicle-httpd logs for details.",
+
            ],
+
            error,
+
          },
+
        });
+
      }
+
      return "error";
    }
  }

-
  const action: "create" | "edit" | "view" =
+
  $: action = (
    $httpdStore.state === "authenticated" && utils.isLocal(baseUrl.hostname)
      ? "edit"
-
      : "view";
+
      : "view"
+
  ) as "edit" | "view";

  let tabs: Record<string, Route>;
  $: {
@@ -182,6 +283,7 @@
    return patchReviews;
  }

+
  let commentBody = "";
  let revisionId: string;
  $: if (view.name === "diff") {
    revisionId = patch.revisions[patch.revisions.length - 1].id;
@@ -190,6 +292,7 @@
  }

  $: patchReviews = computeReviews(patch);
+
  $: selectedItem = patch.state.status === "open" ? items[1] : items[0];
  $: timelineTuple = patch.revisions.map<
    [
      {
@@ -268,6 +371,13 @@
    align-items: center;
    margin: 1rem 0;
  }
+
  .actions {
+
    display: flex;
+
    flex-direction: row;
+
    justify-content: flex-end;
+
    margin: 0 0 2.5rem 0;
+
    gap: 1rem;
+
  }
  .author {
    display: flex;
    align-items: center;
@@ -454,7 +564,7 @@
            projectHead={project.head}
            {...revision}
            first={index === 0}
-
            on:react={handleReaction}
+
            on:react={event => handleReaction(revisionId, event)}
            on:reply={createReply}
            patchId={patch.id}
            expanded={index === patch.revisions.length - 1}
@@ -483,6 +593,35 @@
      {:else}
        {utils.unreachable(view.name)}
      {/if}
+
      {#if $httpdStore.state === "authenticated" && view.name === "activity"}
+
        <div style:margin-top="1rem">
+
          <Textarea
+
            resizable
+
            on:submit={async () => {
+
              await createComment();
+
              commentBody = "";
+
            }}
+
            bind:value={commentBody}
+
            placeholder="Leave your comment" />
+
          <div class="actions txt-small">
+
            <CobStateButton
+
              items={items.filter(([, state]) => !isEqual(state, patch.state))}
+
              {selectedItem}
+
              state={patch.state}
+
              on:saveStatus={saveStatus} />
+
            <Button
+
              variant="secondary"
+
              size="small"
+
              disabled={!commentBody}
+
              on:click={async () => {
+
                await createComment();
+
                commentBody = "";
+
              }}>
+
              Comment
+
            </Button>
+
          </div>
+
        </div>
+
      {/if}
    </div>

    <div class="metadata">
modified tests/e2e/project/issues.spec.ts
@@ -161,7 +161,6 @@ test("go through the entire ui issue flow", async ({
  await page.getByPlaceholder("Add label").press("Enter");
  await page.getByPlaceholder("Add label").fill("documentation");
  await page.getByPlaceholder("Add label").press("Enter");
-

  await page.getByRole("button", { name: "Submit" }).click();

  await expect(page.getByText("This is a title")).toBeVisible();
modified tests/e2e/project/patches.spec.ts
@@ -38,17 +38,20 @@ test("navigate patch details", async ({ page }) => {
  }
});

-
test("adding and removing reactions", async ({ page, authenticatedPeer }) => {
+
test("go through the entire ui patch flow", async ({
+
  page,
+
  authenticatedPeer,
+
}) => {
  await page.goto(authenticatedPeer.uiUrl());
  const { rid, projectFolder } = await createProject(
    authenticatedPeer,
-
    "handle-reactions",
+
    "commenting",
  );
  await authenticatedPeer.git(["switch", "-c", "feature-1"], {
    cwd: projectFolder,
  });
  await authenticatedPeer.git(
-
    ["commit", "--allow-empty", "-m", "Reaction patch"],
+
    ["commit", "--allow-empty", "-m", "Some patch title"],
    {
      cwd: projectFolder,
    },
@@ -56,19 +59,36 @@ test("adding and removing reactions", async ({ page, authenticatedPeer }) => {
  await authenticatedPeer.git(["push", "rad", "HEAD:refs/patches"], {
    cwd: projectFolder,
  });
-
  await authenticatedPeer.rad(
-
    [
-
      "comment",
-
      "bfc3bc2c6af29920283f83e7ada9d52b2d4d3a57",
-
      "--message",
-
      "This is a comment for reactions",
-
    ],
-
    { cwd: projectFolder },
-
  );
  await page.goto(
-
    `${authenticatedPeer.uiUrl()}/${rid}/patches/bfc3bc2c6af29920283f83e7ada9d52b2d4d3a57`,
+
    `${authenticatedPeer.uiUrl()}/${rid}/patches/d41fbd28b06a5fac51a2ba9e05ad9dc885676d71`,
  );
-
  const commentReactionToggle = page.getByTitle("toggle-reaction");
+
  await expect(page.getByRole("button", { name: "1 patch" })).toBeVisible();
+
  await expect(page.getByText("open", { exact: true })).toBeVisible();
+

+
  await page.getByRole("button", { name: "edit" }).click();
+
  await page.getByPlaceholder("Add label").fill("bug");
+
  await page.getByPlaceholder("Add label").press("Enter");
+
  await page.getByPlaceholder("Add label").fill("documentation");
+
  await page.getByPlaceholder("Add label").press("Enter");
+
  await page.getByRole("button", { name: "save" }).click();
+

+
  await expect(
+
    page.getByLabel("chip").filter({ hasText: "documentation" }),
+
  ).toBeVisible();
+
  await expect(
+
    page.getByLabel("chip").filter({ hasText: "bug" }),
+
  ).toBeVisible();
+

+
  await page.getByPlaceholder("Leave your comment").fill("This is a comment");
+
  await page.getByRole("button", { name: "Comment" }).click();
+
  await expect(page.getByText("This is a comment")).toBeVisible();
+

+
  await page.getByTitle("toggle-reply").click();
+
  await page.getByPlaceholder("Leave your reply").fill("This is a reply");
+
  await page.getByRole("button", { name: "Reply", exact: true }).click();
+
  await expect(page.getByText("This is a reply")).toBeVisible();
+

+
  const commentReactionToggle = page.getByTitle("toggle-reaction").first();
  await commentReactionToggle.click();
  await page.getByRole("button", { name: "👍" }).click();
  await expect(page.locator("span").filter({ hasText: "👍 1" })).toBeVisible();
@@ -86,6 +106,16 @@ test("adding and removing reactions", async ({ page, authenticatedPeer }) => {
  await page.getByRole("button", { name: "🎉" }).click();
  await expect(page.locator("span").filter({ hasText: "🎉 1" })).toBeHidden();
  await expect(page.locator(".reaction")).toHaveCount(0);
+

+
  await page.getByRole("button", { name: "Archive patch" }).click();
+
  await expect(page.getByText("archived", { exact: true })).toBeVisible();
+
  await expect(page.getByRole("button", { name: "0 patches" })).toBeVisible();
+

+
  await page.getByLabel("stateToggle").click();
+
  await page.getByText("Convert to draft").click();
+
  await page.getByText("Convert to draft").click();
+
  await expect(page.getByText("draft", { exact: true })).toBeVisible();
+
  await expect(page.getByRole("button", { name: "0 patches" })).toBeVisible();
});

test("test patches counters", async ({ page, authenticatedPeer }) => {