From 9f18028337496fb7d9c4c4d18026da9927d27870 Mon Sep 17 00:00:00 2001 From: babu-ch Date: Fri, 3 Jul 2026 20:54:52 +0900 Subject: [PATCH] fix: Ignore Enter pressed during IME composition in input fields Closes #1703 --- .../src/components/cards/AddCard/AddCard.jsx | 6 +- client/src/components/cards/Card/EditName.jsx | 5 + .../components/cards/CardModal/NameField.jsx | 5 + .../custom-fields/CustomField/ValueField.jsx | 5 + client/src/components/lists/List/EditName.jsx | 5 + .../task-lists/TaskList/AddTask.jsx | 6 +- .../task-lists/TaskList/Task/EditName.jsx | 5 + client/src/utils/event-helpers.js | 3 +- .../features/ime-composition.feature | 11 ++ client/tests/acceptance/pages/CardPage.js | 45 ++++++++ .../acceptance/steps/ime-composition.step.js | 107 ++++++++++++++++++ 11 files changed, 200 insertions(+), 3 deletions(-) create mode 100644 client/tests/acceptance/features/ime-composition.feature create mode 100644 client/tests/acceptance/pages/CardPage.js create mode 100644 client/tests/acceptance/steps/ime-composition.step.js diff --git a/client/src/components/cards/AddCard/AddCard.jsx b/client/src/components/cards/AddCard/AddCard.jsx index 6dd78b09..019b2803 100644 --- a/client/src/components/cards/AddCard/AddCard.jsx +++ b/client/src/components/cards/AddCard/AddCard.jsx @@ -15,7 +15,7 @@ import { usePopup } from '../../../lib/popup'; import selectors from '../../../selectors'; import { useClosable, useForm, useNestedRef } from '../../../hooks'; -import { isModifierKeyPressed } from '../../../utils/event-helpers'; +import { isComposing, isModifierKeyPressed } from '../../../utils/event-helpers'; import { CardTypeIcons } from '../../../constants/Icons'; import SelectCardTypeStep from '../SelectCardTypeStep'; @@ -88,6 +88,10 @@ const AddCard = React.memo(({ isOpened, className, onCreate, onClose }) => { const handleFieldKeyDown = useCallback( (event) => { + if (isComposing(event)) { + return; + } + switch (event.key) { case 'Enter': event.preventDefault(); diff --git a/client/src/components/cards/Card/EditName.jsx b/client/src/components/cards/Card/EditName.jsx index ae73ebe7..dd04374a 100644 --- a/client/src/components/cards/Card/EditName.jsx +++ b/client/src/components/cards/Card/EditName.jsx @@ -15,6 +15,7 @@ import selectors from '../../../selectors'; import entryActions from '../../../entry-actions'; import { useField, useNestedRef } from '../../../hooks'; import { focusEnd } from '../../../utils/element-helpers'; +import { isComposing } from '../../../utils/event-helpers'; import styles from './EditName.module.scss'; @@ -55,6 +56,10 @@ const EditName = React.memo(({ cardId, onClose }) => { const handleFieldKeyDown = useCallback( (event) => { + if (isComposing(event)) { + return; + } + switch (event.key) { case 'Enter': event.preventDefault(); diff --git a/client/src/components/cards/CardModal/NameField.jsx b/client/src/components/cards/CardModal/NameField.jsx index 2c4cb809..e5149439 100755 --- a/client/src/components/cards/CardModal/NameField.jsx +++ b/client/src/components/cards/CardModal/NameField.jsx @@ -12,6 +12,7 @@ import { TextArea } from 'semantic-ui-react'; import { useDidUpdate, usePrevious, useToggle } from '../../../lib/hooks'; import { useEscapeInterceptor, useField, useNestedRef } from '../../../hooks'; +import { isComposing } from '../../../utils/event-helpers'; import styles from './NameField.module.scss'; @@ -43,6 +44,10 @@ const NameField = React.memo(({ defaultValue, size, onUpdate }) => { const handleKeyDown = useCallback( (event) => { + if (isComposing(event)) { + return; + } + if (event.key === 'Enter') { event.preventDefault(); fiedRef.current.blur(); diff --git a/client/src/components/custom-fields/CustomField/ValueField.jsx b/client/src/components/custom-fields/CustomField/ValueField.jsx index 5695367c..4dcaf01c 100644 --- a/client/src/components/custom-fields/CustomField/ValueField.jsx +++ b/client/src/components/custom-fields/CustomField/ValueField.jsx @@ -9,6 +9,7 @@ import { useDidUpdate, usePrevious, useToggle } from '../../../lib/hooks'; import { Input } from '../../../lib/custom-ui'; import { useEscapeInterceptor, useField, useNestedRef } from '../../../hooks'; +import { isComposing } from '../../../utils/event-helpers'; import styles from './ValueField.module.scss'; @@ -35,6 +36,10 @@ const ValueField = React.memo(({ defaultValue, onUpdate, ...props }) => { const handleKeyDown = useCallback( (event) => { + if (isComposing(event)) { + return; + } + if (event.key === 'Enter') { event.preventDefault(); fieldRef.current.blur(); diff --git a/client/src/components/lists/List/EditName.jsx b/client/src/components/lists/List/EditName.jsx index e5e0f86a..f20eb817 100755 --- a/client/src/components/lists/List/EditName.jsx +++ b/client/src/components/lists/List/EditName.jsx @@ -13,6 +13,7 @@ import selectors from '../../../selectors'; import entryActions from '../../../entry-actions'; import { useField, useNestedRef } from '../../../hooks'; import { focusEnd } from '../../../utils/element-helpers'; +import { isComposing } from '../../../utils/event-helpers'; import styles from './EditName.module.scss'; @@ -46,6 +47,10 @@ const EditName = React.memo(({ listId, onClose }) => { const handleFieldKeyDown = useCallback( (event) => { + if (isComposing(event)) { + return; + } + switch (event.key) { case 'Enter': event.preventDefault(); diff --git a/client/src/components/task-lists/TaskList/AddTask.jsx b/client/src/components/task-lists/TaskList/AddTask.jsx index 921f0a45..4c6e245a 100755 --- a/client/src/components/task-lists/TaskList/AddTask.jsx +++ b/client/src/components/task-lists/TaskList/AddTask.jsx @@ -15,7 +15,7 @@ import selectors from '../../../selectors'; import entryActions from '../../../entry-actions'; import { useForm, useNestedRef } from '../../../hooks'; import { focusEnd } from '../../../utils/element-helpers'; -import { isModifierKeyPressed } from '../../../utils/event-helpers'; +import { isComposing, isModifierKeyPressed } from '../../../utils/event-helpers'; import styles from './AddTask.module.scss'; @@ -87,6 +87,10 @@ const AddTask = React.memo(({ children, taskListId, isOpened, onClose }) => { const handleFieldKeyDown = useCallback( (event) => { + if (isComposing(event)) { + return; + } + if (event.key === 'Enter') { if (!isLinkingToCard) { event.preventDefault(); diff --git a/client/src/components/task-lists/TaskList/Task/EditName.jsx b/client/src/components/task-lists/TaskList/Task/EditName.jsx index 233ff866..0667eaa7 100755 --- a/client/src/components/task-lists/TaskList/Task/EditName.jsx +++ b/client/src/components/task-lists/TaskList/Task/EditName.jsx @@ -15,6 +15,7 @@ import selectors from '../../../../selectors'; import entryActions from '../../../../entry-actions'; import { useField, useNestedRef } from '../../../../hooks'; import { focusEnd } from '../../../../utils/element-helpers'; +import { isComposing } from '../../../../utils/event-helpers'; import styles from './EditName.module.scss'; @@ -50,6 +51,10 @@ const EditName = React.memo(({ taskId, onClose }) => { const handleFieldKeyDown = useCallback( (event) => { + if (isComposing(event)) { + return; + } + if (event.key === 'Enter') { event.preventDefault(); submit(); diff --git a/client/src/utils/event-helpers.js b/client/src/utils/event-helpers.js index 2291e236..3e03a281 100644 --- a/client/src/utils/event-helpers.js +++ b/client/src/utils/event-helpers.js @@ -5,5 +5,6 @@ import Config from '../constants/Config'; -// eslint-disable-next-line import/prefer-default-export +export const isComposing = (event) => event.nativeEvent.isComposing || event.keyCode === 229; + export const isModifierKeyPressed = (event) => (Config.IS_MAC ? event.metaKey : event.ctrlKey); diff --git a/client/tests/acceptance/features/ime-composition.feature b/client/tests/acceptance/features/ime-composition.feature new file mode 100644 index 00000000..8899c893 --- /dev/null +++ b/client/tests/acceptance/features/ime-composition.feature @@ -0,0 +1,11 @@ +Feature: IME composition + Background: + Given the user is logged in with email or username "demo" and password "demo" + And a card with an empty task list exists + + Scenario: Confirming an IME composition with Enter does not submit the task form + When the user opens the card + And the user opens the add task form + And the user presses Enter while composing "かいぜん" with an IME + Then the composed text "かいぜん" should remain in the task field + And no task named "かいぜん" should be added diff --git a/client/tests/acceptance/pages/CardPage.js b/client/tests/acceptance/pages/CardPage.js new file mode 100644 index 00000000..5c89db51 --- /dev/null +++ b/client/tests/acceptance/pages/CardPage.js @@ -0,0 +1,45 @@ +import Config from '../Config.js'; + +export default class CardPage { + constructor() { + this.addTaskButtonSelector = 'button[class*="taskButton"]'; + this.addTaskFieldSelector = 'textarea:focus'; + } + + async navigate(cardId) { + await page.goto(`${Config.BASE_URL}/cards/${cardId}`); + } + + async openAddTaskForm() { + await page.click(this.addTaskButtonSelector); + await page.waitForSelector(this.addTaskFieldSelector); + } + + async pressEnterDuringImeComposition(text) { + const session = await context.newCDPSession(page); + + await session.send('Input.imeSetComposition', { + text, + selectionStart: text.length, + selectionEnd: text.length, + }); + + await session.send('Input.dispatchKeyEvent', { + type: 'rawKeyDown', + key: 'Enter', + code: 'Enter', + windowsVirtualKeyCode: 13, + nativeVirtualKeyCode: 13, + }); + + await session.send('Input.dispatchKeyEvent', { + type: 'keyUp', + key: 'Enter', + code: 'Enter', + windowsVirtualKeyCode: 13, + nativeVirtualKeyCode: 13, + }); + + await session.detach(); + } +} diff --git a/client/tests/acceptance/steps/ime-composition.step.js b/client/tests/acceptance/steps/ime-composition.step.js new file mode 100644 index 00000000..20c62410 --- /dev/null +++ b/client/tests/acceptance/steps/ime-composition.step.js @@ -0,0 +1,107 @@ +import assert from 'assert'; +import { Given, Then, When } from '@cucumber/cucumber'; +import { expect } from '@playwright/test'; + +import Config from '../Config.js'; +import CardPage from '../pages/CardPage.js'; + +const cardPage = new CardPage(); + +let accessToken; +let cardId; + +// ---------- GIVEN ---------- + +Given('a card with an empty task list exists', async () => { + const accessTokenResponse = await fetch(`${Config.BASE_URL}/api/access-tokens`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ + emailOrUsername: 'demo', + password: 'demo', + }), + }); + + ({ item: accessToken } = await accessTokenResponse.json()); + + const post = async (path, body) => { + const response = await fetch(`${Config.BASE_URL}/api${path}`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + Authorization: `Bearer ${accessToken}`, + }, + body: JSON.stringify(body), + }); + + const { item } = await response.json(); + return item; + }; + + const project = await post('/projects', { + name: `IME Composition Test ${Date.now()}`, + type: 'private', + }); + + const board = await post(`/projects/${project.id}/boards`, { + name: 'Test Board', + position: 65536, + }); + + const list = await post(`/boards/${board.id}/lists`, { + name: 'Test List', + position: 65536, + type: 'active', + }); + + const card = await post(`/lists/${list.id}/cards`, { + name: 'Test Card', + position: 65536, + type: 'project', + }); + + await post(`/cards/${card.id}/task-lists`, { + name: 'Test Task List', + position: 65536, + }); + + cardId = card.id; +}); + +// ---------- WHEN ---------- + +When('the user opens the card', async () => { + await cardPage.navigate(cardId); +}); + +When('the user opens the add task form', async () => { + await cardPage.openAddTaskForm(); +}); + +When('the user presses Enter while composing {string} with an IME', async (text) => { + await cardPage.pressEnterDuringImeComposition(text); +}); + +// ---------- THEN ---------- + +Then('the composed text {string} should remain in the task field', async (text) => { + await expect(page.locator(cardPage.addTaskFieldSelector)).toHaveValue(text); +}); + +Then('no task named {string} should be added', async (text) => { + const response = await fetch(`${Config.BASE_URL}/api/cards/${cardId}`, { + headers: { + Authorization: `Bearer ${accessToken}`, + }, + }); + + const { included } = await response.json(); + const taskNames = included.tasks.map(({ name }) => name); + + assert.ok( + !taskNames.includes(text), + `Expected no task named "${text}", but found tasks: ${JSON.stringify(taskNames)}`, + ); +});