diff --git a/cypress/integration/interface.js b/cypress/integration/interface.js new file mode 100644 index 0000000..76bbdef --- /dev/null +++ b/cypress/integration/interface.js @@ -0,0 +1,132 @@ +/** + * Copyright (C) 2025 Arthit Suriyawongkul + * See the AUTHORS file at the top-level directory of this distribution + * License: GNU Affero General Public License version 3, or any later version + * See top-level LICENSE file for more information + */ + +/* + * Tests the user interface. + */ + +"use strict"; + +describe('License input field', function () { + beforeEach(function () { + cy.window().then((win) => { + win.sessionStorage.clear(); + win.document.getElementById('selected-licenses').innerHTML = ''; + }) + cy.visit('./index.html'); + // Wait for the license list to be loaded asynchronously before running + // license field-related tests. This prevents races where tests trigger + // input/change events before SPDX_LICENSE_IDS is available. + cy.window().its('SPDX_LICENSE_IDS.length').should('be.gt', 0); + cy.get('#license').should('exist'); + cy.get('#selected-licenses').should('exist'); + cy.get('#selected-licenses').children().should('have.length', 0); + }); + + it('can add a license by typing', function () { + cy.get('#license').type('MIT{enter}'); + cy.get('#selected-licenses .license-id').should('contain', 'MIT'); + cy.get('#license').should('have.value', ''); + }); + + it('can add a license by typing non-strictly with spaces', function () { + // " MIT " -> "MIT" + cy.get('#license').type(' MIT {enter}'); + cy.get('#selected-licenses .license-id').should('contain', 'MIT'); + cy.get('#license').should('have.value', ''); + }); + + it('can add a license by typing non-strictly to original casing', function () { + // "mit" -> "MIT" + cy.get('#license').type('mit{enter}'); + cy.get('#selected-licenses .license-id').should('contain', 'MIT'); + cy.get('#license').should('have.value', ''); + }); + + it('can add a license by clicking on the pop-up list', function () { + // inputType "insertReplacementText" typically appears when the browser + // inserts/replaces the input value as a single operation + // (for example when the user picks an item from a datalist with the + // mouse or when autocompletion replaces the current text) + cy.get('#license') + .invoke('val', 'MIT') + .trigger('input', { inputType: 'insertReplacementText' }); + cy.get('#selected-licenses .license-id', { timeout: 5000 }) + .should('contain', 'MIT'); + cy.get('#license').should('have.value', ''); + }); + + it('can add a license on "change" event (e.g., selecting via keyboard)', function () { + cy.get('#license').invoke('val', 'MIT').trigger('change'); + cy.get('#selected-licenses .license-id', { timeout: 5000 }) + .should('contain', 'MIT'); + cy.get('#license').should('have.value', ''); + }); + + it('should not insert a duplicated license', function () { + cy.get('#license').type('MIT{enter}'); + cy.get('#selected-licenses .license-id').should('have.length', 1); + cy.get('#license').type('MIT{enter}'); + cy.get('#selected-licenses .license-id').should('have.length', 1); + cy.get('#license').should('have.value', ''); + }); + + it('should not insert an invalid license', function () { + cy.get('#license').type('INVALID_LICENSE00{enter}'); + cy.get('#selected-licenses .license-id').should('not.exist'); + cy.get('#license:invalid').should('exist'); + }); + + it('should not insert a shorter match while typing a longer id with same prefix', function () { + // "MIT-0" vs "MIT" + cy.get('#license').type('MIT-0{enter}'); + cy.get('#selected-licenses .license-id').should('contain', 'MIT-0'); + cy.get('#license').should('have.value', ''); + }); + + it('should not insert until user confirms with Enter', function () { + // Type "MIT" but do not confirm with Enter yet + cy.get('#license').type('MIT'); + cy.get('#selected-licenses .license-id').should('not.exist'); + // Press ArrowRight - which is not a confirmation + cy.get('#license').trigger('keydown', { key: 'ArrowRight', code: 'ArrowRight', keyCode: 39, which: 39 }); + cy.get('#selected-licenses .license-id').should('not.exist'); + }); + + it('should not insert when edits produce a matching ID', function () { + // Note the space after "MIT" + cy.get('#license').type('MIT -0'); + cy.get('#selected-licenses .license-id').should('not.exist'); + // Remove the space, to produce "MIT-0" + cy.get('#license').type('{leftarrow}{leftarrow}{backspace}'); + cy.get('#selected-licenses .license-id').should('not.exist'); + // Confirm with Enter + cy.get('#license').type('{enter}'); + cy.get('#selected-licenses .license-id').should('contain', 'MIT-0'); + cy.get('#license').should('have.value', ''); + }); + + it('should insert when user confirms with Enter', function () { + // Type "MIT" but do not confirm with Enter yet + cy.get('#license').type('MIT'); + cy.get('#selected-licenses .license-id').should('not.exist'); + // Simulate Enter keydown to confirm + cy.get('#license').trigger('keydown', { key: 'Enter', code: 'Enter', keyCode: 13, which: 13 }); + cy.get('#selected-licenses .license-id').should('contain', 'MIT'); + cy.get('#license').should('have.value', ''); + }); + + it('should insert when user press Tab', function () { + // Type "MIT" but do not press Tab yet + cy.get('#license').type('MIT'); + cy.get('#selected-licenses .license-id').should('not.exist'); + // Simulate Tab keydown to confirm + cy.get('#license').trigger('keydown', { key: 'Tab', code: 'Tab', keyCode: 9, which: 9 }); + cy.get('#selected-licenses .license-id').should('contain', 'MIT'); + cy.get('#license').should('have.value', ''); + }); +}); diff --git a/js/dynamic_form.js b/js/dynamic_form.js index dc56d45..8e79b4f 100644 --- a/js/dynamic_form.js +++ b/js/dynamic_form.js @@ -1,5 +1,5 @@ /** - * Copyright (C) 2019 The Software Heritage developers + * Copyright (C) 2019-2025 The Software Heritage developers * See the AUTHORS file at the top-level directory of this distribution * License: GNU Affero General Public License version 3, or any later version * See top-level LICENSE file for more information @@ -199,8 +199,21 @@ function fieldToLower(event) { } function initCallbacks() { + // To make sure the selection of a license from the datalist + // works more predictably across browsers, we listen to + // 'input', 'change', and 'keydown' events for the license field. + // This should work with Firefox, Safari, and Chrome-based browsers. + + // In Firefox datalist selection without Enter press does not trigger + // 'change' event, so we need to listen to 'input' event to catch + // a selection with mouse click. + document.querySelector('#license') + .addEventListener('input', validateLicense); document.querySelector('#license') .addEventListener('change', validateLicense); + // Safari needs 'keydown' to catch Enter press when datalist is shown + document.querySelector('#license') + .addEventListener('keydown', validateLicense); document.querySelector('#generateCodemetaV2').disabled = false; document.querySelector('#generateCodemetaV2') diff --git a/js/fields_data.js b/js/fields_data.js index dbf04a7..0d67894 100644 --- a/js/fields_data.js +++ b/js/fields_data.js @@ -1,5 +1,5 @@ /** - * Copyright (C) 2019 The Software Heritage developers + * Copyright (C) 2019-2025 The Software Heritage developers * See the AUTHORS file at the top-level directory of this distribution * License: GNU Affero General Public License version 3, or any later version * See top-level LICENSE file for more information @@ -42,25 +42,73 @@ function insertLicenseElement(licenseId) { } function validateLicense(e) { - // continue only if Enter/Tab key is pressed - if (e.keyCode && e.keyCode !== 13 && e.keyCode !== 9) { + var licenseField = document.getElementById('license'); + licenseField.setCustomValidity(''); // Reset previous message + + var license = licenseField.value.trim(); + if (!license || !SPDX_LICENSE_IDS) { return; } - // Note: For some reason e.keyCode is undefined when Enter/Tab key is pressed. - // Maybe it's because of the datalist. But the above condition should - // work in either case. - var licenseField = document.getElementById('license'); - var license = licenseField.value; - if (SPDX_LICENSE_IDS !== null && SPDX_LICENSE_IDS.indexOf(license) == -1) { + // Only perform validation/insertion when the user explicitly confirms + // their choice (change event, or keydown Enter/Tab). + // Some browsers/selection actions can trigger an 'input' event that is + // not a simple text insertion (e.g. a datalist selection via mouse can + // trigger input of type 'insertReplacementText'); + // we treat those as confirmation too. + if (e.type === "change") { + // proceed + } else if (e.type === "keydown" && (e.key === "Enter" || e.key === "Tab")) { + // proceed + } else if (e.type === "input") { + const CONFIRM_INPUT_TYPES = new Set([ + 'insertReplacementText', // from datalist selection + ]); + if (!(e.inputType && CONFIRM_INPUT_TYPES.has(e.inputType))) { + // Typing characters, pasting, deletions - don't proceed + return; + } + } else { + // Other events (compositionupdate, etc.) — don't proceed + return; + } + + // Correct casing to the canonical SPDX license ID when possible. + // This will allow user to type in any casing and hit Enter once + // to insert the license immediately. + const match = SPDX_LICENSE_IDS.find(id => + id.toLowerCase() === license.toLowerCase()); + if (match) { + license = match; + licenseField.value = match; + } + + if (SPDX_LICENSE_IDS.indexOf(license) == -1) { licenseField.setCustomValidity('Unknown license id'); } else { - insertLicenseElement(license); - licenseField.value = ""; - licenseField.setCustomValidity(''); - generateCodemeta(); + const selectedLicenses = document.getElementById("selected-licenses"); + const duplicated = Array.from(selectedLicenses.getElementsByClassName("license-id")) + .some(el => el.textContent === license); + if (!duplicated) { + insertLicenseElement(license); + generateCodemeta(); + } + + // In Chromium-based browsers the datalist popup may remain visible + // after inserting a license by typing + Enter. + // To hide it we detach and re-attach the datalist. + // We avoid doing this in non-Chromium browsers (e.g. Safari) where + // reattaching can cause the popup to reappear. + var ua = (navigator.userAgent || ''); + var isChromium = /(Chrome|Chromium|CriOS|Edg|OPR|Brave|Vivaldi|SamsungBrowser)/.test(ua); + if (isChromium) { + licenseField.removeAttribute('list'); + setTimeout(() => { + licenseField.setAttribute('list', 'licenses'); + }, 0); + } } }