Skip to content

Commit

Permalink
fix: Add PasswordField to mask input value of the field with `format:…
Browse files Browse the repository at this point in the history
… password`

Fixes: #285
Fixes: #503
  • Loading branch information
igarashitm committed May 3, 2024
1 parent 062330e commit e0c0eaa
Show file tree
Hide file tree
Showing 3 changed files with 269 additions and 0 deletions.
6 changes: 6 additions & 0 deletions packages/ui/src/components/Form/CustomAutoField.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import { TypeaheadField } from './customField/TypeaheadField';
import { ExpressionAwareNestField } from './expression/ExpressionAwareNestField';
import { ExpressionField } from './expression/ExpressionField';
import { PropertiesField } from './properties/PropertiesField';
import { PasswordField } from './customField/PasswordField';

/**
* Custom AutoField that supports all the fields from Uniforms PatternFly
Expand Down Expand Up @@ -38,6 +39,11 @@ export const CustomAutoField = createAutoField((props) => {
return ExpressionAwareNestField;
}

// eslint-disable-next-line @typescript-eslint/no-explicit-any
if ((props.field as any).format === 'password') {
return PasswordField;
}

switch (props.fieldType) {
case Array:
return ListField;
Expand Down
50 changes: 50 additions & 0 deletions packages/ui/src/components/Form/customField/PasswordField.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
import { AutoField } from '@kaoto-next/uniforms-patternfly';
import { CustomAutoFieldDetector } from '../CustomAutoField';
import { AutoForm } from 'uniforms';
import { PasswordField } from './PasswordField';
import { fireEvent, render, screen } from '@testing-library/react';
import { SchemaService } from '../schema.service';
import { act } from 'react-dom/test-utils';

describe('PasswordField', () => {
const mockSchema = {
title: 'Test',
type: 'object',
additionalProperties: false,
properties: {
secret: {
title: 'Secret',
group: 'secret',
format: 'password',
description: 'The secret',
type: 'string',
},
},
};
const mockOnChange = jest.fn();
const schemaService = new SchemaService();
const schemaBridge = schemaService.getSchemaBridge(mockSchema);

beforeEach(() => {
mockOnChange.mockClear();
});

it('should render the component', async () => {
render(
<AutoField.componentDetectorContext.Provider value={CustomAutoFieldDetector}>
<AutoForm schema={schemaBridge!}>
<PasswordField name="secret" />
</AutoForm>
</AutoField.componentDetectorContext.Provider>,
);
let input = screen.getByTestId('password-field');
expect(input).toBeInTheDocument();
expect(input.getAttribute('type')).toEqual('password');
const toggle = screen.getByTestId('password-show-hide-button');
await act(async () => {
fireEvent.click(toggle);
});
input = screen.getByTestId('password-field');
expect(input.getAttribute('type')).toEqual('text');
});
});
213 changes: 213 additions & 0 deletions packages/ui/src/components/Form/customField/PasswordField.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,213 @@
// eslint-disable-next-line @typescript-eslint/no-explicit-any
import { connectField, filterDOMProps } from 'uniforms';
import {
Button,
FormHelperText,
HelperText,
HelperTextItem,
InputGroup,
InputGroupItem,
Menu,
MenuContent,
MenuItem,
MenuItemAction,
MenuList,
Popper,
TextInput,
TextInputProps,
} from '@patternfly/react-core';
import { ExclamationCircleIcon, EyeIcon, EyeSlashIcon, RedoIcon } from '@patternfly/react-icons';
import { wrapField } from '@kaoto-next/uniforms-patternfly';
import { FormEvent, Ref, useCallback, useEffect, useRef, useState } from 'react';

type PasswordFieldProps = {
id: string;
decimal?: boolean;
inputRef?: Ref<HTMLInputElement>;
onChange: (value?: string) => void;
value?: string;
disabled?: boolean;
error?: boolean;
errorMessage?: string;
helperText?: string;
field?: { format: string };
} & Omit<TextInputProps, 'isDisabled'>;

const PasswordFieldComponent = ({ onChange, ...props }: PasswordFieldProps) => {
const generatePassword = () => {
const length = 12;
const charset = 'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789!@%()_-=+';
let retVal = '';
for (let i = 0, n = charset.length; i < length; ++i) {
retVal += charset.charAt(Math.floor(Math.random() * n));
}
return retVal;
};
const [generatedPassword, setGeneratedPassword] = useState<string>(generatePassword());
const [isAutocompleteOpen, setIsAutocompleteOpen] = useState<boolean>(false);
const [passwordHidden, setPasswordHidden] = useState<boolean>(true);
const searchInputRef = useRef<HTMLDivElement>(null);
const autocompleteRef = useRef<HTMLDivElement>(null);

const handleOnChange = (_event: FormEvent<HTMLInputElement>, newValue: string) => {
if (searchInputRef && searchInputRef.current && searchInputRef.current.contains(document.activeElement)) {
setIsAutocompleteOpen(true);
} else {
setIsAutocompleteOpen(false);
}
onChange(newValue);
};

// Whenever an autocomplete option is selected, set the search input value, close the menu, and put the browser
// focus back on the search input
const onSelect = (event?: React.MouseEvent) => {
event?.stopPropagation();
onChange(generatedPassword);
setIsAutocompleteOpen(false);
searchInputRef.current?.focus();
};

const handleMenuKeys = useCallback(
// eslint-disable-next-line @typescript-eslint/no-explicit-any
(event: KeyboardEvent | React.KeyboardEvent<any>) => {
if (!(isAutocompleteOpen && searchInputRef.current && searchInputRef.current.contains(event.target as Node))) {
return;
}
// the escape key closes the autocomplete menu and keeps the focus on the search input.
if (event.key === 'Escape') {
setIsAutocompleteOpen(false);
searchInputRef.current.focus();
// the up and down arrow keys move browser focus into the autocomplete menu
} else if (event.key === 'ArrowDown' || event.key === 'ArrowUp') {
const firstElement = autocompleteRef.current?.querySelector('li > button:not(:disabled)') as HTMLButtonElement;
firstElement && firstElement.focus();
event.preventDefault(); // by default, the up and down arrow keys scroll the window
}
// If the autocomplete is open and the browser focus is in the autocomplete menu
// hitting tab will close the autocomplete and put browser focus back on the search input.
else if (autocompleteRef.current?.contains(event.target as Node) && event.key === 'Tab') {
event.preventDefault();
setIsAutocompleteOpen(false);
searchInputRef.current.focus();
}
},
[isAutocompleteOpen],
);

// The autocomplete menu should close if the user clicks outside the menu.
const handleClickOutside = useCallback(
(
// eslint-disable-next-line @typescript-eslint/no-explicit-any
event: MouseEvent | TouchEvent | KeyboardEvent | React.KeyboardEvent<any> | React.MouseEvent<HTMLButtonElement>,
) => {
if (
isAutocompleteOpen &&
autocompleteRef &&
autocompleteRef.current &&
!searchInputRef.current?.contains(event.target as Node)
) {
setIsAutocompleteOpen(false);
}
if (
!isAutocompleteOpen &&
searchInputRef &&
searchInputRef.current &&
searchInputRef.current.contains(event.target as Node)
) {
setIsAutocompleteOpen(true);
}
},
[isAutocompleteOpen],
);

useEffect(() => {
window.addEventListener('keydown', handleMenuKeys);
window.addEventListener('click', handleClickOutside);
return () => {
window.removeEventListener('keydown', handleMenuKeys);
window.removeEventListener('click', handleClickOutside);
};
}, [handleClickOutside, handleMenuKeys, isAutocompleteOpen]);

const textInput = (
<div ref={searchInputRef} id="password-input">
<InputGroup>
<InputGroupItem isFill>
<TextInput
aria-label={'uniforms text field'}
data-testid={'password-field'}
name={props.name}
isDisabled={props.disabled}
validated={props.error ? 'error' : 'default'}
onChange={handleOnChange}
placeholder={props.placeholder}
ref={props.inputRef}
type={passwordHidden ? 'password' : 'text'}
value={props.value}
{...filterDOMProps(props)}
/>
<FormHelperText>
<HelperText>
<HelperTextItem
icon={props.error && <ExclamationCircleIcon />}
variant={props.error ? 'error' : 'default'}
>
{!props.error ? props.helperText : props.errorMessage}
</HelperTextItem>
</HelperText>
</FormHelperText>
</InputGroupItem>
<InputGroupItem>
<Button
data-testid={'password-show-hide-button'}
variant="control"
onClick={() => setPasswordHidden(!passwordHidden)}
aria-label={passwordHidden ? 'Show' : 'Hide'}
>
{passwordHidden ? <EyeIcon /> : <EyeSlashIcon />}
</Button>
</InputGroupItem>
</InputGroup>
</div>
);

const autocomplete = (
<Menu ref={autocompleteRef} onSelect={onSelect}>
<MenuContent>
<MenuList>
<MenuItem
itemId={0}
actions={
<MenuItemAction
icon={<RedoIcon aria-hidden />}
onClick={(_e) => {
setGeneratedPassword(generatePassword());
}}
actionId="redo"
aria-label="Generate a new suggested password"
/>
}
>
Use suggested password: <b>{`${generatedPassword}`}</b>
</MenuItem>
</MenuList>
</MenuContent>
</Menu>
);

return wrapField(
props,
<Popper
trigger={textInput}
triggerRef={searchInputRef}
popper={autocomplete}
popperRef={autocompleteRef}
isVisible={isAutocompleteOpen}
enableFlip={false}
// append the autocomplete menu to the search input in the DOM for the sake of the keyboard navigation experience
appendTo={() => document.querySelector('#password-input') as HTMLDivElement}
/>,
);
};

export const PasswordField = connectField(PasswordFieldComponent);

0 comments on commit e0c0eaa

Please sign in to comment.