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 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
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 <balsaasanovic95@gmail.com>

- 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 <jreidinger@suse.com>

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

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