From c4812b4a3e49d3daa130d9e5e599b40d3d1054a7 Mon Sep 17 00:00:00 2001
From: Cal Herries <39073188+calherries@users.noreply.github.com>
Date: Mon, 9 Oct 2023 16:55:01 +0300
Subject: [PATCH] Prevent empty dashboard parameter labels (#34358)

---
 .../dashboard-filters/parameters.cy.spec.js   | 17 ++------
 .../ParameterSettings/ParameterSettings.tsx   | 38 ++++++++++++++----
 .../ParameterSettings.unit.spec.tsx           | 39 ++++++++++++++++++-
 .../metabase/parameters/utils/dashboards.ts   |  5 +--
 .../parameters/utils/dashboards.unit.spec.js  |  6 +--
 5 files changed, 75 insertions(+), 30 deletions(-)

diff --git a/e2e/test/scenarios/dashboard-filters/parameters.cy.spec.js b/e2e/test/scenarios/dashboard-filters/parameters.cy.spec.js
index 3edab3ff42c..5857132800c 100644
--- a/e2e/test/scenarios/dashboard-filters/parameters.cy.spec.js
+++ b/e2e/test/scenarios/dashboard-filters/parameters.cy.spec.js
@@ -100,7 +100,7 @@ describe("scenarios > dashboard > parameters", () => {
     cy.get(".DashCard").last().should("contain", "4,939");
   });
 
-  it("should remove parameter name or the whole parameter (metabase#10829, metabase#17933)", () => {
+  it("should be able to remove parameter (metabase#17933)", () => {
     // Mirrored issue in metabase-enterprise#275
 
     const questionDetails = {
@@ -229,20 +229,11 @@ describe("scenarios > dashboard > parameters", () => {
     cy.findByText("Remove").click();
     cy.location("search").should("eq", `?${endsWith.slug}=zmo`);
 
-    // Remove filter name (metabase#10829)
-    // eslint-disable-next-line no-unscoped-text-selectors -- deprecated usage
-    cy.findByText(endsWith.name).find(".Icon-gear").click();
-    cy.findByDisplayValue(endsWith.name).clear().blur();
-
-    cy.location("search").should("eq", "?unnamed=zmo");
-    cy.findByDisplayValue("unnamed");
-
     cy.button("Save").click();
 
-    cy.log("Filter name should be 'unnamed' and the value cleared");
-    filterWidget().contains(/unnamed/i);
-
-    cy.location("search").should("eq", "?unnamed=");
+    cy.log("There should only be one filter remaining and its value cleared");
+    filterWidget().contains(new RegExp(`${endsWith.name}`, "i"));
+    cy.location("search").should("eq", `?${endsWith.slug}=`);
 
     // eslint-disable-next-line no-unscoped-text-selectors -- deprecated usage
     cy.findByText("37.65");
diff --git a/frontend/src/metabase/parameters/components/ParameterSettings/ParameterSettings.tsx b/frontend/src/metabase/parameters/components/ParameterSettings/ParameterSettings.tsx
index da3c53d764b..92c2453bbbd 100644
--- a/frontend/src/metabase/parameters/components/ParameterSettings/ParameterSettings.tsx
+++ b/frontend/src/metabase/parameters/components/ParameterSettings/ParameterSettings.tsx
@@ -1,6 +1,5 @@
-import { useCallback } from "react";
+import { useCallback, useLayoutEffect, useState } from "react";
 import { t } from "ttag";
-import InputBlurChange from "metabase/components/InputBlurChange";
 import Radio from "metabase/core/components/Radio";
 import type {
   Parameter,
@@ -8,6 +7,7 @@ import type {
   ValuesSourceConfig,
   ValuesSourceType,
 } from "metabase-types/api";
+import { TextInput } from "metabase/ui";
 import { canUseCustomSource } from "metabase-lib/parameters/utils/parameter-source";
 import { getIsMultiSelect } from "../../utils/dashboards";
 import { isSingleOrMultiSelectable } from "../../utils/parameter-type";
@@ -46,11 +46,30 @@ const ParameterSettings = ({
   onChangeSourceConfig,
   onRemoveParameter,
 }: ParameterSettingsProps): JSX.Element => {
-  const handleNameChange = useCallback(
+  const [internalValue, setInternalValue] = useState(parameter.name);
+
+  useLayoutEffect(() => {
+    setInternalValue(parameter.name);
+  }, [parameter.name]);
+
+  const handleLabelChange = useCallback(
+    (event: React.ChangeEvent<HTMLInputElement>) => {
+      setInternalValue(event.target.value);
+    },
+    [],
+  );
+
+  const labelError = internalValue ? null : t`Required`;
+
+  const handleLabelBlur = useCallback(
     (event: { target: HTMLInputElement }) => {
-      onChangeName(event.target.value);
+      if (labelError) {
+        setInternalValue(parameter.name);
+      } else {
+        onChangeName(event.target.value);
+      }
     },
-    [onChangeName],
+    [onChangeName, parameter.name, labelError],
   );
 
   const handleSourceSettingsChange = useCallback(
@@ -65,9 +84,12 @@ const ParameterSettings = ({
     <SettingsRoot>
       <SettingSection>
         <SettingLabel>{t`Label`}</SettingLabel>
-        <InputBlurChange
-          value={parameter.name}
-          onBlurChange={handleNameChange}
+        <TextInput
+          onChange={handleLabelChange}
+          value={internalValue}
+          onBlur={handleLabelBlur}
+          error={labelError}
+          aria-label={t`Label`}
         />
       </SettingSection>
       {canUseCustomSource(parameter) && (
diff --git a/frontend/src/metabase/parameters/components/ParameterSettings/ParameterSettings.unit.spec.tsx b/frontend/src/metabase/parameters/components/ParameterSettings/ParameterSettings.unit.spec.tsx
index 80d79e7ef7d..d91aa162b0e 100644
--- a/frontend/src/metabase/parameters/components/ParameterSettings/ParameterSettings.unit.spec.tsx
+++ b/frontend/src/metabase/parameters/components/ParameterSettings/ParameterSettings.unit.spec.tsx
@@ -8,6 +8,13 @@ interface SetupOpts {
   parameter?: UiParameter;
 }
 
+function fillValue(input: HTMLElement, value: string) {
+  userEvent.clear(input);
+  if (value.length) {
+    userEvent.type(input, value);
+  }
+}
+
 describe("ParameterSidebar", () => {
   it("should allow to change source settings for string parameters", () => {
     const { onChangeQueryType } = setup({
@@ -22,6 +29,33 @@ describe("ParameterSidebar", () => {
     expect(onChangeQueryType).toHaveBeenCalledWith("search");
   });
 
+  it("should not update the label if the input is blank", () => {
+    const { onChangeName } = setup({
+      parameter: createMockUiParameter({
+        name: "foo",
+        type: "string/=",
+        sectionId: "string",
+      }),
+    });
+    const labelInput = screen.getByLabelText("Label");
+    expect(labelInput).toHaveValue("foo");
+    fillValue(labelInput, "");
+    // expect there to be an error message with the text "Required"
+    expect(screen.getByText(/required/i)).toBeInTheDocument();
+    labelInput.blur();
+    // when the input blurs, the value should have reverted to the original
+    expect(onChangeName).not.toHaveBeenCalled();
+    expect(labelInput).toHaveValue("foo");
+    // the error message should disappear
+    expect(screen.queryByText(/required/i)).not.toBeInTheDocument();
+
+    // sanity check with a non-blank value
+    fillValue(labelInput, "bar");
+    labelInput.blur();
+    expect(onChangeName).toHaveBeenCalledWith("bar");
+    expect(labelInput).toHaveValue("bar");
+  });
+
   it("should allow to change source settings for location parameters", () => {
     const { onChangeQueryType } = setup({
       parameter: createMockUiParameter({
@@ -38,11 +72,12 @@ describe("ParameterSidebar", () => {
 
 const setup = ({ parameter = createMockUiParameter() }: SetupOpts = {}) => {
   const onChangeQueryType = jest.fn();
+  const onChangeName = jest.fn();
 
   renderWithProviders(
     <ParameterSettings
       parameter={parameter}
-      onChangeName={jest.fn()}
+      onChangeName={onChangeName}
       onChangeDefaultValue={jest.fn()}
       onChangeIsMultiSelect={jest.fn()}
       onChangeQueryType={onChangeQueryType}
@@ -52,5 +87,5 @@ const setup = ({ parameter = createMockUiParameter() }: SetupOpts = {}) => {
     />,
   );
 
-  return { onChangeQueryType };
+  return { onChangeQueryType, onChangeName };
 };
diff --git a/frontend/src/metabase/parameters/utils/dashboards.ts b/frontend/src/metabase/parameters/utils/dashboards.ts
index 728826838f7..9682b97768b 100644
--- a/frontend/src/metabase/parameters/utils/dashboards.ts
+++ b/frontend/src/metabase/parameters/utils/dashboards.ts
@@ -53,11 +53,8 @@ export function createParameter(
 
 export function setParameterName(
   parameter: Parameter,
-  name?: string,
+  name: string,
 ): Parameter {
-  if (!name) {
-    name = "unnamed";
-  }
   const slug = slugify(name);
   return {
     ...parameter,
diff --git a/frontend/src/metabase/parameters/utils/dashboards.unit.spec.js b/frontend/src/metabase/parameters/utils/dashboards.unit.spec.js
index 1c0ad619c45..ab58a83d8ff 100644
--- a/frontend/src/metabase/parameters/utils/dashboards.unit.spec.js
+++ b/frontend/src/metabase/parameters/utils/dashboards.unit.spec.js
@@ -98,10 +98,10 @@ describe("metabase/parameters/utils/dashboards", () => {
       });
     });
 
-    it("should default", () => {
+    it("should not default", () => {
       expect(setParameterName({}, "")).toEqual({
-        name: "unnamed",
-        slug: "unnamed",
+        name: "",
+        slug: "",
       });
     });
   });
-- 
GitLab