From bcb045f1e356cecdcc4ec5b522a960e58da1531a Mon Sep 17 00:00:00 2001 From: Ali Waseem Date: Thu, 22 Jan 2026 10:50:38 -0700 Subject: [PATCH] added initial test of notesbooks --- .../interfaces/Notebook/Notebook.tsx | 164 +++++++++ .../components/CustomActionMenuRender.tsx | 78 +++++ .../components/CustomToolbarRender.tsx | 43 +++ .../interfaces/Notebook/components/index.ts | 2 + .../components/interfaces/Notebook/index.ts | 1 + .../plugins/SQLQueryPlugin/SQLQueryChart.tsx | 108 ++++++ .../SQLQueryPlugin/SQLQueryElement.tsx | 299 ++++++++++++++++ .../Notebook/plugins/SQLQueryPlugin/index.tsx | 61 ++++ .../interfaces/Notebook/renders/index.tsx | 75 ++++ .../NavigationBar/NavigationBar.utils.tsx | 8 +- .../ui/QueryBlock/QueryBlock.types.ts | 19 + apps/studio/package.json | 8 + apps/studio/pages/project/[ref]/notebook.tsx | 16 + pnpm-lock.yaml | 328 +++++++++++++++++- 14 files changed, 1193 insertions(+), 17 deletions(-) create mode 100644 apps/studio/components/interfaces/Notebook/Notebook.tsx create mode 100644 apps/studio/components/interfaces/Notebook/components/CustomActionMenuRender.tsx create mode 100644 apps/studio/components/interfaces/Notebook/components/CustomToolbarRender.tsx create mode 100644 apps/studio/components/interfaces/Notebook/components/index.ts create mode 100644 apps/studio/components/interfaces/Notebook/index.ts create mode 100644 apps/studio/components/interfaces/Notebook/plugins/SQLQueryPlugin/SQLQueryChart.tsx create mode 100644 apps/studio/components/interfaces/Notebook/plugins/SQLQueryPlugin/SQLQueryElement.tsx create mode 100644 apps/studio/components/interfaces/Notebook/plugins/SQLQueryPlugin/index.tsx create mode 100644 apps/studio/components/interfaces/Notebook/renders/index.tsx create mode 100644 apps/studio/components/ui/QueryBlock/QueryBlock.types.ts create mode 100644 apps/studio/pages/project/[ref]/notebook.tsx diff --git a/apps/studio/components/interfaces/Notebook/Notebook.tsx b/apps/studio/components/interfaces/Notebook/Notebook.tsx new file mode 100644 index 00000000000..28e72a90945 --- /dev/null +++ b/apps/studio/components/interfaces/Notebook/Notebook.tsx @@ -0,0 +1,164 @@ +'use client' + +import { useMemo, useRef, useState } from 'react' +import YooptaEditor, { + createYooptaEditor, + YooptaContentValue, + YooptaOnChangeOptions, +} from '@yoopta/editor' +import Paragraph from '@yoopta/paragraph' +import { HeadingOne, HeadingTwo, HeadingThree } from '@yoopta/headings' +import { BulletedList, NumberedList } from '@yoopta/lists' +import Divider from '@yoopta/divider' +import { Bold, Italic, CodeMark, Underline, Strike, Highlight } from '@yoopta/marks' +import ActionMenuList from '@yoopta/action-menu-list' +import Toolbar from '@yoopta/toolbar' + +import { SQLQueryPlugin } from './plugins/SQLQueryPlugin' +import { CustomActionMenuRender } from './components/CustomActionMenuRender' +import { CustomToolbarRender } from './components/CustomToolbarRender' +import { + ParagraphRender, + HeadingOneRender, + HeadingTwoRender, + HeadingThreeRender, + BulletedListRender, + NumberedListRender, + DividerRender, +} from './renders' + +// Define plugins with custom renders that use Supabase design system +const plugins = [ + Paragraph.extend({ + renders: { + paragraph: ParagraphRender, + }, + }), + HeadingOne.extend({ + renders: { + 'heading-one': HeadingOneRender, + }, + }), + HeadingTwo.extend({ + renders: { + 'heading-two': HeadingTwoRender, + }, + }), + HeadingThree.extend({ + renders: { + 'heading-three': HeadingThreeRender, + }, + }), + BulletedList.extend({ + renders: { + 'bulleted-list': BulletedListRender, + }, + }), + NumberedList.extend({ + renders: { + 'numbered-list': NumberedListRender, + }, + }), + Divider.extend({ + renders: { + divider: DividerRender, + }, + }), + SQLQueryPlugin, +] + +// Define marks +const MARKS = [Bold, Italic, CodeMark, Underline, Strike, Highlight] + +// Define tools with custom renders that match Supabase design system +const TOOLS = { + ActionMenu: { + render: CustomActionMenuRender, + tool: ActionMenuList, + }, + Toolbar: { + render: CustomToolbarRender, + tool: Toolbar, + }, +} + +// Initial content with a welcome message +const INITIAL_VALUE: YooptaContentValue = { + '1': { + id: '1', + type: 'HeadingOne', + value: [ + { + id: '1-element', + type: 'heading-one', + children: [{ text: 'Welcome to Notebook' }], + props: { nodeType: 'block' }, + }, + ], + meta: { order: 0, depth: 0 }, + }, + '2': { + id: '2', + type: 'Paragraph', + value: [ + { + id: '2-element', + type: 'paragraph', + children: [ + { + text: 'This is a notebook for exploring your Postgres database. Use the / key to add blocks like text, headings, lists, or SQL queries.', + }, + ], + props: { nodeType: 'block' }, + }, + ], + meta: { order: 1, depth: 0 }, + }, + '3': { + id: '3', + type: 'Paragraph', + value: [ + { + id: '3-element', + type: 'paragraph', + children: [ + { text: 'Try typing /sql to add a SQL query block and start exploring your data.' }, + ], + props: { nodeType: 'block' }, + }, + ], + meta: { order: 2, depth: 0 }, + }, +} + +export function Notebook() { + const editor = useMemo(() => createYooptaEditor(), []) + const selectionRef = useRef(null) + const [value, setValue] = useState(INITIAL_VALUE) + + const onChange = (newValue: YooptaContentValue, options: YooptaOnChangeOptions) => { + setValue(newValue) + } + + return ( +
+
+ +
+
+ ) +} diff --git a/apps/studio/components/interfaces/Notebook/components/CustomActionMenuRender.tsx b/apps/studio/components/interfaces/Notebook/components/CustomActionMenuRender.tsx new file mode 100644 index 00000000000..3bd70162429 --- /dev/null +++ b/apps/studio/components/interfaces/Notebook/components/CustomActionMenuRender.tsx @@ -0,0 +1,78 @@ +import { ActionMenuRenderProps } from '@yoopta/action-menu-list' +import { cn } from 'ui' +import { + Type, + Heading1, + Heading2, + Heading3, + List, + ListOrdered, + Minus, + Database, +} from 'lucide-react' + +// Map plugin types to icons +const PLUGIN_ICONS: Record = { + Paragraph: , + HeadingOne: , + HeadingTwo: , + HeadingThree: , + BulletedList: , + NumberedList: , + Divider: , + SQLQuery: , +} + +export function CustomActionMenuRender({ + actions, + getItemProps, + getRootProps, + empty, +}: ActionMenuRenderProps) { + if (empty) { + return ( +
+
No results found
+
+ ) + } + + return ( +
+
+ {actions.map((action) => { + const itemProps = getItemProps(action.type) + const isSelected = itemProps['data-action-menu-item-selected'] === true + + return ( + + ) + })} +
+
+ ) +} diff --git a/apps/studio/components/interfaces/Notebook/components/CustomToolbarRender.tsx b/apps/studio/components/interfaces/Notebook/components/CustomToolbarRender.tsx new file mode 100644 index 00000000000..61fd95b823a --- /dev/null +++ b/apps/studio/components/interfaces/Notebook/components/CustomToolbarRender.tsx @@ -0,0 +1,43 @@ +import { ToolbarRenderProps } from '@yoopta/toolbar' +import { Bold, Italic, Underline, Strikethrough, Code, Highlighter } from 'lucide-react' +import { cn } from 'ui' + +const MARK_ICONS: Record = { + bold: , + italic: , + underline: , + strike: , + code: , + highlight: , +} + +const MARKS = ['bold', 'italic', 'underline', 'strike', 'code', 'highlight'] as const + +export function CustomToolbarRender({ editor }: ToolbarRenderProps) { + const handleMarkClick = (mark: string) => { + editor.formats[mark]?.toggle() + } + + const isMarkActive = (mark: string) => { + return editor.formats[mark]?.isActive() + } + + return ( +
+ {MARKS.map((mark) => ( + + ))} +
+ ) +} diff --git a/apps/studio/components/interfaces/Notebook/components/index.ts b/apps/studio/components/interfaces/Notebook/components/index.ts new file mode 100644 index 00000000000..f304e504053 --- /dev/null +++ b/apps/studio/components/interfaces/Notebook/components/index.ts @@ -0,0 +1,2 @@ +export { CustomActionMenuRender } from './CustomActionMenuRender' +export { CustomToolbarRender } from './CustomToolbarRender' diff --git a/apps/studio/components/interfaces/Notebook/index.ts b/apps/studio/components/interfaces/Notebook/index.ts new file mode 100644 index 00000000000..de30f5fd46c --- /dev/null +++ b/apps/studio/components/interfaces/Notebook/index.ts @@ -0,0 +1 @@ +export { Notebook } from './Notebook' diff --git a/apps/studio/components/interfaces/Notebook/plugins/SQLQueryPlugin/SQLQueryChart.tsx b/apps/studio/components/interfaces/Notebook/plugins/SQLQueryPlugin/SQLQueryChart.tsx new file mode 100644 index 00000000000..9fb9cc3562b --- /dev/null +++ b/apps/studio/components/interfaces/Notebook/plugins/SQLQueryPlugin/SQLQueryChart.tsx @@ -0,0 +1,108 @@ +import { useMemo, useState } from 'react' +import dayjs from 'dayjs' +import { Bar, BarChart, CartesianGrid, Cell, Tooltip, XAxis, YAxis } from 'recharts' +import { ChartContainer, ChartTooltipContent } from 'ui' +import { CHART_COLORS } from 'components/ui/Charts/Charts.constants' +import type { ChartConfig } from 'components/ui/QueryBlock/QueryBlock.types' + +interface SQLQueryChartProps { + results: any[] + chartConfig: ChartConfig +} + +function getCumulativeResults(data: { rows: readonly any[] }, config: ChartConfig): any[] { + const { xKey, yKey } = config + if (!xKey || !yKey) return data.rows as any[] + + let cumulative = 0 + return data.rows.map((row) => { + cumulative += Number(row[yKey]) || 0 + return { + ...row, + [yKey]: cumulative, + } + }) +} + +export function SQLQueryChart({ results, chartConfig }: SQLQueryChartProps) { + const { xKey, yKey, cumulative = false } = chartConfig + const [focusDataIndex, setFocusDataIndex] = useState() + + const formattedQueryResult = useMemo(() => { + return results?.map((row) => { + return Object.fromEntries( + Object.entries(row).map(([key, value]) => { + if (key === yKey) return [key, Number(value)] + return [key, value] + }) + ) + }) + }, [results, yKey]) + + const chartData = cumulative + ? getCumulativeResults({ rows: formattedQueryResult ?? [] }, chartConfig) + : formattedQueryResult + + const getDateFormat = (key: string) => { + const value = chartData?.[0]?.[key] || '' + if (typeof value === 'number') return 'number' + if (dayjs(value).isValid()) return 'date' + return 'string' + } + + const xKeyDateFormat = getDateFormat(xKey) + + if (!xKey || !yKey) { + return ( +
+

Select columns for the X and Y axes

+
+ ) + } + + return ( +
+ + { + if (e.activeTooltipIndex !== focusDataIndex) { + setFocusDataIndex(e.activeTooltipIndex) + } + }} + onMouseLeave={() => setFocusDataIndex(undefined)} + > + + + xKeyDateFormat === 'date' ? dayjs(value).format('MMM D YYYY HH:mm') : value + } + /> + + } /> + + {chartData?.map((_: any, index: number) => ( + + ))} + + + +
+ ) +} diff --git a/apps/studio/components/interfaces/Notebook/plugins/SQLQueryPlugin/SQLQueryElement.tsx b/apps/studio/components/interfaces/Notebook/plugins/SQLQueryPlugin/SQLQueryElement.tsx new file mode 100644 index 00000000000..c6d0e84e6f6 --- /dev/null +++ b/apps/studio/components/interfaces/Notebook/plugins/SQLQueryPlugin/SQLQueryElement.tsx @@ -0,0 +1,299 @@ +import { useState, useCallback, useEffect } from 'react' +import { + PluginElementRenderProps, + useYooptaEditor, + Elements, + useYooptaReadOnly, +} from '@yoopta/editor' +import { Play, ChevronDown, ChevronUp, Table2, BarChart3, Loader2, Pencil } from 'lucide-react' +import { useMutation } from '@tanstack/react-query' +import { useParams } from 'common' +import { executeSql } from 'data/sql/execute-sql-query' +import { useDatabaseSelectorStateSnapshot } from 'state/database-selector' +import Results from 'components/interfaces/SQLEditor/UtilityPanel/Results' +import { Button, cn, Input_Shadcn_ } from 'ui' +import { BlockViewConfiguration } from 'components/ui/QueryBlock/BlockViewConfiguration' +import { DEFAULT_CHART_CONFIG, type ChartConfig } from 'components/ui/QueryBlock/QueryBlock.types' +import { SQLQueryChart } from './SQLQueryChart' + +export interface SQLQueryElementProps { + sql: string + label: string + chartConfig: ChartConfig + results?: any[] + error?: string +} + +export const SQLQueryElement = ({ + element, + children, + attributes, + blockId, +}: PluginElementRenderProps) => { + const editor = useYooptaEditor() + const isReadOnly = useYooptaReadOnly() + const { ref: projectRef } = useParams() + const state = useDatabaseSelectorStateSnapshot() + + const props = element.props as SQLQueryElementProps + const { sql = '', label = 'Untitled Query', chartConfig = DEFAULT_CHART_CONFIG } = props + + const [localSql, setLocalSql] = useState(sql) + const [localLabel, setLocalLabel] = useState(label) + const [localChartConfig, setLocalChartConfig] = useState(chartConfig) + const [showSql, setShowSql] = useState(true) + const [isEditingLabel, setIsEditingLabel] = useState(false) + const [results, setResults] = useState(props.results) + const [error, setError] = useState(props.error) + + // Sync local state with element props + useEffect(() => { + setLocalSql(sql) + setLocalLabel(label) + setLocalChartConfig(chartConfig) + setResults(props.results) + setError(props.error) + }, [sql, label, chartConfig, props.results, props.error]) + + const { mutate: executeQuery, isPending: isExecuting } = useMutation({ + mutationFn: async (query: string) => { + const result = await executeSql({ + projectRef: projectRef!, + connectionString: state.selectedDatabaseId, + sql: query, + }) + return result + }, + onSuccess: (data) => { + const resultRows = Array.isArray(data.result) ? data.result : [] + setResults(resultRows) + setError(undefined) + + // Update element with results + Elements.updateElement(editor, blockId, { + type: 'sql-query', + props: { + ...props, + sql: localSql, + label: localLabel, + results: resultRows, + error: undefined, + }, + }) + }, + onError: (err: any) => { + const errorMessage = err?.message || 'An error occurred while executing the query' + setError(errorMessage) + setResults(undefined) + + // Update element with error + Elements.updateElement(editor, blockId, { + type: 'sql-query', + props: { + ...props, + sql: localSql, + label: localLabel, + results: undefined, + error: errorMessage, + }, + }) + }, + }) + + const handleRunQuery = useCallback(() => { + if (!localSql.trim() || !projectRef) return + executeQuery(localSql) + }, [localSql, projectRef, executeQuery]) + + const handleSqlChange = useCallback( + (e: React.ChangeEvent) => { + const newSql = e.target.value + setLocalSql(newSql) + + // Debounced update to element + Elements.updateElement(editor, blockId, { + type: 'sql-query', + props: { + ...props, + sql: newSql, + }, + }) + }, + [editor, blockId, props] + ) + + const handleLabelChange = useCallback((e: React.ChangeEvent) => { + const newLabel = e.target.value + setLocalLabel(newLabel) + }, []) + + const handleLabelBlur = useCallback(() => { + setIsEditingLabel(false) + Elements.updateElement(editor, blockId, { + type: 'sql-query', + props: { + ...props, + label: localLabel, + }, + }) + }, [editor, blockId, props, localLabel]) + + const handleChartConfigChange = useCallback( + (config: Partial) => { + const newConfig = { ...localChartConfig, ...config } + setLocalChartConfig(newConfig) + + Elements.updateElement(editor, blockId, { + type: 'sql-query', + props: { + ...props, + chartConfig: newConfig, + }, + }) + }, + [editor, blockId, props, localChartConfig] + ) + + const hasResults = Array.isArray(results) && results.length > 0 + const view = localChartConfig.view || 'table' + + return ( +
+ {/* Header */} +
+
+ {isEditingLabel && !isReadOnly ? ( + { + if (e.key === 'Enter') { + handleLabelBlur() + } + }} + className="h-6 text-sm font-medium max-w-[200px]" + autoFocus + /> + ) : ( + + )} + {!isReadOnly && !isEditingLabel && ( + + )} +
+ +
+
+
+ + {/* SQL Editor */} + {showSql && ( +
+