From 5469646cc9ddabef325c64b980c8b145f605e766 Mon Sep 17 00:00:00 2001 From: akshaykumar2505 Date: Mon, 3 Aug 2026 17:20:46 +0530 Subject: [PATCH 1/2] feat: add model info panel, readable compile errors, and Explore query time Surface branch/version/saved-by metadata in Models, format Cube compile errors into clear console cards, and show elapsed query time after Run Query. Co-authored-by: Cursor --- public/locales/en/explore.json | 5 + public/locales/ru/explore.json | 5 + public/locales/zh/explore.json | 5 + src/components/CodeEditor/index.module.less | 41 +++++ src/components/CodeEditor/index.tsx | 106 ++++++++++++- src/components/Console/index.module.less | 136 +++++++++++++--- src/components/Console/index.tsx | 93 ++++++++--- .../ExploreDataSection/index.module.less | 42 ++++- src/components/ExploreDataSection/index.tsx | 110 +++++++++++-- src/graphql/generated.ts | 32 ++++ src/graphql/gql/schemas.gql | 8 + src/graphql/gql/versions.gql | 15 ++ src/pages/Models/index.tsx | 27 +++- .../__tests__/formatCompileErrors.test.ts | 61 ++++++++ src/utils/helpers/formatCompileErrors.ts | 146 ++++++++++++++++++ 15 files changed, 761 insertions(+), 71 deletions(-) create mode 100644 src/utils/helpers/__tests__/formatCompileErrors.test.ts create mode 100644 src/utils/helpers/formatCompileErrors.ts diff --git a/public/locales/en/explore.json b/public/locales/en/explore.json index 34842e42..36731322 100644 --- a/public/locales/en/explore.json +++ b/public/locales/en/explore.json @@ -21,6 +21,11 @@ "sql": "Generated SQL", "rest_api": "REST API", "run_query": "Run Query", + "query_time": "Query time", + "apply_to_explore": "Apply to Explore", + "apply_to_explore_success": "Query applied to Explore. Click Run Query to execute.", + "apply_to_explore_error": "Could not apply query", + "copy": "Copy", "export": "Export", "new": "New", "results": "Results", diff --git a/public/locales/ru/explore.json b/public/locales/ru/explore.json index 4f074e47..39e3c16e 100644 --- a/public/locales/ru/explore.json +++ b/public/locales/ru/explore.json @@ -21,6 +21,11 @@ "sql": "Сгенерированный SQL", "rest_api": "REST API", "run_query": "Выполнить запрос", + "query_time": "Время запроса", + "apply_to_explore": "Применить к Explore", + "apply_to_explore_success": "Запрос применён к Explore. Нажмите «Выполнить запрос».", + "apply_to_explore_error": "Не удалось применить запрос", + "copy": "Копировать", "export": "Экспорт", "new": "Новый", "results": "Результаты", diff --git a/public/locales/zh/explore.json b/public/locales/zh/explore.json index e5469328..86fbb046 100644 --- a/public/locales/zh/explore.json +++ b/public/locales/zh/explore.json @@ -21,6 +21,11 @@ "sql": "生成的 SQL", "rest_api": "REST API", "run_query": "运行查询", + "query_time": "查询时间", + "apply_to_explore": "应用到 Explore", + "apply_to_explore_success": "查询已应用到 Explore。点击运行查询以执行。", + "apply_to_explore_error": "无法应用查询", + "copy": "复制", "export": "导出", "new": "新建", "results": "结果", diff --git a/src/components/CodeEditor/index.module.less b/src/components/CodeEditor/index.module.less index cd3be0d5..cb3055e9 100644 --- a/src/components/CodeEditor/index.module.less +++ b/src/components/CodeEditor/index.module.less @@ -114,6 +114,47 @@ border-bottom: 1px solid rgba(0, 0, 0, 0.1); } +.infoBtn { + color: rgba(0, 0, 0, 0.45); + font-size: 16px; + + &:hover { + color: #4a7faa; + } +} + +.infoIcon { + margin-left: 6px; + color: #5a7a96; + font-size: 12px !important; + + &:hover { + color: #3d6a95; + } +} + +.infoList { + display: grid; + grid-template-columns: 140px 1fr; + gap: 8px 16px; + margin: 0; + + dt { + color: rgba(0, 0, 0, 0.45); + font-weight: 600; + } + + dd { + margin: 0; + word-break: break-all; + } +} + +.infoMono { + font-family: SFMono-Regular, Consolas, "Liberation Mono", Menlo, monospace; + font-size: 12px; +} + .editorControls { margin-bottom: 16px; padding: 0 16px; diff --git a/src/components/CodeEditor/index.tsx b/src/components/CodeEditor/index.tsx index a893303a..3e86e979 100644 --- a/src/components/CodeEditor/index.tsx +++ b/src/components/CodeEditor/index.tsx @@ -1,6 +1,10 @@ -import { Col, Row, Space, Tooltip } from "antd"; +import { Col, Modal, Row, Space, Tooltip } from "antd"; import Scrollbar from "react-custom-scrollbars"; -import { CloseOutlined, SyncOutlined } from "@ant-design/icons"; +import { + CloseOutlined, + InfoCircleOutlined, + SyncOutlined, +} from "@ant-design/icons"; import { Editor } from "@monaco-editor/react"; import { useTranslation } from "react-i18next"; import { useResponsive, useSize, useTrackedEffect } from "ahooks"; @@ -14,7 +18,9 @@ import Console from "@/components/Console"; import type { ConsoleError } from "@/components/Console"; import { MONACO_OPTIONS } from "@/utils/constants/monaco"; import type { Dataschema } from "@/types/dataschema"; +import type { Member } from "@/types/team"; import equals from "@/utils/helpers/equals"; +import formatTime from "@/utils/helpers/formatTime"; import { createCompletionProvider } from "@/utils/cubejs-language/completionProvider"; import { createDiagnosticProvider } from "@/utils/cubejs-language/diagnosticProvider"; import { createHoverProvider } from "@/utils/cubejs-language/hoverProvider"; @@ -23,6 +29,7 @@ import { parseYamlDocument } from "@/utils/cubejs-language/yamlParser"; import { parseJsDocument } from "@/utils/cubejs-language/jsParser"; import { CubeRegistry } from "@/utils/cubejs-language/registry"; import { isSmartGenerated, parseProvenance } from "@/utils/provenanceParser"; +import CurrentUserStore from "@/stores/CurrentUserStore"; import SaveIcon from "@/assets/save.svg"; @@ -33,6 +40,29 @@ import styles from "./index.module.less"; import type { MergeStrategy } from "./RegenerateModal"; import type { FC } from "react"; +const resolveSavedBy = ( + schema: Dataschema | undefined, + members: Member[] | undefined, + currentUserId?: string, + currentUserName?: string | null +): string => { + if (!schema) return "—"; + + const fromRelation = schema.user?.display_name?.trim(); + if (fromRelation) return fromRelation; + + const member = members?.find((m) => m.user_id === schema.user_id); + const fromMember = member?.displayName?.trim() || member?.email?.trim() || ""; + if (fromMember) return fromMember; + + if (schema.user_id && schema.user_id === currentUserId) { + const self = currentUserName?.trim(); + if (self) return self; + } + + return "—"; +}; + interface CodeEditorProps { schemas?: Dataschema[]; active?: string | null; @@ -53,6 +83,8 @@ interface CodeEditorProps { validationError: string | ConsoleError[]; cubeRegistry?: CubeRegistry; onRefreshRegistry?: () => void; + branchName?: string; + versionNumber?: number; } const languages = { @@ -76,8 +108,11 @@ const CodeEditor: FC = ({ validationError, cubeRegistry, onRefreshRegistry, + branchName, + versionNumber, }) => { const { t } = useTranslation(["models", "common"]); + const { teamData, currentUser } = CurrentUserStore(); const pageHeader = useRef(null); const editorHeader = useRef(null); const completionDisposableRef = useRef(null); @@ -95,6 +130,7 @@ const CodeEditor: FC = ({ ); const [showRegenerateModal, setShowRegenerateModal] = useState(false); + const [showInfoModal, setShowInfoModal] = useState(false); // Create a default registry if none provided const registryRef = useRef(cubeRegistry ?? new CubeRegistry()); @@ -587,6 +623,18 @@ const CodeEditor: FC = ({ onClick={() => onTabChange(files[name])} > {files[name].name} {files[name].code !== content?.[name] && "*"} + {active === name && ( + + { + e.preventDefault(); + e.stopPropagation(); + setShowInfoModal(true); + }} + /> + + )} = ({ )} + setShowInfoModal(false)} + footer={null} + destroyOnClose + > + {files[active] && ( +
+
Name
+
{files[active].name}
+ +
Branch
+
{branchName || "—"}
+ +
Version
+
+ {typeof versionNumber === "number" ? versionNumber : "—"} +
+ +
Saved by
+
+ {resolveSavedBy( + files[active], + teamData?.members, + currentUser?.id, + currentUser?.displayName || currentUser?.email + )} +
+ +
Datasource
+
{files[active].datasource?.name || "—"}
+ +
Created at
+
+ {files[active].created_at + ? formatTime(files[active].created_at) + : "—"} +
+ +
Updated at
+
+ {files[active].updated_at + ? formatTime(files[active].updated_at) + : "—"} +
+ +
Checksum
+
+ {files[active].checksum || "—"} +
+
+ )} +
setShowRegenerateModal(false)} diff --git a/src/components/Console/index.module.less b/src/components/Console/index.module.less index 3b0c8808..803f6d40 100644 --- a/src/components/Console/index.module.less +++ b/src/components/Console/index.module.less @@ -1,17 +1,23 @@ .card { - height: 280px; + display: flex; + flex-direction: column; + height: 320px; + background: #fafafa; + border-top: 1px solid rgba(0, 0, 0, 0.08); box-shadow: none !important; } .closeButton { cursor: pointer; + flex-shrink: 0; } .tabBtn { position: relative; - color: black; + color: #1f1f1f; font-weight: 600; cursor: pointer; + &::after { position: absolute; bottom: -16px; @@ -27,51 +33,139 @@ .header { display: flex; + flex-shrink: 0; align-items: center; justify-content: space-between; - padding: 16px 12px 14px; - border-bottom: 1px solid rgba(0, 0, 0, 0.1); + padding: 14px 12px 12px; + background: #fff; + border-bottom: 1px solid rgba(0, 0, 0, 0.08); } .body { - height: 240px; - padding: 16px 0; + flex: 1; + min-height: 0; + padding: 12px; overflow-y: auto; } -.errorItem { - padding: 2px 8px !important; +.errorList { + display: flex; + flex-direction: column; + gap: 10px; +} + +.errorCard { + display: flex; + gap: 10px; + padding: 12px 14px; + background: #fff; + border: 1px solid #ffccc7; + border-left: 3px solid #ff4d4f; + border-radius: 6px; +} + +.clickable { + cursor: pointer; + + &:hover { + background: #fff8f7; + } +} + +.errorCardIcon { + flex-shrink: 0; + padding-top: 2px; +} + +.errorCardBody { + display: flex; + flex: 1; + flex-direction: column; + gap: 6px; + min-width: 0; +} + +.errorMeta { + display: flex; + flex-wrap: wrap; + gap: 8px; + align-items: center; +} + +.cubeName { + color: #1f1f1f; + font-size: 13px; + font-weight: 600; +} + +.fieldPath { + padding: 1px 6px; + color: #a8071a; font-size: 12px; - font-family: monospace; - align-items: flex-start !important; + font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; + background: #fff1f0; + border-radius: 4px; +} + +.lineRef { + color: #8c8c8c; + font-size: 12px; +} + +.errorMessage { + color: #262626; + font-size: 13px; + line-height: 1.45; + word-break: break-word; +} + +.allowedBlock { + margin-top: 2px; +} + +.allowedLabel { + margin-bottom: 6px; + color: #8c8c8c; + font-size: 11px; + font-weight: 600; + letter-spacing: 0.02em; + text-transform: uppercase; +} + +.allowedList { + display: flex; + flex-wrap: wrap; gap: 6px; } +.allowedChip { + padding: 2px 8px; + color: #434343; + font-size: 12px; + font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; + background: #f5f5f5; + border: 1px solid #e8e8e8; + border-radius: 999px; +} + .errorIcon { color: #ff4d4f; - margin-top: 2px; + font-size: 14px; } .warningIcon { color: #faad14; - margin-top: 2px; -} - -.clickableLine { - cursor: pointer; - &:hover { - text-decoration: underline; - } + font-size: 14px; } .warningCount { margin-left: 8px; - font-size: 11px; color: #999; + font-size: 11px; } .noErrors { padding: 12px; color: #999; - font-size: 12px; + font-size: 13px; } diff --git a/src/components/Console/index.tsx b/src/components/Console/index.tsx index d0f8629c..fbb92bcf 100644 --- a/src/components/Console/index.tsx +++ b/src/components/Console/index.tsx @@ -1,6 +1,9 @@ -import { Badge, List } from "antd"; +import { Badge } from "antd"; import { ExclamationCircleOutlined, WarningOutlined } from "@ant-design/icons"; +import { formatCompileErrors } from "@/utils/helpers/formatCompileErrors"; +import type { FormattedCompileError } from "@/utils/helpers/formatCompileErrors"; + import CloseIcon from "@/assets/console-close.svg"; import s from "./index.module.less"; @@ -10,6 +13,9 @@ import type { FC } from "react"; export interface ConsoleError { severity: "error" | "warning"; message: string; + cube?: string; + path?: string; + allowed?: string[]; line?: number | null; column?: number | null; fileName?: string | null; @@ -24,7 +30,56 @@ interface ConsoleProps { function parseErrors(errors: string | ConsoleError[]): ConsoleError[] { if (Array.isArray(errors)) return errors; if (!errors || !errors.trim()) return []; - return [{ severity: "error", message: errors }]; + return formatCompileErrors(errors); +} + +function ErrorCard({ + item, + onGoToLine, +}: { + item: ConsoleError | FormattedCompileError; + onGoToLine?: (line: number, column?: number) => void; +}) { + const line = "line" in item ? item.line : null; + const column = "column" in item ? item.column : null; + const clickable = !!line; + + return ( +
line && onGoToLine?.(line, column ?? 1)} + > +
+ {item.severity === "error" ? ( + + ) : ( + + )} +
+
+ {(item.cube || item.path || line) && ( +
+ {item.cube && {item.cube}} + {item.path && {item.path}} + {line ? Ln {line} : null} +
+ )} +
{item.message}
+ {item.allowed && item.allowed.length > 0 && ( +
+
Allowed values
+
+ {item.allowed.map((value) => ( + + {value} + + ))} +
+
+ )} +
+
+ ); } const Console: FC = ({ errors, onClose, onGoToLine }) => { @@ -54,29 +109,17 @@ const Console: FC = ({ errors, onClose, onGoToLine }) => { {items.length === 0 ? (
No issues found
) : ( - ( - - {item.severity === "error" ? ( - - ) : ( - - )} - - item.line && onGoToLine?.(item.line, item.column ?? 1) - } - > - {item.line ? `Ln ${item.line}: ` : ""} - {item.message} - - - )} - /> +
+ {items.map((item, index) => ( + + ))} +
)} diff --git a/src/components/ExploreDataSection/index.module.less b/src/components/ExploreDataSection/index.module.less index b22fb0d5..a4f7da1f 100644 --- a/src/components/ExploreDataSection/index.module.less +++ b/src/components/ExploreDataSection/index.module.less @@ -121,14 +121,42 @@ left: 20px; } +.runActions { + display: flex; + align-items: center; + gap: 8px; +} + +.queryTime { + color: rgba(0, 0, 0, 0.55); + font-size: 13px; + white-space: nowrap; +} + .run { display: flex; align-items: center; justify-content: center; - width: 100%; background: #4a7faa; } +.reset { + display: flex; + align-items: center; + justify-content: center; + white-space: nowrap; + color: #fff; + background: #4a7faa; + border-color: #4a7faa; + + &:hover, + &:focus { + color: #fff !important; + background: #3d6d93 !important; + border-color: #3d6d93 !important; + } +} + .buttonGroup { display: flex !important; gap: 10px; @@ -224,7 +252,19 @@ margin-top: calc(5%); } +.sqlHeaderRow { + display: flex; + align-items: center; + gap: 8px; + margin-bottom: 8px; +} + .sqlHeader { font-weight: 700; font-size: 14px; } + +.sqlCopy { + display: inline-flex; + align-items: center; +} diff --git a/src/components/ExploreDataSection/index.tsx b/src/components/ExploreDataSection/index.tsx index 1e2f5956..227b4d83 100644 --- a/src/components/ExploreDataSection/index.tsx +++ b/src/components/ExploreDataSection/index.tsx @@ -1,8 +1,9 @@ -import { Col, Divider, Dropdown, Radio, Row, Tooltip } from "antd"; +import { Col, Divider, Dropdown, Radio, Row, Tooltip, message } from "antd"; import { DownOutlined } from "@ant-design/icons"; import { useResponsive, useSetState } from "ahooks"; import { useTranslation } from "react-i18next"; import cn from "classnames"; +import { useEffect, useRef, useState } from "react"; import useAnalyticsQueryMembers from "@/hooks/useAnalyticsQueryMembers"; import useFormatExport from "@/hooks/useFormatExport"; @@ -24,6 +25,7 @@ import type { Branch, DataSourceInfo } from "@/types/dataSource"; import AlertIcon from "@/assets/alert.svg"; import ReportIcon from "@/assets/report.svg"; import ArrowIcon from "@/assets/arrow-small-right.svg"; +import CopyIcon from "@/assets/copy.svg"; import s from "./index.module.less"; @@ -32,6 +34,11 @@ import type { FC, ReactNode } from "react"; type SortUpdater = (nextSortBy: SortBySet[]) => void; +const formatQueryTime = (ms: number): string => { + if (ms < 1000) return `${Math.round(ms)} ms`; + return `${(ms / 1000).toFixed(2)} s`; +}; + interface ExploreDataSectionProps extends Omit { width?: number; height?: number; @@ -40,6 +47,8 @@ interface ExploreDataSectionProps extends Omit { onOpenModal: (type: string) => void; onExec: any; onQueryChange: (query: string, ...args: any) => void | SortUpdater; + onApplyQuery?: (state: PlaygroundState) => void; + onResetQuery?: () => void; disabled: boolean; playgroundState: PlaygroundState; dataSource?: DataSourceInfo; @@ -69,6 +78,8 @@ const ExploreDataSection: FC = (props) => { onOpenModal = () => {}, onExec = () => {}, onQueryChange = () => {}, + onApplyQuery, + onResetQuery, state: workspaceState, playgroundState, queryState, @@ -87,6 +98,9 @@ const ExploreDataSection: FC = (props) => { const [currState, updateState] = useSetState({ section: workspaceState?.dataSection || "sql", }); + const [queryTimeMs, setQueryTimeMs] = useState(null); + const queryStartedAt = useRef(null); + const wasLoading = useRef(false); const { exportData, isExporting } = useFormatExport({ datasourceId: dataSource?.id, @@ -94,6 +108,25 @@ const ExploreDataSection: FC = (props) => { query: playgroundState, }); + useEffect(() => { + if (loading) { + wasLoading.current = true; + return; + } + + if (wasLoading.current && queryStartedAt.current != null) { + setQueryTimeMs(performance.now() - queryStartedAt.current); + queryStartedAt.current = null; + wasLoading.current = false; + } + }, [loading]); + + const handleExec = () => { + queryStartedAt.current = performance.now(); + setQueryTimeMs(null); + onExec(); + }; + // const formConfig = { // rows: { // label: t("data_section.rows_limit"), @@ -231,6 +264,9 @@ const ExploreDataSection: FC = (props) => { {t("data_section.shown")}: {rows.length} / {limit},{" "} {t("data_section.offset")}: {offset}, {t("data_section.columns")}:{" "} {columns.length} + {queryTimeMs != null && !loading && ( + <>, Query time: {formatQueryTime(queryTimeMs)} + )} } /> @@ -252,6 +288,7 @@ const ExploreDataSection: FC = (props) => { onQueryChange, settings, rowHeight, + queryTimeMs, t, limit, empty, @@ -268,7 +305,18 @@ const ExploreDataSection: FC = (props) => { return ( <> - {t("SQL")} +
+ {t("SQL")} + +
= (props) => { dataSourceId={dataSource.id} branchId={currentBranch.id} playgroundState={playgroundState} + onApplyQuery={(nextState) => { + onApplyQuery?.(nextState); + updateState({ section: "results" }); + onSectionChange("results"); + }} /> ); } - }, [currentBranch?.id, dataSource?.id, playgroundState]); + }, [ + currentBranch?.id, + dataSource?.id, + onApplyQuery, + onSectionChange, + playgroundState, + updateState, + ]); + + const handleResetQuery = () => { + onResetQuery?.(); + message.success("Selection cleared"); + }; const onChange = (values: Partial) => { if (values.limit !== limit) { @@ -306,17 +371,34 @@ const ExploreDataSection: FC = (props) => {
- +
+ + {onResetQuery && ( + + )} + {queryTimeMs != null && !loading && ( + + Query time: {formatQueryTime(queryTimeMs)} + + )} +
; }>; }>; @@ -12954,6 +12958,8 @@ export type VersionByBranchIdQuery = { name: string; code: string; checksum?: string | null; + user?: { __typename?: "users"; display_name?: string | null } | null; + datasource?: { __typename?: "datasources"; name: string } | null; }>; }>; versions_aggregate: { @@ -12975,6 +12981,7 @@ export type CurrentVersionQuery = { __typename?: "versions"; id: any; checksum: string; + user?: { __typename?: "users"; display_name?: string | null } | null; dataschemas: Array<{ __typename?: "dataschemas"; created_at: any; @@ -12985,6 +12992,8 @@ export type CurrentVersionQuery = { name: string; code: string; checksum?: string | null; + user?: { __typename?: "users"; display_name?: string | null } | null; + datasource?: { __typename?: "datasources"; name: string } | null; }>; }>; }; @@ -14430,6 +14439,14 @@ export const AllDataSchemasDocument = gql` created_at updated_at datasource_id + user_id + checksum + user { + display_name + } + datasource { + name + } } } } @@ -14681,6 +14698,12 @@ export const VersionByBranchIdDocument = gql` name code checksum + user { + display_name + } + datasource { + name + } } } versions_aggregate(where: { branch_id: { _eq: $branch_id } }) { @@ -14708,6 +14731,9 @@ export const CurrentVersionDocument = gql` ) { id checksum + user { + display_name + } dataschemas(order_by: { name: asc }) { created_at updated_at @@ -14717,6 +14743,12 @@ export const CurrentVersionDocument = gql` name code checksum + user { + display_name + } + datasource { + name + } } } } diff --git a/src/graphql/gql/schemas.gql b/src/graphql/gql/schemas.gql index 2bf12b76..5120f30f 100644 --- a/src/graphql/gql/schemas.gql +++ b/src/graphql/gql/schemas.gql @@ -45,6 +45,14 @@ query AllDataSchemas( created_at updated_at datasource_id + user_id + checksum + user { + display_name + } + datasource { + name + } } } } diff --git a/src/graphql/gql/versions.gql b/src/graphql/gql/versions.gql index f7f0094f..9adc596c 100644 --- a/src/graphql/gql/versions.gql +++ b/src/graphql/gql/versions.gql @@ -27,6 +27,12 @@ query versionByBranchId($branch_id: uuid!, $limit: Int, $offset: Int) { name code checksum + user { + display_name + } + datasource { + name + } } } versions_aggregate(where: { branch_id: { _eq: $branch_id } }) { @@ -45,6 +51,9 @@ query CurrentVersion($branch_id: uuid!) { ) { id checksum + user { + display_name + } dataschemas(order_by: { name: asc }) { created_at updated_at @@ -54,6 +63,12 @@ query CurrentVersion($branch_id: uuid!) { name code checksum + user { + display_name + } + datasource { + name + } } } } diff --git a/src/pages/Models/index.tsx b/src/pages/Models/index.tsx index badc4b2b..3ff85d04 100644 --- a/src/pages/Models/index.tsx +++ b/src/pages/Models/index.tsx @@ -30,10 +30,12 @@ import { parseProvenance } from "@/utils/provenanceParser"; import calcChecksum from "@/utils/helpers/dataschemasChecksum"; import getTables from "@/utils/helpers/getTables"; import getCurrentBranch from "@/utils/helpers/getCurrentBranch"; +import { formatCompileErrors } from "@/utils/helpers/formatCompileErrors"; import type { CubeRegistry } from "@/utils/cubejs-language/registry"; import type { Branch, DataSourceInfo, Schema } from "@/types/dataSource"; import type { Dataschema } from "@/types/dataschema"; import type { Version } from "@/types/version"; +import type { ConsoleError } from "@/components/Console"; import type { Branches_Insert_Input } from "@/graphql/generated"; import type { CreateBranchFormValues } from "@/components/BranchSelection"; import CurrentUserStore from "@/stores/CurrentUserStore"; @@ -81,7 +83,7 @@ interface ModelsProps { schemaFetching?: boolean; onModalClose: () => void; data?: object[]; - validationError?: string; + validationError?: string | ConsoleError[]; isConsoleOpen?: boolean; toggleConsole?: () => void; onSaveVersion: ( @@ -222,11 +224,14 @@ export const Models: React.FC = ({ {dataSource.name} - {!isMobile && validationError && ( - - {t("models:alerts.compilation_error")} - - )} + {!isMobile && + (Array.isArray(validationError) + ? validationError.length > 0 + : !!validationError) && ( + + {t("models:alerts.compilation_error")} + + )} ) : ( t("models") @@ -291,6 +296,8 @@ export const Models: React.FC = ({ validationError={validationError} cubeRegistry={cubeRegistry} onRefreshRegistry={onRefreshRegistry} + branchName={currentBranch?.name} + versionNumber={versionsCount} />
@@ -319,6 +326,7 @@ export const Models: React.FC = ({ > { }); const validationError = useMemo( - () => metaData?.error?.message, + () => formatCompileErrors(metaData?.error?.message), [metaData.error] ); useEffect(() => { - toggleConsole(!!validationError); + const hasErrors = Array.isArray(validationError) + ? validationError.length > 0 + : !!validationError; + toggleConsole(hasErrors); }, [validationError]); const sqlResult = useMemo( diff --git a/src/utils/helpers/__tests__/formatCompileErrors.test.ts b/src/utils/helpers/__tests__/formatCompileErrors.test.ts new file mode 100644 index 00000000..4a7b19f9 --- /dev/null +++ b/src/utils/helpers/__tests__/formatCompileErrors.test.ts @@ -0,0 +1,61 @@ +import { describe, expect, it } from "vitest"; + +import { formatCompileErrors } from "../formatCompileErrors"; + +describe("formatCompileErrors", () => { + it("returns empty for blank input", () => { + expect(formatCompileErrors("")).toEqual([]); + expect(formatCompileErrors(null)).toEqual([]); + }); + + it("strips GraphQL/Joi noise and structures field errors", () => { + const raw = `[GraphQL] Error: Compile errors: +Errors: +ErrorDemo cube: "measures.broken_sum.type" must be one of [count, number, string, boolean, time, sum, avg, min, max, countDistinct, runningTotal, countDistinctApprox]. "measures.broken_sum.sql" is not allowed Possible reasons (one of): * (measures.broken_sum.type == notARealType) must be one of [count, number, string, boolean, time, sum, avg, min, max, countDistinct, runningTotal, countDistinctApprox] * (measures.broken_sum.sql = () => 'amount') is not allowed`; + + const result = formatCompileErrors(raw); + + expect(result).toEqual([ + { + severity: "error", + cube: "ErrorDemo", + path: "measures.broken_sum.type", + message: "Invalid type", + allowed: [ + "count", + "number", + "string", + "boolean", + "time", + "sum", + "avg", + "min", + "max", + "countDistinct", + "runningTotal", + "countDistinctApprox", + ], + }, + { + severity: "error", + cube: "ErrorDemo", + path: "measures.broken_sum.sql", + message: "This property is not allowed for the current type", + }, + ]); + }); + + it("keeps a simple message readable", () => { + const result = formatCompileErrors( + "[GraphQL] Error: Compile errors:\nOrders cube: Cube Orders doesn't exist" + ); + + expect(result).toEqual([ + { + severity: "error", + cube: "Orders", + message: "Cube Orders doesn't exist", + }, + ]); + }); +}); diff --git a/src/utils/helpers/formatCompileErrors.ts b/src/utils/helpers/formatCompileErrors.ts new file mode 100644 index 00000000..2cb33cbb --- /dev/null +++ b/src/utils/helpers/formatCompileErrors.ts @@ -0,0 +1,146 @@ +/** + * Turn Cube/Hasura/urql compile-error blobs into readable console rows. + * + * Input often looks like: + * [GraphQL] Error: Compile errors: + * Errors: + * ErrorDemo cube: "measures.broken_sum.type" must be one of [...]. "measures.broken_sum.sql" is not allowed + * Possible reasons (one of): * (...) * (...) + */ +export type FormattedCompileError = { + severity: "error" | "warning"; + /** Short human-readable summary */ + message: string; + /** Cube name when known */ + cube?: string; + /** Field path, e.g. measures.broken_sum.type */ + path?: string; + /** Allowed values when message is an enum violation */ + allowed?: string[]; +}; + +export function formatCompileErrors( + raw: string | undefined | null +): FormattedCompileError[] { + if (!raw?.trim()) return []; + + let text = raw.trim(); + + text = text.replace(/^\[GraphQL\]\s*/i, ""); + text = text.replace(/^Error:\s*/i, ""); + text = text.replace(/^Compile errors:\s*/i, ""); + text = text.replace(/^Errors:\s*/i, ""); + text = text.trim(); + + const reasonsIdx = text.search(/\bPossible reasons\b/i); + if (reasonsIdx >= 0) { + text = text.slice(0, reasonsIdx).trim(); + } + + if (!text) { + return [{ severity: "error", message: raw.trim() }]; + } + + const cubeChunks = text + .split(/(?=(?:^|\n)\s*[A-Za-z_][\w]*\s+cube:)/) + .map((chunk) => chunk.trim()) + .filter(Boolean); + + const items: FormattedCompileError[] = []; + + for (const chunk of cubeChunks.length ? cubeChunks : [text]) { + items.push(...parseCubeChunk(chunk)); + } + + return dedupe(items); +} + +function parseCubeChunk(chunk: string): FormattedCompileError[] { + const cleaned = chunk + .replace(/\b([A-Za-z_][\w]*)\s+cube:\s*/i, "$1: ") + .replace(/\s+/g, " ") + .trim(); + + const cubeMatch = cleaned.match(/^([A-Za-z_][\w]*):\s*(.*)$/); + const cube = cubeMatch?.[1]; + const body = cubeMatch ? cubeMatch[2] : cleaned; + + const parts = body + .split(/(?="[^"]+"\s+(?:must be|is not allowed|is required))/i) + .map((part) => + part + .replace(/^[.\s]+/, "") + .replace(/\.\s*$/, "") + .trim() + ) + .filter(Boolean); + + const sources = parts.length ? parts : [body]; + + return sources.map((part) => toStructuredError(part, cube)); +} + +function toStructuredError(part: string, cube?: string): FormattedCompileError { + const pathMatch = part.match(/^"([^"]+)"\s+(.*)$/); + const path = pathMatch?.[1]; + const rest = (pathMatch?.[2] || part).trim(); + + const allowedMatch = rest.match(/^must be one of\s*\[([^\]]+)\]\.?\s*$/i); + if (allowedMatch) { + const allowed = allowedMatch[1] + .split(",") + .map((v) => v.trim()) + .filter(Boolean); + + return { + severity: "error", + cube, + path, + message: "Invalid type", + allowed, + }; + } + + if (/^is not allowed\.?$/i.test(rest)) { + return { + severity: "error", + cube, + path, + message: "This property is not allowed for the current type", + }; + } + + if (/^is required\.?$/i.test(rest)) { + return { + severity: "error", + cube, + path, + message: "This property is required", + }; + } + + return { + severity: "error", + cube, + path, + message: rest || part, + }; +} + +function dedupe(items: FormattedCompileError[]): FormattedCompileError[] { + const seen = new Set(); + const result: FormattedCompileError[] = []; + + for (const item of items) { + const key = [item.cube, item.path, item.message, item.allowed?.join(",")] + .filter(Boolean) + .join("|"); + if (seen.has(key)) continue; + seen.add(key); + result.push(item); + } + + return result; +} + +export default formatCompileErrors; From 1869e357ae1e8ededd9f0e0441fb48119d320f99 Mon Sep 17 00:00:00 2001 From: akshaykumar2505 Date: Mon, 3 Aug 2026 17:27:15 +0530 Subject: [PATCH 2/2] feat: Explore select-all, Rest apply, filters, and Smart Gen UI polish Add select-all for Explore members, Apply Rest body to Explore, clearer filter labels, SQL copy, and Smart Gen / Data Sources UX refinements on the same UX branch. Co-authored-by: Cursor --- public/locales/en/common.json | 1 + public/locales/ru/common.json | 1 + public/locales/zh/common.json | 1 + .../DataModelGeneration/index.module.less | 12 ++ src/components/DataModelGeneration/index.tsx | 29 ++- src/components/DataSourceFormBody/index.tsx | 28 +-- src/components/ExploreCubes/index.tsx | 2 + .../ExploreCubesSection/index.module.less | 29 ++- src/components/ExploreCubesSection/index.tsx | 96 +++++++-- src/components/ExploreWorkspace/index.tsx | 4 + .../PlaygroundFilterSelect/index.module.less | 21 ++ .../PlaygroundFilterSelect/index.tsx | 89 ++++++--- src/components/PrismCode/index.tsx | 6 +- src/components/RestAPI/index.module.less | 15 +- src/components/RestAPI/index.tsx | 36 +++- src/components/SmartGeneration/index.tsx | 112 ++++++++++- src/components/TableSelection/index.tsx | 22 +-- src/hooks/useAnalyticsQuery.ts | 186 +++++++++++------- src/hooks/usePlayground.ts | 1 + src/pages/DataSources/index.tsx | 15 +- .../parseRestBodyToPlaygroundState.test.ts | 56 ++++++ .../helpers/parseRestBodyToPlaygroundState.ts | 111 +++++++++++ 22 files changed, 694 insertions(+), 179 deletions(-) create mode 100644 src/components/PlaygroundFilterSelect/index.module.less create mode 100644 src/utils/helpers/parseRestBodyToPlaygroundState.test.ts create mode 100644 src/utils/helpers/parseRestBodyToPlaygroundState.ts diff --git a/public/locales/en/common.json b/public/locales/en/common.json index 82775591..5b488fa4 100644 --- a/public/locales/en/common.json +++ b/public/locales/en/common.json @@ -30,6 +30,7 @@ "columns": "columns", "select_all": "Select all", "clear": "Clear", + "copy": "Copy", "docs": "Docs", "team": "Team", "host": "Host", diff --git a/public/locales/ru/common.json b/public/locales/ru/common.json index 75f8376c..dbab5e3a 100644 --- a/public/locales/ru/common.json +++ b/public/locales/ru/common.json @@ -30,6 +30,7 @@ "columns": "столбцы", "select_all": "Выбрать все", "clear": "Очистить", + "copy": "Копировать", "docs": "Документация", "team": "Команда", "host": "Хост", diff --git a/public/locales/zh/common.json b/public/locales/zh/common.json index 42378775..bf1c4707 100644 --- a/public/locales/zh/common.json +++ b/public/locales/zh/common.json @@ -30,6 +30,7 @@ "columns": "列", "select_all": "全选", "clear": "清除", + "copy": "复制", "docs": "文档", "team": "团队", "host": "主机", diff --git a/src/components/DataModelGeneration/index.module.less b/src/components/DataModelGeneration/index.module.less index 8b152b0d..95a5d881 100644 --- a/src/components/DataModelGeneration/index.module.less +++ b/src/components/DataModelGeneration/index.module.less @@ -5,10 +5,22 @@ .dataSource { display: flex; align-items: center; + justify-content: space-between; + gap: 12px; margin-bottom: var(--gap-base); } +.dataSourceInfo { + display: flex; + align-items: center; + min-width: 0; +} + +.smartGenerate { + flex-shrink: 0; +} + .iconWrapper { display: flex; align-items: center; diff --git a/src/components/DataModelGeneration/index.tsx b/src/components/DataModelGeneration/index.tsx index 07795418..47769c46 100644 --- a/src/components/DataModelGeneration/index.tsx +++ b/src/components/DataModelGeneration/index.tsx @@ -1,4 +1,5 @@ import { Col, Collapse, Empty, Form, Row, Spin, Typography } from "antd"; +import { ThunderboltOutlined } from "@ant-design/icons"; import { useTranslation } from "react-i18next"; import cn from "classnames"; import { useResponsive } from "ahooks"; @@ -36,8 +37,7 @@ interface DataModelGenerationProps { initialValue?: DynamicForm; loading?: boolean; resetOnSubmit?: boolean; - smartGenTables?: string[]; - onSmartGenerate?: (schemaName: string, tableName: string) => void; + onSmartGenerate?: () => void; } const options = [ @@ -56,7 +56,6 @@ const DataModelGeneration: FC = ({ initialValue = {}, loading = false, resetOnSubmit = false, - smartGenTables = [], onSmartGenerate, }) => { const { t } = useTranslation(["dataModelGeneration", "common"]); @@ -113,12 +112,24 @@ const DataModelGeneration: FC = ({
- {"icon" in dataSource && ( -
{dataSource.icon}
+
+ {"icon" in dataSource && ( +
{dataSource.icon}
+ )} + + {dataSource.name} + +
+ {onSmartGenerate && ( + )} - - {dataSource.name} -
{t("text")} {t("title")} @@ -154,8 +165,6 @@ const DataModelGeneration: FC = ({ schema={filteredSchema} path={s} initialValue={initialValue} - smartGenTables={smartGenTables} - onSmartGenerate={onSmartGenerate} /> ); diff --git a/src/components/DataSourceFormBody/index.tsx b/src/components/DataSourceFormBody/index.tsx index ee02fb95..4d095913 100644 --- a/src/components/DataSourceFormBody/index.tsx +++ b/src/components/DataSourceFormBody/index.tsx @@ -69,24 +69,15 @@ const DataSourceFormBody: FC = ({ const { teamData } = CurrentUserStore(); const [, setLocation] = useLocation(); - const SMART_GEN_TABLES = ["semantic_events", "data_points", "entities"]; - - const onSmartGenerate = useCallback( - (schemaName: string, tableName: string) => { - if (!editId || !teamData?.dataSources) return; - const ds = teamData.dataSources.find((d) => d.id === editId); - const activeBranch = ds?.branches?.find( - (b) => b.status === Branch_Statuses_Enum.Active - ); - if (!activeBranch) return; - const schemaParam = encodeURIComponent(schemaName); - const tableParam = encodeURIComponent(tableName); - setLocation( - `${MODELS}/${editId}/${activeBranch.id}/smartgen?schema=${schemaParam}&table=${tableParam}` - ); - }, - [editId, teamData, setLocation] - ); + const onSmartGenerate = useCallback(() => { + if (!editId || !teamData?.dataSources) return; + const ds = teamData.dataSources.find((d) => d.id === editId); + const activeBranch = ds?.branches?.find( + (b) => b.status === Branch_Statuses_Enum.Active + ); + if (!activeBranch) return; + setLocation(`${MODELS}/${editId}/${activeBranch.id}/smartgen`); + }, [editId, teamData, setLocation]); const onGoBack = () => onChangeStep?.(step - 1) || setStep(step - 1); const onGoForward = () => onChangeStep?.(step + 1) || setStep(step + 1); @@ -157,7 +148,6 @@ const DataSourceFormBody: FC = ({ onSkip={onSkip || onGoForward} loading={loading} initialValue={formState?.step2 || formData?.step2 || {}} - smartGenTables={SMART_GEN_TABLES} onSmartGenerate={onSmartGenerate} /> ); diff --git a/src/components/ExploreCubes/index.tsx b/src/components/ExploreCubes/index.tsx index 03721c25..5b5513bd 100644 --- a/src/components/ExploreCubes/index.tsx +++ b/src/components/ExploreCubes/index.tsx @@ -21,7 +21,9 @@ interface ExploreCubesProps { toQuery?: (member: CubeMember) => any ) => { add: (member: CubeMember) => void; + addMany: (members: CubeMember[]) => void; remove: (member: CubeMember) => void; + removeMany: (members: CubeMember[]) => void; update: (member: CubeMember, newValue: any) => void; }; availableQueryMembers: Record< diff --git a/src/components/ExploreCubesSection/index.module.less b/src/components/ExploreCubesSection/index.module.less index 652c9351..7c82b0dd 100644 --- a/src/components/ExploreCubesSection/index.module.less +++ b/src/components/ExploreCubesSection/index.module.less @@ -1,12 +1,37 @@ .root { background: #f9f9f9; + white-space: normal; + overflow: visible; } -.categoryTitle { - display: block; +.categorySection { + white-space: normal; +} + +.categoryHeader { + display: flex; + align-items: center; + justify-content: space-between; + gap: 8px; margin-bottom: 8px; + white-space: normal; +} + +.categoryTitle { color: rgba(0, 0, 0, 0.5); font-weight: 700; font-size: 12px; text-transform: uppercase; } + +.selectAll { + flex-shrink: 0; + font-size: 12px; + font-weight: 500; + white-space: nowrap; + color: rgba(0, 0, 0, 0.65); + + :global(.ant-checkbox + span) { + padding-inline-end: 0; + } +} diff --git a/src/components/ExploreCubesSection/index.tsx b/src/components/ExploreCubesSection/index.tsx index 044d593c..6b72bc62 100644 --- a/src/components/ExploreCubesSection/index.tsx +++ b/src/components/ExploreCubesSection/index.tsx @@ -1,7 +1,7 @@ import { useState } from "react"; import { set, getOr, get } from "unchanged"; import { useTranslation } from "react-i18next"; -import { Typography } from "antd"; +import { Checkbox, Typography } from "antd"; import { SHOWN_CATEGORIES } from "@/components/ExploreCubes"; import ExploreCubesSubSection from "@/components/ExploreCubesSubSection"; @@ -18,6 +18,7 @@ import type { import s from "./index.module.less"; import type { ReactNode } from "react"; +import type { CheckboxChangeEvent } from "antd/es/checkbox"; const { Text } = Typography; @@ -107,6 +108,10 @@ const getSubSections = ( }; }; +// Raw (no granularity) members only — avoid selecting every time granularity +const getSelectableMembers = (catMembers: CubeMember[]): CubeMember[] => + catMembers.filter((member) => !member.granularity); + const Cube = ({ members, selectedMembers, @@ -199,13 +204,10 @@ const Cube = ({ ); }; - const getCategory = (category: string): ReactNode => { - let catMembers = getMembersCategory(category); - if (!catMembers.length) { - return null; - } + const expandCategoryMembers = (category: string): CubeMember[] => { + const catMembers = getMembersCategory(category); - catMembers = catMembers.reduce((acc: CubeMember[], member: CubeMember) => { + return catMembers.reduce((acc: CubeMember[], member: CubeMember) => { let newMembers = acc; if (member.type === "time") { @@ -216,6 +218,52 @@ const Cube = ({ } return newMembers; }, []); + }; + + const onSelectAllCategory = ( + category: string, + catMembers: CubeMember[], + e: CheckboxChangeEvent + ) => { + e.stopPropagation(); + + const selectableMembers = getSelectableMembers(catMembers); + const categoryCubeMembers = getSelectedCategoryMembers(category); + const selected = selectableMembers.filter((member) => + categoryCubeMembers.includes(member.name) + ); + const unselected = selectableMembers.filter( + (member) => !categoryCubeMembers.includes(member.name) + ); + + if (e.target.checked) { + if (unselected.length) { + onMemberSelect(category).addMany(unselected); + } + return; + } + + // Also clear any selected time granularities for this cube/category + const selectedInCategory = catMembers.filter((member) => + categoryCubeMembers.includes(member.name) + ); + const toRemove = selectedInCategory.length ? selectedInCategory : selected; + + if (toRemove.length) { + onMemberSelect(category).removeMany( + toRemove.map((member) => ({ + ...member, + index: membersIndex[member.name]?.index, + })) + ); + } + }; + + const getCategory = (category: string): ReactNode => { + const catMembers = expandCategoryMembers(category); + if (!catMembers.length) { + return null; + } const { subSections, freeMembers } = getSubSections( catMembers, @@ -223,13 +271,31 @@ const Cube = ({ ); const categoryCubeMembers = getSelectedCategoryMembers(category); + const selectableMembers = getSelectableMembers(catMembers); + const allSelected = + selectableMembers.length > 0 && + selectableMembers.every((member) => + categoryCubeMembers.includes(member.name) + ); const selectedFilters: string[] = Object.values( selectedMembers.filters || {} ).map((m) => m.dimension!.name); return (
- {t(`common:words.${category}`)} +
+ + {t(`common:words.${category}`)} + + onSelectAllCategory(category, catMembers, e)} + onClick={(e) => e.stopPropagation()} + > + {t("common:words.select_all")} + +
{freeMembers.map((member, index) => getItem( @@ -272,16 +338,20 @@ const Cube = ({ ); }; +interface MemberSelectApi { + add: (member: CubeMember) => void; + addMany: (members: CubeMember[]) => void; + remove: (member: CubeMember) => void; + removeMany: (members: CubeMember[]) => void; + update: (member: CubeMember, newValue: any) => void; +} + interface CubeProps { members: CubeMembers; onMemberSelect: ( memberType?: string, toQuery?: (member: CubeMember) => any - ) => { - add: (member: CubeMember) => void; - remove: (member: CubeMember) => void; - update: (member: CubeMember, newValue: any) => void; - }; + ) => MemberSelectApi; selectedMembers: Record; } diff --git a/src/components/ExploreWorkspace/index.tsx b/src/components/ExploreWorkspace/index.tsx index 22277782..78cec235 100644 --- a/src/components/ExploreWorkspace/index.tsx +++ b/src/components/ExploreWorkspace/index.tsx @@ -6,6 +6,7 @@ import ExploreDataSection from "@/components/ExploreDataSection"; import ErrorFound from "@/components/ErrorFound"; import ExploreCubes from "@/components/ExploreCubes"; import usePlayground, { queryStateKeys } from "@/hooks/usePlayground"; +import { initialState } from "@/hooks/useAnalyticsQuery"; import useExploreWorkspace from "@/hooks/useExploreWorkspace"; import useDimensions from "@/hooks/useDimensions"; import useLocation from "@/hooks/useLocation"; @@ -83,6 +84,7 @@ const ExploreWorkspace: FC = (props) => { setOffset, setPage, setOrderBy, + doReset, }, settings, dispatchSettings, @@ -187,6 +189,8 @@ const ExploreWorkspace: FC = (props) => { currentBranch={currentBranch} onExec={onRunQuery} onQueryChange={onQueryChange} + onApplyQuery={doReset} + onResetQuery={() => doReset(initialState)} onOpenModal={onOpenModal} disabled={!isQueryChanged} state={state} diff --git a/src/components/PlaygroundFilterSelect/index.module.less b/src/components/PlaygroundFilterSelect/index.module.less new file mode 100644 index 00000000..32a84a4e --- /dev/null +++ b/src/components/PlaygroundFilterSelect/index.module.less @@ -0,0 +1,21 @@ +.select { + width: 280px; + max-width: 100%; + + :global(.ant-select-selection-item) { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + } +} + +.optionLabel { + overflow: hidden; + text-overflow: ellipsis; +} + +.optionCube { + margin-left: 8px; + color: rgba(0, 0, 0, 0.45); + font-size: 12px; +} diff --git a/src/components/PlaygroundFilterSelect/index.tsx b/src/components/PlaygroundFilterSelect/index.tsx index 42692124..6080d48c 100644 --- a/src/components/PlaygroundFilterSelect/index.tsx +++ b/src/components/PlaygroundFilterSelect/index.tsx @@ -1,4 +1,4 @@ -import { Select } from "antd"; +import { Select, Tooltip } from "antd"; import { useDebounceFn } from "ahooks"; import { useTranslation } from "react-i18next"; @@ -6,6 +6,8 @@ import type { CubeMember } from "@/types/cube"; import ArrowBottom from "@/assets/arrow-big.svg"; +import s from "./index.module.less"; + import type { FC } from "react"; interface PlaygroundFilterSelectProps { @@ -14,6 +16,14 @@ interface PlaygroundFilterSelectProps { value?: string; } +const getMemberLabel = (member: CubeMember) => + member.shortTitle || member.title || member.name; + +const getCubeName = (member: CubeMember) => { + const parts = (member.name || "").split("."); + return parts.length > 1 ? parts[0] : undefined; +}; + const PlaygroundFilterSelect: FC = ({ availableMembers, value, @@ -23,39 +33,68 @@ const PlaygroundFilterSelect: FC = ({ const [data, setData] = useState(availableMembers); + useEffect(() => { + setData(availableMembers); + }, [availableMembers]); + const { run: handleSearch } = useDebounceFn( (val: string) => { - let res = availableMembers; - if (val) { - res = availableMembers.filter( - (m) => m.title && m.title.toLowerCase().includes(val) - ); + const query = val.trim().toLowerCase(); + if (!query) { + setData(availableMembers); + return; } - setData(res); + setData( + availableMembers.filter((m) => + [m.shortTitle, m.title, m.name] + .filter(Boolean) + .some((field) => field!.toLowerCase().includes(query)) + ) + ); }, { wait: 200 } ); + const selectedMember = + availableMembers.find((m) => m.name === value) || + data.find((m) => m.name === value); + return ( - + + + ); }; diff --git a/src/components/PrismCode/index.tsx b/src/components/PrismCode/index.tsx index 30a82955..89f6bcaa 100644 --- a/src/components/PrismCode/index.tsx +++ b/src/components/PrismCode/index.tsx @@ -66,11 +66,7 @@ const PrismCode: FC = ({ code, lang = "sql", style }) => { return (
-        
+        
           
void; } interface RestApiState { @@ -257,6 +259,7 @@ const RestAPI: FC = ({ dataSourceId, branchId, playgroundState, + onApplyQuery, }) => { const { t } = useTranslation(["explore", "common"], { useSuspense: false }); const { accessToken, workosAccessToken } = AuthTokensStore(); @@ -378,6 +381,22 @@ const RestAPI: FC = ({ setState(defaultState); }; + const onApplyToExplore = () => { + if (!onApplyQuery) return; + + try { + const nextState = parseRestBodyToPlaygroundState(getValues("json") || ""); + onApplyQuery(nextState); + message.success("Query applied to Explore. Click Run Query to execute."); + } catch (e: any) { + message.error( + e?.message + ? `Could not apply query: ${e.message}` + : "Could not apply query" + ); + } + }; + useEffect(() => { setValue("token", `Bearer ${activeToken}`); }, [authMethod, activeToken, setValue]); @@ -529,9 +548,22 @@ const RestAPI: FC = ({ +
+ + {t("common:form.labels.body")} + + {onApplyQuery && ( + + )} +
= ({ - dataSource, - schema, - branchId, - branches, + dataSource: initialDataSource, + dataSources, + schema: initialSchemaData, + branchId: initialBranchId, + branches: initialBranches, onComplete, onCancel, initialSchema, @@ -870,6 +874,61 @@ const SmartGeneration: FC = ({ previousFilters, }) => { const { t } = useTranslation(["models", "common"]); + + const availableSources = useMemo(() => { + const list = + dataSources && dataSources.length > 0 ? dataSources : [initialDataSource]; + return list.filter(Boolean) as DataSourceInfo[]; + }, [dataSources, initialDataSource]); + + const [selectedDataSourceId, setSelectedDataSourceId] = useState( + () => availableSources[0]?.id || initialDataSource.id || "" + ); + + useEffect(() => { + if (!availableSources.length) return; + if (!availableSources.some((ds) => ds.id === selectedDataSourceId)) { + setSelectedDataSourceId(availableSources[0].id || ""); + } + }, [availableSources, selectedDataSourceId]); + + const dataSource = + availableSources.find((ds) => ds.id === selectedDataSourceId) || + availableSources[0] || + initialDataSource; + + const branches = dataSource.branches?.length + ? dataSource.branches + : selectedDataSourceId === initialDataSource.id + ? initialBranches + : dataSource.branches || []; + + const branchId = + branches.find((b) => b.status === Branch_Statuses_Enum.Active)?.id || + branches[0]?.id || + (selectedDataSourceId === initialDataSource.id ? initialBranchId : "") || + ""; + + const [tablesResult, execFetchTables] = useFetchTablesQuery({ + variables: { id: selectedDataSourceId }, + pause: !selectedDataSourceId, + requestPolicy: "cache-and-network", + }); + + useEffect(() => { + if (selectedDataSourceId) { + execFetchTables(); + } + }, [selectedDataSourceId, execFetchTables]); + + const schema = + tablesResult.data?.fetch_tables?.schema || + (selectedDataSourceId === initialDataSource.id + ? initialSchemaData + : undefined); + + const isSchemaLoading = tablesResult.fetching && !schema; + const [step, setStep] = useState("select"); const [targetBranchId, setTargetBranchId] = useState(branchId); const [lockedBranchId, setLockedBranchId] = useState(null); @@ -914,6 +973,16 @@ const SmartGeneration: FC = ({ const [smartGenResult, execSmartGen] = useSmartGenDataSchemasMutation(); + const handleDataSourceSelect = useCallback((id: string) => { + setSelectedDataSourceId(id); + setSelectedSchema(""); + setSelectedTable(""); + setFilters([]); + setError(null); + setLockedBranchId(null); + setSaveToBranchId(""); + }, []); + useEffect(() => { setTargetBranchId(branchId); }, [branchId]); @@ -1266,7 +1335,17 @@ const SmartGeneration: FC = ({ const saveBranch = saveToBranchId || lockedBranchId || targetBranchId; - const isTableOptionsLoading = !schema || tableOptions.length === 0; + const isTableOptionsLoading = + isSchemaLoading || !schema || tableOptions.length === 0; + + const dataSourceOptions = useMemo( + () => + availableSources.map((ds) => ({ + value: ds.id!, + label: ds.name, + })), + [availableSources] + ); const selectedTableValue = useMemo(() => { if (!selectedSchema || !selectedTable) return undefined; @@ -1488,9 +1567,28 @@ const SmartGeneration: FC = ({ /> )} - {/* Step 1: Table Selection */} + {/* Step 1: Data Source + Table Selection */} {step === "select" && ( <> +
+ Select a data source + = ({ placeholder="Search for a table..." style={{ width: "100%" }} size="large" - disabled={isTableOptionsLoading} + disabled={!selectedDataSourceId || isTableOptionsLoading} loading={isTableOptionsLoading} value={selectedTableValue} onChange={handleTableSelect} diff --git a/src/components/TableSelection/index.tsx b/src/components/TableSelection/index.tsx index 579b7387..b4b00f75 100644 --- a/src/components/TableSelection/index.tsx +++ b/src/components/TableSelection/index.tsx @@ -1,8 +1,7 @@ import { useResponsive } from "ahooks"; import { useController } from "react-hook-form"; import { useTranslation } from "react-i18next"; -import { Checkbox, Tooltip } from "antd"; -import { ThunderboltOutlined } from "@ant-design/icons"; +import { Checkbox } from "antd"; import cn from "classnames"; import Button from "@/components/Button"; @@ -20,8 +19,6 @@ interface TableSelectionProps { type: string; control: Control; initialValue?: DynamicForm; - smartGenTables?: string[]; - onSmartGenerate?: (schemaName: string, tableName: string) => void; } const TableSelection: FC = ({ @@ -30,8 +27,6 @@ const TableSelection: FC = ({ control, initialValue, type, - smartGenTables = [], - onSmartGenerate, }) => { const windowSize = useResponsive(); const { t } = useTranslation(["common"]); @@ -98,21 +93,6 @@ const TableSelection: FC = ({ > ({schema[path][tb].length}) {t("common:words.columns")} - {smartGenTables.includes(tb) && onSmartGenerate && ( - - - - )}
))} diff --git a/src/hooks/useAnalyticsQuery.ts b/src/hooks/useAnalyticsQuery.ts index bdd51d76..6d8613db 100644 --- a/src/hooks/useAnalyticsQuery.ts +++ b/src/hooks/useAnalyticsQuery.ts @@ -17,74 +17,120 @@ const defaultFilterValues = { number: { operator: "set", }, - boolean: { - operator: "equals", - values: ["true"], - }, }; interface Action { type: | "add" - | "update" + | "addMany" | "update" | "setLimit" | "setOffset" | "setPage" | "setOrder" | "remove" + | "removeMany" | "reset"; [key: string]: any; } -const reducer: Reducer = ( + +const applyAdd = ( state: PlaygroundState, - action: Action -) => { + action: { memberType?: string; value: any; operatorType?: string } +): PlaygroundState => { let { memberType } = action; + let { value } = action; - if (action.type === "add") { - let { value } = action; - - if (memberType !== "filters") { - const [memberName, granularity = null] = action?.value?.split( - /\+/ - ) as string[]; - - if (granularity) { - memberType = "timeDimensions"; - value = { - dimension: memberName, - granularity, - }; - } - - const slice = state[ - memberType as keyof PlaygroundState - ] as unknown as CubeMember[]; - - const isMemberExists = !!slice.find( - (member) => - member.dimension?.name === memberName && - member.granularity === granularity - ); + if (memberType !== "filters") { + const [memberName, granularity = null] = action?.value?.split( + /\+/ + ) as string[]; - if (isMemberExists) { - return state; - } - } else { - const filterDefaults = - defaultFilterValues[ - action.operatorType as keyof typeof defaultFilterValues - ] ?? defaultFilterValues.string; + if (granularity) { + memberType = "timeDimensions"; value = { - ...action.value, - ...filterDefaults, + dimension: memberName, + granularity, }; } - const elementsCount = getOr([], memberType, state).length; + const slice = state[ + memberType as keyof PlaygroundState + ] as unknown as CubeMember[]; + + const isMemberExists = !!slice.find( + (member) => + member.dimension?.name === memberName && + member.granularity === granularity + ); + + if (isMemberExists) { + return state; + } + } else { + value = { + ...action.value, + ...defaultFilterValues[ + action.operatorType as keyof typeof defaultFilterValues + ], + }; + } + + const elementsCount = getOr([], memberType, state).length; + + return set([memberType, elementsCount], value, state); +}; + +const applyRemove = ( + state: PlaygroundState, + action: { memberType?: string; value: any; index?: number } +): PlaygroundState => { + let { memberType } = action; + let { index } = action; + const { value } = action; + + if (memberType !== "filters") { + const [memberName, granularity = null] = value?.split(/\+/); + + if (granularity) { + memberType = "timeDimensions"; + const slice = state[memberType as keyof PlaygroundState] as CubeMember[]; + + index = slice.findIndex( + (member: CubeMember) => + member.dimension === memberName && member.granularity === granularity + ); + } else if (index === undefined || index === null) { + const slice = getOr([], memberType, state) as unknown[]; + index = slice.findIndex((member) => member === value); + } + } + + if (index === undefined || index === null || index < 0) { + return state; + } + + return remove([memberType, index], state); +}; + +const reducer: Reducer = ( + state: PlaygroundState, + action: Action +) => { + if (action.type === "add") { + return applyAdd(state, action); + } - return set([memberType, elementsCount], value, state); + if (action.type === "addMany") { + return (action.values || []).reduce( + (acc: PlaygroundState, value: any) => + applyAdd(acc, { + memberType: action.memberType, + value, + operatorType: action.operatorType, + }), + state + ); } if (action.type === "update") { @@ -116,27 +162,20 @@ const reducer: Reducer = ( } if (action.type === "remove") { - let { index } = action; - const { value } = action; - - if (memberType !== "filters") { - const [memberName, granularity = null] = value?.split(/\+/); - - if (granularity) { - memberType = "timeDimensions"; - const slice = state[ - memberType as keyof PlaygroundState - ] as CubeMember[]; - - index = slice.findIndex( - (member: CubeMember) => - member.dimension === memberName && - member.granularity === granularity - ); - } - } + return applyRemove(state, action); + } - return remove([memberType, index], state); + if (action.type === "removeMany") { + return (action.values || []).reduce( + (acc: PlaygroundState, item: { value: any; index?: number }) => + applyRemove(acc, { + memberType: action.memberType, + value: item.value, + // Resolve by name on each step so earlier removals do not shift indices + index: undefined, + }), + state + ); } if (action.type === "reset") { @@ -158,7 +197,7 @@ export const queryState: PlaygroundState = { ...queryBaseMembers, order: [], timezone: "UTC", - limit: 100, + limit: 1000, offset: 0, }; @@ -183,6 +222,12 @@ const useAnalyticsQuery = () => { value: toQuery(member), operatorType: getOperatorType(member), }), + addMany: (members: CubeMember[]) => + dispatch({ + type: "addMany", + memberType, + values: members.map(toQuery), + }), remove: (member: CubeMember) => dispatch({ type: "remove", @@ -190,6 +235,15 @@ const useAnalyticsQuery = () => { value: toQuery(member), index: member.index, }), + removeMany: (members: CubeMember[]) => + dispatch({ + type: "removeMany", + memberType, + values: members.map((member) => ({ + value: toQuery(member), + index: member.index, + })), + }), update: (member: CubeMember, newValue: any) => dispatch({ type: "update", diff --git a/src/hooks/usePlayground.ts b/src/hooks/usePlayground.ts index 4c4f7898..bd1d8ade 100644 --- a/src/hooks/usePlayground.ts +++ b/src/hooks/usePlayground.ts @@ -276,6 +276,7 @@ export default ({ meta = [], explorationData, rawSql }: Props) => { setOffset, setPage, setOrderBy, + doReset, }, settings, dispatchSettings, diff --git a/src/pages/DataSources/index.tsx b/src/pages/DataSources/index.tsx index 703f7fc1..034d12f2 100644 --- a/src/pages/DataSources/index.tsx +++ b/src/pages/DataSources/index.tsx @@ -8,7 +8,7 @@ import { Spin, message, } from "antd"; -import { CopyOutlined, SettingOutlined } from "@ant-design/icons"; +import { SettingOutlined } from "@ant-design/icons"; import { useResponsive } from "ahooks"; import { useEffect, useState } from "react"; import { useParams } from "@vitjs/runtime"; @@ -138,20 +138,19 @@ export const DataSources = ({ label: t("common:words.edit"), onClick: () => dataSource.id && onEdit(dataSource.id), }, - { - key: "generate", - label: t("common:words.generate_models"), - onClick: () => - dataSource.id && onGenerateModel(dataSource.id), - }, isPortalAdmin && otherTeams.length > 0 && { key: "copy", - icon: , label: t("common:words.copy_to_team", "Copy to team"), onClick: () => dataSource.id && setCopyModalDs(dataSource.id), }, + { + key: "generate", + label: t("common:words.generate_models"), + onClick: () => + dataSource.id && onGenerateModel(dataSource.id), + }, { key: "delete", className: styles.deleteItem, diff --git a/src/utils/helpers/parseRestBodyToPlaygroundState.test.ts b/src/utils/helpers/parseRestBodyToPlaygroundState.test.ts new file mode 100644 index 00000000..15e11147 --- /dev/null +++ b/src/utils/helpers/parseRestBodyToPlaygroundState.test.ts @@ -0,0 +1,56 @@ +import { describe, test, expect } from "vitest"; + +import { parseRestBodyToPlaygroundState } from "./parseRestBodyToPlaygroundState"; + +describe("parseRestBodyToPlaygroundState", () => { + test("parses wrapped query body", () => { + const state = parseRestBodyToPlaygroundState( + JSON.stringify({ + query: { + measures: ["Celestial.count"], + dimensions: ["Celestial.geohash"], + filters: [ + { + dimension: "Celestial.geohash", + operator: "set", + values: [], + }, + ], + timeDimensions: [{ dimension: "Celestial.date", granularity: "day" }], + order: { "Celestial.count": "desc" }, + limit: 100, + offset: 10, + timezone: "UTC", + }, + format: "csv", + }) + ); + + expect(state.measures).toEqual(["Celestial.count"]); + expect(state.dimensions).toEqual(["Celestial.geohash"]); + expect(state.filters).toHaveLength(1); + expect(state.timeDimensions).toEqual([ + { dimension: "Celestial.date", granularity: "day" }, + ]); + expect(state.order).toEqual([{ id: "Celestial.count", desc: true }]); + expect(state.limit).toBe(100); + expect(state.offset).toBe(10); + }); + + test("parses bare query object", () => { + const state = parseRestBodyToPlaygroundState( + JSON.stringify({ + measures: ["Orders.count"], + dimensions: ["Orders.status"], + }) + ); + + expect(state.measures).toEqual(["Orders.count"]); + expect(state.dimensions).toEqual(["Orders.status"]); + expect(state.order).toEqual([]); + }); + + test("throws on invalid json", () => { + expect(() => parseRestBodyToPlaygroundState("{")).toThrow("Invalid JSON"); + }); +}); diff --git a/src/utils/helpers/parseRestBodyToPlaygroundState.ts b/src/utils/helpers/parseRestBodyToPlaygroundState.ts new file mode 100644 index 00000000..26dff054 --- /dev/null +++ b/src/utils/helpers/parseRestBodyToPlaygroundState.ts @@ -0,0 +1,111 @@ +import { initialState } from "@/hooks/useAnalyticsQuery"; +import type { PlaygroundState } from "@/types/exploration"; +import type { SortBy } from "@/types/sort"; + +const QUERY_KEYS = [ + "measures", + "dimensions", + "filters", + "timeDimensions", + "segments", + "order", + "timezone", + "limit", + "offset", + "page", +] as const; + +const isPlainObject = (value: unknown): value is Record => + !!value && typeof value === "object" && !Array.isArray(value); + +const normalizeOrder = (order: unknown): SortBy[] => { + if (Array.isArray(order)) { + return order + .map((item) => { + if (!item || typeof item !== "object") return null; + const row = item as Record; + if (typeof row.id === "string") { + return { + id: row.id, + desc: Boolean(row.desc), + }; + } + return null; + }) + .filter(Boolean) as SortBy[]; + } + + if (isPlainObject(order)) { + return Object.entries(order).map(([id, direction]) => ({ + id, + desc: direction === "desc", + })); + } + + return []; +}; + +const extractQuery = (parsed: unknown): Record => { + if (!isPlainObject(parsed)) { + throw new Error("Body must be a JSON object"); + } + + if (isPlainObject(parsed.query)) { + return parsed.query; + } + + const hasQueryField = QUERY_KEYS.some((key) => key in parsed); + if (hasQueryField) { + return parsed; + } + + throw new Error( + 'Body must include a Cube.js query object (or { "query": { ... } })' + ); +}; + +/** + * Parse a Cube.js REST /v1/load body (or a bare query) into Explore playground state. + */ +export const parseRestBodyToPlaygroundState = ( + body: string +): PlaygroundState => { + let parsed: unknown; + + try { + parsed = JSON.parse(body); + } catch { + throw new Error("Invalid JSON"); + } + + const query = extractQuery(parsed); + const next: PlaygroundState = { + ...initialState, + measures: Array.isArray(query.measures) ? query.measures : [], + dimensions: Array.isArray(query.dimensions) ? query.dimensions : [], + filters: Array.isArray(query.filters) ? (query.filters as any) : [], + timeDimensions: Array.isArray(query.timeDimensions) + ? (query.timeDimensions as PlaygroundState["timeDimensions"]) + : [], + segments: Array.isArray(query.segments) ? (query.segments as any) : [], + order: normalizeOrder(query.order), + timezone: + typeof query.timezone === "string" + ? query.timezone + : initialState.timezone, + limit: + typeof query.limit === "number" && Number.isFinite(query.limit) + ? query.limit + : initialState.limit, + offset: + typeof query.offset === "number" && Number.isFinite(query.offset) + ? query.offset + : initialState.offset, + }; + + if (typeof query.page === "number" && Number.isFinite(query.page)) { + next.page = query.page; + } + + return next; +};