From 9ce72a44cd1214019088379987b5bb903a5007f1 Mon Sep 17 00:00:00 2001
From: Anton Kulyk <kuliks.anton@gmail.com>
Date: Wed, 26 Jun 2024 11:31:16 +0100
Subject: [PATCH] Bump dashboard description max length limit to 1.5K (#44470)

* Bump dashboard description max len limit to 1.5K

* Extract `DASHBOARD_DESCRIPTION_MAX_LENGTH` const

* Handle description max length in dashboard sidebar

* Add red border to dashboard description box

* Add test
---
 .../components/EditableText/EditableText.tsx  | 31 +++++++++-------
 .../DashboardInfoSidebar.styled.tsx           | 13 +++++++
 .../DashboardInfoSidebar.tsx                  | 36 +++++++++++++++----
 .../DashboardInfoSidebar.unit.spec.tsx        | 27 ++++++++++++++
 frontend/src/metabase/dashboard/constants.ts  |  2 ++
 .../containers/CopyDashboardForm.tsx          |  6 +++-
 .../containers/CreateDashboardForm.tsx        |  7 +++-
 7 files changed, 101 insertions(+), 21 deletions(-)

diff --git a/frontend/src/metabase/core/components/EditableText/EditableText.tsx b/frontend/src/metabase/core/components/EditableText/EditableText.tsx
index 80656987df0..f206a4ed17e 100644
--- a/frontend/src/metabase/core/components/EditableText/EditableText.tsx
+++ b/frontend/src/metabase/core/components/EditableText/EditableText.tsx
@@ -4,6 +4,8 @@ import type {
   HTMLAttributes,
   Ref,
   MouseEvent,
+  FocusEventHandler,
+  FocusEvent,
 } from "react";
 import { forwardRef, useCallback, useEffect, useState, useRef } from "react";
 import { usePrevious } from "react-use";
@@ -14,7 +16,7 @@ import { EditableTextArea, EditableTextRoot } from "./EditableText.styled";
 
 export type EditableTextAttributes = Omit<
   HTMLAttributes<HTMLDivElement>,
-  "onChange"
+  "onChange" | "onFocus" | "onBlur"
 >;
 
 export interface EditableTextProps extends EditableTextAttributes {
@@ -26,8 +28,8 @@ export interface EditableTextProps extends EditableTextAttributes {
   isDisabled?: boolean;
   isMarkdown?: boolean;
   onChange?: (value: string) => void;
-  onFocus?: () => void;
-  onBlur?: () => void;
+  onFocus?: FocusEventHandler<HTMLTextAreaElement>;
+  onBlur?: FocusEventHandler<HTMLTextAreaElement>;
   "data-testid"?: string;
 }
 
@@ -72,18 +74,21 @@ const EditableText = forwardRef(function EditableText(
     }
   }, [isInFocus, isMarkdown]);
 
-  const handleBlur = useCallback(() => {
-    setIsInFocus(false);
+  const handleBlur = useCallback(
+    (event: FocusEvent<HTMLTextAreaElement>) => {
+      setIsInFocus(false);
 
-    if (!isOptional && !inputValue) {
-      setInputValue(submitValue);
-    } else if (inputValue !== submitValue && submitOnBlur.current) {
-      setSubmitValue(inputValue);
-      onChange?.(inputValue);
-    }
+      if (!isOptional && !inputValue) {
+        setInputValue(submitValue);
+      } else if (inputValue !== submitValue && submitOnBlur.current) {
+        setSubmitValue(inputValue);
+        onChange?.(inputValue);
+      }
 
-    onBlur?.();
-  }, [inputValue, submitValue, isOptional, onChange, onBlur, setIsInFocus]);
+      onBlur?.(event);
+    },
+    [inputValue, submitValue, isOptional, onChange, onBlur, setIsInFocus],
+  );
 
   const handleChange = useCallback(
     (event: ChangeEvent<HTMLTextAreaElement>) => {
diff --git a/frontend/src/metabase/dashboard/components/DashboardInfoSidebar/DashboardInfoSidebar.styled.tsx b/frontend/src/metabase/dashboard/components/DashboardInfoSidebar/DashboardInfoSidebar.styled.tsx
index 7082d0e53df..fb606664b92 100644
--- a/frontend/src/metabase/dashboard/components/DashboardInfoSidebar/DashboardInfoSidebar.styled.tsx
+++ b/frontend/src/metabase/dashboard/components/DashboardInfoSidebar/DashboardInfoSidebar.styled.tsx
@@ -1,3 +1,4 @@
+import { css } from "@emotion/react";
 import styled from "@emotion/styled";
 
 import EditableText from "metabase/core/components/EditableText";
@@ -61,3 +62,15 @@ export const ContentSection = styled.div`
 export const DescriptionHeader = styled.h3`
   margin-bottom: 0.5rem;
 `;
+
+export const EditableDescription = styled(EditableText)<{ hasError?: boolean }>`
+  ${props =>
+    props.hasError &&
+    css`
+      border-color: var(--mb-color-error);
+
+      &:hover {
+        border-color: var(--mb-color-error);
+      }
+    `}
+`;
diff --git a/frontend/src/metabase/dashboard/components/DashboardInfoSidebar/DashboardInfoSidebar.tsx b/frontend/src/metabase/dashboard/components/DashboardInfoSidebar/DashboardInfoSidebar.tsx
index 1f3768f1421..177e507a17c 100644
--- a/frontend/src/metabase/dashboard/components/DashboardInfoSidebar/DashboardInfoSidebar.tsx
+++ b/frontend/src/metabase/dashboard/components/DashboardInfoSidebar/DashboardInfoSidebar.tsx
@@ -1,4 +1,4 @@
-import type { Dispatch, SetStateAction } from "react";
+import type { Dispatch, FocusEvent, SetStateAction } from "react";
 import { useCallback, useState } from "react";
 import { t } from "ttag";
 
@@ -6,24 +6,25 @@ import ErrorBoundary from "metabase/ErrorBoundary";
 import { Timeline } from "metabase/common/components/Timeline";
 import { getTimelineEvents } from "metabase/common/components/Timeline/utils";
 import { useRevisionListQuery } from "metabase/common/hooks";
-import EditableText from "metabase/core/components/EditableText";
 import {
   revertToRevision,
   toggleAutoApplyFilters,
   updateDashboard,
 } from "metabase/dashboard/actions";
+import { DASHBOARD_DESCRIPTION_MAX_LENGTH } from "metabase/dashboard/constants";
 import { isDashboardCacheable } from "metabase/dashboard/utils";
 import { useUniqueId } from "metabase/hooks/use-unique-id";
 import { useDispatch, useSelector } from "metabase/lib/redux";
 import { PLUGIN_CACHING } from "metabase/plugins";
 import { getUser } from "metabase/selectors/user";
-import { Stack, Switch } from "metabase/ui";
+import { Text, Stack, Switch } from "metabase/ui";
 import type { Dashboard } from "metabase-types/api";
 
 import {
   ContentSection,
   DashboardInfoSidebarRoot,
   DescriptionHeader,
+  EditableDescription,
   HistoryHeader,
 } from "./DashboardInfoSidebar.styled";
 
@@ -75,6 +76,8 @@ const DashboardInfoSidebarBody = ({
   setDashboardAttribute,
   setPage,
 }: DashboardSidebarPageProps) => {
+  const [descriptionError, setDescriptionError] = useState<string | null>(null);
+
   const { data: revisions } = useRevisionListQuery({
     query: { model_type: "dashboard", model_id: dashboard.id },
   });
@@ -84,12 +87,25 @@ const DashboardInfoSidebarBody = ({
 
   const handleDescriptionChange = useCallback(
     (description: string) => {
-      setDashboardAttribute?.("description", description);
-      dispatch(updateDashboard({ attributeNames: ["description"] }));
+      if (description.length <= DASHBOARD_DESCRIPTION_MAX_LENGTH) {
+        setDashboardAttribute?.("description", description);
+        dispatch(updateDashboard({ attributeNames: ["description"] }));
+      }
     },
     [dispatch, setDashboardAttribute],
   );
 
+  const handleDescriptionBlur = useCallback(
+    (event: FocusEvent<HTMLTextAreaElement>) => {
+      if (event.target.value.length > DASHBOARD_DESCRIPTION_MAX_LENGTH) {
+        setDescriptionError(
+          t`Must be ${DASHBOARD_DESCRIPTION_MAX_LENGTH} characters or less`,
+        );
+      }
+    },
+    [],
+  );
+
   const handleToggleAutoApplyFilters = useCallback(
     (isAutoApplyingFilters: boolean) => {
       dispatch(toggleAutoApplyFilters(isAutoApplyingFilters));
@@ -107,17 +123,25 @@ const DashboardInfoSidebarBody = ({
     <>
       <ContentSection>
         <DescriptionHeader>{t`About`}</DescriptionHeader>
-        <EditableText
+        <EditableDescription
           initialValue={dashboard.description}
           isDisabled={!canWrite}
           onChange={handleDescriptionChange}
+          onFocus={() => setDescriptionError("")}
+          onBlur={handleDescriptionBlur}
           isOptional
           isMultiline
           isMarkdown
+          hasError={!!descriptionError}
           placeholder={t`Add description`}
           key={`dashboard-description-${dashboard.description}`}
           style={{ fontSize: ".875rem" }}
         />
+        {!!descriptionError && (
+          <Text color="error" size="xs" mt="xs">
+            {descriptionError}
+          </Text>
+        )}
       </ContentSection>
 
       {!dashboard.archived && (
diff --git a/frontend/src/metabase/dashboard/components/DashboardInfoSidebar/DashboardInfoSidebar.unit.spec.tsx b/frontend/src/metabase/dashboard/components/DashboardInfoSidebar/DashboardInfoSidebar.unit.spec.tsx
index 1022d5eba89..816a2574b22 100644
--- a/frontend/src/metabase/dashboard/components/DashboardInfoSidebar/DashboardInfoSidebar.unit.spec.tsx
+++ b/frontend/src/metabase/dashboard/components/DashboardInfoSidebar/DashboardInfoSidebar.unit.spec.tsx
@@ -28,6 +28,11 @@ function setup({ dashboard = createMockDashboard() }: SetupOpts = {}) {
   };
 }
 
+jest.mock("metabase/dashboard/constants", () => ({
+  ...jest.requireActual("metabase/dashboard/constants"),
+  DASHBOARD_DESCRIPTION_MAX_LENGTH: 20,
+}));
+
 describe("DashboardInfoSidebar", () => {
   it("should render the component", () => {
     setup();
@@ -51,6 +56,28 @@ describe("DashboardInfoSidebar", () => {
     );
   });
 
+  it("should validate description length", async () => {
+    const expectedErrorMessage = "Must be 20 characters or less";
+    const { setDashboardAttribute } = setup();
+
+    await userEvent.click(screen.getByTestId("editable-text"));
+    await userEvent.type(
+      screen.getByPlaceholderText("Add description"),
+      "in incididunt incididunt laboris ut elit culpa sit dolor amet",
+    );
+    await userEvent.tab();
+
+    expect(screen.getByText(expectedErrorMessage)).toBeInTheDocument();
+
+    await userEvent.click(screen.getByTestId("editable-text"));
+    expect(screen.queryByText(expectedErrorMessage)).not.toBeInTheDocument();
+
+    await userEvent.tab();
+    expect(screen.getByText(expectedErrorMessage)).toBeInTheDocument();
+
+    expect(setDashboardAttribute).not.toHaveBeenCalled();
+  });
+
   it("should allow to clear description", async () => {
     const { setDashboardAttribute } = setup({
       dashboard: createMockDashboard({ description: "some description" }),
diff --git a/frontend/src/metabase/dashboard/constants.ts b/frontend/src/metabase/dashboard/constants.ts
index b2a80a4fb4c..a974f3aea89 100644
--- a/frontend/src/metabase/dashboard/constants.ts
+++ b/frontend/src/metabase/dashboard/constants.ts
@@ -5,6 +5,8 @@ import type {
 
 import type { EmbedDisplayParams } from "./types";
 
+export const DASHBOARD_DESCRIPTION_MAX_LENGTH = 1500;
+
 export const SIDEBAR_NAME: Record<DashboardSidebarName, DashboardSidebarName> =
   {
     addQuestion: "addQuestion",
diff --git a/frontend/src/metabase/dashboard/containers/CopyDashboardForm.tsx b/frontend/src/metabase/dashboard/containers/CopyDashboardForm.tsx
index ef2c256f627..6df4e2ce5e4 100644
--- a/frontend/src/metabase/dashboard/containers/CopyDashboardForm.tsx
+++ b/frontend/src/metabase/dashboard/containers/CopyDashboardForm.tsx
@@ -23,13 +23,17 @@ import * as Errors from "metabase/lib/errors";
 import type { CollectionId, Dashboard } from "metabase-types/api";
 
 import { DashboardCopyModalShallowCheckboxLabel } from "../components/DashboardCopyModal/DashboardCopyModalShallowCheckboxLabel/DashboardCopyModalShallowCheckboxLabel";
+import { DASHBOARD_DESCRIPTION_MAX_LENGTH } from "../constants";
 
 const DASHBOARD_SCHEMA = Yup.object({
   name: Yup.string()
     .required(Errors.required)
     .max(100, Errors.maxLength)
     .default(""),
-  description: Yup.string().nullable().max(255, Errors.maxLength).default(null),
+  description: Yup.string()
+    .nullable()
+    .max(DASHBOARD_DESCRIPTION_MAX_LENGTH, Errors.maxLength)
+    .default(null),
   collection_id: Yup.number().nullable().default(null),
   is_shallow_copy: Yup.boolean().default(false),
 });
diff --git a/frontend/src/metabase/dashboard/containers/CreateDashboardForm.tsx b/frontend/src/metabase/dashboard/containers/CreateDashboardForm.tsx
index 44d432fe5e3..2cfaa326ce1 100644
--- a/frontend/src/metabase/dashboard/containers/CreateDashboardForm.tsx
+++ b/frontend/src/metabase/dashboard/containers/CreateDashboardForm.tsx
@@ -20,12 +20,17 @@ import * as Errors from "metabase/lib/errors";
 import type { CollectionId, Dashboard } from "metabase-types/api";
 import type { State } from "metabase-types/store";
 
+import { DASHBOARD_DESCRIPTION_MAX_LENGTH } from "../constants";
+
 const DASHBOARD_SCHEMA = Yup.object({
   name: Yup.string()
     .required(Errors.required)
     .max(100, Errors.maxLength)
     .default(""),
-  description: Yup.string().nullable().max(255, Errors.maxLength).default(null),
+  description: Yup.string()
+    .nullable()
+    .max(DASHBOARD_DESCRIPTION_MAX_LENGTH, Errors.maxLength)
+    .default(null),
   collection_id: Yup.number().nullable(),
 });
 
-- 
GitLab