Radish alpha
r
rad:z4D5UCArafTzTQpDZNQRuqswh3ury
Radicle desktop app
Radicle
Git
Implement label editing
Merged rudolfs opened 1 year ago
5 files changed +278 -86 ebc8b323 a2e21230
deleted src/components/IssueMetadata.svelte
@@ -1,70 +0,0 @@
-
<script lang="ts">
-
  import type { Issue } from "@bindings/cob/issue/Issue";
-

-
  import { authorForNodeId } from "@app/lib/utils";
-

-
  import Border from "@app/components/Border.svelte";
-
  import IssueStateBadge from "@app/components/IssueStateBadge.svelte";
-
  import NodeId from "@app/components/NodeId.svelte";
-

-
  interface Props {
-
    issue: Issue;
-
  }
-

-
  const { issue }: Props = $props();
-
</script>
-

-
<style>
-
  .divider {
-
    width: 2px;
-
    background-color: var(--color-fill-ghost);
-
    height: calc(100% + 4px);
-
    top: 0;
-
    position: relative;
-
  }
-
  .section {
-
    padding: 0.5rem;
-
    font-size: var(--font-size-small);
-
    display: flex;
-
    flex-direction: column;
-
    align-items: flex-start;
-
    height: 100%;
-
  }
-
  .section-title {
-
    margin-bottom: 0.5rem;
-
    color: var(--color-foreground-dim);
-
  }
-
</style>
-

-
<Border variant="ghost" styleGap="0">
-
  <div class="section" style:min-width="8rem">
-
    <div class="section-title">Status</div>
-
    <IssueStateBadge state={issue.state} />
-
  </div>
-

-
  <div class="divider"></div>
-

-
  <div class="section" style:flex="1">
-
    <div class="section-title">Labels</div>
-
    <div class="global-flex" style:flex-wrap="wrap">
-
      {#each issue.labels as label}
-
        <div class="global-counter txt-small">{label}</div>
-
      {:else}
-
        <span class="txt-missing">No labels.</span>
-
      {/each}
-
    </div>
-
  </div>
-

-
  <div class="divider"></div>
-

-
  <div class="section" style:flex="1">
-
    <div class="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>
-
  </div>
-
</Border>
modified src/components/IssueTeaser.svelte
@@ -12,6 +12,7 @@
  import Icon from "./Icon.svelte";
  import Id from "./Id.svelte";
  import InlineTitle from "./InlineTitle.svelte";
+
  import Label from "./Label.svelte";
  import NodeId from "./NodeId.svelte";

  interface Props {
@@ -91,7 +92,7 @@
  <div class="global-flex">
    {#if !compact}
      {#each issue.labels as label}
-
        <div class="global-counter txt-small">{label}</div>
+
        <Label {label} />
      {/each}
    {/if}

added src/components/Label.svelte
@@ -0,0 +1,11 @@
+
<script lang="ts">
+
  interface Props {
+
    label: string;
+
  }
+

+
  const { label }: Props = $props();
+
</script>
+

+
<div class="global-counter txt-small" style:max-width="10rem">
+
  <div class="txt-overflow" title={label}>{label}</div>
+
</div>
added src/components/LabelInput.svelte
@@ -0,0 +1,170 @@
+
<script lang="ts">
+
  import Icon from "@app/components/Icon.svelte";
+
  import Label from "./Label.svelte";
+
  import TextInput from "@app/components/TextInput.svelte";
+

+
  interface Props {
+
    allowedToEdit: boolean;
+
    labels: string[];
+
    submitInProgress: boolean;
+
    save: (updatedLabels: string[]) => void;
+
  }
+

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

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

+
  const sanitizedValue = $derived(inputValue.trim());
+

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

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

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

+
  $effect(() => {
+
    if (inputValue !== "") {
+
      if (sanitizedValue.length > 0) {
+
        if (updatedLabels.includes(sanitizedValue)) {
+
          valid = false;
+
          validationMessage = "This label is already assigned";
+
        } else {
+
          valid = true;
+
          validationMessage = undefined;
+
        }
+
      }
+
    } else {
+
      valid = true;
+
      validationMessage = "";
+
    }
+
  });
+

+
  function addLabel() {
+
    if (valid && sanitizedValue) {
+
      updatedLabels = [...updatedLabels, sanitizedValue].sort();
+
      inputValue = "";
+
      save($state.snapshot(updatedLabels));
+
      showInput = false;
+
    }
+
  }
+

+
  function removeLabel(label: string) {
+
    updatedLabels = updatedLabels.filter(x => x !== label);
+
    save($state.snapshot(updatedLabels));
+
    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;
+
    color: var(--color-foreground-default);
+
    gap: 0.5rem;
+
  }
+
</style>
+

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

+
    {#if allowedToEdit}
+
      <div class="global-flex" style:margin-left="auto">
+
        {#if showInput}
+
          <Icon onclick={addLabel} 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 updatedLabels as label}
+
        <button
+
          class="global-counter txt-small"
+
          style:max-width="10rem"
+
          onclick={() => (removeToggles[label] = !removeToggles[label])}>
+
          <div class="txt-overflow" title={label}>{label}</div>
+
          {#if removeToggles[label]}
+
            <Icon name="cross" onclick={() => removeLabel(label)} />
+
          {/if}
+
        </button>
+
      {:else}
+
        <div class="txt-missing">No labels.</div>
+
      {/each}
+
    {:else}
+
      {#each updatedLabels as label}
+
        <Label {label} />
+
      {:else}
+
        <div class="txt-missing">No labels.</div>
+
      {/each}
+
    {/if}
+
  </div>
+

+
  {#if showInput}
+
    <div style:margin-top="0.5rem">
+
      <TextInput
+
        autofocus
+
        {valid}
+
        disabled={submitInProgress}
+
        placeholder="Add label"
+
        bind:value={inputValue}
+
        onSubmit={addLabel} />
+
      {#if !valid && validationMessage}
+
        <div class="validation-message">
+
          <Icon name="warning" />{validationMessage}
+
        </div>
+
      {/if}
+
    </div>
+
  {/if}
+
</div>
modified src/views/repo/Issue.svelte
@@ -12,19 +12,25 @@

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

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

+
  import Border from "@app/components/Border.svelte";
  import CommentComponent from "@app/components/Comment.svelte";
  import CommentToggleInput from "@app/components/CommentToggleInput.svelte";
  import CopyableId from "@app/components/CopyableId.svelte";
  import Icon from "@app/components/Icon.svelte";
  import InlineTitle from "@app/components/InlineTitle.svelte";
-
  import IssueMetadata from "@app/components/IssueMetadata.svelte";
  import IssueSecondColumn from "@app/components/IssueSecondColumn.svelte";
+
  import IssueStateBadge from "@app/components/IssueStateBadge.svelte";
  import IssueStateButton from "@app/components/IssueStateButton.svelte";
  import IssueTimelineLifecycleAction from "@app/components/IssueTimelineLifecycleAction.svelte";
+
  import LabelInput from "@app/components/LabelInput.svelte";
  import Link from "@app/components/Link.svelte";
  import NodeId from "@app/components/NodeId.svelte";
  import TextInput from "@app/components/TextInput.svelte";
@@ -54,26 +60,48 @@
  /* eslint-enable prefer-const */

  const issues = $state(initialIssues);
+

  let topLevelReplyOpen = $state(false);
  let editingTitle = $state(false);
-
  let updatedTitle = $state(issue.title);
+
  let updatedTitle = $state("");
+
  let labelSaveInProgress: boolean = $state(false);

-
  // The view doesn't get destroyed when we switch between different issues in
-
  // the second column and because of that the top-level state gets retained
-
  // when the issue changes. This reactive statement makes sure we always load
-
  // the new issue and reset the state to defaults.
-
  let issueId = issue.id;
  $effect(() => {
-
    if (issueId !== issue.id) {
-
      issueId = issue.id;
-
      topLevelReplyOpen = false;
-
      editingTitle = false;
-
      updatedTitle = issue.title;
-
    }
+
    // The component doesn't get destroyed when we switch between different
+
    // issues in the second column and because of that the top-level state
+
    // gets retained when the issue changes. This reactive statement makes
+
    // sure we always reset the state to defaults.
+

+
    // eslint-disable-next-line @typescript-eslint/no-unused-expressions
+
    issue.id;
+

+
    topLevelReplyOpen = false;
+
    editingTitle = false;
+
    updatedTitle = issue.title;
  });

  const project = $derived(repo.payloads["xyz.radicle.project"]!);

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

  async function toggleReply() {
    topLevelReplyOpen = !topLevelReplyOpen;
    if (!topLevelReplyOpen) {
@@ -293,6 +321,26 @@
    margin-left: 1rem;
    align-items: center;
  }
+

+
  .metadata-divider {
+
    width: 2px;
+
    background-color: var(--color-fill-ghost);
+
    height: calc(100% + 4px);
+
    top: 0;
+
    position: relative;
+
  }
+
  .metadata-section {
+
    padding: 0.5rem;
+
    font-size: var(--font-size-small);
+
    display: flex;
+
    flex-direction: column;
+
    align-items: flex-start;
+
    height: 100%;
+
  }
+
  .metadata-section-title {
+
    margin-bottom: 0.5rem;
+
    color: var(--color-foreground-dim);
+
  }
</style>

<Layout>
@@ -386,7 +434,39 @@
      {/if}
    </div>

-
    <IssueMetadata {issue} />
+
    <Border variant="ghost" styleGap="0">
+
      <div class="metadata-section" style:min-width="8rem">
+
        <div class="metadata-section-title">Status</div>
+
        <IssueStateBadge state={issue.state} />
+
      </div>
+

+
      <div class="metadata-divider"></div>
+

+
      <div class="metadata-section" style:flex="1">
+
        <LabelInput
+
          allowedToEdit={!!roles.isDelegateOrAuthor(
+
            config.publicKey,
+
            repo.delegates.map(delegate => delegate.did),
+
            issue.body.author.did,
+
          )}
+
          labels={issue.labels}
+
          submitInProgress={labelSaveInProgress}
+
          save={saveLabels} />
+
      </div>
+

+
      <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>
+
      </div>
+
    </Border>

    <div class="issue-body">
      <CommentComponent