diff --git a/composer.lock b/composer.lock index f1f4c867f..08cd87bbe 100644 --- a/composer.lock +++ b/composer.lock @@ -1139,16 +1139,16 @@ }, { "name": "phpunit/phpunit", - "version": "13.1.0", + "version": "13.1.1", "source": { "type": "git", "url": "https://github.com/sebastianbergmann/phpunit.git", - "reference": "97f27488f84718f8e7f9a2a31b5ca9f20698b06f" + "reference": "bf114c99266501d45acc00ad96d5606f3f7ad99c" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/sebastianbergmann/phpunit/zipball/97f27488f84718f8e7f9a2a31b5ca9f20698b06f", - "reference": "97f27488f84718f8e7f9a2a31b5ca9f20698b06f", + "url": "https://api.github.com/repos/sebastianbergmann/phpunit/zipball/bf114c99266501d45acc00ad96d5606f3f7ad99c", + "reference": "bf114c99266501d45acc00ad96d5606f3f7ad99c", "shasum": "" }, "require": { @@ -1168,9 +1168,9 @@ "phpunit/php-text-template": "^6.0.0", "phpunit/php-timer": "^9.0.0", "sebastian/cli-parser": "^5.0.0", - "sebastian/comparator": "^8.0.0", - "sebastian/diff": "^8.0.0", - "sebastian/environment": "^9.1.0", + "sebastian/comparator": "^8.1.0", + "sebastian/diff": "^8.1.0", + "sebastian/environment": "^9.2.0", "sebastian/exporter": "^8.0.0", "sebastian/git-state": "^1.0", "sebastian/global-state": "^9.0.0", @@ -1218,7 +1218,7 @@ "support": { "issues": "https://github.com/sebastianbergmann/phpunit/issues", "security": "https://github.com/sebastianbergmann/phpunit/security/policy", - "source": "https://github.com/sebastianbergmann/phpunit/tree/13.1.0" + "source": "https://github.com/sebastianbergmann/phpunit/tree/13.1.1" }, "funding": [ { @@ -1226,7 +1226,7 @@ "type": "other" } ], - "time": "2026-04-03T05:29:00+00:00" + "time": "2026-04-08T03:07:38+00:00" }, { "name": "psr/http-client", @@ -1503,23 +1503,23 @@ }, { "name": "sebastian/comparator", - "version": "8.0.0", + "version": "8.1.1", "source": { "type": "git", "url": "https://github.com/sebastianbergmann/comparator.git", - "reference": "29b232ddc29c2b114c0358c69b3084e7c3da0d58" + "reference": "44b063d0a64da0e8ea74fb6464d8de2b1429ab7e" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/sebastianbergmann/comparator/zipball/29b232ddc29c2b114c0358c69b3084e7c3da0d58", - "reference": "29b232ddc29c2b114c0358c69b3084e7c3da0d58", + "url": "https://api.github.com/repos/sebastianbergmann/comparator/zipball/44b063d0a64da0e8ea74fb6464d8de2b1429ab7e", + "reference": "44b063d0a64da0e8ea74fb6464d8de2b1429ab7e", "shasum": "" }, "require": { "ext-dom": "*", "ext-mbstring": "*", "php": ">=8.4", - "sebastian/diff": "^8.0", + "sebastian/diff": "^8.1", "sebastian/exporter": "^8.0" }, "require-dev": { @@ -1531,7 +1531,7 @@ "type": "library", "extra": { "branch-alias": { - "dev-main": "8.0-dev" + "dev-main": "8.1-dev" } }, "autoload": { @@ -1571,7 +1571,7 @@ "support": { "issues": "https://github.com/sebastianbergmann/comparator/issues", "security": "https://github.com/sebastianbergmann/comparator/security/policy", - "source": "https://github.com/sebastianbergmann/comparator/tree/8.0.0" + "source": "https://github.com/sebastianbergmann/comparator/tree/8.1.1" }, "funding": [ { @@ -1591,7 +1591,7 @@ "type": "tidelift" } ], - "time": "2026-02-06T04:40:39+00:00" + "time": "2026-04-08T04:47:31+00:00" }, { "name": "sebastian/complexity", @@ -1665,16 +1665,16 @@ }, { "name": "sebastian/diff", - "version": "8.0.0", + "version": "8.1.0", "source": { "type": "git", "url": "https://github.com/sebastianbergmann/diff.git", - "reference": "a2b6d09d7729ee87d605a439469f9dcc39be5ea3" + "reference": "9c957d730257f49c873f3761674559bd90098a7d" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/sebastianbergmann/diff/zipball/a2b6d09d7729ee87d605a439469f9dcc39be5ea3", - "reference": "a2b6d09d7729ee87d605a439469f9dcc39be5ea3", + "url": "https://api.github.com/repos/sebastianbergmann/diff/zipball/9c957d730257f49c873f3761674559bd90098a7d", + "reference": "9c957d730257f49c873f3761674559bd90098a7d", "shasum": "" }, "require": { @@ -1687,7 +1687,7 @@ "type": "library", "extra": { "branch-alias": { - "dev-main": "8.0-dev" + "dev-main": "8.1-dev" } }, "autoload": { @@ -1720,7 +1720,7 @@ "support": { "issues": "https://github.com/sebastianbergmann/diff/issues", "security": "https://github.com/sebastianbergmann/diff/security/policy", - "source": "https://github.com/sebastianbergmann/diff/tree/8.0.0" + "source": "https://github.com/sebastianbergmann/diff/tree/8.1.0" }, "funding": [ { @@ -1740,20 +1740,20 @@ "type": "tidelift" } ], - "time": "2026-02-06T04:42:27+00:00" + "time": "2026-04-05T12:02:33+00:00" }, { "name": "sebastian/environment", - "version": "9.1.0", + "version": "9.2.0", "source": { "type": "git", "url": "https://github.com/sebastianbergmann/environment.git", - "reference": "c4a2dc54b1a24e13ef1839cbb5947b967cbae853" + "reference": "c0964f624fcac84e318fc9ef0193cbb9809a331a" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/sebastianbergmann/environment/zipball/c4a2dc54b1a24e13ef1839cbb5947b967cbae853", - "reference": "c4a2dc54b1a24e13ef1839cbb5947b967cbae853", + "url": "https://api.github.com/repos/sebastianbergmann/environment/zipball/c0964f624fcac84e318fc9ef0193cbb9809a331a", + "reference": "c0964f624fcac84e318fc9ef0193cbb9809a331a", "shasum": "" }, "require": { @@ -1768,7 +1768,7 @@ "type": "library", "extra": { "branch-alias": { - "dev-main": "9.1-dev" + "dev-main": "9.2-dev" } }, "autoload": { @@ -1796,7 +1796,7 @@ "support": { "issues": "https://github.com/sebastianbergmann/environment/issues", "security": "https://github.com/sebastianbergmann/environment/security/policy", - "source": "https://github.com/sebastianbergmann/environment/tree/9.1.0" + "source": "https://github.com/sebastianbergmann/environment/tree/9.2.0" }, "funding": [ { @@ -1816,7 +1816,7 @@ "type": "tidelift" } ], - "time": "2026-03-22T06:31:50+00:00" + "time": "2026-04-05T07:07:20+00:00" }, { "name": "sebastian/exporter", diff --git a/js/eventhandlers/formgroups/resourceinformation-title.js b/js/eventhandlers/formgroups/resourceinformation-title.js index 58c04526e..ff632ff79 100644 --- a/js/eventhandlers/formgroups/resourceinformation-title.js +++ b/js/eventhandlers/formgroups/resourceinformation-title.js @@ -73,6 +73,16 @@ $(document).ready(function () { }).first(); $select.val($firstOption.val() || ""); } + // Explicitly set the disabled state based on whether valid options exist. + // A "valid" option has a non-empty value — the placeholder (value="") + // added by select.js does not count. When no valid options exist (e.g. the + // user clicked "Add" while title types are still loading, or the API + // returned no types), disable the select to prevent a required empty + // control from blocking form submission. + const hasValidOptions = $select.find("option").filter(function () { + return $(this).val() !== ""; + }).length > 0; + $select.prop("disabled", !hasValidOptions); // Create a remove button for the new row. const removeBtn = $(" + + + + `; + + $ = require('jquery'); + global.$ = global.jQuery = $; + window.$ = $; + window.jQuery = $; + + // Mock external function imported by the module + window.replaceHelpButtonInClonedRows = jest.fn(); + + // Set up window globals that would normally be populated by select.js AJAX + window.maxTitles = 5; + window.mainTitleTypeId = '1'; + window.alternativeTitleTypeId = '2'; + window.titleTypeOptionsHtml = + '' + + '' + + '' + + ''; + + // Load and eval the script — strip ES module import and wrap document.ready + let script = fs.readFileSync( + path.resolve(__dirname, '../../js/eventhandlers/formgroups/resourceinformation-title.js'), + 'utf8' + ); + script = script.replace(/^import.*$/gm, ''); + script = script.replace('$(document).ready(function () {', '(function () {'); + script = script.replace(/\n\s*\}\);\s*$/, '\n})();'); + window.eval(script); + + document.dispatchEvent(new Event('DOMContentLoaded')); + }); + + afterEach(() => { + // Remove document-level event handlers registered by the module to prevent + // handler accumulation across tests (the script is eval'd in every beforeEach). + $(document).off('elmo:clearTitles'); + $('#button-resourceinformation-addtitle').off('click'); + jest.restoreAllMocks(); + delete window.maxTitles; + delete window.mainTitleTypeId; + delete window.alternativeTitleTypeId; + delete window.titleTypeOptionsHtml; + delete window.replaceHelpButtonInClonedRows; + }); + + test('adds a new title row when add button is clicked', () => { + $('#button-resourceinformation-addtitle').trigger('click'); + + const rows = $('#group-resourceinformation .row'); + expect(rows.length).toBe(2); + }); + + test('cloned title type select is NOT disabled even when original is disabled', () => { + // Simulate the original select being disabled (as happens during AJAX loading) + $('#input-resourceinformation-titletype').prop('disabled', true); + + $('#button-resourceinformation-addtitle').trigger('click'); + + const rows = $('#group-resourceinformation .row'); + expect(rows.length).toBe(2); + const newRow = rows.last(); + const $clonedSelect = newRow.find('select'); + expect($clonedSelect.length).toBe(1); + + // The cloned select must be enabled so the user can pick a title type + expect($clonedSelect.prop('disabled')).toBe(false); + }); + + test('cloned title type select is enabled even when original is enabled', () => { + $('#input-resourceinformation-titletype').prop('disabled', false); + + $('#button-resourceinformation-addtitle').trigger('click'); + + const rows = $('#group-resourceinformation .row'); + expect(rows.length).toBe(2); + const newRow = rows.last(); + const $clonedSelect = newRow.find('select'); + expect($clonedSelect.length).toBe(1); + + expect($clonedSelect.prop('disabled')).toBe(false); + }); + + test('cloned title type select stays disabled when no options are available and original is disabled', () => { + // Simulate clicking "Add" while title types are still loading (original disabled) + window.titleTypeOptionsHtml = ''; + $('#input-resourceinformation-titletype').prop('disabled', true); + + $('#button-resourceinformation-addtitle').trigger('click'); + + const rows = $('#group-resourceinformation .row'); + expect(rows.length).toBe(2); + const newRow = rows.last(); + const $clonedSelect = newRow.find('select'); + expect($clonedSelect.length).toBe(1); + + // Select must be disabled when there are no options to choose from + expect($clonedSelect.prop('disabled')).toBe(true); + }); + + test('cloned title type select becomes disabled when no options are available and original is enabled', () => { + // The template select in resourceInformation.html is not disabled by default. + // If titleTypeOptionsHtml is empty while the original is enabled, the clone + // must still be explicitly disabled to prevent a required empty control. + window.titleTypeOptionsHtml = ''; + $('#input-resourceinformation-titletype').prop('disabled', false); + + $('#button-resourceinformation-addtitle').trigger('click'); + + const rows = $('#group-resourceinformation .row'); + expect(rows.length).toBe(2); + const newRow = rows.last(); + const $clonedSelect = newRow.find('select'); + expect($clonedSelect.length).toBe(1); + + // Must be disabled even though the original was enabled — no options to pick + expect($clonedSelect.prop('disabled')).toBe(true); + }); + + test('cloned title type select is disabled when only placeholder option exists', () => { + // select.js always includes a placeholder option even if the API returns + // no title types — the clone must still be disabled in this case. + window.titleTypeOptionsHtml = ''; + $('#input-resourceinformation-titletype').prop('disabled', false); + + $('#button-resourceinformation-addtitle').trigger('click'); + + const rows = $('#group-resourceinformation .row'); + expect(rows.length).toBe(2); + const newRow = rows.last(); + const $clonedSelect = newRow.find('select'); + expect($clonedSelect.length).toBe(1); + + // Only a placeholder with value="" — no valid option to select + expect($clonedSelect.prop('disabled')).toBe(true); + }); + + test('cloned title type dropdown is visible (unvisible class removed)', () => { + $('#button-resourceinformation-addtitle').trigger('click'); + + const rows = $('#group-resourceinformation .row'); + expect(rows.length).toBe(2); + const newRow = rows.last(); + const $container = newRow.find('[id="container-resourceinformation-titletype"]'); + expect($container.length).toBe(1); + + expect($container.hasClass('unvisible')).toBe(false); + }); + + test('cloned title type dropdown does not contain main title option', () => { + $('#button-resourceinformation-addtitle').trigger('click'); + + const rows = $('#group-resourceinformation .row'); + expect(rows.length).toBe(2); + const newRow = rows.last(); + const $clonedSelect = newRow.find('select'); + expect($clonedSelect.length).toBe(1); + + expect($clonedSelect.find('option[value="1"]').length).toBe(0); + }); + + test('cloned title type dropdown pre-selects alternative title', () => { + $('#button-resourceinformation-addtitle').trigger('click'); + + const rows = $('#group-resourceinformation .row'); + expect(rows.length).toBe(2); + const newRow = rows.last(); + const $clonedSelect = newRow.find('select'); + expect($clonedSelect.length).toBe(1); + + expect($clonedSelect.val()).toBe('2'); + }); + + test('new row input field is cleared', () => { + $('#input-resourceinformation-title').val('Test Title'); + $('#button-resourceinformation-addtitle').trigger('click'); + + const rows = $('#group-resourceinformation .row'); + expect(rows.length).toBe(2); + const newRow = rows.last(); + expect(newRow.find('input').length).toBeGreaterThan(0); + + expect(newRow.find('input').val()).toBe(''); + }); + + test('new row has remove button instead of add button', () => { + $('#button-resourceinformation-addtitle').trigger('click'); + + const rows = $('#group-resourceinformation .row'); + expect(rows.length).toBe(2); + const newRow = rows.last(); + + expect(newRow.find('.addTitle').length).toBe(0); + expect(newRow.find('.removeTitle').length).toBe(1); + }); + + test('add button is disabled when max titles reached', () => { + // The handler reads window.maxTitles at click-time, no re-eval needed + window.maxTitles = 2; + + $('#button-resourceinformation-addtitle').trigger('click'); + + expect($('#button-resourceinformation-addtitle').prop('disabled')).toBe(true); + }); + + test('remove button removes the row and re-enables add button', () => { + // The handler reads window.maxTitles at click-time, no re-eval needed + window.maxTitles = 2; + + $('#button-resourceinformation-addtitle').trigger('click'); + expect($('#button-resourceinformation-addtitle').prop('disabled')).toBe(true); + + // Click the remove button on the new row + $('.removeTitle').trigger('click'); + + const rows = $('#group-resourceinformation .row'); + expect(rows.length).toBe(1); + expect($('#button-resourceinformation-addtitle').prop('disabled')).toBe(false); + }); + + test('elmo:clearTitles resets counter and re-enables add button', () => { + // The handler reads window.maxTitles at click-time, no re-eval needed + window.maxTitles = 2; + + $('#button-resourceinformation-addtitle').trigger('click'); + expect($('#button-resourceinformation-addtitle').prop('disabled')).toBe(true); + + $(document).trigger('elmo:clearTitles'); + expect($('#button-resourceinformation-addtitle').prop('disabled')).toBe(false); + }); +});