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); }); }); }