From 2e5066c1d413036fd07439bda3c0a5b8c2420e39 Mon Sep 17 00:00:00 2001 From: sergeyteleshev Date: Thu, 19 Dec 2024 18:17:03 +0100 Subject: [PATCH 01/48] CB-3753 adds base for input autocomplete --- .../InputField/useInputAutocomplete.ts | 201 ++++++++++++++++++ webapp/packages/core-utils/src/index.ts | 1 + .../core-utils/src/isFuzzySearchable.test.ts | 45 ++++ .../core-utils/src/isFuzzySearchable.ts | 28 +++ 4 files changed, 275 insertions(+) create mode 100644 webapp/packages/core-blocks/src/FormControls/InputField/useInputAutocomplete.ts create mode 100644 webapp/packages/core-utils/src/isFuzzySearchable.test.ts create mode 100644 webapp/packages/core-utils/src/isFuzzySearchable.ts diff --git a/webapp/packages/core-blocks/src/FormControls/InputField/useInputAutocomplete.ts b/webapp/packages/core-blocks/src/FormControls/InputField/useInputAutocomplete.ts new file mode 100644 index 0000000000..fbfa0f006f --- /dev/null +++ b/webapp/packages/core-blocks/src/FormControls/InputField/useInputAutocomplete.ts @@ -0,0 +1,201 @@ +/* + * CloudBeaver - Cloud Database Manager + * Copyright (C) 2020-2024 DBeaver Corp and others + * + * Licensed under the Apache License, Version 2.0. + * you may not use this file except in compliance with the License. + */ +import { action, computed, observable } from 'mobx'; +import { type RefObject, useEffect } from 'react'; + +import { debounce, isFuzzySearchable, isNotNullDefined } from '@cloudbeaver/core-utils'; + +import { useObservableRef } from '../../useObservableRef.js'; + +interface InputAutocompleteOptions { + sourceHints: InputAutocompleteProposal[]; + matchStrategy?: 'startsWith' | 'contains' | 'fuzzy'; + onSelect: (suggestion: InputAutocompleteProposal) => void; + onClose: () => void; + filter?: (suggestion: InputAutocompleteProposal, lastWord?: string) => boolean; +} + +export interface InputAutocompleteProposal { + displayString: string; + replacementString: string; + title?: string; + score?: number; +} + +const SELECTED_INDEX_DEFAULT = -1; +const INPUT_DELAY = 300; + +export const useInputAutocomplete = ( + inputRef: RefObject, + { sourceHints, matchStrategy = 'startsWith', onSelect, filter, onClose }: InputAutocompleteOptions, +) => { + const state = useObservableRef( + () => ({ + input: inputRef.current?.value as string | undefined, + selectionStart: inputRef.current?.selectionStart ?? null, + selectionEnd: inputRef.current?.value?.length ?? null, + selectedIndex: SELECTED_INDEX_DEFAULT, + setSelectedIndex(index: number) { + if (index < SELECTED_INDEX_DEFAULT) { + throw new Error(`Index cannot be less than ${SELECTED_INDEX_DEFAULT}`); + } + + this.selectedIndex = index; + }, + get selected() { + return this.filteredSuggestions[this.selectedIndex]; + }, + replaceCurrentWord(replacement: string) { + const input = this.inputRef.current; + + if (!input) { + return; + } + + const cursorPosition = this.selectionStart; + const words = this.input?.split(' '); + + if (!isNotNullDefined(words) || !isNotNullDefined(cursorPosition) || !isNotNullDefined(this.currentWord)) { + return; + } + + const start = cursorPosition - this.currentWord.length; + const end = cursorPosition; + + this.input = this.input?.slice(0, start) + replacement + this.input?.slice(end); + input.value = this.input; + input.setSelectionRange(start, start + replacement.length); + input.focus(); + }, + get currentWord() { + const cursorPosition = this.selectionStart; + + if (!cursorPosition) { + return ''; + } + + const substring = this.input?.slice(0, cursorPosition); + + if (!substring) { + return ''; + } + + return substring.split(' ').filter(Boolean).at(-1); + }, + get filteredSuggestions() { + if (!this.currentWord) { + return []; + } + + return this.sourceHints + .filter(suggestion => { + const values = [suggestion.displayString.toLocaleLowerCase(), suggestion.replacementString.toLocaleLowerCase()]; + const isEqual = values.some(value => value === this.currentWord?.toLocaleLowerCase()); + + if (!this.currentWord || isEqual) { + return false; + } + + return ( + (this.matchStrategy === 'startsWith' && + values.some(value => isNotNullDefined(this.currentWord) && value.startsWith(this.currentWord))) || + (this.matchStrategy === 'contains' && values.some(value => isNotNullDefined(this.currentWord) && value.includes(this.currentWord))) || + (this.matchStrategy === 'fuzzy' && + values.some(value => isNotNullDefined(this.currentWord) && isFuzzySearchable(value, this.currentWord))) + ); + }) + .filter(suggestion => (filter ? filter(suggestion, this.currentWord) : true)) + .sort((a, b) => { + if (isNotNullDefined(a.score) && isNotNullDefined(b.score)) { + return b.score - a.score; + } + + return 0; + }); + }, + }), + { + input: observable.ref, + selectionStart: observable.ref, + selectionEnd: observable.ref, + selectedIndex: observable.ref, + sourceHints: observable.ref, + matchStrategy: observable.ref, + inputRef: observable.ref, + selected: computed, + currentWord: computed, + filteredSuggestions: computed, + setSelectedIndex: action.bound, + replaceCurrentWord: action.bound, + }, + { sourceHints, matchStrategy, inputRef }, + ); + + const handleInput = debounce((event: Event) => { + const target = event.target as HTMLInputElement; + + state.selectionStart = target.selectionStart; + state.selectionEnd = target.selectionEnd; + state.input = target?.value; + }, INPUT_DELAY); + + // TODO move it to the ui? + function handleKeyDown(event: KeyboardEvent) { + if (!state.filteredSuggestions.length) { + return; + } + + switch (event.key) { + case 'ArrowDown': + event.preventDefault(); + state.setSelectedIndex(Math.min(state.selectedIndex + 1, state.filteredSuggestions.length - 1)); + break; + case 'ArrowUp': + event.preventDefault(); + state.setSelectedIndex(Math.max(state.selectedIndex - 1, 0)); + break; + case 'Enter': + if (state.selected) { + event.preventDefault(); + event.stopPropagation(); + state.replaceCurrentWord(state.selected.replacementString); + state.setSelectedIndex(SELECTED_INDEX_DEFAULT); + onSelect(state.selected); + } + break; + case 'Escape': + state.setSelectedIndex(SELECTED_INDEX_DEFAULT); + onClose(); + break; + default: + break; + } + } + + useEffect(() => { + const input = state.inputRef.current; + + if (!input) { + return; + } + + input.addEventListener('input', handleInput); + input.addEventListener('keydown', handleKeyDown); + return () => { + input.removeEventListener('input', handleInput); + input.removeEventListener('keydown', handleKeyDown); + }; + }, [state.inputRef.current]); + + // TODO move it to the ui? + useEffect(() => { + state.setSelectedIndex(SELECTED_INDEX_DEFAULT); + }, [sourceHints, matchStrategy]); + + return state; +}; diff --git a/webapp/packages/core-utils/src/index.ts b/webapp/packages/core-utils/src/index.ts index e6da19c3d7..1dcee614df 100644 --- a/webapp/packages/core-utils/src/index.ts +++ b/webapp/packages/core-utils/src/index.ts @@ -89,3 +89,4 @@ export * from './types/UndefinedToNull.js'; export * from './bindFunctions.js'; export * from './getDomainFromUrl.js'; export * from './isNumber.js'; +export * from './isFuzzySearchable.js'; diff --git a/webapp/packages/core-utils/src/isFuzzySearchable.test.ts b/webapp/packages/core-utils/src/isFuzzySearchable.test.ts new file mode 100644 index 0000000000..b73b279a90 --- /dev/null +++ b/webapp/packages/core-utils/src/isFuzzySearchable.test.ts @@ -0,0 +1,45 @@ +/* + * CloudBeaver - Cloud Database Manager + * Copyright (C) 2020-2024 DBeaver Corp and others + * + * Licensed under the Apache License, Version 2.0. + * you may not use this file except in compliance with the License. + */ +import { describe, expect, it } from '@jest/globals'; + +import { isFuzzySearchable } from './isFuzzySearchable.js'; + +describe('isFuzzySearchable', () => { + it('should return true if pattern is empty', () => { + expect(isFuzzySearchable('', 'text')).toBe(true); + expect(isFuzzySearchable('', '')).toBe(true); + }); + + it('should return false if text is empty', () => { + expect(isFuzzySearchable('pattern', '')).toBe(false); + }); + + it('should return false if pattern is longer than text', () => { + expect(isFuzzySearchable('pattern', 'pat')).toBe(false); + }); + + it('should return false if pattern is not recognized', () => { + expect(isFuzzySearchable('xyz', 'examination')).toBe(false); + }); + + it('should return true if pattern is found in text', () => { + expect(isFuzzySearchable('pattern', 'pattern')).toBe(true); + expect(isFuzzySearchable('pattern', 'text pattern')).toBe(true); + expect(isFuzzySearchable('cat', 'catalog')).toBe(true); + expect(isFuzzySearchable('bird', 'binaryword')).toBe(true); + expect(isFuzzySearchable('dog', 'doing')).toBe(true); + }); + + it('should return true if pattern is found in text ignoring case', () => { + expect(isFuzzySearchable('pattern', 'Pattern')).toBe(true); + expect(isFuzzySearchable('pattern', 'TEXT PATTERN')).toBe(true); + expect(isFuzzySearchable('cat', 'CATALOG')).toBe(true); + expect(isFuzzySearchable('bird', 'BINARYWORD')).toBe(true); + expect(isFuzzySearchable('dog', 'DOING')).toBe(true); + }); +}); diff --git a/webapp/packages/core-utils/src/isFuzzySearchable.ts b/webapp/packages/core-utils/src/isFuzzySearchable.ts new file mode 100644 index 0000000000..4e6601e858 --- /dev/null +++ b/webapp/packages/core-utils/src/isFuzzySearchable.ts @@ -0,0 +1,28 @@ +/* + * CloudBeaver - Cloud Database Manager + * Copyright (C) 2020-2024 DBeaver Corp and others + * + * Licensed under the Apache License, Version 2.0. + * you may not use this file except in compliance with the License. + */ +export function isFuzzySearchable(pattern: string, text: string): boolean { + if (pattern.length === 0) { + return true; + } + + if (text.length === 0 || pattern.length > text.length) { + return false; + } + + let patternIdx = 0; + const lowerPattern = pattern.toLowerCase(); + const lowerText = text.toLowerCase(); + + for (let textIdx = 0; textIdx < text.length && patternIdx < pattern.length; textIdx++) { + if (lowerPattern[patternIdx] === lowerText[textIdx]) { + patternIdx++; + } + } + + return patternIdx === pattern.length; +} From 93c659f060eb8a8c1c7d30eef2f88d7061799a9f Mon Sep 17 00:00:00 2001 From: sergeyteleshev Date: Fri, 20 Dec 2024 21:21:08 +0100 Subject: [PATCH 02/48] CB-5373 adds InputAutocompletion base --- .../InputField/InputAutocompletion.module.css | 60 +++++++++ .../InputField/InputAutocompletion.tsx | 118 ++++++++++++++++++ .../InputField/useInputAutocomplete.ts | 62 +-------- webapp/packages/core-blocks/src/index.ts | 2 + .../core-ui/src/InlineEditor/InlineEditor.tsx | 20 ++- 5 files changed, 201 insertions(+), 61 deletions(-) create mode 100644 webapp/packages/core-blocks/src/FormControls/InputField/InputAutocompletion.module.css create mode 100644 webapp/packages/core-blocks/src/FormControls/InputField/InputAutocompletion.tsx diff --git a/webapp/packages/core-blocks/src/FormControls/InputField/InputAutocompletion.module.css b/webapp/packages/core-blocks/src/FormControls/InputField/InputAutocompletion.module.css new file mode 100644 index 0000000000..fe6aaef55e --- /dev/null +++ b/webapp/packages/core-blocks/src/FormControls/InputField/InputAutocompletion.module.css @@ -0,0 +1,60 @@ +/* + * CloudBeaver - Cloud Database Manager + * Copyright (C) 2020-2024 DBeaver Corp and others + * + * Licensed under the Apache License, Version 2.0. + * you may not use this file except in compliance with the License. + */ +.menuItem { + composes: theme-ripple from global; +} + +.menu { + composes: theme-text-on-surface theme-background-surface theme-typography--caption theme-elevation-z3 theme-border-color-background from global; + display: flex; + flex-direction: column; + max-height: 300px; + width: auto; + overflow: auto; + outline: none; + z-index: 999; + border: 1px solid; + + & .menuItem { + background: transparent; + display: flex; + flex-direction: row; + align-items: center; + padding: 4px 8px; + text-align: left; + outline: none; + color: inherit; + cursor: pointer; + gap: 8px; + + & .itemIcon, + & .itemTitle { + position: relative; + } + + & .itemIcon { + width: 16px; + height: 16px; + overflow: hidden; + flex-shrink: 0; + + & .iconOrImage { + width: 100%; + height: 100%; + } + } + } +} + +.menuButton { + position: absolute; + right: 0; + padding: 0; + height: 100%; + width: 0px; +} diff --git a/webapp/packages/core-blocks/src/FormControls/InputField/InputAutocompletion.tsx b/webapp/packages/core-blocks/src/FormControls/InputField/InputAutocompletion.tsx new file mode 100644 index 0000000000..a8f214d352 --- /dev/null +++ b/webapp/packages/core-blocks/src/FormControls/InputField/InputAutocompletion.tsx @@ -0,0 +1,118 @@ +/* + * CloudBeaver - Cloud Database Manager + * Copyright (C) 2020-2024 DBeaver Corp and others + * + * Licensed under the Apache License, Version 2.0. + * you may not use this file except in compliance with the License. + */ +import { observer } from 'mobx-react-lite'; +import { forwardRef, useEffect, useRef } from 'react'; +import { Menu, MenuButton, type MenuInitialState, MenuItem, useMenuState } from 'reakit'; + +import { Icon } from '../../Icon.js'; +import { useCombinedRef } from '../../useCombinedRef.js'; +import { useS } from '../../useS.js'; +import style from './InputAutocompletion.module.css'; +import { type InputAutocompleteProposal, useInputAutocomplete } from './useInputAutocomplete.js'; + +interface AutocompletionProps { + items: InputAutocompleteProposal[] | null; + inputRef: React.RefObject; + placement?: MenuInitialState['placement']; + gutter?: number; + ref?: React.Ref; + propertyName?: string; + onSelect?: (proposal: InputAutocompleteProposal) => void; +} + +export const InputAutocompletion = observer( + forwardRef(function Autocompletion( + { items, placement = 'bottom-end', gutter = 1, propertyName, inputRef, onSelect }: AutocompletionProps, + ref: React.Ref, + ) { + const styles = useS(style); + const menuRef = useRef(null); + const menu = useMenuState({ + placement: placement, + gutter: gutter, + }); + const autocompleteState = useInputAutocomplete(inputRef, { + sourceHints: items || [], + matchStrategy: 'startsWith', + }); + + function handleSelect(proposal: InputAutocompleteProposal) { + menu.hide(); + autocompleteState.replaceCurrentWord(proposal.replacementString); + onSelect?.(proposal); + } + + function handleKeyDown(event: any) { + if (!autocompleteState.filteredSuggestions.length) { + return; + } + + switch (event.key) { + case 'ArrowDown': + event.preventDefault(); + menu.next(); + break; + case 'ArrowUp': + event.preventDefault(); + menu.previous(); + break; + case 'Escape': + menu.hide(); + break; + default: + break; + } + } + + const combinedRef = useCombinedRef(menuRef, ref); + + useEffect(() => { + inputRef.current?.addEventListener('keydown', handleKeyDown); + return () => { + inputRef.current?.removeEventListener('keydown', handleKeyDown); + }; + }, []); + + useEffect(() => { + if (menu.visible && (autocompleteState.filteredSuggestions === null || autocompleteState.filteredSuggestions.length === 0)) { + menu.hide(); + } + if (!menu.visible && autocompleteState.filteredSuggestions !== null && autocompleteState.filteredSuggestions.length !== 0) { + menu.show(); + } + }, [items, menu, autocompleteState.filteredSuggestions]); + + return ( + <> + + + + + {autocompleteState.filteredSuggestions.map(item => ( + handleSelect(item)} + > + {/* {item.icon && ( +
+ {item.icon && typeof item.icon === 'string' ? : item.icon} +
+ )} */} +
{item.displayString}
+
+ ))} +
+ + ); + }), +); diff --git a/webapp/packages/core-blocks/src/FormControls/InputField/useInputAutocomplete.ts b/webapp/packages/core-blocks/src/FormControls/InputField/useInputAutocomplete.ts index fbfa0f006f..da58174048 100644 --- a/webapp/packages/core-blocks/src/FormControls/InputField/useInputAutocomplete.ts +++ b/webapp/packages/core-blocks/src/FormControls/InputField/useInputAutocomplete.ts @@ -15,8 +15,6 @@ import { useObservableRef } from '../../useObservableRef.js'; interface InputAutocompleteOptions { sourceHints: InputAutocompleteProposal[]; matchStrategy?: 'startsWith' | 'contains' | 'fuzzy'; - onSelect: (suggestion: InputAutocompleteProposal) => void; - onClose: () => void; filter?: (suggestion: InputAutocompleteProposal, lastWord?: string) => boolean; } @@ -27,29 +25,17 @@ export interface InputAutocompleteProposal { score?: number; } -const SELECTED_INDEX_DEFAULT = -1; const INPUT_DELAY = 300; export const useInputAutocomplete = ( - inputRef: RefObject, - { sourceHints, matchStrategy = 'startsWith', onSelect, filter, onClose }: InputAutocompleteOptions, + inputRef: RefObject, + { sourceHints, matchStrategy = 'startsWith', filter }: InputAutocompleteOptions, ) => { const state = useObservableRef( () => ({ input: inputRef.current?.value as string | undefined, selectionStart: inputRef.current?.selectionStart ?? null, selectionEnd: inputRef.current?.value?.length ?? null, - selectedIndex: SELECTED_INDEX_DEFAULT, - setSelectedIndex(index: number) { - if (index < SELECTED_INDEX_DEFAULT) { - throw new Error(`Index cannot be less than ${SELECTED_INDEX_DEFAULT}`); - } - - this.selectedIndex = index; - }, - get selected() { - return this.filteredSuggestions[this.selectedIndex]; - }, replaceCurrentWord(replacement: string) { const input = this.inputRef.current; @@ -69,7 +55,6 @@ export const useInputAutocomplete = ( this.input = this.input?.slice(0, start) + replacement + this.input?.slice(end); input.value = this.input; - input.setSelectionRange(start, start + replacement.length); input.focus(); }, get currentWord() { @@ -123,14 +108,11 @@ export const useInputAutocomplete = ( input: observable.ref, selectionStart: observable.ref, selectionEnd: observable.ref, - selectedIndex: observable.ref, sourceHints: observable.ref, matchStrategy: observable.ref, inputRef: observable.ref, - selected: computed, currentWord: computed, filteredSuggestions: computed, - setSelectedIndex: action.bound, replaceCurrentWord: action.bound, }, { sourceHints, matchStrategy, inputRef }, @@ -144,39 +126,6 @@ export const useInputAutocomplete = ( state.input = target?.value; }, INPUT_DELAY); - // TODO move it to the ui? - function handleKeyDown(event: KeyboardEvent) { - if (!state.filteredSuggestions.length) { - return; - } - - switch (event.key) { - case 'ArrowDown': - event.preventDefault(); - state.setSelectedIndex(Math.min(state.selectedIndex + 1, state.filteredSuggestions.length - 1)); - break; - case 'ArrowUp': - event.preventDefault(); - state.setSelectedIndex(Math.max(state.selectedIndex - 1, 0)); - break; - case 'Enter': - if (state.selected) { - event.preventDefault(); - event.stopPropagation(); - state.replaceCurrentWord(state.selected.replacementString); - state.setSelectedIndex(SELECTED_INDEX_DEFAULT); - onSelect(state.selected); - } - break; - case 'Escape': - state.setSelectedIndex(SELECTED_INDEX_DEFAULT); - onClose(); - break; - default: - break; - } - } - useEffect(() => { const input = state.inputRef.current; @@ -185,17 +134,10 @@ export const useInputAutocomplete = ( } input.addEventListener('input', handleInput); - input.addEventListener('keydown', handleKeyDown); return () => { input.removeEventListener('input', handleInput); - input.removeEventListener('keydown', handleKeyDown); }; }, [state.inputRef.current]); - // TODO move it to the ui? - useEffect(() => { - state.setSelectedIndex(SELECTED_INDEX_DEFAULT); - }, [sourceHints, matchStrategy]); - return state; }; diff --git a/webapp/packages/core-blocks/src/index.ts b/webapp/packages/core-blocks/src/index.ts index 002ef6cadb..0d12dc1ee0 100644 --- a/webapp/packages/core-blocks/src/index.ts +++ b/webapp/packages/core-blocks/src/index.ts @@ -253,3 +253,5 @@ export * from './importLazyComponent.js'; export * from './ClickableLoader.js'; export * from './FormControls/TagsComboboxLoader.js'; export * from './Flex/Flex.js'; +export * from './FormControls/InputField/useInputAutocomplete.js'; +export * from './FormControls/InputField/InputAutocompletion.js'; diff --git a/webapp/packages/core-ui/src/InlineEditor/InlineEditor.tsx b/webapp/packages/core-ui/src/InlineEditor/InlineEditor.tsx index 41939ee224..585df05d0c 100644 --- a/webapp/packages/core-ui/src/InlineEditor/InlineEditor.tsx +++ b/webapp/packages/core-ui/src/InlineEditor/InlineEditor.tsx @@ -8,7 +8,18 @@ import { observer } from 'mobx-react-lite'; import React, { type ChangeEvent, forwardRef, useCallback, useEffect, useImperativeHandle, useRef } from 'react'; -import { Icon, IconOrImage, Loader, s, useObjectRef, useS, useTranslate } from '@cloudbeaver/core-blocks'; +import { + Icon, + IconOrImage, + type InputAutocompleteProposal, + InputAutocompletion, + Loader, + s, + useInputAutocomplete, + useObjectRef, + useS, + useTranslate, +} from '@cloudbeaver/core-blocks'; import { useService } from '@cloudbeaver/core-di'; import { CommonDialogService, DialogueStateResult } from '@cloudbeaver/core-dialogs'; @@ -28,6 +39,7 @@ export interface InlineEditorProps extends Omit void; onSave?: () => void; onReject?: () => void; @@ -51,6 +63,7 @@ export const InlineEditor = observer( active, loading, disabled, + autoCompleteProposals, onChange, onSave, onUndo, @@ -102,6 +115,10 @@ export const InlineEditor = observer( }, []); const inputRef = useRef(null); + const isAutoCompleteEnabled = !!autoCompleteProposals; + const autocompleteState = useInputAutocomplete(inputRef, { + sourceHints: autoCompleteProposals ?? [], + }); useEffect(() => { if (autofocus && !disabled) { @@ -125,6 +142,7 @@ export const InlineEditor = observer( onKeyDown={handleKeyDown} {...rest} /> + {isAutoCompleteEnabled && }
Date: Mon, 23 Dec 2024 12:11:21 +0100 Subject: [PATCH 03/48] CB-3753 removes unneeded code + fixes bugs with current word selection --- .../FormControls/InputField/InputAutocompletion.tsx | 13 +++++++------ .../FormControls/InputField/useInputAutocomplete.ts | 11 ++++++++--- .../core-ui/src/InlineEditor/InlineEditor.tsx | 6 +----- 3 files changed, 16 insertions(+), 14 deletions(-) diff --git a/webapp/packages/core-blocks/src/FormControls/InputField/InputAutocompletion.tsx b/webapp/packages/core-blocks/src/FormControls/InputField/InputAutocompletion.tsx index a8f214d352..801a8f3ce9 100644 --- a/webapp/packages/core-blocks/src/FormControls/InputField/InputAutocompletion.tsx +++ b/webapp/packages/core-blocks/src/FormControls/InputField/InputAutocompletion.tsx @@ -13,11 +13,12 @@ import { Icon } from '../../Icon.js'; import { useCombinedRef } from '../../useCombinedRef.js'; import { useS } from '../../useS.js'; import style from './InputAutocompletion.module.css'; -import { type InputAutocompleteProposal, useInputAutocomplete } from './useInputAutocomplete.js'; +import { type InputAutocompleteProposal, type InputAutocompleteStrategy, useInputAutocomplete } from './useInputAutocomplete.js'; interface AutocompletionProps { - items: InputAutocompleteProposal[] | null; + sourceHints: InputAutocompleteProposal[]; inputRef: React.RefObject; + matchStrategy?: InputAutocompleteStrategy; placement?: MenuInitialState['placement']; gutter?: number; ref?: React.Ref; @@ -27,7 +28,7 @@ interface AutocompletionProps { export const InputAutocompletion = observer( forwardRef(function Autocompletion( - { items, placement = 'bottom-end', gutter = 1, propertyName, inputRef, onSelect }: AutocompletionProps, + { sourceHints, placement = 'bottom-end', gutter = 1, matchStrategy, propertyName, inputRef, onSelect }: AutocompletionProps, ref: React.Ref, ) { const styles = useS(style); @@ -37,8 +38,8 @@ export const InputAutocompletion = observer( gutter: gutter, }); const autocompleteState = useInputAutocomplete(inputRef, { - sourceHints: items || [], - matchStrategy: 'startsWith', + sourceHints, + matchStrategy, }); function handleSelect(proposal: InputAutocompleteProposal) { @@ -85,7 +86,7 @@ export const InputAutocompletion = observer( if (!menu.visible && autocompleteState.filteredSuggestions !== null && autocompleteState.filteredSuggestions.length !== 0) { menu.show(); } - }, [items, menu, autocompleteState.filteredSuggestions]); + }, [sourceHints, menu, autocompleteState.filteredSuggestions]); return ( <> diff --git a/webapp/packages/core-blocks/src/FormControls/InputField/useInputAutocomplete.ts b/webapp/packages/core-blocks/src/FormControls/InputField/useInputAutocomplete.ts index da58174048..f8a157df40 100644 --- a/webapp/packages/core-blocks/src/FormControls/InputField/useInputAutocomplete.ts +++ b/webapp/packages/core-blocks/src/FormControls/InputField/useInputAutocomplete.ts @@ -12,9 +12,11 @@ import { debounce, isFuzzySearchable, isNotNullDefined } from '@cloudbeaver/core import { useObservableRef } from '../../useObservableRef.js'; +export type InputAutocompleteStrategy = 'startsWith' | 'contains' | 'fuzzy'; + interface InputAutocompleteOptions { sourceHints: InputAutocompleteProposal[]; - matchStrategy?: 'startsWith' | 'contains' | 'fuzzy'; + matchStrategy?: InputAutocompleteStrategy; filter?: (suggestion: InputAutocompleteProposal, lastWord?: string) => boolean; } @@ -54,6 +56,9 @@ export const useInputAutocomplete = ( const end = cursorPosition; this.input = this.input?.slice(0, start) + replacement + this.input?.slice(end); + this.selectionStart = start + replacement.length; + this.selectionEnd = start + replacement.length; + input.value = this.input; input.focus(); }, @@ -70,7 +75,7 @@ export const useInputAutocomplete = ( return ''; } - return substring.split(' ').filter(Boolean).at(-1); + return substring.split(' ').at(-1); }, get filteredSuggestions() { if (!this.currentWord) { @@ -91,7 +96,7 @@ export const useInputAutocomplete = ( values.some(value => isNotNullDefined(this.currentWord) && value.startsWith(this.currentWord))) || (this.matchStrategy === 'contains' && values.some(value => isNotNullDefined(this.currentWord) && value.includes(this.currentWord))) || (this.matchStrategy === 'fuzzy' && - values.some(value => isNotNullDefined(this.currentWord) && isFuzzySearchable(value, this.currentWord))) + values.some(value => isNotNullDefined(this.currentWord) && isFuzzySearchable(this.currentWord, value))) ); }) .filter(suggestion => (filter ? filter(suggestion, this.currentWord) : true)) diff --git a/webapp/packages/core-ui/src/InlineEditor/InlineEditor.tsx b/webapp/packages/core-ui/src/InlineEditor/InlineEditor.tsx index 585df05d0c..a5c5726547 100644 --- a/webapp/packages/core-ui/src/InlineEditor/InlineEditor.tsx +++ b/webapp/packages/core-ui/src/InlineEditor/InlineEditor.tsx @@ -15,7 +15,6 @@ import { InputAutocompletion, Loader, s, - useInputAutocomplete, useObjectRef, useS, useTranslate, @@ -116,9 +115,6 @@ export const InlineEditor = observer( const inputRef = useRef(null); const isAutoCompleteEnabled = !!autoCompleteProposals; - const autocompleteState = useInputAutocomplete(inputRef, { - sourceHints: autoCompleteProposals ?? [], - }); useEffect(() => { if (autofocus && !disabled) { @@ -142,7 +138,7 @@ export const InlineEditor = observer( onKeyDown={handleKeyDown} {...rest} /> - {isAutoCompleteEnabled && } + {isAutoCompleteEnabled && }
Date: Mon, 23 Dec 2024 14:46:17 +0100 Subject: [PATCH 04/48] CB-3753 replaces with existing menu --- .../InputField/InputAutocompletion.module.css | 60 -------- .../InputField/InputAutocompletion.tsx | 119 ---------------- .../InputAutocompletionMenu.module.css | 48 +++++++ .../InputField/InputAutocompletionMenu.tsx | 128 ++++++++++++++++++ .../InputField/useInputAutocomplete.ts | 6 +- webapp/packages/core-blocks/src/index.ts | 2 +- .../core-ui/src/InlineEditor/InlineEditor.tsx | 99 +++++++------- 7 files changed, 233 insertions(+), 229 deletions(-) delete mode 100644 webapp/packages/core-blocks/src/FormControls/InputField/InputAutocompletion.module.css delete mode 100644 webapp/packages/core-blocks/src/FormControls/InputField/InputAutocompletion.tsx create mode 100644 webapp/packages/core-blocks/src/FormControls/InputField/InputAutocompletionMenu.module.css create mode 100644 webapp/packages/core-blocks/src/FormControls/InputField/InputAutocompletionMenu.tsx diff --git a/webapp/packages/core-blocks/src/FormControls/InputField/InputAutocompletion.module.css b/webapp/packages/core-blocks/src/FormControls/InputField/InputAutocompletion.module.css deleted file mode 100644 index fe6aaef55e..0000000000 --- a/webapp/packages/core-blocks/src/FormControls/InputField/InputAutocompletion.module.css +++ /dev/null @@ -1,60 +0,0 @@ -/* - * CloudBeaver - Cloud Database Manager - * Copyright (C) 2020-2024 DBeaver Corp and others - * - * Licensed under the Apache License, Version 2.0. - * you may not use this file except in compliance with the License. - */ -.menuItem { - composes: theme-ripple from global; -} - -.menu { - composes: theme-text-on-surface theme-background-surface theme-typography--caption theme-elevation-z3 theme-border-color-background from global; - display: flex; - flex-direction: column; - max-height: 300px; - width: auto; - overflow: auto; - outline: none; - z-index: 999; - border: 1px solid; - - & .menuItem { - background: transparent; - display: flex; - flex-direction: row; - align-items: center; - padding: 4px 8px; - text-align: left; - outline: none; - color: inherit; - cursor: pointer; - gap: 8px; - - & .itemIcon, - & .itemTitle { - position: relative; - } - - & .itemIcon { - width: 16px; - height: 16px; - overflow: hidden; - flex-shrink: 0; - - & .iconOrImage { - width: 100%; - height: 100%; - } - } - } -} - -.menuButton { - position: absolute; - right: 0; - padding: 0; - height: 100%; - width: 0px; -} diff --git a/webapp/packages/core-blocks/src/FormControls/InputField/InputAutocompletion.tsx b/webapp/packages/core-blocks/src/FormControls/InputField/InputAutocompletion.tsx deleted file mode 100644 index 801a8f3ce9..0000000000 --- a/webapp/packages/core-blocks/src/FormControls/InputField/InputAutocompletion.tsx +++ /dev/null @@ -1,119 +0,0 @@ -/* - * CloudBeaver - Cloud Database Manager - * Copyright (C) 2020-2024 DBeaver Corp and others - * - * Licensed under the Apache License, Version 2.0. - * you may not use this file except in compliance with the License. - */ -import { observer } from 'mobx-react-lite'; -import { forwardRef, useEffect, useRef } from 'react'; -import { Menu, MenuButton, type MenuInitialState, MenuItem, useMenuState } from 'reakit'; - -import { Icon } from '../../Icon.js'; -import { useCombinedRef } from '../../useCombinedRef.js'; -import { useS } from '../../useS.js'; -import style from './InputAutocompletion.module.css'; -import { type InputAutocompleteProposal, type InputAutocompleteStrategy, useInputAutocomplete } from './useInputAutocomplete.js'; - -interface AutocompletionProps { - sourceHints: InputAutocompleteProposal[]; - inputRef: React.RefObject; - matchStrategy?: InputAutocompleteStrategy; - placement?: MenuInitialState['placement']; - gutter?: number; - ref?: React.Ref; - propertyName?: string; - onSelect?: (proposal: InputAutocompleteProposal) => void; -} - -export const InputAutocompletion = observer( - forwardRef(function Autocompletion( - { sourceHints, placement = 'bottom-end', gutter = 1, matchStrategy, propertyName, inputRef, onSelect }: AutocompletionProps, - ref: React.Ref, - ) { - const styles = useS(style); - const menuRef = useRef(null); - const menu = useMenuState({ - placement: placement, - gutter: gutter, - }); - const autocompleteState = useInputAutocomplete(inputRef, { - sourceHints, - matchStrategy, - }); - - function handleSelect(proposal: InputAutocompleteProposal) { - menu.hide(); - autocompleteState.replaceCurrentWord(proposal.replacementString); - onSelect?.(proposal); - } - - function handleKeyDown(event: any) { - if (!autocompleteState.filteredSuggestions.length) { - return; - } - - switch (event.key) { - case 'ArrowDown': - event.preventDefault(); - menu.next(); - break; - case 'ArrowUp': - event.preventDefault(); - menu.previous(); - break; - case 'Escape': - menu.hide(); - break; - default: - break; - } - } - - const combinedRef = useCombinedRef(menuRef, ref); - - useEffect(() => { - inputRef.current?.addEventListener('keydown', handleKeyDown); - return () => { - inputRef.current?.removeEventListener('keydown', handleKeyDown); - }; - }, []); - - useEffect(() => { - if (menu.visible && (autocompleteState.filteredSuggestions === null || autocompleteState.filteredSuggestions.length === 0)) { - menu.hide(); - } - if (!menu.visible && autocompleteState.filteredSuggestions !== null && autocompleteState.filteredSuggestions.length !== 0) { - menu.show(); - } - }, [sourceHints, menu, autocompleteState.filteredSuggestions]); - - return ( - <> - - - - - {autocompleteState.filteredSuggestions.map(item => ( - handleSelect(item)} - > - {/* {item.icon && ( -
- {item.icon && typeof item.icon === 'string' ? : item.icon} -
- )} */} -
{item.displayString}
-
- ))} -
- - ); - }), -); diff --git a/webapp/packages/core-blocks/src/FormControls/InputField/InputAutocompletionMenu.module.css b/webapp/packages/core-blocks/src/FormControls/InputField/InputAutocompletionMenu.module.css new file mode 100644 index 0000000000..ad802b8035 --- /dev/null +++ b/webapp/packages/core-blocks/src/FormControls/InputField/InputAutocompletionMenu.module.css @@ -0,0 +1,48 @@ +/* + * CloudBeaver - Cloud Database Manager + * Copyright (C) 2020-2024 DBeaver Corp and others + * + * Licensed under the Apache License, Version 2.0. + * you may not use this file except in compliance with the License. + */ +.menuItem { + composes: theme-ripple from global; +} + +.menuItem { + background: transparent; + display: flex; + flex-direction: row; + align-items: center; + padding: 4px 8px; + text-align: left; + outline: none; + color: inherit; + cursor: pointer; + gap: 8px; + + & .itemIcon, + & .itemTitle { + position: relative; + } + + & .itemIcon { + width: 16px; + height: 16px; + overflow: hidden; + flex-shrink: 0; + + & .iconOrImage { + width: 100%; + height: 100%; + } + } +} + +.menuButton { + position: absolute; + right: 0; + padding: 0; + height: 100%; + width: 0px; +} diff --git a/webapp/packages/core-blocks/src/FormControls/InputField/InputAutocompletionMenu.tsx b/webapp/packages/core-blocks/src/FormControls/InputField/InputAutocompletionMenu.tsx new file mode 100644 index 0000000000..091936fc28 --- /dev/null +++ b/webapp/packages/core-blocks/src/FormControls/InputField/InputAutocompletionMenu.tsx @@ -0,0 +1,128 @@ +/* + * CloudBeaver - Cloud Database Manager + * Copyright (C) 2020-2024 DBeaver Corp and others + * + * Licensed under the Apache License, Version 2.0. + * you may not use this file except in compliance with the License. + */ +import { observer } from 'mobx-react-lite'; +import { forwardRef, useEffect, useRef } from 'react'; +import { MenuItem } from 'reakit'; + +import { Container } from '../../Containers/Container.js'; +import { getComputed } from '../../getComputed.js'; +import { IconOrImage } from '../../IconOrImage.js'; +import { Menu } from '../../Menu/Menu.js'; +import type { IMenuState } from '../../Menu/MenuStateContext.js'; +import { Text } from '../../Text.js'; +import { useS } from '../../useS.js'; +import style from './InputAutocompletionMenu.module.css'; +import { type InputAutocompleteProposal, type InputAutocompleteStrategy, useInputAutocomplete } from './useInputAutocomplete.js'; + +interface AutocompletionProps { + sourceHints: InputAutocompleteProposal[]; + inputRef: React.RefObject; + matchStrategy?: InputAutocompleteStrategy; + onSelect?: (proposal: InputAutocompleteProposal) => void; +} + +export const InputAutocompletionMenu = observer( + forwardRef(function InputAutocompletionMenu({ sourceHints, matchStrategy, inputRef, onSelect }: AutocompletionProps) { + const styles = useS(style); + const localRef = useRef(null); + const menuRef = useRef(); + const autocompleteState = useInputAutocomplete(inputRef, { + sourceHints, + matchStrategy, + }); + const hidden = getComputed(() => !autocompleteState.filteredSuggestions.length || false); + + function handleSelect(proposal: InputAutocompleteProposal) { + menuRef.current?.hide(); + autocompleteState.replaceCurrentWord(proposal.replacementString); + onSelect?.(proposal); + } + + function hideMenu() { + menuRef.current?.hide(); + autocompleteState.prevented = true; + } + + function handleMenuKeyDown(event: any) { + switch (event.key) { + case 'Escape': + hideMenu(); + break; + default: + break; + } + } + + function handleKeyDown(event: any) { + if (!autocompleteState.filteredSuggestions.length) { + return; + } + + switch (event.key) { + case 'ArrowDown': + event.preventDefault(); + menuRef.current?.next(); + break; + case 'ArrowUp': + event.preventDefault(); + menuRef.current?.previous(); + break; + case 'Escape': + hideMenu(); + break; + default: + break; + } + } + + useEffect(() => { + inputRef.current?.addEventListener('keydown', handleKeyDown); + localRef.current?.addEventListener('keydown', handleMenuKeyDown); + return () => { + inputRef.current?.removeEventListener('keydown', handleKeyDown); + localRef.current?.removeEventListener('keydown', handleMenuKeyDown); + }; + }, [inputRef.current, menuRef.current]); + + useEffect(() => { + if (menuRef.current?.visible && (autocompleteState.filteredSuggestions === null || autocompleteState.filteredSuggestions.length === 0)) { + menuRef.current?.hide(); + } + if (!menuRef.current?.visible && autocompleteState.filteredSuggestions !== null && autocompleteState.filteredSuggestions.length !== 0) { + menuRef.current?.show(); + } + }, [sourceHints, menuRef, autocompleteState.filteredSuggestions]); + + return ( +