diff --git a/package-lock.json b/package-lock.json index f09dc074..4b782027 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1525,6 +1525,123 @@ "@lezer/common": "^1.0.0" } }, + "node_modules/@codemirror/basic-setup": { + "version": "0.20.0", + "resolved": "https://registry.npmjs.org/@codemirror/basic-setup/-/basic-setup-0.20.0.tgz", + "integrity": "sha512-W/ERKMLErWkrVLyP5I8Yh8PXl4r+WFNkdYVSzkXYPQv2RMPSkWpr2BgggiSJ8AHF/q3GuApncDD8I4BZz65fyg==", + "deprecated": "In version 6.0, this package has been renamed to just 'codemirror'", + "license": "MIT", + "dependencies": { + "@codemirror/autocomplete": "^0.20.0", + "@codemirror/commands": "^0.20.0", + "@codemirror/language": "^0.20.0", + "@codemirror/lint": "^0.20.0", + "@codemirror/search": "^0.20.0", + "@codemirror/state": "^0.20.0", + "@codemirror/view": "^0.20.0" + } + }, + "node_modules/@codemirror/basic-setup/node_modules/@codemirror/autocomplete": { + "version": "0.20.3", + "resolved": "https://registry.npmjs.org/@codemirror/autocomplete/-/autocomplete-0.20.3.tgz", + "integrity": "sha512-lYB+NPGP+LEzAudkWhLfMxhTrxtLILGl938w+RcFrGdrIc54A+UgmCoz+McE3IYRFp4xyQcL4uFJwo+93YdgHw==", + "license": "MIT", + "dependencies": { + "@codemirror/language": "^0.20.0", + "@codemirror/state": "^0.20.0", + "@codemirror/view": "^0.20.0", + "@lezer/common": "^0.16.0" + } + }, + "node_modules/@codemirror/basic-setup/node_modules/@codemirror/commands": { + "version": "0.20.0", + "resolved": "https://registry.npmjs.org/@codemirror/commands/-/commands-0.20.0.tgz", + "integrity": "sha512-v9L5NNVA+A9R6zaFvaTbxs30kc69F6BkOoiEbeFw4m4I0exmDEKBILN6mK+GksJtvTzGBxvhAPlVFTdQW8GB7Q==", + "license": "MIT", + "dependencies": { + "@codemirror/language": "^0.20.0", + "@codemirror/state": "^0.20.0", + "@codemirror/view": "^0.20.0", + "@lezer/common": "^0.16.0" + } + }, + "node_modules/@codemirror/basic-setup/node_modules/@codemirror/language": { + "version": "0.20.2", + "resolved": "https://registry.npmjs.org/@codemirror/language/-/language-0.20.2.tgz", + "integrity": "sha512-WB3Bnuusw0xhVvhBocieYKwJm04SOk5bPoOEYksVHKHcGHFOaYaw+eZVxR4gIqMMcGzOIUil0FsCmFk8yrhHpw==", + "license": "MIT", + "dependencies": { + "@codemirror/state": "^0.20.0", + "@codemirror/view": "^0.20.0", + "@lezer/common": "^0.16.0", + "@lezer/highlight": "^0.16.0", + "@lezer/lr": "^0.16.0", + "style-mod": "^4.0.0" + } + }, + "node_modules/@codemirror/basic-setup/node_modules/@codemirror/lint": { + "version": "0.20.3", + "resolved": "https://registry.npmjs.org/@codemirror/lint/-/lint-0.20.3.tgz", + "integrity": "sha512-06xUScbbspZ8mKoODQCEx6hz1bjaq9m8W8DxdycWARMiiX1wMtfCh/MoHpaL7ws/KUMwlsFFfp2qhm32oaCvVA==", + "license": "MIT", + "dependencies": { + "@codemirror/state": "^0.20.0", + "@codemirror/view": "^0.20.2", + "crelt": "^1.0.5" + } + }, + "node_modules/@codemirror/basic-setup/node_modules/@codemirror/search": { + "version": "0.20.1", + "resolved": "https://registry.npmjs.org/@codemirror/search/-/search-0.20.1.tgz", + "integrity": "sha512-ROe6gRboQU5E4z6GAkNa2kxhXqsGNbeLEisbvzbOeB7nuDYXUZ70vGIgmqPu0tB+1M3F9yWk6W8k2vrFpJaD4Q==", + "license": "MIT", + "dependencies": { + "@codemirror/state": "^0.20.0", + "@codemirror/view": "^0.20.0", + "crelt": "^1.0.5" + } + }, + "node_modules/@codemirror/basic-setup/node_modules/@codemirror/state": { + "version": "0.20.1", + "resolved": "https://registry.npmjs.org/@codemirror/state/-/state-0.20.1.tgz", + "integrity": "sha512-ms0tlV5A02OK0pFvTtSUGMLkoarzh1F8mr6jy1cD7ucSC2X/VLHtQCxfhdSEGqTYlQF2hoZtmLv+amqhdgbwjQ==", + "license": "MIT" + }, + "node_modules/@codemirror/basic-setup/node_modules/@codemirror/view": { + "version": "0.20.7", + "resolved": "https://registry.npmjs.org/@codemirror/view/-/view-0.20.7.tgz", + "integrity": "sha512-pqEPCb9QFTOtHgAH5XU/oVy9UR/Anj6r+tG5CRmkNVcqSKEPmBU05WtN/jxJCFZBXf6HumzWC9ydE4qstO3TxQ==", + "license": "MIT", + "dependencies": { + "@codemirror/state": "^0.20.0", + "style-mod": "^4.0.0", + "w3c-keyname": "^2.2.4" + } + }, + "node_modules/@codemirror/basic-setup/node_modules/@lezer/common": { + "version": "0.16.1", + "resolved": "https://registry.npmjs.org/@lezer/common/-/common-0.16.1.tgz", + "integrity": "sha512-qPmG7YTZ6lATyTOAWf8vXE+iRrt1NJd4cm2nJHK+v7X9TsOF6+HtuU/ctaZy2RCrluxDb89hI6KWQ5LfQGQWuA==", + "license": "MIT" + }, + "node_modules/@codemirror/basic-setup/node_modules/@lezer/highlight": { + "version": "0.16.0", + "resolved": "https://registry.npmjs.org/@lezer/highlight/-/highlight-0.16.0.tgz", + "integrity": "sha512-iE5f4flHlJ1g1clOStvXNLbORJoiW4Kytso6ubfYzHnaNo/eo5SKhxs4wv/rtvwZQeZrK3we8S9SyA7OGOoRKQ==", + "license": "MIT", + "dependencies": { + "@lezer/common": "^0.16.0" + } + }, + "node_modules/@codemirror/basic-setup/node_modules/@lezer/lr": { + "version": "0.16.3", + "resolved": "https://registry.npmjs.org/@lezer/lr/-/lr-0.16.3.tgz", + "integrity": "sha512-pau7um4eAw94BEuuShUIeQDTf3k4Wt6oIUOYxMmkZgDHdqtIcxWND4LRxi8nI9KuT4I1bXQv67BCapkxt7Ywqw==", + "license": "MIT", + "dependencies": { + "@lezer/common": "^0.16.0" + } + }, "node_modules/@codemirror/commands": { "version": "6.8.1", "resolved": "https://registry.npmjs.org/@codemirror/commands/-/commands-6.8.1.tgz", @@ -24639,7 +24756,14 @@ "@aws-sdk/client-s3": "^3.716.0", "@aws-sdk/s3-request-presigner": "^3.716.0", "@clerk/nextjs": "^6.12.10", - "@codemirror/view": "^6.33.0", + "@codemirror/autocomplete": "^6.18.6", + "@codemirror/basic-setup": "^0.20.0", + "@codemirror/commands": "^6.8.1", + "@codemirror/language": "^6.11.2", + "@codemirror/search": "^6.5.11", + "@codemirror/state": "^6.5.2", + "@codemirror/theme-one-dark": "^6.1.3", + "@codemirror/view": "^6.38.0", "@dnd-kit/core": "^6.3.1", "@glideapps/glide-data-grid": "^6.0.3", "@groundup-dev/ags": "^0.2.2", @@ -24685,7 +24809,7 @@ "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", "cmdk": "^1.0.0", - "codemirror": "^6.0.1", + "codemirror": "^6.0.2", "d3": "^7.9.0", "date-fns": "^3.6.0", "deck.gl": "^9.1.11", diff --git a/packages/app/.gitignore b/packages/app/.gitignore index 6f044995..fe509d03 100644 --- a/packages/app/.gitignore +++ b/packages/app/.gitignore @@ -42,4 +42,6 @@ next-env.d.ts # Sentry Config File .env.sentry-build-plugin -.env.local \ No newline at end of file +.env.local +# clerk configuration (can include secrets) +/.clerk/ diff --git a/packages/app/package.json b/packages/app/package.json index f28d5c1b..9e730583 100644 --- a/packages/app/package.json +++ b/packages/app/package.json @@ -10,11 +10,17 @@ "lint": "next lint && npm run type-check" }, "dependencies": { - "@turf/turf": "^7.2.0", "@aws-sdk/client-s3": "^3.716.0", "@aws-sdk/s3-request-presigner": "^3.716.0", "@clerk/nextjs": "^6.12.10", - "@codemirror/view": "^6.33.0", + "@codemirror/autocomplete": "^6.18.6", + "@codemirror/basic-setup": "^0.20.0", + "@codemirror/commands": "^6.8.1", + "@codemirror/language": "^6.11.2", + "@codemirror/search": "^6.5.11", + "@codemirror/state": "^6.5.2", + "@codemirror/theme-one-dark": "^6.1.3", + "@codemirror/view": "^6.38.0", "@dnd-kit/core": "^6.3.1", "@glideapps/glide-data-grid": "^6.0.3", "@groundup-dev/ags": "^0.2.2", @@ -45,6 +51,7 @@ "@tanstack/react-query-devtools": "^5.66.9", "@tanstack/react-table": "^8.20.6", "@tanstack/react-virtual": "^3.11.2", + "@turf/turf": "^7.2.0", "@types/d3": "^7.4.3", "@types/react-resizable": "^3.0.8", "@types/recharts": "^1.8.29", @@ -59,7 +66,7 @@ "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", "cmdk": "^1.0.0", - "codemirror": "^6.0.1", + "codemirror": "^6.0.2", "d3": "^7.9.0", "date-fns": "^3.6.0", "deck.gl": "^9.1.11", diff --git a/packages/app/src/actions/data/queries/addComputedColumn.ts b/packages/app/src/actions/data/queries/addComputedColumn.ts new file mode 100644 index 00000000..2ab53a67 --- /dev/null +++ b/packages/app/src/actions/data/queries/addComputedColumn.ts @@ -0,0 +1,24 @@ +"use server"; + +import { getServerUser } from "@/lib/auth"; +import { getProjectForUser } from "@/lib/dal/projects"; +import { revalidateQueryCache } from "@/lib/dal/queries"; +import { addComputedColumn } from "@/db/crud/query"; +import { ComputedColumn } from "@common/db/schema/query"; + +export async function addComputedColumnAction( + projectId: string, + queryId: string, + column: ComputedColumn, +): Promise { + const user = await getServerUser(); + const userProject = await getProjectForUser(user, projectId); + + if (!userProject) { + throw new Error("Project not found"); + } + + await addComputedColumn(queryId, column); + + revalidateQueryCache(queryId, projectId); +} diff --git a/packages/app/src/actions/data/queries/deleteComputedColumn.ts b/packages/app/src/actions/data/queries/deleteComputedColumn.ts new file mode 100644 index 00000000..5a6bdc2e --- /dev/null +++ b/packages/app/src/actions/data/queries/deleteComputedColumn.ts @@ -0,0 +1,23 @@ +"use server"; + +import { getServerUser } from "@/lib/auth"; +import { getProjectForUser } from "@/lib/dal/projects"; +import { revalidateQueryCache } from "@/lib/dal/queries"; +import { deleteComputedColumn } from "@/db/crud/query"; + +export async function deleteComputedColumnAction( + projectId: string, + queryId: string, + columnName: string, +): Promise { + const user = await getServerUser(); + const userProject = await getProjectForUser(user, projectId); + + if (!userProject) { + throw new Error("Project not found"); + } + + await deleteComputedColumn(queryId, columnName); + + revalidateQueryCache(queryId, projectId); +} diff --git a/packages/app/src/actions/data/queries/updateComputedColumn.ts b/packages/app/src/actions/data/queries/updateComputedColumn.ts new file mode 100644 index 00000000..05fdd403 --- /dev/null +++ b/packages/app/src/actions/data/queries/updateComputedColumn.ts @@ -0,0 +1,25 @@ +"use server"; + +import { getServerUser } from "@/lib/auth"; +import { getProjectForUser } from "@/lib/dal/projects"; +import { revalidateQueryCache } from "@/lib/dal/queries"; +import { updateComputedColumn } from "@/db/crud/query"; +import { ComputedColumn } from "@common/db/schema/query"; + +export async function updateComputedColumnAction( + projectId: string, + queryId: string, + columnName: string, + column: ComputedColumn, +): Promise { + const user = await getServerUser(); + const userProject = await getProjectForUser(user, projectId); + + if (!userProject) { + throw new Error("Project not found"); + } + + await updateComputedColumn(queryId, columnName, column); + + revalidateQueryCache(queryId, projectId); +} diff --git a/packages/app/src/app/(app)/projects/[projectId]/data/queries/[queryId]/layout.tsx b/packages/app/src/app/(app)/projects/[projectId]/data/queries/[queryId]/layout.tsx index 965ba30e..a33430e8 100644 --- a/packages/app/src/app/(app)/projects/[projectId]/data/queries/[queryId]/layout.tsx +++ b/packages/app/src/app/(app)/projects/[projectId]/data/queries/[queryId]/layout.tsx @@ -3,10 +3,7 @@ import { getProjectForUser } from "@/lib/dal/projects"; import { parseStringParam } from "@/lib/routing"; import { notFound } from "next/navigation"; -import { QueryToggleButton } from "@/components/data/query/query-toggle-button"; -import { QueryNameInput } from "@/components/data/query/query-name-input"; -import { QuerySaveButton } from "@/components/data/query/query-save-button"; -import { BreadcrumbSetter } from "@/components/data/breadcrumb-setter"; +import { QueryLayoutClient } from "@/components/data/query/query-layout-client"; import { isDefinitionConfigured } from "@/components/data/query/helpers"; import { getQueryForProject } from "@/lib/dal/queries"; @@ -30,36 +27,14 @@ export default async function Layout({ params, children, modal }: Props) { const definitionConfigured = isDefinitionConfigured(query); return ( -
- {/* Header with name input, breadcrumbs, and toggle button */} -
-
- - -
-
- - - -
-
+ {children} - {modal} -
+ ); } diff --git a/packages/app/src/components/data/query/query-layout-client.tsx b/packages/app/src/components/data/query/query-layout-client.tsx new file mode 100644 index 00000000..810e9444 --- /dev/null +++ b/packages/app/src/components/data/query/query-layout-client.tsx @@ -0,0 +1,108 @@ +"use client"; + +import { useState } from "react"; +import { QueryToggleButton } from "@/components/data/query/query-toggle-button"; +import { QueryNameInput } from "@/components/data/query/query-name-input"; +import { QuerySaveButton } from "@/components/data/query/query-save-button"; +import { QuerySidebarToggleButton } from "@/components/data/query/query-sidebar-toggle-button"; +import { QuerySidebar } from "@/components/data/query/query-sidebar"; +import { BreadcrumbSetter } from "@/components/data/breadcrumb-setter"; +import { useQueryData } from "@/hooks/queries/use-query-data"; +import { ResizableHandle, ResizablePanel, ResizablePanelGroup } from "@/components/ui/resizable"; + +function QueryLayoutClient({ + projectId, + queryId, + query, + definitionConfigured, + children, + modal, +}: { + projectId: string; + queryId: string; + query: any; + definitionConfigured: boolean; + children: React.ReactNode; + modal: React.ReactNode; +}) { + const [sidebarOpen, setSidebarOpen] = useState(false); + + // Fetch data once for the entire query + const { data: queryData, isLoading: isDataLoading, error: dataError } = useQueryData( + projectId, + query as any, + 100, // Fetch more rows for sidebar use + 0, + [] // Empty zones for now + ); + + return ( +
+ + +
+ {/* Header with name input, breadcrumbs, and toggle button */} +
+
+ + +
+
+ setSidebarOpen(!sidebarOpen)} + projectId={projectId} + queryId={queryId} + /> + + + +
+
+ {children} + {modal} +
+
+ + {sidebarOpen && ( + + setSidebarOpen(false)} + queryData={queryData} + isLoading={isDataLoading} + error={dataError} + /> + + )} +
+
+ ); +} + +export { QueryLayoutClient }; \ No newline at end of file diff --git a/packages/app/src/components/data/query/query-sidebar-toggle-button.tsx b/packages/app/src/components/data/query/query-sidebar-toggle-button.tsx new file mode 100644 index 00000000..72621cf2 --- /dev/null +++ b/packages/app/src/components/data/query/query-sidebar-toggle-button.tsx @@ -0,0 +1,39 @@ +"use client"; + +import { Button } from "@/components/ui/button"; +import { PanelRight } from "lucide-react"; +import { usePathname } from "next/navigation"; + +type QuerySidebarToggleButtonProps = { + isOpen: boolean; + onToggle: () => void; + projectId: string; + queryId: string;}; + +export function QuerySidebarToggleButton({ + isOpen, + onToggle, + projectId, + queryId +}: QuerySidebarToggleButtonProps) { + const pathname = usePathname(); + + const isDefinitionView = pathname === `/projects/${projectId}/data/queries/${queryId}/definition`; + + if(!isDefinitionView) { + return null; + } + + return ( + + ); +} \ No newline at end of file diff --git a/packages/app/src/components/data/query/query-sidebar.tsx b/packages/app/src/components/data/query/query-sidebar.tsx new file mode 100644 index 00000000..bb06e74c --- /dev/null +++ b/packages/app/src/components/data/query/query-sidebar.tsx @@ -0,0 +1,533 @@ +"use client"; + +import { useState, useEffect, useRef, useMemo } from "react"; +import { Query } from "@common/db/schema/query"; +import { Button } from "@/components/ui/button"; +import { X, Save, Trash2, Database, Calculator } from "lucide-react"; +import { cn } from "@/utils/styles"; +import { useDebouncedValue } from "@/hooks/use-debounced-value"; +import { basicSetup, EditorView } from "codemirror"; +import { autocompletion } from "@codemirror/autocomplete"; +import { EditorState } from "@codemirror/state"; +import { addComputedColumnAction } from "@/actions/data/queries/addComputedColumn"; +import { updateComputedColumnAction } from "@/actions/data/queries/updateComputedColumn"; +import { deleteComputedColumnAction } from "@/actions/data/queries/deleteComputedColumn"; +import { ComputedColumn } from "@common/db/schema/query"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select"; + +type QuerySidebarProps = { + query: Query; + projectId: string; + onClose: () => void; + queryData?: { + rows: any[]; + rowCount: number; + } | null; + isLoading?: boolean; + error?: Error | null; +}; + +type ComputedResult = { + value: any; + error?: string; + rowIndex: number; +}; + +export function QuerySidebar({ + query, + projectId, + onClose, + queryData, + isLoading, + error +}: QuerySidebarProps) { + const [editorCode, setEditorCode] = useState(""); + const [columnName, setColumnName] = useState(""); + const [editingColumn, setEditingColumn] = useState(null); + const debouncedEditorCode = useDebouncedValue(editorCode, 500); + const editorRef = useRef(null); + const viewRef = useRef(null); + + // Parse column names from the debounced code editor + const parseColumnNames = (code: string): string[] => { + return code + .split("\n") + .map((line) => line.trim()) + .filter((line) => line.length > 0 && !line.startsWith("//")) + .map((line) => { + // Extract column name from various formats + const match = line.match(/['"`]?([a-zA-Z_][a-zA-Z0-9_]*)['"`]?/); + return match ? match[1] : line; + }) + .filter((name, index, arr) => arr.indexOf(name) === index); // Remove duplicates + }; + + const columnNames = parseColumnNames(debouncedEditorCode); + + // Generate dynamic completions from query data + const completions = useMemo(() => { + if (!queryData?.rows || queryData.rows.length === 0) { + // Fallback to common column names if no data available + return [ + { label: "category", type: "variable", info: "Column name" }, + { label: "status", type: "variable", info: "Column name" }, + { label: "priority", type: "variable", info: "Column name" }, + { label: "user_id", type: "variable", info: "Column name" }, + { label: "created_at", type: "variable", info: "Column name" }, + { label: "updated_at", type: "variable", info: "Column name" }, + { label: "id", type: "variable", info: "Column name" }, + { label: "name", type: "variable", info: "Column name" }, + { label: "description", type: "variable", info: "Column name" }, + ]; + } + + // Extract column names from the first row + const firstRow = queryData.rows[0]; + const availableColumns = Object.keys(firstRow); + + return availableColumns.map(columnName => ({ + label: columnName, + type: "variable" as const, + info: `Column from query data (${queryData.rowCount} rows)`, + })); + }, [queryData]); + + // Process function body and compute results + const computedResults = useMemo(() => { + if (!queryData?.rows || !debouncedEditorCode.trim()) { + return []; + } + + const functionBody = debouncedEditorCode.trim(); + if (!functionBody) return []; + + try { + // Extract column names from the function body + const columnRegex = /\b([a-zA-Z_][a-zA-Z0-9_]*)\b/g; + const detectedColumns = new Set(); + let match; + + while ((match = columnRegex.exec(functionBody)) !== null) { + const columnName = match[1]; + // Skip JavaScript keywords and common words + const jsKeywords = ['function', 'return', 'if', 'else', 'for', 'while', 'var', 'let', 'const', 'true', 'false', 'null', 'undefined']; + if (!jsKeywords.includes(columnName)) { + detectedColumns.add(columnName); + } + } + + // Create the function with column replacement + let processedBody = functionBody; + const availableColumns = Object.keys(queryData.rows[0] || {}); + + // Replace column names with row access + detectedColumns.forEach(columnName => { + if (availableColumns.includes(columnName)) { + const regex = new RegExp(`\\b${columnName}\\b`, 'g'); + processedBody = processedBody.replace(regex, `row.${columnName}`); + } + }); + + // Create the function with proper wrapping + const functionString = `function compute(row) {\n${processedBody}\n}`; + + // Create a safe evaluation environment + const computeFunction = new Function('row', processedBody); + + // Execute for each row + const results: ComputedResult[] = []; + + queryData.rows.forEach((row, index) => { + try { + const result = computeFunction(row); + results.push({ + value: result, + rowIndex: index, + }); + } catch (error) { + results.push({ + value: null, + error: error instanceof Error ? error.message : String(error), + rowIndex: index, + }); + } + }); + + return results; + } catch (error) { + return [{ + value: null, + error: error instanceof Error ? error.message : String(error), + rowIndex: 0, + }]; + } + }, [debouncedEditorCode, queryData]); + + function myCompletions(context: any) { + let before = context.matchBefore(/\w+/); + // If completion wasn't explicitly started and there + // is no word before the cursor, don't open completions. + if (!context.explicit && !before) return null; + return { + from: before ? before.from : context.pos, + options: completions, + validFor: /^\w*$/, + }; + } + + // Initialize CodeMirror + useEffect(() => { + if (!editorRef.current || viewRef.current) return; + + const state = EditorState.create({ + doc: "// Enter function body for computed column\n// Examples:\n// Simple: category + '_' + status\n// With logic:\n// if(priority > 3) {\n// return 'High'\n// }\n// return 'Low'\n// With variables:\n// let total = col1 + col2\n// if(total > 100) {\n// return 'Large'\n// }\n// return 'Small'", + extensions: [ + basicSetup, + autocompletion({ override: [myCompletions] }), + EditorView.updateListener.of((update) => { + if (update.docChanged) { + setEditorCode(update.state.doc.toString()); + } + }), + EditorView.theme({ + "&": { + fontSize: "13px", + fontFamily: 'ui-monospace, SFMono-Regular, "SF Mono", Consolas, "Liberation Mono", Menlo, monospace', + height: "100%", + }, + ".cm-content": { + padding: "8px", + height: "100%", + }, + ".cm-editor": { + border: "2px solid hsl(var(--input))", + borderColor: "hsl(var(--input))", + borderRadius: "6px", + backgroundColor: "hsl(var(--background))", + height: "100%", + }, + ".cm-editor.cm-focused": { + borderColor: "hsl(var(--primary))", + outline: "none", + }, + ".cm-scroller": { + height: "100%", + }, + }), + ], + }); + + const view = new EditorView({ + state, + parent: editorRef.current, + }); + + viewRef.current = view; + + return () => { + view.destroy(); + viewRef.current = null; + }; + }, []); + + const handleEditColumn = (column: ComputedColumn) => { + setEditingColumn(column.name); + setColumnName(column.name); + if (viewRef.current) { + const transaction = viewRef.current.state.update({ + changes: { from: 0, to: viewRef.current.state.doc.length, insert: column.functionBody }, + }); + viewRef.current.dispatch(transaction); + } + }; + + const handleCancelEdit = () => { + setEditingColumn(null); + setColumnName(""); + if (viewRef.current) { + const transaction = viewRef.current.state.update({ + changes: { from: 0, to: viewRef.current.state.doc.length, insert: "" }, + }); + viewRef.current.dispatch(transaction); + } + }; + + const handleDeleteColumn = async (columnName: string) => { + if (confirm(`Are you sure you want to delete the computed column "${columnName}"?`)) { + try { + await deleteComputedColumnAction(projectId, query.id, columnName); + console.log("Deleted computed column:", columnName); + + // Clear editor state after successful deletion + setEditingColumn(null); + setColumnName(""); + if (viewRef.current) { + const transaction = viewRef.current.state.update({ + changes: { from: 0, to: viewRef.current.state.doc.length, insert: "" }, + }); + viewRef.current.dispatch(transaction); + } + } catch (error) { + console.error("Failed to delete computed column:", error); + } + } + }; + + const handleSave = async () => { + if (!columnName.trim()) { + console.warn("Column name is required"); + return; + } + + if (editingColumn) { + await updateComputedColumnAction(projectId, query.id, editingColumn, { + name: columnName.trim(), + functionBody: editorCode.trim(), + }); + setEditingColumn(null); + } else { + await addComputedColumnAction(projectId, query.id, { + name: columnName.trim(), + functionBody: editorCode.trim(), + }); + } + + console.log(editingColumn ? "Updated" : "Saved", "computed column:", columnName); + + // Clear form after save + setColumnName(""); + if (viewRef.current) { + const transaction = viewRef.current.state.update({ + changes: { from: 0, to: viewRef.current.state.doc.length, insert: "" }, + }); + viewRef.current.dispatch(transaction); + } + }; + + const handleClear = () => { + if (viewRef.current) { + const transaction = viewRef.current.state.update({ + changes: { from: 0, to: viewRef.current.state.doc.length, insert: "" }, + }); + viewRef.current.dispatch(transaction); + } + }; + + const lineCount = editorCode.split("\n").length; + const hasErrors = computedResults.some(result => result.error); + const successCount = computedResults.filter(result => !result.error).length; + + return ( +
+
+

Computed Columns

+ +
+
+
+ {/* Existing Computed Columns */} +
+
+

+ + Existing Computed Columns +

+
+ {Array.isArray(query.computedColumns) && query.computedColumns.length > 0 ? ( + + ) : ( +
No computed columns yet
+ )} +
+
+ + setColumnName(e.target.value)} + placeholder="Enter computed column name" + className="w-full mt-1 px-3 py-2 border border-input rounded-md bg-background text-sm focus:outline-none focus:ring-2 focus:ring-primary focus:border-transparent" + /> +
+
+
+ + + {lineCount} line{lineCount !== 1 ? "s" : ""} • {computedResults.length} result{computedResults.length !== 1 ? "s" : ""} + +
+
+
+
+
+
+ {editingColumn && ( + + )} + {editingColumn && ( + + )} + + +
+ + {/* Computed Results Display */} + {computedResults.length > 0 && ( +
+

+ + Computed Results + {hasErrors && ( + + ({successCount}/{computedResults.length} successful) + + )} +

+ {isLoading && ( +
Loading data...
+ )} + {error && ( +
+ Error: {error instanceof Error ? error.message : String(error)} +
+ )} + {!isLoading && !error && ( +
+ {/* Sample Results */} +
+
+
Sample Results
+ + {computedResults.length} computed value{computedResults.length !== 1 ? "s" : ""} + +
+
+ {computedResults.slice(0, 10).map((result, index) => ( +
+
+ + Row {result.rowIndex + 1}: {result.error ? "ERROR" : String(result.value)} + + {result.error && ( + + {result.error} + + )} +
+
+ ))} + {computedResults.length > 10 && ( +
+ ... and {computedResults.length - 10} more +
+ )} +
+
+ + {/* Error Summary */} + {hasErrors && ( +
+
Errors Found
+
+ {computedResults + .filter(result => result.error) + .slice(0, 3) + .map((result, index) => ( +
+ Row {result.rowIndex + 1}: {result.error} +
+ ))} + {computedResults.filter(result => result.error).length > 3 && ( +
+ ... and {computedResults.filter(result => result.error).length - 3} more errors +
+ )} +
+
+ )} +
+ )} +
+ )} +
+
+
+ ); +} \ No newline at end of file diff --git a/packages/app/src/db/crud/data/query.ts b/packages/app/src/db/crud/data/query.ts index 9e48778d..07e6827e 100644 --- a/packages/app/src/db/crud/data/query.ts +++ b/packages/app/src/db/crud/data/query.ts @@ -163,6 +163,8 @@ export async function countDataForQuery( const tableColumns = getTableColumns(defTable); const selectedColumns = tableConfig?.columns || []; + console.log("selectedColumns", selectedColumns); + // Create a mapping of snake_case to camelCase column names const columnMapping = Object.entries(tableColumns).reduce( (acc, [camelCase, column]) => { diff --git a/packages/app/src/db/crud/query.ts b/packages/app/src/db/crud/query.ts index 18148ca9..91ba5bb2 100644 --- a/packages/app/src/db/crud/query.ts +++ b/packages/app/src/db/crud/query.ts @@ -1,7 +1,7 @@ import { eq, and, isNull } from "drizzle-orm"; import { db } from ".."; -import { query, Query, QueryInsert } from "@common/db/schema/query"; +import { query, Query, QueryInsert, ComputedColumn } from "@common/db/schema/query"; export async function readQueries(projectId: string) { return await db @@ -109,3 +109,71 @@ export async function readQueryForProject( return selectedQuery; } + +export async function addComputedColumn( + queryId: string, + newComputedColumn: ComputedColumn, +): Promise { + const existing = await readQuery(queryId); + if (!existing) { + throw new Error("Query not found"); + } + + const updatedComputedColumns = [ + ...(existing.computedColumns ?? []), + newComputedColumn, + ]; + + const [updated] = await db + .update(query) + .set({ computedColumns: updatedComputedColumns }) + .where(eq(query.id, queryId)) + .returning(); + + return updated; +} + +export async function updateComputedColumn( + queryId: string, + columnName: string, + updatedColumn: ComputedColumn, +): Promise { + const existing = await readQuery(queryId); + if (!existing) { + throw new Error("Query not found"); + } + + const updatedComputedColumns = (existing.computedColumns ?? []).map((col) => + col.name === columnName ? updatedColumn : col + ); + + const [updated] = await db + .update(query) + .set({ computedColumns: updatedComputedColumns }) + .where(eq(query.id, queryId)) + .returning(); + + return updated; +} + +export async function deleteComputedColumn( + queryId: string, + columnName: string, +): Promise { + const existing = await readQuery(queryId); + if (!existing) { + throw new Error("Query not found"); + } + + const updatedComputedColumns = (existing.computedColumns ?? []).filter( + (col) => col.name !== columnName + ); + + const [updated] = await db + .update(query) + .set({ computedColumns: updatedComputedColumns }) + .where(eq(query.id, queryId)) + .returning(); + + return updated; +} diff --git a/packages/app/src/hooks/queries/use-query-data.tsx b/packages/app/src/hooks/queries/use-query-data.tsx index 5dc016d7..ede039e4 100644 --- a/packages/app/src/hooks/queries/use-query-data.tsx +++ b/packages/app/src/hooks/queries/use-query-data.tsx @@ -45,6 +45,7 @@ export async function fetchQueryData( const controller = new AbortController(); const timeoutId = setTimeout(() => controller.abort(), 30000); // 30 second timeout + try { const response = await fetch( `${process.env.NEXT_PUBLIC_API_URL}/api/projects/${params.projectId}/queries/${params.query.id}/data?limit=${params.limit}&offset=${params.offset}`, diff --git a/packages/app/src/hooks/use-debounced-value.ts b/packages/app/src/hooks/use-debounced-value.ts new file mode 100644 index 00000000..420431ab --- /dev/null +++ b/packages/app/src/hooks/use-debounced-value.ts @@ -0,0 +1,12 @@ +import { useEffect, useState } from "react"; + +export function useDebouncedValue(value: T, delay: number): T { + const [debouncedValue, setDebouncedValue] = useState(value); + + useEffect(() => { + const handler = setTimeout(() => setDebouncedValue(value), delay); + return () => clearTimeout(handler); + }, [value, delay]); + + return debouncedValue; +} \ No newline at end of file diff --git a/packages/common/src/db/schema/query.ts b/packages/common/src/db/schema/query.ts index fb258a9e..4a543187 100644 --- a/packages/common/src/db/schema/query.ts +++ b/packages/common/src/db/schema/query.ts @@ -43,6 +43,11 @@ export type QueryDefinition = Partial< > >; +export type ComputedColumn = { + name: string; + functionBody: string; +}; + export const query = pgTable("custom_table", { id: idColumn(), name: varchar("name").notNull(), @@ -53,6 +58,7 @@ export const query = pgTable("custom_table", { onDelete: "set null", }), definition: jsonb("definition").$type(), + computedColumns: jsonb("computed_columns").$type().default([]).notNull(), createdAt: createdAt(), updatedAt: updatedAt(), });