mirror of
https://seed.radicle.dev/rad:z4V1sjrXqjvFdnCUbxPFqd5p4DtH5.git
synced 2026-11-16 03:40:37 +08:00
Implement label editing
This commit is contained in:
@@ -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>
|
||||
@@ -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}
|
||||
|
||||
|
||||
@@ -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>
|
||||
@@ -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
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user