Fix focus in front components inputs (#20961)
Fixes https://github.com/twentyhq/twenty/issues/20714 Fixes keyboard hotkey conflicts when typing inside `<input>` / `<textarea>` elements rendered by Front Components. Editable fields rendered through the component renderer now properly push/pop a focus item onto Twenty's focus stack, disabling global keyboard hotkeys while the user is typing. ## Before https://github.com/user-attachments/assets/2003c2cb-2698-480f-aedf-bb2f30396572 ## After https://github.com/user-attachments/assets/2c7c6cb0-ecd7-4557-a77b-4d1f264345f0
This commit is contained in:
+21
@@ -0,0 +1,21 @@
|
||||
import { useEffect } from 'react';
|
||||
|
||||
import { useRemoveFocusItemFromFocusStackById } from '@/ui/utilities/focus/hooks/useRemoveFocusItemFromFocusStackById';
|
||||
|
||||
type FrontComponentInputFocusCleanupEffectProps = {
|
||||
focusId: string;
|
||||
};
|
||||
|
||||
export const FrontComponentInputFocusCleanupEffect = ({
|
||||
focusId,
|
||||
}: FrontComponentInputFocusCleanupEffectProps) => {
|
||||
const { removeFocusItemFromFocusStackById } =
|
||||
useRemoveFocusItemFromFocusStackById();
|
||||
|
||||
useEffect(
|
||||
() => () => removeFocusItemFromFocusStackById({ focusId }),
|
||||
[focusId, removeFocusItemFromFocusStackById],
|
||||
);
|
||||
|
||||
return null;
|
||||
};
|
||||
+37
-1
@@ -1,4 +1,11 @@
|
||||
import { useCallback } from 'react';
|
||||
import { FrontComponentInputFocusContext } from 'twenty-front-component-renderer';
|
||||
|
||||
import { FrontComponentInputFocusCleanupEffect } from '@/front-components/components/FrontComponentInputFocusCleanupEffect';
|
||||
import { FrontComponentInstanceContext } from '@/front-components/states/contexts/FrontComponentInstanceContext';
|
||||
import { usePushFocusItemToFocusStack } from '@/ui/utilities/focus/hooks/usePushFocusItemToFocusStack';
|
||||
import { useRemoveFocusItemFromFocusStackById } from '@/ui/utilities/focus/hooks/useRemoveFocusItemFromFocusStackById';
|
||||
import { FocusComponentType } from '@/ui/utilities/focus/types/FocusComponentType';
|
||||
|
||||
type FrontComponentRendererProviderProps = {
|
||||
frontComponentId: string;
|
||||
@@ -9,11 +16,40 @@ export const FrontComponentRendererProvider = ({
|
||||
frontComponentId,
|
||||
children,
|
||||
}: FrontComponentRendererProviderProps) => {
|
||||
const focusId = `front-component-input-focus-${frontComponentId}`;
|
||||
|
||||
const { pushFocusItemToFocusStack } = usePushFocusItemToFocusStack();
|
||||
const { removeFocusItemFromFocusStackById } =
|
||||
useRemoveFocusItemFromFocusStackById();
|
||||
|
||||
const setEditableFocused = useCallback(
|
||||
(focused: boolean) => {
|
||||
if (focused) {
|
||||
pushFocusItemToFocusStack({
|
||||
focusId,
|
||||
component: {
|
||||
type: FocusComponentType.TEXT_INPUT,
|
||||
instanceId: focusId,
|
||||
},
|
||||
globalHotkeysConfig: {
|
||||
enableGlobalHotkeysConflictingWithKeyboard: false,
|
||||
},
|
||||
});
|
||||
} else {
|
||||
removeFocusItemFromFocusStackById({ focusId });
|
||||
}
|
||||
},
|
||||
[focusId, pushFocusItemToFocusStack, removeFocusItemFromFocusStackById],
|
||||
);
|
||||
|
||||
return (
|
||||
<FrontComponentInstanceContext.Provider
|
||||
value={{ instanceId: frontComponentId }}
|
||||
>
|
||||
{children}
|
||||
<FrontComponentInputFocusContext.Provider value={setEditableFocused}>
|
||||
<FrontComponentInputFocusCleanupEffect focusId={focusId} />
|
||||
{children}
|
||||
</FrontComponentInputFocusContext.Provider>
|
||||
</FrontComponentInstanceContext.Provider>
|
||||
);
|
||||
};
|
||||
|
||||
+142
@@ -0,0 +1,142 @@
|
||||
import { act, render, renderHook } from '@testing-library/react';
|
||||
import { createStore, Provider as JotaiProvider } from 'jotai';
|
||||
import { useContext, type Context, type createContext } from 'react';
|
||||
|
||||
type SetEditableFocused = (focused: boolean) => void;
|
||||
|
||||
jest.mock('twenty-front-component-renderer', () => {
|
||||
const ReactForMock = require('react') as {
|
||||
createContext: typeof createContext;
|
||||
};
|
||||
return {
|
||||
FrontComponentInputFocusContext:
|
||||
ReactForMock.createContext<SetEditableFocused | null>(null),
|
||||
};
|
||||
});
|
||||
|
||||
import { FrontComponentRendererProvider } from '@/front-components/components/FrontComponentRendererProvider';
|
||||
import { focusStackState } from '@/ui/utilities/focus/states/focusStackState';
|
||||
import { FocusComponentType } from '@/ui/utilities/focus/types/FocusComponentType';
|
||||
import { useAtomStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomStateValue';
|
||||
|
||||
const { FrontComponentInputFocusContext } = jest.requireMock(
|
||||
'twenty-front-component-renderer',
|
||||
) as {
|
||||
FrontComponentInputFocusContext: Context<SetEditableFocused | null>;
|
||||
};
|
||||
|
||||
const FRONT_COMPONENT_ID = 'fc-1';
|
||||
const EXPECTED_FOCUS_ID = `front-component-input-focus-${FRONT_COMPONENT_ID}`;
|
||||
|
||||
const TestConsumerEffect = ({
|
||||
onCallbackResolved,
|
||||
}: {
|
||||
onCallbackResolved: (setEditableFocused: SetEditableFocused | null) => void;
|
||||
}) => {
|
||||
const setEditableFocused = useContext(FrontComponentInputFocusContext);
|
||||
onCallbackResolved(setEditableFocused);
|
||||
return null;
|
||||
};
|
||||
|
||||
const renderProviderWithStore = () => {
|
||||
const store = createStore();
|
||||
let resolvedSetEditableFocused: SetEditableFocused | null | undefined;
|
||||
|
||||
const result = render(
|
||||
<JotaiProvider store={store}>
|
||||
<FrontComponentRendererProvider frontComponentId={FRONT_COMPONENT_ID}>
|
||||
<TestConsumerEffect
|
||||
onCallbackResolved={(callback) => {
|
||||
resolvedSetEditableFocused = callback;
|
||||
}}
|
||||
/>
|
||||
</FrontComponentRendererProvider>
|
||||
</JotaiProvider>,
|
||||
);
|
||||
|
||||
const getFocusStack = () =>
|
||||
renderHook(() => useAtomStateValue(focusStackState), {
|
||||
wrapper: ({ children }) => (
|
||||
<JotaiProvider store={store}>{children}</JotaiProvider>
|
||||
),
|
||||
}).result.current;
|
||||
|
||||
return {
|
||||
store,
|
||||
getFocusStack,
|
||||
getSetEditableFocused: () => resolvedSetEditableFocused,
|
||||
unmount: result.unmount,
|
||||
};
|
||||
};
|
||||
|
||||
describe('FrontComponentRendererProvider', () => {
|
||||
it('should expose a setEditableFocused callback through the context', () => {
|
||||
const { getSetEditableFocused } = renderProviderWithStore();
|
||||
expect(typeof getSetEditableFocused()).toBe('function');
|
||||
});
|
||||
|
||||
it('should push a focus item with suppressed keyboard hotkeys when called with true', () => {
|
||||
const { getSetEditableFocused, getFocusStack } = renderProviderWithStore();
|
||||
|
||||
act(() => {
|
||||
getSetEditableFocused()?.(true);
|
||||
});
|
||||
|
||||
expect(getFocusStack()).toEqual([
|
||||
{
|
||||
focusId: EXPECTED_FOCUS_ID,
|
||||
componentInstance: {
|
||||
componentType: FocusComponentType.TEXT_INPUT,
|
||||
componentInstanceId: EXPECTED_FOCUS_ID,
|
||||
},
|
||||
globalHotkeysConfig: {
|
||||
enableGlobalHotkeysWithModifiers: true,
|
||||
enableGlobalHotkeysConflictingWithKeyboard: false,
|
||||
},
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it('should remove the focus item when called with false', () => {
|
||||
const { getSetEditableFocused, getFocusStack } = renderProviderWithStore();
|
||||
|
||||
act(() => {
|
||||
getSetEditableFocused()?.(true);
|
||||
});
|
||||
expect(getFocusStack()).toHaveLength(1);
|
||||
|
||||
act(() => {
|
||||
getSetEditableFocused()?.(false);
|
||||
});
|
||||
expect(getFocusStack()).toEqual([]);
|
||||
});
|
||||
|
||||
it('should end in pushed state when focus moves between editable fields (true→false→true)', () => {
|
||||
const { getSetEditableFocused, getFocusStack } = renderProviderWithStore();
|
||||
|
||||
act(() => {
|
||||
getSetEditableFocused()?.(true);
|
||||
getSetEditableFocused()?.(false);
|
||||
getSetEditableFocused()?.(true);
|
||||
});
|
||||
|
||||
expect(getFocusStack()).toHaveLength(1);
|
||||
expect(getFocusStack()[0].focusId).toBe(EXPECTED_FOCUS_ID);
|
||||
});
|
||||
|
||||
it('should remove the focus item on unmount', () => {
|
||||
const { getSetEditableFocused, getFocusStack, unmount } =
|
||||
renderProviderWithStore();
|
||||
|
||||
act(() => {
|
||||
getSetEditableFocused()?.(true);
|
||||
});
|
||||
expect(getFocusStack()).toHaveLength(1);
|
||||
|
||||
act(() => {
|
||||
unmount();
|
||||
});
|
||||
|
||||
expect(getFocusStack()).toEqual([]);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user