From 6bdd27c6059e087047aacf003f16dec07c4ee48c Mon Sep 17 00:00:00 2001
From: Anton Kulyk <kuliks.anton@gmail.com>
Date: Tue, 17 Aug 2021 19:04:55 +0300
Subject: [PATCH] Refactor notebook editor's join step (#17445)

* Fix imports order

* Extract JoinClausesContainer

* Refactor JoinClause container element

* Extract JoinTypePicker component

* Refactor JoinStepPicker

* Extract JoinTablePicker

* Extract JoinedTableControlRoot

* Extract label components

* Extract RemoveJoinIcon

* Add prop types

* Turn JoinClause into func component, fix ref usage

* Update notebook's step prop-types shape

* Extract callbacks
---
 .../components/notebook/steps/JoinStep.jsx    | 572 +++++++++++-------
 .../notebook/steps/JoinStep.styled.js         |  82 +++
 2 files changed, 439 insertions(+), 215 deletions(-)
 create mode 100644 frontend/src/metabase/query_builder/components/notebook/steps/JoinStep.styled.js

diff --git a/frontend/src/metabase/query_builder/components/notebook/steps/JoinStep.jsx b/frontend/src/metabase/query_builder/components/notebook/steps/JoinStep.jsx
index f8175ed5b4b..820f5a0d72f 100644
--- a/frontend/src/metabase/query_builder/components/notebook/steps/JoinStep.jsx
+++ b/frontend/src/metabase/query_builder/components/notebook/steps/JoinStep.jsx
@@ -1,23 +1,59 @@
-/* eslint-disable react/prop-types */
-import React from "react";
-
-import { Flex } from "grid-styled";
-import cx from "classnames";
+import React, { useRef } from "react";
+import PropTypes from "prop-types";
 import _ from "underscore";
 import { t } from "ttag";
 
+import PopoverWithTrigger from "metabase/components/PopoverWithTrigger";
+
+import { DatabaseSchemaAndTableDataSelector } from "metabase/query_builder/components/DataSelector";
+import FieldList from "metabase/query_builder/components/FieldList";
+import Join from "metabase-lib/lib/queries/structured/Join";
+
 import {
   NotebookCell,
   NotebookCellItem,
   NotebookCellAdd,
 } from "../NotebookCell";
+import FieldsPicker from "./FieldsPicker";
+import {
+  JoinClausesContainer,
+  JoinClauseContainer,
+  JoinClauseRoot,
+  JoinStrategyIcon,
+  JoinTypeSelectRoot,
+  JoinTypeOptionRoot,
+  JoinTypeIcon,
+  JoinedTableControlRoot,
+  JoinWhereConditionLabel,
+  JoinOnConditionLabel,
+  RemoveJoinIcon,
+} from "./JoinStep.styled";
 
-import Icon from "metabase/components/Icon";
-import PopoverWithTrigger from "metabase/components/PopoverWithTrigger";
+const stepShape = {
+  id: PropTypes.string.isRequired,
+  type: PropTypes.string.isRequired,
+  query: PropTypes.object.isRequired,
+  previewQuery: PropTypes.object,
+  valid: PropTypes.bool.isRequired,
+  visible: PropTypes.bool.isRequired,
+  stageIndex: PropTypes.number.isRequired,
+  itemIndex: PropTypes.number.isRequired,
+  update: PropTypes.func.isRequired,
+  revert: PropTypes.func.isRequired,
+  clean: PropTypes.func.isRequired,
+  actions: PropTypes.array.isRequired,
 
-import { DatabaseSchemaAndTableDataSelector } from "metabase/query_builder/components/DataSelector";
-import FieldList from "metabase/query_builder/components/FieldList";
-import Join from "metabase-lib/lib/queries/structured/Join";
+  previous: stepShape,
+  next: stepShape,
+};
+
+const joinStepPropTypes = {
+  query: PropTypes.object.isRequired,
+  step: PropTypes.shape(stepShape).isRequired,
+  color: PropTypes.string,
+  isLastOpened: PropTypes.bool,
+  updateQuery: PropTypes.func.isRequired,
+};
 
 export default function JoinStep({
   color,
@@ -25,7 +61,6 @@ export default function JoinStep({
   step,
   updateQuery,
   isLastOpened,
-  ...props
 }) {
   const isSingleJoinStep = step.itemIndex != null;
   let joins = query.joins();
@@ -37,190 +72,273 @@ export default function JoinStep({
     joins = [new Join({ fields: "all" }, query.joins().length, query)];
   }
   const valid = _.all(joins, join => join.isValid());
+
+  function addNewJoinClause() {
+    query.join(new Join({ fields: "all" })).update(updateQuery);
+  }
+
   return (
     <NotebookCell color={color} flexWrap="nowrap">
-      <Flex flexDirection="column" className="flex-full">
-        {joins.map((join, index) => (
-          <JoinClause
-            mb={index === joins.length - 1 ? 0 : 2}
-            key={index}
-            color={color}
-            join={join}
-            showRemove={joins.length > 1}
-            updateQuery={updateQuery}
-            isLastOpened={isLastOpened && index === join.length - 1}
-          />
-        ))}
-      </Flex>
+      <JoinClausesContainer>
+        {joins.map((join, index) => {
+          const isLast = index === joins.length - 1;
+          return (
+            <JoinClauseContainer key={index} isLast={isLast}>
+              <JoinClause
+                join={join}
+                color={color}
+                showRemove={joins.length > 1}
+                updateQuery={updateQuery}
+                isLastOpened={isLastOpened && isLast}
+              />
+            </JoinClauseContainer>
+          );
+        })}
+      </JoinClausesContainer>
       {!isSingleJoinStep && valid && (
         <NotebookCellAdd
           color={color}
           className="cursor-pointer ml-auto"
-          onClick={() => {
-            query.join(new Join({ fields: "all" })).update(updateQuery);
-          }}
+          onClick={addNewJoinClause}
         />
       )}
     </NotebookCell>
   );
 }
 
-class JoinClause extends React.Component {
-  render() {
-    const { color, join, updateQuery, showRemove, ...props } = this.props;
-    const query = join.query();
-    if (!query) {
-      return null;
+JoinStep.propTypes = joinStepPropTypes;
+
+const joinClausePropTypes = {
+  color: PropTypes.string,
+  join: PropTypes.object,
+  updateQuery: PropTypes.func,
+  showRemove: PropTypes.bool,
+};
+
+function JoinClause({ color, join, updateQuery, showRemove }) {
+  const joinDimensionPickerRef = useRef();
+  const parentDimensionPickerRef = useRef();
+
+  const query = join.query();
+  if (!query) {
+    return null;
+  }
+
+  let lhsTable;
+  if (join.index() === 0) {
+    // first join's lhs is always the parent table
+    lhsTable = join.parentTable();
+  } else if (join.parentDimension()) {
+    // subsequent can be one of the previously joined tables
+    // NOTE: `lhsDimension` would probably be a better name for `parentDimension`
+    lhsTable = join.parentDimension().field().table;
+  }
+
+  const joinedTable = join.joinedTable();
+
+  function onSourceTableSet(newJoin) {
+    if (!newJoin.parentDimension()) {
+      setTimeout(() => {
+        parentDimensionPickerRef.current.open();
+      });
     }
+  }
 
-    let lhsTable;
-    if (join.index() === 0) {
-      // first join's lhs is always the parent table
-      lhsTable = join.parentTable();
-    } else if (join.parentDimension()) {
-      // subsequent can be one of the previously joined tables
-      // NOTE: `lhsDimension` would probably be a better name for `parentDimension`
-      lhsTable = join.parentDimension().field().table;
+  function onParentDimensionChange(fieldRef) {
+    join
+      .setParentDimension(fieldRef)
+      .setDefaultAlias()
+      .parent()
+      .update(updateQuery);
+    if (!join.joinDimension()) {
+      joinDimensionPickerRef.current.open();
     }
+  }
 
-    const joinedTable = join.joinedTable();
-    const strategyOption = join.strategyOption();
-    return (
-      <Flex align="center" flex="1 1 auto" {...props}>
-        <NotebookCellItem color={color} icon="table2">
-          {(lhsTable && lhsTable.displayName()) || `Previous results`}
-        </NotebookCellItem>
-
-        <PopoverWithTrigger
-          triggerElement={
-            strategyOption ? (
-              <Icon
-                tooltip={t`Change join type`}
-                className="text-brand mr1"
-                name={strategyOption.icon}
-                size={32}
-              />
-            ) : (
-              <NotebookCellItem color={color}>
-                {`Choose a join type`}
-              </NotebookCellItem>
-            )
-          }
-        >
-          {({ onClose }) => (
-            <JoinTypeSelect
-              value={strategyOption && strategyOption.value}
-              onChange={strategy => {
-                join
-                  .setStrategy(strategy)
-                  .parent()
-                  .update(updateQuery);
-                onClose();
-              }}
-              options={join.strategyOptions()}
-            />
-          )}
-        </PopoverWithTrigger>
-
-        <DatabaseSchemaAndTableDataSelector
-          hasTableSearch
-          canChangeDatabase={false}
-          databases={[
-            query.database(),
-            query.database().savedQuestionsDatabase(),
-          ].filter(d => d)}
-          tableFilter={table => table.db_id === query.database().id}
-          selectedDatabaseId={query.databaseId()}
-          selectedTableId={join.joinSourceTableId()}
-          setSourceTableFn={tableId => {
-            const newJoin = join
-              .setJoinSourceTableId(tableId)
-              .setDefaultCondition()
-              .setDefaultAlias();
-            newJoin.parent().update(updateQuery);
-            // _parentDimensionPicker won't be rendered until next update
-            if (!newJoin.parentDimension()) {
-              setTimeout(() => {
-                this._parentDimensionPicker.open();
-              });
-            }
-          }}
-          isInitiallyOpen={join.joinSourceTableId() == null}
-          triggerElement={
-            <NotebookCellItem
-              color={color}
-              icon="table2"
-              inactive={!joinedTable}
-            >
-              {joinedTable ? joinedTable.displayName() : t`Pick a table...`}
-            </NotebookCellItem>
-          }
-        />
+  function onJoinDimensionChange(fieldRef) {
+    join
+      .setJoinDimension(fieldRef)
+      .parent()
+      .update(updateQuery);
+  }
 
-        {joinedTable && (
-          <Flex align="center">
-            <span className="text-medium text-bold ml1 mr2">where</span>
-
-            <JoinDimensionPicker
-              color={color}
-              query={query}
-              dimension={join.parentDimension()}
-              options={join.parentDimensionOptions()}
-              onChange={fieldRef => {
-                join
-                  .setParentDimension(fieldRef)
-                  .setDefaultAlias()
-                  .parent()
-                  .update(updateQuery);
-                if (!join.joinDimension()) {
-                  this._joinDimensionPicker.open();
-                }
-              }}
-              ref={ref => (this._parentDimensionPicker = ref)}
-            />
-
-            <span className="text-medium text-bold mr1">=</span>
-
-            <JoinDimensionPicker
-              color={color}
-              query={query}
-              dimension={join.joinDimension()}
-              options={join.joinDimensionOptions()}
-              onChange={fieldRef => {
-                join
-                  .setJoinDimension(fieldRef)
-                  .parent()
-                  .update(updateQuery);
-              }}
-              ref={ref => (this._joinDimensionPicker = ref)}
-            />
-          </Flex>
-        )}
+  function removeJoin() {
+    join.remove().update(updateQuery);
+  }
 
-        {join.isValid() && (
-          <JoinFieldsPicker
-            className="mb1 ml-auto text-bold"
-            join={join}
-            updateQuery={updateQuery}
+  return (
+    <JoinClauseRoot>
+      <NotebookCellItem color={color} icon="table2">
+        {(lhsTable && lhsTable.displayName()) || `Previous results`}
+      </NotebookCellItem>
+
+      <JoinTypePicker join={join} color={color} updateQuery={updateQuery} />
+
+      <JoinTablePicker
+        join={join}
+        query={query}
+        joinedTable={joinedTable}
+        color={color}
+        updateQuery={updateQuery}
+        onSourceTableSet={onSourceTableSet}
+      />
+
+      {joinedTable && (
+        <JoinedTableControlRoot>
+          <JoinWhereConditionLabel />
+
+          <JoinDimensionPicker
+            color={color}
+            query={query}
+            dimension={join.parentDimension()}
+            options={join.parentDimensionOptions()}
+            onChange={onParentDimensionChange}
+            ref={parentDimensionPickerRef}
           />
-        )}
 
-        {showRemove && (
-          <Icon
-            name="close"
-            size={18}
-            className="cursor-pointer text-light text-medium-hover"
-            onClick={() => join.remove().update(updateQuery)}
+          <JoinOnConditionLabel />
+
+          <JoinDimensionPicker
+            color={color}
+            query={query}
+            dimension={join.joinDimension()}
+            options={join.joinDimensionOptions()}
+            onChange={onJoinDimensionChange}
+            ref={joinDimensionPickerRef}
           />
-        )}
-      </Flex>
-    );
+        </JoinedTableControlRoot>
+      )}
+
+      {join.isValid() && (
+        <JoinFieldsPicker
+          className="mb1 ml-auto text-bold"
+          join={join}
+          updateQuery={updateQuery}
+        />
+      )}
+
+      {showRemove && <RemoveJoinIcon onClick={removeJoin} />}
+    </JoinClauseRoot>
+  );
+}
+
+JoinClause.propTypes = joinClausePropTypes;
+
+const joinTablePickerPropTypes = {
+  join: PropTypes.object,
+  query: PropTypes.object,
+  joinedTable: PropTypes.object,
+  color: PropTypes.string,
+  updateQuery: PropTypes.func,
+  onSourceTableSet: PropTypes.func.isRequired,
+};
+
+function JoinTablePicker({
+  join,
+  query,
+  joinedTable,
+  color,
+  updateQuery,
+  onSourceTableSet,
+}) {
+  const databases = [
+    query.database(),
+    query.database().savedQuestionsDatabase(),
+  ].filter(Boolean);
+
+  function onChange(tableId) {
+    const newJoin = join
+      .setJoinSourceTableId(tableId)
+      .setDefaultCondition()
+      .setDefaultAlias();
+    newJoin.parent().update(updateQuery);
+    onSourceTableSet(newJoin);
   }
+
+  return (
+    <NotebookCellItem color={color} icon="table2" inactive={!joinedTable}>
+      <DatabaseSchemaAndTableDataSelector
+        hasTableSearch
+        canChangeDatabase={false}
+        databases={databases}
+        tableFilter={table => table.db_id === query.database().id}
+        selectedDatabaseId={query.databaseId()}
+        selectedTableId={join.joinSourceTableId()}
+        setSourceTableFn={onChange}
+        isInitiallyOpen={join.joinSourceTableId() == null}
+        triggerElement={
+          joinedTable ? joinedTable.displayName() : t`Pick a table...`
+        }
+      />
+    </NotebookCellItem>
+  );
+}
+
+JoinTablePicker.propTypes = joinTablePickerPropTypes;
+
+const joinTypePickerPropTypes = {
+  join: PropTypes.object,
+  color: PropTypes.string,
+  updateQuery: PropTypes.func,
+};
+
+function JoinTypePicker({ join, color, updateQuery }) {
+  const strategyOption = join.strategyOption();
+
+  function onChange(strategy) {
+    join
+      .setStrategy(strategy)
+      .parent()
+      .update(updateQuery);
+  }
+
+  return (
+    <PopoverWithTrigger
+      triggerElement={
+        strategyOption ? (
+          <JoinStrategyIcon
+            tooltip={t`Change join type`}
+            name={strategyOption.icon}
+          />
+        ) : (
+          <NotebookCellItem color={color}>
+            {`Choose a join type`}
+          </NotebookCellItem>
+        )
+      }
+    >
+      {({ onClose }) => (
+        <JoinTypeSelect
+          value={strategyOption && strategyOption.value}
+          onChange={strategy => {
+            onChange(strategy);
+            onClose();
+          }}
+          options={join.strategyOptions()}
+        />
+      )}
+    </PopoverWithTrigger>
+  );
 }
 
+JoinTypePicker.propTypes = joinTypePickerPropTypes;
+
+const joinStrategyOptionShape = {
+  name: PropTypes.string.isRequired,
+  value: PropTypes.string.isRequired,
+  icon: PropTypes.string.isRequired,
+};
+
+const joinTypeSelectPropTypes = {
+  value: PropTypes.string,
+  onChange: PropTypes.func.isRequired,
+  options: PropTypes.arrayOf(PropTypes.shape(joinStrategyOptionShape))
+    .isRequired,
+};
+
 function JoinTypeSelect({ value, onChange, options }) {
   return (
-    <div className="px1 pt1">
+    <JoinTypeSelectRoot>
       {options.map(option => (
         <JoinTypeOption
           {...option}
@@ -229,32 +347,41 @@ function JoinTypeSelect({ value, onChange, options }) {
           onChange={onChange}
         />
       ))}
-    </div>
+    </JoinTypeSelectRoot>
   );
 }
 
+JoinTypeSelect.propTypes = joinTypeSelectPropTypes;
+
+const joinTypeOptionPropTypes = {
+  ...joinStrategyOptionShape,
+  selected: PropTypes.bool.isRequired,
+  onChange: PropTypes.func.isRequired,
+};
+
 function JoinTypeOption({ name, value, icon, selected, onChange }) {
   return (
-    <Flex
-      align="center"
-      className={cx(
-        "p1 mb1 rounded cursor-pointer text-white-hover bg-brand-hover",
-        {
-          "bg-brand text-white": selected,
-        },
-      )}
-      onClick={() => onChange(value)}
-    >
-      <Icon
-        className={cx("mr1", { "text-brand": !selected })}
-        name={icon}
-        size={24}
-      />
+    <JoinTypeOptionRoot isSelected={selected} onClick={() => onChange(value)}>
+      <JoinTypeIcon name={icon} isSelected={selected} />
       {name}
-    </Flex>
+    </JoinTypeOptionRoot>
   );
 }
 
+JoinTypeOption.propTypes = joinTypeOptionPropTypes;
+
+const joinDimensionPickerPropTypes = {
+  dimension: PropTypes.object.isRequired,
+  onChange: PropTypes.func.isRequired,
+  options: PropTypes.shape({
+    count: PropTypes.number.isRequired,
+    fks: PropTypes.array.isRequired,
+    dimensions: PropTypes.arrayOf(PropTypes.object).isRequired,
+  }).isRequired,
+  query: PropTypes.object.isRequired,
+  color: PropTypes.string,
+};
+
 class JoinDimensionPicker extends React.Component {
   open() {
     this._popover.open();
@@ -292,12 +419,50 @@ class JoinDimensionPicker extends React.Component {
   }
 }
 
-import FieldsPicker from "./FieldsPicker";
+JoinDimensionPicker.propTypes = joinDimensionPickerPropTypes;
+
+const joinFieldsPickerPropTypes = {
+  join: PropTypes.object.isRequired,
+  updateQuery: PropTypes.func.isRequired,
+  className: PropTypes.string,
+};
 
 const JoinFieldsPicker = ({ className, join, updateQuery }) => {
   const dimensions = join.joinedDimensions();
   const selectedDimensions = join.fieldsDimensions();
   const selected = new Set(selectedDimensions.map(d => d.key()));
+
+  function onSelectAll() {
+    join
+      .setFields("all")
+      .parent()
+      .update(updateQuery);
+  }
+
+  function onSelectNone() {
+    join
+      .setFields("none")
+      .parent()
+      .update(updateQuery);
+  }
+
+  function onToggleDimension(dimension) {
+    join
+      .setFields(
+        dimensions
+          .filter(d => {
+            if (d === dimension) {
+              return !selected.has(d.key());
+            } else {
+              return selected.has(d.key());
+            }
+          })
+          .map(d => d.mbql()),
+      )
+      .parent()
+      .update(updateQuery);
+  }
+
   return (
     <FieldsPicker
       className={className}
@@ -305,34 +470,11 @@ const JoinFieldsPicker = ({ className, join, updateQuery }) => {
       selectedDimensions={selectedDimensions}
       isAll={join.fields === "all"}
       isNone={join.fields === "none"}
-      onSelectAll={() =>
-        join
-          .setFields("all")
-          .parent()
-          .update(updateQuery)
-      }
-      onSelectNone={() =>
-        join
-          .setFields("none")
-          .parent()
-          .update(updateQuery)
-      }
-      onToggleDimension={(dimension, enable) => {
-        join
-          .setFields(
-            dimensions
-              .filter(d => {
-                if (d === dimension) {
-                  return !selected.has(d.key());
-                } else {
-                  return selected.has(d.key());
-                }
-              })
-              .map(d => d.mbql()),
-          )
-          .parent()
-          .update(updateQuery);
-      }}
+      onSelectAll={onSelectAll}
+      onSelectNone={onSelectNone}
+      onToggleDimension={onToggleDimension}
     />
   );
 };
+
+JoinFieldsPicker.propTypes = joinFieldsPickerPropTypes;
diff --git a/frontend/src/metabase/query_builder/components/notebook/steps/JoinStep.styled.js b/frontend/src/metabase/query_builder/components/notebook/steps/JoinStep.styled.js
new file mode 100644
index 00000000000..b6d757e04a2
--- /dev/null
+++ b/frontend/src/metabase/query_builder/components/notebook/steps/JoinStep.styled.js
@@ -0,0 +1,82 @@
+import styled from "styled-components";
+import { color } from "metabase/lib/colors";
+import { space } from "metabase/styled-components/theme";
+import Icon from "metabase/components/Icon";
+
+export const JoinClausesContainer = styled.div`
+  display: flex;
+  flex-direction: column;
+  flex: 1 0 auto;
+`;
+
+export const JoinClauseContainer = styled.div`
+  margin-bottom: ${props => (props.isLast ? 0 : "2px")};
+`;
+
+export const JoinClauseRoot = styled.div`
+  display: flex;
+  align-items: center;
+  flex: 1 1 auto;
+`;
+
+export const JoinStrategyIcon = styled(Icon).attrs({ size: 32 })`
+  color: ${color("brand")};
+  margin-right: ${space(1)};
+`;
+
+export const JoinTypeSelectRoot = styled.div`
+  margin: ${space(1)} ${space(1)} 0 ${space(1)};
+`;
+
+export const JoinTypeOptionRoot = styled.div`
+  display: flex;
+  align-items: center;
+  padding: ${space(1)};
+  margin-bottom: ${space(1)};
+  cursor: pointer;
+  border-radius: ${space(1)};
+
+  color: ${props => props.isSelected && color("text-white")};
+  background-color: ${props => props.isSelected && color("brand")};
+
+  :hover {
+    color: ${color("text-white")};
+    background-color: ${color("brand")};
+
+    .Icon {
+      color: ${color("text-white")};
+    }
+  }
+`;
+
+export const JoinTypeIcon = styled(Icon).attrs({ size: 24 })`
+  margin-right: ${space(1)};
+  color: ${props => (props.isSelected ? color("text-white") : color("brand"))};
+`;
+
+export const JoinedTableControlRoot = styled.div`
+  display: flex;
+  align-items: center;
+`;
+
+export const JoinWhereConditionLabel = styled.span.attrs({ children: "where" })`
+  color: ${color("text-medium")};
+  font-weight: bold;
+  margin-left: ${space(1)};
+  margin-right: ${space(2)};
+`;
+
+export const JoinOnConditionLabel = styled.span.attrs({ children: "=" })`
+  font-weight: bold;
+  color: ${color("text-medium")};
+  margin-right: 8px;
+`;
+
+export const RemoveJoinIcon = styled(Icon).attrs({ name: "close", size: 18 })`
+  cursor: pointer;
+  color: ${color("text-light")};
+
+  :hover {
+    color: ${color("text-medium")};
+  }
+`;
-- 
GitLab