Skip to content

Commit dd08243

Browse files
authored
feat(website): add copy buttons to triage columns (#5525)
## Overview Adds a floating copy-to-clipboard button to each result column on the `/triage` page, making it easier to copy conversion data during local testing. Fixes #5368 ## Details - Adds a copy icon to the bottom-right corner of each result box. - Keeps the copy button visible while the JSON content is scrolled. - Copies the complete formatted JSON for the selected source. - Disables copying while data is unavailable or loading. - Displays a check icon and a `Copied` label after a successful copy. - Restores the copy icon after 1.5 seconds. - Uses the existing `@github/clipboard-copy-element` component. ### Screenshots #### Copy buttons <img width="1878" height="911" alt="image" src="https://github.com/user-attachments/assets/9d660ed4-13de-4a5d-9d3f-c6d01c97c4f8" /> #### Copy feedback <img width="1876" height="921" alt="image" src="https://github.com/user-attachments/assets/57225b42-adc3-49cf-982d-578eae83a463" /> ## Testing - Ran `pnpm run build`. - Launched the website emulator with OAuth bypass enabled: ```shell BYPASS_OAUTH_FOR_LOCAL_DEV=true make run-website-emulator ``` - Loaded `CVE-2021-44228` from `api.osv.dev`. - Verified that each column copies its complete JSON content. - Verified that the button remains fixed after scrolling the result content. - Verified that clicking the button displays visible feedback and then resets.
1 parent 5e0153c commit dd08243

3 files changed

Lines changed: 107 additions & 4 deletions

File tree

gcp/website/frontend3/src/templates/triage.html

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -48,7 +48,18 @@ <h1>CVE Conversion Triager</h1>
4848
<div class="loading-spinner hidden">
4949
<md-circular-progress indeterminate></md-circular-progress>
5050
</div>
51-
<pre class="json-content">Select a source to view content</pre>
51+
<div class="json-scroll">
52+
<pre class="json-content">Select a source to view content</pre>
53+
</div>
54+
<textarea id="copy-json-{{ i }}" class="copy-json-source"
55+
aria-hidden="true" tabindex="-1"></textarea>
56+
<clipboard-copy class="triage-copy" for="copy-json-{{ i }}"
57+
aria-disabled="true"
58+
tabindex="-1"
59+
aria-label="Copy JSON from column {{ i }}"
60+
title="Copy JSON from column {{ i }}">
61+
<span class="material-icons" aria-hidden="true">content_copy</span>
62+
</clipboard-copy>
5263
</div>
5364
</div>
5465
{% endfor %}

gcp/website/frontend3/src/triage.js

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,13 +2,52 @@ import "./triage.scss";
22
import "@material/web/textfield/filled-text-field.js";
33
import "@material/web/button/filled-button.js";
44
import "@material/web/progress/circular-progress.js";
5+
import "@github/clipboard-copy-element";
56
import JSONFormatter from "json-formatter-js";
67

8+
const COPY_ICON_RESET_MS = 1500;
9+
710
document.addEventListener("DOMContentLoaded", () => {
811
const vulnIdInput = document.getElementById("vuln-id-input");
912
const loadBtn = document.getElementById("load-btn");
1013
const columns = document.querySelectorAll(".triage-column");
1114

15+
function showCopyFeedback(copyButton) {
16+
const icon = copyButton.querySelector(".material-icons");
17+
clearTimeout(copyButton._copyResetTimer);
18+
icon.textContent = "check";
19+
copyButton.setAttribute("aria-label", "Copied");
20+
copyButton.title = "Copied";
21+
copyButton._copyResetTimer = setTimeout(() => {
22+
icon.textContent = "content_copy";
23+
copyButton.setAttribute("aria-label", copyButton.dataset.copyLabel);
24+
copyButton.title = copyButton.dataset.copyLabel;
25+
}, COPY_ICON_RESET_MS);
26+
}
27+
28+
function setCopyContent(column, content) {
29+
const copyButton = column.querySelector(".triage-copy");
30+
const copySource = column.querySelector(".copy-json-source");
31+
const icon = copyButton.querySelector(".material-icons");
32+
const disabled = !content;
33+
34+
clearTimeout(copyButton._copyResetTimer);
35+
icon.textContent = "content_copy";
36+
copyButton.setAttribute("aria-label", copyButton.dataset.copyLabel);
37+
copyButton.title = copyButton.dataset.copyLabel;
38+
copySource.value = content;
39+
copyButton.setAttribute("aria-disabled", disabled.toString());
40+
copyButton.tabIndex = disabled ? -1 : 0;
41+
}
42+
43+
columns.forEach((column) => {
44+
const copyButton = column.querySelector(".triage-copy");
45+
copyButton.dataset.copyLabel = copyButton.getAttribute("aria-label");
46+
copyButton.addEventListener("clipboard-copy", () => {
47+
showCopyFeedback(copyButton);
48+
});
49+
});
50+
1251
// Map selection values to their respective endpoints/paths
1352
const sourceConfigMap = {
1453
// External APIs
@@ -91,25 +130,30 @@ document.addEventListener("DOMContentLoaded", () => {
91130

92131
if (!sourceKey) {
93132
contentPre.textContent = "Select a source to view content";
133+
setCopyContent(column, "");
94134
return;
95135
}
96136

97137
if (!vulnId) {
98138
contentPre.textContent = "Please enter a Vulnerability ID";
139+
setCopyContent(column, "");
99140
return;
100141
}
101142

102143
spinner.classList.remove("hidden");
103144
contentPre.textContent = "";
145+
setCopyContent(column, "");
104146

105147
fetchData(sourceKey, vulnId)
106148
.then((data) => {
107149
contentPre.textContent = "";
108150
const formatter = new JSONFormatter(data, Infinity, { theme: "dark" });
109151
contentPre.appendChild(formatter.render());
152+
setCopyContent(column, JSON.stringify(data, null, 2));
110153
})
111154
.catch((error) => {
112155
contentPre.textContent = error.message;
156+
setCopyContent(column, "");
113157
})
114158
.finally(() => {
115159
spinner.classList.add("hidden");

gcp/website/frontend3/src/triage.scss

Lines changed: 51 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -78,12 +78,19 @@
7878

7979
.content-display {
8080
flex-grow: 1;
81-
padding: 8px 12px;
82-
overflow: auto;
81+
min-height: 0;
82+
overflow: hidden;
8383
position: relative;
8484
background-color: #1e1e1e;
8585
}
8686

87+
.json-scroll {
88+
box-sizing: border-box;
89+
height: 100%;
90+
overflow: auto;
91+
padding: 8px 52px 52px 12px;
92+
}
93+
8794
.json-content {
8895
white-space: pre-wrap;
8996
word-wrap: break-word;
@@ -94,6 +101,47 @@
94101
color: #d4d4d4;
95102
}
96103

104+
.triage-copy {
105+
position: absolute;
106+
right: 12px;
107+
bottom: 12px;
108+
z-index: 1;
109+
display: flex;
110+
align-items: center;
111+
justify-content: center;
112+
width: 44px;
113+
height: 44px;
114+
padding: 0;
115+
border: 0;
116+
border-radius: 50%;
117+
background-color: #3c4043;
118+
color: #fff;
119+
box-shadow: 0 2px 6px rgb(0 0 0 / 40%);
120+
cursor: pointer;
121+
122+
&:hover:not([aria-disabled="true"]) {
123+
background-color: #5f6368;
124+
}
125+
126+
&:focus-visible {
127+
outline: 2px solid #fff;
128+
outline-offset: 2px;
129+
}
130+
131+
&[aria-disabled="true"] {
132+
color: #9aa0a6;
133+
cursor: default;
134+
}
135+
136+
.material-icons {
137+
font-size: 24px;
138+
}
139+
}
140+
141+
.copy-json-source {
142+
display: none;
143+
}
144+
97145
// Hide top-level root collapsible elements & brackets
98146
.json-content>.json-formatter-row {
99147
>a.json-formatter-toggler-link {
@@ -168,4 +216,4 @@
168216

169217
.hidden {
170218
display: none;
171-
}
219+
}

0 commit comments

Comments
 (0)