From 5ac5eee64910b2a444ff481bb20494376b90eed5 Mon Sep 17 00:00:00 2001 From: Balsa Asanovic Date: Mon, 29 Jan 2024 00:11:22 +0100 Subject: [PATCH 01/11] added username suggestion based on full name Three suggestions are given: name+surname nameInitial+surname name+surnameInitial --- web/src/components/users/FirstUser.jsx | 58 +++++++++++++++++++++++++- 1 file changed, 57 insertions(+), 1 deletion(-) diff --git a/web/src/components/users/FirstUser.jsx b/web/src/components/users/FirstUser.jsx index ace01e94d2..689b2d4263 100644 --- a/web/src/components/users/FirstUser.jsx +++ b/web/src/components/users/FirstUser.jsx @@ -32,6 +32,10 @@ import { FormGroup, TextInput, Skeleton, + Menu, + MenuContent, + MenuList, + MenuItem } from "@patternfly/react-core"; import { Table, Thead, Tr, Th, Tbody, Td } from '@patternfly/react-table'; @@ -97,6 +101,8 @@ export default function FirstUser() { const [isFormOpen, setIsFormOpen] = useState(false); const [isValidPassword, setIsValidPassword] = useState(true); const [isSettingPassword, setIsSettingPassword] = useState(false); + const [showSuggestions, setShowSuggestions] = useState(false); + const [insideDropDown, setInsideDropDown] = useState(false); useEffect(() => { cancellablePromise(client.users.getUser()).then(userValues => { @@ -166,6 +172,26 @@ export default function FirstUser() { setFormValues({ ...formValues, [name]: value }); }; + const suggestUsernames = (fullName) => { + const cleanedName = fullName.replace(/[^\p{L}\p{N} ]/gu, "").toLowerCase(); + const nameParts = cleanedName.split(/\s+/); + const suggestions = []; + + nameParts.forEach((namePart, index) => { + if (index === 0) { + suggestions.push(namePart); + suggestions.push(namePart[0]); + nameParts.length > 1 && suggestions.push(namePart); + } else { + suggestions[0] += namePart; + suggestions[1] += namePart; + suggestions[2] += namePart[0]; + } + }); + + return suggestions; + }; + const isUserDefined = user?.userName && user?.userName !== ""; const showErrors = () => ((errors || []).length > 0); @@ -210,7 +236,14 @@ export default function FirstUser() { /> - + setShowSuggestions(true)} + onBlur={() => !insideDropDown && setShowSuggestions(false)} + > + { showSuggestions && formValues.fullName && !formValues.userName && + setInsideDropDown(true)} + onMouseLeave={() => setInsideDropDown(false)} + > + + + { suggestUsernames(formValues.fullName).map((suggestion, index) => ( + { setInsideDropDown(false); setFormValues({ ...formValues, userName: suggestion }) }} + > + { /* TRANSLATORS: dropdown username suggestions */ } + { _("Use suggested username ") } + {suggestion} + + )) } + + + } { isEditing && From 5d3e42ff4952eedcd69680b1131c2d50cc83365b Mon Sep 17 00:00:00 2001 From: Balsa Asanovic Date: Mon, 29 Jan 2024 00:11:45 +0100 Subject: [PATCH 02/11] added classes for styling dropdown in username suggestions --- web/src/assets/styles/app.scss | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/web/src/assets/styles/app.scss b/web/src/assets/styles/app.scss index 3a9ff7bdef..8aa9b4e838 100644 --- a/web/src/assets/styles/app.scss +++ b/web/src/assets/styles/app.scss @@ -172,3 +172,13 @@ button.kebab-toggler { gap: 0 1em; width: 100%; } + +.first-username-dropdown { + position: absolute; + width: 100%; +} + +.first-username-wrapper { + position: relative; + width: 100%; +} From 502a23092f908f4527cc2587fae8483f8a293737 Mon Sep 17 00:00:00 2001 From: Balsa Asanovic Date: Sun, 11 Feb 2024 19:43:42 +0100 Subject: [PATCH 03/11] removed trailing space from transalation Replace strong with bold tag --- web/src/components/users/FirstUser.jsx | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/web/src/components/users/FirstUser.jsx b/web/src/components/users/FirstUser.jsx index 689b2d4263..88ae2db4bf 100644 --- a/web/src/components/users/FirstUser.jsx +++ b/web/src/components/users/FirstUser.jsx @@ -269,8 +269,7 @@ export default function FirstUser() { onClick={() => { setInsideDropDown(false); setFormValues({ ...formValues, userName: suggestion }) }} > { /* TRANSLATORS: dropdown username suggestions */ } - { _("Use suggested username ") } - {suggestion} + { _("Use suggested username") } {suggestion} )) } From 1f9badfef262eedffec3f7d240c8d016d5580a98 Mon Sep 17 00:00:00 2001 From: Balsa Asanovic Date: Tue, 20 Feb 2024 00:33:12 +0100 Subject: [PATCH 04/11] moved suggestUsernames method to utils.js file --- web/src/components/users/FirstUser.jsx | 22 ++-------------------- 1 file changed, 2 insertions(+), 20 deletions(-) diff --git a/web/src/components/users/FirstUser.jsx b/web/src/components/users/FirstUser.jsx index 88ae2db4bf..61a310e4f5 100644 --- a/web/src/components/users/FirstUser.jsx +++ b/web/src/components/users/FirstUser.jsx @@ -42,6 +42,8 @@ import { Table, Thead, Tr, Th, Tbody, Td } from '@patternfly/react-table'; import { RowActions, PasswordAndConfirmationInput, Popup } from '~/components/core'; +import { suggestUsernames } from '~/components/users/utils'; + const UserNotDefined = ({ actionCb }) => { return (
@@ -172,26 +174,6 @@ export default function FirstUser() { setFormValues({ ...formValues, [name]: value }); }; - const suggestUsernames = (fullName) => { - const cleanedName = fullName.replace(/[^\p{L}\p{N} ]/gu, "").toLowerCase(); - const nameParts = cleanedName.split(/\s+/); - const suggestions = []; - - nameParts.forEach((namePart, index) => { - if (index === 0) { - suggestions.push(namePart); - suggestions.push(namePart[0]); - nameParts.length > 1 && suggestions.push(namePart); - } else { - suggestions[0] += namePart; - suggestions[1] += namePart; - suggestions[2] += namePart[0]; - } - }); - - return suggestions; - }; - const isUserDefined = user?.userName && user?.userName !== ""; const showErrors = () => ((errors || []).length > 0); From 94dce4dfc5523cf954700364523e7cfaa41ac15d Mon Sep 17 00:00:00 2001 From: Balsa Asanovic Date: Tue, 20 Feb 2024 00:33:34 +0100 Subject: [PATCH 05/11] created utils.js file with method for username suggestion --- web/src/components/users/utils.js | 71 +++++++++++++++++++++++++++++++ 1 file changed, 71 insertions(+) create mode 100644 web/src/components/users/utils.js diff --git a/web/src/components/users/utils.js b/web/src/components/users/utils.js new file mode 100644 index 0000000000..546bf79912 --- /dev/null +++ b/web/src/components/users/utils.js @@ -0,0 +1,71 @@ +/* + * Copyright (c) [2024] SUSE LLC + * + * All Rights Reserved. + * + * This program is free software; you can redistribute it and/or modify it + * under the terms of version 2 of the GNU General Public License as published + * by the Free Software Foundation. + * + * This program is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for + * more details. + * + * You should have received a copy of the GNU General Public License along + * with this program; if not, contact SUSE LLC. + * + * To contact SUSE LLC about this file by physical or electronic mail, you may + * find current contact information at www.suse.com. + */ + +/** + * Method which generates username suggestions based on first and last name. + * The method cleans the input name by removing non-alphanumeric characters (except spaces), + * splits the name into parts, and then generates suggestions based on these parts. + * + * @param {string} fullName The full name used to generate username suggestions. + * @returns {string[]} An array of username suggestions. + */ +const suggestUsernames = (fullName) => { + // Cleaning the name. + const cleanedName = fullName + .normalize('NFD') + .trim() + .replace(/[\u0300-\u036f]/g, '') // Replacing accented characters with English equivalents, eg. š with s. + .replace(/[^\p{L}\p{N} ]/gu, "") // Keep only letters, numbers and spaces. Covering the whole Unicode range, not just ASCII. + .toLowerCase(); + + // Split the cleaned name into parts. + const nameParts = cleanedName.split(/\s+/); + + const suggestions = []; + + nameParts.forEach((namePart, index) => { + if (index === 0) { + suggestions.push(namePart); + suggestions.push(namePart[0]); + suggestions.push(namePart[0]); + suggestions.push(namePart); + suggestions.push(namePart[0]); + suggestions.push(namePart); + } else { + if (index === 1) + suggestions[1] += namePart; + suggestions[2] += namePart; + suggestions[3] += namePart[0]; + if (index === nameParts.length - 1) + suggestions[4] += namePart; + else + suggestions[4] += namePart[0]; + suggestions[5] += namePart; + } + }); + + // using Set object to remove duplicates, then converting back to array + return [...new Set(suggestions)]; +}; + +export { + suggestUsernames +}; From b71102d797298cd12c65bcfb5397aaa237b653da Mon Sep 17 00:00:00 2001 From: Balsa Asanovic Date: Tue, 20 Feb 2024 00:33:49 +0100 Subject: [PATCH 06/11] unit tests for utils.js file --- web/src/components/users/utils.test.js | 60 ++++++++++++++++++++++++++ 1 file changed, 60 insertions(+) create mode 100644 web/src/components/users/utils.test.js diff --git a/web/src/components/users/utils.test.js b/web/src/components/users/utils.test.js new file mode 100644 index 0000000000..e3711388de --- /dev/null +++ b/web/src/components/users/utils.test.js @@ -0,0 +1,60 @@ +/* + * Copyright (c) [2024] SUSE LLC + * + * All Rights Reserved. + * + * This program is free software; you can redistribute it and/or modify it + * under the terms of version 2 of the GNU General Public License as published + * by the Free Software Foundation. + * + * This program is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for + * more details. + * + * You should have received a copy of the GNU General Public License along + * with this program; if not, contact SUSE LLC. + * + * To contact SUSE LLC about this file by physical or electronic mail, you may + * find current contact information at www.suse.com. + */ + +import { suggestUsernames } from "./utils"; + +describe('suggestUsernames', () => { + test('handles basic single name', () => { + expect(suggestUsernames('John')).toEqual(expect.arrayContaining(['john', 'j'])); + }); + + test('handles basic two-part name', () => { + expect(suggestUsernames('John Doe')).toEqual(expect.arrayContaining(['john', 'jdoe', 'johnd', 'johndoe'])); + }); + + test('handles name with middle initial', () => { + expect(suggestUsernames('John Q. Doe')).toEqual(expect.arrayContaining(['john', 'jq', 'jqdoe', 'johnqd', 'johnqdoe'])); + }); + + test('normalizes accented characters', () => { + expect(suggestUsernames('José María')).toEqual(expect.arrayContaining(['jose', 'jmaria', 'josem', 'josemaria'])); + }); + + test('removes hyphens and apostrophes', () => { + expect(suggestUsernames("Jean-Luc O'Neill")).toEqual(expect.arrayContaining(['jeanluc', 'joneill', 'jeanluco', 'jeanluconeill'])); + }); + + test('removes non-alphanumeric characters', () => { + expect(suggestUsernames("Anna*#& Maria$%^")).toEqual(expect.arrayContaining(['anna', 'amaria', 'annam', 'annamaria'])); + }); + + test('handles long name with multiple parts', () => { + expect(suggestUsernames("Maria del Carmen Fernandez Vega")).toEqual(expect.arrayContaining(['maria', 'mdel', 'mdelcarmenfernandezvega', 'mariadcfv', 'mdcfvega', 'mariadelcarmenfernandezvega'])); + }); + + test('handles empty or invalid input', () => { + expect(suggestUsernames("")).toEqual(expect.arrayContaining([])); + }); + + test('trims spaces and handles multiple spaces between names', () => { + expect(suggestUsernames(" John Doe ")).toEqual(expect.arrayContaining(['john', 'jdoe', 'johnd', 'johndoe'])); + }); +}); From 06a89e6c0160619eb02ff815bff1c30c48a696cb Mon Sep 17 00:00:00 2001 From: Balsa Asanovic Date: Sun, 25 Feb 2024 23:09:43 +0100 Subject: [PATCH 07/11] moved suggestions menu to internal component --- web/src/components/users/FirstUser.jsx | 66 +++++++++++++++++--------- 1 file changed, 43 insertions(+), 23 deletions(-) diff --git a/web/src/components/users/FirstUser.jsx b/web/src/components/users/FirstUser.jsx index 61a310e4f5..43138b9c92 100644 --- a/web/src/components/users/FirstUser.jsx +++ b/web/src/components/users/FirstUser.jsx @@ -40,7 +40,7 @@ import { import { Table, Thead, Tr, Th, Tbody, Td } from '@patternfly/react-table'; -import { RowActions, PasswordAndConfirmationInput, Popup } from '~/components/core'; +import { RowActions, PasswordAndConfirmationInput, Popup, If } from '~/components/core'; import { suggestUsernames } from '~/components/users/utils'; @@ -82,6 +82,32 @@ const UserData = ({ user, actions }) => { ); }; +const UsernameSuggestions = ({ entries, onSelect, setInsideDropDown }) => { + return ( + setInsideDropDown(true)} + onMouseLeave={() => setInsideDropDown(false)} + > + + + {entries.map((suggestion, index) => ( + onSelect(suggestion)} + > + { /* TRANSLATORS: dropdown username suggestions */} + {_("Use suggested username")} {suggestion} + + ))} + + + + ); +}; + const CREATE_MODE = 'create'; const EDIT_MODE = 'edit'; @@ -193,6 +219,12 @@ export default function FirstUser() { const usingValidPassword = formValues.password && formValues.password !== "" && isValidPassword; const submitDisable = formValues.userName === "" || (isSettingPassword && !usingValidPassword); + const displaySuggestions = !formValues.userName && formValues.fullName && showSuggestions; + const onSuggestionSelected = (suggestion) => { + setInsideDropDown(false); + setFormValues({ ...formValues, userName: suggestion }); + }; + if (isLoading) return ; return ( @@ -235,28 +267,16 @@ export default function FirstUser() { isRequired onChange={handleInputChange} /> - { showSuggestions && formValues.fullName && !formValues.userName && - setInsideDropDown(true)} - onMouseLeave={() => setInsideDropDown(false)} - > - - - { suggestUsernames(formValues.fullName).map((suggestion, index) => ( - { setInsideDropDown(false); setFormValues({ ...formValues, userName: suggestion }) }} - > - { /* TRANSLATORS: dropdown username suggestions */ } - { _("Use suggested username") } {suggestion} - - )) } - - - } + + } + /> { isEditing && From 11db225c6472d4a3a2b37e3e2e0e563619ec0643 Mon Sep 17 00:00:00 2001 From: Balsa Asanovic Date: Mon, 26 Feb 2024 23:10:54 +0100 Subject: [PATCH 08/11] updated the algorithm for suggestion to be more readable --- web/src/components/users/utils.js | 47 ++++++++++++++++--------------- 1 file changed, 24 insertions(+), 23 deletions(-) diff --git a/web/src/components/users/utils.js b/web/src/components/users/utils.js index 546bf79912..ab982fba96 100644 --- a/web/src/components/users/utils.js +++ b/web/src/components/users/utils.js @@ -20,7 +20,7 @@ */ /** - * Method which generates username suggestions based on first and last name. + * Method which generates username suggestions based on given full name. * The method cleans the input name by removing non-alphanumeric characters (except spaces), * splits the name into parts, and then generates suggestions based on these parts. * @@ -37,33 +37,34 @@ const suggestUsernames = (fullName) => { .toLowerCase(); // Split the cleaned name into parts. - const nameParts = cleanedName.split(/\s+/); + const parts = cleanedName.split(/\s+/); + const suggestions = new Set(); - const suggestions = []; + const firstLetters = parts.map(p => p[0]).join(''); + const lastPosition = parts.length - 1; - nameParts.forEach((namePart, index) => { - if (index === 0) { - suggestions.push(namePart); - suggestions.push(namePart[0]); - suggestions.push(namePart[0]); - suggestions.push(namePart); - suggestions.push(namePart[0]); - suggestions.push(namePart); - } else { - if (index === 1) - suggestions[1] += namePart; - suggestions[2] += namePart; - suggestions[3] += namePart[0]; - if (index === nameParts.length - 1) - suggestions[4] += namePart; - else - suggestions[4] += namePart[0]; - suggestions[5] += namePart; - } + const [firstPart, ...allExceptFirst] = parts; + const [firstLetter, ...allExceptFirstLetter] = firstLetters; + const lastPart = parts[lastPosition]; + + // Just the first part of the name + suggestions.add(firstPart); + // The first letter of the first part plus all other parts + suggestions.add(firstLetter + allExceptFirst.join('')); + // The first part plus the first letters of all other parts + suggestions.add(firstPart + allExceptFirstLetter.join('')); + // The first letters except the last one plus the last part + suggestions.add(firstLetters.substring(0, lastPosition) + lastPart); + // All parts without spaces + suggestions.add(parts.join('')); + + // let's drop suggestions with less than 3 characters + suggestions.forEach(s => { + if (s.length < 3) suggestions.delete(s); }); // using Set object to remove duplicates, then converting back to array - return [...new Set(suggestions)]; + return [...suggestions]; }; export { From 3cbb67475e4a777440d442bc4e769cfc9d4405a8 Mon Sep 17 00:00:00 2001 From: Balsa Asanovic Date: Mon, 26 Feb 2024 23:11:22 +0100 Subject: [PATCH 09/11] updated tests for suggestions algorithm --- web/src/components/users/utils.test.js | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/web/src/components/users/utils.test.js b/web/src/components/users/utils.test.js index e3711388de..0b188a4537 100644 --- a/web/src/components/users/utils.test.js +++ b/web/src/components/users/utils.test.js @@ -23,7 +23,7 @@ import { suggestUsernames } from "./utils"; describe('suggestUsernames', () => { test('handles basic single name', () => { - expect(suggestUsernames('John')).toEqual(expect.arrayContaining(['john', 'j'])); + expect(suggestUsernames('John')).toEqual(expect.arrayContaining(['john'])); }); test('handles basic two-part name', () => { @@ -31,7 +31,7 @@ describe('suggestUsernames', () => { }); test('handles name with middle initial', () => { - expect(suggestUsernames('John Q. Doe')).toEqual(expect.arrayContaining(['john', 'jq', 'jqdoe', 'johnqd', 'johnqdoe'])); + expect(suggestUsernames('John Q. Doe')).toEqual(expect.arrayContaining(['john', 'jqdoe', 'johnqd', 'johnqdoe'])); }); test('normalizes accented characters', () => { @@ -47,7 +47,7 @@ describe('suggestUsernames', () => { }); test('handles long name with multiple parts', () => { - expect(suggestUsernames("Maria del Carmen Fernandez Vega")).toEqual(expect.arrayContaining(['maria', 'mdel', 'mdelcarmenfernandezvega', 'mariadcfv', 'mdcfvega', 'mariadelcarmenfernandezvega'])); + expect(suggestUsernames("Maria del Carmen Fernandez Vega")).toEqual(expect.arrayContaining(['maria', 'mdelcarmenfernandezvega', 'mariadcfv', 'mdcfvega', 'mariadelcarmenfernandezvega'])); }); test('handles empty or invalid input', () => { From 75b978130ea1d68d3d6f0a96234bdfaadb7fc1f5 Mon Sep 17 00:00:00 2001 From: Balsa Asanovic Date: Wed, 28 Feb 2024 23:27:44 +0100 Subject: [PATCH 10/11] logged changes --- web/package/cockpit-agama.changes | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/web/package/cockpit-agama.changes b/web/package/cockpit-agama.changes index 792f22ff75..7899366961 100644 --- a/web/package/cockpit-agama.changes +++ b/web/package/cockpit-agama.changes @@ -1,3 +1,9 @@ +------------------------------------------------------------------- +Wed Feb 28 22:26:23 UTC 2024 - Balsa Asanovic + +- Added auto suggestion of usernames during user creation based + on given full name. (gh#openSUSE/agama#1022). + ------------------------------------------------------------------- Mon Feb 26 20:46:45 UTC 2024 - Josef Reidinger From eb79c6f29163f1b0377fd56d2ed4c4a82c4b3c16 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?David=20D=C3=ADaz?= <1691872+dgdavid@users.noreply.github.com> Date: Thu, 29 Feb 2024 08:47:25 +0000 Subject: [PATCH 11/11] Disable cspell in test file It's a test that have a lot of unknown words because they are username suggestions. It makes sense to disable the spell checker in this case. --- web/src/components/users/utils.test.js | 2 ++ 1 file changed, 2 insertions(+) diff --git a/web/src/components/users/utils.test.js b/web/src/components/users/utils.test.js index 0b188a4537..1049e3b171 100644 --- a/web/src/components/users/utils.test.js +++ b/web/src/components/users/utils.test.js @@ -19,6 +19,8 @@ * find current contact information at www.suse.com. */ +/* cspell:disable */ + import { suggestUsernames } from "./utils"; describe('suggestUsernames', () => {