From 6ca582a00f6fd067c1876fbaf3807efaa73cfb6f Mon Sep 17 00:00:00 2001
From: Alexander Polyankin <alexander.polyankin@metabase.com>
Date: Wed, 28 Jun 2023 12:54:43 +0300
Subject: [PATCH] Automatically expand the native query editor when editing a
 question from a dashboard (#31863)

---
 .../dashboard-back-navigation.cy.spec.js      | 29 +++++++++++++++++++
 frontend/src/metabase-types/store/mocks/qb.ts | 11 +++++++
 frontend/src/metabase-types/store/qb.ts       |  9 +++++-
 .../actions/core/initializeQB.ts              | 10 +++++--
 .../query_builder/components/QueryModals.tsx  |  5 +++-
 .../ConvertQueryModal/ConvertQueryModal.tsx   |  9 ++++--
 .../query_builder/components/view/View.jsx    |  4 ++-
 .../src/metabase/query_builder/reducers.js    | 22 ++++++++++----
 .../src/metabase/query_builder/selectors.js   |  6 +++-
 9 files changed, 91 insertions(+), 14 deletions(-)

diff --git a/e2e/test/scenarios/dashboard/dashboard-back-navigation.cy.spec.js b/e2e/test/scenarios/dashboard/dashboard-back-navigation.cy.spec.js
index 2a410011a96..1bc08955288 100644
--- a/e2e/test/scenarios/dashboard/dashboard-back-navigation.cy.spec.js
+++ b/e2e/test/scenarios/dashboard/dashboard-back-navigation.cy.spec.js
@@ -71,6 +71,22 @@ describe("scenarios > dashboard > dashboard back navigation", () => {
     cy.findByLabelText(backButtonLabel).should("not.exist");
   });
 
+  it("should expand the native editor when editing a question from a dashboard", () => {
+    createDashboardWithNativeCard();
+    cy.get("@dashboardId").then(visitDashboard);
+    getDashboardCard().realHover();
+    getDashboardCardMenu().click();
+    popover().findByText("Edit question").click();
+    cy.findByTestId("native-query-editor").should("be.visible");
+
+    queryBuilderHeader().findByLabelText("Back to Test Dashboard").click();
+    getDashboardCard().findByText("Orders SQL").click();
+    cy.findByTestId("native-query-top-bar")
+      .findByText("This question is written in SQL.")
+      .should("be.visible");
+    cy.findByTestId("native-query-editor").should("not.be.visible");
+  });
+
   it("should display a back to the dashboard button in table x-ray dashboards", () => {
     const cardTitle = "Sales per state";
     cy.visit(`/auto/dashboard/table/${ORDERS_ID}`);
@@ -340,6 +356,19 @@ const createDashboardWithCards = () => {
   });
 };
 
+const createDashboardWithNativeCard = () => {
+  const questionDetails = {
+    name: "Orders SQL",
+    native: {
+      query: "SELECT * FROM ORDERS",
+    },
+  };
+
+  cy.createNativeQuestionAndDashboard({ questionDetails }).then(
+    ({ body: { dashboard_id } }) => cy.wrap(dashboard_id).as("dashboardId"),
+  );
+};
+
 const createDashboardWithSlowCard = () => {
   const questionDetails = {
     name: "Sleep card",
diff --git a/frontend/src/metabase-types/store/mocks/qb.ts b/frontend/src/metabase-types/store/mocks/qb.ts
index f7c86811717..be2587c1809 100644
--- a/frontend/src/metabase-types/store/mocks/qb.ts
+++ b/frontend/src/metabase-types/store/mocks/qb.ts
@@ -1,4 +1,5 @@
 import {
+  QueryBuilderDashboardState,
   QueryBuilderState,
   QueryBuilderUIControls,
 } from "metabase-types/store";
@@ -18,6 +19,7 @@ export const createMockQueryBuilderUIControlsState = (
   isShowingTimelineSidebar: false,
   initialChartSetting: null,
   isShowingRawTable: false,
+  isNativeEditorOpen: false,
   queryBuilderMode: "view",
   previousQueryBuilderMode: false,
   snippetCollectionId: null,
@@ -25,6 +27,14 @@ export const createMockQueryBuilderUIControlsState = (
   ...opts,
 });
 
+export const createMockQueryBuilderDashboardState = (
+  opts?: Partial<QueryBuilderDashboardState>,
+): QueryBuilderDashboardState => ({
+  dashboardId: null,
+  isEditing: false,
+  ...opts,
+});
+
 export const createMockQueryBuilderState = (
   opts?: Partial<QueryBuilderState>,
 ): QueryBuilderState => ({
@@ -34,6 +44,7 @@ export const createMockQueryBuilderState = (
     documentTitle: "",
     timeoutId: "",
   },
+  parentDashboard: createMockQueryBuilderDashboardState(),
 
   queryStatus: "complete",
   queryResults: null,
diff --git a/frontend/src/metabase-types/store/qb.ts b/frontend/src/metabase-types/store/qb.ts
index 9d76be31f9b..ed10224db1b 100644
--- a/frontend/src/metabase-types/store/qb.ts
+++ b/frontend/src/metabase-types/store/qb.ts
@@ -1,5 +1,6 @@
 import {
   Card,
+  DashboardId,
   Dataset,
   Field,
   ParameterValueOrArray,
@@ -25,6 +26,7 @@ export interface QueryBuilderUIControls {
   isShowingChartSettingsSidebar: boolean;
   isShowingQuestionDetailsSidebar: boolean;
   isShowingTimelineSidebar: boolean;
+  isNativeEditorOpen: boolean;
   initialChartSetting: null;
   isShowingRawTable: boolean;
   queryBuilderMode: QueryBuilderMode;
@@ -39,10 +41,15 @@ export interface QueryBuilderLoadingControls {
   timeoutId: string;
 }
 
+export interface QueryBuilderDashboardState {
+  dashboardId: DashboardId | null;
+  isEditing: boolean;
+}
+
 export interface QueryBuilderState {
   uiControls: QueryBuilderUIControls;
-
   loadingControls: QueryBuilderLoadingControls;
+  parentDashboard: QueryBuilderDashboardState;
   queryStatus: QueryBuilderQueryStatus;
   queryResults: Dataset[] | null;
   queryStartTime: number | null;
diff --git a/frontend/src/metabase/query_builder/actions/core/initializeQB.ts b/frontend/src/metabase/query_builder/actions/core/initializeQB.ts
index 21aac978f74..b1ce3313758 100644
--- a/frontend/src/metabase/query_builder/actions/core/initializeQB.ts
+++ b/frontend/src/metabase/query_builder/actions/core/initializeQB.ts
@@ -13,6 +13,7 @@ import Snippets from "metabase/entities/snippets";
 import Questions from "metabase/entities/questions";
 import { loadMetadataForCard } from "metabase/questions/actions";
 import { fetchAlertsForQuestion } from "metabase/alert/alert";
+import { getIsEditingInDashboard } from "metabase/query_builder/selectors";
 
 import { Card } from "metabase-types/api";
 import {
@@ -28,12 +29,12 @@ import Question from "metabase-lib/Question";
 import NativeQuery, {
   updateCardTemplateTagNames,
 } from "metabase-lib/queries/NativeQuery";
-import StructuredQuery from "metabase-lib/queries/StructuredQuery";
 
+import StructuredQuery from "metabase-lib/queries/StructuredQuery";
 import { getQueryBuilderModeFromLocation } from "../../typed-utils";
 import { updateUrl } from "../navigation";
-import { cancelQuery, runQuestionQuery } from "../querying";
 
+import { cancelQuery, runQuestionQuery } from "../querying";
 import { resetQB } from "./core";
 import {
   propagateDashboardParameters,
@@ -314,6 +315,11 @@ async function handleQBInit(
     }
   }
 
+  if (question.isNative()) {
+    const isEditing = getIsEditingInDashboard(getState());
+    uiControls.isNativeEditorOpen = isEditing || !question.isSaved();
+  }
+
   if (question.isNative() && !question.query().readOnly()) {
     const query = question.query() as NativeQuery;
     const newQuery = await updateTemplateTagNames(query, getState, dispatch);
diff --git a/frontend/src/metabase/query_builder/components/QueryModals.tsx b/frontend/src/metabase/query_builder/components/QueryModals.tsx
index 8a525e1b068..f6e32b995a6 100644
--- a/frontend/src/metabase/query_builder/components/QueryModals.tsx
+++ b/frontend/src/metabase/query_builder/components/QueryModals.tsx
@@ -31,7 +31,7 @@ import PreviewQueryModal from "metabase/query_builder/components/view/PreviewQue
 import ConvertQueryModal from "metabase/query_builder/components/view/ConvertQueryModal";
 import QuestionMoveToast from "metabase/questions/components/QuestionMoveToast";
 import { Alert, Card, Collection, User } from "metabase-types/api";
-import { QueryBuilderMode } from "metabase-types/store";
+import { QueryBuilderMode, QueryBuilderUIControls } from "metabase-types/store";
 import StructuredQuery from "metabase-lib/queries/StructuredQuery";
 import Question from "metabase-lib/Question";
 import { UpdateQuestionOpts } from "../actions/core/updateQuestion";
@@ -51,6 +51,7 @@ interface QueryModalsProps {
   initialCollectionId: number;
   updateQuestion: (question: Question, config?: UpdateQuestionOpts) => void;
   setQueryBuilderMode: (mode: QueryBuilderMode) => void;
+  setUIControls: (opts: Partial<QueryBuilderUIControls>) => void;
   originalQuestion: Question | null;
   card: Card;
   onCreate: (question: Question) => void;
@@ -99,6 +100,7 @@ class QueryModals extends Component<QueryModalsProps> {
       onOpenModal,
       updateQuestion,
       setQueryBuilderMode,
+      setUIControls,
     } = this.props;
 
     switch (modal) {
@@ -345,6 +347,7 @@ class QueryModals extends Component<QueryModalsProps> {
           <Modal fit onClose={onCloseModal}>
             <ConvertQueryModal
               onUpdateQuestion={updateQuestion}
+              onSetUIControls={setUIControls}
               onClose={onCloseModal}
             />
           </Modal>
diff --git a/frontend/src/metabase/query_builder/components/view/ConvertQueryModal/ConvertQueryModal.tsx b/frontend/src/metabase/query_builder/components/view/ConvertQueryModal/ConvertQueryModal.tsx
index a575bff09ff..7d969dd54b8 100644
--- a/frontend/src/metabase/query_builder/components/view/ConvertQueryModal/ConvertQueryModal.tsx
+++ b/frontend/src/metabase/query_builder/components/view/ConvertQueryModal/ConvertQueryModal.tsx
@@ -9,7 +9,7 @@ import {
   getQuestion,
 } from "metabase/query_builder/selectors";
 import { NativeQueryForm } from "metabase-types/api";
-import { State } from "metabase-types/store";
+import { QueryBuilderUIControls, State } from "metabase-types/store";
 import Question from "metabase-lib/Question";
 
 import NativeQueryModal, { useNativeQuery } from "../NativeQueryModal";
@@ -33,7 +33,8 @@ interface UpdateQuestionOpts {
 interface ConvertQueryModalProps {
   question: Question;
   onLoadQuery: () => Promise<NativeQueryForm>;
-  onUpdateQuestion?: (question: Question, opts?: UpdateQuestionOpts) => void;
+  onUpdateQuestion: (question: Question, opts?: UpdateQuestionOpts) => void;
+  onSetUIControls: (changes: Partial<QueryBuilderUIControls>) => void;
   onClose?: () => void;
 }
 
@@ -41,6 +42,7 @@ const ConvertQueryModal = ({
   question,
   onLoadQuery,
   onUpdateQuestion,
+  onSetUIControls,
   onClose,
 }: ConvertQueryModalProps): JSX.Element => {
   const engineType = getEngineNativeType(question.database()?.engine);
@@ -55,8 +57,9 @@ const ConvertQueryModal = ({
     const newQuestion = question.setDatasetQuery(newDatasetQuery);
 
     onUpdateQuestion?.(newQuestion, { shouldUpdateUrl: true });
+    onSetUIControls({ isNativeEditorOpen: true });
     onClose?.();
-  }, [question, query, onUpdateQuestion, onClose]);
+  }, [question, query, onUpdateQuestion, onSetUIControls, onClose]);
 
   return (
     <NativeQueryModal
diff --git a/frontend/src/metabase/query_builder/components/view/View.jsx b/frontend/src/metabase/query_builder/components/view/View.jsx
index e4400c2b2b0..852461bd277 100644
--- a/frontend/src/metabase/query_builder/components/view/View.jsx
+++ b/frontend/src/metabase/query_builder/components/view/View.jsx
@@ -296,7 +296,8 @@ class View extends Component {
   };
 
   renderNativeQueryEditor = () => {
-    const { question, query, card, height, isDirty } = this.props;
+    const { question, query, card, height, isDirty, isNativeEditorOpen } =
+      this.props;
 
     // Normally, when users open native models,
     // they open an ad-hoc GUI question using the model as a data source
@@ -315,6 +316,7 @@ class View extends Component {
           {...this.props}
           viewHeight={height}
           isOpen={query.isEmpty() || isDirty}
+          isInitiallyOpen={isNativeEditorOpen}
           datasetQuery={card && card.dataset_query}
         />
       </NativeQueryEditorContainer>
diff --git a/frontend/src/metabase/query_builder/reducers.js b/frontend/src/metabase/query_builder/reducers.js
index e24f8f837fd..3cd6fabbe29 100644
--- a/frontend/src/metabase/query_builder/reducers.js
+++ b/frontend/src/metabase/query_builder/reducers.js
@@ -82,6 +82,7 @@ const DEFAULT_UI_CONTROLS = {
   isShowingChartSettingsSidebar: false,
   isShowingQuestionInfoSidebar: false,
   isShowingTimelineSidebar: false,
+  isNativeEditorOpen: false,
   initialChartSetting: null,
   isShowingRawTable: false, // table/viz toggle
   queryBuilderMode: false, // "view" | "notebook" | "dataset"
@@ -96,6 +97,11 @@ const DEFAULT_LOADING_CONTROLS = {
   timeoutId: "",
 };
 
+const DEFAULT_DASHBOARD_STATE = {
+  dashboardId: null,
+  isEditing: false,
+};
+
 const DEFAULT_QUERY_STATUS = "idle";
 
 const UI_CONTROLS_SIDEBAR_DEFAULTS = {
@@ -548,17 +554,23 @@ export const currentState = handleActions(
   null,
 );
 
-export const dashboardId = handleActions(
+export const parentDashboard = handleActions(
   {
     [NAVIGATE_TO_NEW_CARD]: {
-      next: (state, { payload: { dashboardId } }) => dashboardId,
+      next: (state, { payload: { dashboardId } }) => ({
+        dashboardId,
+        isEditing: false,
+      }),
     },
     [EDIT_QUESTION]: {
-      next: (state, { payload: { dashboardId } }) => dashboardId,
+      next: (state, { payload: { dashboardId } }) => ({
+        dashboardId,
+        isEditing: true,
+      }),
     },
-    [CLOSE_QB]: { next: () => null },
+    [CLOSE_QB]: { next: () => DEFAULT_DASHBOARD_STATE },
   },
-  null,
+  DEFAULT_DASHBOARD_STATE,
 );
 
 export const visibleTimelineEventIds = handleActions(
diff --git a/frontend/src/metabase/query_builder/selectors.js b/frontend/src/metabase/query_builder/selectors.js
index 8ea93a510c9..ee0330fbde9 100644
--- a/frontend/src/metabase/query_builder/selectors.js
+++ b/frontend/src/metabase/query_builder/selectors.js
@@ -932,7 +932,11 @@ export const getNativeQueryFn = createSelector(
 );
 
 export const getDashboardId = state => {
-  return state.qb.dashboardId;
+  return state.qb.parentDashboard.dashboardId;
+};
+
+export const getIsEditingInDashboard = state => {
+  return state.qb.parentDashboard.isEditing;
 };
 
 export const getDashboard = state => {
-- 
GitLab