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
132 changes: 132 additions & 0 deletions cypress/integration/interface.js
Original file line number Diff line number Diff line change
@@ -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', '');
});
});
15 changes: 14 additions & 1 deletion js/dynamic_form.js
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -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')
Expand Down
74 changes: 61 additions & 13 deletions js/fields_data.js
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -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);
}
}
}

Expand Down