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 0000000000000000000000000000000000000000..17ae5709d2d12bdcee3a93df99f897c2c4aac5fb --- /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 0000000000000000000000000000000000000000..f3fe410b15217427ff88dd9bc2407d821f38791f --- /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 0000000000000000000000000000000000000000..4e50c84462d2ff52786ef2bb5fcd1c14911e6ae5 --- /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 26bf85ca73161a7a059039060e2159da7e77cf10..0000000000000000000000000000000000000000 --- 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 e5c86366b38dd8947b65f17e752b52996cb39076..0000000000000000000000000000000000000000 --- 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 7a7c01d3dbb65e4750153e47eb70f11374b5a8be..0000000000000000000000000000000000000000 --- 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 5b9d16eb9d2598b051a311550813bd6f87987f60..7cb81fa7f5a1fd1ba7f43bbd9169ec7f6a5b1d0e 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 471d21bf6e890c4d510d2dbbd130b16bfa3140da..518bd98b920558de19a3eb8ba8ce08e082332ab3 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 daed291534026f153f6bd2f177ce3e55c19a5e0b..dca8218cf438910ab007ef7ae0c3adb11ce00e6b 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>