Skip to content
Snippets Groups Projects
Code owners
Assign users and groups as approvers for specific file changes. Learn more.
actions-on-dashboards.cy.spec.js 37.13 KiB
import { assocIn } from "icepick";
import _ from "underscore";
import {
  restore,
  queryWritableDB,
  resetTestTable,
  createModelFromTableName,
  fillActionQuery,
  resyncDatabase,
  visitDashboard,
  editDashboard,
  saveDashboard,
  modal,
  setFilter,
  sidebar,
  popover,
  filterWidget,
  createImplicitAction,
  dragField,
  createAction,
  describeWithSnowplow,
  enableTracking,
  resetSnowplow,
  expectNoBadSnowplowEvents,
  expectGoodSnowplowEvent,
} from "e2e/support/helpers";

import { many_data_types_rows } from "e2e/support/test_tables_data";

import { WRITABLE_DB_ID } from "e2e/support/cypress_data";
import { createMockActionParameter } from "metabase-types/api/mocks";
import { addWidgetStringFilter } from "../native-filters/helpers/e2e-field-filter-helpers";

const TEST_TABLE = "scoreboard_actions";
const TEST_COLUMNS_TABLE = "many_data_types";
const MODEL_NAME = "Test Action Model";

["mysql", "postgres"].forEach(dialect => {
  describe(
    `Write Actions on Dashboards (${dialect})`,
    { tags: ["@external", "@actions"] },
    () => {
      beforeEach(() => {
        cy.intercept("GET", /\/api\/card\/\d+/).as("getModel");
        cy.intercept("GET", "/api/card?f=using_model&model_id=**").as(
          "getCardAssociations",
        );
        cy.intercept("GET", "/api/action").as("getActions");
        cy.intercept("PUT", "/api/action/*").as("updateAction");
        cy.intercept("GET", "/api/action?model-id=*").as("getModelActions");

        cy.intercept(
          "GET",
          "/api/dashboard/*/dashcard/*/execute?parameters=*",
        ).as("executePrefetch");

        cy.intercept("POST", "/api/dashboard/*/dashcard/*/execute").as(
          "executeAPI",
        );
      });

      describeWithSnowplow("adding and executing actions", () => {
        beforeEach(() => {
          resetSnowplow();
          resetTestTable({ type: dialect, table: TEST_TABLE });
          restore(`${dialect}-writable`);
          cy.signInAsAdmin();
          enableTracking();
          resyncDatabase({ dbId: WRITABLE_DB_ID, tableName: TEST_TABLE });
          createModelFromTableName({
            tableName: TEST_TABLE,
            modelName: MODEL_NAME,
          });
        });

        afterEach(() => {
          expectNoBadSnowplowEvents();
        });

        it("adds a custom query action to a dashboard and runs it", () => {
          const ACTION_NAME = "Update Score";

          queryWritableDB(
            `SELECT * FROM ${TEST_TABLE} WHERE id = 1`,
            dialect,
          ).then(result => {
            expect(result.rows.length).to.equal(1);
            expect(result.rows[0].score).to.equal(0);
          });

          cy.get("@modelId").then(id => {
            cy.visit(`/model/${id}/detail`);
            cy.wait(["@getModel", "@getModelActions", "@getCardAssociations"]);
          });

          cy.findByRole("tab", { name: "Actions" }).click();

          cy.findByTestId("model-actions-header")
            .findByText("New action")
            .click();

          cy.findByRole("dialog").within(() => {
            fillActionQuery(
              `UPDATE ${TEST_TABLE} SET score = {{ new_score }} WHERE id = {{ id }}`,
            );
          });

          // can't have this in the .within() because it needs access to document.body
          reorderFields();

          cy.findByRole("dialog").within(() => {
            cy.findAllByText("Number").each(el => {
              cy.wrap(el).click();
            });
            cy.findByText("Save").click();
          });

          cy.findByPlaceholderText("My new fantastic action").type(ACTION_NAME);
          cy.findByTestId("create-action-form").button("Create").click();

          createDashboardWithActionButton({
            actionName: ACTION_NAME,
            idFilter: true,
          });

          expectGoodSnowplowEvent({
            event: "new_action_card_created",
          });

          filterWidget().click();
          addWidgetStringFilter("1");

          cy.findByRole("button", { name: "Update Score" }).click();

          cy.findByRole("dialog").within(() => {
            cy.findByLabelText("New Score").type("55");
            cy.button(ACTION_NAME).click();
          });

          cy.wait("@executeAPI");

          queryWritableDB(
            `SELECT * FROM ${TEST_TABLE} WHERE id = 1`,
            dialect,
          ).then(result => {
            expect(result.rows.length).to.equal(1);
            expect(result.rows[0].score).to.equal(55);
          });
        });

        it("adds an implicit create action to a dashboard and runs it", () => {
          cy.get("@modelId").then(id => {
            createImplicitAction({
              kind: "create",
              model_id: id,
            });
          });

          createDashboardWithActionButton({
            actionName: "Create",
          });

          expectGoodSnowplowEvent({
            event: "new_action_card_created",
          });

          cy.findByRole("button", { name: "Create" }).click();

          modal().within(() => {
            cy.findByPlaceholderText("Team Name").type("Zany Zebras");
            cy.findByPlaceholderText("Score").type("44");

            cy.button("Save").click();
          });

          cy.wait("@executeAPI");

          queryWritableDB(
            `SELECT * FROM ${TEST_TABLE} WHERE team_name = 'Zany Zebras'`,
            dialect,
          ).then(result => {
            expect(result.rows.length).to.equal(1);

            expect(result.rows[0].score).to.equal(44);
          });
        });

        it("adds an implicit update action to a dashboard and runs it", () => {
          const actionName = "Update";

          cy.get("@modelId").then(id => {
            createImplicitAction({
              kind: "update",
              model_id: id,
            });
          });

          createDashboardWithActionButton({
            actionName,
            idFilter: true,
          });

          expectGoodSnowplowEvent({
            event: "new_action_card_created",
          });

          filterWidget().click();
          addWidgetStringFilter("5");

          cy.findByRole("button", { name: actionName }).click();

          cy.wait("@executePrefetch");
          // let's check that the existing values are pre-filled correctly
          modal().within(() => {
            cy.findByPlaceholderText("Team Name")
              .should("have.value", "Energetic Elephants")
              .clear()
              .type("Emotional Elephants");

            cy.findByPlaceholderText("Score")
              .should("have.value", "30")
              .clear()
              .type("88");

            cy.button("Update").click();
          });

          cy.wait("@executeAPI");

          queryWritableDB(
            `SELECT * FROM ${TEST_TABLE} WHERE team_name = 'Emotional Elephants'`,
            dialect,
          ).then(result => {
            expect(result.rows.length).to.equal(1);

            expect(result.rows[0].score).to.equal(88);
          });
        });

        it("adds an implicit delete action to a dashboard and runs it", () => {
          queryWritableDB(
            `SELECT * FROM ${TEST_TABLE} WHERE team_name = 'Cuddly Cats'`,
            dialect,
          ).then(result => {
            expect(result.rows.length).to.equal(1);
            expect(result.rows[0].id).to.equal(3);
          });

          cy.get("@modelId").then(id => {
            createImplicitAction({
              kind: "delete",
              model_id: id,
            });
          });

          createDashboardWithActionButton({
            actionName: "Delete",
          });

          expectGoodSnowplowEvent({
            event: "new_action_card_created",
          });

          cy.findByRole("button", { name: "Delete" }).click();

          modal().within(() => {
            cy.findByPlaceholderText("ID").type("3");
            cy.button("Delete").click();
          });

          cy.wait("@executeAPI");

          queryWritableDB(
            `SELECT * FROM ${TEST_TABLE} WHERE team_name = 'Cuddly Cats'`,
            dialect,
          ).then(result => {
            expect(result.rows.length).to.equal(0);
          });
        });
        describe("hidden fields", () => {
          it("adds an implicit action and runs it", () => {
            cy.get("@modelId").then(id => {
              createImplicitAction({
                kind: "create",
                model_id: id,
              });
            });

            createDashboardWithActionButton({
              actionName: "Create",
              hideField: "Created At",
            });

            cy.findByRole("button", { name: "Create" }).click();

            modal().within(() => {
              cy.findByPlaceholderText("Team Name").type("Zany Zebras");
              cy.findByPlaceholderText("Score").type("44");
              cy.findByPlaceholderText("Created At").should("not.exist");

              cy.button("Save").click();
            });

            cy.wait("@executeAPI");

            queryWritableDB(
              `SELECT * FROM ${TEST_TABLE} WHERE team_name = 'Zany Zebras'`,
              dialect,
            ).then(result => {
              expect(result.rows.length).to.equal(1);

              expect(result.rows[0].score).to.equal(44);
            });
          });

          it("adds a query action and runs it", () => {
            const ACTION_NAME = "Update Score";

            queryWritableDB(
              `SELECT * FROM ${TEST_TABLE} WHERE id = 1`,
              dialect,
            ).then(result => {
              expect(result.rows.length).to.equal(1);
              expect(result.rows[0].score).to.equal(0);
            });

            cy.get("@modelId").then(id => {
              cy.visit(`/model/${id}/detail`);
              cy.wait([
                "@getModel",
                "@getModelActions",
                "@getCardAssociations",
              ]);
            });

            cy.findByRole("tab", { name: "Actions" }).click();

            cy.findByTestId("model-actions-header")
              .findByText("New action")
              .click();

            cy.findByRole("dialog").within(() => {
              fillActionQuery(
                `UPDATE ${TEST_TABLE} SET score = {{ new_score }} WHERE id = {{ id }} [[ and status = {{ current_status }}]]`,
              );
            });

            reorderFields();
            cy.findByRole("dialog").within(() => {
              cy.findAllByText("Number").each(el => {
                cy.wrap(el).click();
              });

              // hide optional field
              formFieldContainer("Current Status").within(() => {
                cy.findByText("Text").click();

                toggleFieldVisibility();
                openFieldSettings();
              });
            });

            popover().within(() => {
              cy.findByLabelText("Required").uncheck();
            });

            cy.findByRole("dialog").within(() => {
              cy.findByText("Save").click();
            });

            cy.findByPlaceholderText("My new fantastic action").type(
              ACTION_NAME,
            );
            cy.findByTestId("create-action-form").button("Create").click();

            createDashboardWithActionButton({
              actionName: ACTION_NAME,
            });

            cy.findByRole("button", { name: "Update Score" }).click();

            cy.findByRole("dialog").within(() => {
              cy.findByLabelText("ID").type("1");
              cy.findByLabelText("New Score").type("55");
              // it's hidden
              cy.findByLabelText("Current Status").should("not.exist");

              cy.button(ACTION_NAME).click();
            });

            cy.wait("@executeAPI");

            queryWritableDB(
              `SELECT * FROM ${TEST_TABLE} WHERE id = 1`,
              dialect,
            ).then(result => {
              expect(result.rows.length).to.equal(1);
              expect(result.rows[0].score).to.equal(55);
            });

            cy.get("@modelId").then(id => {
              cy.visit(`/model/${id}/detail`);
              cy.wait([
                "@getModel",
                "@getModelActions",
                "@getCardAssociations",
              ]);
            });

            cy.findByRole("tab", { name: "Actions" }).click();

            cy.get("[aria-label='Update Score']").within(() => {
              cy.icon("ellipsis").click();
            });

            popover().within(() => {
              cy.findByText("Edit").click();
            });

            cy.findByRole("dialog").within(() => {
              formFieldContainer("Current Status").within(() => {
                toggleFieldVisibility();

                openFieldSettings();
              });
            });

            popover().within(() => {
              cy.findByLabelText("Required").check();
            });

            cy.findByRole("dialog").within(() => {
              cy.findByText("Update").click();
            });

            cy.get("@dashboardId").then(dashboardId => {
              visitDashboard(dashboardId);
            });

            cy.findByRole("button", { name: "Update Score" }).click();

            cy.findByRole("dialog").within(() => {
              cy.findByLabelText("ID").type("1");
              cy.findByLabelText("New Score").type("56");
              cy.findByLabelText("Current Status").type("active");

              cy.button(ACTION_NAME).click();
            });

            cy.wait("@executeAPI");

            queryWritableDB(
              `SELECT * FROM ${TEST_TABLE} WHERE id = 1`,
              dialect,
            ).then(result => {
              expect(result.rows.length).to.equal(1);
              expect(result.rows[0].score).to.equal(56);
            });
          });
        });
      });

      describe(`Actions Data Types`, () => {
        beforeEach(() => {
          resetTestTable({ type: dialect, table: TEST_COLUMNS_TABLE });
          restore(`${dialect}-writable`);
          cy.signInAsAdmin();
          resyncDatabase({
            dbId: WRITABLE_DB_ID,
            tableName: TEST_COLUMNS_TABLE,
          });
          createModelFromTableName({
            tableName: TEST_COLUMNS_TABLE,
            modelName: MODEL_NAME,
          });
        });

        it("can update various data types via implicit actions", () => {
          cy.get("@modelId").then(id => {
            createImplicitAction({
              kind: "update",
              model_id: id,
            });
          });

          createDashboardWithActionButton({
            actionName: "Update",
            idFilter: true,
          });

          filterWidget().click();
          addWidgetStringFilter("1");

          cy.findByRole("button", { name: "Update" }).click();

          cy.wait("@executePrefetch");

          const oldRow = many_data_types_rows[0];

          modal().within(() => {
            changeValue({
              fieldName: "UUID",
              fieldType: "text",
              oldValue: oldRow.uuid,
              newValue: "a0eebc99-9c0b-4ef8-bb6d-6bb9bd380a77",
            });

            changeValue({
              fieldName: "Integer",
              fieldType: "number",
              oldValue: oldRow.integer,
              newValue: 123,
            });

            changeValue({
              fieldName: "Float",
              fieldType: "number",
              oldValue: oldRow.float,
              newValue: 2.2,
            });

            cy.findByLabelText("Boolean").should("be.checked").click();

            changeValue({
              fieldName: "String",
              fieldType: "text",
              oldValue: oldRow.string,
              newValue: "new string",
            });

            changeValue({
              fieldName: "Date",
              fieldType: "date",
              oldValue: oldRow.date,
              newValue: "2020-05-01",
            });

            // we can't assert on this value because mysql and postgres seem to
            // handle timezones differently 🥴
            cy.findByPlaceholderText("TimestampTZ")
              .should("have.attr", "type", "datetime-local")
              .clear()
              .type("2020-05-01T16:45:00");

            cy.button("Update").click();
          });

          cy.wait("@executeAPI");

          queryWritableDB(
            `SELECT * FROM ${TEST_COLUMNS_TABLE} WHERE id = 1`,
            dialect,
          ).then(result => {
            expect(result.rows.length).to.equal(1);

            const row = result.rows[0];

            expect(row).to.have.property(
              "uuid",
              "a0eebc99-9c0b-4ef8-bb6d-6bb9bd380a77",
            );
            expect(row).to.have.property("integer", 123);
            expect(row).to.have.property("float", 2.2);
            expect(row).to.have.property("string", "new string");
            expect(row).to.have.property(
              "boolean",
              dialect === "mysql" ? 0 : false,
            );
            expect(row.date).to.include("2020-05-01"); // js converts this to a full date obj
            expect(row.timestampTZ).to.include("2020-05-01"); // we got timezone issues here
          });
        });

        it("can insert various data types via implicit actions", () => {
          cy.get("@modelId").then(id => {
            createImplicitAction({
              kind: "create",
              model_id: id,
            });
          });

          createDashboardWithActionButton({
            actionName: "Create",
          });

          cy.findByRole("button", { name: "Create" }).click();

          modal().within(() => {
            cy.findByPlaceholderText("UUID").type(
              "a0eebc99-9c0b-4ef8-bb6d-6bb9bd380a15",
            );

            cy.findByPlaceholderText("Integer").type("-20");
            cy.findByPlaceholderText("IntegerUnsigned").type("20");
            cy.findByPlaceholderText("Tinyint").type("101");
            cy.findByPlaceholderText("Tinyint1").type("1");
            cy.findByPlaceholderText("Smallint").type("32767");
            cy.findByPlaceholderText("Mediumint").type("8388607");
            cy.findByPlaceholderText("Bigint").type("922337204775");
            cy.findByPlaceholderText("Float").type("3.4");
            cy.findByPlaceholderText("Double").type("1.79769313486");
            cy.findByPlaceholderText("Decimal").type("123901.21");

            cy.findByLabelText("Boolean").click();

            cy.findByPlaceholderText("String").type("Zany Zebras");
            cy.findByPlaceholderText("Text").type("Zany Zebras");

            cy.findByPlaceholderText("Date").type("2020-02-01");
            cy.findByPlaceholderText("Datetime").type("2020-03-01T12:00:00");
            cy.findByPlaceholderText("DatetimeTZ").type("2020-03-01T12:00:00");
            cy.findByPlaceholderText("Time").type("12:57:57");
            cy.findByPlaceholderText("Timestamp").type("2020-03-01T12:00:00");
            cy.findByPlaceholderText("TimestampTZ").type("2020-03-01T12:00:00");

            cy.button("Save").click();
          });

          cy.wait("@executeAPI");

          queryWritableDB(
            `SELECT * FROM ${TEST_COLUMNS_TABLE} WHERE string = 'Zany Zebras'`,
            dialect,
          ).then(result => {
            expect(result.rows.length).to.equal(1);
            const row = result.rows[0];

            expect(row.uuid).to.equal("a0eebc99-9c0b-4ef8-bb6d-6bb9bd380a15");

            expect(row.integer).to.equal(-20);
            expect(row.integerUnsigned).to.equal(20);
            expect(row.tinyint).to.equal(101);
            expect(row.tinyint1).to.equal(1);
            expect(row.smallint).to.equal(32767);
            expect(row.mediumint).to.equal(8388607);
            expect(row.bigint).to.equal(
              dialect === "mysql" ? 922337204775 : String(922337204775), // the pg driver makes this a string
            );
            expect(row.float).to.equal(3.4);
            expect(row.double).to.equal(1.79769313486);
            expect(row.decimal).to.equal("123901.21"); // js needs this to be a string

            expect(row.boolean).to.equal(dialect === "mysql" ? 1 : true);

            expect(row.string).to.equal("Zany Zebras");
            expect(row.text).to.equal("Zany Zebras");

            expect(row.date).to.include("2020-02-01"); // js converts this to a full date

            // timezones are problematic here
            expect(row.datetime).to.include("2020-03-01");
            expect(row.datetimeTZ).to.include("2020-03-01");
            expect(row.time).to.include("57:57");
            expect(row.timestamp).to.include("2020-03-01");
            expect(row.timestampTZ).to.include("2020-03-01");
          });
        });

        it("does not show json, enum, or binary columns for implicit actions", () => {
          cy.get("@modelId").then(id => {
            createImplicitAction({
              kind: "create",
              model_id: id,
            });
          });

          createDashboardWithActionButton({
            actionName: "Create",
            idFilter: true,
          });

          cy.findByRole("button", { name: "Create" }).click();

          modal().within(() => {
            cy.findByPlaceholderText("UUID").should("be.visible");
            cy.findByPlaceholderText("JSON").should("not.exist");
            cy.findByPlaceholderText("JSONB").should("not.exist");
            cy.findByPlaceholderText("Binary").should("not.exist");

            if (dialect === "mysql") {
              // we only have enums in postgres as of Feb 2023
              cy.findByPlaceholderText("Enum").should("not.exist");
            }
          });
        });

        it("properly loads and updates date and time fields for implicit update actions", () => {
          cy.get("@modelId").then(id => {
            createImplicitAction({
              kind: "update",
              model_id: id,
            });
          });

          createDashboardWithActionButton({
            actionName: "Update",
            idFilter: true,
          });

          filterWidget().click();
          addWidgetStringFilter("1");

          cy.findByRole("button", { name: "Update" }).click();

          cy.wait("@executePrefetch");

          const oldRow = many_data_types_rows[0];
          const newTime = "2020-01-10T01:35:55";

          modal().within(() => {
            changeValue({
              fieldName: "Date",
              fieldType: "date",
              oldValue: oldRow.date,
              newValue: newTime.slice(0, 10),
            });

            changeValue({
              fieldName: "Datetime",
              fieldType: "datetime-local",
              oldValue: oldRow.datetime.replace(" ", "T"),
              newValue: newTime,
            });

            changeValue({
              fieldName: "Time",
              fieldType: "time",
              oldValue: oldRow.time,
              newValue: newTime.slice(-8),
            });

            changeValue({
              fieldName: "Timestamp",
              fieldType: "datetime-local",
              oldValue: oldRow.timestamp.replace(" ", "T"),
              newValue: newTime,
            });

            // only postgres has timezone-aware columns
            // the instance is in US/Pacific so it's -8 hours
            if (dialect === "postgres") {
              changeValue({
                fieldName: "DatetimeTZ",
                fieldType: "datetime-local",
                oldValue: "2020-01-01T00:35:55",
                newValue: newTime,
              });

              changeValue({
                fieldName: "TimestampTZ",
                fieldType: "datetime-local",
                oldValue: "2020-01-01T00:35:55",
                newValue: newTime,
              });
            }

            if (dialect === "mysql") {
              changeValue({
                fieldName: "DatetimeTZ",
                fieldType: "datetime-local",
                oldValue: oldRow.datetimeTZ.replace(" ", "T"),
                newValue: newTime,
              });

              changeValue({
                fieldName: "TimestampTZ",
                fieldType: "datetime-local",
                oldValue: oldRow.timestampTZ.replace(" ", "T"),
                newValue: newTime,
              });
            }
            cy.button("Update").click();
          });

          cy.wait("@executeAPI");

          queryWritableDB(
            `SELECT * FROM ${TEST_COLUMNS_TABLE} WHERE id = 1`,
            dialect,
          ).then(result => {
            const row = result.rows[0];

            // the driver adds a time to this date so we have to use .include
            expect(row.date).to.include(newTime.slice(0, 10));
            expect(row.time).to.equal(newTime.slice(-8));

            // metabase is smart and localizes these, so all of these are +8 hours
            const newTimeAdjusted = newTime.replace("T01", "T09");
            // we need to use .include because the driver adds milliseconds to the timestamp
            expect(row.datetime).to.include(newTimeAdjusted);
            expect(row.timestamp).to.include(newTimeAdjusted);
            expect(row.datetimeTZ).to.include(newTimeAdjusted);
            expect(row.timestampTZ).to.include(newTimeAdjusted);
          });
        });
      });

      describe("editing action before executing it", () => {
        const PG_DB_ID = 2;
        const WRITABLE_TEST_TABLE = "scoreboard_actions";

        const TEST_PARAMETER = createMockActionParameter({
          id: "49596bcb-62bb-49d6-a92d-bf5dbfddf43b",
          name: "Total",
          slug: "total",
          type: "number/=",
          target: ["variable", ["template-tag", "total"]],
        });

        const TEST_TEMPLATE_TAG = {
          id: TEST_PARAMETER.id,
          type: "number",
          name: TEST_PARAMETER.slug,
          "display-name": TEST_PARAMETER.name,
          slug: TEST_PARAMETER.slug,
        };

        const SAMPLE_QUERY_ACTION = {
          name: "Demo Action",
          type: "query",
          parameters: [TEST_PARAMETER],
          database_id: PG_DB_ID,
          dataset_query: {
            type: "native",
            native: {
              query: `UPDATE ORDERS SET TOTAL = TOTAL WHERE ID = {{ ${TEST_TEMPLATE_TAG.name} }}`,
              "template-tags": {
                [TEST_TEMPLATE_TAG.name]: TEST_TEMPLATE_TAG,
              },
            },
            database: PG_DB_ID,
          },
          visualization_settings: {
            fields: {
              [TEST_PARAMETER.id]: {
                id: TEST_PARAMETER.id,
                required: true,
                fieldType: "number",
                inputType: "number",
              },
            },
          },
        };

        const SAMPLE_WRITABLE_QUERY_ACTION = assocIn(
          SAMPLE_QUERY_ACTION,
          ["dataset_query", "native", "query"],
          `UPDATE ${WRITABLE_TEST_TABLE} SET score = 22 WHERE id = {{ ${TEST_TEMPLATE_TAG.name} }}`,
        );

        beforeEach(() => {
          resetTestTable({ type: dialect, table: TEST_COLUMNS_TABLE });
          restore(`${dialect}-writable`);
          cy.signInAsAdmin();
          resyncDatabase({
            dbId: WRITABLE_DB_ID,
            tableName: TEST_COLUMNS_TABLE,
          });
          createModelFromTableName({
            tableName: TEST_COLUMNS_TABLE,
            modelName: MODEL_NAME,
          });

          cy.get("@modelId").then(modelId => {
            createAction({
              ...SAMPLE_WRITABLE_QUERY_ACTION,
              model_id: modelId,
            });
          });

          createDashboardWithActionButton({
            actionName: SAMPLE_QUERY_ACTION.name,
          });
        });

        it("allows to edit action title and field placeholder in action execute modal", () => {
          clickHelper(SAMPLE_QUERY_ACTION.name);

          getActionParametersInputModal().within(() => {
            cy.icon("pencil").click();
          });

          actionEditorModal().within(() => {
            cy.findByText(SAMPLE_QUERY_ACTION.name)
              .click()
              .clear()
              .type("New action name");

            cy.findByTestId("action-form-editor").within(() => {
              cy.icon("gear").click();
            });
          });

          popover().within(() => {
            cy.findByText("Placeholder text").click().type("Test placeholder");
          });

          actionEditorModal().within(() => {
            cy.button("Update").click();
          });

          getActionParametersInputModal().within(() => {
            cy.findByTestId("modal-header").findByText("New action name");

            cy.findAllByPlaceholderText("Test placeholder");
          });
        });
        it("allows to edit action query and parameters in action execute modal", () => {
          clickHelper(SAMPLE_QUERY_ACTION.name);

          getActionParametersInputModal().within(() => {
            cy.icon("pencil").click();
          });

          actionEditorModal().within(() => {
            cy.findByTestId("native-query-editor").click();

            cy.get(".ace_content").type(
              _.times(23, () => "{leftArrow}")
                .join("")
                .concat("{backspace}{backspace}"),
            );
            cy.get(".ace_text-input").type(`{{ score }}`, {
              force: true,
              parseSpecialCharSequences: false,
            });

            cy.findByTestId("action-form-editor").within(() => {
              cy.findAllByText("Number").click({ multiple: true });
            });
          });

          actionEditorModal().within(() => {
            cy.button("Update").click();
          });

          getActionParametersInputModal().within(() => {
            cy.findByLabelText("Total").type(`123`);
            cy.findByLabelText("Score").type(`345`);

            cy.button(SAMPLE_QUERY_ACTION.name).click();
          });

          cy.wait("@executeAPI").then(interception => {
            expect(
              Object.values(interception.request.body.parameters)
                .sort()
                .join(","),
            ).to.equal("123,345");
          });

          cy.findByTestId("toast-undo").within(() => {
            cy.findByText(`Success! The action returned: {"rows-affected":0}`);
          });
        });
      });
    },
  );
});

describe(
  "Action Parameters Mapping",
  { tags: ["@external", "@actions"] },
  () => {
    beforeEach(() => {
      cy.intercept("GET", /\/api\/card\/\d+/).as("getModel");
      cy.intercept("GET", "/api/card?f=using_model&model_id=**").as(
        "getCardAssociations",
      );
      cy.intercept("GET", "/api/action").as("getActions");
      cy.intercept("PUT", "/api/action/*").as("updateAction");
      cy.intercept("GET", "/api/action?model-id=*").as("getModelActions");

      cy.intercept(
        "GET",
        "/api/dashboard/*/dashcard/*/execute?parameters=*",
      ).as("executePrefetch");
    });

    describe("Inline action edit", () => {
      beforeEach(() => {
        resetTestTable({ type: "postgres", table: TEST_TABLE });
        restore("postgres-writable");
        cy.signInAsAdmin();
        resyncDatabase({ dbId: WRITABLE_DB_ID, tableName: TEST_TABLE });
        createModelFromTableName({
          tableName: TEST_TABLE,
          modelName: MODEL_NAME,
        });
      });

      it("refetches form values when id changes (metabase#33084)", () => {
        const actionName = "Update";

        cy.get("@modelId").then(id => {
          createImplicitAction({
            kind: "update",
            model_id: id,
          });
        });

        createDashboardWithActionButton({
          actionName,
          idFilter: true,
        });

        filterWidget().click();
        addWidgetStringFilter("5");

        cy.button(actionName).click();

        cy.wait("@executePrefetch");

        modal().within(() => {
          cy.findByPlaceholderText("Team Name").should(
            "have.value",
            "Energetic Elephants",
          );
          cy.findByPlaceholderText("Score").should("have.value", "30");

          cy.icon("close").click();
        });

        filterWidget().click();
        popover().find("input").first().type("{backspace}10");
        cy.button("Update filter").click();

        cy.button(actionName).click();

        cy.wait("@executePrefetch");

        modal().within(() => {
          cy.findByPlaceholderText("Team Name").should(
            "have.value",
            "Jolly Jellyfish",
          );
          cy.findByPlaceholderText("Score").should("have.value", "60");
        });
      });

      it("should reflect to updated action on mapping form", () => {
        const ACTION_NAME = "Update Score";

        cy.get("@modelId").then(id => {
          cy.visit(`/model/${id}/detail`);
          cy.wait(["@getModel", "@getModelActions", "@getCardAssociations"]);
        });

        cy.findByRole("tab", { name: "Actions" }).click();

        cy.findByTestId("model-actions-header")
          .findByText("New action")
          .click();

        cy.findByRole("dialog").within(() => {
          fillActionQuery(
            `UPDATE ${TEST_TABLE} SET score = {{ new_score }} WHERE id = {{ id }}`,
          );
        });

        cy.findByRole("dialog").within(() => {
          cy.findByText("Save").click();
        });

        cy.findByPlaceholderText("My new fantastic action").type(ACTION_NAME);
        cy.findByTestId("create-action-form").button("Create").click();

        cy.createDashboard({ name: "action packed dashboard" }).then(
          ({ body: { id: dashboardId } }) => {
            visitDashboard(dashboardId);
          },
        );

        editDashboard();

        setFilter("ID");
        sidebar().within(() => {
          cy.button("Done").click();
        });

        cy.button("Add action").click();
        cy.get("aside").within(() => {
          cy.findByPlaceholderText("Button text").clear().type(ACTION_NAME);
          cy.button("Pick an action").click();
        });

        waitForValidActions();

        cy.findByRole("dialog").within(() => {
          cy.findByText(MODEL_NAME).click();
          cy.findByText(ACTION_NAME).click();

          cy.findByText("New Score: required").should("not.exist");
          cy.findByRole("button", { name: "Done" }).should("be.enabled");
          cy.icon("pencil").click();
        });

        cy.wait("@getModel");

        cy.findAllByRole("dialog")
          .filter(":visible")
          .within(() => {
            formFieldContainer("New Score").within(() => {
              toggleFieldVisibility();
            });

            cy.findByRole("button", { name: "Update" }).click();
          });

        cy.wait("@updateAction");

        cy.findByRole("dialog").within(() => {
          cy.findByText("New Score: required");
          cy.findByRole("button", { name: "Done" }).should("be.disabled");
        });
      });
    });
  },
);

function createDashboardWithActionButton({
  actionName,
  modelName = MODEL_NAME,
  idFilter = false,
  hideField,
}) {
  cy.createDashboard({ name: "action packed dashboard" }).then(
    ({ body: { id: dashboardId } }) => {
      cy.wrap(dashboardId).as("dashboardId");
      visitDashboard(dashboardId);
    },
  );

  editDashboard();

  if (idFilter) {
    setFilter("ID");
    sidebar().within(() => {
      cy.button("Done").click();
    });
  }

  cy.button("Add action").click();
  cy.get("aside").within(() => {
    cy.findByPlaceholderText("Button text").clear().type(actionName);
    cy.button("Pick an action").click();
  });

  waitForValidActions();

  cy.findByRole("dialog").within(() => {
    cy.findByText(modelName).click();
    cy.findByText(actionName).click();
  });

  if (hideField) {
    cy.findByRole("dialog").within(() => {
      cy.icon("pencil").click();

      cy.wait("@getModel");
    });

    cy.findAllByRole("dialog")
      .filter(":visible")
      .within(() => {
        formFieldContainer(hideField).within(() => {
          toggleFieldVisibility();
        });

        cy.findByRole("button", { name: "Update" }).click();

        cy.wait("@updateAction");
      });
  }

  if (idFilter) {
    cy.findByRole("dialog").within(() => {
      cy.findByText(/has no parameters to map/i).should("not.exist");
      cy.findByText(/Where should the values/i);
      cy.findAllByText(/ask the user/i)
        .first()
        .click();
    });
    popover().within(() => {
      cy.findByText("ID").click();
    });
  }

  cy.findByRole("dialog").within(() => {
    cy.button("Done").click();
  });

  saveDashboard();
}

const changeValue = ({ fieldName, fieldType, oldValue, newValue }) => {
  cy.findByPlaceholderText(fieldName)
    .should("have.attr", "type", fieldType)
    .should("have.value", oldValue)
    .clear()
    .type(newValue);
};

function formFieldContainer(label) {
  return cy
    .findByLabelText(label)
    .closest("[data-testid=form-field-container]");
}

function openFieldSettings() {
  cy.icon("gear").click();
}

function toggleFieldVisibility() {
  cy.findByText("Show field").click();
}

function reorderFields() {
  dragField(1, 0);
}

const clickHelper = buttonName => {
  // this is dirty, but it seems to be the only reliable solution to detached elements before cypress v12
  // https://github.com/cypress-io/cypress/issues/7306
  cy.wait(100);
  cy.button(buttonName).click();
};

function actionEditorModal() {
  return cy.findByTestId("action-editor-modal");
}

function getActionParametersInputModal() {
  return cy.findByTestId("action-parameters-input-modal");
}

function waitForValidActions() {
  cy.wait("@getActions").then(({ response }) => {
    const { body: actions } = response;

    actions.forEach(action => {
      expect(action.parameters).to.have.length.gt(0);
    });
  });
}