Implement label editing

This commit is contained in:
Rūdolfs Ošiņš
2024-12-05 15:48:02 +01:00
parent ebc8b3236b
commit a2e212303d
5 changed files with 278 additions and 86 deletions
-70
View File
@@ -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>
+2 -1
View File
@@ -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}
+11
View File
@@ -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>
+170
View File
@@ -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>
+95 -15
View File
@@ -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