diff --git a/frontend/src/metabase-lib/metadata/Database.ts b/frontend/src/metabase-lib/metadata/Database.ts index 1b9ce2113d67c6432ca61c16aa332fc1687c4863..d9f8a47c2b4156bf8c5c581d4434f121da835282 100644 --- a/frontend/src/metabase-lib/metadata/Database.ts +++ b/frontend/src/metabase-lib/metadata/Database.ts @@ -1,5 +1,6 @@ // eslint-disable-next-line @typescript-eslint/ban-ts-comment // @ts-nocheck +import { NativePermissions } from "metabase-types/api"; import { generateSchemaId } from "metabase-lib/metadata/utils/schema"; import { createLookupByProperty, memoizeClass } from "metabase-lib/utils"; import Question from "../Question"; @@ -25,6 +26,7 @@ class DatabaseInner extends Base { tables: Table[]; schemas: Schema[]; metadata: Metadata; + native_permissions: NativePermissions; // Only appears in GET /api/database/:id "can-manage"?: boolean; diff --git a/frontend/src/metabase/query_builder/components/QueryModals.jsx b/frontend/src/metabase/query_builder/components/QueryModals.jsx index 658eaeb15923d3fd9664df31e564628d340156bd..288c3d3f1c0060cbceac271e1adb0109bd98449c 100644 --- a/frontend/src/metabase/query_builder/components/QueryModals.jsx +++ b/frontend/src/metabase/query_builder/components/QueryModals.jsx @@ -30,6 +30,7 @@ import NewEventModal from "metabase/timelines/questions/containers/NewEventModal import EditEventModal from "metabase/timelines/questions/containers/EditEventModal"; import MoveEventModal from "metabase/timelines/questions/containers/MoveEventModal"; import PreviewQueryModal from "metabase/query_builder/components/view/PreviewQueryModal"; +import ConvertQueryModal from "metabase/query_builder/components/view/ConvertQueryModal"; import QuestionMoveToast from "./QuestionMoveToast"; const mapDispatchToProps = { @@ -68,6 +69,7 @@ class QueryModals extends React.Component { initialCollectionId, onCloseModal, onOpenModal, + updateQuestion, setQueryBuilderMode, } = this.props; @@ -283,6 +285,14 @@ class QueryModals extends React.Component { <Modal fit onClose={onCloseModal}> <PreviewQueryModal question={question} onClose={onCloseModal} /> </Modal> + ) : modal === MODAL_TYPES.CONVERT_QUERY ? ( + <Modal fit onClose={onCloseModal}> + <ConvertQueryModal + question={question} + onUpdateQuestion={updateQuestion} + onClose={onCloseModal} + /> + </Modal> ) : null; } } diff --git a/frontend/src/metabase/query_builder/components/view/ConvertQueryButton/ConvertQueryButton.styled.tsx b/frontend/src/metabase/query_builder/components/view/ConvertQueryButton/ConvertQueryButton.styled.tsx new file mode 100644 index 0000000000000000000000000000000000000000..ca84b51e840b21c65010baccf3c05fb8f4f79713 --- /dev/null +++ b/frontend/src/metabase/query_builder/components/view/ConvertQueryButton/ConvertQueryButton.styled.tsx @@ -0,0 +1,19 @@ +import styled from "@emotion/styled"; +import { color } from "metabase/lib/colors"; +import Icon from "metabase/components/Icon"; +import IconButtonWrapper from "metabase/components/IconButtonWrapper"; + +export const SqlButton = styled(IconButtonWrapper)` + color: ${color("text-dark")}; + padding: 0.5rem; + + &:hover { + color: ${color("brand")}; + background-color: ${color("bg-medium")}; + } +`; + +export const SqlIcon = styled(Icon)` + width: 1rem; + height: 1rem; +`; diff --git a/frontend/src/metabase/query_builder/components/view/ConvertQueryButton/ConvertQueryButton.tsx b/frontend/src/metabase/query_builder/components/view/ConvertQueryButton/ConvertQueryButton.tsx new file mode 100644 index 0000000000000000000000000000000000000000..8ff0174e1dd997c20c6949e33ec685168d95bc4c --- /dev/null +++ b/frontend/src/metabase/query_builder/components/view/ConvertQueryButton/ConvertQueryButton.tsx @@ -0,0 +1,57 @@ +import React, { useCallback } from "react"; +import { t } from "ttag"; +import { getEngineNativeType } from "metabase/lib/engine"; +import Tooltip from "metabase/components/Tooltip"; +import { MODAL_TYPES } from "metabase/query_builder/constants"; +import Question from "metabase-lib/Question"; +import { SqlButton, SqlIcon } from "./ConvertQueryButton.styled"; + +const BUTTON_TOOLTIP = { + sql: t`View the SQL`, + json: t`View the native query`, +}; + +interface ConvertQueryButtonProps { + question: Question; + onOpenModal?: (modalType: string) => void; +} + +const ConvertQueryButton = ({ + question, + onOpenModal, +}: ConvertQueryButtonProps): JSX.Element => { + const engineType = getEngineNativeType(question.database()?.engine); + + const handleClick = useCallback(() => { + onOpenModal?.(MODAL_TYPES.CONVERT_QUERY); + }, [onOpenModal]); + + return ( + <Tooltip tooltip={BUTTON_TOOLTIP[engineType]} placement="bottom"> + <SqlButton + onClick={handleClick} + data-metabase-event="Notebook Mode; Convert to SQL Click" + > + <SqlIcon name="sql" /> + </SqlButton> + </Tooltip> + ); +}; + +interface ConvertQueryButtonOpts { + question: Question; + queryBuilderMode: string; +} + +ConvertQueryButton.shouldRender = ({ + question, + queryBuilderMode, +}: ConvertQueryButtonOpts) => { + return ( + question.isStructured() && + question.database()?.native_permissions === "write" && + queryBuilderMode === "notebook" + ); +}; + +export default ConvertQueryButton; diff --git a/frontend/src/metabase/query_builder/components/view/ConvertQueryButton/index.ts b/frontend/src/metabase/query_builder/components/view/ConvertQueryButton/index.ts new file mode 100644 index 0000000000000000000000000000000000000000..f9722d7a4b043eb7bfa137bb80c3492f5b5fa531 --- /dev/null +++ b/frontend/src/metabase/query_builder/components/view/ConvertQueryButton/index.ts @@ -0,0 +1 @@ +export { default } from "./ConvertQueryButton"; diff --git a/frontend/src/metabase/query_builder/components/view/ConvertQueryModal/ConvertQueryModal.tsx b/frontend/src/metabase/query_builder/components/view/ConvertQueryModal/ConvertQueryModal.tsx new file mode 100644 index 0000000000000000000000000000000000000000..9d4cb5f666de4206fe70fdab3e19066d9cbe2cac --- /dev/null +++ b/frontend/src/metabase/query_builder/components/view/ConvertQueryModal/ConvertQueryModal.tsx @@ -0,0 +1,68 @@ +import React, { useCallback } from "react"; +import { t } from "ttag"; +import { getEngineNativeType } from "metabase/lib/engine"; +import Button from "metabase/core/components/Button"; +import Question from "metabase-lib/Question"; +import NativeQueryModal, { useNativeQuery } from "../NativeQueryModal"; + +const MODAL_TITLE = { + sql: t`SQL for this question`, + json: t`Native query for this question`, +}; + +const BUTTON_TITLE = { + sql: t`Convert this question to SQL`, + json: t`Convert this question to a native query`, +}; + +interface UpdateQuestionOpts { + shouldUpdateUrl?: boolean; +} + +interface ConvertQueryModalProps { + question: Question; + onUpdateQuestion?: (question: Question, opts?: UpdateQuestionOpts) => void; + onClose?: () => void; +} + +const ConvertQueryModal = ({ + question, + onUpdateQuestion, + onClose, +}: ConvertQueryModalProps): JSX.Element => { + const engineType = getEngineNativeType(question.database()?.engine); + const { query, error, isLoading } = useNativeQuery(question); + + const handleConvertClick = useCallback(() => { + if (!query) { + return; + } + + const newQuestion = question.setDatasetQuery({ + type: "native", + native: { query, "template-tags": {} }, + database: question.datasetQuery().database, + }); + + onUpdateQuestion?.(newQuestion, { shouldUpdateUrl: true }); + onClose?.(); + }, [question, query, onUpdateQuestion, onClose]); + + return ( + <NativeQueryModal + title={MODAL_TITLE[engineType]} + query={query} + error={error} + isLoading={isLoading} + onClose={onClose} + > + {query && ( + <Button primary onClick={handleConvertClick}> + {BUTTON_TITLE[engineType]} + </Button> + )} + </NativeQueryModal> + ); +}; + +export default ConvertQueryModal; diff --git a/frontend/src/metabase/query_builder/components/view/ConvertQueryModal/ConvertQueryModal.unit.spec.tsx b/frontend/src/metabase/query_builder/components/view/ConvertQueryModal/ConvertQueryModal.unit.spec.tsx new file mode 100644 index 0000000000000000000000000000000000000000..de0a1c4d6a18d915f873237391e569ff123f421a --- /dev/null +++ b/frontend/src/metabase/query_builder/components/view/ConvertQueryModal/ConvertQueryModal.unit.spec.tsx @@ -0,0 +1,61 @@ +import React from "react"; +import xhrMock from "xhr-mock"; +import { render, screen } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import Question from "metabase-lib/Question"; +import ConvertQueryModal from "./ConvertQueryModal"; + +const SQL_QUERY = "SELECT 1"; + +describe("ConvertQueryModal", () => { + beforeEach(() => { + xhrMock.setup(); + mockNativeQuery(); + }); + + afterEach(() => { + xhrMock.teardown(); + }); + + it("should show a native query for a structured query", async () => { + const question = Question.create({ databaseId: 1 }); + + render(<ConvertQueryModal question={question} />); + + expect(await screen.findByText(SQL_QUERY)).toBeInTheDocument(); + }); + + it("should allow to convert a structured query to a native query", async () => { + const question = Question.create({ databaseId: 1 }); + const onUpdateQuestion = jest.fn(); + + render( + <ConvertQueryModal + question={question} + onUpdateQuestion={onUpdateQuestion} + />, + ); + userEvent.click(await screen.findByText("Convert this question to SQL")); + + expect(onUpdateQuestion).toHaveBeenCalledWith( + question.setDatasetQuery({ + type: "native", + database: 1, + native: { + query: SQL_QUERY, + "template-tags": {}, + }, + }), + { shouldUpdateUrl: true }, + ); + }); +}); + +const mockNativeQuery = () => { + xhrMock.post("/api/dataset/native", { + status: 200, + body: JSON.stringify({ + query: SQL_QUERY, + }), + }); +}; diff --git a/frontend/src/metabase/query_builder/components/view/ConvertQueryModal/index.ts b/frontend/src/metabase/query_builder/components/view/ConvertQueryModal/index.ts new file mode 100644 index 0000000000000000000000000000000000000000..fd5c4e141f6a6268d4a75f6f09c15ef0f741c368 --- /dev/null +++ b/frontend/src/metabase/query_builder/components/view/ConvertQueryModal/index.ts @@ -0,0 +1 @@ +export { default } from "./ConvertQueryModal"; diff --git a/frontend/src/metabase/query_builder/components/view/NativeQueryButton.jsx b/frontend/src/metabase/query_builder/components/view/NativeQueryButton.jsx deleted file mode 100644 index a6c867d898b05d85d2d1a6a9b3487cab24efa7f7..0000000000000000000000000000000000000000 --- a/frontend/src/metabase/query_builder/components/view/NativeQueryButton.jsx +++ /dev/null @@ -1,124 +0,0 @@ -/* eslint-disable react/prop-types */ -import React from "react"; -import { t } from "ttag"; -import _ from "underscore"; - -import Modal from "metabase/components/Modal"; -import Button from "metabase/core/components/Button"; -import LoadingAndErrorWrapper from "metabase/components/LoadingAndErrorWrapper"; -import Tooltip from "metabase/components/Tooltip"; -import { formatNativeQuery, getEngineNativeType } from "metabase/lib/engine"; -import { MetabaseApi } from "metabase/services"; - -import { - NativeCodeWrapper, - NativeCodeContainer, - SqlIconButton, -} from "./NativeQueryButton.styled"; - -const STRINGS = { - "": { - tooltip: t`View the native query`, - title: t`Native query for this question`, - button: t`Convert this question to a native query`, - }, - sql: { - tooltip: t`View the SQL`, - title: t`SQL for this question`, - button: t`Convert this question to SQL`, - }, -}; - -export default class NativeQueryButton extends React.Component { - state = { - open: false, - loading: false, - native: null, - datasetQuery: null, - }; - - handleOpen = async () => { - const { question } = this.props; - const datasetQuery = question.datasetQuery(); - this.setState({ open: true }); - if (!_.isEqual(datasetQuery, this.state.datasetQuery)) { - this.setState({ loading: true, error: null }); - try { - const native = await MetabaseApi.native(datasetQuery); - this.setState({ loading: false, native, datasetQuery }); - } catch (error) { - console.error(error); - this.setState({ loading: false, error }); - } - } - }; - handleClose = () => { - this.setState({ open: false }); - }; - handleConvert = () => { - const { question, updateQuestion } = this.props; - - const newQuestion = question.setDatasetQuery({ - type: "native", - native: { query: this.getFormattedQuery() }, - database: this.state.datasetQuery.database, - }); - - updateQuestion(newQuestion, { - shouldUpdateUrl: true, - }); - }; - - getFormattedQuery() { - const { question } = this.props; - const { native } = this.state; - return formatNativeQuery( - native && native.query, - question.database().engine, - ); - } - - render() { - const { question, size, ...props } = this.props; - const { loading, error } = this.state; - - const engineType = getEngineNativeType(question.database().engine); - const { tooltip, title, button } = - STRINGS[engineType] || Object.values(STRINGS)[0]; - - return ( - <span {...props}> - <Tooltip tooltip={tooltip} placement="bottom"> - <SqlIconButton iconSize={size} onClick={this.handleOpen} /> - </Tooltip> - <Modal - style={{ padding: "1em" }} - isOpen={this.state.open} - title={title} - footer={ - loading || error ? null : ( - <Button primary onClick={this.handleConvert}> - {button} - </Button> - ) - } - onClose={this.handleClose} - > - <LoadingAndErrorWrapper loading={loading} error={error}> - <NativeCodeWrapper> - <NativeCodeContainer className="p2 sql-code"> - {this.getFormattedQuery()} - </NativeCodeContainer> - </NativeCodeWrapper> - </LoadingAndErrorWrapper> - </Modal> - </span> - ); - } -} - -NativeQueryButton.shouldRender = ({ question, queryBuilderMode }) => - queryBuilderMode === "notebook" && - question.isStructured() && - question.database() && - question.database().native_permissions === "write"; diff --git a/frontend/src/metabase/query_builder/components/view/NativeQueryButton.styled.jsx b/frontend/src/metabase/query_builder/components/view/NativeQueryButton.styled.jsx deleted file mode 100644 index 8fe7e3f47f30f0fdacbc313649cd9c357f67d361..0000000000000000000000000000000000000000 --- a/frontend/src/metabase/query_builder/components/view/NativeQueryButton.styled.jsx +++ /dev/null @@ -1,63 +0,0 @@ -import styled from "@emotion/styled"; - -import { color } from "metabase/lib/colors"; -import { - breakpointMinHeightExtraSmall, - breakpointMinHeightMedium, - breakpointMinHeightSmall, - space, -} from "metabase/styled-components/theme"; -import Button from "metabase/core/components/Button"; - -export const SqlIconButton = styled(Button)` - padding: ${space(1)}; - border: none; - background-color: transparent; - color: ${color("text-dark")}; - cursor: pointer; - - :hover { - background-color: transparent; - color: ${color("brand")}; - } -`; - -SqlIconButton.defaultProps = { - icon: "sql", -}; - -export const NativeCodeWrapper = styled.pre` - box-sizing: border-box; - display: inline-block; - margin: 0; - padding: 0; - max-width: 100%; - overflow: hidden; - vertical-align: bottom; - width: 100%; -`; - -export const NativeCodeContainer = styled.code` - box-sizing: border-box; - display: inline-block; - margin: 0; - max-height: 20vh; - max-width: 100%; - overflow: auto; - vertical-align: bottom; - white-space: pre; - width: 100%; - word-break: break-all; - - ${breakpointMinHeightExtraSmall} { - max-height: 25vh; - } - - ${breakpointMinHeightSmall} { - max-height: 45vh; - } - - ${breakpointMinHeightMedium} { - max-height: 60vh; - } -`; diff --git a/frontend/src/metabase/query_builder/components/view/NativeQueryModal/NativeQueryModal.styled.tsx b/frontend/src/metabase/query_builder/components/view/NativeQueryModal/NativeQueryModal.styled.tsx new file mode 100644 index 0000000000000000000000000000000000000000..21a57fa2411fe7d294cf141225eba6292ff866b3 --- /dev/null +++ b/frontend/src/metabase/query_builder/components/view/NativeQueryModal/NativeQueryModal.styled.tsx @@ -0,0 +1,68 @@ +import styled from "@emotion/styled"; +import { color } from "metabase/lib/colors"; +import Icon from "metabase/components/Icon"; +import LoadingSpinner from "metabase/components/LoadingSpinner"; +import IconButtonWrapper from "metabase/components/IconButtonWrapper"; + +export const ModalRoot = styled.div` + display: flex; + flex-direction: column; + padding: 2rem; + min-width: 40rem; + max-width: 85vw; + min-height: 20rem; + max-height: 90vh; +`; + +export const ModalHeader = styled.div` + display: flex; + align-items: center; + margin-bottom: 1.5rem; +`; + +export const ModalBody = styled.div` + display: flex; + flex: 1 1 auto; + flex-direction: column; + min-height: 0; +`; + +export const ModalFooter = styled.div` + display: flex; + justify-content: end; + margin-top: 1.5rem; +`; + +export const ModalTitle = styled.div` + flex: 1 1 auto; + color: ${color("text-dark")}; + font-size: 1.25rem; + line-height: 1.5rem; + font-weight: bold; +`; + +export const ModalWarningIcon = styled(Icon)` + flex: 0 0 auto; + color: ${color("error")}; + width: 1rem; + height: 1rem; + margin-right: 0.75rem; +`; + +export const ModalCloseButton = styled(IconButtonWrapper)` + flex: 0 0 auto; + margin-left: 1rem; +`; + +export const ModalCloseIcon = styled(Icon)` + color: ${color("text-light")}; +`; + +export const ModalLoadingSpinner = styled(LoadingSpinner)` + color: ${color("brand")}; +`; + +export const ModalDivider = styled.div` + border-top: 1px solid ${color("border")}; + margin-bottom: 1.5rem; +`; diff --git a/frontend/src/metabase/query_builder/components/view/NativeQueryModal/NativeQueryModal.tsx b/frontend/src/metabase/query_builder/components/view/NativeQueryModal/NativeQueryModal.tsx new file mode 100644 index 0000000000000000000000000000000000000000..7805f64368a093833b67a896a7e334b7a09f311d --- /dev/null +++ b/frontend/src/metabase/query_builder/components/view/NativeQueryModal/NativeQueryModal.tsx @@ -0,0 +1,60 @@ +import React, { ReactNode } from "react"; +import { t } from "ttag"; +import NativeCodePanel from "../NativeCodePanel"; +import { + ModalBody, + ModalCloseButton, + ModalCloseIcon, + ModalDivider, + ModalFooter, + ModalHeader, + ModalLoadingSpinner, + ModalRoot, + ModalTitle, + ModalWarningIcon, +} from "./NativeQueryModal.styled"; + +interface NativeQueryModalProps { + title: string; + query?: string; + error?: string; + isLoading?: boolean; + children?: ReactNode; + onClose?: () => void; +} + +const NativeQueryModal = ({ + title, + query, + error, + isLoading, + children, + onClose, +}: NativeQueryModalProps): JSX.Element => { + return ( + <ModalRoot> + <ModalHeader> + {error && <ModalWarningIcon name="warning" />} + <ModalTitle> + {error ? t`An error occurred in your query` : title} + </ModalTitle> + <ModalCloseButton> + <ModalCloseIcon name="close" onClick={onClose} /> + </ModalCloseButton> + </ModalHeader> + {error && <ModalDivider />} + <ModalBody> + {isLoading ? ( + <ModalLoadingSpinner /> + ) : error ? ( + <NativeCodePanel value={error} isHighlighted /> + ) : query ? ( + <NativeCodePanel value={query} isCopyEnabled /> + ) : null} + </ModalBody> + {children && <ModalFooter>{children}</ModalFooter>} + </ModalRoot> + ); +}; + +export default NativeQueryModal; diff --git a/frontend/src/metabase/query_builder/components/view/NativeQueryModal/index.ts b/frontend/src/metabase/query_builder/components/view/NativeQueryModal/index.ts new file mode 100644 index 0000000000000000000000000000000000000000..96bb309650dcb4a58e44705d94df72615dde884c --- /dev/null +++ b/frontend/src/metabase/query_builder/components/view/NativeQueryModal/index.ts @@ -0,0 +1,2 @@ +export { default } from "./NativeQueryModal"; +export { useNativeQuery } from "./use-native-query"; diff --git a/frontend/src/metabase/query_builder/components/view/NativeQueryModal/use-native-query.ts b/frontend/src/metabase/query_builder/components/view/NativeQueryModal/use-native-query.ts new file mode 100644 index 0000000000000000000000000000000000000000..47e6a9a4d26a2f0593f9d88829703f164e457c5c --- /dev/null +++ b/frontend/src/metabase/query_builder/components/view/NativeQueryModal/use-native-query.ts @@ -0,0 +1,37 @@ +import { useEffect, useState } from "react"; +import { getIn } from "icepick"; +import { formatNativeQuery } from "metabase/lib/engine"; +import { NativeQueryForm } from "metabase-types/api"; +import Question from "metabase-lib/Question"; + +interface UseNativeQuery { + query?: string; + error?: string; + isLoading: boolean; +} + +export const useNativeQuery = (question: Question) => { + const [state, setState] = useState<UseNativeQuery>({ isLoading: true }); + + useEffect(() => { + question + .apiGetNativeQueryForm() + .then(data => + setState({ query: getQuery(question, data), isLoading: false }), + ) + .catch(error => + setState({ error: getErrorMessage(error), isLoading: false }), + ); + }, [question]); + + return state; +}; + +const getQuery = (question: Question, data: NativeQueryForm) => { + const engine = question.database()?.engine; + return formatNativeQuery(data.query, engine); +}; + +const getErrorMessage = (error: unknown): string | undefined => { + return getIn(error, ["data", "message"]); +}; diff --git a/frontend/src/metabase/query_builder/components/view/PreviewQueryModal/PreviewQueryModal.styled.tsx b/frontend/src/metabase/query_builder/components/view/PreviewQueryModal/PreviewQueryModal.styled.tsx index 21a57fa2411fe7d294cf141225eba6292ff866b3..c144c5858be7ef95cd27f57c8759799970d75680 100644 --- a/frontend/src/metabase/query_builder/components/view/PreviewQueryModal/PreviewQueryModal.styled.tsx +++ b/frontend/src/metabase/query_builder/components/view/PreviewQueryModal/PreviewQueryModal.styled.tsx @@ -1,68 +1,15 @@ import styled from "@emotion/styled"; import { color } from "metabase/lib/colors"; -import Icon from "metabase/components/Icon"; -import LoadingSpinner from "metabase/components/LoadingSpinner"; -import IconButtonWrapper from "metabase/components/IconButtonWrapper"; +import ExternalLink from "metabase/core/components/ExternalLink"; -export const ModalRoot = styled.div` - display: flex; - flex-direction: column; - padding: 2rem; - min-width: 40rem; - max-width: 85vw; - min-height: 20rem; - max-height: 90vh; -`; - -export const ModalHeader = styled.div` - display: flex; - align-items: center; - margin-bottom: 1.5rem; -`; - -export const ModalBody = styled.div` - display: flex; - flex: 1 1 auto; - flex-direction: column; - min-height: 0; -`; - -export const ModalFooter = styled.div` - display: flex; - justify-content: end; - margin-top: 1.5rem; -`; - -export const ModalTitle = styled.div` - flex: 1 1 auto; - color: ${color("text-dark")}; - font-size: 1.25rem; - line-height: 1.5rem; - font-weight: bold; -`; - -export const ModalWarningIcon = styled(Icon)` - flex: 0 0 auto; - color: ${color("error")}; - width: 1rem; - height: 1rem; - margin-right: 0.75rem; -`; - -export const ModalCloseButton = styled(IconButtonWrapper)` - flex: 0 0 auto; - margin-left: 1rem; -`; - -export const ModalCloseIcon = styled(Icon)` - color: ${color("text-light")}; -`; - -export const ModalLoadingSpinner = styled(LoadingSpinner)` +export const ModalExternalLink = styled(ExternalLink)` color: ${color("brand")}; -`; + font-size: 0.75rem; + line-height: 1rem; + font-weight: bold; + text-decoration: none; -export const ModalDivider = styled.div` - border-top: 1px solid ${color("border")}; - margin-bottom: 1.5rem; + &:hover { + text-decoration: underline; + } `; diff --git a/frontend/src/metabase/query_builder/components/view/PreviewQueryModal/PreviewQueryModal.tsx b/frontend/src/metabase/query_builder/components/view/PreviewQueryModal/PreviewQueryModal.tsx index 79504202bb3037528cefa4abb0fa54fcbb613e9a..d0cc8d000efbbadfdc8800a3366889240153936d 100644 --- a/frontend/src/metabase/query_builder/components/view/PreviewQueryModal/PreviewQueryModal.tsx +++ b/frontend/src/metabase/query_builder/components/view/PreviewQueryModal/PreviewQueryModal.tsx @@ -1,24 +1,9 @@ -import React, { useEffect, useMemo, useState } from "react"; -import { getIn } from "icepick"; +import React from "react"; import { t } from "ttag"; -import { formatNativeQuery } from "metabase/lib/engine"; import MetabaseSettings from "metabase/lib/settings"; -import ExternalLink from "metabase/core/components/ExternalLink"; -import { NativeQueryForm } from "metabase-types/api"; import Question from "metabase-lib/Question"; -import NativeCodePanel from "../NativeCodePanel"; -import { - ModalBody, - ModalCloseButton, - ModalCloseIcon, - ModalDivider, - ModalFooter, - ModalHeader, - ModalLoadingSpinner, - ModalRoot, - ModalTitle, - ModalWarningIcon, -} from "./PreviewQueryModal.styled"; +import NativeQueryModal, { useNativeQuery } from "../NativeQueryModal"; +import { ModalExternalLink } from "./PreviewQueryModal.styled"; interface PreviewQueryModalProps { question: Question; @@ -29,72 +14,24 @@ const PreviewQueryModal = ({ question, onClose, }: PreviewQueryModalProps): JSX.Element => { - const { data, error, isLoading } = useNativeQuery(question); - - const queryText = useMemo(() => { - const engine = question.database()?.engine; - return data ? formatNativeQuery(data.query, engine) : undefined; - }, [question, data]); - - const errorText = useMemo(() => { - return error ? getErrorMessage(error) : undefined; - }, [error]); + const { query, error, isLoading } = useNativeQuery(question); + const learnUrl = MetabaseSettings.learnUrl("debugging-sql/sql-syntax"); return ( - <ModalRoot> - <ModalHeader> - {error && <ModalWarningIcon name="warning" />} - <ModalTitle> - {error ? t`An error occurred in your query` : t`Query preview`} - </ModalTitle> - <ModalCloseButton> - <ModalCloseIcon name="close" onClick={onClose} /> - </ModalCloseButton> - </ModalHeader> - {error && <ModalDivider />} - <ModalBody> - {isLoading ? ( - <ModalLoadingSpinner /> - ) : errorText ? ( - <NativeCodePanel value={errorText} isHighlighted /> - ) : queryText ? ( - <NativeCodePanel value={queryText} isCopyEnabled /> - ) : undefined} - </ModalBody> + <NativeQueryModal + title={t`Query preview`} + query={query} + error={error} + isLoading={isLoading} + onClose={onClose} + > {error && ( - <ModalFooter> - <ExternalLink - href={MetabaseSettings.learnUrl("debugging-sql/sql-syntax")} - > - {t`Learn how to debug SQL errors`} - </ExternalLink> - </ModalFooter> + <ModalExternalLink href={learnUrl}> + {t`Learn how to debug SQL errors`} + </ModalExternalLink> )} - </ModalRoot> + </NativeQueryModal> ); }; -interface UseNativeQuery { - data?: NativeQueryForm; - error?: unknown; - isLoading: boolean; -} - -const useNativeQuery = (question: Question) => { - const [state, setState] = useState<UseNativeQuery>({ isLoading: true }); - - useEffect(() => { - question - .apiGetNativeQueryForm() - .then(data => setState({ data, isLoading: false })) - .catch(error => setState({ isLoading: false, error })); - }, [question]); - - return state; -}; - -const getErrorMessage = (error: unknown): string | undefined => { - return getIn(error, ["data", "message"]); -}; - export default PreviewQueryModal; diff --git a/frontend/src/metabase/query_builder/components/view/PreviewQueryModal/PreviewQueryModal.unit.spec.tsx b/frontend/src/metabase/query_builder/components/view/PreviewQueryModal/PreviewQueryModal.unit.spec.tsx new file mode 100644 index 0000000000000000000000000000000000000000..954c0556eb18ba5d6291cfac53b0b6e6628c37c2 --- /dev/null +++ b/frontend/src/metabase/query_builder/components/view/PreviewQueryModal/PreviewQueryModal.unit.spec.tsx @@ -0,0 +1,54 @@ +import React from "react"; +import xhrMock from "xhr-mock"; +import { render, screen } from "@testing-library/react"; +import Question from "metabase-lib/Question"; +import PreviewQueryModal from "./PreviewQueryModal"; + +const SQL_QUERY = "SELECT 1"; +const SQL_QUERY_ERROR = "Cannot run the query"; + +describe("PreviewQueryModal", () => { + beforeEach(() => { + xhrMock.setup(); + }); + + afterEach(() => { + xhrMock.teardown(); + }); + + it("should show a fully parameterized native query", async () => { + const question = Question.create({ databaseId: 1 }); + mockNativeQuery(); + + render(<PreviewQueryModal question={question} />); + + expect(await screen.findByText(SQL_QUERY)).toBeInTheDocument(); + }); + + it("should show a native query error", async () => { + const question = Question.create({ databaseId: 1 }); + mockNativeQueryError(); + + render(<PreviewQueryModal question={question} />); + + expect(await screen.findByText(SQL_QUERY_ERROR)).toBeInTheDocument(); + }); +}); + +const mockNativeQuery = () => { + xhrMock.post("/api/dataset/native", { + status: 200, + body: JSON.stringify({ + query: "SELECT 1", + }), + }); +}; + +const mockNativeQueryError = () => { + xhrMock.post("/api/dataset/native", { + status: 500, + body: JSON.stringify({ + message: SQL_QUERY_ERROR, + }), + }); +}; diff --git a/frontend/src/metabase/query_builder/components/view/ViewHeader.jsx b/frontend/src/metabase/query_builder/components/view/ViewHeader.jsx index 84ae8f94b1a1828c76a22f8a60ce729e54ffa6d8..9844efb9bbf22bee12ccb95dbf40afcb384af0af 100644 --- a/frontend/src/metabase/query_builder/components/view/ViewHeader.jsx +++ b/frontend/src/metabase/query_builder/components/view/ViewHeader.jsx @@ -24,13 +24,13 @@ import { HeadBreadcrumbs } from "./HeaderBreadcrumbs"; import QuestionDataSource from "./QuestionDataSource"; import QuestionDescription from "./QuestionDescription"; import QuestionNotebookButton from "./QuestionNotebookButton"; +import ConvertQueryButton from "./ConvertQueryButton"; import QuestionFilters, { FilterHeaderToggle, FilterHeader, QuestionFilterWidget, } from "./QuestionFilters"; import { QuestionSummarizeWidget } from "./QuestionSummaries"; -import NativeQueryButton from "./NativeQueryButton"; import { AdHocViewHeading, SaveButton, @@ -371,7 +371,6 @@ function ViewTitleHeaderRightSide(props) { isResultDirty, isActionListVisible, runQuestionQuery, - updateQuestion, cancelQuery, onOpenModal, onEditSummary, @@ -463,15 +462,8 @@ function ViewTitleHeaderRightSide(props) { /> </ViewHeaderIconButtonContainer> )} - {NativeQueryButton.shouldRender(props) && ( - <ViewHeaderIconButtonContainer> - <NativeQueryButton - size={16} - question={question} - updateQuestion={updateQuestion} - data-metabase-event="Notebook Mode; Convert to SQL Click" - /> - </ViewHeaderIconButtonContainer> + {ConvertQueryButton.shouldRender(props) && ( + <ConvertQueryButton question={question} onOpenModal={onOpenModal} /> )} {hasExploreResultsLink && <ExploreResultsLink question={question} />} {hasRunButton && !isShowingNotebook && ( diff --git a/frontend/src/metabase/query_builder/constants.js b/frontend/src/metabase/query_builder/constants.js index 8bc11fe7dad0b2c805f1c3f163767251db362b73..f0f45732e5cac366c06bd453b789d4134c17b85d 100644 --- a/frontend/src/metabase/query_builder/constants.js +++ b/frontend/src/metabase/query_builder/constants.js @@ -18,6 +18,7 @@ export const MODAL_TYPES = { EDIT_EVENT: "edit-event", MOVE_EVENT: "move-event", PREVIEW_QUERY: "preview-query", + CONVERT_QUERY: "convert-query", }; export const SIDEBAR_SIZES = {