diff --git a/apps/client/src/widgets/type_widgets/relation_map/RelationMap.spec.ts b/apps/client/src/widgets/type_widgets/relation_map/RelationMap.spec.ts new file mode 100644 index 00000000000..b785e2486d6 --- /dev/null +++ b/apps/client/src/widgets/type_widgets/relation_map/RelationMap.spec.ts @@ -0,0 +1,121 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest' +import $ from 'jquery' +import utils from '../../../services/utils' +import dialog from '../../../services/dialog' + +vi.mock('../../../services/utils', async (importOriginal) => { + const actual = await importOriginal() as any + return { + ...actual, + default: { + ...actual.default, + filterAttributeName: vi.fn((val: string) => val.replace(/[^a-z0-9]/gi, '')) + } + } +}) + +vi.mock('../../../services/dialog', () => ({ + default: { + prompt: vi.fn() + } +})) + +vi.mock('../../../services/attribute_autocomplete', () => ({ + default: { + initAttributeNameAutocomplete: vi.fn() + } +})) + +vi.mock('../../../services/i18n', () => ({ + t: (key: string) => key +})) + +// Call promptForRelationName and extract the $answer input element from the dialog's shown callback +async function getAnswerFromPrompt(): Promise> { + const { promptForRelationName } = await import('./utils') + + let $answer!: JQuery + + vi.mocked(dialog.prompt).mockImplementation(({ shown }) => { + document.body.innerHTML = '' + const input = document.querySelector('input') as HTMLInputElement + $answer = $(input) as JQuery + shown?.({ $answer }) + return Promise.resolve(null) + }) + + promptForRelationName() + return $answer +} + +describe('IME composition handling - Chinese input (promptForRelationName)', () => { + let input: HTMLInputElement + let $answer: JQuery + + beforeEach(async () => { + vi.clearAllMocks() + $answer = await getAnswerFromPrompt() + input = $answer[0] as HTMLInputElement + }) + + it('does not filter intermediate Chinese characters during composition', () => { + // user starts typing in Chinese IME + input.dispatchEvent(new Event('compositionstart')) + + // intermediate IME states — these are pinyin keystrokes shown before final char + input.value = 'n' + input.dispatchEvent(new Event('input')) + input.value = 'ni' + input.dispatchEvent(new Event('input')) + input.value = 'nin' + input.dispatchEvent(new Event('input')) + input.value = 'ning' + input.dispatchEvent(new Event('input')) + + expect(input.value).toBe('ning') + }) + + it('filters invalid characters from input after IME composition ends', () => { + input.dispatchEvent(new Event('compositionstart')) + + // intermediate pinyin + input.value = 'n' + input.dispatchEvent(new Event('input')) + input.value = 'ni' + input.dispatchEvent(new Event('input')) + + // user selects the Chinese character 你 from the IME picker + input.value = '你' + input.dispatchEvent(new Event('compositionend')) + + expect(input.value).toBe('') + }) + + it('allows normal latin input after Chinese composition ends', () => { + // first do a Chinese composition + input.dispatchEvent(new Event('compositionstart')) + input.value = '你' + input.dispatchEvent(new Event('compositionend')) + + // then type normally in latin + input.value = 'hello' + input.dispatchEvent(new Event('input')) + + expect(input.value).toBe('hello') + }) + + it('handles multiple Chinese characters in sequence', () => { + // first character + input.dispatchEvent(new Event('compositionstart')) + input.value = '你' + input.dispatchEvent(new Event('compositionend')) + + // second character + input.dispatchEvent(new Event('compositionstart')) + input.value = '好' + input.dispatchEvent(new Event('compositionend')) + + expect(input.value).toBe('') + }) +}); + diff --git a/apps/client/src/widgets/type_widgets/relation_map/RelationMap.tsx b/apps/client/src/widgets/type_widgets/relation_map/RelationMap.tsx index 88703315124..8510d6e7b18 100644 --- a/apps/client/src/widgets/type_widgets/relation_map/RelationMap.tsx +++ b/apps/client/src/widgets/type_widgets/relation_map/RelationMap.tsx @@ -432,3 +432,5 @@ function useRelationCreation({ mapApiRef, jsPlumbApiRef }: { mapApiRef: RefObjec return connectionCallback; } + + diff --git a/apps/client/src/widgets/type_widgets/relation_map/utils.ts b/apps/client/src/widgets/type_widgets/relation_map/utils.ts index d21857a398c..70ba115e57a 100644 --- a/apps/client/src/widgets/type_widgets/relation_map/utils.ts +++ b/apps/client/src/widgets/type_widgets/relation_map/utils.ts @@ -45,7 +45,18 @@ export function promptForRelationName(defaultValue?: string): Promise { + let isComposing = false; + + $answer.on("compositionstart", () => { + isComposing = true; + }); + $answer.on("compositionend", () => { + isComposing = false; + const attrName = utils.filterAttributeName($answer.val() as string); + $answer.val(attrName); + }); + $answer.on("input", () => { + if (isComposing) return; const attrName = utils.filterAttributeName($answer.val() as string); $answer.val(attrName); });