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 selectors from '../../../selectors';
|
||||||
import { useClosable, useForm, useNestedRef } from '../../../hooks';
|
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 { CardTypeIcons } from '../../../constants/Icons';
|
||||||
import SelectCardTypeStep from '../SelectCardTypeStep';
|
import SelectCardTypeStep from '../SelectCardTypeStep';
|
||||||
|
|
||||||
@@ -88,6 +88,10 @@ const AddCard = React.memo(({ isOpened, className, onCreate, onClose }) => {
|
|||||||
|
|
||||||
const handleFieldKeyDown = useCallback(
|
const handleFieldKeyDown = useCallback(
|
||||||
(event) => {
|
(event) => {
|
||||||
|
if (isComposing(event)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
switch (event.key) {
|
switch (event.key) {
|
||||||
case 'Enter':
|
case 'Enter':
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ import selectors from '../../../selectors';
|
|||||||
import entryActions from '../../../entry-actions';
|
import entryActions from '../../../entry-actions';
|
||||||
import { useField, useNestedRef } from '../../../hooks';
|
import { useField, useNestedRef } from '../../../hooks';
|
||||||
import { focusEnd } from '../../../utils/element-helpers';
|
import { focusEnd } from '../../../utils/element-helpers';
|
||||||
|
import { isComposing } from '../../../utils/event-helpers';
|
||||||
|
|
||||||
import styles from './EditName.module.scss';
|
import styles from './EditName.module.scss';
|
||||||
|
|
||||||
@@ -55,6 +56,10 @@ const EditName = React.memo(({ cardId, onClose }) => {
|
|||||||
|
|
||||||
const handleFieldKeyDown = useCallback(
|
const handleFieldKeyDown = useCallback(
|
||||||
(event) => {
|
(event) => {
|
||||||
|
if (isComposing(event)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
switch (event.key) {
|
switch (event.key) {
|
||||||
case 'Enter':
|
case 'Enter':
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ import { TextArea } from 'semantic-ui-react';
|
|||||||
import { useDidUpdate, usePrevious, useToggle } from '../../../lib/hooks';
|
import { useDidUpdate, usePrevious, useToggle } from '../../../lib/hooks';
|
||||||
|
|
||||||
import { useEscapeInterceptor, useField, useNestedRef } from '../../../hooks';
|
import { useEscapeInterceptor, useField, useNestedRef } from '../../../hooks';
|
||||||
|
import { isComposing } from '../../../utils/event-helpers';
|
||||||
|
|
||||||
import styles from './NameField.module.scss';
|
import styles from './NameField.module.scss';
|
||||||
|
|
||||||
@@ -43,6 +44,10 @@ const NameField = React.memo(({ defaultValue, size, onUpdate }) => {
|
|||||||
|
|
||||||
const handleKeyDown = useCallback(
|
const handleKeyDown = useCallback(
|
||||||
(event) => {
|
(event) => {
|
||||||
|
if (isComposing(event)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
if (event.key === 'Enter') {
|
if (event.key === 'Enter') {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
fiedRef.current.blur();
|
fiedRef.current.blur();
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import { useDidUpdate, usePrevious, useToggle } from '../../../lib/hooks';
|
|||||||
import { Input } from '../../../lib/custom-ui';
|
import { Input } from '../../../lib/custom-ui';
|
||||||
|
|
||||||
import { useEscapeInterceptor, useField, useNestedRef } from '../../../hooks';
|
import { useEscapeInterceptor, useField, useNestedRef } from '../../../hooks';
|
||||||
|
import { isComposing } from '../../../utils/event-helpers';
|
||||||
|
|
||||||
import styles from './ValueField.module.scss';
|
import styles from './ValueField.module.scss';
|
||||||
|
|
||||||
@@ -35,6 +36,10 @@ const ValueField = React.memo(({ defaultValue, onUpdate, ...props }) => {
|
|||||||
|
|
||||||
const handleKeyDown = useCallback(
|
const handleKeyDown = useCallback(
|
||||||
(event) => {
|
(event) => {
|
||||||
|
if (isComposing(event)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
if (event.key === 'Enter') {
|
if (event.key === 'Enter') {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
fieldRef.current.blur();
|
fieldRef.current.blur();
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ import selectors from '../../../selectors';
|
|||||||
import entryActions from '../../../entry-actions';
|
import entryActions from '../../../entry-actions';
|
||||||
import { useField, useNestedRef } from '../../../hooks';
|
import { useField, useNestedRef } from '../../../hooks';
|
||||||
import { focusEnd } from '../../../utils/element-helpers';
|
import { focusEnd } from '../../../utils/element-helpers';
|
||||||
|
import { isComposing } from '../../../utils/event-helpers';
|
||||||
|
|
||||||
import styles from './EditName.module.scss';
|
import styles from './EditName.module.scss';
|
||||||
|
|
||||||
@@ -46,6 +47,10 @@ const EditName = React.memo(({ listId, onClose }) => {
|
|||||||
|
|
||||||
const handleFieldKeyDown = useCallback(
|
const handleFieldKeyDown = useCallback(
|
||||||
(event) => {
|
(event) => {
|
||||||
|
if (isComposing(event)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
switch (event.key) {
|
switch (event.key) {
|
||||||
case 'Enter':
|
case 'Enter':
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
|
|||||||
@@ -15,7 +15,7 @@ import selectors from '../../../selectors';
|
|||||||
import entryActions from '../../../entry-actions';
|
import entryActions from '../../../entry-actions';
|
||||||
import { useForm, useNestedRef } from '../../../hooks';
|
import { useForm, useNestedRef } from '../../../hooks';
|
||||||
import { focusEnd } from '../../../utils/element-helpers';
|
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';
|
import styles from './AddTask.module.scss';
|
||||||
|
|
||||||
@@ -87,6 +87,10 @@ const AddTask = React.memo(({ children, taskListId, isOpened, onClose }) => {
|
|||||||
|
|
||||||
const handleFieldKeyDown = useCallback(
|
const handleFieldKeyDown = useCallback(
|
||||||
(event) => {
|
(event) => {
|
||||||
|
if (isComposing(event)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
if (event.key === 'Enter') {
|
if (event.key === 'Enter') {
|
||||||
if (!isLinkingToCard) {
|
if (!isLinkingToCard) {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ import selectors from '../../../../selectors';
|
|||||||
import entryActions from '../../../../entry-actions';
|
import entryActions from '../../../../entry-actions';
|
||||||
import { useField, useNestedRef } from '../../../../hooks';
|
import { useField, useNestedRef } from '../../../../hooks';
|
||||||
import { focusEnd } from '../../../../utils/element-helpers';
|
import { focusEnd } from '../../../../utils/element-helpers';
|
||||||
|
import { isComposing } from '../../../../utils/event-helpers';
|
||||||
|
|
||||||
import styles from './EditName.module.scss';
|
import styles from './EditName.module.scss';
|
||||||
|
|
||||||
@@ -50,6 +51,10 @@ const EditName = React.memo(({ taskId, onClose }) => {
|
|||||||
|
|
||||||
const handleFieldKeyDown = useCallback(
|
const handleFieldKeyDown = useCallback(
|
||||||
(event) => {
|
(event) => {
|
||||||
|
if (isComposing(event)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
if (event.key === 'Enter') {
|
if (event.key === 'Enter') {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
submit();
|
submit();
|
||||||
|
|||||||
@@ -5,5 +5,6 @@
|
|||||||
|
|
||||||
import Config from '../constants/Config';
|
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);
|
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