diff --git a/CHANGES.md b/CHANGES.md index 01d88a15..9761a16b 100644 --- a/CHANGES.md +++ b/CHANGES.md @@ -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 @@ -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) diff --git a/package.json b/package.json index a1565edb..6daf31cf 100644 --- a/package.json +++ b/package.json @@ -68,6 +68,10 @@ ], "testMatch": [ "/src/test/js/*.test.ts" - ] + ], + "moduleFileExtensions": ["js", "mjs", "cjs", "jsx", "ts", "tsx", "json", "node", "es6"], + "transform": { + "\\.(es6|[jt]sx?)$": "babel-jest" + } } } diff --git a/src/main/js/LoadingGuard.ts b/src/main/js/LoadingGuard.ts new file mode 100644 index 00000000..c2f80d9a --- /dev/null +++ b/src/main/js/LoadingGuard.ts @@ -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; + } + } +} diff --git a/src/main/js/UnoChoice.es6 b/src/main/js/UnoChoice.es6 index 477aac0b..5499dc71 100644 --- a/src/main/js/UnoChoice.es6 +++ b/src/main/js/UnoChoice.es6 @@ -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, @@ -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__'; @@ -102,6 +105,7 @@ var UnoChoice = UnoChoice || (jQuery3 => { this.proxy = proxy; this.referencedParameters = []; this.filterElement = null; + trackProxy(proxy); } /** * Gets the parameter name. @@ -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()); } } } @@ -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); @@ -465,6 +466,7 @@ var UnoChoice = UnoChoice || (jQuery3 => { this.paramElement = paramElement; this.proxy = proxy; this.referencedParameters = []; + trackProxy(proxy); } /** * Extend the cascade parameter. @@ -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); } } } @@ -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 /** *

This function is the same as makeStaplerProxy available in Jenkins core, but executes calls @@ -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, @@ -912,8 +945,9 @@ 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; }; @@ -921,16 +955,22 @@ var UnoChoice = UnoChoice || (jQuery3 => { 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; }) } }; @@ -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!`); } @@ -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!`); } @@ -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; diff --git a/src/main/webapp/css/unochoice.css b/src/main/webapp/css/unochoice.css index 0abd06ef..3db6aa6e 100644 --- a/src/main/webapp/css/unochoice.css +++ b/src/main/webapp/css/unochoice.css @@ -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); +} diff --git a/src/test/js/LoadingGuard.test.ts b/src/test/js/LoadingGuard.test.ts new file mode 100644 index 00000000..71098938 --- /dev/null +++ b/src/test/js/LoadingGuard.test.ts @@ -0,0 +1,131 @@ +/** + * 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. + */ + +import {afterEach, beforeEach, describe, expect, test} from '@jest/globals'; +// @ts-ignore +import LoadingGuard from '../../main/js/LoadingGuard.ts'; + +let guard: LoadingGuard; +let form: HTMLFormElement; +let build: HTMLButtonElement; +let param: HTMLElement; + +beforeEach(() => { + document.body.innerHTML = ` +

+
+
+
Cancel
+
`; + form = document.querySelector('form') as HTMLFormElement; + build = document.getElementById('build') as HTMLButtonElement; + param = document.getElementById('p1') as HTMLElement; + guard = new LoadingGuard(document); +}); + +afterEach(() => { + // Settles any open ticker so tests don't leak intervals. + guard.pending = 0; + guard.failed = false; + guard.begin()(); +}); + +function status(): string { + return (document.querySelector('.ac-loading-status') as HTMLElement).textContent || ''; +} + +function submitPrevented(): boolean { + let event = new Event('submit', {bubbles: true, cancelable: true}); + form.dispatchEvent(event); + return event.defaultPrevented; +} + +describe('LoadingGuard', () => { + test('disables Build while an update is pending and re-enables it once done', () => { + let done = guard.begin(param); + expect(build.disabled).toBe(true); + expect(status()).toMatch(/^Calculating parameters/); + done(); + expect(build.disabled).toBe(false); + expect(status()).toBe(''); + }); + + test('stays disabled until the last of overlapping updates is done', () => { + let first = guard.begin(param); + let second = guard.begin(); + first(); + expect(build.disabled).toBe(true); + second(); + expect(build.disabled).toBe(false); + }); + + test('a release function counts only once', () => { + let first = guard.begin(param); + let second = guard.begin(); + first(); + first(); + expect(guard.pending).toBe(1); + expect(build.disabled).toBe(true); + second(); + expect(build.disabled).toBe(false); + }); + + test('a failure keeps Build disabled after all updates are done', () => { + let done = guard.begin(param); + guard.fail(); + done(); + expect(build.disabled).toBe(true); + expect(status()).toBe('A parameter failed to load. Reload the page before building.'); + expect(document.querySelector('.ac-loading-status--error')).not.toBeNull(); + }); + + test('buttons inside a parameter are left alone', () => { + let done = guard.begin(param); + expect((document.getElementById('inner') as HTMLButtonElement).disabled).toBe(false); + done(); + }); + + test('a button disabled by someone else stays disabled', () => { + build.disabled = true; + let done = guard.begin(param); + done(); + expect(build.disabled).toBe(true); + }); + + test('blocks submitting the form while busy', () => { + let done = guard.begin(param); + expect(submitPrevented()).toBe(true); + done(); + expect(submitPrevented()).toBe(false); + }); + + test('ignores updates of elements outside a form', () => { + let outside = document.createElement('div'); + document.body.appendChild(outside); + let done = guard.begin(outside); + expect(build.disabled).toBe(false); + expect(document.querySelector('.ac-loading-status')).toBeNull(); + done(); + }); +}); diff --git a/src/test/js/UnoChoice.test.ts b/src/test/js/UnoChoice.test.ts new file mode 100644 index 00000000..6bd8454e --- /dev/null +++ b/src/test/js/UnoChoice.test.ts @@ -0,0 +1,210 @@ +/** + * 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. + */ + +import {beforeAll, beforeEach, describe, expect, test} from '@jest/globals'; + +// UnoChoice.es6 expects Jenkins' globals, so they are set before it is loaded. +// @ts-ignore +const jQuery = require('jquery'); +// @ts-ignore +(global as any).jQuery3 = jQuery; +// @ts-ignore +(global as any).Behaviour = { + applySubtree: () => { + } +}; + +const SEPARATOR = '__LESEP__'; +let UnoChoice: any; +// Per bound object: computes [values, keys] from the referenced values sent by doUpdate. +let backends: { [id: string]: (refs: { [name: string]: string }) => string[] } = {}; +let refsById: { [id: string]: { [name: string]: string } } = {}; +let failing: { [url: string]: boolean } = {}; +let log: string[] = []; +let observer: MutationObserver | null = null; + +const later = (ms: number) => new Promise(resolve => setTimeout(resolve, ms)); + +// Mimics Stapler: slow responses, and response bodies that arrive later still (the callback runs after the body). +function fakeFetch(url: string, init: any): Promise { + let [, id, method] = url.match(/\/bound\/([^/]+)\/(.+)$/) as RegExpMatchArray; + let args = JSON.parse(init.body); + return later(15 + Math.random() * 25).then(() => { + if (failing[url]) { + return {ok: false, status: 500, statusText: 'Server Error', headers: new Map()}; + } + let data: any = null; + if (method === 'doUpdate') { + refsById[id] = {}; + String(args[0]).split(SEPARATOR).filter(Boolean).forEach(pair => { + let [name, value] = pair.split('='); + refsById[id][name] = value; + }); + } else { + let values = backends[id](refsById[id] || {}); + data = [values, values]; + } + return { + ok: true, + status: 200, + statusText: 'OK', + headers: new Map([['content-type', 'application/json;charset=UTF-8']]), + json: () => later(10 + Math.random() * 20).then(() => data), + }; + }); +} + +function selectOf(id: string): HTMLSelectElement { + return document.querySelector(`#${id} select`) as HTMLSelectElement; +} + +function options(id: string): string[] { + return Array.prototype.map.call(selectOf(id).options, (o: HTMLOptionElement) => o.value) as string[]; +} + +function build(): HTMLButtonElement { + return document.getElementById('build') as HTMLButtonElement; +} + +function activeChoice(id: string, name: string): string { + return `
+
+
`; +} + +// Resolves once nothing has been pending for a while; fails the test if it never settles. +async function settle(): Promise { + let quiet = 0; + for (let i = 0; i < 500 && quiet < 10; i++) { + await later(10); + quiet = UnoChoice.loadingGuard.pending === 0 ? quiet + 1 : 0; + } + expect(UnoChoice.loadingGuard.pending).toBe(0); +} + +beforeAll(() => { + // @ts-ignore + (global as any).fetch = fakeFetch; + // @ts-ignore + require('../../main/js/UnoChoice.es6'); + UnoChoice = (window as any).UnoChoice; +}); + +beforeEach(async () => { + // Every test starts from a fresh page; the module-level guard and parameter list are reset. + await settle(); + UnoChoice.cascadeParameters.length = 0; + UnoChoice.loadingGuard.failed = false; + UnoChoice.loadingGuard.forms = []; + UnoChoice.loadingGuard.statuses = []; + UnoChoice.loadingGuard.disabledByGuard = []; + if (observer) observer.disconnect(); + failing = {}; + log = []; + document.body.innerHTML = ` +
+
+
+ ${activeChoice('service', 'Service')} + ${activeChoice('version', 'Version')} +
+ Cancel
+
`; + backends = { + service: refs => [`${refs.Environment}-svc1`, `${refs.Environment}-svc2`], + version: refs => [`${refs.Service}-v1`], + }; + // Records, in order, every re-render of a dependent select and every change of the Build button. + observer = new MutationObserver(records => records.forEach(r => { + if (r.type === 'attributes') { + log.push(build().disabled ? 'build:disabled' : 'build:enabled'); + } else if ((r.target as Element).tagName === 'SELECT') { + log.push(`render:${(r.target as Element).closest('[id]')!.id}`); + } + })); + observer.observe(document.body, {subtree: true, childList: true, attributes: true, attributeFilter: ['disabled']}); + for (let [id, name, refs] of [['service', 'Service', ['Environment']], ['version', 'Version', ['Service']]] as [string, string, string[]][]) { + let proxy = UnoChoice.makeStaplerProxy2(`/bound/${id}`, 'crumb', ['doUpdate', 'getChoicesForUI']); + UnoChoice.renderCascadeChoiceParameter(`#${id}`, false, name, id, 0, id, refs, proxy); + } +}); + +// Build must stay disabled from the first scheduled update until the very last render. +function expectNoEarlyEnable(): void { + let lastRender = log.map(e => e.startsWith('render:')).lastIndexOf(true); + let firstEnable = log.indexOf('build:enabled'); + expect(lastRender).toBeGreaterThanOrEqual(0); + expect(firstEnable).toBeGreaterThan(lastRender); +} + +describe('Build button while cascading parameters update (JENKINS-73556)', () => { + test('is disabled as soon as the initial updates are scheduled, until the whole cascade has rendered', async () => { + expect(build().disabled).toBe(true); + await settle(); + expect(options('service')).toEqual(['a-svc1', 'a-svc2']); + expect(options('version')).toEqual(['a-svc1-v1']); + expect(build().disabled).toBe(false); + expectNoEarlyEnable(); + expect(document.querySelector('.ac-loading-status')!.textContent).toBe(''); + }); + + test('is disabled while a change cascades through dependent parameters', async () => { + await settle(); + log = []; + let environment = document.querySelector('select') as HTMLSelectElement; + environment.value = 'b'; + jQuery(environment).trigger('change'); + expect(build().disabled).toBe(true); + expect(document.querySelector('.ac-loading-status')!.textContent).toMatch(/^Calculating parameters/); + await settle(); + expect(options('service')).toEqual(['b-svc1', 'b-svc2']); + expect(options('version')).toEqual(['b-svc1-v1']); + expect(build().disabled).toBe(false); + expectNoEarlyEnable(); + }); + + test('blocks form submission while updating', async () => { + let event = new Event('submit', {bubbles: true, cancelable: true}); + document.querySelector('form')!.dispatchEvent(event); + expect(event.defaultPrevented).toBe(true); + await settle(); + let after = new Event('submit', {bubbles: true, cancelable: true}); + document.querySelector('form')!.dispatchEvent(after); + expect(after.defaultPrevented).toBe(false); + }); + + test('stays disabled with a reload message when an update fails', async () => { + await settle(); + failing['/bound/version/getChoicesForUI'] = true; + let environment = document.querySelector('select') as HTMLSelectElement; + environment.value = 'b'; + jQuery(environment).trigger('change'); + await settle(); + expect(options('service')).toEqual(['b-svc1', 'b-svc2']); + expect(build().disabled).toBe(true); + expect(document.querySelector('.ac-loading-status')!.textContent) + .toBe('A parameter failed to load. Reload the page before building.'); + }); +});