Skip to content

Commit

Permalink
Auto suggest username (#1022)
Browse files Browse the repository at this point in the history
## Problem

As described in issue #112 this PR addresses the suggestion of username
for user account creation based on full name input.

## Solution

Usernames are suggested based on the given full name in the following
manner:

- Just the first word of the given name
- The first letter of the first word + all other full words
- The full first word + first letters of all other words
- First letters of all words except last + full last word
- All words of a name joined without white spaces

Also, all suggestions with less than 3 characters are filtered out.

This PR fixes #112

## Testing

- Manually tested
- Added unit tests in `web/src/components/users/utils.test.js`

## Screenshots


![image](https://github.com/openSUSE/agama/assets/112288843/e018d7f5-ef5a-4709-9154-6b05a63e481b)
  • Loading branch information
dgdavid authored Feb 29, 2024
2 parents 580572b + eb79c6f commit 8946391
Show file tree
Hide file tree
Showing 5 changed files with 209 additions and 2 deletions.
6 changes: 6 additions & 0 deletions web/package/cockpit-agama.changes
Original file line number Diff line number Diff line change
@@ -1,3 +1,9 @@
-------------------------------------------------------------------
Wed Feb 28 22:26:23 UTC 2024 - Balsa Asanovic <[email protected]>

- 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 <[email protected]>

Expand Down
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%;
}
61 changes: 59 additions & 2 deletions web/src/components/users/FirstUser.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 (
Expand Down Expand Up @@ -76,6 +82,32 @@ const UserData = ({ user, actions }) => {
);
};

const UsernameSuggestions = ({ entries, onSelect, setInsideDropDown }) => {
return (
<Menu
aria-label={_("Username suggestion dropdown")}
className="first-username-dropdown"
onMouseEnter={() => setInsideDropDown(true)}
onMouseLeave={() => setInsideDropDown(false)}
>
<MenuContent>
<MenuList>
{entries.map((suggestion, index) => (
<MenuItem
key={index}
itemId={index}
onClick={() => onSelect(suggestion)}
>
{ /* TRANSLATORS: dropdown username suggestions */}
{_("Use suggested username")} <b>{suggestion}</b>
</MenuItem>
))}
</MenuList>
</MenuContent>
</Menu>
);
};

const CREATE_MODE = 'create';
const EDIT_MODE = 'edit';

Expand All @@ -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 => {
Expand Down Expand Up @@ -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 <Skeleton />;

return (
Expand All @@ -210,7 +250,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 +267,16 @@ export default function FirstUser() {
isRequired
onChange={handleInputChange}
/>
<If
condition={displaySuggestions}
then={
<UsernameSuggestions
entries={suggestUsernames(formValues.fullName)}
onSelect={onSuggestionSelected}
setInsideDropDown={setInsideDropDown}
/>
}
/>
</FormGroup>

{ isEditing &&
Expand Down
72 changes: 72 additions & 0 deletions web/src/components/users/utils.js
Original file line number Diff line number Diff line change
@@ -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
};
62 changes: 62 additions & 0 deletions web/src/components/users/utils.test.js
Original file line number Diff line number Diff line change
@@ -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']));
});
});

0 comments on commit 8946391

Please sign in to comment.