fix: Ignore Enter pressed during IME composition in input fields
Closes #1703
This commit is contained in:
@@ -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();
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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)}`,
|
||||
);
|
||||
});
|
||||
Reference in New Issue
Block a user