From e0c0eaa7d804e2ee489573c29d67b20b69cbeae6 Mon Sep 17 00:00:00 2001 From: Tomohisa Igarashi Date: Thu, 2 May 2024 22:09:23 -0400 Subject: [PATCH] fix: Add PasswordField to mask input value of the field with `format: password` Fixes: #285 Fixes: #503 --- .../src/components/Form/CustomAutoField.tsx | 6 + .../Form/customField/PasswordField.test.tsx | 50 ++++ .../Form/customField/PasswordField.tsx | 213 ++++++++++++++++++ 3 files changed, 269 insertions(+) create mode 100644 packages/ui/src/components/Form/customField/PasswordField.test.tsx create mode 100644 packages/ui/src/components/Form/customField/PasswordField.tsx diff --git a/packages/ui/src/components/Form/CustomAutoField.tsx b/packages/ui/src/components/Form/CustomAutoField.tsx index 73eba048f..daed960bc 100644 --- a/packages/ui/src/components/Form/CustomAutoField.tsx +++ b/packages/ui/src/components/Form/CustomAutoField.tsx @@ -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 @@ -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; diff --git a/packages/ui/src/components/Form/customField/PasswordField.test.tsx b/packages/ui/src/components/Form/customField/PasswordField.test.tsx new file mode 100644 index 000000000..48373bf5c --- /dev/null +++ b/packages/ui/src/components/Form/customField/PasswordField.test.tsx @@ -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( + + + + + , + ); + 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'); + }); +}); diff --git a/packages/ui/src/components/Form/customField/PasswordField.tsx b/packages/ui/src/components/Form/customField/PasswordField.tsx new file mode 100644 index 000000000..5d12f02d7 --- /dev/null +++ b/packages/ui/src/components/Form/customField/PasswordField.tsx @@ -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; + onChange: (value?: string) => void; + value?: string; + disabled?: boolean; + error?: boolean; + errorMessage?: string; + helperText?: string; + field?: { format: string }; +} & Omit; + +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(generatePassword()); + const [isAutocompleteOpen, setIsAutocompleteOpen] = useState(false); + const [passwordHidden, setPasswordHidden] = useState(true); + const searchInputRef = useRef(null); + const autocompleteRef = useRef(null); + + const handleOnChange = (_event: FormEvent, 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) => { + 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 | React.MouseEvent, + ) => { + 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 = ( +
+ + + + + + } + variant={props.error ? 'error' : 'default'} + > + {!props.error ? props.helperText : props.errorMessage} + + + + + + + + +
+ ); + + const autocomplete = ( + + + + } + onClick={(_e) => { + setGeneratedPassword(generatePassword()); + }} + actionId="redo" + aria-label="Generate a new suggested password" + /> + } + > + Use suggested password: {`${generatedPassword}`} + + + + + ); + + return wrapField( + props, + document.querySelector('#password-input') as HTMLDivElement} + />, + ); +}; + +export const PasswordField = connectField(PasswordFieldComponent);