Inline editor feature preview (#33541)

* add edge function knowledge

* deploy edge function mutaton

* add edge function block with deploy

* update function url

* update tools

* editor sql

* add templates

* use monaco editor

* hook up templates

* fixes

* add behind feature flag

* preview docs

* move editor

* initialPrompt

* consolidate widgets

* remove generic editor

* move logic

* fix ts

* include schema metadata

* Fix width of save snippet button

* Set tooltip position

* Use inlineLink component

* Remove unnecessary z index

* Lint

* Lint import statements in EditorPanel

* fixes

* fix keyboard shortcut

* show/hide results and invalidation

* fix focus issues in widget

* fixes

* fix ts

* Support cmd enter shortcut to run query in InlineEditor

* Update InlineEditorPreview to use admonition

* prompt updates

* add discussion url to preview

* schemas array

* One last clean up

---------

Co-authored-by: Joshen Lim <joshenlimek@gmail.com>
This commit is contained in:
Saxon FletcherandJoshen Lim authored and GitHub committed 2025-02-19 14:58:58 +10:00
1 parent 710131456f
commit 28fe5811de
32 files changed
+1471 -252

No files matched your search

@@ -5,6 +5,7 @@ import { LOCAL_STORAGE_KEYS } from 'lib/constants'
import { EMPTY_OBJ } from 'lib/void'
import { APISidePanelPreview } from './APISidePanelPreview'
import { CLSPreview } from './CLSPreview'
import InlineEditorPreview from './InlineEditorPreview'
export const FEATURE_PREVIEWS = [
{
@@ -21,6 +22,13 @@ export const FEATURE_PREVIEWS = [
discussionsUrl: 'https://github.com/orgs/supabase/discussions/20295',
isNew: false,
},
{
key: LOCAL_STORAGE_KEYS.UI_PREVIEW_INLINE_EDITOR,
name: 'Inline SQL Editor',
content: <InlineEditorPreview />,
discussionsUrl: 'https://github.com/orgs/supabase/discussions/33690',
isNew: true,
},
]
type FeaturePreviewContextType = {
@@ -77,3 +85,8 @@ export const useIsColumnLevelPrivilegesEnabled = () => {
const { flags } = useFeaturePreviewContext()
return flags[LOCAL_STORAGE_KEYS.UI_PREVIEW_CLS]
}
export const useIsInlineEditorEnabled = () => {
const { flags } = useFeaturePreviewContext()
return flags[LOCAL_STORAGE_KEYS.UI_PREVIEW_INLINE_EDITOR]
}
@@ -0,0 +1,33 @@
import Image from 'next/image'
import { BASE_PATH } from 'lib/constants'
import { Admonition } from 'ui-patterns'
const InlineEditorPreview = () => {
return (
<div>
<Image
src={`${BASE_PATH}/img/previews/inline-editor-preview.png`}
width={1296}
height={900}
alt="api-docs-side-panel-preview"
className="rounded border mb-4"
/>
<p className="text-sm text-foreground-light">
Access an inline SQL editor where you can write and run queries wherever you are in the
dashboard. Use the inline Assistant to generate or modify queries without leaving the
editor.
</p>
<Admonition type="note" className="my-4">
With the inline editor enabled, editing policies, triggers and database functions will all
be done using the editor.
</Admonition>
<p className="text-sm text-foreground-light">
You can access the inline editor by clicking the code editor icon in the top right corner of
your dashboard.
</p>
</div>
)
}
export default InlineEditorPreview
@@ -5,7 +5,6 @@ import { MutableRefObject, useEffect, useRef } from 'react'
import { cn } from 'ui'
import { Markdown } from 'components/interfaces/Markdown'
import { useProjectContext } from 'components/layouts/ProjectLayout/ProjectContext'
import { formatSql } from 'lib/formatSql'
// [Joshen] Is there a way we can just have one single MonacoEditor component that's shared across the dashboard?
@@ -50,7 +49,6 @@ export const RLSCodeEditor = ({
}: RLSCodeEditorProps) => {
const hasValue = useRef<any>()
const monaco = useMonaco()
const { project } = useProjectContext()
const placeholderId = `monaco-placeholder-${id}`
const options: editor.IStandaloneEditorConstructionOptions = {
@@ -12,7 +12,7 @@ export const generatePolicyCreateSQL = (policy: PostgresPolicy) => {
}
return `
CREATE POLICY "${policy.name}"
ALTER POLICY "${policy.name}"
ON "${policy.schema}"."${policy.table}"
AS ${policy.action}
FOR ${policy.command}
@@ -117,7 +117,7 @@ const PolicyTableRowHeader = ({
onClick={() => {
setAiAssistantPanel({
open: true,
initialInput: `Create a new policy for the ${table.schema} schema on the ${table.name} table that ...`,
initialInput: `Create and name a new policy for the ${table.schema} schema on the ${table.name} table that ...`,
})
}}
tooltip={{
@@ -139,7 +139,13 @@ const TriggerList = ({
<Button type="default" className="px-1" icon={<MoreVertical />} />
</DropdownMenuTrigger>
<DropdownMenuContent side="bottom" align="end" className="w-52">
<DropdownMenuItem className="space-x-2" onClick={() => editTrigger(x)}>
<DropdownMenuItem
className="space-x-2"
onClick={() => {
const sql = generateTriggerCreateSQL(x)
editTrigger(x)
}}
>
<Edit2 size={14} />
<p>Edit trigger</p>
</DropdownMenuItem>
@@ -18,7 +18,11 @@ export const generateTriggerCreateSQL = (trigger: PostgresTrigger) => {
const events = trigger.events.join(' OR ')
const args = trigger.function_args.length > 0 ? `(${trigger.function_args.join(', ')})` : '()'
// Note: CREATE OR REPLACE is not supported for triggers
// We need to drop the existing trigger first if we want to replace it
let sql = `
DROP TRIGGER IF EXISTS "${trigger.name}" ON "${trigger.schema}"."${trigger.table}";
CREATE TRIGGER "${trigger.name}"
${trigger.activation} ${events}
ON "${trigger.schema}"."${trigger.table}"
@@ -1,7 +1,6 @@
import { detectOS } from 'lib/helpers'
import { ArrowDownLeft, Loader2, Wand } from 'lucide-react'
import { useCallback } from 'react'
import { Input, Button } from 'ui'
import { ChangeEvent, KeyboardEvent, useCallback, useEffect, useRef } from 'react'
import { Button, ExpandingTextArea } from 'ui'
interface AskAIWidgetProps {
value: string
@@ -9,6 +8,7 @@ interface AskAIWidgetProps {
onSubmit: (prompt: string) => void
onAccept?: () => void
onReject?: () => void
onCancel?: () => void
isDiffVisible: boolean
isLoading?: boolean
}
@@ -19,42 +19,54 @@ export const AskAIWidget = ({
onSubmit,
onAccept,
onReject,
onCancel,
isDiffVisible,
isLoading = false,
}: AskAIWidgetProps) => {
const os = detectOS()
const textAreaRef = useRef<HTMLTextAreaElement>(null)
// Auto focus input
const inputRef = useCallback((input: HTMLInputElement | null) => {
useEffect(() => {
setTimeout(() => {
input?.focus()
}, 0)
textAreaRef.current?.focus()
textAreaRef.current?.setSelectionRange(value.length, value.length)
}, 100)
}, [])
const handleSubmit = () => {
const handleSubmit = useCallback(() => {
if (value.trim() && !isLoading) {
onSubmit(value)
}
}
}, [value, isLoading, onSubmit])
const handleChange = useCallback(
(e: ChangeEvent<HTMLTextAreaElement>) => {
onChange(e.target.value)
},
[onChange]
)
const handleKeyDown = useCallback(
(e: KeyboardEvent<HTMLTextAreaElement>) => {
if (e.key === 'Enter' && !e.shiftKey && !e.metaKey && !e.ctrlKey) {
e.preventDefault()
handleSubmit()
}
},
[handleSubmit]
)
return (
<div className="overflow-hidden rounded-md p-0 bg-popover border border-foreground/20 focus-within:border-foreground/30 shadow-xl text-sm max-w-xl">
<Input
inputRef={inputRef}
size="xlarge"
inputClassName="bg-transparent border-none shadow-none gap-4 text-xs focus-visible:outline-none focus-visible:ring-0 py-2 pl-3"
placeholder={isDiffVisible ? 'Make an edit...' : 'Edit SQL via the Assistant...'}
autoFocus
<ExpandingTextArea
ref={textAreaRef}
className="bg-transparent border-0 outline-0 ring-0 ring-offset-0 focus:outline-0 focus:ring-0 focus:ring-offset-0 focus-visible:outline-0 focus-visible:ring-0 focus-visible:ring-offset-0 focus-within:outline-0 focus-within:ring-0 focus-within:ring-offset-0 shadow-none rounded-none gap-4 text-xs md:text-xs py-2 pl-3 !leading-[20px]"
placeholder={isDiffVisible ? 'Make an edit...' : 'Edit via the Assistant...'}
value={value}
onChange={(e) => onChange(e.target.value)}
onKeyDown={(e) => {
if (e.key === 'Enter' && !e.metaKey && !e.ctrlKey) {
handleSubmit()
}
}}
onChange={handleChange}
onKeyDown={handleKeyDown}
disabled={isLoading}
/>
{isDiffVisible && (
{isDiffVisible ? (
<div className="flex justify-start p-0 border-t">
<Button
type="text"
@@ -73,6 +85,27 @@ export const AskAIWidget = ({
Reject <span className="text-xs text-foreground-light">Esc</span>
</Button>
</div>
) : (
<div className="flex justify-start p-0 border-t">
<Button
type="text"
onClick={handleSubmit}
loading={isLoading}
className="text-xs h-auto py-1 rounded-none px-3 border-r-border"
disabled={isLoading}
>
{isLoading ? 'Generating...' : 'Generate'}{' '}
{!isLoading && <span className="text-xs text-foreground-light">Enter</span>}
</Button>
<Button
onClick={onCancel}
type="text"
className="text-xs h-auto py-1 rounded-none px-3 border-r-border"
disabled={isLoading}
>
Cancel <span className="text-xs text-foreground-light">Esc</span>
</Button>
</div>
)}
</div>
)
@@ -1,5 +1,5 @@
import { editor } from 'monaco-editor'
import { PropsWithChildren, useEffect, useMemo } from 'react'
import { PropsWithChildren, useEffect, useMemo, useRef } from 'react'
import { createPortal } from 'react-dom'
export interface InlineWidgetProps {
@@ -45,62 +45,83 @@ const InlineWidget = ({
}: PropsWithChildren<InlineWidgetProps>) => {
const lineNumber = beforeLineNumber ?? afterLineNumber
const key = `${id}-${lineNumber.toString()}`
const containerElement = useMemo(() => document.createElement('div'), [])
const zoneIdRef = useRef<string>()
const viewZoneRef = useRef<{
top: number
height: number
heightInLines: number
}>({ top: 0, height: 0, heightInLines: heightInLines })
// Get the appropriate editor instance for diff editor
const targetEditor = 'getModifiedEditor' in editor ? editor.getModifiedEditor() : editor
useEffect(() => {
let zoneId: string
let viewZoneTop = 0
let viewZoneHeight = 0
const recalculateLayout = () => {
const layoutInfo = targetEditor.getLayoutInfo()
if (!layoutInfo) {
return
}
containerElement.style.left = `${layoutInfo.contentLeft}px`
containerElement.style.top = `${viewZoneRef.current.top}px`
containerElement.style.width = `${layoutInfo.width - layoutInfo.contentLeft - 20}px`
containerElement.style.height = `${viewZoneRef.current.height}px`
}
const createViewZone = () => {
targetEditor.changeViewZones((accessor) => {
// Remove existing zone if it exists
if (zoneIdRef.current) {
accessor.removeZone(zoneIdRef.current)
}
// Create new zone with current height
zoneIdRef.current = accessor.addZone({
afterLineNumber: beforeLineNumber ?? afterLineNumber,
heightInLines: viewZoneRef.current.heightInLines,
domNode: document.createElement('div'),
onDomNodeTop: (top) => {
viewZoneRef.current.top = top
recalculateLayout()
},
onComputedHeight: (height) => {
viewZoneRef.current.height = height
recalculateLayout()
},
})
})
}
// Initial setup of view zone and overlay widget
useEffect(() => {
const overlayWidget: editor.IOverlayWidget = {
getId: () => id,
getDomNode: () => containerElement,
getPosition: () => null,
}
const recalculateLayout = () => {
const layoutInfo = targetEditor.getLayoutInfo()
if (!layoutInfo) {
return
}
containerElement.style.left = `${layoutInfo.contentLeft}px`
containerElement.style.top = `${viewZoneTop}px`
containerElement.style.width = `${layoutInfo.width - layoutInfo.contentLeft}px`
containerElement.style.height = `${viewZoneHeight}px`
}
targetEditor.changeViewZones((accessor) => {
zoneId = accessor.addZone({
afterLineNumber: beforeLineNumber ?? afterLineNumber,
heightInLines,
domNode: document.createElement('div'),
onDomNodeTop: (top) => {
viewZoneTop = top
recalculateLayout()
},
onComputedHeight: (height) => {
viewZoneHeight = height
recalculateLayout()
},
})
targetEditor.addOverlayWidget(overlayWidget)
})
createViewZone()
targetEditor.addOverlayWidget(overlayWidget)
// Remove the view zone & overlay widget on unmount
return () => {
targetEditor.changeViewZones((accessor) => {
accessor.removeZone(zoneId)
if (zoneIdRef.current) {
accessor.removeZone(zoneIdRef.current)
}
targetEditor.removeOverlayWidget(overlayWidget)
})
}
}, [targetEditor, id, beforeLineNumber, afterLineNumber, heightInLines, containerElement])
}, [targetEditor, id, beforeLineNumber, afterLineNumber]) // Note: heightInLines removed from deps
// Update view zone height when heightInLines changes
useEffect(() => {
if (heightInLines !== viewZoneRef.current.heightInLines) {
viewZoneRef.current.heightInLines = heightInLines
createViewZone()
}
}, [heightInLines])
return createPortal(children, containerElement, key)
}
@@ -49,9 +49,8 @@ import {
cn,
} from 'ui'
import { subscriptionHasHipaaAddon } from '../Billing/Subscription/Subscription.utils'
import { AskAIWidget } from './AskAIWidget'
import ResizableAIWidget from 'components/ui/AIEditor/ResizableAIWidget'
import { useSqlEditorDiff, useSqlEditorPrompt } from './hooks'
import InlineWidget from './InlineWidget'
import { RunQueryWarningModal } from './RunQueryWarningModal'
import {
ROWS_PER_PAGE_OPTIONS,
@@ -687,29 +686,26 @@ export const SQLEditor = () => {
}}
/>
{showWidget && (
<InlineWidget
<ResizableAIWidget
editor={diffEditorRef.current!}
id="ask-ai-diff"
heightInLines={3}
afterLineNumber={0}
beforeLineNumber={Math.max(0, promptState.startLineNumber - 1)}
>
<AskAIWidget
onSubmit={(prompt: string) => {
handlePrompt(prompt, {
beforeSelection: promptState.beforeSelection,
selection: promptState.selection || defaultSqlDiff.modified,
afterSelection: promptState.afterSelection,
})
}}
value={promptInput}
onChange={setPromptInput}
onAccept={acceptAiHandler}
onReject={discardAiHandler}
isDiffVisible={true}
isLoading={isCompletionLoading}
/>
</InlineWidget>
value={promptInput}
onChange={setPromptInput}
onSubmit={(prompt: string) => {
handlePrompt(prompt, {
beforeSelection: promptState.beforeSelection,
selection: promptState.selection || defaultSqlDiff.modified,
afterSelection: promptState.afterSelection,
})
}}
onAccept={acceptAiHandler}
onReject={discardAiHandler}
onCancel={resetPrompt}
isDiffVisible={true}
isLoading={isCompletionLoading}
startLineNumber={Math.max(0, promptState.startLineNumber)}
endLineNumber={promptState.endLineNumber}
/>
)}
</div>
)}
@@ -741,27 +737,24 @@ export const SQLEditor = () => {
}}
/>
{editorRef.current && promptState.isOpen && !isDiffOpen && (
<InlineWidget
<ResizableAIWidget
editor={editorRef.current}
id="ask-ai"
afterLineNumber={promptState.endLineNumber}
beforeLineNumber={Math.max(0, promptState.startLineNumber - 1)}
heightInLines={2}
>
<AskAIWidget
value={promptInput}
onChange={setPromptInput}
onSubmit={(prompt: string) => {
handlePrompt(prompt, {
beforeSelection: promptState.beforeSelection,
selection: promptState.selection,
afterSelection: promptState.afterSelection,
})
}}
isDiffVisible={false}
isLoading={isCompletionLoading}
/>
</InlineWidget>
value={promptInput}
onChange={setPromptInput}
onSubmit={(prompt: string) => {
handlePrompt(prompt, {
beforeSelection: promptState.beforeSelection,
selection: promptState.selection,
afterSelection: promptState.afterSelection,
})
}}
onCancel={resetPrompt}
isDiffVisible={false}
isLoading={isCompletionLoading}
startLineNumber={Math.max(0, promptState.startLineNumber)}
endLineNumber={promptState.endLineNumber}
/>
)}
<AnimatePresence>
{!promptState.isOpen && !editorRef.current?.getValue() && (
@@ -0,0 +1,47 @@
import { detectOS } from 'lib/helpers'
import { Command, CornerDownLeft, Loader2 } from 'lucide-react'
import { Button } from 'ui'
interface SqlRunButtonProps {
isDisabled?: boolean
isExecuting?: boolean
hasSelection?: boolean
className?: string
onClick: () => void
}
export const SqlRunButton = ({
isDisabled = false,
isExecuting = false,
hasSelection = false,
className,
onClick,
}: SqlRunButtonProps) => {
const os = detectOS()
return (
<Button
onClick={onClick}
disabled={isDisabled}
type="primary"
size="tiny"
iconRight={
isExecuting ? (
<Loader2 className="animate-spin" size={10} strokeWidth={1.5} />
) : (
<div className="flex items-center space-x-1">
{os === 'macos' ? (
<Command size={10} strokeWidth={1.5} />
) : (
<p className="text-xs text-foreground-light">CTRL</p>
)}
<CornerDownLeft size={10} strokeWidth={1.5} />
</div>
)
}
className={className}
>
{hasSelection ? 'Run selected' : 'Run'}
</Button>
)
}
@@ -1,21 +1,10 @@
import {
AlignLeft,
Check,
Command,
CornerDownLeft,
Heart,
Keyboard,
Loader2,
MoreVertical,
} from 'lucide-react'
import { AlignLeft, Check, Heart, Keyboard, MoreVertical } from 'lucide-react'
import { toast } from 'sonner'
import { useParams } from 'common'
import { RoleImpersonationPopover } from 'components/interfaces/RoleImpersonationSelector'
import DatabaseSelector from 'components/ui/DatabaseSelector'
import { useSendEventMutation } from 'data/telemetry/send-event-mutation'
import { useLocalStorageQuery } from 'hooks/misc/useLocalStorage'
import { useSelectedOrganization } from 'hooks/misc/useSelectedOrganization'
import { IS_PLATFORM, LOCAL_STORAGE_KEYS } from 'lib/constants'
import { detectOS } from 'lib/helpers'
import { useSqlEditorV2StateSnapshot } from 'state/sql-editor-v2'
@@ -31,6 +20,7 @@ import {
TooltipTrigger,
cn,
} from 'ui'
import { SqlRunButton } from './RunButton'
import SavingIndicator from './SavingIndicator'
export type UtilityActionsProps = {
@@ -53,9 +43,6 @@ const UtilityActions = ({
const os = detectOS()
const { ref } = useParams()
const snapV2 = useSqlEditorV2StateSnapshot()
const org = useSelectedOrganization()
const { mutate: sendEvent } = useSendEventMutation()
const [isAiOpen] = useLocalStorageQuery(LOCAL_STORAGE_KEYS.SQL_EDITOR_AI_OPEN, true)
const [intellisenseEnabled, setIntellisenseEnabled] = useLocalStorageQuery(
@@ -195,29 +182,12 @@ const UtilityActions = ({
onSelectId={onSelectDatabase}
/>
<RoleImpersonationPopover serviceRoleLabel="postgres" variant="connected-on-both" />
<Button
onClick={executeQuery}
disabled={isDisabled || isExecuting}
type="primary"
size="tiny"
iconRight={
isExecuting ? (
<Loader2 className="animate-spin" size={10} strokeWidth={1.5} />
) : (
<div className="flex items-center space-x-1">
{os === 'macos' ? (
<Command size={10} strokeWidth={1.5} />
) : (
<p className="text-xs text-foreground-light">CTRL</p>
)}
<CornerDownLeft size={10} strokeWidth={1.5} />
</div>
)
}
<SqlRunButton
isDisabled={isDisabled || isExecuting}
isExecuting={isExecuting}
className="rounded-l-none min-w-[82px]"
>
{hasSelection ? 'Run selected' : 'Run'}
</Button>
onClick={executeQuery}
/>
</div>
</div>
</div>
@@ -0,0 +1,23 @@
import { useAppStateSnapshot } from 'state/app-state'
import { Button } from 'ui'
import { SqlEditor } from 'icons'
const InlineEditorButton = () => {
const { setEditorPanel, editorPanel } = useAppStateSnapshot()
return (
<Button
type="text"
size="tiny"
id="editor-trigger"
className="h-full w-full rounded-none text-foreground-light"
onClick={() => {
setEditorPanel({ open: !editorPanel.open })
}}
>
<SqlEditor size={20} strokeWidth={1.5} />
</Button>
)
}
export default InlineEditorButton
@@ -3,10 +3,12 @@ import { useRouter } from 'next/router'
import { ReactNode, useMemo } from 'react'
import { useParams } from 'common'
import { useIsInlineEditorEnabled } from 'components/interfaces/App/FeaturePreview/FeaturePreviewContext'
import Connect from 'components/interfaces/Connect/Connect'
import AssistantButton from 'components/layouts/AppLayout/AssistantButton'
import BranchDropdown from 'components/layouts/AppLayout/BranchDropdown'
import EnableBranchingButton from 'components/layouts/AppLayout/EnableBranchingButton/EnableBranchingButton'
import InlineEditorButton from 'components/layouts/AppLayout/InlineEditorButton'
import OrganizationDropdown from 'components/layouts/AppLayout/OrganizationDropdown'
import ProjectDropdown from 'components/layouts/AppLayout/ProjectDropdown'
import { getResourcesExceededLimitsOrg } from 'components/ui/OveragesBanner/OveragesBanner.utils'
@@ -21,6 +23,7 @@ import BreadcrumbsView from './BreadcrumbsView'
import { FeedbackDropdown } from './FeedbackDropdown'
import HelpPopover from './HelpPopover'
import NotificationsPopoverV2 from './NotificationsPopoverV2/NotificationsPopover'
const LayoutHeaderDivider = () => (
<span className="text-border-stronger">
<svg
@@ -58,6 +61,7 @@ const LayoutHeader = ({
const selectedOrganization = useSelectedOrganization()
const { mobileMenuOpen, setMobileMenuOpen } = useAppStateSnapshot()
const isBranchingEnabled = selectedProject?.is_branch_enabled === true
const isInlineEditorEnabled = useIsInlineEditorEnabled()
const { data: subscription } = useOrgSubscriptionQuery({
orgSlug: selectedOrganization?.slug,
@@ -163,8 +167,15 @@ const LayoutHeader = ({
<div className="absolute md:hidden right-0 h-full w-3 bg-gradient-to-l from-background-dash-sidebar to-transparent pointer-events-none" />
</div>
{!!projectRef && (
<div className="border-l flex-0 h-full">
<AssistantButton />
<div className="flex h-full items-center">
{isInlineEditorEnabled && (
<div className="border-l flex-0 h-full">
<InlineEditorButton />
</div>
)}
<div className="border-l flex-0 h-full">
<AssistantButton />
</div>
</div>
)}
</div>
@@ -1,6 +1,7 @@
import { useParams } from 'common'
import ProjectAPIDocs from 'components/interfaces/ProjectAPIDocs/ProjectAPIDocs'
import { AIAssistantPanel } from 'components/ui/AIAssistantPanel/AIAssistantPanel'
import { EditorPanel } from 'components/ui/EditorPanel/EditorPanel'
import AISettingsModal from 'components/ui/AISettingsModal'
import { Loading } from 'components/ui/Loading'
import { ResourceExhaustionWarningBanner } from 'components/ui/ResourceExhaustionWarningBanner/ResourceExhaustionWarningBanner'
@@ -83,8 +84,13 @@ const ProjectLayout = forwardRef<HTMLDivElement, PropsWithChildren<ProjectLayout
const [isClient, setIsClient] = useState(false)
const selectedOrganization = useSelectedOrganization()
const selectedProject = useSelectedProject()
const { aiAssistantPanel, setAiAssistantPanel, mobileMenuOpen, setMobileMenuOpen } =
useAppStateSnapshot()
const {
editorPanel,
aiAssistantPanel,
setAiAssistantPanel,
mobileMenuOpen,
setMobileMenuOpen,
} = useAppStateSnapshot()
const { open } = aiAssistantPanel
const projectName = selectedProject?.name
@@ -210,7 +216,7 @@ const ProjectLayout = forwardRef<HTMLDivElement, PropsWithChildren<ProjectLayout
)}
</main>
</ResizablePanel>
{isClient && aiAssistantPanel.open && (
{isClient && (aiAssistantPanel.open || editorPanel.open) && (
<>
<ResizableHandle withHandle />
<ResizablePanel
@@ -221,7 +227,8 @@ const ProjectLayout = forwardRef<HTMLDivElement, PropsWithChildren<ProjectLayout
'2xl:min-w-[500px] 2xl:max-w-[600px]'
)}
>
<AIAssistantPanel />
{aiAssistantPanel.open && <AIAssistantPanel />}
{editorPanel.open && <EditorPanel />}
</ResizablePanel>
</>
)}
@@ -0,0 +1,88 @@
import { editor as monacoEditor } from 'monaco-editor'
import { useCallback, useEffect, useRef, useState } from 'react'
import InlineWidget from 'components/interfaces/SQLEditor/InlineWidget'
import { AskAIWidget } from 'components/interfaces/SQLEditor/AskAIWidget'
interface ResizableAIWidgetProps {
editor: monacoEditor.IStandaloneCodeEditor | monacoEditor.IStandaloneDiffEditor
id: string
value: string
onChange: (value: string) => void
onSubmit: (prompt: string) => void
onAccept?: () => void
onReject?: () => void
onCancel?: () => void
isDiffVisible: boolean
isLoading?: boolean
startLineNumber: number
endLineNumber: number
}
const LINE_HEIGHT = 20 // height of each line in pixels
const MIN_LINES = 3 // minimum number of lines to show
const ResizableAIWidget = ({
editor,
id,
value,
onChange,
onSubmit,
onAccept,
onReject,
onCancel,
isDiffVisible,
isLoading = false,
startLineNumber,
endLineNumber,
}: ResizableAIWidgetProps) => {
const containerRef = useRef<HTMLDivElement>(null)
const [heightInLines, setHeightInLines] = useState(MIN_LINES)
const updateHeight = useCallback(() => {
if (containerRef.current) {
const height = containerRef.current.offsetHeight
const newHeightInLines = Math.max(MIN_LINES, Math.ceil(height / LINE_HEIGHT))
setHeightInLines(newHeightInLines)
}
}, [])
useEffect(() => {
// Update height on value change
updateHeight()
// Set up resize observer to track height changes
const resizeObserver = new ResizeObserver(updateHeight)
if (containerRef.current) {
resizeObserver.observe(containerRef.current)
}
return () => {
resizeObserver.disconnect()
}
}, [updateHeight])
return (
<InlineWidget
editor={editor}
id={id}
heightInLines={heightInLines}
afterLineNumber={endLineNumber}
beforeLineNumber={Math.max(0, startLineNumber - 1)}
>
<div ref={containerRef}>
<AskAIWidget
value={value}
onChange={onChange}
onSubmit={onSubmit}
onAccept={onAccept}
onReject={onReject}
onCancel={onCancel}
isDiffVisible={isDiffVisible}
isLoading={isLoading}
/>
</div>
</InlineWidget>
)
}
export default ResizableAIWidget
@@ -0,0 +1,352 @@
import Editor, { DiffEditor, Monaco, OnMount } from '@monaco-editor/react'
import { useCompletion } from 'ai/react'
import { AnimatePresence, motion } from 'framer-motion'
import { Command } from 'lucide-react'
import { editor as monacoEditor } from 'monaco-editor'
import { useCallback, useEffect, useRef, useState } from 'react'
import { toast } from 'sonner'
import { constructHeaders } from 'data/fetchers'
import { detectOS } from 'lib/helpers'
import ResizableAIWidget from './ResizableAIWidget'
interface AIEditorProps {
id?: string
language?: string
value?: string
defaultValue?: string
aiEndpoint?: string
aiMetadata?: {
projectRef?: string
connectionString?: string
includeSchemaMetadata?: boolean
}
initialPrompt?: string
readOnly?: boolean
className?: string
options?: monacoEditor.IStandaloneEditorConstructionOptions
onChange?: (value: string) => void
executeQuery?: () => void
}
// [Joshen] This has overlap with components/interfaces/SQLEditor/MonacoEditor
// Can we try to de-dupe accordingly? Perhaps the SQL Editor could use this AIEditor
// We have a tendency to create multiple versions of the monaco editor like RLSCodeEditor
// so hoping to prevent that from snowballing
const AIEditor = ({
language = 'javascript',
value,
defaultValue = '',
aiEndpoint,
aiMetadata,
initialPrompt,
readOnly = false,
className = '',
options = {},
onChange,
executeQuery,
}: AIEditorProps) => {
const os = detectOS()
const editorRef = useRef<monacoEditor.IStandaloneCodeEditor | null>(null)
const diffEditorRef = useRef<monacoEditor.IStandaloneDiffEditor | null>(null)
const executeQueryRef = useRef(executeQuery)
executeQueryRef.current = executeQuery
const [currentValue, setCurrentValue] = useState(value || defaultValue)
const [isDiffMode, setIsDiffMode] = useState(false)
const [isDiffEditorMounted, setIsDiffEditorMounted] = useState(false)
const [diffValue, setDiffValue] = useState({ original: '', modified: '' })
const [promptState, setPromptState] = useState({
isOpen: Boolean(initialPrompt),
selection: '',
beforeSelection: '',
afterSelection: '',
startLineNumber: 0,
endLineNumber: 0,
})
const [promptInput, setPromptInput] = useState(initialPrompt || '')
const {
complete,
completion,
isLoading: isCompletionLoading,
setCompletion,
} = useCompletion({
api: aiEndpoint || '',
body: aiMetadata,
onResponse: (response) => {
if (!response.ok) throw new Error('Failed to generate completion')
},
onError: (error) => {
toast.error(`Failed to generate: ${error.message}`)
},
})
const handleReset = useCallback(() => {
setCompletion('')
setIsDiffMode(false)
setPromptState((prev) => ({ ...prev, isOpen: false }))
setPromptInput('')
editorRef.current?.focus()
}, [setCompletion])
const handleAcceptDiff = useCallback(() => {
if (diffValue.modified) {
const newValue = diffValue.modified
setCurrentValue(newValue)
onChange?.(newValue)
handleReset()
}
}, [diffValue.modified, onChange, handleReset])
const handleRejectDiff = () => {
handleReset()
}
const handleEditorOnMount: OnMount = (
editor: monacoEditor.IStandaloneCodeEditor,
monaco: Monaco
) => {
editorRef.current = editor
// Set prompt state to open if promptInput exists
if (promptInput) {
const model = editor.getModel()
if (model) {
const lineCount = model.getLineCount()
setPromptState({
isOpen: true,
selection: model.getValue(),
beforeSelection: '',
afterSelection: '',
startLineNumber: 1,
endLineNumber: lineCount,
})
}
}
if (!!executeQueryRef.current) {
editor.addAction({
id: 'run-query',
label: 'Run Query',
keybindings: [monaco.KeyMod.CtrlCmd + monaco.KeyCode.Enter],
contextMenuGroupId: 'operation',
contextMenuOrder: 0,
run: () => executeQueryRef.current?.(),
})
}
editor.addAction({
id: 'generate-ai',
label: 'Generate with AI',
keybindings: [monaco.KeyMod.CtrlCmd | monaco.KeyCode.KeyK],
run: () => {
const selection = editor.getSelection()
const model = editor.getModel()
if (!model || !selection) return
const allLines = model.getLinesContent()
const startLineIndex = selection.startLineNumber - 1
const endLineIndex = selection.endLineNumber
const beforeSelection = allLines.slice(0, startLineIndex).join('\n') + '\n'
const selectedText = allLines.slice(startLineIndex, endLineIndex).join('\n')
const afterSelection = '\n' + allLines.slice(endLineIndex).join('\n')
setPromptState({
isOpen: true,
selection: selectedText,
beforeSelection,
afterSelection,
startLineNumber: selection?.startLineNumber ?? 0,
endLineNumber: selection?.endLineNumber ?? 0,
})
},
})
}
const handlePrompt = async (
prompt: string,
context: {
beforeSelection: string
selection: string
afterSelection: string
}
) => {
try {
setPromptState((prev) => ({
...prev,
selection: context.selection,
beforeSelection: context.beforeSelection,
afterSelection: context.afterSelection,
}))
const headerData = await constructHeaders()
await complete(prompt, {
headers: { Authorization: headerData.get('Authorization') ?? '' },
body: {
...aiMetadata,
completionMetadata: {
textBeforeCursor: context.beforeSelection,
textAfterCursor: context.afterSelection,
language,
prompt,
selection: context.selection,
},
},
})
} catch (error) {
setPromptState((prev) => ({ ...prev, isOpen: false }))
}
}
const defaultOptions: monacoEditor.IStandaloneEditorConstructionOptions = {
tabSize: 2,
fontSize: 13,
readOnly,
minimap: { enabled: false },
wordWrap: 'on',
lineNumbers: 'on',
folding: false,
padding: { top: 4 },
lineNumbersMinChars: 3,
...options,
}
useEffect(() => {
setCurrentValue(value || defaultValue)
}, [value, defaultValue])
useEffect(() => {
if (!isDiffMode) {
setIsDiffEditorMounted(false)
}
}, [isDiffMode])
useEffect(() => {
if (!completion) {
setIsDiffMode(false)
return
}
const original =
promptState.beforeSelection + promptState.selection + promptState.afterSelection
const modified = promptState.beforeSelection + completion + promptState.afterSelection
setDiffValue({ original, modified })
setIsDiffMode(true)
}, [completion, promptState.beforeSelection, promptState.selection, promptState.afterSelection])
useEffect(() => {
const handleKeyboard = (event: KeyboardEvent) => {
if (event.key === 'Escape') {
handleReset()
} else if (
event.key === 'Enter' &&
(os === 'macos' ? event.metaKey : event.ctrlKey) &&
isDiffMode
) {
event.preventDefault()
handleAcceptDiff()
}
}
window.addEventListener('keydown', handleKeyboard)
return () => window.removeEventListener('keydown', handleKeyboard)
}, [os, isDiffMode, handleAcceptDiff, handleReset])
return (
<div className="flex-1 overflow-hidden flex flex-col h-full relative">
{isDiffMode ? (
<div className="w-full h-full">
<DiffEditor
theme="vs-dark"
language={language}
original={diffValue.original}
modified={diffValue.modified}
onMount={(editor: monacoEditor.IStandaloneDiffEditor) => {
diffEditorRef.current = editor
setIsDiffEditorMounted(true)
}}
options={{
...defaultOptions,
renderSideBySide: false,
}}
/>
{isDiffEditorMounted && (
<ResizableAIWidget
editor={diffEditorRef.current!}
id="ask-ai-diff"
value={promptInput}
onChange={setPromptInput}
onSubmit={(prompt: string) => {
handlePrompt(prompt, {
beforeSelection: promptState.beforeSelection,
selection: promptState.selection || diffValue.modified,
afterSelection: promptState.afterSelection,
})
}}
onAccept={handleAcceptDiff}
onReject={handleRejectDiff}
onCancel={handleReset}
isDiffVisible={true}
isLoading={isCompletionLoading}
startLineNumber={Math.max(0, promptState.startLineNumber)}
endLineNumber={promptState.endLineNumber}
/>
)}
</div>
) : (
<div className="w-full h-full relative">
<Editor
theme="vs-dark"
language={language}
value={currentValue}
options={defaultOptions}
onChange={(value: string | undefined) => {
const newValue = value || ''
setCurrentValue(newValue)
onChange?.(newValue)
}}
onMount={handleEditorOnMount}
className={className}
/>
{promptState.isOpen && editorRef.current && (
<ResizableAIWidget
editor={editorRef.current}
id="ask-ai"
value={promptInput}
onChange={setPromptInput}
onSubmit={(prompt: string) => {
handlePrompt(prompt, {
beforeSelection: promptState.beforeSelection,
selection: promptState.selection,
afterSelection: promptState.afterSelection,
})
}}
onCancel={handleReset}
isDiffVisible={false}
isLoading={isCompletionLoading}
startLineNumber={Math.max(0, promptState.startLineNumber)}
endLineNumber={promptState.endLineNumber}
/>
)}
<AnimatePresence>
{!promptState.isOpen && !currentValue && aiEndpoint && (
<motion.p
initial={{ y: 5, opacity: 0 }}
animate={{ y: 0, opacity: 1 }}
exit={{ y: 5, opacity: 0 }}
className="text-foreground-lighter absolute bottom-4 left-4 z-10 font-mono text-xs flex items-center gap-1"
>
Hit {os === 'macos' ? <Command size={12} /> : `CTRL+`}K to edit with the Assistant
</motion.p>
)}
</AnimatePresence>
</div>
)}
</div>
)
}
export default AIEditor
@@ -22,7 +22,7 @@ const DEFAULT_ACTIONS = {
interface CodeEditorProps {
id: string
language: 'pgsql' | 'json' | 'html' | undefined
language: 'pgsql' | 'json' | 'html' | 'typescript' | undefined
autofocus?: boolean
defaultValue?: string
isReadOnly?: boolean
@@ -0,0 +1,396 @@
import { debounce } from 'lodash'
import { Book, Save, X } from 'lucide-react'
import { useCallback, useEffect, useState } from 'react'
import { toast } from 'sonner'
import { useParams } from 'common'
import {
createSqlSnippetSkeletonV2,
suffixWithLimit,
} from 'components/interfaces/SQLEditor/SQLEditor.utils'
import Results from 'components/interfaces/SQLEditor/UtilityPanel/Results'
import { SqlRunButton } from 'components/interfaces/SQLEditor/UtilityPanel/RunButton'
import { useSqlTitleGenerateMutation } from 'data/ai/sql-title-mutation'
import { QueryResponseError, useExecuteSqlMutation } from 'data/sql/execute-sql-mutation'
import { useOrgOptedIntoAi } from 'hooks/misc/useOrgOptedIntoAi'
import { useSelectedProject } from 'hooks/misc/useSelectedProject'
import { BASE_PATH, IS_PLATFORM } from 'lib/constants'
import { uuidv4 } from 'lib/helpers'
import { useProfile } from 'lib/profile'
import { useAppStateSnapshot } from 'state/app-state'
import { useSqlEditorV2StateSnapshot } from 'state/sql-editor-v2'
import { AiIconAnimation, Button, cn, Input_Shadcn_, SQL_ICON } from 'ui'
import { Admonition } from 'ui-patterns'
import { containsUnknownFunction, isReadOnlySelect } from '../AIAssistantPanel/AIAssistant.utils'
import AIEditor from '../AIEditor'
import { ButtonTooltip } from '../ButtonTooltip'
import { InlineLink } from '../InlineLink'
import SqlWarningAdmonition from '../SqlWarningAdmonition'
interface EditorPanelProps {
onChange?: (value: string) => void
}
export const EditorPanel = ({ onChange }: EditorPanelProps) => {
const { ref } = useParams()
const project = useSelectedProject()
const { editorPanel, setEditorPanel, setAiAssistantPanel } = useAppStateSnapshot()
const { profile } = useProfile()
const snapV2 = useSqlEditorV2StateSnapshot()
const { mutateAsync: generateSqlTitle } = useSqlTitleGenerateMutation()
const isOptedInToAI = useOrgOptedIntoAi()
const includeSchemaMetadata = isOptedInToAI || !IS_PLATFORM
const [isSaving, setIsSaving] = useState(false)
const [error, setError] = useState<QueryResponseError>()
const [results, setResults] = useState<undefined | any[]>(undefined)
const [showWarning, setShowWarning] = useState<'hasWriteOperation' | 'hasUnknownFunctions'>()
const [currentValue, setCurrentValue] = useState(editorPanel.initialValue || '')
const [showTemplates, setShowTemplates] = useState(false)
const [templateSearch, setTemplateSearch] = useState('')
const [savedCode, setSavedCode] = useState<string>('')
const [isPreviewingTemplate, setIsPreviewingTemplate] = useState(false)
const [showResults, setShowResults] = useState(true)
const errorHeader = error?.formattedError?.split('\n')?.filter((x: string) => x.length > 0)?.[0]
const errorContent =
error?.formattedError
?.split('\n')
?.filter((x: string) => x.length > 0)
?.slice(1) ?? []
const { mutate: executeSql, isLoading: isExecuting } = useExecuteSqlMutation({
onSuccess: async (res) => {
setResults(res.result)
},
onError: (error) => {
setError(error)
setResults([])
},
})
const handleChat = () => {
setAiAssistantPanel({
open: true,
sqlSnippets: currentValue ? [currentValue] : [],
initialInput: 'Help me understand and improve this SQL query...',
suggestions: {
title:
'I can help you understand and improve your SQL query. Here are a few example prompts to get you started:',
prompts: [
'Explain what this query does...',
'Help me optimize this query...',
'Show me how to add more conditions...',
'Help me join this with another table...',
],
},
})
}
const onExecuteSql = (skipValidation = false) => {
setError(undefined)
setShowWarning(undefined)
if (currentValue.length === 0) return
if (!skipValidation) {
const isReadOnlySelectSQL = isReadOnlySelect(currentValue)
if (!isReadOnlySelectSQL) {
const hasUnknownFunctions = containsUnknownFunction(currentValue)
setShowWarning(hasUnknownFunctions ? 'hasUnknownFunctions' : 'hasWriteOperation')
return
}
}
if (editorPanel.onSave) {
editorPanel.onSave(currentValue)
}
executeSql({
sql: suffixWithLimit(currentValue, 100),
projectRef: project?.ref,
connectionString: project?.connectionString,
handleError: (error) => {
throw error
},
contextualInvalidation: true,
})
}
const handleChange = (value: string) => {
setCurrentValue(value)
onChange?.(value)
}
const onSelectTemplate = (content: string) => {
handleChange(content)
setSavedCode(content)
setShowTemplates(false)
}
// Create a debounced version of the revert code function
const debouncedRevertCode = useCallback(
debounce(() => {
setIsPreviewingTemplate(false)
handleChange(savedCode)
}, 300),
[savedCode]
)
const handleTemplateMouseEnter = (templateContent: string) => {
// Cancel any pending revert
debouncedRevertCode.cancel()
if (!isPreviewingTemplate) {
setSavedCode(currentValue)
}
setIsPreviewingTemplate(true)
handleChange(templateContent)
}
const handleTemplateMouseLeave = () => {
if (isPreviewingTemplate) {
debouncedRevertCode()
}
}
// Cleanup debounce on unmount
useEffect(() => {
return () => {
debouncedRevertCode.cancel()
}
}, [debouncedRevertCode])
useEffect(() => {
if (editorPanel.initialValue !== undefined && editorPanel.initialValue !== currentValue) {
setCurrentValue(editorPanel.initialValue)
}
}, [editorPanel.initialValue])
useEffect(() => {
if (editorPanel.initialValue !== currentValue) {
setEditorPanel({
initialValue: currentValue,
})
}
}, [currentValue, setEditorPanel])
return (
<div className="flex flex-col h-full bg-surface-100">
<div className="border-b flex shrink-0 items-center gap-x-3 px-5 h-[46px]">
<span className="text-sm flex-1">SQL Editor</span>
<div className="flex gap-2 items-center">
<Button
size="tiny"
type="default"
className="h-7"
onClick={handleChat}
icon={<AiIconAnimation size={16} />}
>
Chat
</Button>
<ButtonTooltip
tooltip={{
content: {
side: 'bottom',
text: 'Save as snippet',
},
}}
size="tiny"
type="default"
className="w-7 h-7"
loading={isSaving}
icon={<Save size={16} />}
onClick={async () => {
if (!ref) return console.error('Project ref is required')
if (!project) return console.error('Project is required')
if (!profile) return console.error('Profile is required')
try {
setIsSaving(true)
const { title: name } = await generateSqlTitle({ sql: currentValue })
const snippet = createSqlSnippetSkeletonV2({
id: uuidv4(),
name,
sql: currentValue,
owner_id: profile.id,
project_id: project.id,
})
snapV2.addSnippet({ projectRef: ref, snippet })
snapV2.addNeedsSaving(snippet.id)
toast.success(
<div>
Saved snippet! View it{' '}
<InlineLink href={`/project/${ref}/sql/${snippet.id}`}>here</InlineLink>
</div>
)
} catch (error: any) {
toast.error(`Failed to create new query: ${error.message}`)
} finally {
setIsSaving(false)
}
}}
/>
<Button
size="tiny"
type="default"
className="w-7 h-7"
onClick={() => setEditorPanel({ open: false })}
icon={<X size={16} />}
/>
</div>
</div>
<div className="flex-1 overflow-hidden flex flex-col h-full">
<div className="flex-1 min-h-0 relative">
<AIEditor
language="pgsql"
value={currentValue}
onChange={handleChange}
aiEndpoint={`${BASE_PATH}/api/ai/sql/complete`}
aiMetadata={{
projectRef: project?.ref,
connectionString: project?.connectionString,
includeSchemaMetadata,
}}
initialPrompt={editorPanel.initialPrompt}
options={{
tabSize: 2,
fontSize: 13,
minimap: { enabled: false },
wordWrap: 'on',
lineNumbers: 'on',
folding: false,
padding: { top: 4 },
lineNumbersMinChars: 3,
}}
executeQuery={onExecuteSql}
/>
</div>
{error !== undefined && (
<div className="shrink-0">
<Admonition
type="warning"
className="m-0 rounded-none border-x-0 border-b-0 [&>div>div>pre]:text-sm [&>div]:flex [&>div]:flex-col [&>div]:gap-y-2"
title={errorHeader || 'Error running SQL query'}
description={
<div>
{errorContent.length > 0 ? (
errorContent.map((errorText: string, i: number) => (
<pre key={`err-${i}`} className="font-mono text-xs whitespace-pre-wrap">
{errorText}
</pre>
))
) : (
<p className="font-mono text-xs">{error.error}</p>
)}
</div>
}
/>
</div>
)}
{showWarning && (
<SqlWarningAdmonition
className="border-t"
warningType={showWarning}
onCancel={() => setShowWarning(undefined)}
onConfirm={() => {
setShowWarning(undefined)
onExecuteSql(true)
}}
/>
)}
{results !== undefined && results.length > 0 && (
<div className={`max-h-72 shrink-0 flex flex-col`}>
{showResults && (
<div className="border-t flex-1 overflow-auto">
<Results rows={results} />
</div>
)}
<p className="text-xs text-foreground-light border-t py-2 px-5 flex items-center justify-between">
<span className="font-mono">
{results.length} rows{results.length >= 100 && ` (Limited to only 100 rows)`}
</span>
<Button
size="tiny"
type="default"
className="ml-2"
onClick={() => setShowResults((prev) => !prev)}
>
{showResults ? 'Hide Results' : 'Show Results'}
</Button>
</p>
</div>
)}
{results !== undefined && results.length === 0 && (
<div className="shrink-0">
<p className="text-xs text-foreground-light font-mono py-2 px-5">
Success. No rows returned.
</p>
</div>
)}
{showTemplates && editorPanel.templates && (
<div className="bg-surface-100 border-t w-full flex flex-col max-h-80 h-full text-sm">
<div className="px-4 py-3 border-b shrink-0">
<Input_Shadcn_
placeholder="Search templates..."
value={templateSearch}
onChange={(e) => setTemplateSearch(e.target.value)}
/>
</div>
<div className="overflow-auto flex-1 p-2">
{editorPanel.templates
?.filter((template) => {
const searchLower = templateSearch.toLowerCase()
return (
template.name.toLowerCase().includes(searchLower) ||
template.description.toLowerCase().includes(searchLower)
)
})
?.map((template, i) => (
<div
key={i}
className="cursor-pointer group rounded-lg flex items-center gap-4 px-4 py-3 hover:bg-surface-200"
onClick={() => onSelectTemplate(template.content)}
onMouseEnter={() => handleTemplateMouseEnter(template.content)}
onMouseLeave={handleTemplateMouseLeave}
>
<SQL_ICON
size={18}
strokeWidth={1.5}
className={cn(
'transition-colors fill-foreground-muted group-aria-selected:fill-foreground',
'w-5 h-5 shrink-0 grow-0 -ml-0.5'
)}
/>
<div>
<p className="text-xs mb-1">{template.name}</p>
<p className="text-xs text-foreground-light">{template.description}</p>
</div>
</div>
))}
</div>
</div>
)}
<div className="bg-surface-100 flex items-center gap-2 !justify-between px-5 py-4 w-full border-t shrink-0">
<Button
size="tiny"
type="default"
onClick={() => setShowTemplates(!showTemplates)}
icon={<Book size={14} />}
>
{showTemplates ? 'Templates' : 'Templates'}
</Button>
<SqlRunButton isDisabled={isExecuting} isExecuting={isExecuting} onClick={onExecuteSql} />
</div>
</div>
</div>
)
}
export default EditorPanel
@@ -10,6 +10,7 @@ import Link from 'next/link'
import { ComponentProps } from 'react'
import { useSqlEditorV2StateSnapshot } from 'state/sql-editor-v2'
import {
cn,
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
@@ -18,21 +19,32 @@ import {
} from 'ui'
import { ButtonTooltip } from '../ButtonTooltip'
import { useSelectedOrganization } from 'hooks/misc/useSelectedOrganization'
import { useAppStateSnapshot } from 'state/app-state'
import { useIsInlineEditorEnabled } from 'components/interfaces/App/FeaturePreview/FeaturePreviewContext'
interface EditQueryButtonProps {
id?: string
title: string
sql?: string
className?: string
type?: 'default' | 'text'
}
export const EditQueryButton = ({ id, sql, title }: EditQueryButtonProps) => {
export const EditQueryButton = ({
id,
sql,
title,
className,
type = 'text',
}: EditQueryButtonProps) => {
const router = useRouter()
const { ref } = useParams()
const { newQuery } = useNewQuery()
const sqlEditorSnap = useSqlEditorV2StateSnapshot()
const { setEditorPanel } = useAppStateSnapshot()
const isInSQLEditor = router.pathname.includes('/sql')
const isInNewSnippet = router.pathname.endsWith('/sql')
const isInlineEditorEnabled = useIsInlineEditorEnabled()
const tooltip: { content: ComponentProps<typeof TooltipContent> & { text: string } } = {
content: { side: 'bottom', text: 'Edit in SQL Editor' },
}
@@ -54,9 +66,9 @@ export const EditQueryButton = ({ id, sql, title }: EditQueryButtonProps) => {
return (
<ButtonTooltip
asChild
type="text"
type={type}
size="tiny"
className="w-7 h-7"
className={cn('w-7 h-7', className)}
icon={<Edit size={14} />}
tooltip={tooltip}
>
@@ -67,12 +79,19 @@ export const EditQueryButton = ({ id, sql, title }: EditQueryButtonProps) => {
return !isInSQLEditor || isInNewSnippet ? (
<ButtonTooltip
type="text"
type={type}
size="tiny"
className="w-7 h-7"
className={cn('w-7 h-7', className)}
icon={<Edit size={14} />}
onClick={() => {
handleEditInSQLEditor()
if (isInlineEditorEnabled) {
setEditorPanel({
open: true,
initialValue: sql,
})
} else {
handleEditInSQLEditor()
}
sendEvent({
action: TelemetryActions.ASSISTANT_EDIT_IN_SQL_EDITOR_CLICKED,
properties: {
@@ -88,10 +107,10 @@ export const EditQueryButton = ({ id, sql, title }: EditQueryButtonProps) => {
<DropdownMenu>
<DropdownMenuTrigger asChild>
<ButtonTooltip
type="text"
type={type}
size="tiny"
disabled={!sql}
className="w-7 h-7"
className={cn('w-7 h-7', className)}
icon={<Edit size={14} />}
tooltip={!!sql ? tooltip : { content: { side: 'bottom', text: undefined } }}
/>
@@ -28,6 +28,7 @@ import { BlockViewConfiguration } from './BlockViewConfiguration'
import { EditQueryButton } from './EditQueryButton'
import { ParametersPopover } from './ParametersPopover'
import { getCumulativeResults } from './QueryBlock.utils'
import SqlWarningAdmonition from '../SqlWarningAdmonition'
export const DEFAULT_CHART_CONFIG: ChartConfig = {
type: 'bar',
@@ -282,51 +283,25 @@ export const QueryBlock = ({
}
>
{!!showWarning && (
<Admonition
type="warning"
className="mb-0 rounded-none border-0 shrink-0 bg-background-100"
>
<p>
{showWarning === 'hasWriteOperation'
? 'This query contains write operations.'
: 'This query involves running a function.'}{' '}
Are you sure you want to execute it?
</p>
<p className="text-foreground-light">
Make sure you are not accidentally removing something important.
</p>
<div className="flex justify-stretch mt-2 gap-2">
<Button
type="outline"
size="tiny"
className="w-full flex-1"
onClick={() => setShowWarning(undefined)}
>
Cancel
</Button>
<Button
type="danger"
size="tiny"
disabled={!sql}
className="w-full flex-1"
onClick={() => {
// [Joshen] This is for when we introduced the concept of parameters into our reports
// const processedSql = processParameterizedSql(sql!, combinedParameterValues)
if (sql) {
setShowWarning(undefined)
execute({
projectRef: ref,
connectionString: project?.connectionString,
sql,
})
onRunQuery?.('mutation')
}
}}
>
Run
</Button>
</div>
</Admonition>
<SqlWarningAdmonition
warningType={showWarning}
className="border-b"
onCancel={() => setShowWarning(undefined)}
onConfirm={() => {
// [Joshen] This is for when we introduced the concept of parameters into our reports
// const processedSql = processParameterizedSql(sql!, combinedParameterValues)
if (sql) {
setShowWarning(undefined)
execute({
projectRef: ref,
connectionString: project?.connectionString,
sql,
})
onRunQuery?.('mutation')
}
}}
disabled={!sql}
/>
)}
{isExecuting && queryResult === undefined && (
@@ -0,0 +1,51 @@
import { Button } from 'ui'
import { Admonition } from 'ui-patterns'
export interface SqlWarningAdmonitionProps {
warningType: 'hasWriteOperation' | 'hasUnknownFunctions'
onCancel: () => void
onConfirm: () => void
disabled?: boolean
className?: string
}
const SqlWarningAdmonition = ({
warningType,
onCancel,
onConfirm,
disabled = false,
className,
}: SqlWarningAdmonitionProps) => {
return (
<Admonition
type="warning"
className={`mb-0 rounded-none border-0 shrink-0 bg-background-100 ${className}`}
>
<p>
{warningType === 'hasWriteOperation'
? 'This query contains write operations.'
: 'This query involves running a function.'}{' '}
Are you sure you want to execute it?
</p>
<p className="text-foreground-light">
Make sure you are not accidentally removing something important.
</p>
<div className="flex justify-stretch mt-2 gap-2">
<Button type="outline" size="tiny" className="w-full flex-1" onClick={onCancel}>
Cancel
</Button>
<Button
type="danger"
size="tiny"
disabled={disabled}
className="w-full flex-1"
onClick={onConfirm}
>
Run
</Button>
</div>
</Admonition>
)
}
export default SqlWarningAdmonition
+2
View File
@@ -41,10 +41,12 @@ export const USAGE_APPROACHING_THRESHOLD = 0.75
export const LOCAL_STORAGE_KEYS = {
AI_ASSISTANT_STATE: 'supabase-ai-assistant-state',
EDITOR_PANEL_STATE: 'supabase-editor-panel-state',
UI_PREVIEW_NAVIGATION_LAYOUT: 'supabase-ui-preview-nav-layout',
UI_PREVIEW_API_SIDE_PANEL: 'supabase-ui-api-side-panel',
UI_PREVIEW_CLS: 'supabase-ui-cls',
UI_PREVIEW_INLINE_EDITOR: 'supabase-ui-preview-inline-editor',
UI_ONBOARDING_NEW_PAGE_SHOWN: 'supabase-ui-onboarding-new-page-shown',
SQL_SCRATCH_PAD_BANNER_ACKNOWLEDGED: 'supabase-sql-scratch-pad-banner-acknowledged',
+10 -6
View File
@@ -68,6 +68,7 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse)
# You generate and debug SQL
The generated SQL (must be valid SQL), and must adhere to the following:
- Always retrieve public schema information first
- Always use double apostrophe in SQL strings (eg. 'Night''s watch')
- Always use semicolons
- Use vector(384) data type for any embedding/vector related query
@@ -96,8 +97,8 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse)
# You write row level security policies.
Your purpose is to generate a policy with the constraints given by the user.
- First, use getSchema to retrieve more information about a schema or schemas that will contain policies, usually the public schema.
Your purpose is to generate a policy with the constraints given by the user using the getRlsKnowledge tool.
- First, use getSchemaTables to retrieve more information about a schema or schemas that will contain policies, usually the public schema.
- Then retrieve existing RLS policies and guidelines on how to write policies using the getRlsKnowledge tool .
- Then write new policies or update existing policies based on the prompt
- When asked to suggest policies, either alter existing policies or add new ones to the public schema.
@@ -126,10 +127,13 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse)
Instructions:
1. Only modify the selected text based on this prompt: ${prompt}
2. Your response should be ONLY the modified selection text, nothing else. Remove selected text if needed.
3. Do not wrap in code blocks or markdown
4. You can respond with one word or multiple words
5. Ensure the modified text flows naturally within the current line
2. Get schema tables information using the getSchemaTables tool
3. Get existing RLS policies and guidelines on how to write policies using the getRlsKnowledge tool
4. Write new policies or update existing policies based on the prompt
5. Your response should be ONLY the modified selection text, nothing else. Remove selected text if needed.
6. Do not wrap in code blocks or markdown
7. You can respond with one word or multiple words
8. Ensure the modified text flows naturally within the current line
6. Avoid duplicating SQL keywords (SELECT, FROM, WHERE, etc) when considering the full statement
7. If there is no surrounding context (before or after), make sure your response is a complete valid SQL statement that can be run and resolves the prompt.
+1 -1
View File
@@ -105,7 +105,7 @@ async function handlePost(req: NextApiRequest, res: NextApiResponse) {
# You write row level security policies.
Your purpose is to generate a policy with the constraints given by the user.
- First, use getSchema to retrieve more information about a schema or schemas that will contain policies, usually the public schema.
- First, use getSchemaTables to retrieve more information about a schema or schemas that will contain policies, usually the public schema.
- Then retrieve existing RLS policies and guidelines on how to write policies using the getRlsKnowledge tool .
- Then write new policies or update existing policies based on the prompt
- When asked to suggest policies, either alter existing policies or add new ones to the public schema.
+2 -1
View File
@@ -28,7 +28,7 @@ export const getTools = ({
}
return {
getSchema: tool({
getSchemaTables: tool({
description: 'Get more information about one or more schemas',
parameters: z.object({
schemas: z.array(z.string()).describe('The schema names to get the definitions for'),
@@ -85,6 +85,7 @@ export const getTools = ({
{
projectRef,
connectionString,
schema: schemas?.join(','),
},
undefined,
headers
@@ -6,6 +6,8 @@ import { useState } from 'react'
import { PolicyEditorPanel } from 'components/interfaces/Auth/Policies/PolicyEditorPanel'
import Policies from 'components/interfaces/Auth/Policies/Policies'
import { generatePolicyCreateSQL } from 'components/interfaces/Auth/Policies/PolicyTableRow/PolicyTableRow.utils'
import { getGeneralPolicyTemplates } from 'components/interfaces/Auth/Policies/PolicyEditorModal/PolicyEditorModal.constants'
import AuthLayout from 'components/layouts/AuthLayout/AuthLayout'
import { useProjectContext } from 'components/layouts/ProjectLayout/ProjectContext'
import AlertError from 'components/ui/AlertError'
@@ -22,6 +24,8 @@ import { PROTECTED_SCHEMAS } from 'lib/constants/schemas'
import type { NextPageWithLayout } from 'types'
import { Input } from 'ui'
import DefaultLayout from 'components/layouts/DefaultLayout'
import { useIsInlineEditorEnabled } from 'components/interfaces/App/FeaturePreview/FeaturePreviewContext'
import { useAppStateSnapshot } from 'state/app-state'
/**
* Filter tables by table name and policy name
@@ -65,6 +69,8 @@ const AuthPoliciesPage: NextPageWithLayout = () => {
}>()
const { schema = 'public', search: searchString = '' } = params
const { project } = useProjectContext()
const { setEditorPanel } = useAppStateSnapshot()
const isInlineEditorEnabled = useIsInlineEditorEnabled()
const [selectedTable, setSelectedTable] = useState<string>()
const [showPolicyAiEditor, setShowPolicyAiEditor] = useState(false)
@@ -147,12 +153,45 @@ const AuthPoliciesPage: NextPageWithLayout = () => {
hasTables={tables.length > 0}
isLocked={isLocked}
onSelectCreatePolicy={(table: string) => {
setSelectedTable(table)
setShowPolicyAiEditor(true)
if (isInlineEditorEnabled) {
setEditorPanel({
open: true,
initialValue: `create policy "replace_with_policy_name"
on ${schema}.${table}
for select
to authenticated
using (
true -- Write your policy condition here
);`,
label: `Create new RLS policy on "${table}"`,
saveLabel: 'Create policy',
initialPrompt: `Create and name a entirely new RLS policy for the "${table}" table in the ${schema} schema. The policy should...`,
})
} else {
setSelectedTable(table)
setShowPolicyAiEditor(true)
}
}}
onSelectEditPolicy={(policy) => {
setSelectedPolicyToEdit(policy)
setShowPolicyAiEditor(true)
if (isInlineEditorEnabled) {
const sql = generatePolicyCreateSQL(policy)
const templates = getGeneralPolicyTemplates(policy.schema, policy.table)
setEditorPanel({
open: true,
initialValue: sql,
label: `Edit policy "${policy.name}"`,
saveLabel: 'Update policy',
templates: templates.map((template) => ({
name: template.templateName,
description: template.description,
content: template.statement,
})),
initialPrompt: `Update the policy with name "${policy.name}" in the ${policy.schema} schema on the ${policy.table} table. It should...`,
})
} else {
setSelectedPolicyToEdit(policy)
setShowPolicyAiEditor(true)
}
}}
/>
)}
@@ -11,23 +11,54 @@ import { DatabaseFunction } from 'data/database-functions/database-functions-que
import { useCheckPermissions, usePermissionsLoaded } from 'hooks/misc/useCheckPermissions'
import type { NextPageWithLayout } from 'types'
import DefaultLayout from 'components/layouts/DefaultLayout'
import { useIsInlineEditorEnabled } from 'components/interfaces/App/FeaturePreview/FeaturePreviewContext'
import { useAppStateSnapshot } from 'state/app-state'
const FunctionsPage: NextPageWithLayout = () => {
const [selectedFunction, setSelectedFunction] = useState<DatabaseFunction | undefined>()
const [showCreateFunctionForm, setShowCreateFunctionForm] = useState(false)
const [showDeleteFunctionForm, setShowDeleteFunctionForm] = useState(false)
const { setEditorPanel } = useAppStateSnapshot()
const isInlineEditorEnabled = useIsInlineEditorEnabled()
const canReadFunctions = useCheckPermissions(PermissionAction.TENANT_SQL_ADMIN_READ, 'functions')
const isPermissionsLoaded = usePermissionsLoaded()
const createFunction = () => {
setSelectedFunction(undefined)
setShowCreateFunctionForm(true)
if (isInlineEditorEnabled) {
setEditorPanel({
open: true,
initialValue: `create function function_name()
returns void
language plpgsql
as $$
begin
-- Write your function logic here
end;
$$;`,
label: 'Create new database function',
saveLabel: 'Create function',
initialPrompt: 'Create a new database function that...',
})
} else {
setSelectedFunction(undefined)
setShowCreateFunctionForm(true)
}
}
const editFunction = (fn: any) => {
setSelectedFunction(fn)
setShowCreateFunctionForm(true)
const editFunction = (fn: DatabaseFunction) => {
if (isInlineEditorEnabled) {
setEditorPanel({
open: true,
initialValue: fn.complete_statement,
label: `Edit function "${fn.name}"`,
saveLabel: 'Update function',
initialPrompt: `Update the database function "${fn.name}" to...`,
})
} else {
setSelectedFunction(fn)
setShowCreateFunctionForm(true)
}
}
const deleteFunction = (fn: any) => {
@@ -3,6 +3,7 @@ import { useState } from 'react'
import { CreateTrigger, DeleteTrigger } from 'components/interfaces/Database'
import TriggersList from 'components/interfaces/Database/Triggers/TriggersList/TriggersList'
import { generateTriggerCreateSQL } from 'components/interfaces/Database/Triggers/TriggersList/TriggerList.utils'
import DatabaseLayout from 'components/layouts/DatabaseLayout/DatabaseLayout'
import { ScaffoldContainer, ScaffoldSection } from 'components/layouts/Scaffold'
import { FormHeader } from 'components/ui/Forms/FormHeader'
@@ -10,23 +11,51 @@ import NoPermission from 'components/ui/NoPermission'
import { useCheckPermissions, usePermissionsLoaded } from 'hooks/misc/useCheckPermissions'
import type { NextPageWithLayout } from 'types'
import DefaultLayout from 'components/layouts/DefaultLayout'
import { useIsInlineEditorEnabled } from 'components/interfaces/App/FeaturePreview/FeaturePreviewContext'
import { useAppStateSnapshot } from 'state/app-state'
const TriggersPage: NextPageWithLayout = () => {
const [selectedTrigger, setSelectedTrigger] = useState<any>()
const [showCreateTriggerForm, setShowCreateTriggerForm] = useState<boolean>(false)
const [showDeleteTriggerForm, setShowDeleteTriggerForm] = useState<boolean>(false)
const { setEditorPanel } = useAppStateSnapshot()
const isInlineEditorEnabled = useIsInlineEditorEnabled()
const canReadTriggers = useCheckPermissions(PermissionAction.TENANT_SQL_ADMIN_READ, 'triggers')
const isPermissionsLoaded = usePermissionsLoaded()
const createTrigger = () => {
setSelectedTrigger(undefined)
setShowCreateTriggerForm(true)
if (isInlineEditorEnabled) {
setEditorPanel({
open: true,
initialValue: `create trigger trigger_name
after insert or update or delete on table_name
for each row
execute function function_name();`,
label: 'Create new database trigger',
saveLabel: 'Create trigger',
initialPrompt: 'Create a new database trigger that...',
})
} else {
setSelectedTrigger(undefined)
setShowCreateTriggerForm(true)
}
}
const editTrigger = (trigger: any) => {
setSelectedTrigger(trigger)
setShowCreateTriggerForm(true)
if (isInlineEditorEnabled) {
const sql = generateTriggerCreateSQL(trigger)
setEditorPanel({
open: true,
initialValue: sql,
label: `Edit trigger "${trigger.name}"`,
saveLabel: 'Update trigger',
initialPrompt: `Update the database trigger "${trigger.name}" to...`,
})
} else {
setSelectedTrigger(trigger)
setShowCreateTriggerForm(true)
}
}
const deleteTrigger = (trigger: any) => {
Binary file not shown.

After

Width:  |  Height:  |  Size: 72 KiB

+75 -5
View File
@@ -1,8 +1,9 @@
import type { Message as MessageType } from 'ai/react'
import { LOCAL_STORAGE_KEYS as COMMON_LOCAL_STORAGE_KEYS } from 'common'
import { SupportedAssistantEntities } from 'components/ui/AIAssistantPanel/AIAssistant.types'
import { LOCAL_STORAGE_KEYS } from 'lib/constants'
import { SupportedAssistantEntities } from 'components/ui/AIAssistantPanel/AIAssistant.types'
import { proxy, snapshot, subscribe, useSnapshot } from 'valtio'
import { SQL_TEMPLATES } from 'components/interfaces/SQLEditor/SQLEditor.queries'
export type CommonDatabaseEntity = {
id: number
@@ -16,6 +17,12 @@ export type SuggestionsType = {
prompts?: string[]
}
export type Template = {
name: string
description: string
content: string
}
type AiAssistantPanelType = {
open: boolean
messages: MessageType[]
@@ -30,6 +37,17 @@ type AiAssistantPanelType = {
tables: { schema: string; name: string }[]
}
type EditorPanelType = {
open: boolean
initialValue?: string
label?: string
saveLabel?: string
onSave?: (value: string) => void
functionName?: string
templates?: Template[]
initialPrompt?: string
}
type DashboardHistoryType = {
sql?: string
editor?: string
@@ -47,6 +65,19 @@ const INITIAL_AI_ASSISTANT: AiAssistantPanelType = {
tables: [],
}
const INITIAL_EDITOR_PANEL: EditorPanelType = {
open: false,
initialValue: '',
label: '',
saveLabel: '',
initialPrompt: '',
templates: SQL_TEMPLATES.filter((template) => template.type === 'template').map((template) => ({
name: template.title,
description: template.description,
content: template.sql,
})),
}
const EMPTY_DASHBOARD_HISTORY: DashboardHistoryType = {
sql: undefined,
editor: undefined,
@@ -56,6 +87,7 @@ const getInitialState = () => {
if (typeof window === 'undefined') {
return {
aiAssistantPanel: INITIAL_AI_ASSISTANT,
editorPanel: INITIAL_EDITOR_PANEL,
dashboardHistory: EMPTY_DASHBOARD_HISTORY,
activeDocsSection: ['introduction'],
docsLanguage: 'js',
@@ -74,11 +106,13 @@ const getInitialState = () => {
}
const stored = localStorage.getItem(LOCAL_STORAGE_KEYS.AI_ASSISTANT_STATE)
const storedEditor = localStorage.getItem(LOCAL_STORAGE_KEYS.EDITOR_PANEL_STATE)
const urlParams = new URLSearchParams(window.location.search)
const aiAssistantPanelOpenParam = urlParams.get('aiAssistantPanelOpen')
let parsedAiAssistant = INITIAL_AI_ASSISTANT
let parsedEditorPanel = INITIAL_EDITOR_PANEL
try {
if (stored) {
@@ -89,6 +123,9 @@ const getInitialState = () => {
return value
})
}
if (storedEditor) {
parsedEditorPanel = JSON.parse(storedEditor)
}
} catch {
// Ignore parsing errors
}
@@ -101,6 +138,7 @@ const getInitialState = () => {
? aiAssistantPanelOpenParam === 'true'
: parsedAiAssistant.open,
},
editorPanel: parsedEditorPanel,
dashboardHistory: EMPTY_DASHBOARD_HISTORY,
activeDocsSection: ['introduction'],
docsLanguage: 'js',
@@ -206,8 +244,12 @@ export const appState = proxy({
},
setAiAssistantPanel: (value: Partial<AiAssistantPanelType>) => {
const hasEntityChanged = value.entity?.id !== appState.aiAssistantPanel.entity?.id
// Close Editor panel if AI Assistant panel is being opened
if (value.open && appState.editorPanel.open) {
appState.editorPanel.open = false
}
const hasEntityChanged = value.entity?.id !== appState.aiAssistantPanel.entity?.id
appState.aiAssistantPanel = {
...appState.aiAssistantPanel,
content: hasEntityChanged ? '' : appState.aiAssistantPanel.content,
@@ -227,21 +269,49 @@ export const appState = proxy({
appState.ongoingQueriesPanelOpen = value
},
setEditorPanel: (value: Partial<EditorPanelType>) => {
// Close AI Assistant panel if editor panel is being opened
if (value.open && appState.aiAssistantPanel.open) {
appState.aiAssistantPanel.open = false
}
// Reset templates to initial if initialValue is empty
if (value.initialValue === '') {
value.templates = INITIAL_EDITOR_PANEL.templates
}
if (!value.open) {
value.initialPrompt = INITIAL_EDITOR_PANEL.initialPrompt
}
appState.editorPanel = {
...appState.editorPanel,
...value,
}
},
mobileMenuOpen: false,
setMobileMenuOpen: (value: boolean) => {
appState.mobileMenuOpen = value
},
})
// Set up localStorage subscription
// Set up localStorage subscriptions
if (typeof window !== 'undefined') {
subscribe(appState, () => {
const state = {
// Save AI assistant state with limited message history
const aiAssistantState = {
...appState.aiAssistantPanel,
// limit to 20 messages so as to not overflow the context window
messages: appState.aiAssistantPanel.messages?.slice(-20),
}
localStorage.setItem(LOCAL_STORAGE_KEYS.AI_ASSISTANT_STATE, JSON.stringify(state))
localStorage.setItem(LOCAL_STORAGE_KEYS.AI_ASSISTANT_STATE, JSON.stringify(aiAssistantState))
// Save editor panel state
localStorage.setItem(
LOCAL_STORAGE_KEYS.EDITOR_PANEL_STATE,
JSON.stringify(appState.editorPanel)
)
})
}
@@ -1,6 +1,6 @@
'use client'
import React, { forwardRef, useEffect, useImperativeHandle, useRef } from 'react'
import React, { forwardRef, useImperativeHandle, useRef } from 'react'
import { cn } from '../../lib/utils'
import { TextArea } from '../shadcn/ui/text-area'
@@ -14,31 +14,34 @@ export interface ExpandingTextAreaProps extends React.TextareaHTMLAttributes<HTM
*/
const ExpandingTextArea = forwardRef<HTMLTextAreaElement, ExpandingTextAreaProps>(
({ className, value, ...props }, ref) => {
const textAreaRef = useRef<HTMLTextAreaElement>(null)
const internalRef = useRef<HTMLTextAreaElement | null>(null)
// Expose the ref to the parent component
useImperativeHandle(ref, () => textAreaRef.current!)
/**
* This effect is used to resize the textarea based on the content
*/
useEffect(() => {
if (textAreaRef) {
if (textAreaRef.current && !value) {
textAreaRef.current.style.height = '40px'
} else if (textAreaRef && textAreaRef.current) {
textAreaRef.current.style.height = 'auto'
const newHeight = textAreaRef.current.scrollHeight + 'px'
textAreaRef.current.style.height = newHeight
}
useImperativeHandle(ref, () => internalRef.current as HTMLTextAreaElement, [])
const updateTextAreaHeight = (element: HTMLTextAreaElement | null) => {
if (!element) return
// Update the height
if (!value) {
element.style.height = 'auto'
element.style.minHeight = '36px'
} else {
element.style.height = 'auto'
element.style.height = element.scrollHeight + 'px'
}
}, [value, textAreaRef])
}
return (
<TextArea
ref={textAreaRef}
ref={(element) => {
if (element) {
internalRef.current = element
updateTextAreaHeight(element)
}
}}
rows={1}
aria-expanded={false}
className={cn('transition-all resize-none leading-6 box-border', className)}
className={cn('h-auto resize-none box-border', className)}
value={value}
{...props}
/>