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 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%; +} diff --git a/web/src/components/users/FirstUser.jsx b/web/src/components/users/FirstUser.jsx index ace01e94d2..43138b9c92 100644 --- a/web/src/components/users/FirstUser.jsx +++ b/web/src/components/users/FirstUser.jsx @@ -32,11 +32,17 @@ import { FormGroup, TextInput, Skeleton, + Menu, + MenuContent, + MenuList, + MenuItem } from "@patternfly/react-core"; 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'; const UserNotDefined = ({ actionCb }) => { return ( @@ -76,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'; @@ -97,6 +129,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 => { @@ -185,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 ( @@ -210,7 +250,14 @@ export default function FirstUser() { /> - + setShowSuggestions(true)} + onBlur={() => !insideDropDown && setShowSuggestions(false)} + > + + } + /> { isEditing && diff --git a/web/src/components/users/utils.js b/web/src/components/users/utils.js new file mode 100644 index 0000000000..ab982fba96 --- /dev/null +++ b/web/src/components/users/utils.js @@ -0,0 +1,72 @@ +/* + * 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 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. + * + * @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 parts = cleanedName.split(/\s+/); + const suggestions = new Set(); + + const firstLetters = parts.map(p => p[0]).join(''); + const lastPosition = parts.length - 1; + + 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 [...suggestions]; +}; + +export { + suggestUsernames +}; diff --git a/web/src/components/users/utils.test.js b/web/src/components/users/utils.test.js new file mode 100644 index 0000000000..1049e3b171 --- /dev/null +++ b/web/src/components/users/utils.test.js @@ -0,0 +1,62 @@ +/* + * 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. + */ + +/* cspell:disable */ + +import { suggestUsernames } from "./utils"; + +describe('suggestUsernames', () => { + test('handles basic single name', () => { + expect(suggestUsernames('John')).toEqual(expect.arrayContaining(['john'])); + }); + + 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', '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', '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'])); + }); +});