Skip to content

Commit 888e3fd

Browse files
refactor(dashboard): unify copy button behavior
1 parent 0ff9cb3 commit 888e3fd

10 files changed

Lines changed: 308 additions & 136 deletions

File tree

internal/admin/dashboard/static/css/dashboard.css

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3137,14 +3137,14 @@ body.conversation-drawer-open {
31373137
color: var(--danger);
31383138
}
31393139

3140-
.auth-key-copy-btn {
3140+
.copy-feedback-btn {
31413141
display: inline-flex;
31423142
align-items: center;
31433143
gap: 6px;
31443144
transition: background-color 0.15s, border-color 0.15s, color 0.15s;
31453145
}
31463146

3147-
.auth-key-copy-btn-copied {
3147+
.copy-feedback-btn-copied {
31483148
background: color-mix(in srgb, var(--success) 12%, var(--bg));
31493149
border-color: color-mix(in srgb, var(--success) 40%, var(--border));
31503150
color: var(--success);

internal/admin/dashboard/static/js/modules/audit-list.js

Lines changed: 27 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,12 @@
11
(function(global) {
22
function dashboardAuditListModule() {
3+
const clipboardModuleFactory = typeof global.dashboardClipboardModule === 'function'
4+
? global.dashboardClipboardModule
5+
: null;
6+
const clipboard = clipboardModuleFactory
7+
? clipboardModuleFactory()
8+
: null;
9+
310
return {
411
_auditQueryStr() {
512
if (this.customStartDate && this.customEndDate) {
@@ -182,26 +189,27 @@
182189
};
183190
},
184191

185-
async copyAuditJSON(v, event) {
186-
if (v == null || v === undefined || v === '') return;
187-
const button = event && event.currentTarget instanceof HTMLElement ? event.currentTarget : null;
188-
if (button && !button.dataset.copyLabel) {
189-
button.dataset.copyLabel = String(button.textContent || 'Copy').trim();
190-
}
191-
try {
192-
const payload = this.formatJSON(v);
193-
await navigator.clipboard.writeText(payload);
194-
} catch (e) {
195-
console.error('Failed to copy audit payload:', e);
196-
if (button) {
197-
button.textContent = 'Copy failed';
198-
button.disabled = true;
199-
window.setTimeout(() => {
200-
button.textContent = button.dataset.copyLabel || 'Copy';
201-
button.disabled = false;
202-
}, 2000);
192+
auditPaneState(pane) {
193+
const formatJSON = this.formatJSON.bind(this);
194+
195+
return {
196+
pane,
197+
copyState: clipboard
198+
? clipboard.createClipboardButtonState({
199+
logPrefix: 'Failed to copy audit payload:'
200+
})
201+
: {
202+
copied: false,
203+
error: false,
204+
copy() {
205+
return Promise.resolve();
206+
}
207+
},
208+
209+
copyBody() {
210+
return this.copyState.copy(this.pane.copyBody, formatJSON);
203211
}
204-
}
212+
};
205213
}
206214
};
207215
}

internal/admin/dashboard/static/js/modules/audit-list.test.js

Lines changed: 78 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ const path = require('node:path');
55
const vm = require('node:vm');
66

77
function loadAuditListModuleFactory(overrides = {}) {
8+
const clipboardSource = fs.readFileSync(path.join(__dirname, 'clipboard.js'), 'utf8');
89
const source = fs.readFileSync(path.join(__dirname, 'audit-list.js'), 'utf8');
910
const window = {
1011
...(overrides.window || {})
@@ -15,6 +16,7 @@ function loadAuditListModuleFactory(overrides = {}) {
1516
window
1617
};
1718
vm.createContext(context);
19+
vm.runInContext(clipboardSource, context);
1820
vm.runInContext(source, context);
1921
return context.window.dashboardAuditListModule;
2022
}
@@ -122,3 +124,79 @@ test('fetchAuditLog preserves a successful payload when workflow prefetch fails'
122124
assert.equal(loggedErrors.length, 1);
123125
assert.match(String(loggedErrors[0][0]), /Failed to prefetch audit workflows:/);
124126
});
127+
128+
test('auditPaneState copies the formatted body and resets success feedback', async () => {
129+
let resetCallback = null;
130+
const writes = [];
131+
const module = createAuditListModule({
132+
setTimeout(callback) {
133+
resetCallback = callback;
134+
return 1;
135+
},
136+
clearTimeout() {},
137+
window: {
138+
navigator: {
139+
clipboard: {
140+
writeText(value) {
141+
writes.push(value);
142+
return Promise.resolve();
143+
}
144+
}
145+
}
146+
}
147+
});
148+
149+
const paneState = module.auditPaneState({
150+
copyBody: { model: 'gpt-5', stream: false }
151+
});
152+
153+
await paneState.copyBody();
154+
155+
assert.deepEqual(writes, ['{\n "model": "gpt-5",\n "stream": false\n}']);
156+
assert.equal(paneState.copyState.copied, true);
157+
assert.equal(paneState.copyState.error, false);
158+
159+
assert.equal(typeof resetCallback, 'function');
160+
resetCallback();
161+
162+
assert.equal(paneState.copyState.copied, false);
163+
assert.equal(paneState.copyState.error, false);
164+
});
165+
166+
test('auditPaneState marks copy failures and clears the error after reset', async () => {
167+
let resetCallback = null;
168+
const module = createAuditListModule({
169+
console: {
170+
error() {}
171+
},
172+
setTimeout(callback) {
173+
resetCallback = callback;
174+
return 1;
175+
},
176+
clearTimeout() {},
177+
window: {
178+
navigator: {
179+
clipboard: {
180+
writeText() {
181+
return Promise.reject(new Error('denied'));
182+
}
183+
}
184+
}
185+
}
186+
});
187+
188+
const paneState = module.auditPaneState({
189+
copyBody: { id: 'resp_123' }
190+
});
191+
192+
await paneState.copyBody();
193+
194+
assert.equal(paneState.copyState.copied, false);
195+
assert.equal(paneState.copyState.error, true);
196+
197+
assert.equal(typeof resetCallback, 'function');
198+
resetCallback();
199+
200+
assert.equal(paneState.copyState.copied, false);
201+
assert.equal(paneState.copyState.error, false);
202+
});

internal/admin/dashboard/static/js/modules/auth-keys.js

Lines changed: 24 additions & 86 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,12 @@
11
(function(global) {
22
function dashboardAuthKeysModule() {
3+
const clipboardModuleFactory = typeof global.dashboardClipboardModule === 'function'
4+
? global.dashboardClipboardModule
5+
: null;
6+
const clipboard = clipboardModuleFactory
7+
? clipboardModuleFactory()
8+
: null;
9+
310
return {
411
authKeys: [],
512
authKeysAvailable: true,
@@ -10,9 +17,18 @@
1017
authKeyFormSubmitting: false,
1118
authKeyIssuedValue: '',
1219
authKeyDeactivatingID: '',
13-
authKeyCopied: false,
14-
authKeyCopyError: false,
15-
authKeyCopyResetTimer: null,
20+
authKeyCopyState: clipboard
21+
? clipboard.createClipboardButtonState({
22+
logPrefix: 'Failed to copy auth key:'
23+
})
24+
: {
25+
copied: false,
26+
error: false,
27+
resetFeedback() {},
28+
copy() {
29+
return Promise.resolve();
30+
}
31+
},
1632
authKeyForm: {
1733
name: '',
1834
description: '',
@@ -106,7 +122,7 @@
106122
this.authKeyError = '';
107123
this.authKeyNotice = '';
108124
if (!this.authKeyIssuedValue) {
109-
this.resetAuthKeyCopyFeedback();
125+
this.authKeyCopyState.resetFeedback();
110126
this.authKeyForm = this.defaultAuthKeyForm();
111127
}
112128
},
@@ -117,97 +133,19 @@
117133
}
118134
this.authKeyFormOpen = false;
119135
this.authKeyError = '';
120-
this.resetAuthKeyCopyFeedback();
136+
this.authKeyCopyState.resetFeedback();
121137
if (!this.authKeyFormSubmitting && !this.authKeyIssuedValue) {
122138
this.authKeyForm = this.defaultAuthKeyForm();
123139
}
124140
},
125141

126-
clearAuthKeyCopyResetTimer() {
127-
if (this.authKeyCopyResetTimer !== null) {
128-
clearTimeout(this.authKeyCopyResetTimer);
129-
this.authKeyCopyResetTimer = null;
130-
}
131-
},
132-
133-
scheduleAuthKeyCopyFeedbackReset() {
134-
this.clearAuthKeyCopyResetTimer();
135-
this.authKeyCopyResetTimer = setTimeout(() => {
136-
this.authKeyCopied = false;
137-
this.authKeyCopyError = false;
138-
this.authKeyCopyResetTimer = null;
139-
}, 2000);
140-
},
141-
142-
resetAuthKeyCopyFeedback() {
143-
this.clearAuthKeyCopyResetTimer();
144-
this.authKeyCopied = false;
145-
this.authKeyCopyError = false;
146-
},
147-
148-
setAuthKeyCopyFeedback(copied, hasError) {
149-
this.authKeyCopied = copied;
150-
this.authKeyCopyError = hasError;
151-
this.scheduleAuthKeyCopyFeedbackReset();
152-
},
153-
154142
copyAuthKeyValue() {
155-
const value = String(this.authKeyIssuedValue || '');
156-
const clipboard = global.navigator && global.navigator.clipboard;
157-
158-
this.resetAuthKeyCopyFeedback();
159-
160-
if (clipboard && typeof clipboard.writeText === 'function') {
161-
return clipboard.writeText(value).then(() => {
162-
this.setAuthKeyCopyFeedback(true, false);
163-
}).catch((error) => {
164-
console.error('Failed to copy auth key:', error);
165-
this.setAuthKeyCopyFeedback(false, true);
166-
});
167-
}
168-
169-
const doc = global.document;
170-
if (!doc || !doc.body || typeof doc.createElement !== 'function' || typeof doc.execCommand !== 'function') {
171-
this.setAuthKeyCopyFeedback(false, true);
172-
return Promise.resolve();
173-
}
174-
175-
const textarea = doc.createElement('textarea');
176-
textarea.value = value;
177-
textarea.setAttribute('readonly', '');
178-
textarea.style.position = 'fixed';
179-
textarea.style.top = '0';
180-
textarea.style.left = '0';
181-
textarea.style.opacity = '0';
182-
183-
try {
184-
doc.body.appendChild(textarea);
185-
if (typeof textarea.focus === 'function') {
186-
textarea.focus();
187-
}
188-
if (typeof textarea.select === 'function') {
189-
textarea.select();
190-
}
191-
if (typeof textarea.setSelectionRange === 'function') {
192-
textarea.setSelectionRange(0, textarea.value.length);
193-
}
194-
const copied = !!doc.execCommand('copy');
195-
this.setAuthKeyCopyFeedback(copied, !copied);
196-
} catch (error) {
197-
console.error('Failed to copy auth key:', error);
198-
this.setAuthKeyCopyFeedback(false, true);
199-
} finally {
200-
if (textarea.parentNode) {
201-
textarea.parentNode.removeChild(textarea);
202-
}
203-
}
204-
205-
return Promise.resolve();
143+
return this.authKeyCopyState.copy(this.authKeyIssuedValue);
206144
},
207145

208146
dismissIssuedKey() {
209147
this.authKeyIssuedValue = '';
210-
this.resetAuthKeyCopyFeedback();
148+
this.authKeyCopyState.resetFeedback();
211149
this.authKeyForm = this.defaultAuthKeyForm();
212150
},
213151

@@ -273,7 +211,7 @@
273211
const issued = await res.json();
274212
this.authKeyIssuedValue = issued.value || '';
275213
this.authKeyFormOpen = true;
276-
this.resetAuthKeyCopyFeedback();
214+
this.authKeyCopyState.resetFeedback();
277215
this.authKeyForm = this.defaultAuthKeyForm();
278216
await this.fetchAuthKeys();
279217
} catch (e) {

internal/admin/dashboard/static/js/modules/auth-keys.test.js

Lines changed: 12 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ const path = require('node:path');
55
const vm = require('node:vm');
66

77
function loadAuthKeysModuleFactory(overrides = {}) {
8+
const clipboardSource = fs.readFileSync(path.join(__dirname, 'clipboard.js'), 'utf8');
89
const source = fs.readFileSync(path.join(__dirname, 'auth-keys.js'), 'utf8');
910
const window = {
1011
...(overrides.window || {})
@@ -17,6 +18,7 @@ function loadAuthKeysModuleFactory(overrides = {}) {
1718
window
1819
};
1920
vm.createContext(context);
21+
vm.runInContext(clipboardSource, context);
2022
vm.runInContext(source, context);
2123
return context.window.dashboardAuthKeysModule;
2224
}
@@ -162,13 +164,13 @@ test('copyAuthKeyValue uses navigator.clipboard when available and resets feedba
162164
await module.copyAuthKeyValue();
163165

164166
assert.deepEqual(writes, ['sk_gom_test']);
165-
assert.equal(module.authKeyCopied, true);
166-
assert.equal(module.authKeyCopyError, false);
167+
assert.equal(module.authKeyCopyState.copied, true);
168+
assert.equal(module.authKeyCopyState.error, false);
167169

168170
timers.runAll();
169171

170-
assert.equal(module.authKeyCopied, false);
171-
assert.equal(module.authKeyCopyError, false);
172+
assert.equal(module.authKeyCopyState.copied, false);
173+
assert.equal(module.authKeyCopyState.error, false);
172174
});
173175

174176
test('copyAuthKeyValue sets an error flag when navigator.clipboard rejects', async () => {
@@ -194,13 +196,13 @@ test('copyAuthKeyValue sets an error flag when navigator.clipboard rejects', asy
194196

195197
await module.copyAuthKeyValue();
196198

197-
assert.equal(module.authKeyCopied, false);
198-
assert.equal(module.authKeyCopyError, true);
199+
assert.equal(module.authKeyCopyState.copied, false);
200+
assert.equal(module.authKeyCopyState.error, true);
199201

200202
timers.runAll();
201203

202-
assert.equal(module.authKeyCopied, false);
203-
assert.equal(module.authKeyCopyError, false);
204+
assert.equal(module.authKeyCopyState.copied, false);
205+
assert.equal(module.authKeyCopyState.error, false);
204206
});
205207

206208
test('copyAuthKeyValue falls back to document.execCommand when clipboard API is unavailable', async () => {
@@ -250,8 +252,8 @@ test('copyAuthKeyValue falls back to document.execCommand when clipboard API is
250252
assert.equal(appended.length, 1);
251253
assert.equal(removed.length, 1);
252254
assert.equal(appended[0].value, 'sk_gom_test');
253-
assert.equal(module.authKeyCopied, true);
254-
assert.equal(module.authKeyCopyError, false);
255+
assert.equal(module.authKeyCopyState.copied, true);
256+
assert.equal(module.authKeyCopyState.error, false);
255257
});
256258

257259
test('fetchAuthKeys preserves existing rows and surfaces non-auth HTTP errors', async () => {

0 commit comments

Comments
 (0)