Radish alpha
r
rad:z4D5UCArafTzTQpDZNQRuqswh3ury
Radicle desktop app
Radicle
Git
Implement assignee editing
Merged rudolfs opened 1 year ago
4 files changed +240 -14 a2e21230 7d5ff0ac
added src/components/AssigneeInput.svelte
@@ -0,0 +1,183 @@
+
<script lang="ts">
+
  import type { Author } from "@bindings/cob/Author";
+

+
  import { authorForNodeId, parseNodeId } from "@app/lib/utils";
+

+
  import Icon from "@app/components/Icon.svelte";
+
  import NodeId from "@app/components/NodeId.svelte";
+
  import TextInput from "@app/components/TextInput.svelte";
+

+
  interface Props {
+
    allowedToEdit: boolean;
+
    assignees: Author[];
+
    submitInProgress: boolean;
+
    save: (updatedAssignees: string[]) => void;
+
  }
+

+
  const {
+
    allowedToEdit = false,
+
    assignees,
+
    submitInProgress = false,
+
    save,
+
  }: Props = $props();
+

+
  let updatedAssignees: Author[] = $state([]);
+
  let showInput: boolean = $state(false);
+
  let inputValue = $state("");
+
  let validationMessage: string | undefined = $state(undefined);
+
  let valid: boolean = $state(false);
+
  let assignee: string | undefined = undefined;
+

+
  let removeToggles: Record<string, boolean> = $state({});
+

+
  $effect(() => {
+
    // Reset component state whenever the assignees change in the parent. This
+
    // happens when the issue ID changes for example when the user navigates
+
    // to a different issue via the sidebar.
+
    updatedAssignees = assignees;
+

+
    showInput = false;
+
    validationMessage = undefined;
+
    valid = true;
+
    removeToggles = {};
+
  });
+

+
  $effect(() => {
+
    if (inputValue === "") {
+
      validationMessage = "";
+
      valid = true;
+
    } else {
+
      const parsedNodeId = parseNodeId(inputValue);
+
      if (parsedNodeId) {
+
        assignee = `${parsedNodeId.prefix}${parsedNodeId.pubkey}`;
+
        if (updatedAssignees.find(({ did }) => did === assignee)) {
+
          validationMessage = "This assignee is already added";
+
          valid = false;
+
        } else {
+
          validationMessage = undefined;
+
          valid = true;
+
        }
+
      } else {
+
        validationMessage = "This is not a valid DID";
+
        valid = false;
+
      }
+
    }
+
  });
+

+
  function addAssignee() {
+
    if (valid && assignee) {
+
      updatedAssignees = [...updatedAssignees, { did: assignee }];
+
      inputValue = "";
+
      save($state.snapshot(updatedAssignees.map(x => x.did)));
+
      showInput = false;
+
    }
+
  }
+

+
  function removeAssignee(assignee: Author) {
+
    updatedAssignees = updatedAssignees.filter(
+
      ({ did }) => did !== assignee.did,
+
    );
+
    save($state.snapshot(updatedAssignees.map(x => x.did)));
+
    showInput = false;
+
  }
+
</script>
+

+
<style>
+
  .header {
+
    font-size: var(--font-size-small);
+
    margin-bottom: 0.5rem;
+
    color: var(--color-foreground-dim);
+
  }
+
  .body {
+
    display: flex;
+
    align-items: center;
+
    flex-wrap: wrap;
+
    flex-direction: row;
+
    gap: 0.5rem;
+
    font-size: var(--font-size-small);
+
  }
+
  .validation-message {
+
    display: flex;
+
    align-items: center;
+
    gap: 0.25rem;
+
    color: var(--color-foreground-red);
+
    position: relative;
+
    margin-top: 0.5rem;
+
  }
+
  button {
+
    border: 0;
+
    cursor: pointer;
+
    gap: 0.5rem;
+
    background-color: transparent;
+
    border: none;
+
    display: flex;
+
    color: var(--color-foreground-default);
+
  }
+
</style>
+

+
<div style:width="100%">
+
  <div class="global-flex" style:align-items="flex-start">
+
    <div class="header">Assignees</div>
+

+
    {#if allowedToEdit}
+
      <div class="global-flex" style:margin-left="auto">
+
        {#if showInput}
+
          <Icon onclick={addAssignee} name="checkmark" styleCursor="pointer" />
+
          <Icon
+
            onclick={() => {
+
              inputValue = "";
+
              showInput = false;
+
            }}
+
            name="cross"
+
            styleCursor="pointer" />
+
        {:else}
+
          <Icon
+
            name="plus"
+
            onclick={() => (showInput = true)}
+
            styleCursor="pointer"></Icon>
+
        {/if}
+
      </div>
+
    {/if}
+
  </div>
+

+
  <div class="body">
+
    {#if allowedToEdit}
+
      {#each updatedAssignees as assignee}
+
        <button
+
          class="txt-small"
+
          onclick={() =>
+
            (removeToggles[assignee.did] = !removeToggles[assignee.did])}>
+
          <NodeId {...authorForNodeId(assignee)} />
+
          {#if removeToggles[assignee.did]}
+
            <Icon name="cross" onclick={() => removeAssignee(assignee)} />
+
          {/if}
+
        </button>
+
      {:else}
+
        <div class="txt-missing">Not assigned to anyone.</div>
+
      {/each}
+
    {:else}
+
      {#each updatedAssignees as assignee}
+
        <NodeId {...authorForNodeId(assignee)} />
+
      {:else}
+
        <div class="txt-missing">Not assigned to anyone.</div>
+
      {/each}
+
    {/if}
+
  </div>
+

+
  {#if showInput}
+
    <div style:margin-top="0.5rem">
+
      <TextInput
+
        autofocus
+
        {valid}
+
        disabled={submitInProgress}
+
        placeholder="Add assignee"
+
        bind:value={inputValue}
+
        onSubmit={addAssignee} />
+
      {#if !valid && validationMessage}
+
        <div class="validation-message">
+
          <Icon name="warning" />{validationMessage}
+
        </div>
+
      {/if}
+
    </div>
+
  {/if}
+
</div>
modified src/components/LabelInput.svelte
@@ -1,6 +1,6 @@
<script lang="ts">
  import Icon from "@app/components/Icon.svelte";
-
  import Label from "./Label.svelte";
+
  import Label from "@app/components/Label.svelte";
  import TextInput from "@app/components/TextInput.svelte";

  interface Props {
modified src/lib/utils.ts
@@ -179,3 +179,27 @@ export function isMac() {
export function modifierKey() {
  return isMac() ? "⌘" : "ctrl";
}
+

+
export function parseNodeId(
+
  nid: string,
+
): { prefix: string; pubkey: string } | undefined {
+
  const match = /^(did:key:)?(z[a-zA-Z0-9]+)$/.exec(nid);
+
  if (match) {
+
    let hex: Uint8Array | undefined = undefined;
+
    try {
+
      hex = bs58.decode(match[2].substring(1));
+
    } catch (error) {
+
      console.error("utils.parseNodId: Not able to decode received NID", error);
+
      return undefined;
+
    }
+
    // This checks also that the first 2 bytes are equal
+
    // to the ed25519 public key type used.
+
    if (hex && !(hex.byteLength === 34 && hex[0] === 0xed && hex[1] === 1)) {
+
      return undefined;
+
    }
+

+
    return { prefix: match[1] || "did:key:", pubkey: match[2] };
+
  }
+

+
  return undefined;
+
}
modified src/views/repo/Issue.svelte
@@ -12,11 +12,7 @@

  import * as roles from "@app/lib/roles";
  import { invoke } from "@app/lib/invoke";
-
  import {
-
    publicKeyFromDid,
-
    scrollIntoView,
-
    authorForNodeId,
-
  } from "@app/lib/utils";
+
  import { publicKeyFromDid, scrollIntoView } from "@app/lib/utils";

  import { announce } from "@app/components/AnnounceSwitch.svelte";

@@ -38,6 +34,7 @@

  import Layout from "./Layout.svelte";
  import Sidebar from "@app/components/Sidebar.svelte";
+
  import AssigneeInput from "@app/components/AssigneeInput.svelte";

  interface Props {
    repo: RepoInfo;
@@ -65,6 +62,7 @@
  let editingTitle = $state(false);
  let updatedTitle = $state("");
  let labelSaveInProgress: boolean = $state(false);
+
  let assigneesSaveInProgress: boolean = $state(false);

  $effect(() => {
    // The component doesn't get destroyed when we switch between different
@@ -102,6 +100,26 @@
    }
  }

+
  async function saveAssignees(assignees: string[]) {
+
    try {
+
      assigneesSaveInProgress = true;
+
      await invoke("edit_issue", {
+
        rid: repo.rid,
+
        cobId: issue.id,
+
        action: {
+
          type: "assign",
+
          assignees,
+
        },
+
        opts: { announce: $announce },
+
      });
+
    } catch (error) {
+
      console.error("Editing assignees failed", error);
+
    } finally {
+
      assigneesSaveInProgress = false;
+
      await reload();
+
    }
+
  }
+

  async function toggleReply() {
    topLevelReplyOpen = !topLevelReplyOpen;
    if (!topLevelReplyOpen) {
@@ -457,14 +475,15 @@
      <div class="metadata-divider"></div>

      <div class="metadata-section" style:flex="1">
-
        <div class="metadata-section-title">Assignees</div>
-
        <div class="global-flex" style:flex-wrap="wrap">
-
          {#each issue.assignees as assignee}
-
            <NodeId {...authorForNodeId(assignee)} />
-
          {:else}
-
            <span class="txt-missing">Not assigned to anyone.</span>
-
          {/each}
-
        </div>
+
        <AssigneeInput
+
          allowedToEdit={!!roles.isDelegateOrAuthor(
+
            config.publicKey,
+
            repo.delegates.map(delegate => delegate.did),
+
            issue.body.author.did,
+
          )}
+
          assignees={issue.assignees}
+
          submitInProgress={assigneesSaveInProgress}
+
          save={saveAssignees} />
      </div>
    </Border>