From fe4261d7b6a6563d811fc4a4c92a3fdb0979fa15 Mon Sep 17 00:00:00 2001 From: mmelko Date: Tue, 17 Mar 2026 16:09:17 +0100 Subject: [PATCH 1/2] feat(Suggestions): Centralize suggestion menu state in SuggestionContext Lift the local visibility state (isVisible/setIsVisible) from the useSuggestions hook into SuggestionContext so only one suggestion menu can be open at a time across all fields. - Add currentOpenMenu/setCurrentOpenMenu to SuggestionContext - Replace local useState with context-driven visibility using unique menuId (useId) - Change useSuggestions return type from ReactNode to { suggestionsMenu, openSuggestions } - Update all field consumers and tests to use the new return shape --- src/form/KeyValue/KeyValueField.test.tsx | 10 ++- src/form/KeyValue/KeyValueField.tsx | 4 +- src/form/fields/PasswordField.test.tsx | 18 ++++- src/form/fields/PasswordField.tsx | 4 +- src/form/fields/StringField.test.tsx | 18 ++++- src/form/fields/StringField.tsx | 4 +- src/form/fields/TextAreaField.test.tsx | 10 ++- src/form/fields/TextAreaField.tsx | 4 +- src/form/hooks/suggestions.test.tsx | 71 ++++++++++-------- src/form/hooks/suggestions.tsx | 73 ++++++++++++++----- .../providers/SuggestionRegistryProvider.tsx | 18 ++++- 11 files changed, 168 insertions(+), 66 deletions(-) diff --git a/src/form/KeyValue/KeyValueField.test.tsx b/src/form/KeyValue/KeyValueField.test.tsx index 2977978..c4f8d77 100644 --- a/src/form/KeyValue/KeyValueField.test.tsx +++ b/src/form/KeyValue/KeyValueField.test.tsx @@ -3,7 +3,10 @@ import { KeyValueField } from './KeyValueField'; // Mock useSuggestions to control its output jest.mock('../hooks/suggestions', () => ({ - useSuggestions: jest.fn(() => null), + useSuggestions: jest.fn(() => ({ + suggestionsMenu: null, + openSuggestions: jest.fn(), + })), })); describe('KeyValueField', () => { @@ -75,7 +78,10 @@ describe('KeyValueField', () => { const SuggestionsMenu = () =>
Suggestions
; const { useSuggestions } = jest.requireMock('../hooks/suggestions'); - useSuggestions.mockImplementation(() => ); + useSuggestions.mockImplementation(() => ({ + suggestionsMenu: , + openSuggestions: jest.fn(), + })); const { getByTestId } = render(); const input = getByTestId('keyvalue-input'); diff --git a/src/form/KeyValue/KeyValueField.tsx b/src/form/KeyValue/KeyValueField.tsx index f1a2aab..b36cbe6 100644 --- a/src/form/KeyValue/KeyValueField.tsx +++ b/src/form/KeyValue/KeyValueField.tsx @@ -22,7 +22,7 @@ export const KeyValueField = forwardRef( useImperativeHandle(ref, () => inputRef.current as HTMLInputElement); - const suggestions = useSuggestions({ + const { suggestionsMenu } = useSuggestions({ propName: name, schema: STRING_SCHEMA, inputRef, @@ -47,7 +47,7 @@ export const KeyValueField = forwardRef( value={value} /> - {suggestions} + {suggestionsMenu} ); }, diff --git a/src/form/fields/PasswordField.test.tsx b/src/form/fields/PasswordField.test.tsx index c5e9627..1564a1f 100644 --- a/src/form/fields/PasswordField.test.tsx +++ b/src/form/fields/PasswordField.test.tsx @@ -1,10 +1,26 @@ import { act, fireEvent, render, waitFor, within } from '@testing-library/react'; +import { ReactNode, useState } from 'react'; import { ModelContextProvider } from '../providers/ModelProvider'; import { SchemaProvider } from '../providers/SchemaProvider'; import { SuggestionContext } from '../providers/SuggestionRegistryProvider'; import { ROOT_PATH } from '../utils'; import { PasswordField } from './PasswordField'; +const StatefulSuggestionProvider = ({ + children, + getProviders, +}: { + children: ReactNode; + getProviders: jest.Mock; +}) => { + const [currentOpenMenu, setCurrentOpenMenu] = useState(null); + return ( + + {children} + + ); +}; + describe('PasswordField', () => { const mockSuggestionProvider = { id: 'test-provider', @@ -19,7 +35,7 @@ describe('PasswordField', () => { const renderWithSuggestions = (children: React.ReactNode) => { return render( - {children}, + {children}, ); }; diff --git a/src/form/fields/PasswordField.tsx b/src/form/fields/PasswordField.tsx index 2dd2bad..7e1e662 100644 --- a/src/form/fields/PasswordField.tsx +++ b/src/form/fields/PasswordField.tsx @@ -51,7 +51,7 @@ export const PasswordField: FunctionComponent = ({ propName, require [onFieldChange], ); - const suggestions = useSuggestions({ + const { suggestionsMenu } = useSuggestions({ propName, schema, inputRef, @@ -86,7 +86,7 @@ export const PasswordField: FunctionComponent = ({ propName, require ref={inputRef} /> - {suggestions} + {suggestionsMenu} @@ -47,7 +62,7 @@ describe('useSuggestions', () => { const renderWithContext = (children: ReactNode) => { return render( - {children}, + {children}, ); }; @@ -75,35 +90,29 @@ describe('useSuggestions', () => { const inputElement = document.createElement('input'); const inputRef = { current: inputElement }; - let result: ReactNode; - - await act(async () => { - const hookResult = renderHook( - () => - useSuggestions({ - inputRef, - propName: 'testProp', - schema: { type: 'string' }, - value: '', - setValue: setValueMock, - }), - { - wrapper: ({ children }) => ( - - {children} - - ), - }, - ); - - result = hookResult.result.current; - }); + const { result } = renderHook( + () => + useSuggestions({ + inputRef, + propName: 'testProp', + schema: { type: 'string' }, + value: '', + setValue: setValueMock, + }), + { + wrapper: ({ children }) => ( + + {children} + + ), + }, + ); await waitFor(() => { expect(mockProvider.getSuggestions).not.toHaveBeenCalled(); }); - expect(result).toBeDefined(); + expect(result.current.suggestionsMenu).toBeDefined(); }); it('should handle setValue being undefined', () => { @@ -116,7 +125,7 @@ describe('useSuggestions', () => { const { result } = renderHook(() => useSuggestions(propsWithoutSetValue), { wrapper: ({ children }) => ( - {children} + {children} ), }); @@ -139,7 +148,7 @@ describe('useSuggestions', () => { }), { wrapper: ({ children }) => ( - + {children} ), @@ -175,7 +184,7 @@ describe('useSuggestions', () => { }), { wrapper: ({ children }) => ( - + {children} ), @@ -205,7 +214,7 @@ describe('useSuggestions', () => { const { rerender } = renderHook(() => useSuggestions(props), { wrapper: ({ children }) => ( - {children} + {children} ), }); diff --git a/src/form/hooks/suggestions.tsx b/src/form/hooks/suggestions.tsx index 1f1829f..c5230c1 100644 --- a/src/form/hooks/suggestions.tsx +++ b/src/form/hooks/suggestions.tsx @@ -1,6 +1,6 @@ import { Menu, MenuContent, MenuItem, MenuList, Popper, SearchInput } from '@patternfly/react-core'; import { JSONSchema4 } from 'json-schema'; -import { ReactNode, RefObject, useCallback, useContext, useEffect, useMemo, useRef, useState } from 'react'; +import { ReactNode, RefObject, useCallback, useContext, useEffect, useId, useMemo, useRef, useState } from 'react'; import { GroupedSuggestions, Suggestion, SuggestionProvider } from '../models/suggestions'; import { SuggestionContext } from '../providers'; import { applySuggestion } from '../utils/apply-suggestion'; @@ -13,14 +13,28 @@ type UseSuggestionsProps = { value: string | number; setValue?: (value: string) => void; }; -export const useSuggestions = ({ propName, schema, inputRef, value, setValue }: UseSuggestionsProps): ReactNode => { - const [isVisible, setIsVisible] = useState(false); + +type UseSuggestionsReturn = { + suggestionsMenu: ReactNode; + openSuggestions: () => void; +}; + +export const useSuggestions = ({ + propName, + schema, + inputRef, + value, + setValue, +}: UseSuggestionsProps): UseSuggestionsReturn => { + const menuId = `${propName}-${useId()}`; const [searchValue, setSearchValue] = useState(''); const [groupedSuggestions, setGroupedSuggestions] = useState({ root: [] }); const menuRef = useRef(null); const firstElementRef = useRef(null); const searchInputRef = useRef(null); - const { getProviders } = useContext(SuggestionContext); + const { getProviders, currentOpenMenu, setCurrentOpenMenu } = useContext(SuggestionContext); + + const isVisible = currentOpenMenu === menuId; const suggestionProviders: SuggestionProvider[] = useMemo( () => getProviders(propName, schema), @@ -30,22 +44,22 @@ export const useSuggestions = ({ propName, schema, inputRef, value, setValue }: const onEscapeKey = useCallback( (event: React.KeyboardEvent | KeyboardEvent) => { event.preventDefault(); - setIsVisible(false); + setCurrentOpenMenu(null); requestAnimationFrame(() => { inputRef.current?.focus(); }); }, - [inputRef], + [inputRef, setCurrentOpenMenu], ); const handleInputKeyDown = useCallback( (event: Event) => { if (!(event instanceof KeyboardEvent)) return; -if ((event.ctrlKey && event.code === 'Space') || (event.altKey && event.code === 'Escape')) { + if ((event.ctrlKey && event.code === 'Space') || (event.altKey && event.code === 'Escape')) { event.preventDefault(); setSearchValue(''); - setIsVisible(true); + setCurrentOpenMenu(menuId); requestAnimationFrame(() => { firstElementRef.current?.focus(); firstElementRef.current?.scrollIntoView({ behavior: 'smooth', block: 'nearest', inline: 'nearest' }); @@ -54,20 +68,20 @@ if ((event.ctrlKey && event.code === 'Space') || (event.altKey && event.code === onEscapeKey(event); } }, - [onEscapeKey], + [onEscapeKey, setCurrentOpenMenu, menuId], ); const getHandleOnClick = useCallback( (inputValue: string | number, suggestion: Suggestion) => () => { const { newValue, cursorPosition } = applySuggestion(suggestion, inputValue, inputRef.current?.selectionStart); - setIsVisible(false); + setCurrentOpenMenu(null); setValue?.(newValue); inputRef.current?.focus(); inputRef.current?.setSelectionRange(cursorPosition, cursorPosition); }, - [inputRef, setValue], + [inputRef, setValue, setCurrentOpenMenu], ); const getHandleMenuKeyDown = useCallback( @@ -129,6 +143,28 @@ if ((event.ctrlKey && event.code === 'Space') || (event.altKey && event.code === }; }, [suggestionProviders, value, propName, inputRef, isVisible, searchValue]); + const focusOnSearchInput = useCallback(() => { + searchInputRef.current?.focus(); + }, []); + + const handleOnSearchChange = useCallback((_event: unknown, value: string) => { + setSearchValue(value); + }, []); + + const openSuggestions = useCallback(() => { + setSearchValue(''); + setCurrentOpenMenu((prev) => { + const newValue = prev === menuId ? null : menuId; + if (newValue) { + requestAnimationFrame(() => { + firstElementRef.current?.focus(); + firstElementRef.current?.scrollIntoView({ behavior: 'smooth', block: 'nearest', inline: 'nearest' }); + }); + } + return newValue; + }); + }, [menuId, setCurrentOpenMenu]); + /** Register keyboard bindings */ useEffect(() => { const input = inputRef.current; @@ -156,15 +192,7 @@ if ((event.ctrlKey && event.code === 'Space') || (event.altKey && event.code === }; }, [handleInputKeyDown, inputRef]); - const focusOnSearchInput = useCallback(() => { - searchInputRef.current?.focus(); - }, []); - - const handleOnSearchChange = useCallback((_event: unknown, value: string) => { - setSearchValue(value); - }, []); - - return ( + const suggestionsMenu = ( ); + + return { + suggestionsMenu, + openSuggestions, + }; }; diff --git a/src/form/providers/SuggestionRegistryProvider.tsx b/src/form/providers/SuggestionRegistryProvider.tsx index 2692f2a..c2d7d3d 100644 --- a/src/form/providers/SuggestionRegistryProvider.tsx +++ b/src/form/providers/SuggestionRegistryProvider.tsx @@ -6,17 +6,24 @@ interface SuggestionContextApi { registerProvider: (provider: SuggestionProvider) => void; unregisterProvider: (id: string) => void; } +type MenuStateSetter = (menuId: string | null | ((prev: string | null) => string | null)) => void; + interface SuggestionContext { getProviders: (propertyName: string, schema: JSONSchema4) => SuggestionProvider[]; + currentOpenMenu: string | null; + setCurrentOpenMenu: MenuStateSetter; } export const SuggestionContextApi = createContext(undefined); export const SuggestionContext = createContext({ getProviders: () => [], + currentOpenMenu: null, + setCurrentOpenMenu: () => {}, }); export const SuggestionRegistryProvider: FunctionComponent = ({ children }) => { const [providers, setProviders] = useState([]); + const [currentOpenMenu, setCurrentOpenMenu] = useState(null); const registerProvider = useCallback((provider: SuggestionProvider) => { setProviders((prevProviders) => { @@ -48,9 +55,18 @@ export const SuggestionRegistryProvider: FunctionComponent = [registerProvider, unregisterProvider], ); + const contextValue = useMemo( + () => ({ + getProviders, + currentOpenMenu, + setCurrentOpenMenu, + }), + [getProviders, currentOpenMenu], + ); + return ( - {children} + {children} ); }; From 8c01bb16ebe90c5108f1d608af6ce30824aee554 Mon Sep 17 00:00:00 2001 From: mmelko Date: Tue, 17 Mar 2026 16:13:03 +0100 Subject: [PATCH 2/2] feat(Suggestions): Add lightbulb button and double-click to open suggestions menu - Add SuggestionsButton component with lightbulb icon, shown on hover/focus - Add double-click event listener on input fields to toggle suggestions - Add CSS rules to show/hide the button on parent hover - Update StringField, PasswordField, and TextAreaField to include the button --- src/form/KaotoForm.scss | 16 ++++++++++ .../__snapshots__/KaotoForm.test.tsx.snap | 30 ++++++++++++++++++- src/form/fields/FieldActions.test.tsx | 2 +- src/form/fields/PasswordField.tsx | 5 +++- src/form/fields/StringField.tsx | 5 +++- src/form/fields/SuggestionsButton.tsx | 20 +++++++++++++ src/form/fields/TextAreaField.tsx | 5 +++- .../__snapshots__/PasswordField.test.tsx.snap | 30 ++++++++++++++++++- .../__snapshots__/StringField.test.tsx.snap | 28 +++++++++++++++++ .../__snapshots__/TextAreaField.test.tsx.snap | 30 ++++++++++++++++++- src/form/hooks/suggestions.tsx | 11 +++++-- 11 files changed, 172 insertions(+), 10 deletions(-) create mode 100644 src/form/fields/SuggestionsButton.tsx diff --git a/src/form/KaotoForm.scss b/src/form/KaotoForm.scss index 60eda3b..c5bd671 100644 --- a/src/form/KaotoForm.scss +++ b/src/form/KaotoForm.scss @@ -42,6 +42,22 @@ justify-content: space-between; } + &__suggestions-button { + display: none; + } + + /* stylelint-disable-next-line selector-class-pattern */ + .pf-v6-c-text-input-group:hover &__suggestions-button, + /* stylelint-disable-next-line selector-class-pattern */ + .pf-v6-c-text-input-group:focus-within &__suggestions-button, + /* stylelint-disable-next-line selector-class-pattern */ + .pf-v6-c-input-group:hover &__suggestions-button, + /* stylelint-disable-next-line selector-class-pattern */ + .pf-v6-c-input-group:focus-within &__suggestions-button, + &__suggestions-button:focus { + display: inline-flex; + } + &__empty { display: none; } diff --git a/src/form/__snapshots__/KaotoForm.test.tsx.snap b/src/form/__snapshots__/KaotoForm.test.tsx.snap index d3872ef..6a5abf0 100644 --- a/src/form/__snapshots__/KaotoForm.test.tsx.snap +++ b/src/form/__snapshots__/KaotoForm.test.tsx.snap @@ -47,7 +47,7 @@ exports[`KaotoForm should validate the model 1`] = ` +