From cb51e574de31c7d4485a9dfbef3261f67c0b7495 Mon Sep 17 00:00:00 2001
From: Ryan Laurie <30528226+iethree@users.noreply.github.com>
Date: Mon, 12 Sep 2022 08:11:47 -0600
Subject: [PATCH] Action Creator 4: Derive parameter types from form settings
 on save (#25297)

* derive parameter types from form settings on save

* update template tag types with parameter types

* improve tests
---
 frontend/src/metabase-types/api/index.ts      |   1 +
 frontend/src/metabase-types/api/parameters.ts |  27 ++
 .../api/writeback-form-settings.ts            |  11 +-
 .../src/metabase/entities/actions/actions.ts  |  18 +-
 .../metabase/entities/actions/constants.ts    |  30 +++
 .../src/metabase/entities/actions/utils.ts    |  67 ++++-
 .../entities/actions/utils.unit.spec.ts       | 237 +++++++++++++++---
 .../FormCreator/FieldSettingsPopover.tsx      |   2 +-
 .../ActionCreator/FormCreator/constants.ts    |   6 +-
 .../ActionCreator/FormCreator/utils.ts        |  12 +-
 10 files changed, 358 insertions(+), 53 deletions(-)
 create mode 100644 frontend/src/metabase-types/api/parameters.ts
 create mode 100644 frontend/src/metabase/entities/actions/constants.ts

diff --git a/frontend/src/metabase-types/api/index.ts b/frontend/src/metabase-types/api/index.ts
index 0d9de43dfab..83c3028cca1 100644
--- a/frontend/src/metabase-types/api/index.ts
+++ b/frontend/src/metabase-types/api/index.ts
@@ -25,3 +25,4 @@ export * from "./timeline";
 export * from "./user";
 export * from "./writeback";
 export * from "./writeback-form-settings";
+export * from "./parameters";
diff --git a/frontend/src/metabase-types/api/parameters.ts b/frontend/src/metabase-types/api/parameters.ts
new file mode 100644
index 00000000000..312c18382c2
--- /dev/null
+++ b/frontend/src/metabase-types/api/parameters.ts
@@ -0,0 +1,27 @@
+export type StringParameterType =
+  | "string/="
+  | "string/!="
+  | "string/contains"
+  | "string/does-not-contain"
+  | "string/starts-with"
+  | "string/ends-with";
+
+export type NumberParameterType =
+  | "number/="
+  | "number/!="
+  | "number/between"
+  | "number/>="
+  | "number/<=";
+
+export type DateParameterType =
+  | "date/single"
+  | "date/range"
+  | "date/relative"
+  | "date/month-year"
+  | "date/quarter-year";
+("date/all-options");
+
+export type ParameterType =
+  | StringParameterType
+  | NumberParameterType
+  | DateParameterType;
diff --git a/frontend/src/metabase-types/api/writeback-form-settings.ts b/frontend/src/metabase-types/api/writeback-form-settings.ts
index 311575b6a3f..6b0df92174a 100644
--- a/frontend/src/metabase-types/api/writeback-form-settings.ts
+++ b/frontend/src/metabase-types/api/writeback-form-settings.ts
@@ -1,15 +1,16 @@
 export type FormType = "inline" | "modal";
-export type FieldType = "text" | "number" | "date" | "category";
+export type FieldType = "string" | "number" | "date" | "category";
+
+export type DateInputType = "date" | "datetime" | "monthyear" | "quarteryear";
+
 export type InputType =
+  | DateInputType
   | "string"
   | "text"
   | "number"
-  | "date"
-  | "datetime"
-  | "monthyear"
-  | "quarteryear"
   | "dropdown"
   | "inline-select";
+
 export type Size = "small" | "medium" | "large";
 
 export type DateRange = [string, string];
diff --git a/frontend/src/metabase/entities/actions/actions.ts b/frontend/src/metabase/entities/actions/actions.ts
index fb905ed5e45..08b581e0579 100644
--- a/frontend/src/metabase/entities/actions/actions.ts
+++ b/frontend/src/metabase/entities/actions/actions.ts
@@ -6,7 +6,11 @@ import type { ActionFormSettings } from "metabase-types/api";
 import { CardApi } from "metabase/services";
 
 import { saveForm } from "./forms";
-import { removeOrphanSettings } from "metabase/entities/actions/utils";
+import {
+  removeOrphanSettings,
+  setParameterTypesFromFieldSettings,
+  setTemplateTagTypesFromFieldSettings,
+} from "metabase/entities/actions/utils";
 
 type ActionParams = {
   name: string;
@@ -24,12 +28,17 @@ const getAPIFn =
     question,
     collection_id,
     formSettings,
-  }: ActionParams) =>
-    apifn({
+  }: ActionParams) => {
+    question = setTemplateTagTypesFromFieldSettings(formSettings, question);
+
+    return apifn({
       ...question.card(),
       name,
       description,
-      parameters: question.parameters(),
+      parameters: setParameterTypesFromFieldSettings(
+        formSettings,
+        question.parameters(),
+      ),
       is_write: true,
       display: "table",
       visualization_settings: removeOrphanSettings(
@@ -38,6 +47,7 @@ const getAPIFn =
       ),
       collection_id,
     });
+  };
 
 const createAction = getAPIFn(CardApi.create);
 const updateAction = getAPIFn(CardApi.update);
diff --git a/frontend/src/metabase/entities/actions/constants.ts b/frontend/src/metabase/entities/actions/constants.ts
new file mode 100644
index 00000000000..da65b5b94b7
--- /dev/null
+++ b/frontend/src/metabase/entities/actions/constants.ts
@@ -0,0 +1,30 @@
+import type { ParameterType } from "metabase-types/api";
+import type { TemplateTagType } from "metabase-types/types/Query";
+
+interface FieldTypeMap {
+  [key: string]: ParameterType;
+}
+
+interface TagTypeMap {
+  [key: string]: TemplateTagType;
+}
+
+export const fieldTypeToParameterTypeMap: FieldTypeMap = {
+  string: "string/=",
+  category: "string/=",
+  number: "number/=",
+};
+
+export const dateTypetoParameterTypeMap: FieldTypeMap = {
+  date: "date/single",
+  datetime: "date/single",
+  monthyear: "date/month-year",
+  quarteryear: "date/quarter-year",
+};
+
+export const fieldTypeToTagTypeMap: TagTypeMap = {
+  string: "text",
+  category: "text",
+  number: "number",
+  date: "date",
+};
diff --git a/frontend/src/metabase/entities/actions/utils.ts b/frontend/src/metabase/entities/actions/utils.ts
index 191e39f0ada..a437eaaef92 100644
--- a/frontend/src/metabase/entities/actions/utils.ts
+++ b/frontend/src/metabase/entities/actions/utils.ts
@@ -1,7 +1,21 @@
 import _ from "underscore";
+import type {
+  ActionFormSettings,
+  FieldType,
+  InputType,
+  ParameterType,
+} from "metabase-types/api";
 
-import type { ActionFormSettings } from "metabase-types/api";
 import type { Parameter as ParameterObject } from "metabase-types/types/Parameter";
+import type { TemplateTag, TemplateTagType } from "metabase-types/types/Query";
+import type NativeQuery from "metabase-lib/lib/queries/NativeQuery";
+import type Question from "metabase-lib/lib/Question";
+
+import {
+  fieldTypeToParameterTypeMap,
+  dateTypetoParameterTypeMap,
+  fieldTypeToTagTypeMap,
+} from "./constants";
 
 export const removeOrphanSettings = (
   settings: ActionFormSettings,
@@ -16,3 +30,54 @@ export const removeOrphanSettings = (
     fields: _.omit(settings.fields, orphanIds),
   };
 };
+
+const getParameterTypeFromFieldSettings = (
+  fieldType: FieldType,
+  inputType: InputType,
+): ParameterType => {
+  if (fieldType === "date") {
+    return dateTypetoParameterTypeMap[inputType] ?? "date/single";
+  }
+
+  return fieldTypeToParameterTypeMap[fieldType] ?? "string/=";
+};
+
+const getTagTypeFromFieldSettings = (fieldType: FieldType): TemplateTagType => {
+  return fieldTypeToTagTypeMap[fieldType] ?? "text";
+};
+
+export const setParameterTypesFromFieldSettings = (
+  settings: ActionFormSettings,
+  parameters: ParameterObject[],
+): ParameterObject[] => {
+  const fields = settings.fields;
+  return parameters.map(parameter => {
+    const field = fields[parameter.id];
+    return {
+      ...parameter,
+      type: field
+        ? getParameterTypeFromFieldSettings(field.fieldType, field.inputType)
+        : "string/=",
+    };
+  });
+};
+
+export const setTemplateTagTypesFromFieldSettings = (
+  settings: ActionFormSettings,
+  question: Question,
+): Question => {
+  const fields = settings.fields;
+
+  (question.query() as NativeQuery)
+    .templateTagsWithoutSnippets()
+    .forEach((tag: TemplateTag) => {
+      question = question.setQuery(
+        (question.query() as NativeQuery).setTemplateTag(tag.name, {
+          ...tag,
+          type: getTagTypeFromFieldSettings(fields[tag.id].fieldType),
+        }),
+      );
+    });
+
+  return question;
+};
diff --git a/frontend/src/metabase/entities/actions/utils.unit.spec.ts b/frontend/src/metabase/entities/actions/utils.unit.spec.ts
index 3204219160c..b9abc923da1 100644
--- a/frontend/src/metabase/entities/actions/utils.unit.spec.ts
+++ b/frontend/src/metabase/entities/actions/utils.unit.spec.ts
@@ -1,52 +1,217 @@
-import { removeOrphanSettings } from "./utils";
-
 import {
   getDefaultFormSettings,
   getDefaultFieldSettings,
 } from "metabase/writeback/components/ActionCreator/FormCreator/utils";
 
+import Question from "metabase-lib/lib/Question";
+import { metadata } from "__support__/sample_database_fixture";
+
 import type { Parameter as ParameterObject } from "metabase-types/types/Parameter";
 
+import {
+  removeOrphanSettings,
+  setParameterTypesFromFieldSettings,
+  setTemplateTagTypesFromFieldSettings,
+} from "./utils";
+
+import { NativeDatasetQuery } from "metabase-types/types/Card";
+
+const creatQuestionWithTemplateTags = (tagType: string) =>
+  new Question(
+    {
+      dataset_query: {
+        type: "native",
+        database: null,
+        native: {
+          query:
+            "INSERT INTO products (name, price) VALUES ({{name}}, {{price}});",
+          "template-tags": {
+            name: {
+              id: "aaa",
+              name: "name",
+              type: tagType,
+            },
+            price: {
+              id: "bbb",
+              name: "price",
+              type: tagType,
+            },
+          },
+        },
+      },
+    },
+    metadata,
+  );
+
 describe("entities > actions > utils", () => {
-  it("should remove orphan settings", () => {
-    const formSettings = getDefaultFormSettings();
-    formSettings.name = "test form";
-    formSettings.fields.aaa = getDefaultFieldSettings();
-    formSettings.fields.bbb = getDefaultFieldSettings();
-    formSettings.fields.ccc = getDefaultFieldSettings();
-
-    const parameters = [
-      { id: "aaa", name: "foo" },
-      { id: "ccc", name: "bar" },
-    ] as ParameterObject[];
-
-    const result = removeOrphanSettings(formSettings, parameters);
-
-    expect(result.name).toEqual("test form");
-    expect(result.fields).toHaveProperty("aaa");
-    expect(result.fields).toHaveProperty("ccc");
-    expect(result.fields).not.toHaveProperty("bbb");
+  describe("removeOrphanSettings", () => {
+    it("should remove orphan settings", () => {
+      const formSettings = getDefaultFormSettings({
+        name: "test form",
+        fields: {
+          aaa: getDefaultFieldSettings(),
+          bbb: getDefaultFieldSettings(),
+          ccc: getDefaultFieldSettings(),
+        },
+      });
+
+      const parameters = [
+        { id: "aaa", name: "foo" },
+        { id: "ccc", name: "bar" },
+      ] as ParameterObject[];
+
+      const result = removeOrphanSettings(formSettings, parameters);
+
+      expect(result.name).toEqual("test form");
+      expect(result.fields).toHaveProperty("aaa");
+      expect(result.fields).toHaveProperty("ccc");
+      expect(result.fields).not.toHaveProperty("bbb");
+    });
+
+    it("should leave non-orphan settings intact", () => {
+      const formSettings = getDefaultFormSettings({
+        name: "test form",
+        fields: {
+          aaa: getDefaultFieldSettings(),
+          bbb: getDefaultFieldSettings(),
+          ccc: getDefaultFieldSettings(),
+        },
+      });
+
+      const parameters = [
+        { id: "aaa", name: "foo" },
+        { id: "bbb", name: "foo" },
+        { id: "ccc", name: "bar" },
+      ] as ParameterObject[];
+
+      const result = removeOrphanSettings(formSettings, parameters);
+
+      expect(result.name).toEqual("test form");
+      expect(result.fields).toHaveProperty("aaa");
+      expect(result.fields).toHaveProperty("bbb");
+      expect(result.fields).toHaveProperty("ccc");
+    });
   });
 
-  it("should leave non-orphan settings intact", () => {
-    const formSettings = getDefaultFormSettings();
-    formSettings.name = "test form";
+  describe("setParameterTypesFromFieldSettings", () => {
+    it("should set string parameter types", () => {
+      const formSettings = getDefaultFormSettings({
+        name: "test form",
+        fields: {
+          aaa: getDefaultFieldSettings({ fieldType: "string" }),
+          bbb: getDefaultFieldSettings({ fieldType: "string" }),
+          ccc: getDefaultFieldSettings({ fieldType: "string" }),
+        },
+      });
+
+      const parameters = [
+        { id: "aaa", name: "foo", type: "number/=" },
+        { id: "bbb", name: "foo", type: "number/=" },
+        { id: "ccc", name: "bar", type: "number/=" },
+      ] as ParameterObject[];
+
+      const newParams = setParameterTypesFromFieldSettings(
+        formSettings,
+        parameters,
+      );
+
+      newParams.forEach(param => expect(param.type).toEqual("string/="));
+    });
+
+    it("should set number parameter types", () => {
+      const formSettings = getDefaultFormSettings({
+        name: "test form",
+        fields: {
+          aaa: getDefaultFieldSettings({ fieldType: "number" }),
+          bbb: getDefaultFieldSettings({ fieldType: "number" }),
+          ccc: getDefaultFieldSettings({ fieldType: "number" }),
+        },
+      });
+
+      const parameters = [
+        { id: "aaa", name: "foo", type: "string/=" },
+        { id: "bbb", name: "foo", type: "string/=" },
+        { id: "ccc", name: "bar", type: "string/=" },
+      ] as ParameterObject[];
+
+      const newParams = setParameterTypesFromFieldSettings(
+        formSettings,
+        parameters,
+      );
+
+      newParams.forEach(param => expect(param.type).toEqual("number/="));
+    });
+
+    it("should set date parameter types", () => {
+      const formSettings = getDefaultFormSettings({
+        name: "test form",
+        fields: {
+          aaa: getDefaultFieldSettings({ fieldType: "date" }),
+          bbb: getDefaultFieldSettings({ fieldType: "date" }),
+          ccc: getDefaultFieldSettings({ fieldType: "date" }),
+        },
+      });
+
+      const parameters = [
+        { id: "aaa", name: "foo", type: "string/=" },
+        { id: "bbb", name: "foo", type: "string/=" },
+        { id: "ccc", name: "bar", type: "string/=" },
+      ] as ParameterObject[];
+
+      const newParams = setParameterTypesFromFieldSettings(
+        formSettings,
+        parameters,
+      );
+
+      newParams.forEach(param => expect(param.type).toEqual("date/single"));
+    });
+  });
+
+  describe("setTemplateTagTypesFromFieldSettings", () => {
+    it("should set text and number template tag types", () => {
+      const question = creatQuestionWithTemplateTags("date");
+
+      const formSettings = getDefaultFormSettings({
+        name: "test form",
+        fields: {
+          aaa: getDefaultFieldSettings({ fieldType: "string" }),
+          bbb: getDefaultFieldSettings({ fieldType: "number" }),
+        },
+      });
+
+      const newQuestion = setTemplateTagTypesFromFieldSettings(
+        formSettings,
+        question,
+      );
+
+      const tags = (newQuestion.card().dataset_query as NativeDatasetQuery)
+        .native["template-tags"];
+
+      expect(tags.name.type).toEqual("text");
+      expect(tags.price.type).toEqual("number");
+    });
+
+    it("should set date template tag types", () => {
+      const question = creatQuestionWithTemplateTags("number");
 
-    formSettings.fields.aaa = getDefaultFieldSettings();
-    formSettings.fields.bbb = getDefaultFieldSettings();
-    formSettings.fields.ccc = getDefaultFieldSettings();
+      const formSettings = getDefaultFormSettings({
+        name: "test form",
+        fields: {
+          aaa: getDefaultFieldSettings({ fieldType: "date" }),
+          bbb: getDefaultFieldSettings({ fieldType: "date" }),
+        },
+      });
 
-    const parameters = [
-      { id: "aaa", name: "foo" },
-      { id: "bbb", name: "foo" },
-      { id: "ccc", name: "bar" },
-    ] as ParameterObject[];
+      const newQuestion = setTemplateTagTypesFromFieldSettings(
+        formSettings,
+        question,
+      );
 
-    const result = removeOrphanSettings(formSettings, parameters);
+      const tags = (newQuestion.card().dataset_query as NativeDatasetQuery)
+        .native["template-tags"];
 
-    expect(result.name).toEqual("test form");
-    expect(result.fields).toHaveProperty("aaa");
-    expect(result.fields).toHaveProperty("bbb");
-    expect(result.fields).toHaveProperty("ccc");
+      expect(tags.name.type).toEqual("date");
+      expect(tags.price.type).toEqual("date");
+    });
   });
 });
diff --git a/frontend/src/metabase/writeback/components/ActionCreator/FormCreator/FieldSettingsPopover.tsx b/frontend/src/metabase/writeback/components/ActionCreator/FormCreator/FieldSettingsPopover.tsx
index c9d954ea3c5..fd7bde1d163 100644
--- a/frontend/src/metabase/writeback/components/ActionCreator/FormCreator/FieldSettingsPopover.tsx
+++ b/frontend/src/metabase/writeback/components/ActionCreator/FormCreator/FieldSettingsPopover.tsx
@@ -107,7 +107,7 @@ function InputTypeSelect({
     <Radio
       vertical
       value={value}
-      options={inputTypes[fieldType ?? "text"]}
+      options={inputTypes[fieldType ?? "string"]}
       onChange={onChange}
     />
   );
diff --git a/frontend/src/metabase/writeback/components/ActionCreator/FormCreator/constants.ts b/frontend/src/metabase/writeback/components/ActionCreator/FormCreator/constants.ts
index 384a7ed5045..4930527502a 100644
--- a/frontend/src/metabase/writeback/components/ActionCreator/FormCreator/constants.ts
+++ b/frontend/src/metabase/writeback/components/ActionCreator/FormCreator/constants.ts
@@ -8,7 +8,7 @@ interface FieldOptionType {
 
 export const getFieldTypes = (): FieldOptionType[] => [
   {
-    value: "text",
+    value: "string",
     name: t`text`,
   },
   {
@@ -31,7 +31,7 @@ interface InputOptionType {
 }
 
 interface InputOptionsMap {
-  text: InputOptionType[];
+  string: InputOptionType[];
   number: InputOptionType[];
   date: InputOptionType[];
   category: InputOptionType[];
@@ -60,7 +60,7 @@ const getSelectInputs = (): InputOptionType[] => [
 ];
 
 export const getInputTypes = (): InputOptionsMap => ({
-  text: [...getTextInputs(), ...getSelectInputs()],
+  string: [...getTextInputs(), ...getSelectInputs()],
   number: [
     {
       value: "number",
diff --git a/frontend/src/metabase/writeback/components/ActionCreator/FormCreator/utils.ts b/frontend/src/metabase/writeback/components/ActionCreator/FormCreator/utils.ts
index b8c9f5e929b..e34d488fb0d 100644
--- a/frontend/src/metabase/writeback/components/ActionCreator/FormCreator/utils.ts
+++ b/frontend/src/metabase/writeback/components/ActionCreator/FormCreator/utils.ts
@@ -1,21 +1,27 @@
 import type { ActionFormSettings, FieldSettings } from "metabase-types/api";
 
-export const getDefaultFormSettings = (): ActionFormSettings => ({
+export const getDefaultFormSettings = (
+  overrides: Partial<ActionFormSettings> = {},
+): ActionFormSettings => ({
   name: "",
   type: "inline",
   description: "",
   fields: {},
   confirmMessage: "",
+  ...overrides,
 });
 
-export const getDefaultFieldSettings = (): FieldSettings => ({
+export const getDefaultFieldSettings = (
+  overrides: Partial<FieldSettings> = {},
+): FieldSettings => ({
   name: "",
   order: 0,
   description: "",
   placeholder: "",
-  fieldType: "text",
+  fieldType: "string",
   inputType: "string",
   required: false,
   hidden: false,
   width: "medium",
+  ...overrides,
 });
-- 
GitLab