Skip to content

Commit 5d8773c

Browse files
committed
feat: live editing comments in suggestions in new UI
1 parent c271bdb commit 5d8773c

11 files changed

Lines changed: 345 additions & 6 deletions

File tree

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
.textarea {
2+
width: 100%;
3+
min-height: 6em;
4+
resize: vertical;
5+
transition: background-color 0.2s ease;
6+
}
7+
8+
.pending {
9+
background-color: var(--light-yellow);
10+
}
11+
12+
.saving {
13+
background-color: var(--light-yellow);
14+
}
15+
16+
.saved {
17+
background-color: var(--light-green);
18+
}
19+
20+
.error {
21+
background-color: var(--light-red);
22+
}
Lines changed: 64 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,70 @@
1+
import { useEffect, useRef, useState } from "preact/hooks";
2+
import { useCommentMutation } from "@/hooks/useComment";
3+
import styles from "./Comment.module.css";
4+
5+
type SaveState = "idle" | "pending" | "saving" | "saved" | "error";
6+
17
type Props = {
2-
comment: string;
8+
suggestionId: number;
9+
comment: string | null;
10+
canEdit: boolean;
311
};
412

5-
export function Comment({ comment }: Props) {
13+
const DEBOUNCE_SAVE_MS = 500;
14+
const DEBOUNCE_CLEAR_SAVED_FEEDBACK_MS = 2000;
15+
16+
export function Comment({ suggestionId, comment, canEdit }: Props) {
17+
const [value, setValue] = useState(comment ?? "");
18+
const [saveState, setSaveState] = useState<SaveState>("idle");
19+
const debounceRef = useRef<ReturnType<typeof setTimeout> | null>(null);
20+
const savedTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
21+
22+
const mutation = useCommentMutation(suggestionId);
23+
24+
// Sync if the prop changes externally (e.g. parent re-fetches)
25+
useEffect(() => {
26+
setValue(comment ?? "");
27+
}, [comment]);
28+
29+
function handleChange(e: Event) {
30+
const next = (e.target as HTMLTextAreaElement).value;
31+
setValue(next);
32+
setSaveState("pending");
33+
34+
if (debounceRef.current) clearTimeout(debounceRef.current);
35+
if (savedTimeoutRef.current) clearTimeout(savedTimeoutRef.current);
36+
37+
debounceRef.current = setTimeout(() => {
38+
setSaveState("saving");
39+
mutation.mutate(
40+
{ id: suggestionId, data: { comment: next } },
41+
{
42+
onSuccess: () => {
43+
setSaveState("saved");
44+
savedTimeoutRef.current = setTimeout(
45+
() => setSaveState("idle"),
46+
DEBOUNCE_CLEAR_SAVED_FEEDBACK_MS,
47+
);
48+
},
49+
onError: () => {
50+
setSaveState("error");
51+
},
52+
},
53+
);
54+
}, DEBOUNCE_SAVE_MS);
55+
}
56+
57+
const stateClass = styles[saveState];
58+
659
return (
7-
<textarea className="box rounded border monospace" disabled>
8-
{comment}
9-
</textarea>
60+
<textarea
61+
className={`box rounded border monospace ${styles.textarea} ${stateClass}`}
62+
value={value}
63+
onInput={handleChange}
64+
placeholder="Free comment: context, additional info, dismissal reason, etc."
65+
disabled={!canEdit}
66+
maxLength={1000}
67+
data-save-state={saveState}
68+
/>
1069
);
1170
}

frontend/src/components/suggestions/Suggestion.tsx

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import { Link } from "wouter-preact";
22
import type { Suggestion as SuggestionType } from "@/api/generated/models";
3+
import { useAuth } from "@/hooks/useAuth";
34
import { ActivityLog } from "./ActivityLog";
45
import { AffectedProductsList } from "./AffectedProductsList";
56
import { CategorizedMaintainersList } from "./CategorizedMaintainersList";
@@ -30,6 +31,9 @@ export function Suggestion({ suggestion }: Props) {
3031
categorized_url_references,
3132
} = suggestion;
3233

34+
const { user } = useAuth();
35+
const canEdit = Boolean(user?.is_committer || user?.is_admin);
36+
3337
const nvdUrl = `https://nvd.nist.gov/vuln/detail/${encodeURIComponent(cve_id)}`;
3438

3539
return (
@@ -88,7 +92,9 @@ export function Suggestion({ suggestion }: Props) {
8892
)}
8993

9094
{/* Comment */}
91-
{comment && comment.length > 0 && <Comment comment={comment} />}
95+
{(comment || canEdit) && (
96+
<Comment suggestionId={id} comment={comment ?? null} canEdit={canEdit} />
97+
)}
9298
</article>
9399
);
94100
}

frontend/src/hooks/useComment.ts

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
import { useQueryClient } from "@tanstack/react-query";
2+
import { getGetSuggestionQueryKey, useUpdateSuggestionComment } from "@/api/generated/endpoints";
3+
import type { Suggestion } from "@/api/generated/models";
4+
5+
export function useCommentMutation(suggestionId: number) {
6+
const queryClient = useQueryClient();
7+
8+
return useUpdateSuggestionComment({
9+
mutation: {
10+
onSuccess: (data) => {
11+
const queryKey = getGetSuggestionQueryKey(suggestionId);
12+
queryClient.setQueryData<Suggestion>(queryKey, (prev) => {
13+
if (!prev) return prev;
14+
return { ...prev, comment: data.comment ?? null };
15+
});
16+
},
17+
},
18+
});
19+
}

src/api/suggestions/serializers.py

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,18 @@
1313
from shared.models.linkage import CVEDerivationClusterProposal
1414

1515

16+
class SuggestionCommentSerializer(serializers.Serializer):
17+
"""Serializer for reading or updating a suggestion comment."""
18+
19+
comment = serializers.CharField(
20+
allow_null=True,
21+
allow_blank=True,
22+
required=True,
23+
max_length=1000,
24+
help_text="Free-text comment. Set to empty string to clear.",
25+
)
26+
27+
1628
class MetricHumanReadableItemSerializer(serializers.Serializer):
1729
label = serializers.CharField()
1830
value = serializers.CharField()

src/api/suggestions/views.py

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@
1111
from api.serializers import ErrorDetailSerializer
1212
from api.suggestions.serializers import (
1313
ActivityLogEntrySerializer,
14+
SuggestionCommentSerializer,
1415
SuggestionSerializer,
1516
folded_event_to_dict,
1617
)
@@ -134,3 +135,40 @@ def activity_log(self, request: Request, pk: int) -> Response:
134135
data = [folded_event_to_dict(e) for e in folded]
135136
serializer = ActivityLogEntrySerializer(data, many=True)
136137
return Response(serializer.data)
138+
139+
@extend_schema(
140+
methods=["get"],
141+
operation_id="getSuggestionComment",
142+
description="Get the current comment for a suggestion.",
143+
responses={200: SuggestionCommentSerializer, 404: ErrorDetailSerializer},
144+
)
145+
@extend_schema(
146+
methods=["patch"],
147+
operation_id="updateSuggestionComment",
148+
description="Update the comment for a suggestion. Send an empty string to clear it.",
149+
request=SuggestionCommentSerializer,
150+
responses={
151+
200: SuggestionCommentSerializer,
152+
400: ErrorDetailSerializer,
153+
403: ErrorDetailSerializer,
154+
404: ErrorDetailSerializer,
155+
},
156+
)
157+
@action(
158+
detail=True,
159+
methods=["get", "patch"],
160+
url_path="comment",
161+
serializer_class=SuggestionCommentSerializer,
162+
)
163+
def comment(self, request: Request, pk: int) -> Response:
164+
if request.method == "GET":
165+
instance = self.get_object()
166+
return Response(self.get_serializer(instance).data)
167+
elif request.method == "PATCH":
168+
serializer = self.get_serializer(data=request.data)
169+
serializer.is_valid(raise_exception=True)
170+
instance = self.get_object()
171+
instance.set_comment(serializer.validated_data["comment"])
172+
return Response(self.get_serializer(instance).data)
173+
else:
174+
raise MethodNotAllowed(request.method)
Lines changed: 100 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,100 @@
1+
from collections.abc import Callable
2+
3+
from django.contrib.auth.models import User
4+
from rest_framework.reverse import reverse
5+
from rest_framework.test import APIClient
6+
7+
from shared.models.linkage import CVEDerivationClusterProposal
8+
9+
10+
def url(id: int) -> str:
11+
return reverse("cvederivationclusterproposal-comment", kwargs={"pk": id})
12+
13+
14+
def test_get_comment_anonymous_no_comment(
15+
cached_suggestion: CVEDerivationClusterProposal,
16+
) -> None:
17+
client = APIClient()
18+
response = client.get(url(cached_suggestion.pk))
19+
assert response.status_code == 200
20+
assert response.data == {"comment": None}
21+
22+
23+
def test_get_comment_anonymous_with_comment(
24+
make_cached_suggestion: Callable[..., CVEDerivationClusterProposal],
25+
) -> None:
26+
suggestion = make_cached_suggestion(comment="foo")
27+
client = APIClient()
28+
response = client.get(url(suggestion.pk))
29+
assert response.status_code == 200
30+
assert response.data == {"comment": "foo"}
31+
32+
33+
def test_get_comment_not_found(cached_suggestion: CVEDerivationClusterProposal) -> None:
34+
client = APIClient()
35+
response = client.get(url(cached_suggestion.pk + 1))
36+
assert response.status_code == 404
37+
38+
39+
def test_patch_comment_unauthenticated(
40+
cached_suggestion: CVEDerivationClusterProposal,
41+
) -> None:
42+
client = APIClient()
43+
response = client.patch(
44+
url(cached_suggestion.pk), {"comment": "foo"}, format="json"
45+
)
46+
assert response.status_code == 401
47+
48+
49+
def test_patch_comment_non_comitter(
50+
cached_suggestion: CVEDerivationClusterProposal,
51+
user: User,
52+
) -> None:
53+
client = APIClient()
54+
client.force_login(user)
55+
response = client.patch(
56+
url(cached_suggestion.pk), {"comment": "foo"}, format="json"
57+
)
58+
assert response.status_code == 403
59+
60+
61+
def test_patch_comment_sets_value(
62+
cached_suggestion: CVEDerivationClusterProposal,
63+
committer: User,
64+
) -> None:
65+
client = APIClient()
66+
client.force_login(committer)
67+
response = client.patch(
68+
url(cached_suggestion.pk), {"comment": "foo"}, format="json"
69+
)
70+
assert response.status_code == 200
71+
assert response.data == {"comment": "foo"}
72+
response = client.get(url(cached_suggestion.pk))
73+
assert response.status_code == 200
74+
assert response.data == {"comment": "foo"}
75+
76+
77+
def test_patch_comment_clears_with_empty_string(
78+
make_cached_suggestion: Callable[..., CVEDerivationClusterProposal],
79+
committer: User,
80+
) -> None:
81+
suggestion = make_cached_suggestion(comment="foo")
82+
client = APIClient()
83+
client.force_login(committer)
84+
response = client.patch(url(suggestion.pk), {"comment": ""}, format="json")
85+
assert response.status_code == 200
86+
assert response.data == {"comment": None}
87+
response = client.get(url(suggestion.pk))
88+
assert response.status_code == 200
89+
assert response.data == {"comment": None}
90+
91+
92+
def test_patch_comment_not_found(
93+
cached_suggestion: CVEDerivationClusterProposal, committer: User
94+
) -> None:
95+
client = APIClient()
96+
client.force_login(committer)
97+
response = client.patch(
98+
url(cached_suggestion.pk + 1), {"comment": "foo"}, format="json"
99+
)
100+
assert response.status_code == 404

src/shared/models/linkage.py

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -182,6 +182,11 @@ def restore_package(self, package: str) -> None:
182182
overlay_type=PackageOverlay.Type.IGNORED,
183183
).delete()
184184

185+
def set_comment(self, comment: str | None) -> None:
186+
"""Update the free-text comment independently of status changes."""
187+
self.comment = comment or None
188+
self.save(update_fields=["comment"])
189+
185190
def change_status(
186191
self,
187192
status: SuggestionStatus,

src/shared/tests/conftest.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -324,6 +324,7 @@ def wrapped(
324324
drv: ProvenanceFlags.PACKAGE_NAME_MATCH
325325
},
326326
status: CVEDerivationClusterProposal.Status = CVEDerivationClusterProposal.Status.PENDING,
327+
comment: str | None = None,
327328
rejection_reason: CVEDerivationClusterProposal.RejectionReason | None = None,
328329
in_issue_draft: bool = False,
329330
age: timedelta = timedelta(0),
@@ -334,6 +335,7 @@ def wrapped(
334335
rejection_reason=rejection_reason,
335336
in_issue_draft=in_issue_draft,
336337
cve=container.cve,
338+
comment=comment,
337339
algorithm_version=algorithm_version
338340
if algorithm_version is not None
339341
else CVEDerivationClusterProposal.CURRENT_ALGORITHM_VERSION,

src/webview/tests/conftest.py

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -63,6 +63,15 @@ def as_staff(
6363
yield page
6464

6565

66+
@pytest.fixture
67+
def as_committer(
68+
logged_in_as: Callable[..., AbstractContextManager[Page]],
69+
committer: User,
70+
) -> Generator[Page]:
71+
with logged_in_as(committer) as page:
72+
yield page
73+
74+
6675
@pytest.fixture
6776
def logged_in_as(
6877
live_server: LiveServer,

0 commit comments

Comments
 (0)