Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 5 additions & 1 deletion CHANGES.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
# Active Choices plug-in changelog

## Version 2.8.11 (????/??/??)
## Version 2.8.11 (2026/??/??)

- Bump @humanfs/node from 0.16.6 to 0.16.8
- Bump eslint from 10.9.1 to 10.11.0
Expand All @@ -16,6 +16,10 @@
- Bump webpack from 5.109.2 to 5.111.1
- Bump webpack-cli from 7.2.2 to 7.2.3
- Prevent infinite loop when a Cascade Reference Parameter references itself #1017 (thanks @B-Sp67)
- JENKINS-73556, GH-868: Disable the Build button while reactive parameters are updating,
and keep it disabled with a reload message if an update fails #868 (thanks @Tomer9000)
- JENKINS-75793, GH-902: Wait for a parameter to be rendered before updating the parameters
that reference it #902 (thanks @Tomer9000)

## Version 2.8.10 (2026/08/25)

Expand Down
6 changes: 5 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -68,6 +68,10 @@
],
"testMatch": [
"<rootDir>/src/test/js/*.test.ts"
]
],
"moduleFileExtensions": ["js", "mjs", "cjs", "jsx", "ts", "tsx", "json", "node", "es6"],
"transform": {
"\\.(es6|[jt]sx?)$": "babel-jest"
}
}
}
166 changes: 166 additions & 0 deletions src/main/js/LoadingGuard.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,166 @@
/**
* The MIT License (MIT)
*
* Copyright (c) 2014-2026 Ioannis Moutsatsos, Bruno P. Kinoshita
*
* Permission is hereby granted, free of charge, to any person obtaining a copy
* of this software and associated documentation files (the "Software"), to deal
* in the Software without restriction, including without limitation the rights
* to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
* copies of the Software, and to permit persons to whom the Software is
* furnished to do so, subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in
* all copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
* FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
* AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
* LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
* OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
* THE SOFTWARE.
*/

/**
* Keeps the submit buttons of a parameters form disabled while Active Choices
* parameters are updating (JENKINS-73556).
*
* See also:
*
* - https://github.com/jenkinsci/active-choices-plugin/issues/868
* - https://github.com/jenkinsci/active-choices-plugin/issues/902
* - https://github.com/jenkinsci/active-choices-plugin/pull/1039
*
* @since 2.8.11
*/
export default class LoadingGuard {

doc: Document;
pending: number = 0;
failed: boolean = false;
busySince: number = 0;
ticker: number | null = null;
forms: HTMLFormElement[] = [];
statuses: HTMLElement[] = [];
// Only buttons this guard disabled are re-enabled, so other scripts' disabled state is kept.
disabledByGuard: HTMLButtonElement[] = [];

constructor(doc: Document) {
this.doc = doc;
// Blocks implicit submission (Enter in a text field) and programmatic requestSubmit() while busy.
doc.addEventListener('submit', (e: Event) => {
if (this.isBusy() && this.forms.indexOf(e.target as HTMLFormElement) >= 0) {
e.preventDefault();
e.stopImmediatePropagation();
}
}, true);
}

/**
* Marks an update as started; the returned function marks it finished and is safe to call more than once.
*
* @param element {Element?}: The Active Choices HTML element.
*/
public begin(element?: Element): () => void {
this.watch(element);
if (this.pending === 0) {
this.busySince = Date.now();
}
this.pending++;
this.render();
let finished = false;
return () => {
if (finished) {
return;
}
finished = true;
this.pending--;
this.render();
};
}

/**
* A failed update leaves a stale value behind, so the form stays blocked until the page is reloaded.
*/
public fail(): void {
this.failed = true;
this.render();
}

public isBusy(): boolean {
return this.failed || this.pending > 0;
}

private watch(element?: Element): void {
let form = element ? element.closest('form') : null;
if (!form || this.forms.indexOf(form) >= 0) {
return;
}
this.forms.push(form);
let buttons = this.submitButtons(form);
let bar = buttons.length > 0 ? buttons[buttons.length - 1].parentElement : null;
if (bar) {
let status = this.doc.createElement('span');
status.className = 'ac-loading-status';
status.setAttribute('aria-live', 'polite');
bar.appendChild(status);
this.statuses.push(status);
}
}

/**
* Submit controls of the form itself; anything inside a parameter (div[name=parameter]) is left alone.
*
* @param form {HTMLFormElement}: The HTML submit form element.
* @return {HTMLButtonElement[]}: List of buttons that are of type submit and have the name "parameter".
*/
private submitButtons(form: HTMLFormElement): HTMLButtonElement[] {
let buttons: HTMLButtonElement[] = [];
let candidates = form.querySelectorAll('button, input[type="submit"]');
for (let i = 0; i < candidates.length; i++) {
let button = candidates[i] as HTMLButtonElement;
if (button.type === 'submit' && !button.closest('[name="parameter"]')) {
buttons.push(button);
}
}
return buttons;
}

private render(): void {
let busy = this.isBusy();
if (busy) {
for (let i = 0; i < this.forms.length; i++) {
let buttons = this.submitButtons(this.forms[i]);
for (let j = 0; j < buttons.length; j++) {
if (!buttons[j].disabled) {
buttons[j].disabled = true;
this.disabledByGuard.push(buttons[j]);
}
}
}
} else {
for (let i = 0; i < this.disabledByGuard.length; i++) {
this.disabledByGuard[i].disabled = false;
}
this.disabledByGuard = [];
}
let text = '';
if (this.failed) {
text = 'A parameter failed to load. Reload the page before building.';
} else if (busy) {
text = `Calculating parameters… ${Math.round((Date.now() - this.busySince) / 1000)}s`;
}
for (let i = 0; i < this.statuses.length; i++) {
this.statuses[i].textContent = text;
this.statuses[i].classList.toggle('ac-loading-status--error', this.failed);
}
// Ticks the elapsed seconds while busy; stops once settled or failed.
if (busy && !this.failed && this.ticker === null && this.statuses.length > 0) {
this.ticker = window.setInterval(() => this.render(), 1000);
} else if ((!busy || this.failed) && this.ticker !== null) {
window.clearInterval(this.ticker);
this.ticker = null;
}
}
}
75 changes: 56 additions & 19 deletions src/main/js/UnoChoice.es6
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@
* THE SOFTWARE.
*/
import Util from './Util.ts';
import LoadingGuard from './LoadingGuard.ts';

/*
* At the time of writing, requestIdleCallback is still not available in Safari,
Expand Down Expand Up @@ -82,6 +83,8 @@ jQuery3.noConflict();
*/
var UnoChoice = UnoChoice || (jQuery3 => {
let util = new Util(jQuery3);
// Keeps Build disabled while any parameter is updating (JENKINS-73556).
let loadingGuard = new LoadingGuard(document);
// The final public object
let instance = {};
let SEPARATOR = '__LESEP__';
Expand All @@ -102,6 +105,7 @@ var UnoChoice = UnoChoice || (jQuery3 => {
this.proxy = proxy;
this.referencedParameters = [];
this.filterElement = null;
trackProxy(proxy);
}
/**
* Gets the parameter name.
Expand Down Expand Up @@ -370,9 +374,7 @@ var UnoChoice = UnoChoice || (jQuery3 => {
let other = cascadeParameters[i];
if (this.referencesMe(other)) {
console.log(`[${this.paramName}] - CascadeParameter#update - Updating ${other.getParameterName()} from ${this.getParameterName()}`);
window.requestIdleCallback(async () => {
await other.update(other.getParameterName() === this.getParameterName());
})
scheduleUpdate(other, other.getParameterName() === this.getParameterName());
}
}
}
Expand Down Expand Up @@ -435,10 +437,9 @@ var UnoChoice = UnoChoice || (jQuery3 => {
//_self.cascadeParameter.loading(true);
jQuery3(".behavior-loading").show();
// start updating in separate async function so browser will be able to repaint and show 'loading' animation , see JENKINS-34487
window.requestIdleCallback(async () => {
await _self.cascadeParameter.update(false);
scheduleUpdate(_self.cascadeParameter, false).then(() => {
jQuery3(".behavior-loading").hide();
})
});
}
});
cascadeParameter.getReferencedParameters().push(this);
Expand All @@ -465,6 +466,7 @@ var UnoChoice = UnoChoice || (jQuery3 => {
this.paramElement = paramElement;
this.proxy = proxy;
this.referencedParameters = [];
trackProxy(proxy);
}
/**
* Extend the cascade parameter.
Expand Down Expand Up @@ -556,9 +558,7 @@ var UnoChoice = UnoChoice || (jQuery3 => {
let other = cascadeParameters[i];
if (this.referencesMe(other)) {
console.log(`[${this.paramName}] - DynamicReferenceParameter#update - Updating ${other.paramName} from ${this.paramName}`);
window.requestIdleCallback(async () => {
await other.update(true);
})
scheduleUpdate(other, true);
}
}
}
Expand Down Expand Up @@ -847,6 +847,31 @@ var UnoChoice = UnoChoice || (jQuery3 => {
return value;
}

// Updates the parameter once the browser is idle; Build stays disabled from now until the returned promise settles.
function scheduleUpdate(parameter, avoidRecursion) {
let done = loadingGuard.begin(parameter.getParameterElement());
return new Promise(resolve => {
window.requestIdleCallback(async () => {
try {
await parameter.update(avoidRecursion);
} catch (e) {
loadingGuard.fail();
console.error(`[${parameter.getParameterName()}] - scheduleUpdate - Update failed`, e);
} finally {
done();
resolve();
}
});
});
}

// Calls made through this proxy keep Build disabled until their response has been rendered.
function trackProxy(proxy) {
if (proxy) {
proxy.loadingGuard = loadingGuard;
}
}

// Hacks in Jenkins core
/**
* <p>This function is the same as makeStaplerProxy available in Jenkins core, but executes calls
Expand Down Expand Up @@ -901,6 +926,14 @@ var UnoChoice = UnoChoice || (jQuery3 => {
// what is rendered when, and properly chain certain actions (e.g.
// a dependant parameter is only rendered after its parent/referenced
// parameter).
// JENKINS-73556: the call counts as pending until its callback has rendered the response.
let guard = proxy.loadingGuard;
let done = guard ? guard.begin() : () => {};
let failed = error => {
if (guard) guard.fail();
done();
console.error(`makeStaplerProxy2 - ${methodName} failed`, error);
};
await fetch(url + methodName, {
method: 'POST',
headers: headers,
Expand All @@ -912,25 +945,32 @@ var UnoChoice = UnoChoice || (jQuery3 => {
status: response.status,
statusText: response.statusText,
};
// Returned, so that awaiting this call also waits for its callback (JENKINS-75793).
if (response.headers.has('content-type') && response.headers.get('content-type').startsWith('application/json')) {
response.json().then(function (responseObject) {
return response.json().then(function (responseObject) {
t.responseObject = function () {
return responseObject;
};
t.responseJSON = responseObject;
if (callback != null) {
callback(t);
}
});
}).then(done, failed);
} else {
response.text().then(function (responseText) {
return response.text().then(function (responseText) {
t.responseText = responseText;
if (callback != null) {
callback(t);
}
});
}).then(done, failed);
}
} else {
// The parameter keeps its previous value, so it must not be submitted.
failed(`HTTP ${response.status} ${response.statusText}`);
}
}, function (error) {
failed(error);
throw error;
})
}
};
Expand Down Expand Up @@ -1019,9 +1059,7 @@ var UnoChoice = UnoChoice || (jQuery3 => {

// call update methods in Java passing the HTML values
console.log(`[${name}] - renderCascadeChoiceParameter - Updating cascade of parameter [${name}] ...`);
window.requestIdleCallback(async () => {
await cascadeParameter.update(false);
})
scheduleUpdate(cascadeParameter, false);
} else {
console.log(`[${name}] - renderCascadeChoiceParameter - Parameter error: Missing parameter [${paramName}] HTML element!`);
}
Expand Down Expand Up @@ -1104,9 +1142,7 @@ var UnoChoice = UnoChoice || (jQuery3 => {

// call update methods in Java passing the HTML values
console.log(`[${name}] - renderDynamicRenderParameter - Updating cascade of parameter [${name}] ...`);
window.requestIdleCallback(async () => {
await dynamicParameter.update(false);
})
scheduleUpdate(dynamicParameter, false);
} else {
console.log(`[${name}] - renderDynamicRenderParameter - Parameter error: Missing parameter [${paramName}] HTML element!`);
}
Expand All @@ -1120,6 +1156,7 @@ var UnoChoice = UnoChoice || (jQuery3 => {
instance.ReferencedParameter = ReferencedParameter;
instance.FilterElement = FilterElement;
instance.makeStaplerProxy2 = makeStaplerProxy2;
instance.loadingGuard = loadingGuard;
instance.cascadeParameters = cascadeParameters;
instance.renderChoiceParameter = renderChoiceParameter;
instance.renderCascadeChoiceParameter = renderCascadeChoiceParameter;
Expand Down
11 changes: 11 additions & 0 deletions src/main/webapp/css/unochoice.css
Original file line number Diff line number Diff line change
Expand Up @@ -59,3 +59,14 @@
box-shadow: var(--form-input-glow--focus);
outline: none;
}

/* JENKINS-73556: shown next to Build while parameters are updating */
.ac-loading-status {
align-self: center;
margin-left: 0.75rem;
color: var(--text-color-secondary);
}

.ac-loading-status--error {
color: var(--error-color);
}
Loading
Loading