From 932e8bf78be245392081fd215f1d82ba6a307fc4 Mon Sep 17 00:00:00 2001
From: Alexander Polyankin <alexander.polyankin@metabase.com>
Date: Thu, 23 Jun 2022 19:22:10 +0300
Subject: [PATCH] Move EditableText to core components (#23508)

---
 .../EditableText/EditableText.stories.tsx     | 32 ++++++++
 .../EditableText/EditableText.styled.tsx      | 45 +++++++++++
 .../components/EditableText/EditableText.tsx  | 79 +++++++++++++++++++
 .../components/EditableText/index.ts          |  0
 .../EditableText/EditableText.stories.tsx     | 25 ------
 .../EditableText/EditableText.styled.tsx      | 47 -----------
 .../components/EditableText/EditableText.tsx  | 73 -----------------
 .../SavedQuestionHeaderButton.jsx             | 10 +--
 .../SavedQuestionHeaderButton.styled.jsx      | 16 ++--
 .../view/sidebars/QuestionInfoSidebar.tsx     |  6 +-
 10 files changed, 170 insertions(+), 163 deletions(-)
 create mode 100644 frontend/src/metabase/core/components/EditableText/EditableText.stories.tsx
 create mode 100644 frontend/src/metabase/core/components/EditableText/EditableText.styled.tsx
 create mode 100644 frontend/src/metabase/core/components/EditableText/EditableText.tsx
 rename frontend/src/metabase/{query_builder => core}/components/EditableText/index.ts (100%)
 delete mode 100644 frontend/src/metabase/query_builder/components/EditableText/EditableText.stories.tsx
 delete mode 100644 frontend/src/metabase/query_builder/components/EditableText/EditableText.styled.tsx
 delete mode 100644 frontend/src/metabase/query_builder/components/EditableText/EditableText.tsx

diff --git a/frontend/src/metabase/core/components/EditableText/EditableText.stories.tsx b/frontend/src/metabase/core/components/EditableText/EditableText.stories.tsx
new file mode 100644
index 00000000000..17ae5709d2d
--- /dev/null
+++ b/frontend/src/metabase/core/components/EditableText/EditableText.stories.tsx
@@ -0,0 +1,32 @@
+import React from "react";
+import { ComponentStory } from "@storybook/react";
+import EditableText from "./EditableText";
+
+export default {
+  title: "Core/EditableText",
+  component: EditableText,
+};
+
+const Template: ComponentStory<typeof EditableText> = args => {
+  return <EditableText {...args} />;
+};
+
+export const Default = Template.bind({});
+Default.args = {
+  initialValue: "Question",
+  placeholder: "Enter title",
+};
+
+export const Multiline = Template.bind({});
+Multiline.args = {
+  initialValue: "Question",
+  placeholder: "Enter title",
+  isMultiline: true,
+};
+
+export const WithMaxWidth = Template.bind({});
+WithMaxWidth.args = {
+  initialValue: "Question",
+  placeholder: "Enter title",
+  style: { maxWidth: 500 },
+};
diff --git a/frontend/src/metabase/core/components/EditableText/EditableText.styled.tsx b/frontend/src/metabase/core/components/EditableText/EditableText.styled.tsx
new file mode 100644
index 00000000000..f3fe410b152
--- /dev/null
+++ b/frontend/src/metabase/core/components/EditableText/EditableText.styled.tsx
@@ -0,0 +1,45 @@
+import styled from "@emotion/styled";
+import { color } from "metabase/lib/colors";
+
+export const EditableTextRoot = styled.div`
+  position: relative;
+  color: ${color("text-dark")};
+  padding: 0.25rem;
+  border: 1px solid transparent;
+
+  &:hover,
+  &:focus-within {
+    border-color: ${color("border")};
+  }
+
+  &:after {
+    content: attr(data-value);
+    visibility: hidden;
+    white-space: pre-wrap;
+    word-wrap: break-word;
+  }
+`;
+
+export const EditableTextArea = styled.textarea`
+  position: absolute;
+  top: 0;
+  left: 0;
+  width: 100%;
+  height: 100%;
+  min-height: 0;
+  padding: inherit;
+  color: inherit;
+  font-size: inherit;
+  font-weight: inherit;
+  line-height: inherit;
+  cursor: pointer;
+  border: none;
+  resize: none;
+  outline: none;
+  overflow: hidden;
+  background: transparent;
+
+  &:focus {
+    cursor: text;
+  }
+`;
diff --git a/frontend/src/metabase/core/components/EditableText/EditableText.tsx b/frontend/src/metabase/core/components/EditableText/EditableText.tsx
new file mode 100644
index 00000000000..4e50c84462d
--- /dev/null
+++ b/frontend/src/metabase/core/components/EditableText/EditableText.tsx
@@ -0,0 +1,79 @@
+import React, {
+  ChangeEvent,
+  KeyboardEvent,
+  forwardRef,
+  HTMLAttributes,
+  Ref,
+  useCallback,
+  useState,
+} from "react";
+import { EditableTextArea, EditableTextRoot } from "./EditableText.styled";
+
+export type EditableTextAttributes = Omit<
+  HTMLAttributes<HTMLDivElement>,
+  "onChange"
+>;
+
+export interface EditableTextProps extends EditableTextAttributes {
+  initialValue?: string | null;
+  placeholder?: string;
+  isMultiline?: boolean;
+  onChange?: (value: string) => void;
+  "data-testid"?: string;
+}
+
+const EditableText = forwardRef(function EditableText(
+  {
+    initialValue,
+    placeholder,
+    isMultiline = false,
+    onChange,
+    "data-testid": dataTestId,
+    ...props
+  }: EditableTextProps,
+  ref: Ref<HTMLDivElement>,
+) {
+  const [inputValue, setInputValue] = useState(initialValue ?? "");
+  const [submitValue, setSubmitValue] = useState(initialValue ?? "");
+
+  const handleBlur = useCallback(() => {
+    if (inputValue !== submitValue) {
+      setSubmitValue(inputValue);
+      onChange?.(inputValue);
+    }
+  }, [inputValue, submitValue, onChange]);
+
+  const handleChange = useCallback(
+    (event: ChangeEvent<HTMLTextAreaElement>) => {
+      setInputValue(event.currentTarget.value);
+    },
+    [],
+  );
+
+  const handleKeyDown = useCallback(
+    (event: KeyboardEvent<HTMLTextAreaElement>) => {
+      if (event.key === "Escape") {
+        setInputValue(submitValue);
+      } else if (event.key === "Enter" && !isMultiline) {
+        event.preventDefault();
+        event.currentTarget.blur();
+      }
+    },
+    [submitValue, isMultiline],
+  );
+
+  return (
+    <EditableTextRoot {...props} ref={ref} data-value={`${inputValue}\u00A0`}>
+      <EditableTextArea
+        value={inputValue}
+        placeholder={placeholder}
+        data-testid={dataTestId}
+        onBlur={handleBlur}
+        onChange={handleChange}
+        onKeyDown={handleKeyDown}
+      />
+    </EditableTextRoot>
+  );
+});
+
+export default EditableText;
diff --git a/frontend/src/metabase/query_builder/components/EditableText/index.ts b/frontend/src/metabase/core/components/EditableText/index.ts
similarity index 100%
rename from frontend/src/metabase/query_builder/components/EditableText/index.ts
rename to frontend/src/metabase/core/components/EditableText/index.ts
diff --git a/frontend/src/metabase/query_builder/components/EditableText/EditableText.stories.tsx b/frontend/src/metabase/query_builder/components/EditableText/EditableText.stories.tsx
deleted file mode 100644
index 26bf85ca731..00000000000
--- a/frontend/src/metabase/query_builder/components/EditableText/EditableText.stories.tsx
+++ /dev/null
@@ -1,25 +0,0 @@
-import React from "react";
-import { ComponentStory } from "@storybook/react";
-import { useArgs } from "@storybook/client-api";
-import EditableText from "./EditableText";
-
-export default {
-  title: "Query Builder/EditableText",
-  component: EditableText,
-};
-
-const Template: ComponentStory<typeof EditableText> = args => {
-  const [{ initialValue }, updateArgs] = useArgs();
-  const handleChange = (value: string | null | undefined) =>
-    updateArgs({ initialValue: value });
-
-  console.log(initialValue);
-
-  return <EditableText initialValue={initialValue} onChange={handleChange} />;
-};
-
-export const Default = Template.bind({});
-Default.args = {
-  initialValue:
-    "Users with their LTV, Source, and State. Number of new saved questions the last 12 weeks by the method used to create it: GUI or SQL",
-};
diff --git a/frontend/src/metabase/query_builder/components/EditableText/EditableText.styled.tsx b/frontend/src/metabase/query_builder/components/EditableText/EditableText.styled.tsx
deleted file mode 100644
index e5c86366b38..00000000000
--- a/frontend/src/metabase/query_builder/components/EditableText/EditableText.styled.tsx
+++ /dev/null
@@ -1,47 +0,0 @@
-import styled from "@emotion/styled";
-import { color } from "metabase/lib/colors";
-import { css } from "@emotion/react";
-
-export const SharedStyles = css`
-  border: 1px solid transparent;
-  border-radius: 0.25rem;
-  padding: 0.5rem;
-  grid-area: 1 / 1 / 2 / 2;
-  font-size: 0.875rem;
-  line-height: 1.25rem;
-  min-height: 0;
-  color: ${color("text-dark")};
-`;
-
-interface EditableTextRootProps {
-  value: string | null;
-}
-
-export const EditableTextRoot = styled.div<EditableTextRootProps>`
-  display: grid;
-  max-width: 500px;
-
-  &:after {
-    content: "${props => props.value?.replace(/\n/g, "\\00000a")} ";
-    white-space: pre-wrap;
-    visibility: hidden;
-    ${SharedStyles}
-  }
-`;
-
-export const EditableTextArea = styled.textarea`
-  resize: none;
-  overflow: hidden;
-  cursor: pointer;
-  outline: none;
-  &:hover,
-  &:focus {
-    border: 1px solid ${color("border")};
-  }
-
-  &:focus {
-    cursor: text;
-  }
-
-  ${SharedStyles}
-`;
diff --git a/frontend/src/metabase/query_builder/components/EditableText/EditableText.tsx b/frontend/src/metabase/query_builder/components/EditableText/EditableText.tsx
deleted file mode 100644
index 7a7c01d3dbb..00000000000
--- a/frontend/src/metabase/query_builder/components/EditableText/EditableText.tsx
+++ /dev/null
@@ -1,73 +0,0 @@
-import React, { useState, useRef } from "react";
-import { KEY_ESCAPE, KEY_ENTER } from "metabase/lib/keyboard";
-
-import {
-  EditableTextRoot,
-  EditableTextArea,
-  SharedStyles,
-} from "./EditableText.styled";
-
-interface EditableTextProps {
-  initialValue: string | null;
-  onChange?: (val: string | null) => void;
-  submitOnEnter?: boolean;
-  "data-testid"?: string;
-  placeholder?: string;
-}
-
-const EditableText = ({
-  initialValue,
-  onChange,
-  submitOnEnter,
-  "data-testid": dataTestid,
-  placeholder,
-}: EditableTextProps) => {
-  const [value, setValue] = useState<string | null>(initialValue);
-  const textArea = useRef<HTMLTextAreaElement>(null);
-
-  const handleChange = (e: React.ChangeEvent<HTMLTextAreaElement>) => {
-    setValue(e.target.value);
-  };
-
-  const handleBlur = (e: React.ChangeEvent<HTMLTextAreaElement>) => {
-    const {
-      target: { value },
-    } = e;
-
-    if (onChange && value !== initialValue) {
-      onChange(value);
-    }
-  };
-
-  const handleKeyDown = (e: React.KeyboardEvent) => {
-    if (e.key === KEY_ESCAPE) {
-      setValue(initialValue);
-    }
-    if (e.key === KEY_ENTER && submitOnEnter) {
-      textArea.current?.blur();
-      e.preventDefault();
-    }
-  };
-
-  return (
-    <EditableTextRoot value={value}>
-      <EditableTextArea
-        placeholder={placeholder}
-        value={value || ""}
-        onChange={handleChange}
-        onBlur={handleBlur}
-        onKeyDown={handleKeyDown}
-        rows={1}
-        cols={1}
-        ref={textArea}
-        data-testid={dataTestid}
-      />
-    </EditableTextRoot>
-  );
-};
-
-export default Object.assign(EditableText, {
-  SharedStyles,
-  Root: EditableTextRoot,
-  TextArea: EditableTextArea,
-});
diff --git a/frontend/src/metabase/query_builder/components/SavedQuestionHeaderButton/SavedQuestionHeaderButton.jsx b/frontend/src/metabase/query_builder/components/SavedQuestionHeaderButton/SavedQuestionHeaderButton.jsx
index 5b9d16eb9d2..7cb81fa7f5a 100644
--- a/frontend/src/metabase/query_builder/components/SavedQuestionHeaderButton/SavedQuestionHeaderButton.jsx
+++ b/frontend/src/metabase/query_builder/components/SavedQuestionHeaderButton/SavedQuestionHeaderButton.jsx
@@ -1,15 +1,12 @@
 import React from "react";
 import { t } from "ttag";
 import PropTypes from "prop-types";
-
 import { PLUGIN_MODERATION } from "metabase/plugins";
-
 import { color } from "metabase/lib/colors";
-
-import EditableText from "../EditableText";
 import {
   HeaderRoot,
   HeaderReviewIcon,
+  HeaderTitle,
 } from "./SavedQuestionHeaderButton.styled";
 
 SavedQuestionHeaderButton.propTypes = {
@@ -28,11 +25,10 @@ function SavedQuestionHeaderButton({ className, question, onSave }) {
 
   return (
     <HeaderRoot>
-      <EditableText
+      <HeaderTitle
         initialValue={question.displayName()}
-        onChange={onSave}
-        submitOnEnter
         placeholder={t`A nice title`}
+        onChange={onSave}
         data-testid="saved-question-header-title"
       />
       {reviewIconName && (
diff --git a/frontend/src/metabase/query_builder/components/SavedQuestionHeaderButton/SavedQuestionHeaderButton.styled.jsx b/frontend/src/metabase/query_builder/components/SavedQuestionHeaderButton/SavedQuestionHeaderButton.styled.jsx
index 471d21bf6e8..518bd98b920 100644
--- a/frontend/src/metabase/query_builder/components/SavedQuestionHeaderButton/SavedQuestionHeaderButton.styled.jsx
+++ b/frontend/src/metabase/query_builder/components/SavedQuestionHeaderButton/SavedQuestionHeaderButton.styled.jsx
@@ -1,20 +1,18 @@
 import styled from "@emotion/styled";
-
-import EditableText from "../EditableText/EditableText";
 import Icon from "metabase/components/Icon";
+import EditableText from "metabase/core/components/EditableText";
 
 export const HeaderRoot = styled.div`
-  ${EditableText.Root}:after, ${EditableText.TextArea} {
-    font-size: 1.25rem;
-    font-weight: 700;
-    line-height: 1.5rem;
-    padding: 0.25rem;
-  }
-
   display: flex;
   align-items: center;
 `;
 
+export const HeaderTitle = styled(EditableText)`
+  font-size: 1.25rem;
+  font-weight: 700;
+  line-height: 1.5rem;
+`;
+
 export const HeaderReviewIcon = styled(Icon)`
   padding-left: 0.25rem;
 `;
diff --git a/frontend/src/metabase/query_builder/components/view/sidebars/QuestionInfoSidebar.tsx b/frontend/src/metabase/query_builder/components/view/sidebars/QuestionInfoSidebar.tsx
index daed2915340..dca8218cf43 100644
--- a/frontend/src/metabase/query_builder/components/view/sidebars/QuestionInfoSidebar.tsx
+++ b/frontend/src/metabase/query_builder/components/view/sidebars/QuestionInfoSidebar.tsx
@@ -1,4 +1,5 @@
 import React from "react";
+import { t } from "ttag";
 
 import {
   PLUGIN_MODERATION,
@@ -13,7 +14,7 @@ import QuestionActivityTimeline from "metabase/query_builder/components/Question
 import Question from "metabase-lib/lib/Question";
 import { Card } from "metabase-types/types/Card";
 
-import EditableText from "../../EditableText";
+import EditableText from "metabase/core/components/EditableText";
 import { Root, ContentSection } from "./QuestionInfoSidebar.styled";
 
 interface QuestionInfoSidebarProps {
@@ -49,8 +50,9 @@ export const QuestionInfoSidebar = ({
       <ContentSection>
         <EditableText
           initialValue={description}
+          placeholder={t`Description`}
+          isMultiline
           onChange={handleSave}
-          placeholder="Description"
         />
         <PLUGIN_MODERATION.QuestionModerationSection question={question} />
       </ContentSection>
-- 
GitLab