From a6f73fc0829a181f7b73c6106c9a734a18ad3277 Mon Sep 17 00:00:00 2001 From: "J.C. Zhong" Date: Wed, 3 Apr 2024 10:08:28 -0700 Subject: [PATCH] feat: update text2sql ui (#1429) * feat: update text2sql ui * fix linter * address comments * use querycell keymap * add close popup behavior * clean up --- package.json | 2 +- .../components/AIAssistant/AICommandBar.scss | 73 +++ .../components/AIAssistant/AICommandBar.tsx | 240 ++++++++++ .../AIAssistant/AICommandInput.scss | 82 ++++ .../components/AIAssistant/AICommandInput.tsx | 211 +++++++++ .../AIAssistant/AICommandResultView.tsx | 142 ++++++ .../AIAssistant/QueryGenerationButton.tsx | 67 --- .../AIAssistant/QueryGenerationModal.scss | 59 --- .../AIAssistant/QueryGenerationModal.tsx | 414 ------------------ .../components/AIAssistant/TableSelector.tsx | 115 +++-- .../components/AIAssistant/TableTag.tsx | 48 -- .../AIAssistant/TextToSQLModeSelector.tsx | 41 -- .../webapp/components/DataDoc/DataDoc.scss | 18 +- .../DataDocQueryCell/DataDocQueryCell.tsx | 114 +++-- .../QueryCellTitle/QueryCellTitle.tsx | 1 - .../TranspileQueryModal/QueryComparison.tsx | 6 +- querybook/webapp/const/command.ts | 37 ++ querybook/webapp/const/keyMap.ts | 4 + querybook/webapp/hooks/useCommand.ts | 70 +++ .../ResizableTextArea/ResizableTextArea.tsx | 127 +++--- 20 files changed, 1094 insertions(+), 777 deletions(-) create mode 100644 querybook/webapp/components/AIAssistant/AICommandBar.scss create mode 100644 querybook/webapp/components/AIAssistant/AICommandBar.tsx create mode 100644 querybook/webapp/components/AIAssistant/AICommandInput.scss create mode 100644 querybook/webapp/components/AIAssistant/AICommandInput.tsx create mode 100644 querybook/webapp/components/AIAssistant/AICommandResultView.tsx delete mode 100644 querybook/webapp/components/AIAssistant/QueryGenerationButton.tsx delete mode 100644 querybook/webapp/components/AIAssistant/QueryGenerationModal.scss delete mode 100644 querybook/webapp/components/AIAssistant/QueryGenerationModal.tsx delete mode 100644 querybook/webapp/components/AIAssistant/TableTag.tsx delete mode 100644 querybook/webapp/components/AIAssistant/TextToSQLModeSelector.tsx create mode 100644 querybook/webapp/const/command.ts create mode 100644 querybook/webapp/hooks/useCommand.ts diff --git a/package.json b/package.json index ca11426e0..57451de37 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "querybook", - "version": "3.32.0", + "version": "3.33.0", "description": "A Big Data Webapp", "private": true, "scripts": { diff --git a/querybook/webapp/components/AIAssistant/AICommandBar.scss b/querybook/webapp/components/AIAssistant/AICommandBar.scss new file mode 100644 index 000000000..06e623b92 --- /dev/null +++ b/querybook/webapp/components/AIAssistant/AICommandBar.scss @@ -0,0 +1,73 @@ +.AICommandBar { + position: relative; + + .command-popup-view { + position: absolute; + top: calc( + -1 * var(--padding) - 36px - 1 * var(--margin-xs) + ); // 36px is the height of the warning message + left: calc(-1 * var(--padding)); + right: calc(-1 * var(--padding)); + z-index: 20; + border-radius: var(--border-radius-sm); + padding: var(--padding); + background-color: var(--bg); + box-shadow: 0 0px 8px var(--bg-dark); + overflow: hidden; + + .warning-message { + height: 36px; + } + + .discard-confirm-view { + position: absolute; + top: 0; + left: 0; + right: 0; + bottom: 0; + display: flex; + justify-content: center; + align-items: center; + + .discard-confirm-background { + position: absolute; + top: 0; + left: 0; + right: 0; + bottom: 0; + background-color: var(--bg-invert); + opacity: 0.5; + z-index: 21; + } + + .discard-confirm-dialog { + padding: var(--padding-lg); + border-radius: var(--border-radius-sm); + background-color: var(--bg); + z-index: 22; + } + } + } + + .popup-backdrop { + position: fixed; + top: 0; + left: 0; + right: 0; + bottom: 0; + z-index: 19; + } + + .popup-placeholder { + height: 40px; + } + + .cover-controls-banner { + position: absolute; + left: 0; + right: 0; + top: -100px; + height: 100px; + background-color: var(--bg); + } +} diff --git a/querybook/webapp/components/AIAssistant/AICommandBar.tsx b/querybook/webapp/components/AIAssistant/AICommandBar.tsx new file mode 100644 index 000000000..739305e6d --- /dev/null +++ b/querybook/webapp/components/AIAssistant/AICommandBar.tsx @@ -0,0 +1,240 @@ +import { set, uniq } from 'lodash'; +import React, { forwardRef, useCallback, useEffect, useState } from 'react'; + +import { AICommandInput } from 'components/AIAssistant/AICommandInput'; +import { AICommandResultView } from 'components/AIAssistant/AICommandResultView'; +import { ComponentType, ElementType } from 'const/analytics'; +import { + IQueryCellCommand, + QUERY_CELL_COMMANDS, + QueryCellCommandType, +} from 'const/command'; +import { IQueryEngine } from 'const/queryEngine'; +import { CommandRunner, useCommand } from 'hooks/useCommand'; +import { useEvent } from 'hooks/useEvent'; +import { useForwardedRef } from 'hooks/useForwardedRef'; +import { trackClick } from 'lib/analytics'; +import { TableToken } from 'lib/sql-helper/sql-lexer'; +import { matchKeyPress } from 'lib/utils/keyboard'; +import { analyzeCode } from 'lib/web-worker'; +import { Button, TextButton } from 'ui/Button/Button'; +import { IconButton } from 'ui/Button/IconButton'; +import { Message } from 'ui/Message/Message'; +import { IResizableTextareaHandles } from 'ui/ResizableTextArea/ResizableTextArea'; +import { StyledText } from 'ui/StyledText/StyledText'; + +import './AICommandBar.scss'; + +interface IQueryCellCommandBarProps { + query: string; + queryEngine: IQueryEngine; + engineId: number; + queryEngines: IQueryEngine[]; + queryEngineById: Record; + onUpdateQuery: (query: string, run: boolean) => void; + onUpdateEngineId: (engineId: number) => void; + onFormatQuery: () => void; + ref: React.Ref; +} + +const useTablesInQuery = (query: string, language: string) => { + const [tables, setTables] = useState([]); + + useEffect(() => { + if (!query) { + return; + } + + analyzeCode(query, 'autocomplete', language).then((codeAnalysis) => { + const tableReferences: TableToken[] = [].concat.apply( + [], + Object.values(codeAnalysis?.lineage.references ?? {}) + ); + setTables( + tableReferences.map(({ schema, name }) => `${schema}.${name}`) + ); + }); + }, [query, language]); + + return tables; +}; + +export const AICommandBar: React.FC = forwardRef( + ({ query = '', queryEngine, onUpdateQuery, onFormatQuery }, ref) => { + const defaultCommand = QUERY_CELL_COMMANDS.find( + (cmd) => cmd.name === (query ? 'edit' : 'generate') + ); + const tablesInQuery = useTablesInQuery(query, queryEngine.language); + const [tables, setTables] = useState(tablesInQuery); + const commandInputRef = useForwardedRef(ref); + const [showPopupView, setShowPopupView] = useState(false); + const [command, setCommand] = + useState(defaultCommand); + const [commandInputValue, setCommandInputValue] = useState(''); + const [commandRunner, setCommandRunner] = useState(); + const [commandKwargs, setCommandKwargs] = useState>( + {} + ); + const [showConfirm, setShowConfirm] = useState(false); + + const { + runCommand, + isRunning, + cancelCommand, + commandResult, + resetCommandResult, + } = useCommand(command, commandRunner); + + useEffect(() => { + setTables((tables) => uniq([...tablesInQuery, ...tables])); + }, [tablesInQuery]); + + useEffect(() => { + if (command.name === 'format') { + // Have to use a function here to prevent onFormatQuery from being called + setCommandRunner(() => onFormatQuery); + } else if (command.name === 'generate' || command.name === 'edit') { + setCommandKwargs({ + query_engine_id: queryEngine.id, + tables: tables, + question: commandInputValue, + original_query: command.name === 'generate' ? '' : query, + }); + } + }, [ + command.name, + commandInputValue, + onFormatQuery, + query, + queryEngine, + tables, + ]); + + const handleCommand = useCallback(() => { + runCommand(commandKwargs); + resetCommandResult(); + setShowPopupView(!command.inplace); + + if (command.name === 'generate' || command.name === 'edit') { + trackClick({ + component: ComponentType.AI_ASSISTANT, + element: ElementType.QUERY_GENERATION_BUTTON, + aux: { + mode: command.name, + question: commandKwargs.question, + tables, + }, + }); + } + }, [command, runCommand, setShowPopupView, commandKwargs]); + + const getCommandResultView = () => { + if (!commandResult) { + return null; + } + + if (command.name === 'generate' || command.name === 'edit') { + return ( + { + onUpdateQuery(query, false); + setShowPopupView(false); + resetCommandResult(); + }} + onDiscard={() => { + setShowPopupView(false); + resetCommandResult(); + }} + /> + ); + } + + // TODO: Handle other command types, or have it covered by AICommandResultView + return null; + }; + + const discardConfirmDOM = ( +
+
+
+ + Are you sure you want to discard the changes? + +
+
+
+
+ ); + + return ( +
+ {showPopupView && ( + <> + {/* This is a workaround to hide the query cell controls when hovering */} +
+
{ + if (commandResult) { + setShowConfirm(true); + } else { + setShowPopupView(false); + } + }} + /> + {/* Placeholder to prevent layout shift */} +
+ + )} +
+ {showPopupView && + command.type === QueryCellCommandType.AI && ( + + )} + { + setCommand( + (oldCommand) => command ?? defaultCommand + ); + setCommandInputValue(inputValue); + }} + onSubmit={handleCommand} + running={isRunning} + cancelGeneration={cancelCommand} + ref={commandInputRef} + /> + {showPopupView && getCommandResultView()} + {showConfirm && discardConfirmDOM} +
+
+ ); + } +); diff --git a/querybook/webapp/components/AIAssistant/AICommandInput.scss b/querybook/webapp/components/AIAssistant/AICommandInput.scss new file mode 100644 index 000000000..da6bacd35 --- /dev/null +++ b/querybook/webapp/components/AIAssistant/AICommandInput.scss @@ -0,0 +1,82 @@ +.AICommandInput { + flex: 1; + display: flex; + flex-direction: row; + align-items: flex-start; + background-color: var(--bg-lightest); + border-radius: var(--border-radius-sm); + + &:hover { + background-color: var(--bg-hover); + } + + .stars-icon { + position: relative; + color: var(--icon); + width: 40px; + height: 40px; + + display: flex; + align-items: center; + justify-content: center; + } + + .command-container { + height: 40px; + } + + .command { + color: var(--color-accent-dark); + background-color: var(--bg-dark); + line-height: 24px; + margin-top: var(--margin-xs); + padding-left: 2px; + padding-right: 2px; + vertical-align: middle; + font-weight: bold; + border-radius: 4px; + } + + .text2sql-mode { + color: var(--color-pink-dark); + } + + .question-text-area { + flex: 1; + min-width: auto; + line-height: 24px; + min-height: 40px; + overflow: hidden; + padding: 8px 8px; + } + + .button { + height: 28px; + margin: 6px; + } +} + +.AICommandInput-popover { + border-radius: var(--border-radius-sm); + overflow: hidden; + + .command-item { + display: flex; + gap: 8px; + justify-content: space-between; + font-size: var(--xsmall-text-size); + padding: 6px 12px; + + .command-name { + font-weight: bold; + } + + .command-hint { + color: var(--text-light); + } + } + + .active { + background-color: var(--bg-hover); + } +} diff --git a/querybook/webapp/components/AIAssistant/AICommandInput.tsx b/querybook/webapp/components/AIAssistant/AICommandInput.tsx new file mode 100644 index 000000000..92dd6030c --- /dev/null +++ b/querybook/webapp/components/AIAssistant/AICommandInput.tsx @@ -0,0 +1,211 @@ +import React, { + forwardRef, + useCallback, + useEffect, + useMemo, + useRef, + useState, +} from 'react'; + +import { IQueryCellCommand } from 'const/command'; +import { useForwardedRef } from 'hooks/useForwardedRef'; +import { matchKeyPress } from 'lib/utils/keyboard'; +import { Button } from 'ui/Button/Button'; +import { Icon } from 'ui/Icon/Icon'; +import { Popover } from 'ui/Popover/Popover'; +import { + IResizableTextareaHandles, + ResizableTextArea, +} from 'ui/ResizableTextArea/ResizableTextArea'; + +import './AICommandInput.scss'; + +interface AICommandInputProps { + commands: Array; + placeholder?: string; + running: boolean; + onCommandChange: (command: IQueryCellCommand, commandArg: string) => void; + onSubmit: () => void; + cancelGeneration: () => void; + ref: React.Ref; +} + +export const AICommandInput: React.FC = forwardRef( + ( + { commands = [], running, onCommandChange, onSubmit, cancelGeneration }, + ref + ) => { + const textareaRef = useForwardedRef(ref); + const anchorRef = useRef(null); + const [command, setCommand] = useState(); + const [commandValue, setCommandValue] = useState(''); + const [currentCommandItemIndex, setCurrentCommandItemIndex] = + useState(0); + + const filteredCommands = useMemo(() => { + if (command || !commandValue.startsWith('/')) { + return []; + } + return commands.filter((cmd) => + ('/' + cmd.name).startsWith(commandValue.toLowerCase()) + ); + }, [command, commandValue, commands]); + + useEffect(() => { + onCommandChange(command, commandValue); + }, [command, commandValue]); + + const setNewCommand = useCallback( + (newCommand: IQueryCellCommand) => { + setCommand(newCommand); + setCommandValue(''); + }, + [setCommand, setCommandValue] + ); + + const handleChange = useCallback( + (value: string) => { + if (command || !value.startsWith('/')) { + setCommandValue(value); + return; + } + + // when value starts with "/" + const newCommand = commands.find( + (cmd) => '/' + cmd.name === value.toLowerCase() + ); + + // found a matching command + if (newCommand) { + setNewCommand(newCommand); + } else { + setCommandValue(value); + } + }, + [command, commands, setNewCommand, setCommandValue] + ); + + const onKeyDown = useCallback( + (event: React.KeyboardEvent) => { + if (filteredCommands.length > 0) { + if ( + matchKeyPress(event, 'Enter') || + matchKeyPress(event, 'Tab') + ) { + setNewCommand( + filteredCommands[currentCommandItemIndex] + ); + event.preventDefault(); + return; + } + + if (matchKeyPress(event, 'Up')) { + setCurrentCommandItemIndex((prev) => + Math.max(prev - 1, 0) + ); + event.preventDefault(); + return; + } + if (matchKeyPress(event, 'Down')) { + setCurrentCommandItemIndex((prev) => + Math.min(prev + 1, filteredCommands.length - 1) + ); + event.preventDefault(); + return; + } + } else if (matchKeyPress(event, 'Enter') && !event.shiftKey) { + if (!running) { + onSubmit(); + } + event.preventDefault(); + } else if (matchKeyPress(event, 'Delete') && !commandValue) { + setCommand(undefined); + } + }, + [ + commandValue, + filteredCommands, + onSubmit, + currentCommandItemIndex, + running, + ] + ); + + const commandItemDom = ({ name, hint }) => ( +
+ {'/' + name} + + {hint} +
+ ); + + return ( +
+ + + +
+ {command && ( +
{'/' + command.name}
+ )} +
+ + {filteredCommands.length > 0 && ( + null} + > +
+ {filteredCommands.map((cmd, index) => { + return ( +
{ + setNewCommand(cmd); + textareaRef.current?.focus(); + }} + className={ + currentCommandItemIndex === index + ? 'active' + : undefined + } + > + {commandItemDom(cmd)} +
+ ); + })} +
+
+ )} + {running && ( +
+ ); + } +); diff --git a/querybook/webapp/components/AIAssistant/AICommandResultView.tsx b/querybook/webapp/components/AIAssistant/AICommandResultView.tsx new file mode 100644 index 000000000..46183f4bc --- /dev/null +++ b/querybook/webapp/components/AIAssistant/AICommandResultView.tsx @@ -0,0 +1,142 @@ +import React, { useCallback, useEffect, useState } from 'react'; + +import { TableSelector } from 'components/AIAssistant/TableSelector'; +import { QueryComparison } from 'components/TranspileQueryModal/QueryComparison'; +import { ComponentType, ElementType } from 'const/analytics'; +import { IQueryCellCommand } from 'const/command'; +import useNonEmptyState from 'hooks/useNonEmptyState'; +import { trackClick } from 'lib/analytics'; +import { Button } from 'ui/Button/Button'; + +interface IAICommandResultViewProps { + command: IQueryCellCommand; + commandKwargs: Record; + metastoreId: number; + originalQuery: string; + tables: string[]; + commandResult: Record; + isStreaming: boolean; + onContinue: () => void; + onTablesChange: (tables: string[]) => void; + onAccept: (query: string) => void; + onDiscard: () => void; +} + +export const AICommandResultView = ({ + command, + commandKwargs, + metastoreId, + originalQuery, + tables, + commandResult, + isStreaming, + onContinue, + onTablesChange, + onAccept, + onDiscard, +}: IAICommandResultViewProps) => { + const [newQuery, setNewQuery] = useNonEmptyState(''); + const [explanation, setExplanation] = useState(''); + const [foundTables, setFoundTables] = useState(false); + + useEffect(() => { + const { type, data } = commandResult; + + if (type === 'tables') { + onTablesChange(data); + setFoundTables(true); + } else { + const { + explanation, + query: rawNewQuery, + data: additionalData, + } = data; + setExplanation(explanation || additionalData); + setNewQuery(rawNewQuery); + setFoundTables(false); + } + }, [commandResult]); + + const handleAccept = useCallback(() => { + onAccept(newQuery); + trackClick({ + component: ComponentType.AI_ASSISTANT, + element: ElementType.QUERY_GENERATION_APPLY_BUTTON, + aux: { + mode: command.name, + question: commandKwargs.question, + tables, + query: newQuery, + }, + }); + }, [onAccept, newQuery]); + + const handleDiscard = useCallback(() => { + onDiscard(); + if (newQuery) { + trackClick({ + component: ComponentType.AI_ASSISTANT, + element: ElementType.QUERY_GENERATION_REJECT_BUTTON, + aux: { + mode: command.name, + question: commandKwargs.question, + tables, + query: newQuery, + }, + }); + } + }, [onAccept, newQuery]); + + const tablesDOM = ( +
+
Please review table(s) to use for the query
+ { + onTablesChange(tables); + setFoundTables(true); + }} + /> + {foundTables && tables.length > 0 && ( +
+
+ )} +
+ ); + + const queryDiffDOM = (originalQuery || newQuery) && ( +
+ +
+ ); + + const actionButtonsDOM = newQuery && !isStreaming && ( +
+
+ ); + + return ( +
+ {tablesDOM} + {explanation &&
{explanation}
} + {queryDiffDOM} + {actionButtonsDOM} +
+ ); +}; diff --git a/querybook/webapp/components/AIAssistant/QueryGenerationButton.tsx b/querybook/webapp/components/AIAssistant/QueryGenerationButton.tsx deleted file mode 100644 index 00bcb91e6..000000000 --- a/querybook/webapp/components/AIAssistant/QueryGenerationButton.tsx +++ /dev/null @@ -1,67 +0,0 @@ -import React, { useState } from 'react'; - -import PublicConfig from 'config/querybook_public_config.yaml'; -import { ComponentType, ElementType } from 'const/analytics'; -import { IQueryEngine } from 'const/queryEngine'; -import { trackClick } from 'lib/analytics'; -import { IconButton } from 'ui/Button/IconButton'; - -import { QueryGenerationModal } from './QueryGenerationModal'; - -const AIAssistantConfig = PublicConfig.ai_assistant; - -interface IProps { - dataCellId: number; - query: string; - engineId: number; - queryEngines: IQueryEngine[]; - queryEngineById: Record; - onUpdateQuery: (query: string, run: boolean) => void; - onUpdateEngineId: (engineId: number) => void; -} - -export const QueryGenerationButton = ({ - dataCellId, - query = '', - engineId, - queryEngines, - queryEngineById, - onUpdateQuery, - onUpdateEngineId, -}: IProps) => { - const [show, setShow] = useState(false); - - return ( - <> - {AIAssistantConfig.enabled && - AIAssistantConfig.query_generation.enabled && ( - { - setShow(true); - trackClick({ - component: ComponentType.AI_ASSISTANT, - element: - ElementType.QUERY_GENERATION_MODAL_OPEN_BUTTON, - }); - }} - /> - )} - {show && ( - setShow(false)} - /> - )} - - ); -}; diff --git a/querybook/webapp/components/AIAssistant/QueryGenerationModal.scss b/querybook/webapp/components/AIAssistant/QueryGenerationModal.scss deleted file mode 100644 index 4f6d2f64e..000000000 --- a/querybook/webapp/components/AIAssistant/QueryGenerationModal.scss +++ /dev/null @@ -1,59 +0,0 @@ -.QueryGenerationModal { - .title { - font-size: var(--text-size); - font-weight: var(--bold-font); - margin-bottom: var(--margin); - } - - .Modal-box { - .Modal-content { - min-height: 40vh; - } - - .Modal-bottom { - margin-top: 0 !important; - } - } - .question-bar { - flex: 1; - display: flex; - flex-direction: row; - align-items: center; - background-color: var(--bg-light); - border-radius: var(--border-radius-sm); - margin-top: 12px; - - &:hover { - background-color: var(--bg-hover); - } - - .stars-icon { - position: relative; - color: var(--icon); - padding: 8px; - - display: flex; - align-items: center; - } - - .text2sql-mode { - color: var(--color-pink-dark); - } - - .question-text-area { - flex: 1; - min-width: auto; - line-height: 24px; - min-height: 48px; - overflow: hidden; - padding: 12px 8px; - } - } - - .action-buttons { - display: flex; - flex-direction: row; - justify-content: flex-end; - margin-top: var(--margin); - } -} diff --git a/querybook/webapp/components/AIAssistant/QueryGenerationModal.tsx b/querybook/webapp/components/AIAssistant/QueryGenerationModal.tsx deleted file mode 100644 index ddbb62faf..000000000 --- a/querybook/webapp/components/AIAssistant/QueryGenerationModal.tsx +++ /dev/null @@ -1,414 +0,0 @@ -import { uniq } from 'lodash'; -import React, { useCallback, useEffect, useState } from 'react'; - -import { QueryEngineSelector } from 'components/QueryRunButton/QueryRunButton'; -import { QueryComparison } from 'components/TranspileQueryModal/QueryComparison'; -import { AICommandType } from 'const/aiAssistant'; -import { ComponentType, ElementType } from 'const/analytics'; -import { IQueryEngine } from 'const/queryEngine'; -import { SurveySurfaceType } from 'const/survey'; -import { useSurveyTrigger } from 'hooks/ui/useSurveyTrigger'; -import { useAISocket } from 'hooks/useAISocket'; -import { trackClick } from 'lib/analytics'; -import { TableToken } from 'lib/sql-helper/sql-lexer'; -import { matchKeyPress } from 'lib/utils/keyboard'; -import { analyzeCode } from 'lib/web-worker'; -import { Button } from 'ui/Button/Button'; -import { Checkbox } from 'ui/Checkbox/Checkbox'; -import { Icon } from 'ui/Icon/Icon'; -import { Message } from 'ui/Message/Message'; -import { Modal } from 'ui/Modal/Modal'; -import { ResizableTextArea } from 'ui/ResizableTextArea/ResizableTextArea'; -import { StyledText } from 'ui/StyledText/StyledText'; -import { Tag } from 'ui/Tag/Tag'; - -import { TableSelector } from './TableSelector'; -import { TableTag } from './TableTag'; -import { TextToSQLMode, TextToSQLModeSelector } from './TextToSQLModeSelector'; - -import './QueryGenerationModal.scss'; - -interface IProps { - query: string; - engineId: number; - queryEngines: IQueryEngine[]; - queryEngineById: Record; - onUpdateQuery: (query: string, run: boolean) => void; - onUpdateEngineId: (engineId: number) => void; - onHide: () => void; -} - -const useTablesInQuery = (query, language) => { - const [tables, setTables] = useState([]); - - useEffect(() => { - if (!query) { - return; - } - - analyzeCode(query, 'autocomplete', language).then((codeAnalysis) => { - const tableReferences: TableToken[] = [].concat.apply( - [], - Object.values(codeAnalysis?.lineage.references ?? {}) - ); - setTables( - tableReferences.map(({ schema, name }) => `${schema}.${name}`) - ); - }); - }, [query, language]); - - return tables; -}; - -const useSQLGeneration = ( - onData: (data: { type?: string; data: { [key: string]: string } }) => void -): { - generating: boolean; - generateSQL: (data: { - query_engine_id: number; - tables: string[]; - question: string; - original_query: string; - }) => void; - cancelGeneration: () => void; -} => { - const socket = useAISocket(AICommandType.TEXT_TO_SQL, onData); - return { - generating: socket.loading, - generateSQL: socket.emit, - cancelGeneration: socket.cancel, - }; -}; - -export const QueryGenerationModal = ({ - query = '', - engineId, - queryEngines, - queryEngineById, - onUpdateQuery, - onUpdateEngineId, - onHide, -}: IProps) => { - const tablesInQuery = useTablesInQuery( - query, - queryEngineById[engineId]?.language - ); - const [question, setQuestion] = useState(''); - const [tables, setTables] = useState(tablesInQuery); - const [textToSQLMode, setTextToSQLMode] = useState( - !!query ? TextToSQLMode.EDIT : TextToSQLMode.GENERATE - ); - const [newQuery, setNewQuery] = useState(''); - const [streamData, setStreamData] = useState<{ [key: string]: string }>({}); - const [foundTables, setFoundTables] = useState([]); - - const onData = useCallback(({ type, data }) => { - if (type === 'tables') { - setTables([...data.slice(0, 1)]); // select the first table by default - setFoundTables(data); - } else { - setStreamData(data); - } - }, []); - - const { generating, generateSQL, cancelGeneration } = - useSQLGeneration(onData); - - useEffect(() => { - if (!generating) { - setTables((tables) => uniq([...tablesInQuery, ...tables])); - } - }, [tablesInQuery, generating]); - - const { explanation, query: rawNewQuery, data } = streamData; - - useEffect(() => { - if (rawNewQuery) { - setNewQuery(rawNewQuery); - } - }, [rawNewQuery]); - - const triggerSurvey = useSurveyTrigger(); - useEffect(() => { - if (!newQuery || generating) { - return; - } - triggerSurvey(SurveySurfaceType.TEXT_TO_SQL, { - question, - tables, - query: newQuery, - }); - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [newQuery, triggerSurvey, generating]); - - const onGenerate = useCallback(() => { - setFoundTables([]); - generateSQL({ - query_engine_id: engineId, - tables, - question, - original_query: textToSQLMode === TextToSQLMode.EDIT ? query : null, - }); - trackClick({ - component: ComponentType.AI_ASSISTANT, - element: ElementType.QUERY_GENERATION_BUTTON, - aux: { - mode: textToSQLMode, - question, - tables, - }, - }); - }, [engineId, question, tables, query, generateSQL, trackClick]); - - const onKeyDown = useCallback( - (event: React.KeyboardEvent) => { - if ( - !generating && - matchKeyPress(event, 'Enter') && - !event.shiftKey - ) { - onGenerate(); - } - }, - [onGenerate] - ); - - const handleKeepQuery = useCallback(() => { - onUpdateQuery(newQuery, false); - setTextToSQLMode(TextToSQLMode.EDIT); - setQuestion(''); - setNewQuery(''); - trackClick({ - component: ComponentType.AI_ASSISTANT, - element: ElementType.QUERY_GENERATION_KEEP_BUTTON, - aux: { - mode: textToSQLMode, - question, - tables, - query: newQuery, - }, - }); - }, [newQuery, onUpdateQuery, textToSQLMode, question, tables]); - - const questionBarDOM = ( -
- - - -
- -
- - {generating && ( -
- ); - - const tablesDOM = foundTables.length > 0 && ( -
-
- Please review the tables below that I found for your question. - Select the tables you would like to use or you can also search - for tables above. -
-
- {foundTables.map((table) => ( -
- - setTables((oldTables) => - checked - ? uniq([...oldTables, table]) - : oldTables.filter((t) => t !== table) - ) - } - /> - -
- ))} -
-
-
-
- ); - - const bottomDOM = newQuery && !generating && ( -
-
- ); - - return ( - { - cancelGeneration(); - onHide(); - }} - className="QueryGenerationModal" - bottomDOM={bottomDOM} - > -
- -
- - Please select query engine and table(s) to get started, - or AI will try the best to find the table(s) for you. - -
- -
- -
-
-
- - {questionBarDOM} - {tablesDOM} - {(explanation || data) && ( -
{explanation || data}
- )} - - {(query || newQuery) && ( -
- - {New Query} -
- } - disableHighlight={generating} - hideEmptyQuery={true} - /> -
- )} -
- - ); -}; diff --git a/querybook/webapp/components/AIAssistant/TableSelector.tsx b/querybook/webapp/components/AIAssistant/TableSelector.tsx index ed7f879ec..c3c752a64 100644 --- a/querybook/webapp/components/AIAssistant/TableSelector.tsx +++ b/querybook/webapp/components/AIAssistant/TableSelector.tsx @@ -1,24 +1,23 @@ -import React, { useCallback, useState } from 'react'; +import React, { useCallback, useMemo, useState } from 'react'; +import { components, MultiValueProps } from 'react-select'; import AsyncSelect, { Props as AsyncProps } from 'react-select/async'; +import { TableTooltipByName } from 'components/CodeMirrorTooltip/TableTooltip'; import { asyncReactSelectStyles, makeReactSelectStyle, } from 'lib/utils/react-select'; import { SearchTableResource } from 'resource/search'; import { overlayRoot } from 'ui/Overlay/Overlay'; - -import { TableTag } from './TableTag'; +import { Popover } from 'ui/Popover/Popover'; +import { PopoverHoverWrapper } from 'ui/Popover/PopoverHoverWrapper'; interface ITableSelectProps { metastoreId: number; tableNames: string[]; onTableNamesChange: (tableNames: string[]) => void; usePortalMenu?: boolean; - selectProps?: Partial>; - - // remove the selected table name after select clearAfterSelect?: boolean; } @@ -33,7 +32,17 @@ export const TableSelector: React.FunctionComponent = ({ const [searchText, setSearchText] = useState(''); const asyncSelectProps: Partial> = {}; const tableReactSelectStyle = React.useMemo( - () => makeReactSelectStyle(usePortalMenu, asyncReactSelectStyles), + () => + makeReactSelectStyle(usePortalMenu, { + ...asyncReactSelectStyles, + multiValue: (styles) => { + return { + ...styles, + color: 'var(--color-accent-dark)', + backgroundColor: 'var(--color-accent-lightest-0)', + }; + }, + }), [usePortalMenu] ); if (usePortalMenu) { @@ -55,7 +64,7 @@ export const TableSelector: React.FunctionComponent = ({ ); const tableNameOptions = filteredTableNames.map( ({ id, schema, name }) => ({ - value: id, + value: `${schema}.${name}`, label: `${schema}.${name}`, }) ); @@ -64,46 +73,56 @@ export const TableSelector: React.FunctionComponent = ({ [metastoreId, tableNames] ); + const MultiValueTableContainer = useMemo( + () => (props: MultiValueProps<{ label: string; value: string }>) => { + return ( + + {(showPopover, anchorElement) => ( + <> + + {showPopover && ( + null} + anchor={anchorElement} + layout={['right']} + > + + + )} + + )} + + ); + }, + [metastoreId] + ); + return ( -
- { - const newTableName = option?.label ?? null; - if (newTableName == null) { - onTableNamesChange([]); - return; - } - const newTableNames = tableNames.concat(newTableName); - onTableNamesChange(newTableNames); - }} - loadOptions={loadOptions} - defaultOptions={[]} - inputValue={searchText} - onInputChange={(text) => setSearchText(text)} - noOptionsMessage={() => (searchText ? 'No table found.' : null)} - {...asyncSelectProps} - {...selectProps} - /> - {tableNames.length ? ( -
- {tableNames.map((tableName) => ( - { - const newTableNames = tableNames.filter( - (name) => name !== tableName - ); - onTableNamesChange(newTableNames); - }} - highlighted - /> - ))} -
- ) : null} -
+ { + onTableNamesChange(options.map((option) => option.value)); + }} + loadOptions={loadOptions} + defaultOptions={[]} + inputValue={searchText} + value={tableNames.map((tableName) => ({ + value: tableName, + label: tableName, + }))} + onInputChange={(text) => setSearchText(text)} + noOptionsMessage={() => (searchText ? 'No table found.' : null)} + isMulti + components={{ + MultiValueContainer: MultiValueTableContainer, + }} + {...asyncSelectProps} + {...selectProps} + /> ); }; diff --git a/querybook/webapp/components/AIAssistant/TableTag.tsx b/querybook/webapp/components/AIAssistant/TableTag.tsx deleted file mode 100644 index fd34acd26..000000000 --- a/querybook/webapp/components/AIAssistant/TableTag.tsx +++ /dev/null @@ -1,48 +0,0 @@ -import React from 'react'; - -import { TableTooltipByName } from 'components/CodeMirrorTooltip/TableTooltip'; -import { Popover } from 'ui/Popover/Popover'; -import { PopoverHoverWrapper } from 'ui/Popover/PopoverHoverWrapper'; -import { HoverIconTag } from 'ui/Tag/HoverIconTag'; - -interface ITableTagProps { - metastoreId: number; - tableName: string; - onIconClick?: () => void; - highlighted?: boolean; -} - -export const TableTag: React.FunctionComponent = ({ - metastoreId, - tableName, - onIconClick, - highlighted = false, -}) => ( - - {(showPopover, anchorElement) => ( - <> - - {showPopover && ( - null} - anchor={anchorElement} - layout={['right']} - > - - - )} - - )} - -); diff --git a/querybook/webapp/components/AIAssistant/TextToSQLModeSelector.tsx b/querybook/webapp/components/AIAssistant/TextToSQLModeSelector.tsx deleted file mode 100644 index fd8db9ae5..000000000 --- a/querybook/webapp/components/AIAssistant/TextToSQLModeSelector.tsx +++ /dev/null @@ -1,41 +0,0 @@ -import React from 'react'; - -import { Dropdown } from 'ui/Dropdown/Dropdown'; -import { ListMenu } from 'ui/Menu/ListMenu'; - -export enum TextToSQLMode { - GENERATE = 'GENERATE', - EDIT = 'EDIT', -} - -interface IProps { - selectedMode: TextToSQLMode; - modes: TextToSQLMode[]; - onModeSelect: (mode: TextToSQLMode) => any; -} - -export const TextToSQLModeSelector: React.FC = ({ - selectedMode, - modes, - onModeSelect, -}) => { - const engineItems = modes.map((mode) => ({ - name: {mode}, - onClick: onModeSelect.bind(null, mode), - checked: selectedMode === mode, - })); - - return ( -
{selectedMode}
} - layout={['bottom', 'left']} - className="engine-selector-dropdown" - > - {engineItems.length > 1 && ( -
- -
- )} -
- ); -}; diff --git a/querybook/webapp/components/DataDoc/DataDoc.scss b/querybook/webapp/components/DataDoc/DataDoc.scss index 545bd03a4..42d4f406d 100644 --- a/querybook/webapp/components/DataDoc/DataDoc.scss +++ b/querybook/webapp/components/DataDoc/DataDoc.scss @@ -150,8 +150,7 @@ .additional-dropdown-button, .add-snippet-wrapper, .query-editor-float-buttons-wrapper, - .chart-cell-controls, - .QueryGenerationButton { + .chart-cell-controls { opacity: 0; transition: opacity 0.2s ease-out; } @@ -169,10 +168,21 @@ .additional-dropdown-button, .add-snippet-wrapper, .query-editor-float-buttons-wrapper, - .chart-cell-controls, - .QueryGenerationButton { + .chart-cell-controls { opacity: 1; } + + .AICommandInput { + .stars-icon { + color: var(--color-accent); + } + } + + .QueryCellTitle { + .IconButton { + color: var(--color-accent); + } + } } } } diff --git a/querybook/webapp/components/DataDocQueryCell/DataDocQueryCell.tsx b/querybook/webapp/components/DataDocQueryCell/DataDocQueryCell.tsx index fa7250b7e..245dfa048 100644 --- a/querybook/webapp/components/DataDocQueryCell/DataDocQueryCell.tsx +++ b/querybook/webapp/components/DataDocQueryCell/DataDocQueryCell.tsx @@ -8,7 +8,7 @@ import React from 'react'; import toast from 'react-hot-toast'; import { connect } from 'react-redux'; -import { QueryGenerationButton } from 'components/AIAssistant/QueryGenerationButton'; +import { AICommandBar } from 'components/AIAssistant/AICommandBar'; import { DataDocQueryExecutions } from 'components/DataDocQueryExecutions/DataDocQueryExecutions'; import { QueryCellTitle } from 'components/QueryCellTitle/QueryCellTitle'; import { runQuery, transformQuery } from 'components/QueryComposer/RunQuery'; @@ -23,6 +23,7 @@ import { QuerySnippetInsertionModal } from 'components/QuerySnippetInsertionModa import { TemplatedQueryView } from 'components/TemplateQueryView/TemplatedQueryView'; import { TranspileQueryModal } from 'components/TranspileQueryModal/TranspileQueryModal'; import { UDFForm } from 'components/UDFForm/UDFForm'; +import PublicConfig from 'config/querybook_public_config.yaml'; import { ComponentType, ElementType } from 'const/analytics'; import { IDataQueryCellMeta, @@ -44,7 +45,12 @@ import { import { DEFAULT_ROW_LIMIT } from 'lib/sql-helper/sql-limiter'; import { getPossibleTranspilers } from 'lib/templated-query/transpile'; import { enableResizable } from 'lib/utils'; -import { getShortcutSymbols, KeyMap, matchKeyPress } from 'lib/utils/keyboard'; +import { + getShortcutSymbols, + KeyMap, + matchKeyMap, + matchKeyPress, +} from 'lib/utils/keyboard'; import { doesLanguageSupportUDF } from 'lib/utils/udf'; import * as dataDocActions from 'redux/dataDoc/action'; import * as dataSourcesActions from 'redux/dataSources/action'; @@ -61,6 +67,7 @@ import { Dropdown } from 'ui/Dropdown/Dropdown'; import { Icon } from 'ui/Icon/Icon'; import { IListMenuItem, ListMenu } from 'ui/Menu/ListMenu'; import { Modal } from 'ui/Modal/Modal'; +import { IResizableTextareaHandles } from 'ui/ResizableTextArea/ResizableTextArea'; import { AccentText } from 'ui/StyledText/StyledText'; import { ISelectedRange } from './common'; @@ -68,6 +75,8 @@ import { ErrorQueryCell } from './ErrorQueryCell'; import './DataDocQueryCell.scss'; +const AIAssistantConfig = PublicConfig.ai_assistant; + const ON_CHANGE_DEBOUNCE_MS = 500; const FORMAT_QUERY_SHORTCUT = getShortcutSymbols( KeyMap.queryEditor.formatQuery.key @@ -126,6 +135,7 @@ interface IState { class DataDocQueryCellComponent extends React.PureComponent { private queryEditorRef = React.createRef(); private runButtonRef = React.createRef(); + private commandInputRef = React.createRef(); public constructor(props) { super(props); @@ -224,6 +234,7 @@ class DataDocQueryCellComponent extends React.PureComponent { public _keyMapMemo(engines: IQueryEngine[]) { const keyMap = { [KeyMap.queryEditor.runQuery.key]: this.clickOnRunButton, + [KeyMap.queryEditor.focusCommandInput.key]: this.focusCommandInput, }; for (const [index, engine] of engines.entries()) { @@ -367,6 +378,11 @@ class DataDocQueryCellComponent extends React.PureComponent { } } + @bind + public focusCommandInput() { + this.commandInputRef.current?.focus(); + } + @bind public handleChange(query: string, run: boolean = false) { this.setState( @@ -746,40 +762,64 @@ class DataDocQueryCellComponent extends React.PureComponent { ); return ( -
- - {queryTitleDOM} - -
- +
+ + {queryTitleDOM} + +
+ + {this.getAdditionalDropDownButtonDOM()} +
+
+ {AIAssistantConfig.enabled && isEditable && ( + - {this.getAdditionalDropDownButtonDOM()} -
-
+ )} + ); } @@ -813,18 +853,6 @@ class DataDocQueryCellComponent extends React.PureComponent { const editorDOM = !queryCollapsed && (
- = ({ icon={socket.loading ? 'Loading' : 'Hash'} size={18} tooltip="AI: generate title" - color={!value && query ? 'accent' : undefined} onClick={handleTitleGenerationClick} /> )} diff --git a/querybook/webapp/components/TranspileQueryModal/QueryComparison.tsx b/querybook/webapp/components/TranspileQueryModal/QueryComparison.tsx index ba9e2e0ba..4a72d46f8 100644 --- a/querybook/webapp/components/TranspileQueryModal/QueryComparison.tsx +++ b/querybook/webapp/components/TranspileQueryModal/QueryComparison.tsx @@ -14,6 +14,7 @@ export const QueryComparison: React.FC<{ toQueryTitle?: string | React.ReactNode; disableHighlight?: boolean; hideEmptyQuery?: boolean; + autoHeight?: boolean; }> = ({ fromQuery, toQuery, @@ -21,6 +22,7 @@ export const QueryComparison: React.FC<{ toQueryTitle, disableHighlight, hideEmptyQuery, + autoHeight = false, }) => { const hasHiddenQuery = hideEmptyQuery && (!fromQuery || !toQuery); @@ -76,7 +78,7 @@ export const QueryComparison: React.FC<{ highlightRanges={removedRanges} query={fromQuery} maxEditorHeight={'40vh'} - autoHeight={false} + autoHeight={autoHeight} />
)} @@ -95,7 +97,7 @@ export const QueryComparison: React.FC<{ highlightRanges={addedRanges} query={toQuery} maxEditorHeight={'40vh'} - autoHeight={false} + autoHeight={autoHeight} />
)} diff --git a/querybook/webapp/const/command.ts b/querybook/webapp/const/command.ts new file mode 100644 index 000000000..456194fa4 --- /dev/null +++ b/querybook/webapp/const/command.ts @@ -0,0 +1,37 @@ +import { AICommandType } from './aiAssistant'; + +export enum QueryCellCommandType { + SYNC = 'sync', // Command that is executed synchronously + ASYNC = 'async', // Command that is executed asynchronously, e.g. API + AI = 'ai', // Command that is executed by AI assistant, which is through websocket +} +export interface IQueryCellCommand { + name: string; + hint: string; + type: QueryCellCommandType; + inplace: boolean; // Whether the command will modify the cell content directly + aiCommand?: AICommandType; +} + +export const QUERY_CELL_COMMANDS: Array = [ + { + name: 'generate', + hint: 'Generate a new query', + type: QueryCellCommandType.AI, + inplace: false, + aiCommand: AICommandType.TEXT_TO_SQL, + }, + { + name: 'edit', + hint: 'Edit the query', + type: QueryCellCommandType.AI, + inplace: false, + aiCommand: AICommandType.TEXT_TO_SQL, + }, + { + name: 'format', + hint: 'Format the query', + type: QueryCellCommandType.SYNC, + inplace: true, + }, +]; diff --git a/querybook/webapp/const/keyMap.ts b/querybook/webapp/const/keyMap.ts index 5ea9ef806..9958a7e5e 100644 --- a/querybook/webapp/const/keyMap.ts +++ b/querybook/webapp/const/keyMap.ts @@ -136,6 +136,10 @@ const DEFAULT_KEY_MAP = { key: 'Cmd-Alt-Down', name: 'Select the same position on the below line and then edit all selected lines together', }, + focusCommandInput: { + key: 'Cmd-I', + name: 'Focus command input', + }, }, }; diff --git a/querybook/webapp/hooks/useCommand.ts b/querybook/webapp/hooks/useCommand.ts new file mode 100644 index 000000000..be7b82b4d --- /dev/null +++ b/querybook/webapp/hooks/useCommand.ts @@ -0,0 +1,70 @@ +import { useCallback, useEffect, useRef, useState } from 'react'; + +import { AICommandType } from 'const/aiAssistant'; +import { IQueryCellCommand, QueryCellCommandType } from 'const/command'; +import { useAISocket } from 'hooks/useAISocket'; + +type SyncCommandRunner = (kwargs: Record) => any; +type AsyncCommandRunner = (kwargs: Record) => Promise; + +export type CommandRunner = SyncCommandRunner | AsyncCommandRunner; + +export const useCommand = ( + command: IQueryCellCommand, + commandRunner?: CommandRunner +): { + runCommand: (kwargs: Record) => void; + isRunning: boolean; + commandResult: any; + cancelCommand: () => void; + resetCommandResult: () => void; +} => { + const [isRunning, setIsRunning] = useState(false); + const [commandResult, setCommandResult] = useState(); + + const socket = useAISocket(command.aiCommand, setCommandResult); + + const runCommand = useCallback( + (kwargs: Record) => { + if (command.type === QueryCellCommandType.SYNC) { + setIsRunning(true); + const result = (commandRunner as SyncCommandRunner)(kwargs); + setCommandResult(result); + setIsRunning(false); + } else if (command.type === QueryCellCommandType.ASYNC) { + setIsRunning(true); + (commandRunner as AsyncCommandRunner)(kwargs) + .then((result) => { + if (!isRunning) { + setCommandResult(result); + } + }) + .finally(() => { + setIsRunning(false); + }); + } else if ( + command.type === QueryCellCommandType.AI && + socket.emit + ) { + socket.emit(kwargs); + } + }, + [command, commandRunner, socket.emit] + ); + + return { + runCommand, + isRunning: + command.type === QueryCellCommandType.AI + ? socket.loading + : isRunning, + commandResult, + cancelCommand: + command.type === QueryCellCommandType.AI + ? socket.cancel + : () => { + setIsRunning(false); + }, + resetCommandResult: () => setCommandResult(undefined), + }; +}; diff --git a/querybook/webapp/ui/ResizableTextArea/ResizableTextArea.tsx b/querybook/webapp/ui/ResizableTextArea/ResizableTextArea.tsx index f605bfa9b..d516de380 100644 --- a/querybook/webapp/ui/ResizableTextArea/ResizableTextArea.tsx +++ b/querybook/webapp/ui/ResizableTextArea/ResizableTextArea.tsx @@ -1,10 +1,20 @@ import clsx from 'clsx'; import { throttle } from 'lodash'; -import React, { useCallback, useEffect, useRef } from 'react'; +import React, { + forwardRef, + useCallback, + useEffect, + useImperativeHandle, + useRef, +} from 'react'; import styled from 'styled-components'; import { useResizeObserver } from 'hooks/useResizeObserver'; +export interface IResizableTextareaHandles { + focus: () => void; +} + export interface IResizableTextareaProps extends Omit< React.TextareaHTMLAttributes, @@ -16,6 +26,7 @@ export interface IResizableTextareaProps disabled?: boolean; autoResize?: boolean; rows?: number; + ref?: React.Ref; onChange: (value: string) => any; } @@ -46,56 +57,74 @@ const StyledTextarea = styled.textarea` } `; -export const ResizableTextArea: React.FC = ({ - value = '', - className = '', - transparent = false, - disabled = false, - autoResize = true, - rows = 1, - onChange, +export const ResizableTextArea = forwardRef< + IResizableTextareaHandles, + IResizableTextareaProps +>( + ( + { + value = '', + className = '', + transparent = false, + disabled = false, + autoResize = true, + rows = 1, + onChange, + + ...textareaProps + }, + ref + ) => { + const textareaRef = useRef(); + const autoHeight = useCallback( + throttle(() => { + if (textareaRef.current && autoResize) { + const textarea = textareaRef.current; + textarea.style.height = 'auto'; + textarea.style.height = `${textarea.scrollHeight}px`; + } + }, 500), + [autoResize] + ); - ...textareaProps -}) => { - const textareaRef = useRef(); - const autoHeight = useCallback( - throttle(() => { - if (textareaRef.current && autoResize) { - const textarea = textareaRef.current; - textarea.style.height = 'auto'; - textarea.style.height = `${textarea.scrollHeight}px`; - } - }, 500), - [autoResize] - ); + useEffect(() => { + autoHeight(); + }, [value, autoResize]); - useEffect(() => { - autoHeight(); - }, [value, autoResize]); + useResizeObserver(textareaRef.current, autoHeight); - useResizeObserver(textareaRef.current, autoHeight); + const handleChange = useCallback( + (evt: React.ChangeEvent) => { + onChange(evt.target.value); + }, + [onChange] + ); - const handleChange = useCallback( - (evt: React.ChangeEvent) => { - onChange(evt.target.value); - }, - [onChange] - ); + useImperativeHandle( + ref, + () => ({ + focus: () => { + textareaRef.current?.focus(); + }, + }), + [] + ); - return ( - - ); -}; + return ( + + ); + } +);