| import SyntaxHighlighter from "react-syntax-highlighter"; |
| import { |
| ClipboardIcon, |
| PlayIcon, |
| BookmarkIcon as BookmarkIconOutline, |
| QuestionMarkCircleIcon, |
| MinusIcon, |
| } from "@heroicons/react/24/outline"; |
| import { CustomTooltip } from "../Library/Tooltip"; |
| import { monokai } from "react-syntax-highlighter/dist/esm/styles/hljs"; |
| import { format } from "prettier-sql"; |
| import { useEffect, useRef, useState } from "react"; |
| import { |
| useRunSqlInConversation, |
| useUpdateSqlQuery, |
| useGetUserProfile, |
| } from "@/hooks"; |
| import { |
| Alert, |
| AlertActions, |
| AlertDescription, |
| AlertTitle, |
| } from "../Catalyst/alert"; |
| import { Button } from "../Catalyst/button"; |
| import { Dialect } from "../Library/types"; |
| import Minimizer from "../Minimizer/Minimizer"; |
|
|
| function copyToClipboard(text: string) { |
| navigator.clipboard.writeText(text); |
| } |
|
|
| function classNames(...classes: string[]) { |
| return classes.filter(Boolean).join(" "); |
| } |
|
|
| const SPACES = ["Enter", "Tab", "Space", "Backspace"]; |
| const SPACE_CHARACTERS = [" ", "\n", "\t", "\r"]; |
|
|
| |
| function getCursorPositionWithoutSpaces( |
| text: string, |
| originalCursorPosition: number |
| ) { |
| const textBeforeCursor = text.substring(0, originalCursorPosition); |
| const nonSpaceCharacters = textBeforeCursor.replace(/\s/g, ""); |
| return nonSpaceCharacters.length; |
| } |
|
|
| |
| function getNewCursorPosition( |
| formattedText: string, |
| lastCharacterPosition: number |
| ) { |
| let nonSpaceCharacterCount = 0; |
| let i = 0; |
|
|
| |
| |
| for (; i < formattedText.length; i++) { |
| if (nonSpaceCharacterCount === lastCharacterPosition) { |
| break; |
| } |
|
|
| if (!SPACE_CHARACTERS.includes(formattedText[i])) { |
| nonSpaceCharacterCount++; |
| } |
| } |
|
|
| return i; |
| } |
|
|
| type SupportedFormatters = |
| | "bigquery" |
| | "db2" |
| | "hive" |
| | "mariadb" |
| | "mysql" |
| | "n1ql" |
| | "plsql" |
| | "postgresql" |
| | "redshift" |
| | "spark" |
| | "sql" |
| | "tsql"; |
|
|
| const formattedCodeOrInitial = (code: string, dialect: SupportedFormatters) => { |
| try { |
| return format(code, { language: dialect || Dialect.Postgres }); |
| } catch { |
| return code; |
| } |
| }; |
|
|
| export const CodeBlock = ({ |
| code, |
| dialect, |
| resultId, |
| onUpdateSQLRunResult, |
| onSaveSQLStringResult, |
| forChart = false, |
| minimize, |
| }: { |
| code: string; |
| resultId: string; |
| dialect?: string; |
| onUpdateSQLRunResult: (sql_string_result_id: string, arg: string) => void; |
| onSaveSQLStringResult: ( |
| data?: { created_at: string; chartjs_json: string } | void |
| ) => void; |
| forChart: boolean; |
| minimize?: boolean; |
| }) => { |
| const { data: profile } = useGetUserProfile(); |
| const [savedCode, setSavedCode] = useState<string>(() => |
| formattedCodeOrInitial(code, dialect as SupportedFormatters) |
| ); |
| const [formattedCode, setFormattedCode] = useState<string>(() => |
| formattedCodeOrInitial(code, dialect as SupportedFormatters) |
| ); |
| |
| const shouldHideSql = profile?.hide_sql_preference; |
| const [minimized, setMinimized] = useState( |
| minimize || shouldHideSql || false |
| ); |
| const textareaRef = useRef<HTMLTextAreaElement>(null); |
| const syntaxHighlighterId = `syntax-highlighter-${resultId}`; |
|
|
| const [lastChar, setLastChar] = useState<string>(""); |
| |
| const BookmarkIcon = BookmarkIconOutline; |
| const extraSpace = ""; |
|
|
| const { isPending: isPendingRunSql, mutate: runSql } = |
| useRunSqlInConversation( |
| { |
| sql: savedCode, |
| resultId: resultId, |
| }, |
| { |
| onSettled: (data, error) => { |
| if (error) { |
| console.error("onsettled error in: ", error); |
| } else { |
| if (data?.content) { |
| onUpdateSQLRunResult(resultId, data.content as string); |
| } |
| } |
| }, |
| } |
| ); |
|
|
| const { isPending: isPendingSaveSql, mutate: updateSQL } = useUpdateSqlQuery({ |
| onSettled: (data, error) => { |
| if (error) { |
| console.error("onsettled error in: ", error); |
| } else { |
| onSaveSQLStringResult(data); |
| } |
| }, |
| }); |
|
|
| function saveNewSQLString() { |
| if (!resultId) return; |
| updateSQL({ |
| sqlStringResultId: resultId, |
| code: savedCode, |
| forChart: forChart, |
| }); |
| } |
|
|
| useEffect(() => { |
| try { |
| |
| if (SPACES.includes(lastChar)) { |
| setFormattedCode(savedCode + extraSpace); |
| return; |
| } |
|
|
| |
| const savedCodeWithoutSpaces = savedCode.replace(/\s/g, ""); |
| const formattedCodeWithoutSpaces = formattedCode.replace(/\s/g, ""); |
| if ( |
| lastChar != "" && |
| savedCodeWithoutSpaces === formattedCodeWithoutSpaces |
| ) { |
| return; |
| } |
|
|
| const formatted = format(savedCode, { |
| language: (dialect as SupportedFormatters) || Dialect.Postgres, |
| }); |
| setFormattedCode(formatted + extraSpace); |
|
|
| if (textareaRef.current !== null) { |
| |
| const oldCursorPosition = getCursorPositionWithoutSpaces( |
| savedCode, |
| textareaRef.current.selectionStart |
| ); |
|
|
| textareaRef.current.value = formatted; |
|
|
| |
| const newCursorPosition = getNewCursorPosition( |
| formatted, |
| oldCursorPosition |
| ); |
|
|
| textareaRef.current.setSelectionRange( |
| newCursorPosition, |
| newCursorPosition, |
| "forward" |
| ); |
| } |
| } catch (e) { |
| setFormattedCode(savedCode); |
| } |
| }, [savedCode, formattedCode, lastChar, dialect]); |
|
|
| useEffect(() => { |
| if (!minimized && textareaRef.current !== null) { |
| textareaRef.current.value = formattedCode; |
| } |
| }, [minimized, formattedCode, textareaRef]); |
|
|
| const handleTextUpdate = (e: React.ChangeEvent<HTMLTextAreaElement>) => { |
| |
| setSavedCode(e.target.value); |
| }; |
|
|
| const handleKeyboardInput = (e: React.KeyboardEvent<HTMLTextAreaElement>) => { |
| setLastChar(e.code || e.key); |
|
|
| |
| if (e.key === "Tab") { |
| e.preventDefault(); |
| const { selectionStart, selectionEnd } = e.currentTarget; |
| |
| e.currentTarget.value = |
| e.currentTarget.value.substring(0, selectionStart) + |
| " " + |
| e.currentTarget.value.substring(selectionEnd); |
| e.currentTarget.selectionStart = selectionStart + 2; |
| e.currentTarget.selectionEnd = selectionEnd + 2; |
| |
| } else { |
| setSavedCode(textareaRef.current?.value || ""); |
| } |
| }; |
|
|
| |
| const mirrorScroll = () => { |
| if (textareaRef.current !== null) { |
| const { scrollLeft, scrollTop } = textareaRef.current; |
| const syntaxHighlighter = document.getElementById(syntaxHighlighterId); |
| if (syntaxHighlighter !== null) { |
| syntaxHighlighter.scrollLeft = scrollLeft; |
| syntaxHighlighter.scrollTop = scrollTop; |
| } |
| } |
| }; |
|
|
| const [isHelpOpen, setIsHelpOpen] = useState(false); |
| const openSQLForChartHelp = () => { |
| setIsHelpOpen(true); |
| }; |
|
|
| return ( |
| <Minimizer |
| minimized={minimized} |
| setMinimized={setMinimized} |
| label="Code block" |
| classes="bg-gray-900" |
| > |
| <div |
| role="button" |
| tabIndex={0} |
| className="flex flex-col relative" |
| onKeyDown={() => textareaRef.current?.focus()} |
| onClick={() => textareaRef.current?.focus()} |
| > |
| <textarea |
| spellCheck={false} |
| ref={textareaRef} |
| className="absolute h-full w-full border-0 inset-0 resize-none bg-transparent overflow-y-hidden overflow-x-scroll text-transparent p-2 font-mono caret-white outline-none focus:outline-none focus:border focus:ring-0 focus:rounded-xl whitespace-pre" |
| onChange={handleTextUpdate} |
| onKeyDown={handleKeyboardInput} |
| onScroll={mirrorScroll} |
| /> |
| <SyntaxHighlighter |
| // add dynamic ID based on resultId |
| id={syntaxHighlighterId} |
| children={formattedCode} |
| language="sql" // TODO: make dynamic to support multiple DB dialects? |
| style={monokai} |
| wrapLines={true} |
| customStyle={{ |
| flex: "1", |
| overflow: "scroll", |
| scrollbarWidth: "none", |
| background: "transparent", |
| }} |
| /> |
| |
| {/* Top right corner icons */} |
| <div className="absolute top-0 right-0 m-2 flex gap-1"> |
| {/* Help Icon */} |
| {forChart && ( |
| <CustomTooltip hoverText="Help"> |
| <button |
| tabIndex={-1} |
| onClick={openSQLForChartHelp} |
| className="p-1" |
| > |
| <QuestionMarkCircleIcon className="w-6 h-6 [&>path]:stroke-[2] group-hover:-rotate-12" /> |
| </button> |
| </CustomTooltip> |
| )} |
| </div> |
| |
| <div className="absolute bottom-0 right-0 m-2 flex gap-1"> |
| {/* Minimize Icon */} |
| <CustomTooltip hoverText="Minimize"> |
| {/* On minimize, also collapse if already expanded */} |
| <button |
| tabIndex={-1} |
| onClick={(e) => { |
| e.stopPropagation(); |
| setMinimized(true); |
| }} |
| className="p-1" |
| > |
| <MinusIcon className="w-6 h-6 [&>path]:stroke-[2] group-hover:-rotate-6" /> |
| </button> |
| </CustomTooltip> |
| |
| {/* Save Icon */} |
| <CustomTooltip hoverText="Save"> |
| <button |
| tabIndex={-1} |
| onClick={saveNewSQLString} |
| className="p-1" |
| disabled={isPendingSaveSql} |
| > |
| <BookmarkIcon |
| className={classNames( |
| isPendingSaveSql ? "animate-spin" : "group-hover:-rotate-6", |
| "w-6 h-6 [&>path]:stroke-[2]" |
| )} |
| /> |
| </button> |
| </CustomTooltip> |
| |
| {/* Copy Icon */} |
| <CustomTooltip clickText="COPIED!" hoverText="Copy"> |
| <button |
| tabIndex={-1} |
| onClick={() => copyToClipboard(savedCode)} |
| className="p-1" |
| > |
| <ClipboardIcon className="w-6 h-6 [&>path]:stroke-[2] group-hover:-rotate-6" /> |
| </button> |
| </CustomTooltip> |
| |
| {/* Run Icon */} |
| <CustomTooltip hoverText="Run"> |
| <button |
| tabIndex={-1} |
| onClick={() => { |
| runSql(); |
| }} |
| disabled={isPendingRunSql} |
| className="p-1" |
| > |
| <PlayIcon |
| className={classNames( |
| isPendingRunSql ? "animate-spin" : "group-hover:-rotate-12", |
| "w-6 h-6 [&>path]:stroke-[2]" |
| )} |
| /> |
| </button> |
| </CustomTooltip> |
| </div> |
| |
| {/* Help for editing queries when codeblock is linked to a chart */} |
| {forChart && ( |
| <Alert className="lg:ml-72" open={isHelpOpen} onClose={setIsHelpOpen}> |
| <AlertTitle> |
| Quick overview of how you can edit chart-linked queries |
| </AlertTitle> |
| <AlertDescription> |
| Charts are generated from the SQL results automatically. <br /> |
| <br /> |
| The first column returned by the query is used as the x-axis, and |
| the second column is used as the y-axis. |
| <br /> |
| <br /> |
| You can edit the query to change the chart type, add filters, or |
| change the x-axis and y-axis columns. |
| <br /> |
| <br /> |
| But the query must return at least two columns for the basic chart |
| types to work (labels and values respectively). |
| </AlertDescription> |
| <AlertActions> |
| <Button plain onClick={() => setIsHelpOpen(false)}> |
| Got it! |
| </Button> |
| </AlertActions> |
| </Alert> |
| )} |
| </div> |
| </Minimizer> |
| ); |
| }; |
|
|