From c9084750e89b16b85c17ea4fe1d039e56402910b Mon Sep 17 00:00:00 2001
From: Anton Kulyk <kuliks.anton@gmail.com>
Date: Wed, 8 Dec 2021 11:02:12 +0200
Subject: [PATCH] Add basic dataset metadata editor UI (#19206)

---
 frontend/src/metabase/App.jsx                 |   5 +-
 frontend/src/metabase/components/EditBar.jsx  |  29 ++-
 frontend/src/metabase/components/Icon.tsx     |   5 +-
 frontend/src/metabase/icon_paths.ts           |   8 +
 .../src/metabase/query_builder/actions.js     |  42 ++++-
 .../DatasetEditor/DatasetEditor.jsx           | 172 ++++++++++++++----
 .../DatasetEditor/DatasetEditor.styled.jsx    |  49 ++++-
 .../DatasetFieldMetadataSidebar.jsx           | 130 +++++++++++++
 .../DatasetFieldMetadataSidebar.styled.jsx    |  13 ++
 .../DatasetFieldMetadataSidebar/index.js      |   1 +
 .../DatasetEditor/DatasetQueryEditor.jsx      |  81 +++++++++
 .../EditorTabs/EditorTabs.styled.tsx          |  68 +++++++
 .../DatasetEditor/EditorTabs/EditorTabs.tsx   |  49 +++++
 .../DatasetEditor/EditorTabs/index.ts         |   1 +
 .../ResizableNotebook/ResizableNotebook.jsx   |  21 ++-
 .../components/NativeQueryEditor.jsx          |  59 +++---
 .../components/VisualizationResult.jsx        |  17 +-
 .../DatasetManagementSection.jsx              |  15 +-
 .../src/metabase/query_builder/reducers.js    |   1 +
 .../src/metabase/query_builder/selectors.js   |   5 +
 frontend/src/metabase/query_builder/utils.js  |  18 +-
 frontend/src/metabase/routes.jsx              |   1 +
 .../components/TableInteractive.jsx           |   9 +-
 .../components/Visualization.jsx              |  19 +-
 24 files changed, 709 insertions(+), 109 deletions(-)
 create mode 100644 frontend/src/metabase/query_builder/components/DatasetEditor/DatasetFieldMetadataSidebar/DatasetFieldMetadataSidebar.jsx
 create mode 100644 frontend/src/metabase/query_builder/components/DatasetEditor/DatasetFieldMetadataSidebar/DatasetFieldMetadataSidebar.styled.jsx
 create mode 100644 frontend/src/metabase/query_builder/components/DatasetEditor/DatasetFieldMetadataSidebar/index.js
 create mode 100644 frontend/src/metabase/query_builder/components/DatasetEditor/DatasetQueryEditor.jsx
 create mode 100644 frontend/src/metabase/query_builder/components/DatasetEditor/EditorTabs/EditorTabs.styled.tsx
 create mode 100644 frontend/src/metabase/query_builder/components/DatasetEditor/EditorTabs/EditorTabs.tsx
 create mode 100644 frontend/src/metabase/query_builder/components/DatasetEditor/EditorTabs/index.ts

diff --git a/frontend/src/metabase/App.jsx b/frontend/src/metabase/App.jsx
index 3f3acbc8380..0ea04a41a38 100644
--- a/frontend/src/metabase/App.jsx
+++ b/frontend/src/metabase/App.jsx
@@ -44,7 +44,10 @@ const getErrorComponent = ({ status, data, context }) => {
   }
 };
 
-const PATHS_WITHOUT_NAVBAR = [/\/dataset\/.*\/query/];
+const PATHS_WITHOUT_NAVBAR = [
+  /\/dataset\/.*\/query/,
+  /\/dataset\/.*\/metadata/,
+];
 
 @connect(mapStateToProps)
 export default class App extends Component {
diff --git a/frontend/src/metabase/components/EditBar.jsx b/frontend/src/metabase/components/EditBar.jsx
index 32cb7fe1df8..dabd95bf85d 100644
--- a/frontend/src/metabase/components/EditBar.jsx
+++ b/frontend/src/metabase/components/EditBar.jsx
@@ -6,9 +6,11 @@ class EditBar extends Component {
   static propTypes = {
     title: PropTypes.string.isRequired,
     subtitle: PropTypes.string,
+    center: PropTypes.node,
     buttons: PropTypes.oneOfType([PropTypes.element, PropTypes.array])
       .isRequired,
     admin: PropTypes.bool,
+    className: PropTypes.string,
   };
 
   static defaultProps = {
@@ -16,18 +18,27 @@ class EditBar extends Component {
   };
 
   render() {
-    const { admin, buttons, subtitle, title } = this.props;
+    const { admin, buttons, subtitle, title, center, className } = this.props;
     return (
       <div
-        className={cx("EditHeader wrapper py1 flex align-center", {
-          "EditHeader--admin": admin,
-        })}
-      >
-        <span className="EditHeader-title">{title}</span>
-        {subtitle && (
-          <span className="EditHeader-subtitle mx1">{subtitle}</span>
+        className={cx(
+          "EditHeader wrapper py1 flex align-center justify-between",
+          {
+            "EditHeader--admin": admin,
+          },
+          className,
         )}
-        <span className="flex-align-right flex">{buttons}</span>
+      >
+        <div>
+          <span className="EditHeader-title">{title}</span>
+          {subtitle && (
+            <span className="EditHeader-subtitle mx1">{subtitle}</span>
+          )}
+        </div>
+        {center && <div>{center}</div>}
+        <div>
+          <span className="flex">{buttons}</span>
+        </div>
       </div>
     );
   }
diff --git a/frontend/src/metabase/components/Icon.tsx b/frontend/src/metabase/components/Icon.tsx
index 0f3c37227cd..29c99e4586f 100644
--- a/frontend/src/metabase/components/Icon.tsx
+++ b/frontend/src/metabase/components/Icon.tsx
@@ -57,11 +57,12 @@ export const iconPropTypes = {
   scale: stringOrNumberPropType,
   tooltip: PropTypes.string,
   className: PropTypes.string,
-  innerRef: PropTypes.any,
   onClick: PropTypes.func,
 };
 
-type IconProps = PropTypes.InferProps<typeof iconPropTypes>;
+type IconProps = PropTypes.InferProps<typeof iconPropTypes> & {
+  innerRef?: () => void;
+};
 
 class BaseIcon extends Component<IconProps> {
   static propTypes = iconPropTypes;
diff --git a/frontend/src/metabase/icon_paths.ts b/frontend/src/metabase/icon_paths.ts
index 109f9d7997f..f86d54b117e 100644
--- a/frontend/src/metabase/icon_paths.ts
+++ b/frontend/src/metabase/icon_paths.ts
@@ -467,6 +467,14 @@ export const ICON_PATHS: Record<string, any> = {
     "M1.6 0h28.8A1.6 1.6 0 0 1 32 1.6v28.8a1.6 1.6 0 0 1-1.6 1.6H1.6A1.6 1.6 0 0 1 0 30.4V1.6A1.6 1.6 0 0 1 1.6 0zm1.6 3.2v6.4h6.4V3.2H3.2zm9.6 0v6.4h16V3.2h-16zm-9.6 9.6v6.4h6.4v-6.4H3.2zm9.6 0v6.4h16v-6.4h-16zm-9.6 9.6v6.4h6.4v-6.4H3.2zm9.6 0v6.4h16v-6.4h-16z",
   table_spaced:
     "M0 0h7.784v7.784H0V0zm12.108 0h7.784v7.784h-7.784V0zm12.108 0H32v7.784h-7.784V0zM0 12.108h7.784v7.784H0v-7.784zm12.108 0h7.784v7.784h-7.784v-7.784zm12.108 0H32v7.784h-7.784v-7.784zM0 24.216h7.784V32H0v-7.784zm12.108 0h7.784V32h-7.784v-7.784zm12.108 0H32V32h-7.784v-7.784z",
+  three_dots: {
+    path:
+      "M3.35484 9.35484C4.1031 9.35484 4.70968 8.74826 4.70968 8C4.70968 7.25175 4.1031 6.64516 3.35484 6.64516C2.60658 6.64516 2 7.25175 2 8C2 8.74826 2.60658 9.35484 3.35484 9.35484ZM12.6452 9.35484C13.3934 9.35484 14 8.74826 14 8C14 7.25175 13.3934 6.64516 12.6452 6.64516C11.8969 6.64516 11.2903 7.25175 11.2903 8C11.2903 8.74826 11.8969 9.35484 12.6452 9.35484ZM9.35484 8C9.35484 8.74826 8.74826 9.35484 8 9.35484C7.25174 9.35484 6.64516 8.74826 6.64516 8C6.64516 7.25175 7.25174 6.64516 8 6.64516C8.74826 6.64516 9.35484 7.25175 9.35484 8Z M0 2C0 0.895431 0.895431 0 2 0H14C15.1046 0 16 0.895431 16 2V14C16 15.1046 15.1046 16 14 16H2C0.895431 16 0 15.1046 0 14V2Z",
+    attrs: {
+      fillRule: "evenodd",
+      viewBox: "0 0 16 16",
+    },
+  },
   trash:
     "M4.31904507,29.7285487 C4.45843264,30.9830366 5.59537721,32 6.85726914,32 L20.5713023,32 C21.8337371,32 22.9701016,30.9833707 23.1095264,29.7285487 L25.1428571,11.4285714 L2.28571429,11.4285714 L4.31904507,29.7285487 L4.31904507,29.7285487 Z M6.85714286,4.57142857 L8.57142857,0 L18.8571429,0 L20.5714286,4.57142857 L25.1428571,4.57142857 C27.4285714,4.57142857 27.4285714,9.14285714 27.4285714,9.14285714 L13.7142857,9.14285714 L-1.0658141e-14,9.14285714 C-1.0658141e-14,9.14285714 -1.0658141e-14,4.57142857 2.28571429,4.57142857 L6.85714286,4.57142857 L6.85714286,4.57142857 Z M9.14285714,4.57142857 L18.2857143,4.57142857 L17.1428571,2.28571429 L10.2857143,2.28571429 L9.14285714,4.57142857 L9.14285714,4.57142857 Z",
   triangle_left: "M21,0 L5,16 L21,32 L21,5.47117907e-13 L21,0 Z",
diff --git a/frontend/src/metabase/query_builder/actions.js b/frontend/src/metabase/query_builder/actions.js
index 52c246351c3..812d5dfd852 100644
--- a/frontend/src/metabase/query_builder/actions.js
+++ b/frontend/src/metabase/query_builder/actions.js
@@ -52,6 +52,7 @@ import {
   getIsPreviewing,
   getTableForeignKeys,
   getQueryBuilderMode,
+  getDatasetEditorTab,
   getIsShowingTemplateTagsEditor,
   getIsRunning,
   getNativeEditorCursorOffset,
@@ -96,16 +97,17 @@ export const resetUIControls = createAction(RESET_UI_CONTROLS);
 
 export const setQueryBuilderMode = (
   queryBuilderMode,
-  { shouldUpdateUrl = true } = {},
+  { shouldUpdateUrl = true, datasetEditorTab = "query" } = {},
 ) => async dispatch => {
   await dispatch(
     setUIControls({
       queryBuilderMode,
+      datasetEditorTab,
       isShowingChartSettingsSidebar: false,
     }),
   );
   if (shouldUpdateUrl) {
-    await dispatch(updateUrl(null, { queryBuilderMode }));
+    await dispatch(updateUrl(null, { queryBuilderMode, datasetEditorTab }));
   }
   if (queryBuilderMode === "notebook") {
     dispatch(cancelQuery());
@@ -155,10 +157,16 @@ export const popState = createThunkAction(
         await dispatch(setCurrentState(location.state));
       }
     }
-    const queryBuilderModeFromURL = getQueryBuilderModeFromLocation(location);
+
+    const {
+      mode: queryBuilderModeFromURL,
+      ...uiControls
+    } = getQueryBuilderModeFromLocation(location);
+
     if (getQueryBuilderMode(getState()) !== queryBuilderModeFromURL) {
       await dispatch(
         setQueryBuilderMode(queryBuilderModeFromURL, {
+          ...uiControls,
           shouldUpdateUrl: queryBuilderModeFromURL === "dataset",
         }),
       );
@@ -226,7 +234,13 @@ export const updateUrl = createThunkAction(
   UPDATE_URL,
   (
     card,
-    { dirty, replaceState, preserveParameters = true, queryBuilderMode } = {},
+    {
+      dirty,
+      replaceState,
+      preserveParameters = true,
+      queryBuilderMode,
+      datasetEditorTab,
+    } = {},
   ) => (dispatch, getState) => {
     let question;
     if (!card) {
@@ -252,6 +266,9 @@ export const updateUrl = createThunkAction(
     if (!queryBuilderMode) {
       queryBuilderMode = getQueryBuilderMode(getState());
     }
+    if (!datasetEditorTab) {
+      datasetEditorTab = getDatasetEditorTab(getState());
+    }
 
     const copy = cleanCopyCard(card);
 
@@ -269,6 +286,7 @@ export const updateUrl = createThunkAction(
       pathname: getPathNameFromQueryBuilderMode({
         pathname: urlParsed.pathname || "",
         queryBuilderMode,
+        datasetEditorTab,
       }),
       search: preserveParameters ? window.location.search : "",
       hash: urlParsed.hash,
@@ -282,8 +300,8 @@ export const updateUrl = createThunkAction(
     const isSameCard =
       currentState && currentState.serializedCard === newState.serializedCard;
     const isSameMode =
-      getQueryBuilderModeFromLocation(locationDescriptor) ===
-      getQueryBuilderModeFromLocation(window.location);
+      getQueryBuilderModeFromLocation(locationDescriptor).mode ===
+      getQueryBuilderModeFromLocation(window.location).mode;
 
     if (isSameCard && isSameURL) {
       return;
@@ -343,10 +361,16 @@ export const initializeQB = (location, params, queryParams) => {
 
     const cardId = Urls.extractEntityId(params.slug);
     let card, originalCard;
+
+    const {
+      mode: queryBuilderMode,
+      ...otherUiControls
+    } = getQueryBuilderModeFromLocation(location);
     const uiControls = {
       isEditing: false,
       isShowingTemplateTagsEditor: false,
-      queryBuilderMode: getQueryBuilderModeFromLocation(location),
+      queryBuilderMode,
+      ...otherUiControls,
     };
 
     // load up or initialize the card we'll be working on
@@ -1508,6 +1532,10 @@ export const revertToRevision = createThunkAction(
   },
 );
 
+export const setDatasetEditorTab = datasetEditorTab => dispatch => {
+  dispatch(setQueryBuilderMode("dataset", { datasetEditorTab }));
+};
+
 export const CANCEL_DATASET_CHANGES = "metabase/qb/CANCEL_DATASET_CHANGES";
 export const onCancelDatasetChanges = () => (dispatch, getState) => {
   const cardBeforeChanges = getOriginalCard(getState());
diff --git a/frontend/src/metabase/query_builder/components/DatasetEditor/DatasetEditor.jsx b/frontend/src/metabase/query_builder/components/DatasetEditor/DatasetEditor.jsx
index 69e2d96dceb..e3e468dd74a 100644
--- a/frontend/src/metabase/query_builder/components/DatasetEditor/DatasetEditor.jsx
+++ b/frontend/src/metabase/query_builder/components/DatasetEditor/DatasetEditor.jsx
@@ -1,13 +1,14 @@
-import React from "react";
+import React, { useEffect, useCallback, useMemo, useState } from "react";
 import PropTypes from "prop-types";
+import { connect } from "react-redux";
 import { t } from "ttag";
+import _ from "underscore";
 
 import ActionButton from "metabase/components/ActionButton";
 import Button from "metabase/core/components/Button";
 import DebouncedFrame from "metabase/components/DebouncedFrame";
-import EditBar from "metabase/components/EditBar";
+import Icon from "metabase/components/Icon";
 
-import NativeQueryEditor from "metabase/query_builder/components/NativeQueryEditor";
 import QueryVisualization from "metabase/query_builder/components/QueryVisualization";
 
 import ViewSidebar from "metabase/query_builder/components/view/ViewSidebar";
@@ -15,18 +16,30 @@ import DataReference from "metabase/query_builder/components/dataref/DataReferen
 import TagEditorSidebar from "metabase/query_builder/components/template_tags/TagEditorSidebar";
 import SnippetSidebar from "metabase/query_builder/components/template_tags/SnippetSidebar";
 
-import ResizableNotebook from "./ResizableNotebook";
+import { setDatasetEditorTab } from "metabase/query_builder/actions";
+import { getDatasetEditorTab } from "metabase/query_builder/selectors";
+
+import { isSameField } from "metabase/lib/query/field_ref";
+
+import DatasetFieldMetadataSidebar from "./DatasetFieldMetadataSidebar";
+import DatasetQueryEditor from "./DatasetQueryEditor";
+import EditorTabs from "./EditorTabs";
+
 import {
   Root,
+  DatasetEditBar,
   MainContainer,
   QueryEditorContainer,
+  TableHeaderColumnName,
   TableContainer,
 } from "./DatasetEditor.styled";
 
 const propTypes = {
   question: PropTypes.object.isRequired,
+  datasetEditorTab: PropTypes.oneOf(["query", "metadata"]).isRequired,
   height: PropTypes.number,
   setQueryBuilderMode: PropTypes.func.isRequired,
+  setDatasetEditorTab: PropTypes.func.isRequired,
   onSave: PropTypes.func.isRequired,
   onCancelDatasetChanges: PropTypes.func.isRequired,
   handleResize: PropTypes.func.isRequired,
@@ -41,8 +54,17 @@ const propTypes = {
 };
 
 const INITIAL_NOTEBOOK_EDITOR_HEIGHT = 500;
+const TABLE_HEADER_HEIGHT = 45;
+
+function mapStateToProps(state) {
+  return {
+    datasetEditorTab: getDatasetEditorTab(state),
+  };
+}
 
-function getSidebar(props) {
+const mapDispatchToProps = { setDatasetEditorTab };
+
+function getSidebar(props, { datasetEditorTab, focusedField }) {
   const {
     question: dataset,
     isShowingTemplateTagsEditor,
@@ -52,9 +74,15 @@ function getSidebar(props) {
     toggleDataReference,
     toggleSnippetSidebar,
   } = props;
+
+  if (datasetEditorTab === "metadata") {
+    return <DatasetFieldMetadataSidebar field={focusedField} />;
+  }
+
   if (!dataset.isNative()) {
     return null;
   }
+
   if (isShowingTemplateTagsEditor) {
     return <TagEditorSidebar {...props} onClose={toggleTemplateTagsEditor} />;
   }
@@ -64,39 +92,118 @@ function getSidebar(props) {
   if (isShowingSnippetSidebar) {
     return <SnippetSidebar {...props} onClose={toggleSnippetSidebar} />;
   }
+
   return null;
 }
 
 function DatasetEditor(props) {
   const {
     question: dataset,
+    datasetEditorTab,
     height,
     setQueryBuilderMode,
+    setDatasetEditorTab,
     onCancelDatasetChanges,
+    onSave,
     handleResize,
   } = props;
 
-  const onCancel = () => {
+  const isEditingQuery = datasetEditorTab === "query";
+  const isEditingMetadata = datasetEditorTab === "metadata";
+
+  const [editorHeight, setEditorHeight] = useState(
+    isEditingQuery ? INITIAL_NOTEBOOK_EDITOR_HEIGHT : 0,
+  );
+
+  const [focusedField, setFocusedField] = useState();
+
+  useEffect(() => {
+    const resultMetadata = dataset.getResultMetadata();
+    if (!focusedField && resultMetadata?.length > 0) {
+      setFocusedField(resultMetadata[0]);
+    }
+  }, [dataset, focusedField]);
+
+  const onChangeEditorTab = useCallback(
+    tab => {
+      setDatasetEditorTab(tab);
+      setEditorHeight(tab === "query" ? INITIAL_NOTEBOOK_EDITOR_HEIGHT : 0);
+    },
+    [setDatasetEditorTab],
+  );
+
+  const handleCancel = useCallback(() => {
     onCancelDatasetChanges();
     setQueryBuilderMode("view");
-  };
+  }, [setQueryBuilderMode, onCancelDatasetChanges]);
 
-  const onSave = async () => {
-    await props.onSave(dataset.card());
+  const handleSave = useCallback(async () => {
+    await onSave(dataset.card());
     setQueryBuilderMode("view");
-  };
+  }, [dataset, onSave, setQueryBuilderMode]);
+
+  const sidebar = getSidebar(props, { datasetEditorTab, focusedField });
+
+  const handleTableElementClick = useCallback(
+    ({ element, ...clickedObject }) => {
+      const isColumnClick =
+        clickedObject?.column && Object.keys(clickedObject)?.length === 1;
+
+      if (isColumnClick) {
+        const field = dataset
+          .getResultMetadata()
+          .find(f =>
+            isSameField(clickedObject.column?.field_ref, f?.field_ref),
+          );
+        setFocusedField(field);
+      }
+    },
+    [dataset],
+  );
+
+  const renderSelectableTableColumnHeader = useCallback(
+    (element, column) => (
+      <TableHeaderColumnName
+        isSelected={isSameField(column?.field_ref, focusedField?.field_ref)}
+      >
+        <Icon name="three_dots" size={14} />
+        <span>{column.display_name}</span>
+      </TableHeaderColumnName>
+    ),
+    [focusedField],
+  );
 
-  const sidebar = getSidebar(props);
+  const renderTableHeaderWrapper = useMemo(
+    () =>
+      datasetEditorTab === "metadata"
+        ? renderSelectableTableColumnHeader
+        : undefined,
+    [datasetEditorTab, renderSelectableTableColumnHeader],
+  );
 
   return (
     <React.Fragment>
-      <EditBar
+      <DatasetEditBar
         title={t`You're editing ${dataset.displayName()}`}
+        center={
+          <EditorTabs
+            currentTab={datasetEditorTab}
+            onChange={onChangeEditorTab}
+            options={[
+              { id: "query", name: t`Query`, icon: "notebook" },
+              { id: "metadata", name: t`Metadata`, icon: "label" },
+            ]}
+          />
+        }
         buttons={[
-          <Button key="cancel" onClick={onCancel} small>{t`Cancel`}</Button>,
+          <Button
+            key="cancel"
+            onClick={handleCancel}
+            small
+          >{t`Cancel`}</Button>,
           <ActionButton
             key="save"
-            actionFn={onSave}
+            actionFn={handleSave}
             normalText={t`Save changes`}
             activeText={t`Saving…`}
             failedText={t`Save failed`}
@@ -107,34 +214,25 @@ function DatasetEditor(props) {
       />
       <Root>
         <MainContainer>
-          <QueryEditorContainer>
-            {dataset.isNative() ? (
-              <NativeQueryEditor
-                {...props}
-                isInitiallyOpen
-                viewHeight={height}
-                hasParametersList={false}
-                // We need to rerun the query after saving changes or canceling edits
-                // By default, NativeQueryEditor cancels an active query on unmount,
-                // which can also cancel the expected query rerun
-                // (see https://github.com/metabase/metabase/issues/19180)
-                cancelQueryOnLeave={false}
-              />
-            ) : (
-              <ResizableNotebook
-                {...props}
-                height={INITIAL_NOTEBOOK_EDITOR_HEIGHT}
-                onResizeStop={handleResize}
-              />
-            )}
+          <QueryEditorContainer isResizable={isEditingQuery}>
+            <DatasetQueryEditor
+              {...props}
+              isActive={isEditingQuery}
+              height={editorHeight}
+              viewHeight={height}
+              onResizeStop={handleResize}
+            />
           </QueryEditorContainer>
           <TableContainer isSidebarOpen={!!sidebar}>
-            <DebouncedFrame className="flex-full" enabled={false}>
+            <DebouncedFrame className="flex-full" enabled>
               <QueryVisualization
                 {...props}
                 className="spread"
                 noHeader
-                isVisualizationClickable={false}
+                queryBuilderMode="dataset"
+                handleVisualizationClick={handleTableElementClick}
+                tableHeaderHeight={isEditingMetadata && TABLE_HEADER_HEIGHT}
+                renderTableHeaderWrapper={renderTableHeaderWrapper}
               />
             </DebouncedFrame>
           </TableContainer>
@@ -149,4 +247,4 @@ function DatasetEditor(props) {
 
 DatasetEditor.propTypes = propTypes;
 
-export default DatasetEditor;
+export default connect(mapStateToProps, mapDispatchToProps)(DatasetEditor);
diff --git a/frontend/src/metabase/query_builder/components/DatasetEditor/DatasetEditor.styled.jsx b/frontend/src/metabase/query_builder/components/DatasetEditor/DatasetEditor.styled.jsx
index 4a5021adbb5..d65d7ee4883 100644
--- a/frontend/src/metabase/query_builder/components/DatasetEditor/DatasetEditor.styled.jsx
+++ b/frontend/src/metabase/query_builder/components/DatasetEditor/DatasetEditor.styled.jsx
@@ -1,6 +1,44 @@
 import styled, { css } from "styled-components";
+import EditBar from "metabase/components/EditBar";
 import { color } from "metabase/lib/colors";
-import { breakpointMinSmall } from "metabase/styled-components/theme";
+import { breakpointMinSmall, space } from "metabase/styled-components/theme";
+
+export const DatasetEditBar = styled(EditBar)`
+  background-color: ${color("nav")};
+`;
+
+export const TableHeaderColumnName = styled.div`
+  display: flex;
+  flex-direction: row;
+  align-items: center;
+  min-width: 35px;
+
+  margin: 24px 0.75em;
+  padding: ${space(0)} ${space(1)};
+
+  white-space: nowrap;
+  text-overflow: ellipsis;
+  overflow-x: hidden;
+
+  color: ${color("brand")};
+  background-color: transparent;
+  font-weight: bold;
+  cursor: pointer;
+
+  border: 1px solid ${color("brand")};
+  border-radius: 8px;
+
+  ${props =>
+    props.isSelected &&
+    css`
+      color: ${color("text-white")};
+      background-color: ${color("brand")};
+    `}
+
+  .Icon {
+    margin-right: 4px;
+  }
+`;
 
 // Mirrors styling of some QB View div elements
 
@@ -19,10 +57,15 @@ export const MainContainer = styled.div`
 `;
 
 export const QueryEditorContainer = styled.div`
-  margin-bottom: 1rem;
-  border-bottom: 1px solid ${color("border")};
   z-index: 2;
   width: 100%;
+
+  ${props =>
+    props.isResizable &&
+    css`
+      margin-bottom: 1rem;
+      border-bottom: 1px solid ${color("border")};
+    `}
 `;
 
 const tableVisibilityStyle = css`
diff --git a/frontend/src/metabase/query_builder/components/DatasetEditor/DatasetFieldMetadataSidebar/DatasetFieldMetadataSidebar.jsx b/frontend/src/metabase/query_builder/components/DatasetEditor/DatasetFieldMetadataSidebar/DatasetFieldMetadataSidebar.jsx
new file mode 100644
index 00000000000..707bf7dbf26
--- /dev/null
+++ b/frontend/src/metabase/query_builder/components/DatasetEditor/DatasetFieldMetadataSidebar/DatasetFieldMetadataSidebar.jsx
@@ -0,0 +1,130 @@
+import React, { useMemo } from "react";
+import PropTypes from "prop-types";
+import { t } from "ttag";
+
+import Field from "metabase-lib/lib/metadata/Field";
+import {
+  field_visibility_types,
+  field_semantic_types,
+  has_field_values_options,
+} from "metabase/lib/core";
+
+import RootForm from "metabase/containers/Form";
+
+import SidebarContent from "metabase/query_builder/components/SidebarContent";
+
+import { PaddedContent, Divider } from "./DatasetFieldMetadataSidebar.styled";
+
+const propTypes = {
+  field: PropTypes.instanceOf(Field).isRequired,
+};
+
+function getVisibilityTypeName(visibilityType) {
+  if (visibilityType.id === "normal") {
+    return t`Table and details views`;
+  }
+  if (visibilityType.id === "details-only") {
+    return t`Detail views only`;
+  }
+  return visibilityType.name;
+}
+
+function getFieldSemanticTypes() {
+  return [
+    ...field_semantic_types,
+    {
+      id: null,
+      name: t`No special type`,
+      section: t`Other`,
+    },
+  ];
+}
+
+const FORM_FIELDS = [
+  { name: "display_name", title: t`Display name` },
+  {
+    name: "description",
+    title: t`Description`,
+    placeholder: t`It’s optional, but oh, so helpful`,
+    type: "text",
+  },
+  {
+    name: "semantic_type",
+    type: "select",
+    options: getFieldSemanticTypes().map(type => ({
+      name: type.name,
+      value: type.id,
+    })),
+  },
+  {
+    name: "visibility_type",
+    title: t`This column should appear in…`,
+    type: "radio",
+    options: field_visibility_types
+      .filter(type => type.id !== "sensitive")
+      .map(type => ({
+        name: getVisibilityTypeName(type),
+        value: type.id,
+      })),
+  },
+  {
+    name: "display_as",
+    title: t`Display as`,
+    type: "radio",
+    options: [
+      { name: t`Text`, value: "text" },
+      { name: t`Link`, value: "link" },
+    ],
+  },
+  {
+    name: "has_field_values",
+    title: t`Filtering on this field`,
+    info: t`When this field is used in a filter, what should people use to enter the value they want to filter on?`,
+    type: "select",
+    options: has_field_values_options,
+  },
+];
+
+function DatasetFieldMetadataSidebar({ field }) {
+  const initialValues = useMemo(
+    () => ({
+      display_name: field?.display_name,
+      description: field?.description,
+      semantic_type: field?.semantic_type,
+      visibility_type: "normal",
+      display_as: "text",
+      has_field_values: "search",
+    }),
+    [field],
+  );
+
+  return (
+    <SidebarContent>
+      <PaddedContent>
+        {field && (
+          <RootForm
+            fields={FORM_FIELDS}
+            initialValues={initialValues}
+            overwriteOnInitialValuesChange
+          >
+            {({ Form, FormField }) => (
+              <Form>
+                <FormField name="display_name" />
+                <FormField name="description" />
+                <FormField name="semantic_type" />
+                <Divider />
+                <FormField name="visibility_type" />
+                <FormField name="display_as" />
+                <FormField name="has_field_values" />
+              </Form>
+            )}
+          </RootForm>
+        )}
+      </PaddedContent>
+    </SidebarContent>
+  );
+}
+
+DatasetFieldMetadataSidebar.propTypes = propTypes;
+
+export default DatasetFieldMetadataSidebar;
diff --git a/frontend/src/metabase/query_builder/components/DatasetEditor/DatasetFieldMetadataSidebar/DatasetFieldMetadataSidebar.styled.jsx b/frontend/src/metabase/query_builder/components/DatasetEditor/DatasetFieldMetadataSidebar/DatasetFieldMetadataSidebar.styled.jsx
new file mode 100644
index 00000000000..151adfd1d08
--- /dev/null
+++ b/frontend/src/metabase/query_builder/components/DatasetEditor/DatasetFieldMetadataSidebar/DatasetFieldMetadataSidebar.styled.jsx
@@ -0,0 +1,13 @@
+import styled from "styled-components";
+import { color } from "metabase/lib/colors";
+
+export const PaddedContent = styled.div`
+  padding: 24px;
+`;
+
+export const Divider = styled.div`
+  height: 1px;
+  width: 100%;
+  background-color: ${color("bg-medium")};
+  margin-bottom: 21px;
+`;
diff --git a/frontend/src/metabase/query_builder/components/DatasetEditor/DatasetFieldMetadataSidebar/index.js b/frontend/src/metabase/query_builder/components/DatasetEditor/DatasetFieldMetadataSidebar/index.js
new file mode 100644
index 00000000000..951653c81b9
--- /dev/null
+++ b/frontend/src/metabase/query_builder/components/DatasetEditor/DatasetFieldMetadataSidebar/index.js
@@ -0,0 +1 @@
+export { default } from "./DatasetFieldMetadataSidebar";
diff --git a/frontend/src/metabase/query_builder/components/DatasetEditor/DatasetQueryEditor.jsx b/frontend/src/metabase/query_builder/components/DatasetEditor/DatasetQueryEditor.jsx
new file mode 100644
index 00000000000..ebfcbc04614
--- /dev/null
+++ b/frontend/src/metabase/query_builder/components/DatasetEditor/DatasetQueryEditor.jsx
@@ -0,0 +1,81 @@
+import React, { useMemo, useState } from "react";
+import PropTypes from "prop-types";
+import NativeQueryEditor from "metabase/query_builder/components/NativeQueryEditor";
+import { isReducedMotionPreferred } from "metabase/lib/dom";
+import ResizableNotebook from "./ResizableNotebook";
+
+const SMOOTH_RESIZE_STYLE = { transition: "height 0.25s" };
+
+const propTypes = {
+  question: PropTypes.object.isRequired,
+  isActive: PropTypes.bool.isRequired, // if QB mode is set to "query"
+  height: PropTypes.number.isRequired,
+};
+
+function DatasetQueryEditor({ question: dataset, isActive, height, ...props }) {
+  const [isResizing, setResizing] = useState(false);
+
+  const resizableBoxProps = useMemo(() => {
+    // Disables resizing by removing a handle in "metadata" mode
+    const resizeHandles = isActive ? ["s"] : [];
+
+    // The editor can change its size in two cases:
+    // 1. By manually resizing the window with a handle
+    // 2. Automatically when editor mode is changed between "query" and "metadata"
+    // For the 2nd case, we're smoothing the resize effect by adding a `transition` style
+    // For the 1st case, we need to make sure it's not included, so resizing doesn't lag
+    const style =
+      isResizing || isReducedMotionPreferred()
+        ? undefined
+        : SMOOTH_RESIZE_STYLE;
+
+    const resizableBoxProps = {
+      height,
+      resizeHandles,
+      onResizeStart: () => setResizing(true),
+      onResizeStop: () => setResizing(false),
+      style,
+    };
+
+    if (!isActive) {
+      // Overwrites native query editor's resizable area constraints,
+      // so the automatic "close" animation doesn't get stuck
+      resizableBoxProps.minConstraints = [0, 0];
+    }
+
+    return resizableBoxProps;
+  }, [height, isResizing, isActive]);
+
+  return dataset.isNative() ? (
+    <NativeQueryEditor
+      {...props}
+      question={dataset}
+      isInitiallyOpen
+      hasTopBar={isActive}
+      hasEditingSidebar={isActive}
+      hasParametersList={false}
+      resizableBoxProps={resizableBoxProps}
+      // We need to rerun the query after saving changes or canceling edits
+      // By default, NativeQueryEditor cancels an active query on unmount,
+      // which can also cancel the expected query rerun
+      // (see https://github.com/metabase/metabase/issues/19180)
+      cancelQueryOnLeave={false}
+    />
+  ) : (
+    <ResizableNotebook
+      {...props}
+      question={dataset}
+      isResizing={isResizing}
+      resizableBoxProps={resizableBoxProps}
+    />
+  );
+}
+
+DatasetQueryEditor.propTypes = propTypes;
+
+export default React.memo(
+  DatasetQueryEditor,
+  // should prevent the editor from re-rendering in "metadata" mode
+  // when it's completely covered with the results table
+  (prevProps, nextProps) => prevProps.height === 0 && nextProps.height === 0,
+);
diff --git a/frontend/src/metabase/query_builder/components/DatasetEditor/EditorTabs/EditorTabs.styled.tsx b/frontend/src/metabase/query_builder/components/DatasetEditor/EditorTabs/EditorTabs.styled.tsx
new file mode 100644
index 00000000000..203261b1bfd
--- /dev/null
+++ b/frontend/src/metabase/query_builder/components/DatasetEditor/EditorTabs/EditorTabs.styled.tsx
@@ -0,0 +1,68 @@
+import styled, { css } from "styled-components";
+import { alpha, darken, color } from "metabase/lib/colors";
+
+export const TabBar = styled.ul`
+  display: flex;
+  flex-direction: row;
+  justify-content: center;
+  align-items: center;
+  gap: 16px;
+`;
+
+function getActiveTabColor() {
+  return darken("nav");
+}
+
+function getInactiveTabColor() {
+  const active = getActiveTabColor();
+  return alpha(active, 0.3);
+}
+
+const inactiveTabCSS = css`
+  border-color: ${getInactiveTabColor()};
+
+  :hover {
+    background-color: ${getInactiveTabColor()};
+  }
+`;
+
+const activeTabCSS = css`
+  background-color: ${getActiveTabColor()};
+  border-color: ${getActiveTabColor()};
+`;
+
+export const Tab = styled.label<{ selected: boolean }>`
+  display: flex;
+  flex-direction: row;
+  justify-content: center;
+  align-items: center;
+  padding: 6px 12px;
+
+  color: ${color("text-white")};
+  font-weight: bold;
+
+  border: 2px solid;
+  border-radius: 8px;
+  cursor: pointer;
+
+  transition: all 0.3s;
+
+  .Icon {
+    margin-right: 10px;
+  }
+
+  ${props => (props.selected ? activeTabCSS : inactiveTabCSS)};
+`;
+
+export const RadioInput = styled.input.attrs({ type: "radio" })`
+  cursor: inherit;
+  position: absolute;
+  opacity: 0;
+  width: 0;
+  height: 0;
+  top: 0;
+  left: 0;
+  margin: 0;
+  padding: 0;
+  z-index: 1;
+`;
diff --git a/frontend/src/metabase/query_builder/components/DatasetEditor/EditorTabs/EditorTabs.tsx b/frontend/src/metabase/query_builder/components/DatasetEditor/EditorTabs/EditorTabs.tsx
new file mode 100644
index 00000000000..5e8b9edf5a1
--- /dev/null
+++ b/frontend/src/metabase/query_builder/components/DatasetEditor/EditorTabs/EditorTabs.tsx
@@ -0,0 +1,49 @@
+import React from "react";
+import _ from "underscore";
+
+import Icon from "metabase/components/Icon";
+import { TabBar, Tab, RadioInput } from "./EditorTabs.styled";
+
+type Props = {
+  currentTab: string;
+  options: {
+    id: string;
+    name: string;
+    icon: string;
+  }[];
+  onChange: (optionId: string) => void;
+};
+
+function EditorTabs({ currentTab, options, onChange, ...props }: Props) {
+  const inputId = "editor-tabs";
+
+  return (
+    <TabBar {...props}>
+      {options.map(option => {
+        const selected = currentTab === option.id;
+        const id = `${inputId}-${option.id}`;
+        const labelId = `${id}-label`;
+        return (
+          <li key={option.id}>
+            <Tab id={labelId} htmlFor={id} selected={selected}>
+              <Icon name={option.icon} />
+              <RadioInput
+                id={id}
+                name={inputId}
+                value={option.id}
+                checked={selected}
+                onChange={() => {
+                  onChange(option.id);
+                }}
+                aria-labelledby={labelId}
+              />
+              <span data-testid={`${id}-name`}>{option.name}</span>
+            </Tab>
+          </li>
+        );
+      })}
+    </TabBar>
+  );
+}
+
+export default EditorTabs;
diff --git a/frontend/src/metabase/query_builder/components/DatasetEditor/EditorTabs/index.ts b/frontend/src/metabase/query_builder/components/DatasetEditor/EditorTabs/index.ts
new file mode 100644
index 00000000000..82ed4e893b9
--- /dev/null
+++ b/frontend/src/metabase/query_builder/components/DatasetEditor/EditorTabs/index.ts
@@ -0,0 +1 @@
+export { default } from "./EditorTabs";
diff --git a/frontend/src/metabase/query_builder/components/DatasetEditor/ResizableNotebook/ResizableNotebook.jsx b/frontend/src/metabase/query_builder/components/DatasetEditor/ResizableNotebook/ResizableNotebook.jsx
index 37dedd9214d..88becd3bcef 100644
--- a/frontend/src/metabase/query_builder/components/DatasetEditor/ResizableNotebook/ResizableNotebook.jsx
+++ b/frontend/src/metabase/query_builder/components/DatasetEditor/ResizableNotebook/ResizableNotebook.jsx
@@ -1,4 +1,4 @@
-import React, { useState } from "react";
+import React from "react";
 import PropTypes from "prop-types";
 import { ResizableBox } from "react-resizable";
 
@@ -7,24 +7,25 @@ import Notebook from "metabase/query_builder/components/notebook/Notebook";
 import { NotebookContainer, Handle } from "./ResizableNotebook.styled";
 
 const propTypes = {
-  height: PropTypes.number.isRequired,
+  isResizing: PropTypes.bool.isRequired,
+  resizableBoxProps: PropTypes.object.isRequired,
   onResizeStop: PropTypes.func.isRequired,
 };
 
-function ResizableNotebook({ height, onResizeStop, ...notebookProps }) {
-  const [isResizing, setResizing] = useState(false);
+function ResizableNotebook({
+  isResizing,
+  onResizeStop,
+  resizableBoxProps,
+  ...notebookProps
+}) {
   return (
     <ResizableBox
       className="border-top flex"
       axis="y"
-      resizeHandles={["s"]}
-      height={height}
       handle={<Handle />}
-      onResizeStart={() => {
-        setResizing(true);
-      }}
+      {...resizableBoxProps}
       onResizeStop={(...args) => {
-        setResizing(false);
+        resizableBoxProps.onResizeStop(...args);
         onResizeStop(...args);
       }}
     >
diff --git a/frontend/src/metabase/query_builder/components/NativeQueryEditor.jsx b/frontend/src/metabase/query_builder/components/NativeQueryEditor.jsx
index db3f06a3c1a..77a8981b94e 100644
--- a/frontend/src/metabase/query_builder/components/NativeQueryEditor.jsx
+++ b/frontend/src/metabase/query_builder/components/NativeQueryEditor.jsx
@@ -411,6 +411,9 @@ export default class NativeQueryEditor extends Component {
       isNativeEditorOpen,
       openSnippetModalWithSelectedText,
       hasParametersList = true,
+      hasTopBar = true,
+      hasEditingSidebar = true,
+      resizableBoxProps = {},
     } = this.props;
 
     const parameters = query.question().parameters();
@@ -423,32 +426,34 @@ export default class NativeQueryEditor extends Component {
 
     return (
       <div className="NativeQueryEditor bg-light full">
-        <div className="flex align-center" style={{ minHeight: 55 }}>
-          <DataSourceSelectors
-            isNativeEditorOpen={isNativeEditorOpen}
-            query={query}
-            readOnly={readOnly}
-            setDatabaseId={this.setDatabaseId}
-            setTableId={this.setTableId}
-          />
-          {hasParametersList && (
-            <SyncedParametersList
-              className="mt1"
-              parameters={parameters}
-              setParameterValue={setParameterValue}
-              setParameterIndex={this.setParameterIndex}
-              isEditing
-              commitImmediately
-            />
-          )}
-          {query.hasWritePermission() && (
-            <VisibilityToggler
-              isOpen={isNativeEditorOpen}
+        {hasTopBar && (
+          <div className="flex align-center" style={{ minHeight: 55 }}>
+            <DataSourceSelectors
+              isNativeEditorOpen={isNativeEditorOpen}
+              query={query}
               readOnly={readOnly}
-              toggleEditor={this.toggleEditor}
+              setDatabaseId={this.setDatabaseId}
+              setTableId={this.setTableId}
             />
-          )}
-        </div>
+            {hasParametersList && (
+              <SyncedParametersList
+                className="mt1"
+                parameters={parameters}
+                setParameterValue={setParameterValue}
+                setParameterIndex={this.setParameterIndex}
+                isEditing
+                commitImmediately
+              />
+            )}
+            {query.hasWritePermission() && (
+              <VisibilityToggler
+                isOpen={isNativeEditorOpen}
+                readOnly={readOnly}
+                toggleEditor={this.toggleEditor}
+              />
+            )}
+          </div>
+        )}
         <ResizableBox
           ref={this.resizeBox}
           className={cx("border-top flex ", { hide: !isNativeEditorOpen })}
@@ -456,11 +461,13 @@ export default class NativeQueryEditor extends Component {
           minConstraints={[Infinity, getEditorLineHeight(MIN_HEIGHT_LINES)]}
           axis="y"
           handle={dragHandle}
+          resizeHandles={["s"]}
+          {...resizableBoxProps}
           onResizeStop={(e, data) => {
             this.props.handleResize();
+            resizableBoxProps?.onResizeStop(e, data);
             this._editor.resize();
           }}
-          resizeHandles={["s"]}
         >
           <div className="flex-full" id="id_sql" ref={this.editor} />
 
@@ -485,7 +492,7 @@ export default class NativeQueryEditor extends Component {
               closeModal={this.props.closeSnippetModal}
             />
           )}
-          {!readOnly && (
+          {hasEditingSidebar && !readOnly && (
             <NativeQueryEditorSidebar
               runQuery={this.runQuery}
               {...this.props}
diff --git a/frontend/src/metabase/query_builder/components/VisualizationResult.jsx b/frontend/src/metabase/query_builder/components/VisualizationResult.jsx
index 5095c87db0c..d18f875074f 100644
--- a/frontend/src/metabase/query_builder/components/VisualizationResult.jsx
+++ b/frontend/src/metabase/query_builder/components/VisualizationResult.jsx
@@ -2,6 +2,7 @@
 import React, { Component } from "react";
 import { t, jt } from "ttag";
 import cx from "classnames";
+import _ from "underscore";
 
 import ErrorMessage from "metabase/components/ErrorMessage";
 import Visualization from "metabase/visualizations/components/Visualization";
@@ -10,6 +11,12 @@ import { CreateAlertModalContent } from "metabase/query_builder/components/Alert
 import Modal from "metabase/components/Modal";
 import { ALERT_TYPE_ROWS } from "metabase-lib/lib/Alert";
 
+const ALLOWED_VISUALIZATION_PROPS = [
+  // Table Interactive
+  "tableHeaderHeight",
+  "renderTableHeaderWrapper",
+];
+
 export default class VisualizationResult extends Component {
   state = {
     showCreateAlertModal: false,
@@ -27,7 +34,7 @@ export default class VisualizationResult extends Component {
     const {
       question,
       isDirty,
-      isVisualizationClickable,
+      queryBuilderMode,
       navigateToNewCardInsideQB,
       result,
       rawSeries,
@@ -77,6 +84,10 @@ export default class VisualizationResult extends Component {
         </div>
       );
     } else {
+      const vizSpecificProps = _.pick(
+        this.props,
+        ...ALLOWED_VISUALIZATION_PROPS,
+      );
       return (
         <Visualization
           className={className}
@@ -84,15 +95,17 @@ export default class VisualizationResult extends Component {
           onChangeCardAndRun={navigateToNewCardInsideQB}
           isEditing={true}
           isQueryBuilder={true}
+          queryBuilderMode={queryBuilderMode}
           showTitle={false}
-          isClickable={isVisualizationClickable}
           metadata={question.metadata()}
+          handleVisualizationClick={this.props.handleVisualizationClick}
           onOpenChartSettings={this.props.onOpenChartSettings}
           onUpdateWarnings={this.props.onUpdateWarnings}
           onUpdateVisualizationSettings={
             this.props.onUpdateVisualizationSettings
           }
           query={this.props.query}
+          {...vizSpecificProps}
         />
       );
     }
diff --git a/frontend/src/metabase/query_builder/components/view/sidebars/DatasetManagementSection/DatasetManagementSection.jsx b/frontend/src/metabase/query_builder/components/view/sidebars/DatasetManagementSection/DatasetManagementSection.jsx
index 072d2297fa4..1257c1acec1 100644
--- a/frontend/src/metabase/query_builder/components/view/sidebars/DatasetManagementSection/DatasetManagementSection.jsx
+++ b/frontend/src/metabase/query_builder/components/view/sidebars/DatasetManagementSection/DatasetManagementSection.jsx
@@ -36,7 +36,15 @@ function DatasetManagementSection({
   turnDatasetIntoQuestion,
 }) {
   const onEditQueryDefinitionClick = () => {
-    setQueryBuilderMode("dataset");
+    setQueryBuilderMode("dataset", {
+      datasetEditorTab: "query",
+    });
+  };
+
+  const onCustomizeMetadataClick = () => {
+    setQueryBuilderMode("dataset", {
+      datasetEditorTab: "metadata",
+    });
   };
 
   return (
@@ -48,7 +56,10 @@ function DatasetManagementSection({
           onClick={onEditQueryDefinitionClick}
         >{t`Edit query definition`}</Button>
         <Row>
-          <Button icon="label">{t`Customize metadata`}</Button>
+          <Button
+            icon="label"
+            onClick={onCustomizeMetadataClick}
+          >{t`Customize Metadata`}</Button>
           <MetadataIndicatorContainer>
             <DatasetMetadataStrengthIndicator dataset={dataset} />
           </MetadataIndicatorContainer>
diff --git a/frontend/src/metabase/query_builder/reducers.js b/frontend/src/metabase/query_builder/reducers.js
index 31ac842d5d0..625e9338349 100644
--- a/frontend/src/metabase/query_builder/reducers.js
+++ b/frontend/src/metabase/query_builder/reducers.js
@@ -68,6 +68,7 @@ const DEFAULT_UI_CONTROLS = {
   isShowingRawTable: false, // table/viz toggle
   queryBuilderMode: false, // "view" | "notebook" | "dataset"
   snippetCollectionId: null,
+  datasetEditorTab: "query", // "query" / "metadata"
 };
 
 const UI_CONTROLS_SIDEBAR_DEFAULTS = {
diff --git a/frontend/src/metabase/query_builder/selectors.js b/frontend/src/metabase/query_builder/selectors.js
index 12f07d23edd..98911ce8866 100644
--- a/frontend/src/metabase/query_builder/selectors.js
+++ b/frontend/src/metabase/query_builder/selectors.js
@@ -171,6 +171,11 @@ export const getQueryBuilderMode = createSelector(
   uiControls => uiControls.queryBuilderMode,
 );
 
+export const getDatasetEditorTab = createSelector(
+  [getUiControls],
+  uiControls => uiControls.datasetEditorTab,
+);
+
 export const getOriginalQuestion = createSelector(
   [getMetadata, getOriginalCard],
   (metadata, card) => metadata && card && new Question(card, metadata),
diff --git a/frontend/src/metabase/query_builder/utils.js b/frontend/src/metabase/query_builder/utils.js
index f06f0783537..613a74d85f3 100644
--- a/frontend/src/metabase/query_builder/utils.js
+++ b/frontend/src/metabase/query_builder/utils.js
@@ -7,23 +7,31 @@ import NativeQuery from "metabase-lib/lib/queries/NativeQuery";
 export function getQueryBuilderModeFromLocation(location) {
   const { pathname } = location;
   if (pathname.endsWith("/notebook")) {
-    return "notebook";
+    return {
+      mode: "notebook",
+    };
   }
-  if (pathname.endsWith("/query")) {
-    return "dataset";
+  if (pathname.endsWith("/query") || pathname.endsWith("/metadata")) {
+    return {
+      mode: "dataset",
+      datasetEditorTab: pathname.endsWith("/query") ? "query" : "metadata",
+    };
   }
-  return "view";
+  return {
+    mode: "view",
+  };
 }
 
 export function getPathNameFromQueryBuilderMode({
   pathname,
   queryBuilderMode,
+  datasetEditorTab = "query",
 }) {
   if (queryBuilderMode === "view") {
     return pathname;
   }
   if (queryBuilderMode === "dataset") {
-    return `${pathname}/query`;
+    return `${pathname}/${datasetEditorTab}`;
   }
   return `${pathname}/${queryBuilderMode}`;
 }
diff --git a/frontend/src/metabase/routes.jsx b/frontend/src/metabase/routes.jsx
index 095c058696d..76e4574cd2b 100644
--- a/frontend/src/metabase/routes.jsx
+++ b/frontend/src/metabase/routes.jsx
@@ -242,6 +242,7 @@ export const getRoutes = store => (
           <Route path=":slug" component={QueryBuilder} />
           <Route path=":slug/notebook" component={QueryBuilder} />
           <Route path=":slug/query" component={QueryBuilder} />
+          <Route path=":slug/metadata" component={QueryBuilder} />
         </Route>
 
         <Route path="browse" component={BrowseApp}>
diff --git a/frontend/src/metabase/visualizations/components/TableInteractive.jsx b/frontend/src/metabase/visualizations/components/TableInteractive.jsx
index 17fb4ab939a..d772dbada77 100644
--- a/frontend/src/metabase/visualizations/components/TableInteractive.jsx
+++ b/frontend/src/metabase/visualizations/components/TableInteractive.jsx
@@ -134,7 +134,14 @@ export default class TableInteractive extends Component {
   }
 
   shouldComponentUpdate(nextProps, nextState) {
-    const PROP_KEYS = ["width", "height", "settings", "data", "clicked"];
+    const PROP_KEYS = [
+      "width",
+      "height",
+      "settings",
+      "data",
+      "clicked",
+      "renderTableHeaderWrapper",
+    ];
     // compare specific props and state to determine if we should re-render
     return (
       !_.isEqual(
diff --git a/frontend/src/metabase/visualizations/components/Visualization.jsx b/frontend/src/metabase/visualizations/components/Visualization.jsx
index 4d1fc1c7dab..84a38b537cb 100644
--- a/frontend/src/metabase/visualizations/components/Visualization.jsx
+++ b/frontend/src/metabase/visualizations/components/Visualization.jsx
@@ -65,7 +65,6 @@ export default class Visualization extends React.PureComponent {
     isEditing: false,
     isSettings: false,
     isQueryBuilder: false,
-    isClickable: true,
     onUpdateVisualizationSettings: () => {},
     // prefer passing in a function that doesn't cause the application to reload
     onChangeLocation: location => {
@@ -184,12 +183,17 @@ export default class Visualization extends React.PureComponent {
     if (!metadata || !card) {
       return;
     }
+    const { isQueryBuilder, queryBuilderMode } = this.props;
     const question = new Question(card, metadata);
 
     // Datasets in QB should behave as raw tables opened in simple mode
     // composeDataset replaces the dataset_query with a clean query using the dataset as a source table
     // Ideally, this logic should happen somewhere else
-    return question.isDataset() ? question.composeDataset() : question;
+    return question.isDataset() &&
+      isQueryBuilder &&
+      queryBuilderMode !== "dataset"
+      ? question.composeDataset()
+      : question;
   }
 
   getClickActions(clicked) {
@@ -214,8 +218,8 @@ export default class Visualization extends React.PureComponent {
   }
 
   visualizationIsClickable = clicked => {
-    const { onChangeCardAndRun, isClickable } = this.props;
-    if (!onChangeCardAndRun || !isClickable) {
+    const { onChangeCardAndRun } = this.props;
+    if (!onChangeCardAndRun) {
       return false;
     }
     try {
@@ -227,6 +231,8 @@ export default class Visualization extends React.PureComponent {
   };
 
   handleVisualizationClick = clicked => {
+    const { handleVisualizationClick } = this.props;
+
     if (clicked) {
       MetabaseAnalytics.trackStructEvent(
         "Actions",
@@ -237,6 +243,11 @@ export default class Visualization extends React.PureComponent {
       );
     }
 
+    if (typeof handleVisualizationClick === "function") {
+      handleVisualizationClick(clicked);
+      return;
+    }
+
     if (
       performDefaultAction(this.getClickActions(clicked), {
         dispatch: this.props.dispatch,
-- 
GitLab