Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Auto suggest username #1022

Merged
merged 16 commits into from
Feb 29, 2024
Merged
Show file tree
Hide file tree
Changes from 10 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
10 changes: 10 additions & 0 deletions web/src/assets/styles/app.scss
Original file line number Diff line number Diff line change
Expand Up @@ -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%;
}
39 changes: 38 additions & 1 deletion web/src/components/users/FirstUser.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -32,12 +32,18 @@ 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 { suggestUsernames } from '~/components/users/utils';

const UserNotDefined = ({ actionCb }) => {
return (
<div className="stack">
Expand Down Expand Up @@ -97,6 +103,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 => {
Expand Down Expand Up @@ -210,7 +218,14 @@ export default function FirstUser() {
/>
</FormGroup>

<FormGroup fieldId="userName" label={_("Username")} isRequired>
<FormGroup
className="first-username-wrapper"
fieldId="userName"
label={_("Username")}
isRequired
onFocus={() => setShowSuggestions(true)}
onBlur={() => !insideDropDown && setShowSuggestions(false)}
>
<TextInput
id="userName"
name="userName"
Expand All @@ -220,6 +235,28 @@ export default function FirstUser() {
isRequired
onChange={handleInputChange}
/>
{ showSuggestions && formValues.fullName && !formValues.userName &&
<Menu
aria-label={_("Username suggestion dropdown")}
className="first-username-dropdown"
onMouseEnter={() => setInsideDropDown(true)}
onMouseLeave={() => setInsideDropDown(false)}
dgdavid marked this conversation as resolved.
Show resolved Hide resolved
>
<MenuContent>
<MenuList>
{ suggestUsernames(formValues.fullName).map((suggestion, index) => (
<MenuItem
key={index}
itemId={index}
onClick={() => { setInsideDropDown(false); setFormValues({ ...formValues, userName: suggestion }) }}
>
{ /* TRANSLATORS: dropdown username suggestions */ }
{ _("Use suggested username") } <b>{suggestion}</b>
dgdavid marked this conversation as resolved.
Show resolved Hide resolved
</MenuItem>
)) }
</MenuList>
</MenuContent>
</Menu> }
dgdavid marked this conversation as resolved.
Show resolved Hide resolved
</FormGroup>

{ isEditing &&
Expand Down
71 changes: 71 additions & 0 deletions web/src/components/users/utils.js
Original file line number Diff line number Diff line change
@@ -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.
dgdavid marked this conversation as resolved.
Show resolved Hide resolved
* 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;
}
});
dgdavid marked this conversation as resolved.
Show resolved Hide resolved

// using Set object to remove duplicates, then converting back to array
return [...new Set(suggestions)];
dgdavid marked this conversation as resolved.
Show resolved Hide resolved
};

export {
suggestUsernames
};
60 changes: 60 additions & 0 deletions web/src/components/users/utils.test.js
Original file line number Diff line number Diff line change
@@ -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";
dgdavid marked this conversation as resolved.
Show resolved Hide resolved
dgdavid marked this conversation as resolved.
Show resolved Hide resolved

describe('suggestUsernames', () => {
test('handles basic single name', () => {
expect(suggestUsernames('John')).toEqual(expect.arrayContaining(['john', 'j']));
dgdavid marked this conversation as resolved.
Show resolved Hide resolved
});

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']));
dgdavid marked this conversation as resolved.
Show resolved Hide resolved
});

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']));
dgdavid marked this conversation as resolved.
Show resolved Hide resolved
});

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']));
});
});
Loading