//
// Copyright (C) 2019-23 The Qt Company
//
// This plugin provides UI customization for codereview.qt-project.org
//
'use strict';
var BUTTONS = [
{ key: 'gerrit-plugin-qt-workflow~abandon', icon: "block" },
{ key: 'gerrit-plugin-qt-workflow~defer', icon: "watch_later" },
{ key: 'gerrit-plugin-qt-workflow~reopen', icon: "history" },
{ key: 'gerrit-plugin-qt-workflow~stage', icon: "done_all" },
{ key: 'gerrit-plugin-qt-workflow~unstage', icon: "undo" },
{ key: 'gerrit-plugin-qt-workflow~precheck', icon: "preview" }
];
Gerrit.install(plugin => {
plugin.buttons = null;
var CiStatusColorElement = null;
var CiStatusElement = null;
var CiStatusMessage = null;
var CiStatusMessageNew = null;
var CiStatusColor = null;
// Ensure comment dialog component is registered.
function createCommentDialog() {
var commentDialog = customElements.get('comment-dialog')
if (commentDialog) {
return
}
Polymer({
is: 'comment-dialog',
ready: function() {
this.innerHTML = `
`;
}
});
plugin.registerDynamicCustomComponent('comment-dialog', 'comment-dialog');
}
function onCommentActionBtn(actionUrl, actionTitle) {
plugin.popup('comment-dialog').then((v) => {
const dialog = v.popup.querySelector('#commentdialog')
const confirmBtn = dialog.querySelector('#confirmBtn')
const cancelBtn = dialog.querySelector('#cancelBtn')
const titleEl = dialog.querySelector('#dialogTitle')
const commentInput = dialog.querySelector('#CommentInput')
// Set dialog title
titleEl.textContent = actionTitle;
// Guard against multiple submissions via Ctrl+Enter or repeated clicks.
let submitting = false;
// The gerrit plugin popup api does not delete the dom elements
// a manual deleting is needed or the ids confuse the scripts.
const ironOverlayHandler = (event) => {
// Only handle events for THIS specific popup to prevent cross-dialog interference
if (event.target !== v.popup && !v.popup.contains(event.target)) {
return;
}
if (submitting) {
event.preventDefault();
return;
}
v.popup.remove();
document.removeEventListener('iron-overlay-canceled', ironOverlayHandler);
};
document.addEventListener('iron-overlay-canceled', ironOverlayHandler);
confirmBtn.addEventListener('click', function onConfirm() {
if (submitting) return;
submitting = true;
// Preserve original text/color so we can restore later.
const confirmOrigText = confirmBtn.textContent;
const confirmOrigColor = confirmBtn.style.color;
const cancelOrigColor = cancelBtn.style.color;
// Update UI to indicate submitting state.
confirmBtn.disabled = true;
cancelBtn.disabled = true;
confirmBtn.setAttribute('loading');
confirmBtn.textContent = 'Submitting...';
confirmBtn.style.color = 'var(--deemphasized-text-color)';
cancelBtn.style.color = 'var(--deemphasized-text-color)';
const message = commentInput.value.trim();
const payload = message ? { message: message } : {};
plugin.restApi().post(actionUrl, payload).then(() => {
// Clean up event listener before reload
document.removeEventListener('iron-overlay-canceled', ironOverlayHandler);
window.location.reload(true);
}).catch((failed_resp) => {
// Restore UI so user can retry.
submitting = false;
confirmBtn.removeAttribute('loading');
confirmBtn.disabled = false;
cancelBtn.disabled = false;
confirmBtn.textContent = confirmOrigText;
confirmBtn.style.color = confirmOrigColor;
cancelBtn.style.color = cancelOrigColor;
this.dispatchEvent(
new CustomEvent('show-alert', {
detail: {message: failed_resp},
composed: true,
bubbles: true,
})
);
});
});
cancelBtn.addEventListener('click', function onCancel() {
if (submitting) return;
v.close()
v.popup.remove();
document.removeEventListener('iron-overlay-canceled', ironOverlayHandler);
});
const formEl = dialog.querySelector('#commentForm') || dialog.querySelector('form');
formEl.addEventListener('submit', (e) => {
e.preventDefault();
if (!submitting) confirmBtn.click();
});
dialog.addEventListener('keydown', (e) => {
if (e.key === 'Enter' && (e.ctrlKey || e.metaKey)) {
e.preventDefault();
if (!submitting) confirmBtn.click();
}
});
// Focus the textarea
commentInput.focus();
});
}
// Ensure precheck dialog component is registered and global overflow listeners are set once.
function createPrecheck() {
// Avoids defining precheck module twice which would cause exception.
// This would happend during some UI actions e.g. opening edit mode.
var precheck = customElements.get('precheck-dialog')
if (precheck) {
return
}
Polymer({
is: 'precheck-dialog',
ready: function() {
this.innerHTML = `
`;
}
});
plugin.registerDynamicCustomComponent('precheck-dialog', 'precheck-dialog');
}
// Track the currently open precheck dialog
let activePrecheckPopup = null;
function onPrecheckBtn(c) {
// Clean up any existing precheck dialog before opening a new one
if (activePrecheckPopup) {
try {
if (activePrecheckPopup.remove) activePrecheckPopup.remove();
activePrecheckPopup = null;
} catch (e) {
// Ignore cleanup errors
}
}
plugin.popup('precheck-dialog').then((v) => {
activePrecheckPopup = v.popup;
const dialog = v.popup.querySelector('#precheckdialog')
const confirmBtn = dialog.querySelector('#confirmBtn')
const cancelBtn = dialog.querySelector('#cancelBtn')
// Guard against multiple submissions via Ctrl+Enter or repeated clicks.
let submitting = false;
dialog.querySelector('#typeSelect').addEventListener('change', (event) => {
const typeSelect = event.currentTarget.value;
const checkboxes = dialog.querySelector('#checkboxes');
if (typeSelect === 'custom') {
checkboxes.hidden = false;
} else {
checkboxes.hidden = true;
}
});
// The gerrit plugin popup api does not delete the dom elements
// a manual deleting is needed or the ids confuse the scripts.
const ironOverlayHandler = (event) => {
// Only handle events for THIS specific popup to prevent cross-dialog interference
if (event.target !== v.popup && !v.popup.contains(event.target)) {
return;
}
if (submitting) {
event.preventDefault();
return;
}
// Confirm cancellation to prevent accidental dismissal
const shouldCancel = confirm('Are you sure you want to discard this precheck configuration?');
if (!shouldCancel) {
event.preventDefault();
return;
}
v.popup.remove();
activePrecheckPopup = null;
document.removeEventListener('iron-overlay-canceled', ironOverlayHandler);
document.removeEventListener('keydown', escapeKeyHandler, true);
};
document.addEventListener('iron-overlay-canceled', ironOverlayHandler);
// Global Escape key handler to catch Escape presses even when dialog doesn't have focus
const escapeKeyHandler = (event) => {
if (event.key !== 'Escape') return;
// Check if our dialog popup is still in the DOM and visible
if (!activePrecheckPopup) {
return;
}
// Check if popup is in the document (might be in shadow DOM)
if (!activePrecheckPopup.isConnected) {
return;
}
const computedStyle = window.getComputedStyle(activePrecheckPopup);
if (computedStyle.display === 'none' || computedStyle.visibility === 'hidden') {
return;
}
event.preventDefault();
event.stopPropagation();
event.stopImmediatePropagation();
if (submitting) {
return;
}
// Confirm cancellation to prevent accidental dismissal
const shouldCancel = confirm('Are you sure you want to discard this precheck configuration?');
if (shouldCancel) {
if (v.close) v.close();
if (activePrecheckPopup.remove) activePrecheckPopup.remove();
activePrecheckPopup = null;
document.removeEventListener('iron-overlay-canceled', ironOverlayHandler);
document.removeEventListener('keydown', escapeKeyHandler, true);
}
};
document.addEventListener('keydown', escapeKeyHandler, true);
confirmBtn.addEventListener('click', function onOpen() {
if (submitting) return;
submitting = true;
// Preserve original text/color so we can restore later.
const confirmOrigText = confirmBtn.textContent;
const confirmOrigColor = confirmBtn.style.color;
const cancelOrigColor = cancelBtn.style.color;
// Update UI to indicate submitting state.
confirmBtn.disabled = true;
cancelBtn.disabled = true;
confirmBtn.setAttribute('loading');
confirmBtn.textContent = 'Submitting...';
confirmBtn.style.color = 'var(--deemphasized-text-color)';
cancelBtn.style.color = 'var(--deemphasized-text-color)';
const actions = plugin.__precheckActions || {};
const preAction = actions["gerrit-plugin-qt-workflow~precheck"];
const url = preAction && preAction.__url;
if (!url) {
// Restore UI so user can retry.
submitting = false;
confirmBtn.removeAttribute('loading');
confirmBtn.disabled = false;
cancelBtn.disabled = false;
confirmBtn.textContent = confirmOrigText;
confirmBtn.style.color = confirmOrigColor;
cancelBtn.style.color = cancelOrigColor;
this.dispatchEvent(
new CustomEvent('show-alert', {
detail: {message: 'Precheck action is not available.'},
composed: true,
bubbles: true,
})
);
return;
}
plugin.restApi().post(url, {
type: dialog.querySelector('#typeSelect').value,
onlybuild: dialog.querySelector('#BuildOnlyCheckBox').checked,
cherrypick: dialog.querySelector('#CherrypickCheckBox').checked,
platforms: dialog.querySelector('#PlatformsInput').value,
}).then(() => {
// Clean up event listeners before reload
activePrecheckPopup = null;
document.removeEventListener('iron-overlay-canceled', ironOverlayHandler);
document.removeEventListener('keydown', escapeKeyHandler, true);
window.location.reload(true);
}).catch((failed_resp) => {
// Restore UI so user can retry.
submitting = false;
confirmBtn.removeAttribute('loading');
confirmBtn.disabled = false;
cancelBtn.disabled = false;
confirmBtn.textContent = confirmOrigText;
confirmBtn.style.color = confirmOrigColor;
cancelBtn.style.color = cancelOrigColor;
this.dispatchEvent(
new CustomEvent('show-alert', {
detail: {message: failed_resp},
composed: true,
bubbles: true,
})
);
});
});
cancelBtn.addEventListener('click', function onOpen() {
if (submitting) return;
// Confirm cancellation to prevent accidental dismissal
const shouldCancel = confirm('Are you sure you want to discard this precheck configuration?');
if (!shouldCancel) return;
v.close()
v.popup.remove();
activePrecheckPopup = null;
document.removeEventListener('iron-overlay-canceled', ironOverlayHandler);
document.removeEventListener('keydown', escapeKeyHandler, true);
});
const formEl = dialog.querySelector('#precheckForm') || dialog.querySelector('form');
formEl.addEventListener('submit', (e) => {
e.preventDefault();
if (!submitting) confirmBtn.click();
});
dialog.addEventListener('keydown', (e) => {
if (e.key === 'Enter' && (e.ctrlKey || e.metaKey)) {
e.preventDefault();
if (!submitting) confirmBtn.click();
}
});
});
}
// Register dialog components now (idempotent)
createCommentDialog();
createPrecheck();
// Register global listeners once for overflow actions and fallback clicks.
if (!plugin.__precheckTapBound) {
plugin.__precheckTapBound = true;
const precheckTapHandler = (e) => {
e.preventDefault();
e.stopPropagation();
e.stopImmediatePropagation();
onPrecheckBtn();
};
document.addEventListener('gerrit-plugin-qt-workflow~precheck-revision-tap', precheckTapHandler, true);
document.addEventListener('gerrit-plugin-qt-workflow~precheck-change-tap', precheckTapHandler, true);
// Fallback for environments not emitting custom tap events.
document.addEventListener('click', (e) => {
const path = e.composedPath ? e.composedPath() : [e.target];
for (let i = 0; i < path.length; i++) {
const node = path[i];
if (!node || !node.classList || !node.classList.contains) continue;
if (node.classList.contains('itemAction')) {
const dataId = node.getAttribute && node.getAttribute('data-id');
if (dataId === 'gerrit-plugin-qt-workflow~precheck-revision') {
e.preventDefault();
e.stopPropagation();
e.stopImmediatePropagation();
onPrecheckBtn();
break;
}
}
}
}, true);
}
function htmlToElement(html) {
var template = document.createElement('template');
html = html.trim(); // No white space
template.innerHTML = html;
return template.content.firstChild;
}
// Customize header
plugin.hook('header-title', {replace: true} ).onAttached(element => {
const css_str = '';
const html_str = '';
var elem = htmlToElement(css_str);
element.appendChild(elem);
elem = htmlToElement(html_str);
element.appendChild(elem);
});
// Hide Sanity Bot review score row by default in reply dialog
plugin.hook('review-label-scores-sanity-review').onAttached(element => {
const html = '';
var wrapper_elem = document.createElement('div');
wrapper_elem.innerHTML = html;
// Place the sanity review label elements inside the new wrapper element.
// When upgrading to a new Gerrit release use, "console.log(element)" to debug structure of the elements
var sanity_elem_root = element.parentNode;
var child_elem;
while (sanity_elem_root.childNodes.length) {
child_elem = sanity_elem_root.removeChild(sanity_elem_root.childNodes[0]);
wrapper_elem.querySelector("#sanitybotreviewscorediv").appendChild(child_elem);
}
sanity_elem_root.appendChild(wrapper_elem);
// Install click listener to show the score buttons when clicking on more.
var more_button_handler = wrapper_elem.querySelector("#review-label-scores-sanity-review-more-button");
var more_button_div = wrapper_elem.querySelector("#sanitybotreviewmorediv");
var review_score_div = wrapper_elem.querySelector("#sanitybotreviewscorediv");
more_button_handler.addEventListener("click", function() {
more_button_div.style.display = "none";
review_score_div.style.display = "block";
});
});
// Show the Security Sensitive banner under the commit message when the hashtag is present
plugin.hook('commit-container').onAttached(element => {
const hashtag = element.change.hashtags
? element.change.hashtags.includes("Qt-Security change")
: false;
if (hashtag) {
const el = document.createElement('div');
el.textContent = "Change affects security critical files";
el.style = `color: var(--error-foreground);
background: var(--error-background);
padding: var(--spacing-s) var(--spacing-l);
border: 1px solid darkred;
border-radius: var(--border-radius);
font-size: large;
font-weight: bold;
text-align: center;`;
element.appendChild(el);
}
});
// Customize change view
plugin.on('show-revision-actions', function(revisionActions, changeInfo) {
var actions = Object.assign({}, revisionActions, changeInfo.actions);
plugin.__precheckActions = actions;
var cActions = plugin.changeActions();
// Hide 'Sanity-Review+1' button in header
let secondaryActions = cActions.el.__topLevelSecondaryActions;
if (secondaryActions && Array.isArray(secondaryActions)) {
secondaryActions.forEach((action) => {
if (action.__key === "review" && action.label === "Sanity-Review+1") {
cActions.hideQuickApproveAction();
}
});
}
// Remove any existing buttons
if (plugin.buttons) {
BUTTONS.forEach((button) => {
let key = button.key;
if (typeof plugin.buttons[key] !== 'undefined' && plugin.buttons[key] !== null) {
cActions.removeTapListener(plugin.buttons[key], (param) => {} );
cActions.remove(plugin.buttons[key]);
plugin.buttons[key] = null;
}
});
} else plugin.buttons = [];
// Add buttons based on server response
BUTTONS.forEach((button) => {
let key = button.key;
let action = actions[key];
if (action) {
// add button
plugin.buttons[key] = cActions.add(action.__type, action.label);
// hide dropdown action (only after button is created)
cActions.setActionHidden(action.__type, action.__key, true);
cActions.setIcon(plugin.buttons[key], button.icon);
cActions.setTitle(plugin.buttons[key], action.title);
cActions.setEnabled(plugin.buttons[key], action.enabled===true);
if (key === 'gerrit-plugin-qt-workflow~precheck') {
createPrecheck()
cActions.addTapListener(plugin.buttons[key], onPrecheckBtn);
} else {
cActions.addTapListener(plugin.buttons[key], buttonEventCallback);
}
if (key === 'gerrit-plugin-qt-workflow~stage') {
// hide submit button when it would be disabled next to the stage button
if (actions['submit'] && !actions['submit'].enabled) {
cActions.setActionHidden('revision', 'submit', true);
}
}
}
});
function buttonEventCallback(event) {
var button_key = event.type.substring(0, event.type.indexOf('-tap'));
var button_action = null;
var button_index;
for (var k in plugin.buttons) {
if (plugin.buttons[k] === button_key) {
button_action = actions[k];
button_index = k;
break;
}
}
if (button_action) {
// Check if this action should show a comment dialog
const actionsWithDialog = [
'gerrit-plugin-qt-workflow~defer',
'gerrit-plugin-qt-workflow~reopen',
'gerrit-plugin-qt-workflow~abandon',
'gerrit-plugin-qt-workflow~unstage'
];
if (actionsWithDialog.includes(button_index)) {
// Show comment dialog for these actions
createCommentDialog();
onCommentActionBtn(button_action.__url, button_action.title);
return;
}
// For other actions, proceed with immediate post (e.g., stage, unstage)
const buttonEl = this.shadowRoot.querySelector(`[data-action-key="${button_key}"]`);
// Preserve original text/color to restore later.
buttonEl.dataset._origText = buttonEl.textContent;
buttonEl.dataset._origColor = buttonEl.style.color || '';
buttonEl.setAttribute('loading', true);
buttonEl.disabled = true;
// Visually deemphasize while submitting and show status text.
buttonEl.textContent = 'Submitting...';
buttonEl.style.color = 'var(--deemphasized-text-color)';
plugin.restApi().post(button_action.__url, {})
.then((ok_resp) => {
buttonEl.removeAttribute('loading');
buttonEl.disabled = false;
// Restore original text/color (best-effort before reload).
buttonEl.textContent = buttonEl.dataset._origText;
buttonEl.style.color = buttonEl.dataset._origColor;
window.location.reload(true);
}).catch((failed_resp) => {
buttonEl.removeAttribute('loading');
buttonEl.disabled = false;
// Restore UI so user can retry.
buttonEl.textContent = buttonEl.dataset._origText;
buttonEl.style.color = buttonEl.dataset._origColor;
this.dispatchEvent(
new CustomEvent('show-alert', {
detail: {message: failed_resp},
composed: true,
bubbles: true,
})
);
});
} else console.log('unexpected error: no action');
}
});
function updateCiStatusUI() {
if (CiStatusColorElement && CiStatusColor) CiStatusColorElement.style.color = CiStatusColor;
if (CiStatusElement && CiStatusMessage !== CiStatusMessageNew ) {
const elem = document.createElement('div');
elem.innerHTML = CiStatusMessageNew.trim(); // No white space;
CiStatusElement.parentElement.appendChild(elem);
CiStatusElement.nextElementSibling.style.display = "none";
CiStatusMessage = CiStatusMessageNew;
}
}
plugin.restApi().get('/accounts/self/gerrit-plugin-qt-workflow~cistatus')
.then((ok_resp) => {
if (ok_resp.message) CiStatusMessageNew = ok_resp.message;
if (ok_resp.status_color) CiStatusColor = ok_resp.status_color;
updateCiStatusUI();
}).catch((failed_resp) => {
// use defaults
});
plugin.hook('main-header-ci-status').onAttached(element => {
var rootElem = element.parentElement.parentElement.shadowRoot;
CiStatusElement = rootElem.querySelector(".itemAction");
var button = rootElem.querySelector("gr-button");
CiStatusColorElement = button.shadowRoot.querySelector("gr-icon");
updateCiStatusUI();
});
});