From 3a39a6c78064aa332314b2d74c551f08d3ed851d Mon Sep 17 00:00:00 2001 From: Kathy Nguyen Date: Thu, 1 Dec 2022 14:12:30 -0800 Subject: [PATCH 01/38] Cascade delete interaction step --- .../CampaignInteractionStepsForm.test.js | 87 ++++++++++++++++--- ...0154133_cascade_delete_interaction_step.js | 13 +++ 2 files changed, 90 insertions(+), 10 deletions(-) create mode 100644 migrations/20221130154133_cascade_delete_interaction_step.js diff --git a/__test__/components/CampaignInteractionStepsForm.test.js b/__test__/components/CampaignInteractionStepsForm.test.js index 403e9eb00..673f06e77 100644 --- a/__test__/components/CampaignInteractionStepsForm.test.js +++ b/__test__/components/CampaignInteractionStepsForm.test.js @@ -20,7 +20,10 @@ import { import { setupTest, cleanupTest, - createStartedCampaign, + createCampaign, + createInvite, + createOrganization, + createUser, makeRunnableMutations, runComponentQueries, muiTheme @@ -635,14 +638,13 @@ describe("CampaignInteractionStepsForm", () => { beforeEach(async () => { await setupTest(); - const startedCampaign = await createStartedCampaign(); - ({ - testOrganization: { - data: { createOrganization: organization } - }, - testAdminUser: adminUser, - testCampaign: campaign - } = startedCampaign); + adminUser = await createUser(); + const testOrganization = await createOrganization( + adminUser, + await createInvite() + ); + campaign = await createCampaign(adminUser, testOrganization); + organization = testOrganization.data.createOrganization; }, global.DATABASE_SETUP_TEARDOWN_TIMEOUT); afterEach(async () => { @@ -843,7 +845,72 @@ describe("CampaignInteractionStepsForm", () => { ]) ); - done(); + // Delete "Red" interaction step + wrappedComponent.setState( + { + expandedSection: 3 + }, + async () => { + const newInteractionSteps = []; + + interactionStepsAfter.forEach(step => { + const newStep = JSON.parse( + JSON.stringify( + instance.state.interactionSteps.find(mStep => { + return step.answer_option === mStep.answerOption; + }) + ) + ); + + newStep.id = step.id; + newStep.parentInteractionId = step.parent_interaction_id; + + if (step.answer_option === "Red") { + newStep.isDeleted = true; + } + + newInteractionSteps.push(newStep); + }); + + instance.state.interactionSteps = newInteractionSteps; + await instance.onSave(); + + const interactionStepsAfterDelete = await r + .knex("interaction_step") + .where({ campaign_id: campaign.id }); + + // Test that the "Red" interaction step and its children are deleted + expect(interactionStepsAfterDelete).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + answer_actions: "", + answer_actions_data: null, + answer_option: "", + campaign_id: Number(campaign.id), + id: expect.any(Number), + is_deleted: false, + parent_interaction_id: null, + question: "What's your favorite color?", + script: "Hi {firstName}! Let's talk about colors." + }), + expect.objectContaining({ + answer_actions: "complex-test-action", + answer_actions_data: + '{"value":"{\\"hex\\":\\"#4B0082\\",\\"rgb\\":{\\"r\\":75,\\"g\\":0,\\"b\\":130}}","label":"indigo"}', + answer_option: "Purple", + campaign_id: Number(campaign.id), + id: expect.any(Number), + is_deleted: false, + parent_interaction_id: expect.any(Number), + question: "", + script: "Purple is a great color, {firstName}!" + }) + ]) + ); + + done(); + } + ); } ); }); diff --git a/migrations/20221130154133_cascade_delete_interaction_step.js b/migrations/20221130154133_cascade_delete_interaction_step.js new file mode 100644 index 000000000..f27747d6f --- /dev/null +++ b/migrations/20221130154133_cascade_delete_interaction_step.js @@ -0,0 +1,13 @@ +exports.up = function(knex) { + return knex.schema.alterTable("interaction_step", (table) => { + table.dropForeign("parent_interaction_id"); + table.foreign("parent_interaction_id").references("interaction_step.id").onDelete("CASCADE"); + }); +}; + +exports.down = function(knex) { + return knex.schema.alterTable("interaction_step", (table) => { + table.dropForeign("parent_interaction_id"); + table.foreign("parent_interaction_id").references("interaction_step.id").onDelete("NO ACTION"); + }); +}; From 383c06573494dba89ca7dd3dd98dd9527aee07d2 Mon Sep 17 00:00:00 2001 From: Kathy Nguyen Date: Fri, 3 Feb 2023 17:53:00 -0500 Subject: [PATCH 02/38] SQLite adjustments --- .../CampaignInteractionStepsForm.test.js | 17 +++++++++++++---- src/server/api/schema.js | 6 ++++++ 2 files changed, 19 insertions(+), 4 deletions(-) diff --git a/__test__/components/CampaignInteractionStepsForm.test.js b/__test__/components/CampaignInteractionStepsForm.test.js index 053b8ef6e..11ab21a4f 100644 --- a/__test__/components/CampaignInteractionStepsForm.test.js +++ b/__test__/components/CampaignInteractionStepsForm.test.js @@ -777,10 +777,18 @@ describe("CampaignInteractionStepsForm", () => { .knex("interaction_step") .where({ campaign_id: campaign.id }); - interactionStepsAfter.forEach(step => { - // eslint-disable-next-line no-param-reassign - step.is_deleted = !!step.is_deleted; - }); + /** + * Normalize is_deleted field due to various possible truthy values in different databases types + * @param {array} is Interaction steps + */ + function normalizeIsDeleted(is) { + is.forEach(step => { + // eslint-disable-next-line no-param-reassign + step.is_deleted = !!step.is_deleted; + }); + } + + normalizeIsDeleted(interactionStepsAfter); expect(interactionStepsAfter).toEqual( expect.arrayContaining([ @@ -879,6 +887,7 @@ describe("CampaignInteractionStepsForm", () => { .where({ campaign_id: campaign.id }); // Test that the "Red" interaction step and its children are deleted + normalizeIsDeleted(interactionStepsAfterDelete); expect(interactionStepsAfterDelete).toEqual( expect.arrayContaining([ expect.objectContaining({ diff --git a/src/server/api/schema.js b/src/server/api/schema.js index 9f46a29a6..933fdd5bf 100644 --- a/src/server/api/schema.js +++ b/src/server/api/schema.js @@ -18,6 +18,7 @@ import { Organization, Tag, UserOrganization, + isSqlite, r, cacheableData } from "../models"; @@ -419,6 +420,11 @@ async function updateInteractionSteps( origCampaignRecord, idMap = {} ) { + // Allows cascade delete for SQLite + if (isSqlite) { + await r.knex.raw("PRAGMA foreign_keys = ON"); + } + for (let i = 0; i < interactionSteps.length; i++) { const is = interactionSteps[i]; // map the interaction step ids for new ones From 4a64fb16d9ac9c642aed4a53e85ba0f5735720cd Mon Sep 17 00:00:00 2001 From: Kathy Nguyen Date: Wed, 31 Jan 2024 12:38:11 -0500 Subject: [PATCH 03/38] Add assignment load limit --- docs/REFERENCE-environment_variables.md | 1 + src/server/api/assignment.js | 8 ++++++++ 2 files changed, 9 insertions(+) diff --git a/docs/REFERENCE-environment_variables.md b/docs/REFERENCE-environment_variables.md index 994544d15..4712a0c3c 100644 --- a/docs/REFERENCE-environment_variables.md +++ b/docs/REFERENCE-environment_variables.md @@ -6,6 +6,7 @@ | ASSETS_DIR | Directory path where front-end packaged JavaScript is saved and loaded. _Required_. | | ASSETS_MAP_FILE | File name of map file, within ASSETS_DIR, containing map of general file names to unique build-specific file names. | | ASSIGNMENT_CONTACTS_SIDEBAR | Show a sidebar with a list of contacts to the texter. Allows texter to freely navigate between conversations, regardless of status. | +| ASSIGNMENT_LOAD_LIMIT | Limit of contacts to load at one time for an assignment. Used when Spoke is deployed on a service with time and bandwidth limitations, such as AWS Lambda. Type: integer | | AUTH0_DOMAIN | Domain name on Auth0 account, should end in `.auth0.com`, e.g. `example.auth0.com`. _Required_. | | AUTH0_CLIENT_ID | Client ID from Auth0 app. _Required_. | | AUTH0_CLIENT_SECRET | Client secret from Auth0 app. _Required_. | diff --git a/src/server/api/assignment.js b/src/server/api/assignment.js index debc700b6..730bbf61d 100644 --- a/src/server/api/assignment.js +++ b/src/server/api/assignment.js @@ -114,6 +114,14 @@ export function getContacts( query = query.where({ assignment_id: assignment.id }); + + let assignmentLoadLimit = getConfig("ASSIGNMENT_LOAD_LIMIT"); + if (assignmentLoadLimit) { + assignmentLoadLimit = parseInt(assignmentLoadLimit); + if (!isNaN(assignmentLoadLimit)) { + query.limit(assignmentLoadLimit); + } + } } if (contactsFilter) { From fcf7b678a562148cee2ccc54a5b16fe45be3d960 Mon Sep 17 00:00:00 2001 From: Kathy Nguyen Date: Tue, 20 Feb 2024 16:51:15 -0800 Subject: [PATCH 04/38] Address SonarCloud issue --- __test__/components/CampaignInteractionStepsForm.test.js | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/__test__/components/CampaignInteractionStepsForm.test.js b/__test__/components/CampaignInteractionStepsForm.test.js index 11ab21a4f..fe4c5706d 100644 --- a/__test__/components/CampaignInteractionStepsForm.test.js +++ b/__test__/components/CampaignInteractionStepsForm.test.js @@ -156,6 +156,9 @@ describe("CampaignInteractionStepsForm", () => { ]; StyleSheetTestUtils.suppressStyleInjection(); + function emptyFunction() { + return {}; + } wrappedComponent = mount( { formValues={{ interactionSteps }} - onChange={() => {}} - onSubmit={() => {}} + onChange={emptyFunction} + onSubmit={emptyFunction} ensureComplete customFields={[]} saveLabel="save" From b8ac77b0176b122ec0f1450c0f9b844f7017d291 Mon Sep 17 00:00:00 2001 From: Kathy Nguyen Date: Tue, 20 Feb 2024 16:54:25 -0800 Subject: [PATCH 05/38] Update nesting --- __test__/components/CampaignInteractionStepsForm.test.js | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/__test__/components/CampaignInteractionStepsForm.test.js b/__test__/components/CampaignInteractionStepsForm.test.js index fe4c5706d..c258cc6bb 100644 --- a/__test__/components/CampaignInteractionStepsForm.test.js +++ b/__test__/components/CampaignInteractionStepsForm.test.js @@ -105,6 +105,9 @@ describe("CampaignInteractionStepsForm", () => { let interactionSteps; describe("when there are no action handlers", () => { + function emptyFunction() { + return {}; + } beforeEach(async () => { interactionSteps = [ { @@ -156,9 +159,6 @@ describe("CampaignInteractionStepsForm", () => { ]; StyleSheetTestUtils.suppressStyleInjection(); - function emptyFunction() { - return {}; - } wrappedComponent = mount( Date: Wed, 21 Feb 2024 11:57:15 -0800 Subject: [PATCH 06/38] Refactor for SonarCloud --- .../CampaignInteractionStepsForm.test.js | 14 ++++++++++---- 1 file changed, 10 insertions(+), 4 deletions(-) diff --git a/__test__/components/CampaignInteractionStepsForm.test.js b/__test__/components/CampaignInteractionStepsForm.test.js index c258cc6bb..9bfe2d981 100644 --- a/__test__/components/CampaignInteractionStepsForm.test.js +++ b/__test__/components/CampaignInteractionStepsForm.test.js @@ -29,6 +29,7 @@ import { muiTheme } from "../test_helpers"; import ThemeContext from "../../src/containers/context/ThemeContext"; +import { wrap } from "lodash"; describe("CampaignInteractionStepsForm", () => { describe("basic instantiation", function t() { @@ -105,7 +106,7 @@ describe("CampaignInteractionStepsForm", () => { let interactionSteps; describe("when there are no action handlers", () => { - function emptyFunction() { + function dummyFunction() { return {}; } beforeEach(async () => { @@ -166,8 +167,8 @@ describe("CampaignInteractionStepsForm", () => { formValues={{ interactionSteps }} - onChange={emptyFunction} - onSubmit={emptyFunction} + onChange={dummyFunction} + onSubmit={dummyFunction} ensureComplete customFields={[]} saveLabel="save" @@ -179,8 +180,13 @@ describe("CampaignInteractionStepsForm", () => { }); it("doesn't render the answer actions", async () => { + function cmpProp(prop, val) { + return function(node) { + return node.props()[prop] === val; + }; + } const answerActionsComponents = wrappedComponent.findWhere( - node => node.props()["data-test"] === "actionSelect" + cmpProp("data-test", "actionSelect") ); expect(answerActionsComponents.exists()).toEqual(false); }); From 38187bb87648ddad76717436619a8f57f1f65dd2 Mon Sep 17 00:00:00 2001 From: Kathy Nguyen Date: Wed, 21 Feb 2024 12:01:22 -0800 Subject: [PATCH 07/38] Update for SonarCloud --- .../CampaignInteractionStepsForm.test.js | 19 +++++++++++++------ 1 file changed, 13 insertions(+), 6 deletions(-) diff --git a/__test__/components/CampaignInteractionStepsForm.test.js b/__test__/components/CampaignInteractionStepsForm.test.js index 9bfe2d981..27a21bc45 100644 --- a/__test__/components/CampaignInteractionStepsForm.test.js +++ b/__test__/components/CampaignInteractionStepsForm.test.js @@ -29,7 +29,6 @@ import { muiTheme } from "../test_helpers"; import ThemeContext from "../../src/containers/context/ThemeContext"; -import { wrap } from "lodash"; describe("CampaignInteractionStepsForm", () => { describe("basic instantiation", function t() { @@ -106,7 +105,20 @@ describe("CampaignInteractionStepsForm", () => { let interactionSteps; describe("when there are no action handlers", () => { + function cmpProp(prop, val) { + /** + * @returns True if the node prop and val are equal. False otherwise. + */ + return function(node) { + return node.props()[prop] === val; + }; + } function dummyFunction() { + /** + * Empty function that does nothing + * + * @returns Empty object + */ return {}; } beforeEach(async () => { @@ -180,11 +192,6 @@ describe("CampaignInteractionStepsForm", () => { }); it("doesn't render the answer actions", async () => { - function cmpProp(prop, val) { - return function(node) { - return node.props()[prop] === val; - }; - } const answerActionsComponents = wrappedComponent.findWhere( cmpProp("data-test", "actionSelect") ); From 8abcdfa9e7910b22ec30dbe337a2d090ff83684a Mon Sep 17 00:00:00 2001 From: Kathy Nguyen Date: Wed, 21 Feb 2024 14:26:19 -0800 Subject: [PATCH 08/38] Address SonarCloud issue --- .../CampaignInteractionStepsForm.test.js | 20 ++++++++++--------- 1 file changed, 11 insertions(+), 9 deletions(-) diff --git a/__test__/components/CampaignInteractionStepsForm.test.js b/__test__/components/CampaignInteractionStepsForm.test.js index 27a21bc45..253d36e86 100644 --- a/__test__/components/CampaignInteractionStepsForm.test.js +++ b/__test__/components/CampaignInteractionStepsForm.test.js @@ -104,23 +104,25 @@ describe("CampaignInteractionStepsForm", () => { let wrappedComponent; let interactionSteps; + function cmpProp(prop, val) { + /** + * @returns True if the node prop and val are equal. False otherwise. + */ + return function(node) { + return node.props()[prop] === val; + }; + } + describe("when there are no action handlers", () => { - function cmpProp(prop, val) { - /** - * @returns True if the node prop and val are equal. False otherwise. - */ - return function(node) { - return node.props()[prop] === val; - }; - } function dummyFunction() { /** * Empty function that does nothing - * + * * @returns Empty object */ return {}; } + beforeEach(async () => { interactionSteps = [ { From f19483c29f3db0276988f17bcc90dff40727d06c Mon Sep 17 00:00:00 2001 From: Kathy Nguyen Date: Wed, 21 Feb 2024 14:29:30 -0800 Subject: [PATCH 09/38] Address SonarCloud --- .../CampaignInteractionStepsForm.test.js | 22 +++++++++---------- 1 file changed, 11 insertions(+), 11 deletions(-) diff --git a/__test__/components/CampaignInteractionStepsForm.test.js b/__test__/components/CampaignInteractionStepsForm.test.js index 253d36e86..2ec499005 100644 --- a/__test__/components/CampaignInteractionStepsForm.test.js +++ b/__test__/components/CampaignInteractionStepsForm.test.js @@ -113,16 +113,16 @@ describe("CampaignInteractionStepsForm", () => { }; } - describe("when there are no action handlers", () => { - function dummyFunction() { - /** - * Empty function that does nothing - * - * @returns Empty object - */ - return {}; - } + function dummyFunction() { + /** + * Empty function that does nothing + * + * @returns Empty object + */ + return {}; + } + describe("when there are no action handlers", () => { beforeEach(async () => { interactionSteps = [ { @@ -254,8 +254,8 @@ describe("CampaignInteractionStepsForm", () => { formValues={{ interactionSteps }} - onChange={() => {}} - onSubmit={() => {}} + onChange={dummyFunction} + onSubmit={dummyFunction} ensureComplete customFields={[]} saveLabel="save" From cb70095a7a6d6d20358aa66ab8bcd83fb741d334 Mon Sep 17 00:00:00 2001 From: Kathy Nguyen Date: Wed, 21 Feb 2024 14:49:40 -0800 Subject: [PATCH 10/38] Address SonarCloud issue --- .../CampaignInteractionStepsForm.test.js | 378 +++++++++--------- src/server/api/schema.js | 6 +- 2 files changed, 192 insertions(+), 192 deletions(-) diff --git a/__test__/components/CampaignInteractionStepsForm.test.js b/__test__/components/CampaignInteractionStepsForm.test.js index 2ec499005..9c443b103 100644 --- a/__test__/components/CampaignInteractionStepsForm.test.js +++ b/__test__/components/CampaignInteractionStepsForm.test.js @@ -285,7 +285,7 @@ describe("CampaignInteractionStepsForm", () => { const step1 = cards.at(1); const selectField1 = step1.find(GSSelectField); const step1AnswerActionNodes = step1.findWhere( - node => node.props()["data-test"] === "actionSelect" + cmpProp("data-test", "actionSelect") ); expect(step1AnswerActionNodes.first().props().value).toEqual( "red-handler" @@ -307,7 +307,7 @@ describe("CampaignInteractionStepsForm", () => { ]); const step1ClientChoiceNodes = step1.findWhere( - node => node.props()["data-test"] === "actionDataAutoComplete" + cmpProp("data-test", "actionDataAutoComplete") ); expect(step1ClientChoiceNodes.exists()).toEqual(false); @@ -316,7 +316,7 @@ describe("CampaignInteractionStepsForm", () => { const step2 = cards.at(2); const selectField2 = step2.find(GSSelectField); const step2AnswerActionNodes = step2.findWhere( - node => node.props()["data-test"] === "actionSelect" + cmpProp("data-test", "actionSelect") ); expect(step2AnswerActionNodes.first().props().value).toEqual( @@ -339,7 +339,7 @@ describe("CampaignInteractionStepsForm", () => { ]); const step2ClientChoiceNodes = step2.findWhere( - node => node.props()["data-test"] === "actionDataAutoComplete" + cmpProp("data-test", "actionDataAutoComplete") ); expect(step2ClientChoiceNodes.exists()).toEqual(false); @@ -348,7 +348,7 @@ describe("CampaignInteractionStepsForm", () => { const step3 = cards.at(3); const selectField3 = step3.find(GSSelectField); const step3AnswerActionNodes = step3.findWhere( - node => node.props()["data-test"] === "actionSelect" + cmpProp("data-test", "actionSelect") ); expect(step3AnswerActionNodes.first().props().value).toEqual(""); @@ -369,7 +369,7 @@ describe("CampaignInteractionStepsForm", () => { ]); const step3ClientChoiceNodes = step3.findWhere( - node => node.props()["data-test"] === "actionDataAutoComplete" + cmpProp("data-test", "actionDataAutoComplete") ); expect(step3ClientChoiceNodes.exists()).toEqual(false); @@ -445,8 +445,8 @@ describe("CampaignInteractionStepsForm", () => { formValues={{ interactionSteps }} - onChange={() => {}} - onSubmit={() => {}} + onChange={dummyFunction} + onSubmit={dummyFunction} ensureComplete customFields={[]} saveLabel="save" @@ -490,7 +490,7 @@ describe("CampaignInteractionStepsForm", () => { const step1 = cards.at(1); const selectField1 = step1.find(GSSelectField); const step1AnswerActionNodes = step1.findWhere( - node => node.props()["data-test"] === "actionSelect" + cmpProp("data-test", "actionSelect") ); expect(step1AnswerActionNodes.first().props().value).toEqual( @@ -513,7 +513,7 @@ describe("CampaignInteractionStepsForm", () => { ]); const step1ClientChoiceNodes = step1.findWhere( - node => node.props()["data-test"] === "actionDataAutoComplete" + cmpProp("data-test", "actionDataAutoComplete") ); expect(step1ClientChoiceNodes.at(2).props().options).toEqual([ @@ -535,7 +535,7 @@ describe("CampaignInteractionStepsForm", () => { const step2 = cards.at(2); const selectField2 = step2.find(GSSelectField); const step2AnswerActionNodes = step2.findWhere( - node => node.props()["data-test"] === "actionSelect" + cmpProp("data-test", "actionSelect") ); expect(step2AnswerActionNodes.first().props().value).toEqual( @@ -558,7 +558,7 @@ describe("CampaignInteractionStepsForm", () => { ]); const step2ClientChoiceNodes = step2.findWhere( - node => node.props()["data-test"] === "actionDataAutoComplete" + cmpProp("data-test", "actionDataAutoComplete") ); expect(step2ClientChoiceNodes.first().props().value).toEqual({ @@ -585,7 +585,7 @@ describe("CampaignInteractionStepsForm", () => { const step3 = cards.at(3); const selectField3 = step3.find(GSSelectField); const step3AnswerActionNodes = step3.findWhere( - node => node.props()["data-test"] === "actionSelect" + cmpProp("data-test", "actionSelect") ); expect(step3AnswerActionNodes.first().props().value).toEqual( @@ -608,7 +608,7 @@ describe("CampaignInteractionStepsForm", () => { ]); const step3ClientChoiceNodes = step3.findWhere( - node => node.props()["data-test"] === "actionDataAutoComplete" + cmpProp("data-test", "actionDataAutoComplete") ); expect(step3ClientChoiceNodes.exists()).toEqual(false); @@ -617,7 +617,7 @@ describe("CampaignInteractionStepsForm", () => { const step4 = cards.at(4); const selectField4 = step4.find(GSSelectField); const step4AnswerActionNodes = step4.findWhere( - node => node.props()["data-test"] === "actionSelect" + cmpProp("data-test", "actionSelect") ); expect(step4AnswerActionNodes.first().props().value).toEqual(""); @@ -638,7 +638,7 @@ describe("CampaignInteractionStepsForm", () => { ]); const step4ClientChoiceNodes = step4.findWhere( - node => node.props()["data-test"] === "actionDataAutoComplete" + cmpProp("data-test", "actionDataAutoComplete") ); expect(step4ClientChoiceNodes.exists()).toEqual(false); @@ -653,6 +653,180 @@ describe("CampaignInteractionStepsForm", () => { let interactionSteps; let queryResults; + function saveInteractionSteps(done) { + return function(interactionStepsBefore) { + expect(interactionStepsBefore).toHaveLength(0); + + return wrappedComponent.setState( + { + expandedSection: 3, + campaignFormValues: { + ...queryResults.campaignData.campaign, + interactionSteps + } + }, + async () => { + const campaignInteractionStepsForm = wrappedComponent.find( + CampaignInteractionStepsForm + ); + + expect(campaignInteractionStepsForm.exists()).toEqual(true); + + const instance = campaignInteractionStepsForm.instance(); + + await instance.onSave(); + + const interactionStepsAfter = await r + .knex("interaction_step") + .where({ campaign_id: campaign.id }); + + /** + * Normalize is_deleted field due to various possible truthy values in different databases types + * @param {array} is Interaction steps + */ + function normalizeIsDeleted(is) { + is.forEach(step => { + // eslint-disable-next-line no-param-reassign + step.is_deleted = !!step.is_deleted; + }); + } + + normalizeIsDeleted(interactionStepsAfter); + + expect(interactionStepsAfter).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + answer_actions: "", + answer_actions_data: null, + answer_option: "", + campaign_id: Number(campaign.id), + id: expect.any(Number), + is_deleted: false, + parent_interaction_id: null, + question: "What's your favorite color?", + script: "Hi {firstName}! Let's talk about colors." + }), + expect.objectContaining({ + answer_actions: "complex-test-action", + answer_actions_data: + '{"value":"{\\"hex\\":\\"#B22222\\",\\"rgb\\":{\\"r\\":178,\\"g\\":34,\\"b\\":34}}","label":"firebrick"}', + answer_option: "Red", + id: expect.any(Number), + campaign_id: Number(campaign.id), + is_deleted: false, + parent_interaction_id: expect.any(Number), + question: "What's your favorite shade of red?", + script: "Red is a great color, {firstName}!" + }), + expect.objectContaining({ + answer_actions: "", + answer_actions_data: "", + answer_option: "Crimson", + campaign_id: Number(campaign.id), + id: expect.any(Number), + is_deleted: false, + parent_interaction_id: expect.any(Number), + question: "", + script: "Crimson is a great shade of red, {firstName}!" + }), + expect.objectContaining({ + answer_actions: "", + answer_actions_data: "", + answer_option: "Cherry", + campaign_id: Number(campaign.id), + id: expect.any(Number), + is_deleted: false, + parent_interaction_id: expect.any(Number), + question: "", + script: "Cherry is a great shade of red, {firstName}!" + }), + expect.objectContaining({ + answer_actions: "complex-test-action", + answer_actions_data: + '{"value":"{\\"hex\\":\\"#4B0082\\",\\"rgb\\":{\\"r\\":75,\\"g\\":0,\\"b\\":130}}","label":"indigo"}', + answer_option: "Purple", + campaign_id: Number(campaign.id), + id: expect.any(Number), + is_deleted: false, + parent_interaction_id: expect.any(Number), + question: "", + script: "Purple is a great color, {firstName}!" + }) + ]) + ); + + // Delete "Red" interaction step + wrappedComponent.setState( + { + expandedSection: 3 + }, + async () => { + const newInteractionSteps = []; + + interactionStepsAfter.forEach(step => { + const newStep = JSON.parse( + JSON.stringify( + instance.state.interactionSteps.find(mStep => { + return step.answer_option === mStep.answerOption; + }) + ) + ); + + newStep.id = step.id; + newStep.parentInteractionId = step.parent_interaction_id; + + if (step.answer_option === "Red") { + newStep.isDeleted = true; + } + + newInteractionSteps.push(newStep); + }); + + instance.state.interactionSteps = newInteractionSteps; + await instance.onSave(); + + const interactionStepsAfterDelete = await r + .knex("interaction_step") + .where({ campaign_id: campaign.id }); + + // Test that the "Red" interaction step and its children are deleted + normalizeIsDeleted(interactionStepsAfterDelete); + expect(interactionStepsAfterDelete).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + answer_actions: "", + answer_actions_data: null, + answer_option: "", + campaign_id: Number(campaign.id), + id: expect.any(Number), + is_deleted: false, + parent_interaction_id: null, + question: "What's your favorite color?", + script: "Hi {firstName}! Let's talk about colors." + }), + expect.objectContaining({ + answer_actions: "complex-test-action", + answer_actions_data: + '{"value":"{\\"hex\\":\\"#4B0082\\",\\"rgb\\":{\\"r\\":75,\\"g\\":0,\\"b\\":130}}","label":"indigo"}', + answer_option: "Purple", + campaign_id: Number(campaign.id), + id: expect.any(Number), + is_deleted: false, + parent_interaction_id: expect.any(Number), + question: "", + script: "Purple is a great color, {firstName}!" + }) + ]) + ); + + done(); + } + ); + } + ); + }; + } + beforeEach(async () => { await setupTest(); @@ -769,177 +943,7 @@ describe("CampaignInteractionStepsForm", () => { expect(wrappedComponent.exists()).toEqual(true); r.knex("interaction_step") .where({ campaign_id: campaign.id }) - .then(interactionStepsBefore => { - expect(interactionStepsBefore).toHaveLength(0); - - return wrappedComponent.setState( - { - expandedSection: 3, - campaignFormValues: { - ...queryResults.campaignData.campaign, - interactionSteps - } - }, - async () => { - const campaignInteractionStepsForm = wrappedComponent.find( - CampaignInteractionStepsForm - ); - - expect(campaignInteractionStepsForm.exists()).toEqual(true); - - const instance = campaignInteractionStepsForm.instance(); - - await instance.onSave(); - - const interactionStepsAfter = await r - .knex("interaction_step") - .where({ campaign_id: campaign.id }); - - /** - * Normalize is_deleted field due to various possible truthy values in different databases types - * @param {array} is Interaction steps - */ - function normalizeIsDeleted(is) { - is.forEach(step => { - // eslint-disable-next-line no-param-reassign - step.is_deleted = !!step.is_deleted; - }); - } - - normalizeIsDeleted(interactionStepsAfter); - - expect(interactionStepsAfter).toEqual( - expect.arrayContaining([ - expect.objectContaining({ - answer_actions: "", - answer_actions_data: null, - answer_option: "", - campaign_id: Number(campaign.id), - id: expect.any(Number), - is_deleted: false, - parent_interaction_id: null, - question: "What's your favorite color?", - script: "Hi {firstName}! Let's talk about colors." - }), - expect.objectContaining({ - answer_actions: "complex-test-action", - answer_actions_data: - '{"value":"{\\"hex\\":\\"#B22222\\",\\"rgb\\":{\\"r\\":178,\\"g\\":34,\\"b\\":34}}","label":"firebrick"}', - answer_option: "Red", - id: expect.any(Number), - campaign_id: Number(campaign.id), - is_deleted: false, - parent_interaction_id: expect.any(Number), - question: "What's your favorite shade of red?", - script: "Red is a great color, {firstName}!" - }), - expect.objectContaining({ - answer_actions: "", - answer_actions_data: "", - answer_option: "Crimson", - campaign_id: Number(campaign.id), - id: expect.any(Number), - is_deleted: false, - parent_interaction_id: expect.any(Number), - question: "", - script: "Crimson is a great shade of red, {firstName}!" - }), - expect.objectContaining({ - answer_actions: "", - answer_actions_data: "", - answer_option: "Cherry", - campaign_id: Number(campaign.id), - id: expect.any(Number), - is_deleted: false, - parent_interaction_id: expect.any(Number), - question: "", - script: "Cherry is a great shade of red, {firstName}!" - }), - expect.objectContaining({ - answer_actions: "complex-test-action", - answer_actions_data: - '{"value":"{\\"hex\\":\\"#4B0082\\",\\"rgb\\":{\\"r\\":75,\\"g\\":0,\\"b\\":130}}","label":"indigo"}', - answer_option: "Purple", - campaign_id: Number(campaign.id), - id: expect.any(Number), - is_deleted: false, - parent_interaction_id: expect.any(Number), - question: "", - script: "Purple is a great color, {firstName}!" - }) - ]) - ); - - // Delete "Red" interaction step - wrappedComponent.setState( - { - expandedSection: 3 - }, - async () => { - const newInteractionSteps = []; - - interactionStepsAfter.forEach(step => { - const newStep = JSON.parse( - JSON.stringify( - instance.state.interactionSteps.find(mStep => { - return step.answer_option === mStep.answerOption; - }) - ) - ); - - newStep.id = step.id; - newStep.parentInteractionId = step.parent_interaction_id; - - if (step.answer_option === "Red") { - newStep.isDeleted = true; - } - - newInteractionSteps.push(newStep); - }); - - instance.state.interactionSteps = newInteractionSteps; - await instance.onSave(); - - const interactionStepsAfterDelete = await r - .knex("interaction_step") - .where({ campaign_id: campaign.id }); - - // Test that the "Red" interaction step and its children are deleted - normalizeIsDeleted(interactionStepsAfterDelete); - expect(interactionStepsAfterDelete).toEqual( - expect.arrayContaining([ - expect.objectContaining({ - answer_actions: "", - answer_actions_data: null, - answer_option: "", - campaign_id: Number(campaign.id), - id: expect.any(Number), - is_deleted: false, - parent_interaction_id: null, - question: "What's your favorite color?", - script: "Hi {firstName}! Let's talk about colors." - }), - expect.objectContaining({ - answer_actions: "complex-test-action", - answer_actions_data: - '{"value":"{\\"hex\\":\\"#4B0082\\",\\"rgb\\":{\\"r\\":75,\\"g\\":0,\\"b\\":130}}","label":"indigo"}', - answer_option: "Purple", - campaign_id: Number(campaign.id), - id: expect.any(Number), - is_deleted: false, - parent_interaction_id: expect.any(Number), - question: "", - script: "Purple is a great color, {firstName}!" - }) - ]) - ); - - done(); - } - ); - } - ); - }); + .then(saveInteractionSteps(done)); }); }); }); diff --git a/src/server/api/schema.js b/src/server/api/schema.js index 933fdd5bf..966b67f41 100644 --- a/src/server/api/schema.js +++ b/src/server/api/schema.js @@ -394,11 +394,7 @@ async function editCampaign(id, campaign, loaders, user, origCampaignRecord) { }); // hacky easter egg to force reload campaign contacts - if ( - r.redis && - campaignUpdates.description && - campaignUpdates.description.endsWith("..") - ) { + if (r.redis && campaignUpdates.description?.endsWith("..")) { // some asynchronous cache-priming console.log( "force-loading loadCampaignCache", From 2608d7077f0a87abf559518fa16ac6c71e668156 Mon Sep 17 00:00:00 2001 From: Kathy Nguyen Date: Wed, 21 Feb 2024 15:01:47 -0800 Subject: [PATCH 11/38] Address SonarCloud nesting --- .../CampaignInteractionStepsForm.test.js | 350 +++++++++--------- 1 file changed, 175 insertions(+), 175 deletions(-) diff --git a/__test__/components/CampaignInteractionStepsForm.test.js b/__test__/components/CampaignInteractionStepsForm.test.js index 9c443b103..94b405926 100644 --- a/__test__/components/CampaignInteractionStepsForm.test.js +++ b/__test__/components/CampaignInteractionStepsForm.test.js @@ -122,6 +122,180 @@ describe("CampaignInteractionStepsForm", () => { return {}; } + function saveInteractionSteps(campaign, done, interactionSteps, queryResults, wrappedComponent) { + return function(interactionStepsBefore) { + expect(interactionStepsBefore).toHaveLength(0); + + return wrappedComponent.setState( + { + expandedSection: 3, + campaignFormValues: { + ...queryResults.campaignData.campaign, + interactionSteps + } + }, + async () => { + const campaignInteractionStepsForm = wrappedComponent.find( + CampaignInteractionStepsForm + ); + + expect(campaignInteractionStepsForm.exists()).toEqual(true); + + const instance = campaignInteractionStepsForm.instance(); + + await instance.onSave(); + + const interactionStepsAfter = await r + .knex("interaction_step") + .where({ campaign_id: campaign.id }); + + /** + * Normalize is_deleted field due to various possible truthy values in different databases types + * @param {array} is Interaction steps + */ + function normalizeIsDeleted(is) { + is.forEach(step => { + // eslint-disable-next-line no-param-reassign + step.is_deleted = !!step.is_deleted; + }); + } + + normalizeIsDeleted(interactionStepsAfter); + + expect(interactionStepsAfter).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + answer_actions: "", + answer_actions_data: null, + answer_option: "", + campaign_id: Number(campaign.id), + id: expect.any(Number), + is_deleted: false, + parent_interaction_id: null, + question: "What's your favorite color?", + script: "Hi {firstName}! Let's talk about colors." + }), + expect.objectContaining({ + answer_actions: "complex-test-action", + answer_actions_data: + '{"value":"{\\"hex\\":\\"#B22222\\",\\"rgb\\":{\\"r\\":178,\\"g\\":34,\\"b\\":34}}","label":"firebrick"}', + answer_option: "Red", + id: expect.any(Number), + campaign_id: Number(campaign.id), + is_deleted: false, + parent_interaction_id: expect.any(Number), + question: "What's your favorite shade of red?", + script: "Red is a great color, {firstName}!" + }), + expect.objectContaining({ + answer_actions: "", + answer_actions_data: "", + answer_option: "Crimson", + campaign_id: Number(campaign.id), + id: expect.any(Number), + is_deleted: false, + parent_interaction_id: expect.any(Number), + question: "", + script: "Crimson is a great shade of red, {firstName}!" + }), + expect.objectContaining({ + answer_actions: "", + answer_actions_data: "", + answer_option: "Cherry", + campaign_id: Number(campaign.id), + id: expect.any(Number), + is_deleted: false, + parent_interaction_id: expect.any(Number), + question: "", + script: "Cherry is a great shade of red, {firstName}!" + }), + expect.objectContaining({ + answer_actions: "complex-test-action", + answer_actions_data: + '{"value":"{\\"hex\\":\\"#4B0082\\",\\"rgb\\":{\\"r\\":75,\\"g\\":0,\\"b\\":130}}","label":"indigo"}', + answer_option: "Purple", + campaign_id: Number(campaign.id), + id: expect.any(Number), + is_deleted: false, + parent_interaction_id: expect.any(Number), + question: "", + script: "Purple is a great color, {firstName}!" + }) + ]) + ); + + // Delete "Red" interaction step + wrappedComponent.setState( + { + expandedSection: 3 + }, + async () => { + const newInteractionSteps = []; + + interactionStepsAfter.forEach(step => { + const newStep = JSON.parse( + JSON.stringify( + instance.state.interactionSteps.find(mStep => { + return step.answer_option === mStep.answerOption; + }) + ) + ); + + newStep.id = step.id; + newStep.parentInteractionId = step.parent_interaction_id; + + if (step.answer_option === "Red") { + newStep.isDeleted = true; + } + + newInteractionSteps.push(newStep); + }); + + instance.state.interactionSteps = newInteractionSteps; + await instance.onSave(); + + const interactionStepsAfterDelete = await r + .knex("interaction_step") + .where({ campaign_id: campaign.id }); + + // Test that the "Red" interaction step and its children are deleted + normalizeIsDeleted(interactionStepsAfterDelete); + expect(interactionStepsAfterDelete).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + answer_actions: "", + answer_actions_data: null, + answer_option: "", + campaign_id: Number(campaign.id), + id: expect.any(Number), + is_deleted: false, + parent_interaction_id: null, + question: "What's your favorite color?", + script: "Hi {firstName}! Let's talk about colors." + }), + expect.objectContaining({ + answer_actions: "complex-test-action", + answer_actions_data: + '{"value":"{\\"hex\\":\\"#4B0082\\",\\"rgb\\":{\\"r\\":75,\\"g\\":0,\\"b\\":130}}","label":"indigo"}', + answer_option: "Purple", + campaign_id: Number(campaign.id), + id: expect.any(Number), + is_deleted: false, + parent_interaction_id: expect.any(Number), + question: "", + script: "Purple is a great color, {firstName}!" + }) + ]) + ); + + done(); + } + ); + } + ); + }; + } + describe("when there are no action handlers", () => { beforeEach(async () => { interactionSteps = [ @@ -653,180 +827,6 @@ describe("CampaignInteractionStepsForm", () => { let interactionSteps; let queryResults; - function saveInteractionSteps(done) { - return function(interactionStepsBefore) { - expect(interactionStepsBefore).toHaveLength(0); - - return wrappedComponent.setState( - { - expandedSection: 3, - campaignFormValues: { - ...queryResults.campaignData.campaign, - interactionSteps - } - }, - async () => { - const campaignInteractionStepsForm = wrappedComponent.find( - CampaignInteractionStepsForm - ); - - expect(campaignInteractionStepsForm.exists()).toEqual(true); - - const instance = campaignInteractionStepsForm.instance(); - - await instance.onSave(); - - const interactionStepsAfter = await r - .knex("interaction_step") - .where({ campaign_id: campaign.id }); - - /** - * Normalize is_deleted field due to various possible truthy values in different databases types - * @param {array} is Interaction steps - */ - function normalizeIsDeleted(is) { - is.forEach(step => { - // eslint-disable-next-line no-param-reassign - step.is_deleted = !!step.is_deleted; - }); - } - - normalizeIsDeleted(interactionStepsAfter); - - expect(interactionStepsAfter).toEqual( - expect.arrayContaining([ - expect.objectContaining({ - answer_actions: "", - answer_actions_data: null, - answer_option: "", - campaign_id: Number(campaign.id), - id: expect.any(Number), - is_deleted: false, - parent_interaction_id: null, - question: "What's your favorite color?", - script: "Hi {firstName}! Let's talk about colors." - }), - expect.objectContaining({ - answer_actions: "complex-test-action", - answer_actions_data: - '{"value":"{\\"hex\\":\\"#B22222\\",\\"rgb\\":{\\"r\\":178,\\"g\\":34,\\"b\\":34}}","label":"firebrick"}', - answer_option: "Red", - id: expect.any(Number), - campaign_id: Number(campaign.id), - is_deleted: false, - parent_interaction_id: expect.any(Number), - question: "What's your favorite shade of red?", - script: "Red is a great color, {firstName}!" - }), - expect.objectContaining({ - answer_actions: "", - answer_actions_data: "", - answer_option: "Crimson", - campaign_id: Number(campaign.id), - id: expect.any(Number), - is_deleted: false, - parent_interaction_id: expect.any(Number), - question: "", - script: "Crimson is a great shade of red, {firstName}!" - }), - expect.objectContaining({ - answer_actions: "", - answer_actions_data: "", - answer_option: "Cherry", - campaign_id: Number(campaign.id), - id: expect.any(Number), - is_deleted: false, - parent_interaction_id: expect.any(Number), - question: "", - script: "Cherry is a great shade of red, {firstName}!" - }), - expect.objectContaining({ - answer_actions: "complex-test-action", - answer_actions_data: - '{"value":"{\\"hex\\":\\"#4B0082\\",\\"rgb\\":{\\"r\\":75,\\"g\\":0,\\"b\\":130}}","label":"indigo"}', - answer_option: "Purple", - campaign_id: Number(campaign.id), - id: expect.any(Number), - is_deleted: false, - parent_interaction_id: expect.any(Number), - question: "", - script: "Purple is a great color, {firstName}!" - }) - ]) - ); - - // Delete "Red" interaction step - wrappedComponent.setState( - { - expandedSection: 3 - }, - async () => { - const newInteractionSteps = []; - - interactionStepsAfter.forEach(step => { - const newStep = JSON.parse( - JSON.stringify( - instance.state.interactionSteps.find(mStep => { - return step.answer_option === mStep.answerOption; - }) - ) - ); - - newStep.id = step.id; - newStep.parentInteractionId = step.parent_interaction_id; - - if (step.answer_option === "Red") { - newStep.isDeleted = true; - } - - newInteractionSteps.push(newStep); - }); - - instance.state.interactionSteps = newInteractionSteps; - await instance.onSave(); - - const interactionStepsAfterDelete = await r - .knex("interaction_step") - .where({ campaign_id: campaign.id }); - - // Test that the "Red" interaction step and its children are deleted - normalizeIsDeleted(interactionStepsAfterDelete); - expect(interactionStepsAfterDelete).toEqual( - expect.arrayContaining([ - expect.objectContaining({ - answer_actions: "", - answer_actions_data: null, - answer_option: "", - campaign_id: Number(campaign.id), - id: expect.any(Number), - is_deleted: false, - parent_interaction_id: null, - question: "What's your favorite color?", - script: "Hi {firstName}! Let's talk about colors." - }), - expect.objectContaining({ - answer_actions: "complex-test-action", - answer_actions_data: - '{"value":"{\\"hex\\":\\"#4B0082\\",\\"rgb\\":{\\"r\\":75,\\"g\\":0,\\"b\\":130}}","label":"indigo"}', - answer_option: "Purple", - campaign_id: Number(campaign.id), - id: expect.any(Number), - is_deleted: false, - parent_interaction_id: expect.any(Number), - question: "", - script: "Purple is a great color, {firstName}!" - }) - ]) - ); - - done(); - } - ); - } - ); - }; - } - beforeEach(async () => { await setupTest(); @@ -943,7 +943,7 @@ describe("CampaignInteractionStepsForm", () => { expect(wrappedComponent.exists()).toEqual(true); r.knex("interaction_step") .where({ campaign_id: campaign.id }) - .then(saveInteractionSteps(done)); + .then(saveInteractionSteps(campaign, done, interactionSteps, queryResults, wrappedComponent)); }); }); }); From 774ad1bcd259260e62705955062cfd652e49f65d Mon Sep 17 00:00:00 2001 From: Kathy Nguyen Date: Wed, 21 Feb 2024 15:08:58 -0800 Subject: [PATCH 12/38] Address SonarCloud nesting --- .../CampaignInteractionStepsForm.test.js | 259 +++++++++--------- 1 file changed, 129 insertions(+), 130 deletions(-) diff --git a/__test__/components/CampaignInteractionStepsForm.test.js b/__test__/components/CampaignInteractionStepsForm.test.js index 94b405926..640841d25 100644 --- a/__test__/components/CampaignInteractionStepsForm.test.js +++ b/__test__/components/CampaignInteractionStepsForm.test.js @@ -123,46 +123,133 @@ describe("CampaignInteractionStepsForm", () => { } function saveInteractionSteps(campaign, done, interactionSteps, queryResults, wrappedComponent) { - return function(interactionStepsBefore) { - expect(interactionStepsBefore).toHaveLength(0); + async function setStateCallback() { + const campaignInteractionStepsForm = wrappedComponent.find( + CampaignInteractionStepsForm + ); + + expect(campaignInteractionStepsForm.exists()).toEqual(true); + + const instance = campaignInteractionStepsForm.instance(); + + await instance.onSave(); + + const interactionStepsAfter = await r + .knex("interaction_step") + .where({ campaign_id: campaign.id }); + + /** + * Normalize is_deleted field due to various possible truthy values in different databases types + * @param {array} is Interaction steps + */ + function normalizeIsDeleted(is) { + is.forEach(step => { + // eslint-disable-next-line no-param-reassign + step.is_deleted = !!step.is_deleted; + }); + } + + normalizeIsDeleted(interactionStepsAfter); + + expect(interactionStepsAfter).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + answer_actions: "", + answer_actions_data: null, + answer_option: "", + campaign_id: Number(campaign.id), + id: expect.any(Number), + is_deleted: false, + parent_interaction_id: null, + question: "What's your favorite color?", + script: "Hi {firstName}! Let's talk about colors." + }), + expect.objectContaining({ + answer_actions: "complex-test-action", + answer_actions_data: + '{"value":"{\\"hex\\":\\"#B22222\\",\\"rgb\\":{\\"r\\":178,\\"g\\":34,\\"b\\":34}}","label":"firebrick"}', + answer_option: "Red", + id: expect.any(Number), + campaign_id: Number(campaign.id), + is_deleted: false, + parent_interaction_id: expect.any(Number), + question: "What's your favorite shade of red?", + script: "Red is a great color, {firstName}!" + }), + expect.objectContaining({ + answer_actions: "", + answer_actions_data: "", + answer_option: "Crimson", + campaign_id: Number(campaign.id), + id: expect.any(Number), + is_deleted: false, + parent_interaction_id: expect.any(Number), + question: "", + script: "Crimson is a great shade of red, {firstName}!" + }), + expect.objectContaining({ + answer_actions: "", + answer_actions_data: "", + answer_option: "Cherry", + campaign_id: Number(campaign.id), + id: expect.any(Number), + is_deleted: false, + parent_interaction_id: expect.any(Number), + question: "", + script: "Cherry is a great shade of red, {firstName}!" + }), + expect.objectContaining({ + answer_actions: "complex-test-action", + answer_actions_data: + '{"value":"{\\"hex\\":\\"#4B0082\\",\\"rgb\\":{\\"r\\":75,\\"g\\":0,\\"b\\":130}}","label":"indigo"}', + answer_option: "Purple", + campaign_id: Number(campaign.id), + id: expect.any(Number), + is_deleted: false, + parent_interaction_id: expect.any(Number), + question: "", + script: "Purple is a great color, {firstName}!" + }) + ]) + ); - return wrappedComponent.setState( + // Delete "Red" interaction step + wrappedComponent.setState( { - expandedSection: 3, - campaignFormValues: { - ...queryResults.campaignData.campaign, - interactionSteps - } + expandedSection: 3 }, async () => { - const campaignInteractionStepsForm = wrappedComponent.find( - CampaignInteractionStepsForm - ); - - expect(campaignInteractionStepsForm.exists()).toEqual(true); + const newInteractionSteps = []; + + interactionStepsAfter.forEach(step => { + const newStep = JSON.parse( + JSON.stringify( + instance.state.interactionSteps.find(mStep => { + return step.answer_option === mStep.answerOption; + }) + ) + ); + + newStep.id = step.id; + newStep.parentInteractionId = step.parent_interaction_id; + + if (step.answer_option === "Red") { + newStep.isDeleted = true; + } - const instance = campaignInteractionStepsForm.instance(); + newInteractionSteps.push(newStep); + }); + instance.state.interactionSteps = newInteractionSteps; await instance.onSave(); - const interactionStepsAfter = await r + const interactionStepsAfterDelete = await r .knex("interaction_step") .where({ campaign_id: campaign.id }); - /** - * Normalize is_deleted field due to various possible truthy values in different databases types - * @param {array} is Interaction steps - */ - function normalizeIsDeleted(is) { - is.forEach(step => { - // eslint-disable-next-line no-param-reassign - step.is_deleted = !!step.is_deleted; - }); - } - - normalizeIsDeleted(interactionStepsAfter); - - expect(interactionStepsAfter).toEqual( + // Test that the "Red" interaction step and its children are deleted + normalizeIsDeleted(interactionStepsAfterDelete); + expect(interactionStepsAfterDelete).toEqual( expect.arrayContaining([ expect.objectContaining({ answer_actions: "", @@ -175,40 +262,6 @@ describe("CampaignInteractionStepsForm", () => { question: "What's your favorite color?", script: "Hi {firstName}! Let's talk about colors." }), - expect.objectContaining({ - answer_actions: "complex-test-action", - answer_actions_data: - '{"value":"{\\"hex\\":\\"#B22222\\",\\"rgb\\":{\\"r\\":178,\\"g\\":34,\\"b\\":34}}","label":"firebrick"}', - answer_option: "Red", - id: expect.any(Number), - campaign_id: Number(campaign.id), - is_deleted: false, - parent_interaction_id: expect.any(Number), - question: "What's your favorite shade of red?", - script: "Red is a great color, {firstName}!" - }), - expect.objectContaining({ - answer_actions: "", - answer_actions_data: "", - answer_option: "Crimson", - campaign_id: Number(campaign.id), - id: expect.any(Number), - is_deleted: false, - parent_interaction_id: expect.any(Number), - question: "", - script: "Crimson is a great shade of red, {firstName}!" - }), - expect.objectContaining({ - answer_actions: "", - answer_actions_data: "", - answer_option: "Cherry", - campaign_id: Number(campaign.id), - id: expect.any(Number), - is_deleted: false, - parent_interaction_id: expect.any(Number), - question: "", - script: "Cherry is a great shade of red, {firstName}!" - }), expect.objectContaining({ answer_actions: "complex-test-action", answer_actions_data: @@ -224,75 +277,21 @@ describe("CampaignInteractionStepsForm", () => { ]) ); - // Delete "Red" interaction step - wrappedComponent.setState( - { - expandedSection: 3 - }, - async () => { - const newInteractionSteps = []; - - interactionStepsAfter.forEach(step => { - const newStep = JSON.parse( - JSON.stringify( - instance.state.interactionSteps.find(mStep => { - return step.answer_option === mStep.answerOption; - }) - ) - ); - - newStep.id = step.id; - newStep.parentInteractionId = step.parent_interaction_id; - - if (step.answer_option === "Red") { - newStep.isDeleted = true; - } - - newInteractionSteps.push(newStep); - }); - - instance.state.interactionSteps = newInteractionSteps; - await instance.onSave(); - - const interactionStepsAfterDelete = await r - .knex("interaction_step") - .where({ campaign_id: campaign.id }); - - // Test that the "Red" interaction step and its children are deleted - normalizeIsDeleted(interactionStepsAfterDelete); - expect(interactionStepsAfterDelete).toEqual( - expect.arrayContaining([ - expect.objectContaining({ - answer_actions: "", - answer_actions_data: null, - answer_option: "", - campaign_id: Number(campaign.id), - id: expect.any(Number), - is_deleted: false, - parent_interaction_id: null, - question: "What's your favorite color?", - script: "Hi {firstName}! Let's talk about colors." - }), - expect.objectContaining({ - answer_actions: "complex-test-action", - answer_actions_data: - '{"value":"{\\"hex\\":\\"#4B0082\\",\\"rgb\\":{\\"r\\":75,\\"g\\":0,\\"b\\":130}}","label":"indigo"}', - answer_option: "Purple", - campaign_id: Number(campaign.id), - id: expect.any(Number), - is_deleted: false, - parent_interaction_id: expect.any(Number), - question: "", - script: "Purple is a great color, {firstName}!" - }) - ]) - ); - - done(); - } - ); + done(); } ); + } + + return function(interactionStepsBefore) { + expect(interactionStepsBefore).toHaveLength(0); + + return wrappedComponent.setState({ + expandedSection: 3, + campaignFormValues: { + ...queryResults.campaignData.campaign, + interactionSteps + } + }, setStateCallback); }; } From 6e030237e1b80655dbd3b6d0bcf0cf43dbb653cf Mon Sep 17 00:00:00 2001 From: Kathy Nguyen Date: Wed, 21 Feb 2024 15:19:00 -0800 Subject: [PATCH 13/38] Address SonarCloud nesting --- .../CampaignInteractionStepsForm.test.js | 159 +++++++++--------- 1 file changed, 80 insertions(+), 79 deletions(-) diff --git a/__test__/components/CampaignInteractionStepsForm.test.js b/__test__/components/CampaignInteractionStepsForm.test.js index 640841d25..a330148cc 100644 --- a/__test__/components/CampaignInteractionStepsForm.test.js +++ b/__test__/components/CampaignInteractionStepsForm.test.js @@ -123,32 +123,23 @@ describe("CampaignInteractionStepsForm", () => { } function saveInteractionSteps(campaign, done, interactionSteps, queryResults, wrappedComponent) { - async function setStateCallback() { + let instance, interactionStepsAfter; + + async function callback1() { const campaignInteractionStepsForm = wrappedComponent.find( CampaignInteractionStepsForm ); expect(campaignInteractionStepsForm.exists()).toEqual(true); - const instance = campaignInteractionStepsForm.instance(); + instance = campaignInteractionStepsForm.instance(); await instance.onSave(); - const interactionStepsAfter = await r + interactionStepsAfter = await r .knex("interaction_step") .where({ campaign_id: campaign.id }); - /** - * Normalize is_deleted field due to various possible truthy values in different databases types - * @param {array} is Interaction steps - */ - function normalizeIsDeleted(is) { - is.forEach(step => { - // eslint-disable-next-line no-param-reassign - step.is_deleted = !!step.is_deleted; - }); - } - normalizeIsDeleted(interactionStepsAfter); expect(interactionStepsAfter).toEqual( @@ -214,74 +205,84 @@ describe("CampaignInteractionStepsForm", () => { ); // Delete "Red" interaction step - wrappedComponent.setState( - { - expandedSection: 3 - }, - async () => { - const newInteractionSteps = []; - - interactionStepsAfter.forEach(step => { - const newStep = JSON.parse( - JSON.stringify( - instance.state.interactionSteps.find(mStep => { - return step.answer_option === mStep.answerOption; - }) - ) - ); - - newStep.id = step.id; - newStep.parentInteractionId = step.parent_interaction_id; - - if (step.answer_option === "Red") { - newStep.isDeleted = true; - } + wrappedComponent.setState({ + expandedSection: 3 + }, callback2); + } + + async function callback2() { + const newInteractionSteps = []; - newInteractionSteps.push(newStep); - }); - - instance.state.interactionSteps = newInteractionSteps; - await instance.onSave(); - - const interactionStepsAfterDelete = await r - .knex("interaction_step") - .where({ campaign_id: campaign.id }); - - // Test that the "Red" interaction step and its children are deleted - normalizeIsDeleted(interactionStepsAfterDelete); - expect(interactionStepsAfterDelete).toEqual( - expect.arrayContaining([ - expect.objectContaining({ - answer_actions: "", - answer_actions_data: null, - answer_option: "", - campaign_id: Number(campaign.id), - id: expect.any(Number), - is_deleted: false, - parent_interaction_id: null, - question: "What's your favorite color?", - script: "Hi {firstName}! Let's talk about colors." - }), - expect.objectContaining({ - answer_actions: "complex-test-action", - answer_actions_data: - '{"value":"{\\"hex\\":\\"#4B0082\\",\\"rgb\\":{\\"r\\":75,\\"g\\":0,\\"b\\":130}}","label":"indigo"}', - answer_option: "Purple", - campaign_id: Number(campaign.id), - id: expect.any(Number), - is_deleted: false, - parent_interaction_id: expect.any(Number), - question: "", - script: "Purple is a great color, {firstName}!" - }) - ]) - ); - - done(); + interactionStepsAfter.forEach(step => { + const newStep = JSON.parse( + JSON.stringify( + instance.state.interactionSteps.find(mStep => { + return step.answer_option === mStep.answerOption; + }) + ) + ); + + newStep.id = step.id; + newStep.parentInteractionId = step.parent_interaction_id; + + if (step.answer_option === "Red") { + newStep.isDeleted = true; } + + newInteractionSteps.push(newStep); + }); + + instance.state.interactionSteps = newInteractionSteps; + await instance.onSave(); + + const interactionStepsAfterDelete = await r + .knex("interaction_step") + .where({ campaign_id: campaign.id }); + + // Test that the "Red" interaction step and its children are deleted + normalizeIsDeleted(interactionStepsAfterDelete); + expect(interactionStepsAfterDelete).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + answer_actions: "", + answer_actions_data: null, + answer_option: "", + campaign_id: Number(campaign.id), + id: expect.any(Number), + is_deleted: false, + parent_interaction_id: null, + question: "What's your favorite color?", + script: "Hi {firstName}! Let's talk about colors." + }), + expect.objectContaining({ + answer_actions: "complex-test-action", + answer_actions_data: + '{"value":"{\\"hex\\":\\"#4B0082\\",\\"rgb\\":{\\"r\\":75,\\"g\\":0,\\"b\\":130}}","label":"indigo"}', + answer_option: "Purple", + campaign_id: Number(campaign.id), + id: expect.any(Number), + is_deleted: false, + parent_interaction_id: expect.any(Number), + question: "", + script: "Purple is a great color, {firstName}!" + }) + ]) ); + + done(); } - + + /** + * Normalize is_deleted field due to various possible truthy values in different databases types + * @param {array} is Interaction steps + */ + function normalizeIsDeleted(is) { + is.forEach(step => { + // eslint-disable-next-line no-param-reassign + step.is_deleted = !!step.is_deleted; + }); + } + return function(interactionStepsBefore) { expect(interactionStepsBefore).toHaveLength(0); @@ -291,7 +292,7 @@ describe("CampaignInteractionStepsForm", () => { ...queryResults.campaignData.campaign, interactionSteps } - }, setStateCallback); + }, callback1); }; } From 980390d2c1b1af772f0dc836cacefa3db16d27ac Mon Sep 17 00:00:00 2001 From: Kathy Nguyen Date: Wed, 21 Feb 2024 15:34:15 -0800 Subject: [PATCH 14/38] Fix SonarCloud nesting issues --- .../CampaignInteractionStepsForm.test.js | 53 +++++++++---------- 1 file changed, 26 insertions(+), 27 deletions(-) diff --git a/__test__/components/CampaignInteractionStepsForm.test.js b/__test__/components/CampaignInteractionStepsForm.test.js index a330148cc..5ed4877d4 100644 --- a/__test__/components/CampaignInteractionStepsForm.test.js +++ b/__test__/components/CampaignInteractionStepsForm.test.js @@ -123,6 +123,7 @@ describe("CampaignInteractionStepsForm", () => { } function saveInteractionSteps(campaign, done, interactionSteps, queryResults, wrappedComponent) { + const newInteractionSteps = []; let instance, interactionStepsAfter; async function callback1() { @@ -140,7 +141,7 @@ describe("CampaignInteractionStepsForm", () => { .knex("interaction_step") .where({ campaign_id: campaign.id }); - normalizeIsDeleted(interactionStepsAfter); + interactionStepsAfter.map(normalizeIsDeleted); expect(interactionStepsAfter).toEqual( expect.arrayContaining([ @@ -211,26 +212,7 @@ describe("CampaignInteractionStepsForm", () => { } async function callback2() { - const newInteractionSteps = []; - - interactionStepsAfter.forEach(step => { - const newStep = JSON.parse( - JSON.stringify( - instance.state.interactionSteps.find(mStep => { - return step.answer_option === mStep.answerOption; - }) - ) - ); - - newStep.id = step.id; - newStep.parentInteractionId = step.parent_interaction_id; - - if (step.answer_option === "Red") { - newStep.isDeleted = true; - } - - newInteractionSteps.push(newStep); - }); + interactionStepsAfter.forEach(deleteRedInteractionSteps); instance.state.interactionSteps = newInteractionSteps; await instance.onSave(); @@ -240,7 +222,7 @@ describe("CampaignInteractionStepsForm", () => { .where({ campaign_id: campaign.id }); // Test that the "Red" interaction step and its children are deleted - normalizeIsDeleted(interactionStepsAfterDelete); + interactionStepsAfterDelete.map(normalizeIsDeleted); expect(interactionStepsAfterDelete).toEqual( expect.arrayContaining([ expect.objectContaining({ @@ -272,15 +254,32 @@ describe("CampaignInteractionStepsForm", () => { done(); } + function deleteRedInteractionSteps(step) { + const newStep = JSON.parse( + JSON.stringify( + instance.state.interactionSteps.find(mStep => { + return step.answer_option === mStep.answerOption; + }) + ) + ); + + newStep.id = step.id; + newStep.parentInteractionId = step.parent_interaction_id; + + if (step.answer_option === "Red") { + newStep.isDeleted = true; + } + + newInteractionSteps.push(newStep); + } + /** * Normalize is_deleted field due to various possible truthy values in different databases types * @param {array} is Interaction steps */ - function normalizeIsDeleted(is) { - is.forEach(step => { - // eslint-disable-next-line no-param-reassign - step.is_deleted = !!step.is_deleted; - }); + function normalizeIsDeleted(step) { + // eslint-disable-next-line no-param-reassign + step.is_deleted = !!step.is_deleted; } return function(interactionStepsBefore) { From b0b5345b01c6a1c5dce52cd32d4da3e0b81c4822 Mon Sep 17 00:00:00 2001 From: Kathy Nguyen Date: Wed, 21 Feb 2024 15:41:02 -0800 Subject: [PATCH 15/38] Address SonarCloud nesting --- .../CampaignInteractionStepsForm.test.js | 19 +++++++++++++------ 1 file changed, 13 insertions(+), 6 deletions(-) diff --git a/__test__/components/CampaignInteractionStepsForm.test.js b/__test__/components/CampaignInteractionStepsForm.test.js index 5ed4877d4..354be9099 100644 --- a/__test__/components/CampaignInteractionStepsForm.test.js +++ b/__test__/components/CampaignInteractionStepsForm.test.js @@ -104,11 +104,20 @@ describe("CampaignInteractionStepsForm", () => { let wrappedComponent; let interactionSteps; + function cmpAnswerOptions(step) { + return function(mStep) { + /** + * @returns True if the answer options are equal. False otherwise. + */ + return step.answer_option === mStep.answerOption; + } + } + function cmpProp(prop, val) { - /** - * @returns True if the node prop and val are equal. False otherwise. - */ return function(node) { + /** + * @returns True if the node prop and val are equal. False otherwise. + */ return node.props()[prop] === val; }; } @@ -257,9 +266,7 @@ describe("CampaignInteractionStepsForm", () => { function deleteRedInteractionSteps(step) { const newStep = JSON.parse( JSON.stringify( - instance.state.interactionSteps.find(mStep => { - return step.answer_option === mStep.answerOption; - }) + instance.state.interactionSteps.find(cmpAnswerOptions(step)) ) ); From bbaa85a8ae9620dc8bfbe83f1f2212c393e50559 Mon Sep 17 00:00:00 2001 From: Kathy Nguyen Date: Wed, 21 Feb 2024 16:25:56 -0800 Subject: [PATCH 16/38] Remove code duplication --- .../CampaignInteractionStepsForm.test.js | 96 +++------ .../campaign/updateQuestionResponses.test.js | 199 +----------------- __test__/test_helpers.js | 69 ++++++ 3 files changed, 111 insertions(+), 253 deletions(-) diff --git a/__test__/components/CampaignInteractionStepsForm.test.js b/__test__/components/CampaignInteractionStepsForm.test.js index 354be9099..bd5100c65 100644 --- a/__test__/components/CampaignInteractionStepsForm.test.js +++ b/__test__/components/CampaignInteractionStepsForm.test.js @@ -18,6 +18,7 @@ import { operations as adminCampaignEditOps } from "../../src/containers/AdminCampaignEdit"; import { + mockInteractionSteps, setupTest, cleanupTest, createCampaign, @@ -110,7 +111,7 @@ describe("CampaignInteractionStepsForm", () => { * @returns True if the answer options are equal. False otherwise. */ return step.answer_option === mStep.answerOption; - } + }; } function cmpProp(prop, val) { @@ -131,7 +132,13 @@ describe("CampaignInteractionStepsForm", () => { return {}; } - function saveInteractionSteps(campaign, done, interactionSteps, queryResults, wrappedComponent) { + function saveInteractionSteps( + campaign, + done, + interactionSteps, + queryResults, + wrappedComponent + ) { const newInteractionSteps = []; let instance, interactionStepsAfter; @@ -215,9 +222,12 @@ describe("CampaignInteractionStepsForm", () => { ); // Delete "Red" interaction step - wrappedComponent.setState({ - expandedSection: 3 - }, callback2); + wrappedComponent.setState( + { + expandedSection: 3 + }, + callback2 + ); } async function callback2() { @@ -292,66 +302,22 @@ describe("CampaignInteractionStepsForm", () => { return function(interactionStepsBefore) { expect(interactionStepsBefore).toHaveLength(0); - return wrappedComponent.setState({ - expandedSection: 3, - campaignFormValues: { - ...queryResults.campaignData.campaign, - interactionSteps - } - }, callback1); + return wrappedComponent.setState( + { + expandedSection: 3, + campaignFormValues: { + ...queryResults.campaignData.campaign, + interactionSteps + } + }, + callback1 + ); }; } describe("when there are no action handlers", () => { beforeEach(async () => { - interactionSteps = [ - { - id: "new_1", - questionText: "What is your favorite color", - script: "Hello {firstName}. Let's talk about your favorite color.", - answerOption: "", - answerActions: "", - answerActionsData: "", - parentInteractionId: null, - isDeleted: false, - interactionSteps: [ - { - id: "new_2", - questionText: "What is your favorite shade of red?", - script: "Red is an awesome color, {firstName}!", - answerOption: "Red", - answerActions: "", - answerActionsData: "", - parentInteractionId: "new_1", - isDeleted: false, - interactionSteps: [ - { - id: "new_21", - questionText: "", - script: "Crimson is a rad shade of red, {firstName}", - answerOption: "Crimson", - answerActions: "", - answerActionsData: "", - parentInteractionId: "new_2", - isDeleted: false, - interactionSteps: [] - } - ] - }, - { - id: "new_3", - questionText: "", - script: "Purple is an awesome color, {firstName}!", - answerOption: "Purple", - answerActions: "", - answerActionsData: "", - parentInteractionId: "new_1", - isDeleted: false, - interactionSteps: [] - } - ] - } - ]; + interactionSteps = [mockInteractionSteps]; StyleSheetTestUtils.suppressStyleInjection(); wrappedComponent = mount( @@ -949,7 +915,15 @@ describe("CampaignInteractionStepsForm", () => { expect(wrappedComponent.exists()).toEqual(true); r.knex("interaction_step") .where({ campaign_id: campaign.id }) - .then(saveInteractionSteps(campaign, done, interactionSteps, queryResults, wrappedComponent)); + .then( + saveInteractionSteps( + campaign, + done, + interactionSteps, + queryResults, + wrappedComponent + ) + ); }); }); }); diff --git a/__test__/server/api/campaign/updateQuestionResponses.test.js b/__test__/server/api/campaign/updateQuestionResponses.test.js index 7073188a5..82a8bf3aa 100644 --- a/__test__/server/api/campaign/updateQuestionResponses.test.js +++ b/__test__/server/api/campaign/updateQuestionResponses.test.js @@ -5,6 +5,7 @@ import { cleanupTest, createScript, createStartedCampaign, + mockInteractionSteps, runGql, sendMessage, setupTest, @@ -166,65 +167,7 @@ describe("mutations.updateQuestionResponses", () => { describe("when called through the mutation", () => { beforeEach(async () => { - const inputInteractionSteps = [ - { - id: "new_1", - questionText: "What is your favorite color", - script: "Hello {firstName}. Let's talk about your favorite color.", - answerOption: "", - answerActions: "", - answerActionsData: "", - parentInteractionId: null, - isDeleted: false, - interactionSteps: [ - { - id: "new_2", - questionText: "What is your favorite shade of red?", - script: "Red is an awesome color, {firstName}!", - answerOption: "Red", - answerActions: "", - answerActionsData: "", - parentInteractionId: "new_1", - isDeleted: false, - interactionSteps: [ - { - id: "new_21", - questionText: "", - script: "Crimson is a rad shade of red, {firstName}", - answerOption: "Crimson", - answerActions: "", - answerActionsData: "", - parentInteractionId: "new_2", - isDeleted: false, - interactionSteps: [] - }, - { - id: "new_22", - questionText: "", - script: "Firebrick is a rad shade of red, {firstName}", - answerOption: "Firebrick", - answerActions: "", - answerActionsData: "", - parentInteractionId: "new_2", - isDeleted: false, - interactionSteps: [] - } - ] - }, - { - id: "new_3", - questionText: "", - script: "Purple is an awesome color, {firstName}!", - answerOption: "Purple", - answerActions: "", - answerActionsData: "", - parentInteractionId: "new_1", - isDeleted: false, - interactionSteps: [] - } - ] - } - ]; + const inputInteractionSteps = [mockInteractionSteps]; ({ interactionSteps, @@ -310,7 +253,7 @@ describe("mutations.updateQuestionResponses", () => { campaign_id: Number(campaign.id), question: "What is your favorite color", script: "Hello {firstName}. Let's talk about your favorite color.", - answer_actions: "", + answer_actions: "complex-test-action", value: "Red" }, { @@ -320,7 +263,7 @@ describe("mutations.updateQuestionResponses", () => { campaign_id: Number(campaign.id), question: "What is your favorite shade of red?", script: "Red is an awesome color, {firstName}!", - answer_actions: "", + answer_actions: "complex-test-action", value: "Crimson" } ]); @@ -430,7 +373,7 @@ describe("mutations.updateQuestionResponses", () => { expect(databaseQueryResults.rows || databaseQueryResults).toEqual([ { - answer_actions: "", + answer_actions: "complex-test-action", answer_option: "Red", campaign_id: 1, child_id: 2, @@ -449,136 +392,8 @@ describe("mutations.updateQuestionResponses", () => { let inputInteractionStepsWithoutActionHandlers; beforeEach(async () => { - inputInteractionStepsWithoutActionHandlers = [ - { - id: "new_1", - questionText: "What is your favorite color", - script: "Hello {firstName}. Let's talk about your favorite color.", - answerOption: "", - answerActions: "", - answerActionsData: "", - parentInteractionId: null, - isDeleted: false, - interactionSteps: [ - { - id: "new_2", - questionText: "What is your favorite shade of red?", - script: "Red is an awesome color, {firstName}!", - answerOption: "Red", - answerActions: "", - answerActionsData: "", - parentInteractionId: "new_1", - isDeleted: false, - interactionSteps: [ - { - id: "new_21", - questionText: "", - script: "Crimson is a rad shade of red, {firstName}", - answerOption: "Crimson", - answerActions: "", - answerActionsData: "", - parentInteractionId: "new_2", - isDeleted: false, - interactionSteps: [] - }, - { - id: "new_22", - questionText: "", - script: "Firebrick is a rad shade of red, {firstName}", - answerOption: "Firebrick", - answerActions: "", - answerActionsData: "", - parentInteractionId: "new_2", - isDeleted: false, - interactionSteps: [] - } - ] - }, - { - id: "new_3", - questionText: "", - script: "Purple is an awesome color, {firstName}!", - answerOption: "Purple", - answerActions: "", - answerActionsData: "", - parentInteractionId: "new_1", - isDeleted: false, - interactionSteps: [] - } - ] - } - ]; - - inputInteractionStepsWithActionHandlers = [ - { - id: "new_1", - questionText: "What is your favorite color", - script: "Hello {firstName}. Let's talk about your favorite color.", - answerOption: "", - answerActions: "", - answerActionsData: "", - parentInteractionId: null, - isDeleted: false, - interactionSteps: [ - { - id: "new_2", - questionText: "What is your favorite shade of red?", - script: "Red is an awesome color, {firstName}!", - answerOption: "Red", - answerActions: "complex-test-action", - answerActionsData: "red answer actions data", - parentInteractionId: "new_1", - isDeleted: false, - interactionSteps: [ - { - id: "new_21", - questionText: "", - script: "Crimson is a rad shade of red, {firstName}", - answerOption: "Crimson", - answerActions: "complex-test-action", - answerActionsData: "crimson answer actions data", - parentInteractionId: "new_2", - isDeleted: false, - interactionSteps: [] - }, - { - id: "new_22", - questionText: "", - script: "Firebrick is a rad shade of red, {firstName}", - answerOption: "Firebrick", - answerActions: "", - answerActionsData: "", - parentInteractionId: "new_2", - isDeleted: false, - interactionSteps: [] - } - ] - }, - { - id: "new_3", - questionText: "", - script: "Purple is an awesome color, {firstName}!", - answerOption: "Purple", - answerActions: "", - answerActionsData: "", - parentInteractionId: "new_1", - isDeleted: false, - interactionSteps: [] - }, - { - id: "new_4", - questionText: "", - script: "Blue is an awesome color, {firstName}!", - answerOption: "Blue", - answerActions: "complex-test-action", - answerActionsData: "blue answer actions data", - parentInteractionId: "new_1", - isDeleted: false, - interactionSteps: [] - } - ] - } - ]; + inputInteractionStepsWithoutActionHandlers = [mockInteractionSteps]; + inputInteractionStepsWithActionHandlers = [mockInteractionSteps]; }); describe("happy path", () => { diff --git a/__test__/test_helpers.js b/__test__/test_helpers.js index 377a92a8b..6c81b11bf 100644 --- a/__test__/test_helpers.js +++ b/__test__/test_helpers.js @@ -117,6 +117,75 @@ export async function runGql(operation, vars, user) { return result; } +export const mockInteractionSteps = { + id: "new_1", + questionText: "What is your favorite color", + script: "Hello {firstName}. Let's talk about your favorite color.", + answerOption: "", + answerActions: "", + answerActionsData: "", + parentInteractionId: null, + isDeleted: false, + interactionSteps: [ + { + id: "new_2", + questionText: "What is your favorite shade of red?", + script: "Red is an awesome color, {firstName}!", + answerOption: "Red", + answerActions: "complex-test-action", + answerActionsData: "red answer actions data", + parentInteractionId: "new_1", + isDeleted: false, + interactionSteps: [ + { + id: "new_21", + questionText: "", + script: "Crimson is a rad shade of red, {firstName}", + answerOption: "Crimson", + answerActions: "complex-test-action", + answerActionsData: "crimson answer actions data", + parentInteractionId: "new_2", + isDeleted: false, + interactionSteps: [] + }, + { + id: "new_22", + questionText: "", + script: "Firebrick is a rad shade of red, {firstName}", + answerOption: "Firebrick", + answerActions: "", + answerActionsData: "", + parentInteractionId: "new_2", + isDeleted: false, + interactionSteps: [] + } + ] + }, + { + id: "new_3", + questionText: "", + script: "Purple is an awesome color, {firstName}!", + answerOption: "Purple", + answerActions: "", + answerActionsData: "", + parentInteractionId: "new_1", + isDeleted: false, + interactionSteps: [] + }, + { + id: "new_4", + questionText: "", + script: "Blue is an awesome color, {firstName}!", + answerOption: "Blue", + answerActions: "complex-test-action", + answerActionsData: "blue answer actions data", + parentInteractionId: "new_1", + isDeleted: false, + interactionSteps: [] + } + ] +}; + export const updateUserRoles = async ( adminUser, organizationId, From 2e22a882f69556217585b4521396230f86f6ec3d Mon Sep 17 00:00:00 2001 From: Kathy Nguyen Date: Wed, 21 Feb 2024 16:42:25 -0800 Subject: [PATCH 17/38] Address SonarCloud issues --- .../campaign/updateQuestionResponses.test.js | 41 ++++++++++--------- 1 file changed, 21 insertions(+), 20 deletions(-) diff --git a/__test__/server/api/campaign/updateQuestionResponses.test.js b/__test__/server/api/campaign/updateQuestionResponses.test.js index 82a8bf3aa..6ffe7b6f3 100644 --- a/__test__/server/api/campaign/updateQuestionResponses.test.js +++ b/__test__/server/api/campaign/updateQuestionResponses.test.js @@ -6,6 +6,7 @@ import { createScript, createStartedCampaign, mockInteractionSteps, + muiTheme, runGql, sendMessage, setupTest, @@ -34,7 +35,6 @@ import { contactDataFragment } from "../../../../src/containers/TexterTodo"; -import { muiTheme } from "../../../test_helpers"; import ThemeContext from "../../../../src/containers/context/ThemeContext"; describe("mutations.updateQuestionResponses", () => { @@ -457,24 +457,21 @@ describe("mutations.updateQuestionResponses", () => { }); describe("when some of the steps have an action handler", () => { + function getMessagePass(received, expectedObject) { + let pass = false; + if (received?.id && expectedObject?.id) { + pass = Number(received.id) === Number(expectedObject.id); + } + const message = pass ? "ok" : "fail"; + return { + message, + pass + }; + } + beforeEach(async () => { expect.extend({ - objectWithId: (received, expectedObject) => { - let pass = false; - if ( - received && - received.id && - expectedObject && - expectedObject.id - ) { - pass = Number(received.id) === Number(expectedObject.id); - } - const message = pass ? "ok" : "fail"; - return { - message, - pass - }; - } + objectWithId: getMessagePass }); ({ @@ -563,8 +560,8 @@ describe("mutations.updateQuestionResponses", () => { ); }); - describe("when a response is added", () => { - beforeEach(async () => { + const responseAdded = { + beforeEach: async () => { questionResponses = [ { campaignContactId: contacts[0].id, @@ -592,7 +589,11 @@ describe("mutations.updateQuestionResponses", () => { ComplexTestActionHandler, "processDeletedQuestionResponse" ); - }); + } + } + + describe("when a response is added", () => { + beforeEach(responseAdded.beforeEach); it("calls the action handler for the new response", async () => { await Mutations.updateQuestionResponses( From bab116017b9066462e6370a28b5170d58390e6e5 Mon Sep 17 00:00:00 2001 From: Kathy Nguyen Date: Fri, 23 Feb 2024 15:11:22 -0800 Subject: [PATCH 18/38] Refactor test for SonarCloud --- .../campaign/updateQuestionResponses.test.js | 99 ++++++++++++------- 1 file changed, 61 insertions(+), 38 deletions(-) diff --git a/__test__/server/api/campaign/updateQuestionResponses.test.js b/__test__/server/api/campaign/updateQuestionResponses.test.js index 6ffe7b6f3..f89255bfb 100644 --- a/__test__/server/api/campaign/updateQuestionResponses.test.js +++ b/__test__/server/api/campaign/updateQuestionResponses.test.js @@ -589,13 +589,8 @@ describe("mutations.updateQuestionResponses", () => { ComplexTestActionHandler, "processDeletedQuestionResponse" ); - } - } - - describe("when a response is added", () => { - beforeEach(responseAdded.beforeEach); - - it("calls the action handler for the new response", async () => { + }, + it1: async () => { await Mutations.updateQuestionResponses( undefined, { questionResponses, campaignContactId: contacts[0].id }, @@ -635,11 +630,17 @@ describe("mutations.updateQuestionResponses", () => { ComplexTestActionHandler.processAction.mock.calls[0][0] .previousValue ).toBeNull(); - }); + } + } + + describe("when a response is added", () => { + beforeEach(responseAdded.beforeEach); + + it("calls the action handler for the new response", responseAdded.it1); }); - describe("when responses are added, resubmitted with no change, updated, and deleted", () => { - beforeEach(async () => { + const responseResubmitted = { + beforeEach: async () => { questionResponses = [ { campaignContactId: contacts[0].id, @@ -667,9 +668,8 @@ describe("mutations.updateQuestionResponses", () => { ComplexTestActionHandler, "processDeletedQuestionResponse" ); - }); - - describe("when one of the question responses has already been saved with the same value", () => { + }, + saved: () => { it("calls processAction for the new question response", async () => { await Mutations.updateQuestionResponses( undefined, @@ -686,9 +686,8 @@ describe("mutations.updateQuestionResponses", () => { ComplexTestActionHandler.processDeletedQuestionResponse ).not.toHaveBeenCalled(); }); - }); - - describe("when one of the question responses was updated", () => { + }, + updated: () => { beforeEach(async () => { questionResponses[0].value = "Blue"; }); @@ -718,9 +717,8 @@ describe("mutations.updateQuestionResponses", () => { ]) ); }); - }); - - describe("when one of the question responses is deleted", () => { + }, + deleted: () => { it("calls processDeletedQuestionResponse", async () => { await Mutations.updateQuestionResponses( undefined, @@ -771,11 +769,21 @@ describe("mutations.updateQuestionResponses", () => { .calls[0][0].previousValue ).toEqual("Crimson"); }); - }); + } + } + + describe("when responses are added, resubmitted with no change, updated, and deleted", () => { + beforeEach(responseResubmitted.beforeEach); + + describe("when one of the question responses has already been saved with the same value", responseResubmitted.saved); + + describe("when one of the question responses was updated", responseResubmitted.updated); + + describe("when one of the question responses is deleted", responseResubmitted.deleted); }); - describe("when no action handlers are configured", () => { - beforeEach(async () => { + const noActionHandlersConfigured = { + beforeEach: async () => { ({ interactionSteps, redInteractionStep, @@ -785,9 +793,8 @@ describe("mutations.updateQuestionResponses", () => { inputInteractionStepsWithActionHandlers, 2 )); - }); - - it("exits early and logs an error", async () => { + }, + earlyExit: async () => { jest .spyOn(ActionHandlers, "rawAllActionHandlers") .mockReturnValue({}); @@ -800,11 +807,17 @@ describe("mutations.updateQuestionResponses", () => { ); expect(cacheableData.organization.load).not.toHaveBeenCalled(); - }); + } + } + + describe("when no action handlers are configured", () => { + beforeEach(noActionHandlersConfigured.beforeEach); + + it("exits early and logs an error", noActionHandlersConfigured.earlyExit); }); - describe("when task dispatch fails", () => { - beforeEach(async () => { + const taskDispatchFails = { + beforeEach: async () => { ({ interactionSteps, redInteractionStep, @@ -814,9 +827,8 @@ describe("mutations.updateQuestionResponses", () => { inputInteractionStepsWithActionHandlers, 2 )); - }); - - it("dispatches other actions", async () => { + }, + dispatchOtherActions: async () => { jest.spyOn(ComplexTestActionHandler, "processAction"); jest.spyOn(jobRunner, "dispatchTask").mockImplementationOnce(() => { throw new Error("foo"); @@ -846,11 +858,17 @@ describe("mutations.updateQuestionResponses", () => { } ] ]); - }); + } + } + + describe("when task dispatch fails", () => { + beforeEach(taskDispatchFails.beforeEach); + + it("dispatches other actions", taskDispatchFails.dispatchOtherActions); }); - describe("when the action handler throws an exception", () => { - beforeEach(async () => { + const actionHandlerThrowsException = { + beforeEach: async () => { ({ interactionSteps, redInteractionStep, @@ -860,9 +878,8 @@ describe("mutations.updateQuestionResponses", () => { inputInteractionStepsWithActionHandlers, 2 )); - }); - - it("processes the other actions", async () => { + }, + processOtherActions: async () => { jest .spyOn(ComplexTestActionHandler, "processAction") .mockRejectedValueOnce(new Error("oh no")); @@ -906,7 +923,13 @@ describe("mutations.updateQuestionResponses", () => { ] ]) ); - }); + } + } + + describe("when the action handler throws an exception", () => { + beforeEach(actionHandlerThrowsException.beforeEach); + + it("processes the other actions", actionHandlerThrowsException.processOtherActions); }); }); }); From 7e8e5ad072196135b06cd62b30d0818845dc7b9a Mon Sep 17 00:00:00 2001 From: Kathy Nguyen Date: Fri, 23 Feb 2024 15:27:22 -0800 Subject: [PATCH 19/38] Refactor for SonarCloud issues --- .../campaign/updateQuestionResponses.test.js | 206 +++++++++--------- 1 file changed, 108 insertions(+), 98 deletions(-) diff --git a/__test__/server/api/campaign/updateQuestionResponses.test.js b/__test__/server/api/campaign/updateQuestionResponses.test.js index f89255bfb..381f6f328 100644 --- a/__test__/server/api/campaign/updateQuestionResponses.test.js +++ b/__test__/server/api/campaign/updateQuestionResponses.test.js @@ -639,6 +639,104 @@ describe("mutations.updateQuestionResponses", () => { it("calls the action handler for the new response", responseAdded.it1); }); + async function deletedResponse () { + await Mutations.updateQuestionResponses( + undefined, + { + questionResponses: [questionResponses[0]], + campaignContactId: contacts[0].id + }, + { loaders, user: texterUser } + ); + + expect( + ComplexTestActionHandler.processAction + ).not.toHaveBeenCalled(); + + expect( + ComplexTestActionHandler.processDeletedQuestionResponse + ).toHaveBeenCalled(); + + expect( + ComplexTestActionHandler.processDeletedQuestionResponse.mock + .calls[0][0].questionResponse + ).toEqual(questionResponses[1]); + + expect( + ComplexTestActionHandler.processDeletedQuestionResponse.mock.calls[0][0].interactionStep.id.toString() + ).toEqual(shadesOfRedInteractionSteps[0].id); + + expect( + ComplexTestActionHandler.processDeletedQuestionResponse.mock + .calls[0][0].campaignContactId + ).toEqual(contacts[0].id); + + expect( + ComplexTestActionHandler.processDeletedQuestionResponse.mock + .calls[0][0].contact.id + ).toEqual(contacts[0].id); + + expect( + ComplexTestActionHandler.processDeletedQuestionResponse.mock.calls[0][0].campaign.id.toString() + ).toEqual(campaign.id); + + expect( + ComplexTestActionHandler.processDeletedQuestionResponse.mock.calls[0][0].organization.id.toString() + ).toEqual(organization.id); + + expect( + ComplexTestActionHandler.processDeletedQuestionResponse.mock + .calls[0][0].previousValue + ).toEqual("Crimson"); + } + + async function newResponse() { + await Mutations.updateQuestionResponses( + undefined, + { questionResponses, campaignContactId: contacts[0].id }, + { loaders, user: texterUser } + ); + + await sleep(100); + + expect( + ComplexTestActionHandler.processAction + ).not.toHaveBeenCalled(); + expect( + ComplexTestActionHandler.processDeletedQuestionResponse + ).not.toHaveBeenCalled(); + } + + async function setQuestionResponseValue() { + questionResponses[0].value = "Blue"; + } + + async function updatedResponse() { + await Mutations.updateQuestionResponses( + undefined, + { questionResponses, campaignContactId: contacts[0].id }, + { loaders, user: texterUser } + ); + + expect( + ComplexTestActionHandler.processDeletedQuestionResponse + ).not.toHaveBeenCalled(); + expect(ComplexTestActionHandler.processAction.mock.calls).toEqual( + expect.arrayContaining([ + [ + expect.objectContaining({ + actionObject: expect.objectWithId(colorInteractionSteps[2]), + campaignContactId: Number(contacts[0].id), + contact: expect.objectWithId(contacts[0]), + campaign: expect.objectWithId(campaign), + organization: expect.objectWithId(organization), + previousValue: "Red" + }) + ] + ]) + ); + } + const responseResubmitted = { beforeEach: async () => { questionResponses = [ @@ -670,105 +768,15 @@ describe("mutations.updateQuestionResponses", () => { ); }, saved: () => { - it("calls processAction for the new question response", async () => { - await Mutations.updateQuestionResponses( - undefined, - { questionResponses, campaignContactId: contacts[0].id }, - { loaders, user: texterUser } - ); - - await sleep(100); - - expect( - ComplexTestActionHandler.processAction - ).not.toHaveBeenCalled(); - expect( - ComplexTestActionHandler.processDeletedQuestionResponse - ).not.toHaveBeenCalled(); - }); + it("calls processAction for the new question response", newResponse); }, updated: () => { - beforeEach(async () => { - questionResponses[0].value = "Blue"; - }); - - it("calls processAction for for the updated response, and it passes in previousValue", async () => { - await Mutations.updateQuestionResponses( - undefined, - { questionResponses, campaignContactId: contacts[0].id }, - { loaders, user: texterUser } - ); - - expect( - ComplexTestActionHandler.processDeletedQuestionResponse - ).not.toHaveBeenCalled(); - expect(ComplexTestActionHandler.processAction.mock.calls).toEqual( - expect.arrayContaining([ - [ - expect.objectContaining({ - actionObject: expect.objectWithId(colorInteractionSteps[2]), - campaignContactId: Number(contacts[0].id), - contact: expect.objectWithId(contacts[0]), - campaign: expect.objectWithId(campaign), - organization: expect.objectWithId(organization), - previousValue: "Red" - }) - ] - ]) - ); - }); + beforeEach(setQuestionResponseValue); + + it("calls processAction for for the updated response, and it passes in previousValue", updatedResponse); }, deleted: () => { - it("calls processDeletedQuestionResponse", async () => { - await Mutations.updateQuestionResponses( - undefined, - { - questionResponses: [questionResponses[0]], - campaignContactId: contacts[0].id - }, - { loaders, user: texterUser } - ); - - expect( - ComplexTestActionHandler.processAction - ).not.toHaveBeenCalled(); - - expect( - ComplexTestActionHandler.processDeletedQuestionResponse - ).toHaveBeenCalled(); - - expect( - ComplexTestActionHandler.processDeletedQuestionResponse.mock - .calls[0][0].questionResponse - ).toEqual(questionResponses[1]); - - expect( - ComplexTestActionHandler.processDeletedQuestionResponse.mock.calls[0][0].interactionStep.id.toString() - ).toEqual(shadesOfRedInteractionSteps[0].id); - - expect( - ComplexTestActionHandler.processDeletedQuestionResponse.mock - .calls[0][0].campaignContactId - ).toEqual(contacts[0].id); - - expect( - ComplexTestActionHandler.processDeletedQuestionResponse.mock - .calls[0][0].contact.id - ).toEqual(contacts[0].id); - - expect( - ComplexTestActionHandler.processDeletedQuestionResponse.mock.calls[0][0].campaign.id.toString() - ).toEqual(campaign.id); - - expect( - ComplexTestActionHandler.processDeletedQuestionResponse.mock.calls[0][0].organization.id.toString() - ).toEqual(organization.id); - - expect( - ComplexTestActionHandler.processDeletedQuestionResponse.mock - .calls[0][0].previousValue - ).toEqual("Crimson"); - }); + it("calls processDeletedQuestionResponse", deletedResponse); } } @@ -816,6 +824,10 @@ describe("mutations.updateQuestionResponses", () => { it("exits early and logs an error", noActionHandlersConfigured.earlyExit); }); + function throwError() { + throw new Error("foo"); + } + const taskDispatchFails = { beforeEach: async () => { ({ @@ -830,9 +842,7 @@ describe("mutations.updateQuestionResponses", () => { }, dispatchOtherActions: async () => { jest.spyOn(ComplexTestActionHandler, "processAction"); - jest.spyOn(jobRunner, "dispatchTask").mockImplementationOnce(() => { - throw new Error("foo"); - }); + jest.spyOn(jobRunner, "dispatchTask").mockImplementationOnce(throwError); await Mutations.updateQuestionResponses( {}, { questionResponses, campaignContactId: contacts[0].id }, From 68c1e47cbfa7663e74095b46eb21dfae407c8e94 Mon Sep 17 00:00:00 2001 From: Kathy Nguyen Date: Fri, 23 Feb 2024 15:39:54 -0800 Subject: [PATCH 20/38] Reduce code duplication --- .../CampaignInteractionStepsForm.test copy.js | 923 ++++++++++++++++++ .../CampaignInteractionStepsForm.test.js | 33 +- 2 files changed, 936 insertions(+), 20 deletions(-) create mode 100644 __test__/components/CampaignInteractionStepsForm.test copy.js diff --git a/__test__/components/CampaignInteractionStepsForm.test copy.js b/__test__/components/CampaignInteractionStepsForm.test copy.js new file mode 100644 index 000000000..dae7022e8 --- /dev/null +++ b/__test__/components/CampaignInteractionStepsForm.test copy.js @@ -0,0 +1,923 @@ +/** + * @jest-environment jsdom + */ +import React from "react"; +import { mount } from "enzyme"; +import { r } from "../../src/server/models"; +import { StyleSheetTestUtils } from "aphrodite"; + +import Card from "@material-ui/core/Card"; +import CardHeader from "@material-ui/core/CardHeader"; + +import GSSelectField from "../../src/components/forms/GSSelectField"; +import GSScriptField from "../../src/components/forms/GSScriptField"; +import { CampaignInteractionStepsFormBase as CampaignInteractionStepsForm } from "../../src/components/CampaignInteractionStepsForm"; +import CampaignFormSectionHeading from "../../src/components/CampaignFormSectionHeading"; +import { + AdminCampaignEditBase as AdminCampaignEdit, + operations as adminCampaignEditOps +} from "../../src/containers/AdminCampaignEdit"; +import { + mockInteractionSteps, + setupTest, + cleanupTest, + createCampaign, + createInvite, + createOrganization, + createUser, + makeRunnableMutations, + runComponentQueries, + muiTheme +} from "../test_helpers"; +import ThemeContext from "../../src/containers/context/ThemeContext"; + +describe("CampaignInteractionStepsForm", () => { + describe("basic instantiation", function t() { + let wrappedComponent; + let component; + const getInteractionSteps = () => + JSON.parse( + '[{"id":"3237","questionText":"Disposition","script":"Hi {firstName}, is {texterFirstName} with WI Working Families! I’m making sure you got the word about our next event! Please join us tomorrow July 11 for a #WFP2020 presidential meet & greet happy hour with Julián Castro at 7pm in Milwaukee! Will you join, {firstName}?","answerOption":"Initial Message","answerActions":"","parentInteractionId":null,"isDeleted":false},{"id":"3238","questionText":"SMS Ask","script":"Fantastic! Advanced registration is required! Please RSVP at https://wfpus.org/2S5dJq0 and make sure to tell your friends and family about the event too! Want to get updates from WFP via text? Reply YES to confirm & start receiving mobile updates from 738674. Msg & data rates may apply.","answerOption":"Yes","answerActions":"","parentInteractionId":"3237","isDeleted":false},{"id":"3239","questionText":"","script":"Thank you for joining! Check out our website http://workingfamilies.org for more information about our work.","answerOption":"SMS Yes","answerActions":"","parentInteractionId":"3238","isDeleted":false},{"id":"3240","questionText":"SMS Ask","script":"I hope you can {firstName}! This is a great chance to get to know Julián and our #WFP2020 process! Please RSVP here and an organizer will be in touch to answer any questions -- https://wfpus.org/2S5dJq0 and make sure to tell your friends and family about the event too! Want to get updates from WFP via text? Reply YES to confirm & start receiving mobile updates from 738674. Msg & data rates may apply.","answerOption":"Maybe","answerActions":"","parentInteractionId":"3237","isDeleted":false},{"id":"3241","questionText":"","script":"Thank you for joining! Check out our website http://workingfamilies.org for more information about our work.","answerOption":"SMS Yes","answerActions":"","parentInteractionId":"3240","isDeleted":false},{"id":"3242","questionText":"SMS Ask","script":"No worries, {firstName}! We can keep you updated on events and ways to make local change through updates from WFP via text. Opt in by replying YES to confirm & start receiving mobile updates from 738674. Msg & data rates may apply.","answerOption":"Cannot Attend This Time","answerActions":"","parentInteractionId":"3237","isDeleted":false},{"id":"3243","questionText":"","script":"Thank you for joining! Check out our website http://workingfamilies.org for more information about our work.","answerOption":"SMS Yes","answerActions":"","parentInteractionId":"3242","isDeleted":false},{"id":"3244","questionText":"","script":"OK. Thank you for your time. \\nNonsupporter of the candidate\\nJulián Castro is one of six candidates being considered for endorsement. The WFP endorsement process involves the membership, so we create opportunities for voters to meet, speak with, and ask hard questions of the candidates or their surrogates. Would you like to attend tonight?","answerOption":"Nonsupporter of WFP","answerActions":"","parentInteractionId":"3237","isDeleted":false},{"id":"3245","questionText":"","script":"Thank you for letting me know. We really appreciate your past support. You can always get updates by texting WFP2020 to 738674 or join a volunteer team online at www.WFP4theMany.org ","answerOption":"No longer interested, non opt out","answerActions":"","parentInteractionId":"3237","isDeleted":false},{"id":"3246","questionText":"In District?","script":"Thank you for letting me know! We will update our records. If you are in the Milwaukee area, would you like to attend our event?","answerOption":"Wrong # default ","answerActions":"","parentInteractionId":"3237","isDeleted":false},{"id":"3247","questionText":"SMS Ask","script":"I hope you can! We will be at Working Families Party HQ from 7-830pm. Please RSVP here and an organizer will be in touch to answer any questions -- https://wfpus.org/2S5dJq0 and make sure to tell your friends and family about the event too! Want to get updates from WFP via text? Reply YES to confirm & start receiving mobile updates from 738674. Msg & data rates may apply.","answerOption":"Yes/Maybe","answerActions":"","parentInteractionId":"3246","isDeleted":false},{"id":"3248","questionText":"","script":"Thank you for joining! Check out our website http://workingfamilies.org for more information about our work.","answerOption":"SMS Yes","answerActions":"","parentInteractionId":"3247","isDeleted":false},{"id":"3249","questionText":"","script":"Thank you for getting back to me. You can always get updates by texting WFP2020 to 738674 or join a volunteer team online at www.WFP4theMany.org ","answerOption":"No - any reason","answerActions":"","parentInteractionId":"3246","isDeleted":false},{"id":"3250","questionText":"SMS Ask","script":"Thank you for letting me know! We will update our records. We will be at Working Families Party HQ from 7pm. Please RSVP here and an organizer will be in touch to answer any questions -- https://wfpus.org/2S5dJq0 and make sure to tell your friends and family about the event too! Want updates from WFP via text? Reply YES to confirm & start receiving mobile updates from 738674. Msg & data rates may apply","answerOption":"Wrong # interested","answerActions":"","parentInteractionId":"3237","isDeleted":false},{"id":"3251","questionText":"","script":"Thank you for joining! Check out our website http://workingfamilies.org for more information about our work","answerOption":"SMS Yes","answerActions":"","parentInteractionId":"3250","isDeleted":false},{"id":"3252","questionText":"Zip Ask","script":"Thank you for letting me know. If I may get your zip code, I can update our records so you will get relevant action alerts.","answerOption":"Moved - NOT FOR WRONG #s","answerActions":"","parentInteractionId":"3237","isDeleted":false},{"id":"3253","questionText":"SMS Ask","script":"Thank you! I will make sure that gets updated. Want to get updates relevant to your location from WFP via text? Reply YES to confirm & start receiving mobile updates from 738674. Msg & data rates may apply.","answerOption":"Provides zip","answerActions":"","parentInteractionId":"3252","isDeleted":false},{"id":"3254","questionText":"","script":"Thank you for joining! Check out our website http://workingfamilies.org for more information about our work.","answerOption":"SMS Yes","answerActions":"","parentInteractionId":"3253","isDeleted":false},{"id":"3255","questionText":"","script":"Thank you for getting back to me. You can always get updates by texting WFP2020 to 738674 or join a volunteer team online at www.WFP4theMany.org ","answerOption":"No zip","answerActions":"","parentInteractionId":"3252","isDeleted":false},{"id":"3256","questionText":"","script":"I am so sorry to hear that, and will update our records. Please take good care.","answerOption":"Person died ","answerActions":"","parentInteractionId":"3237","isDeleted":false},{"id":"3257","questionText":"","script":"Voy a notar que un organizador quien habla español debería comunicarse contigo pronto. ¡Gracias!","answerOption":"Spanish ","answerActions":"","parentInteractionId":"3237","isDeleted":false}]' + ); + + beforeEach(() => { + StyleSheetTestUtils.suppressStyleInjection(); + wrappedComponent = mount( + + {}} + onSubmit={() => {}} + ensureComplete + customFields={[]} + saveLabel="save" + errors={[]} + availableActions={[]} + /> + + ); + component = wrappedComponent.find(CampaignInteractionStepsForm); + }); + + afterEach(() => { + wrappedComponent.unmount(); + }); + + it("initializes state correctly", () => { + expect(component.state().displayAllSteps).toEqual(true); + expect(component.state().interactionSteps.length).toEqual(21); + }); + + it("has the correct heading", () => { + const divs = component.find(CampaignFormSectionHeading).find("div"); + expect(divs.at(1).props().children).toEqual( + "What do you want to discuss?" + ); + }); + + it("rendered the first interaction step", () => { + const cards = component.find(Card); + const card = cards.at(0); + const cardHeader = card.find(CardHeader); + + expect(cardHeader.props().subtitle).toEqual( + expect.stringMatching(/^Enter a script.*/) + ); + + const interactionSteps = getInteractionSteps(); + const scripts = component.find(GSScriptField); + + expect(scripts.at(0).props().value).toEqual(interactionSteps[0].script); + }); + + it("rendered all the interaction steps", () => { + const interactionSteps = getInteractionSteps().map(step => step.script); + const scripts = component.find(GSScriptField).map(c => c.props().value); + + expect(interactionSteps.sort()).toEqual(scripts.sort()); + }); + }); + + describe("action handlers", () => { + const pinkInteractionStep = { + id: 4, + questionText: "", + script: "Deep Pink is an awesome color, {firstName}!", + answerOption: "Deep Pink", + answerActions: "", + answerActionsData: null, + parentInteractionId: 1, + isDeleted: false + }; + + let wrappedComponent; + let interactionSteps; + + function cmpAnswerOptions(step) { + return function(mStep) { + /** + * @returns True if the answer options are equal. False otherwise. + */ + return step.answer_option === mStep.answerOption; + }; + } + + function cmpProp(prop, val) { + return function(node) { + /** + * @returns True if the node prop and val are equal. False otherwise. + */ + return node.props()[prop] === val; + }; + } + + function dummyFunction() { + /** + * Empty function that does nothing + * + * @returns Empty object + */ + return {}; + } + + function saveInteractionSteps( + campaign, + done, + interactionSteps, + queryResults, + wrappedComponent + ) { + const newInteractionSteps = []; + let instance, interactionStepsAfter; + + async function callback1() { + const campaignInteractionStepsForm = wrappedComponent.find( + CampaignInteractionStepsForm + ); + + expect(campaignInteractionStepsForm.exists()).toEqual(true); + + instance = campaignInteractionStepsForm.instance(); + + await instance.onSave(); + + interactionStepsAfter = await r + .knex("interaction_step") + .where({ campaign_id: campaign.id }); + + interactionStepsAfter.map(normalizeIsDeleted); + + expect(interactionStepsAfter).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + answer_actions: "", + answer_actions_data: null, + answer_option: "", + campaign_id: Number(campaign.id), + id: expect.any(Number), + is_deleted: false, + parent_interaction_id: null, + question: "What's your favorite color?", + script: "Hi {firstName}! Let's talk about colors." + }), + expect.objectContaining({ + answer_actions: "complex-test-action", + answer_actions_data: + '{"value":"{\\"hex\\":\\"#B22222\\",\\"rgb\\":{\\"r\\":178,\\"g\\":34,\\"b\\":34}}","label":"firebrick"}', + answer_option: "Red", + id: expect.any(Number), + campaign_id: Number(campaign.id), + is_deleted: false, + parent_interaction_id: expect.any(Number), + question: "What's your favorite shade of red?", + script: "Red is a great color, {firstName}!" + }), + expect.objectContaining({ + answer_actions: "", + answer_actions_data: "", + answer_option: "Crimson", + campaign_id: Number(campaign.id), + id: expect.any(Number), + is_deleted: false, + parent_interaction_id: expect.any(Number), + question: "", + script: "Crimson is a great shade of red, {firstName}!" + }), + expect.objectContaining({ + answer_actions: "", + answer_actions_data: "", + answer_option: "Cherry", + campaign_id: Number(campaign.id), + id: expect.any(Number), + is_deleted: false, + parent_interaction_id: expect.any(Number), + question: "", + script: "Cherry is a great shade of red, {firstName}!" + }), + expect.objectContaining({ + answer_actions: "complex-test-action", + answer_actions_data: + '{"value":"{\\"hex\\":\\"#4B0082\\",\\"rgb\\":{\\"r\\":75,\\"g\\":0,\\"b\\":130}}","label":"indigo"}', + answer_option: "Purple", + campaign_id: Number(campaign.id), + id: expect.any(Number), + is_deleted: false, + parent_interaction_id: expect.any(Number), + question: "", + script: "Purple is a great color, {firstName}!" + }) + ]) + ); + + // Delete "Red" interaction step + wrappedComponent.setState( + { + expandedSection: 3 + }, + callback2 + ); + } + + async function callback2() { + interactionStepsAfter.forEach(deleteRedInteractionSteps); + + instance.state.interactionSteps = newInteractionSteps; + await instance.onSave(); + + const interactionStepsAfterDelete = await r + .knex("interaction_step") + .where({ campaign_id: campaign.id }); + + // Test that the "Red" interaction step and its children are deleted + interactionStepsAfterDelete.map(normalizeIsDeleted); + expect(interactionStepsAfterDelete).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + answer_actions: "", + answer_actions_data: null, + answer_option: "", + campaign_id: Number(campaign.id), + id: expect.any(Number), + is_deleted: false, + parent_interaction_id: null, + question: "What's your favorite color?", + script: "Hi {firstName}! Let's talk about colors." + }), + expect.objectContaining({ + answer_actions: "complex-test-action", + answer_actions_data: + '{"value":"{\\"hex\\":\\"#4B0082\\",\\"rgb\\":{\\"r\\":75,\\"g\\":0,\\"b\\":130}}","label":"indigo"}', + answer_option: "Purple", + campaign_id: Number(campaign.id), + id: expect.any(Number), + is_deleted: false, + parent_interaction_id: expect.any(Number), + question: "", + script: "Purple is a great color, {firstName}!" + }) + ]) + ); + + done(); + } + + function deleteRedInteractionSteps(step) { + const newStep = JSON.parse( + JSON.stringify( + instance.state.interactionSteps.find(cmpAnswerOptions(step)) + ) + ); + + newStep.id = step.id; + newStep.parentInteractionId = step.parent_interaction_id; + + if (step.answer_option === "Red") { + newStep.isDeleted = true; + } + + newInteractionSteps.push(newStep); + } + + /** + * Normalize is_deleted field due to various possible truthy values in different databases types + * @param {array} is Interaction steps + */ + function normalizeIsDeleted(step) { + // eslint-disable-next-line no-param-reassign + step.is_deleted = !!step.is_deleted; + } + + return function(interactionStepsBefore) { + expect(interactionStepsBefore).toHaveLength(0); + + return wrappedComponent.setState( + { + expandedSection: 3, + campaignFormValues: { + ...queryResults.campaignData.campaign, + interactionSteps + } + }, + callback1 + ); + }; + } + + describe("when there are no action handlers", () => { + beforeEach(async () => { + interactionSteps = [mockInteractionSteps]; + + StyleSheetTestUtils.suppressStyleInjection(); + wrappedComponent = mount( + + + + ); + }); + + it("doesn't render the answer actions", async () => { + const answerActionsComponents = wrappedComponent.findWhere( + cmpProp("data-test", "actionSelect") + ); + expect(answerActionsComponents.exists()).toEqual(false); + }); + }); + + describe("when there are action handlers and no answerActionsData", () => { + beforeEach(async () => { + interactionSteps = [ + { + id: 1, + questionText: "What is your favorite color", + script: "Hello {firstName}. Let's talk about your favorite color.", + answerOption: "", + answerActions: "", + answerActionsData: null, + parentInteractionId: null, + isDeleted: false + }, + { + id: 2, + questionText: "", + script: "Red is an awesome color, {firstName}!", + answerOption: "Red", + answerActionsData: null, + answerActions: "red-handler", + parentInteractionId: 1, + isDeleted: false + }, + { + id: 3, + questionText: "", + script: "Purple is an awesome color, {firstName}!", + answerOption: "Purple", + answerActions: "purple-handler", + answerActionsData: null, + parentInteractionId: 1, + isDeleted: false + }, + pinkInteractionStep + ]; + + StyleSheetTestUtils.suppressStyleInjection(); + wrappedComponent = mount( + + + + ); + }); + + it("renders the answer actions", async () => { + const cards = wrappedComponent.find(Card); + expect(cards.exists()).toEqual(true); + + // FIRST STEP VALIDATION + const step1 = cards.at(1); + const selectField1 = step1.find(GSSelectField); + const step1AnswerActionNodes = step1.findWhere( + cmpProp("data-test", "actionSelect") + ); + expect(step1AnswerActionNodes.first().props().value).toEqual( + "red-handler" + ); + + expect(selectField1.props().choices).toEqual([ + { + value: "", + label: "None" + }, + { + value: "red-handler", + label: "Red Action" + }, + { + value: "purple-handler", + label: "Purple Action" + } + ]); + + const step1ClientChoiceNodes = step1.findWhere( + cmpProp("data-test", "actionDataAutoComplete") + ); + + expect(step1ClientChoiceNodes.exists()).toEqual(false); + + // SECOND STEP VALIDATION + const step2 = cards.at(2); + const selectField2 = step2.find(GSSelectField); + const step2AnswerActionNodes = step2.findWhere( + cmpProp("data-test", "actionSelect") + ); + + expect(step2AnswerActionNodes.first().props().value).toEqual( + "purple-handler" + ); + + expect(selectField2.props().choices).toEqual([ + { + value: "", + label: "None" + }, + { + value: "red-handler", + label: "Red Action" + }, + { + value: "purple-handler", + label: "Purple Action" + } + ]); + + const step2ClientChoiceNodes = step2.findWhere( + cmpProp("data-test", "actionDataAutoComplete") + ); + + expect(step2ClientChoiceNodes.exists()).toEqual(false); + + // THIRD STEP VALIDATION + const step3 = cards.at(3); + const selectField3 = step3.find(GSSelectField); + const step3AnswerActionNodes = step3.findWhere( + cmpProp("data-test", "actionSelect") + ); + + expect(step3AnswerActionNodes.first().props().value).toEqual(""); + + expect(selectField3.props().choices).toEqual([ + { + value: "", + label: "None" + }, + { + value: "red-handler", + label: "Red Action" + }, + { + value: "purple-handler", + label: "Purple Action" + } + ]); + + const step3ClientChoiceNodes = step3.findWhere( + cmpProp("data-test", "actionDataAutoComplete") + ); + + expect(step3ClientChoiceNodes.exists()).toEqual(false); + }); + }); + + describe("when there are action handlers and answerActionsData", () => { + beforeEach(async () => { + interactionSteps = [ + { + id: 1, + questionText: "What is your favorite color", + script: "Hello {firstName}. Let's talk about your favorite color.", + answerOption: "", + answerActions: "", + answerActionsData: null, + parentInteractionId: null, + isDeleted: false + }, + { + id: 2, + questionText: "", + script: "Red is an awesome color, {firstName}!", + answerOption: "Red", + answerActionsData: JSON.stringify({ + value: "#FF0000", + label: "red" + }), + answerActions: "color-handler", + parentInteractionId: 1, + isDeleted: false + }, + { + id: 3, + questionText: "", + script: "Purple is an awesome color, {firstName}!", + answerOption: "Purple", + answerActions: "color-handler", + answerActionsData: JSON.stringify({ + value: "#800080", + label: "purple" + }), + parentInteractionId: 1, + isDeleted: false + }, + { ...pinkInteractionStep, answerActions: "pink-handler" }, + { + id: 5, + questionText: "", + script: "That's too bad, {firstName}!", + answerOption: "I don't have one", + answerActions: "", + answerActionsData: null, + parentInteractionId: 1, + isDeleted: false + } + ]; + + StyleSheetTestUtils.suppressStyleInjection(); + wrappedComponent = mount( + + + + ); + }); + + it("renders the answer actions and answer-actions data", async () => { + const cards = wrappedComponent.find(Card); + expect(cards.exists()).toEqual(true); + + // FIRST STEP VALIDATION + const step1 = cards.at(1); + const selectField1 = step1.find(GSSelectField); + const step1AnswerActionNodes = step1.findWhere( + cmpProp("data-test", "actionSelect") + ); + + expect(step1AnswerActionNodes.first().props().value).toEqual( + "color-handler" + ); + + expect(selectField1.props().choices).toEqual([ + { + value: "", + label: "None" + }, + { + value: "color-handler", + label: "Color Action" + }, + { + value: "pink-handler", + label: "Pink Action" + } + ]); + + const step1ClientChoiceNodes = step1.findWhere( + cmpProp("data-test", "actionDataAutoComplete") + ); + + expect(step1ClientChoiceNodes.at(2).props().options).toEqual([ + { + label: "red", + value: "#FF0000" + }, + { + label: "purple", + value: "#800080" + }, + { + label: "fuschsia", + value: "#FF00FF" + } + ]); + + // SECOND STEP VALIDATION + const step2 = cards.at(2); + const selectField2 = step2.find(GSSelectField); + const step2AnswerActionNodes = step2.findWhere( + cmpProp("data-test", "actionSelect") + ); + + expect(step2AnswerActionNodes.first().props().value).toEqual( + "color-handler" + ); + + expect(selectField2.props().choices).toEqual([ + { + value: "", + label: "None" + }, + { + value: "color-handler", + label: "Color Action" + }, + { + value: "pink-handler", + label: "Pink Action" + } + ]); + + const step2ClientChoiceNodes = step2.findWhere( + cmpProp("data-test", "actionDataAutoComplete") + ); + + expect(step2ClientChoiceNodes.first().props().value).toEqual({ + label: "purple", + value: "#800080" + }); + + expect(step2ClientChoiceNodes.first().props().options).toEqual([ + { + label: "red", + value: "#FF0000" + }, + { + label: "purple", + value: "#800080" + }, + { + label: "fuschsia", + value: "#FF00FF" + } + ]); + + // THIRD STEP VALIDATION + const step3 = cards.at(3); + const selectField3 = step3.find(GSSelectField); + const step3AnswerActionNodes = step3.findWhere( + cmpProp("data-test", "actionSelect") + ); + + expect(step3AnswerActionNodes.first().props().value).toEqual( + "pink-handler" + ); + + expect(selectField3.props().choices).toEqual([ + { + value: "", + label: "None" + }, + { + value: "color-handler", + label: "Color Action" + }, + { + value: "pink-handler", + label: "Pink Action" + } + ]); + + const step3ClientChoiceNodes = step3.findWhere( + cmpProp("data-test", "actionDataAutoComplete") + ); + + expect(step3ClientChoiceNodes.exists()).toEqual(false); + + // FOURTH STEP VALIDATION + const step4 = cards.at(4); + const selectField4 = step4.find(GSSelectField); + const step4AnswerActionNodes = step4.findWhere( + cmpProp("data-test", "actionSelect") + ); + + expect(step4AnswerActionNodes.first().props().value).toEqual(""); + + expect(selectField4.props().choices).toEqual([ + { + value: "", + label: "None" + }, + { + value: "color-handler", + label: "Color Action" + }, + { + value: "pink-handler", + label: "Pink Action" + } + ]); + + const step4ClientChoiceNodes = step4.findWhere( + cmpProp("data-test", "actionDataAutoComplete") + ); + + expect(step4ClientChoiceNodes.exists()).toEqual(false); + }); + }); + + describe("when CampaignInteractionStepsForm submits interaction steps through the editCampaign mutation", () => { + let wrappedComponent; + let adminUser; + let campaign; + let organization; + let interactionSteps; + let queryResults; + + beforeEach(async () => { + await setupTest(); + + adminUser = await createUser(); + const testOrganization = await createOrganization( + adminUser, + await createInvite() + ); + campaign = await createCampaign(adminUser, testOrganization); + organization = testOrganization.data.createOrganization; + }, global.DATABASE_SETUP_TEARDOWN_TIMEOUT); + + afterEach(async () => { + await cleanupTest(); + }, global.DATABASE_SETUP_TEARDOWN_TIMEOUT); + + beforeEach(async () => { + interactionSteps = [ + { + id: "new_72", + questionText: "What's your favorite color?", + script: "Hi {firstName}! Let's talk about colors.", + answerOption: "", + answerActions: "", + answerActionsData: null, + parentInteractionId: null, + isDeleted: false + }, + { + id: "new_73", + questionText: "What's your favorite shade of red?", + script: "Red is a great color, {firstName}!", + answerOption: "Red", + answerActions: "complex-test-action", + answerActionsData: + '{"value":"{\\"hex\\":\\"#B22222\\",\\"rgb\\":{\\"r\\":178,\\"g\\":34,\\"b\\":34}}","label":"firebrick"}', + parentInteractionId: "new_72", + isDeleted: false + }, + { + id: "new_74", + questionText: "", + script: "Purple is a great color, {firstName}!", + answerOption: "Purple", + answerActions: "complex-test-action", + answerActionsData: + '{"value":"{\\"hex\\":\\"#4B0082\\",\\"rgb\\":{\\"r\\":75,\\"g\\":0,\\"b\\":130}}","label":"indigo"}', + parentInteractionId: "new_72", + isDeleted: false + }, + { + id: "new_75", + questionText: "", + script: "Crimson is a great shade of red, {firstName}!", + answerOption: "Crimson", + answerActions: "", + answerActionsData: "", + parentInteractionId: "new_73", + isDeleted: false + }, + { + id: "new_76", + questionText: "", + script: "Cherry is a great shade of red, {firstName}!", + answerOption: "Cherry", + answerActions: "", + answerActionsData: "", + parentInteractionId: "new_73", + isDeleted: false + } + ]; + + const params = { + campaignId: campaign.id, + organizationId: organization.id, + adminPerms: true + }; + + const ownProps = { + params: { + ...params + } + }; + + queryResults = await runComponentQueries( + adminCampaignEditOps.queries, + adminUser, + ownProps + ); + + const wrappedMutations = makeRunnableMutations( + adminCampaignEditOps.mutations, + adminUser, + ownProps + ); + + StyleSheetTestUtils.suppressStyleInjection(); + wrappedComponent = mount( + + + + ); + }); + + it("saves the interaction steps with onSave is invoked", done => { + expect(wrappedComponent.exists()).toEqual(true); + r.knex("interaction_step") + .where({ campaign_id: campaign.id }) + .then( + saveInteractionSteps( + campaign, + done, + interactionSteps, + queryResults, + wrappedComponent + ) + ); + }); + }); + }); +}); diff --git a/__test__/components/CampaignInteractionStepsForm.test.js b/__test__/components/CampaignInteractionStepsForm.test.js index bd5100c65..eef6d9e68 100644 --- a/__test__/components/CampaignInteractionStepsForm.test.js +++ b/__test__/components/CampaignInteractionStepsForm.test.js @@ -102,6 +102,17 @@ describe("CampaignInteractionStepsForm", () => { }); describe("action handlers", () => { + const pinkInteractionStep = { + id: 4, + questionText: "", + script: "Deep Pink is an awesome color, {firstName}!", + answerOption: "Deep Pink", + answerActions: "", + answerActionsData: null, + parentInteractionId: 1, + isDeleted: false + }; + let wrappedComponent; let interactionSteps; @@ -380,16 +391,7 @@ describe("CampaignInteractionStepsForm", () => { parentInteractionId: 1, isDeleted: false }, - { - id: 4, - questionText: "", - script: "Deep Pink is an awesome color, {firstName}!", - answerOption: "Deep Pink", - answerActions: "", - answerActionsData: null, - parentInteractionId: 1, - isDeleted: false - } + { ...pinkInteractionStep } ]; StyleSheetTestUtils.suppressStyleInjection(); @@ -561,16 +563,7 @@ describe("CampaignInteractionStepsForm", () => { parentInteractionId: 1, isDeleted: false }, - { - id: 4, - questionText: "", - script: "Deep Pink is an awesome color, {firstName}!", - answerOption: "Deep Pink", - answerActions: "pink-handler", - answerActionsData: null, - parentInteractionId: 1, - isDeleted: false - }, + { ...pinkInteractionStep, answerActions: "pink-handler" }, { id: 5, questionText: "", From ddd9221a1e6fa9215343327b27591886769f0e14 Mon Sep 17 00:00:00 2001 From: Kathy Nguyen Date: Fri, 23 Feb 2024 15:41:24 -0800 Subject: [PATCH 21/38] Delete CampaignInteractionStepsForm.test copy.js --- .../CampaignInteractionStepsForm.test copy.js | 923 ------------------ 1 file changed, 923 deletions(-) delete mode 100644 __test__/components/CampaignInteractionStepsForm.test copy.js diff --git a/__test__/components/CampaignInteractionStepsForm.test copy.js b/__test__/components/CampaignInteractionStepsForm.test copy.js deleted file mode 100644 index dae7022e8..000000000 --- a/__test__/components/CampaignInteractionStepsForm.test copy.js +++ /dev/null @@ -1,923 +0,0 @@ -/** - * @jest-environment jsdom - */ -import React from "react"; -import { mount } from "enzyme"; -import { r } from "../../src/server/models"; -import { StyleSheetTestUtils } from "aphrodite"; - -import Card from "@material-ui/core/Card"; -import CardHeader from "@material-ui/core/CardHeader"; - -import GSSelectField from "../../src/components/forms/GSSelectField"; -import GSScriptField from "../../src/components/forms/GSScriptField"; -import { CampaignInteractionStepsFormBase as CampaignInteractionStepsForm } from "../../src/components/CampaignInteractionStepsForm"; -import CampaignFormSectionHeading from "../../src/components/CampaignFormSectionHeading"; -import { - AdminCampaignEditBase as AdminCampaignEdit, - operations as adminCampaignEditOps -} from "../../src/containers/AdminCampaignEdit"; -import { - mockInteractionSteps, - setupTest, - cleanupTest, - createCampaign, - createInvite, - createOrganization, - createUser, - makeRunnableMutations, - runComponentQueries, - muiTheme -} from "../test_helpers"; -import ThemeContext from "../../src/containers/context/ThemeContext"; - -describe("CampaignInteractionStepsForm", () => { - describe("basic instantiation", function t() { - let wrappedComponent; - let component; - const getInteractionSteps = () => - JSON.parse( - '[{"id":"3237","questionText":"Disposition","script":"Hi {firstName}, is {texterFirstName} with WI Working Families! I’m making sure you got the word about our next event! Please join us tomorrow July 11 for a #WFP2020 presidential meet & greet happy hour with Julián Castro at 7pm in Milwaukee! Will you join, {firstName}?","answerOption":"Initial Message","answerActions":"","parentInteractionId":null,"isDeleted":false},{"id":"3238","questionText":"SMS Ask","script":"Fantastic! Advanced registration is required! Please RSVP at https://wfpus.org/2S5dJq0 and make sure to tell your friends and family about the event too! Want to get updates from WFP via text? Reply YES to confirm & start receiving mobile updates from 738674. Msg & data rates may apply.","answerOption":"Yes","answerActions":"","parentInteractionId":"3237","isDeleted":false},{"id":"3239","questionText":"","script":"Thank you for joining! Check out our website http://workingfamilies.org for more information about our work.","answerOption":"SMS Yes","answerActions":"","parentInteractionId":"3238","isDeleted":false},{"id":"3240","questionText":"SMS Ask","script":"I hope you can {firstName}! This is a great chance to get to know Julián and our #WFP2020 process! Please RSVP here and an organizer will be in touch to answer any questions -- https://wfpus.org/2S5dJq0 and make sure to tell your friends and family about the event too! Want to get updates from WFP via text? Reply YES to confirm & start receiving mobile updates from 738674. Msg & data rates may apply.","answerOption":"Maybe","answerActions":"","parentInteractionId":"3237","isDeleted":false},{"id":"3241","questionText":"","script":"Thank you for joining! Check out our website http://workingfamilies.org for more information about our work.","answerOption":"SMS Yes","answerActions":"","parentInteractionId":"3240","isDeleted":false},{"id":"3242","questionText":"SMS Ask","script":"No worries, {firstName}! We can keep you updated on events and ways to make local change through updates from WFP via text. Opt in by replying YES to confirm & start receiving mobile updates from 738674. Msg & data rates may apply.","answerOption":"Cannot Attend This Time","answerActions":"","parentInteractionId":"3237","isDeleted":false},{"id":"3243","questionText":"","script":"Thank you for joining! Check out our website http://workingfamilies.org for more information about our work.","answerOption":"SMS Yes","answerActions":"","parentInteractionId":"3242","isDeleted":false},{"id":"3244","questionText":"","script":"OK. Thank you for your time. \\nNonsupporter of the candidate\\nJulián Castro is one of six candidates being considered for endorsement. The WFP endorsement process involves the membership, so we create opportunities for voters to meet, speak with, and ask hard questions of the candidates or their surrogates. Would you like to attend tonight?","answerOption":"Nonsupporter of WFP","answerActions":"","parentInteractionId":"3237","isDeleted":false},{"id":"3245","questionText":"","script":"Thank you for letting me know. We really appreciate your past support. You can always get updates by texting WFP2020 to 738674 or join a volunteer team online at www.WFP4theMany.org ","answerOption":"No longer interested, non opt out","answerActions":"","parentInteractionId":"3237","isDeleted":false},{"id":"3246","questionText":"In District?","script":"Thank you for letting me know! We will update our records. If you are in the Milwaukee area, would you like to attend our event?","answerOption":"Wrong # default ","answerActions":"","parentInteractionId":"3237","isDeleted":false},{"id":"3247","questionText":"SMS Ask","script":"I hope you can! We will be at Working Families Party HQ from 7-830pm. Please RSVP here and an organizer will be in touch to answer any questions -- https://wfpus.org/2S5dJq0 and make sure to tell your friends and family about the event too! Want to get updates from WFP via text? Reply YES to confirm & start receiving mobile updates from 738674. Msg & data rates may apply.","answerOption":"Yes/Maybe","answerActions":"","parentInteractionId":"3246","isDeleted":false},{"id":"3248","questionText":"","script":"Thank you for joining! Check out our website http://workingfamilies.org for more information about our work.","answerOption":"SMS Yes","answerActions":"","parentInteractionId":"3247","isDeleted":false},{"id":"3249","questionText":"","script":"Thank you for getting back to me. You can always get updates by texting WFP2020 to 738674 or join a volunteer team online at www.WFP4theMany.org ","answerOption":"No - any reason","answerActions":"","parentInteractionId":"3246","isDeleted":false},{"id":"3250","questionText":"SMS Ask","script":"Thank you for letting me know! We will update our records. We will be at Working Families Party HQ from 7pm. Please RSVP here and an organizer will be in touch to answer any questions -- https://wfpus.org/2S5dJq0 and make sure to tell your friends and family about the event too! Want updates from WFP via text? Reply YES to confirm & start receiving mobile updates from 738674. Msg & data rates may apply","answerOption":"Wrong # interested","answerActions":"","parentInteractionId":"3237","isDeleted":false},{"id":"3251","questionText":"","script":"Thank you for joining! Check out our website http://workingfamilies.org for more information about our work","answerOption":"SMS Yes","answerActions":"","parentInteractionId":"3250","isDeleted":false},{"id":"3252","questionText":"Zip Ask","script":"Thank you for letting me know. If I may get your zip code, I can update our records so you will get relevant action alerts.","answerOption":"Moved - NOT FOR WRONG #s","answerActions":"","parentInteractionId":"3237","isDeleted":false},{"id":"3253","questionText":"SMS Ask","script":"Thank you! I will make sure that gets updated. Want to get updates relevant to your location from WFP via text? Reply YES to confirm & start receiving mobile updates from 738674. Msg & data rates may apply.","answerOption":"Provides zip","answerActions":"","parentInteractionId":"3252","isDeleted":false},{"id":"3254","questionText":"","script":"Thank you for joining! Check out our website http://workingfamilies.org for more information about our work.","answerOption":"SMS Yes","answerActions":"","parentInteractionId":"3253","isDeleted":false},{"id":"3255","questionText":"","script":"Thank you for getting back to me. You can always get updates by texting WFP2020 to 738674 or join a volunteer team online at www.WFP4theMany.org ","answerOption":"No zip","answerActions":"","parentInteractionId":"3252","isDeleted":false},{"id":"3256","questionText":"","script":"I am so sorry to hear that, and will update our records. Please take good care.","answerOption":"Person died ","answerActions":"","parentInteractionId":"3237","isDeleted":false},{"id":"3257","questionText":"","script":"Voy a notar que un organizador quien habla español debería comunicarse contigo pronto. ¡Gracias!","answerOption":"Spanish ","answerActions":"","parentInteractionId":"3237","isDeleted":false}]' - ); - - beforeEach(() => { - StyleSheetTestUtils.suppressStyleInjection(); - wrappedComponent = mount( - - {}} - onSubmit={() => {}} - ensureComplete - customFields={[]} - saveLabel="save" - errors={[]} - availableActions={[]} - /> - - ); - component = wrappedComponent.find(CampaignInteractionStepsForm); - }); - - afterEach(() => { - wrappedComponent.unmount(); - }); - - it("initializes state correctly", () => { - expect(component.state().displayAllSteps).toEqual(true); - expect(component.state().interactionSteps.length).toEqual(21); - }); - - it("has the correct heading", () => { - const divs = component.find(CampaignFormSectionHeading).find("div"); - expect(divs.at(1).props().children).toEqual( - "What do you want to discuss?" - ); - }); - - it("rendered the first interaction step", () => { - const cards = component.find(Card); - const card = cards.at(0); - const cardHeader = card.find(CardHeader); - - expect(cardHeader.props().subtitle).toEqual( - expect.stringMatching(/^Enter a script.*/) - ); - - const interactionSteps = getInteractionSteps(); - const scripts = component.find(GSScriptField); - - expect(scripts.at(0).props().value).toEqual(interactionSteps[0].script); - }); - - it("rendered all the interaction steps", () => { - const interactionSteps = getInteractionSteps().map(step => step.script); - const scripts = component.find(GSScriptField).map(c => c.props().value); - - expect(interactionSteps.sort()).toEqual(scripts.sort()); - }); - }); - - describe("action handlers", () => { - const pinkInteractionStep = { - id: 4, - questionText: "", - script: "Deep Pink is an awesome color, {firstName}!", - answerOption: "Deep Pink", - answerActions: "", - answerActionsData: null, - parentInteractionId: 1, - isDeleted: false - }; - - let wrappedComponent; - let interactionSteps; - - function cmpAnswerOptions(step) { - return function(mStep) { - /** - * @returns True if the answer options are equal. False otherwise. - */ - return step.answer_option === mStep.answerOption; - }; - } - - function cmpProp(prop, val) { - return function(node) { - /** - * @returns True if the node prop and val are equal. False otherwise. - */ - return node.props()[prop] === val; - }; - } - - function dummyFunction() { - /** - * Empty function that does nothing - * - * @returns Empty object - */ - return {}; - } - - function saveInteractionSteps( - campaign, - done, - interactionSteps, - queryResults, - wrappedComponent - ) { - const newInteractionSteps = []; - let instance, interactionStepsAfter; - - async function callback1() { - const campaignInteractionStepsForm = wrappedComponent.find( - CampaignInteractionStepsForm - ); - - expect(campaignInteractionStepsForm.exists()).toEqual(true); - - instance = campaignInteractionStepsForm.instance(); - - await instance.onSave(); - - interactionStepsAfter = await r - .knex("interaction_step") - .where({ campaign_id: campaign.id }); - - interactionStepsAfter.map(normalizeIsDeleted); - - expect(interactionStepsAfter).toEqual( - expect.arrayContaining([ - expect.objectContaining({ - answer_actions: "", - answer_actions_data: null, - answer_option: "", - campaign_id: Number(campaign.id), - id: expect.any(Number), - is_deleted: false, - parent_interaction_id: null, - question: "What's your favorite color?", - script: "Hi {firstName}! Let's talk about colors." - }), - expect.objectContaining({ - answer_actions: "complex-test-action", - answer_actions_data: - '{"value":"{\\"hex\\":\\"#B22222\\",\\"rgb\\":{\\"r\\":178,\\"g\\":34,\\"b\\":34}}","label":"firebrick"}', - answer_option: "Red", - id: expect.any(Number), - campaign_id: Number(campaign.id), - is_deleted: false, - parent_interaction_id: expect.any(Number), - question: "What's your favorite shade of red?", - script: "Red is a great color, {firstName}!" - }), - expect.objectContaining({ - answer_actions: "", - answer_actions_data: "", - answer_option: "Crimson", - campaign_id: Number(campaign.id), - id: expect.any(Number), - is_deleted: false, - parent_interaction_id: expect.any(Number), - question: "", - script: "Crimson is a great shade of red, {firstName}!" - }), - expect.objectContaining({ - answer_actions: "", - answer_actions_data: "", - answer_option: "Cherry", - campaign_id: Number(campaign.id), - id: expect.any(Number), - is_deleted: false, - parent_interaction_id: expect.any(Number), - question: "", - script: "Cherry is a great shade of red, {firstName}!" - }), - expect.objectContaining({ - answer_actions: "complex-test-action", - answer_actions_data: - '{"value":"{\\"hex\\":\\"#4B0082\\",\\"rgb\\":{\\"r\\":75,\\"g\\":0,\\"b\\":130}}","label":"indigo"}', - answer_option: "Purple", - campaign_id: Number(campaign.id), - id: expect.any(Number), - is_deleted: false, - parent_interaction_id: expect.any(Number), - question: "", - script: "Purple is a great color, {firstName}!" - }) - ]) - ); - - // Delete "Red" interaction step - wrappedComponent.setState( - { - expandedSection: 3 - }, - callback2 - ); - } - - async function callback2() { - interactionStepsAfter.forEach(deleteRedInteractionSteps); - - instance.state.interactionSteps = newInteractionSteps; - await instance.onSave(); - - const interactionStepsAfterDelete = await r - .knex("interaction_step") - .where({ campaign_id: campaign.id }); - - // Test that the "Red" interaction step and its children are deleted - interactionStepsAfterDelete.map(normalizeIsDeleted); - expect(interactionStepsAfterDelete).toEqual( - expect.arrayContaining([ - expect.objectContaining({ - answer_actions: "", - answer_actions_data: null, - answer_option: "", - campaign_id: Number(campaign.id), - id: expect.any(Number), - is_deleted: false, - parent_interaction_id: null, - question: "What's your favorite color?", - script: "Hi {firstName}! Let's talk about colors." - }), - expect.objectContaining({ - answer_actions: "complex-test-action", - answer_actions_data: - '{"value":"{\\"hex\\":\\"#4B0082\\",\\"rgb\\":{\\"r\\":75,\\"g\\":0,\\"b\\":130}}","label":"indigo"}', - answer_option: "Purple", - campaign_id: Number(campaign.id), - id: expect.any(Number), - is_deleted: false, - parent_interaction_id: expect.any(Number), - question: "", - script: "Purple is a great color, {firstName}!" - }) - ]) - ); - - done(); - } - - function deleteRedInteractionSteps(step) { - const newStep = JSON.parse( - JSON.stringify( - instance.state.interactionSteps.find(cmpAnswerOptions(step)) - ) - ); - - newStep.id = step.id; - newStep.parentInteractionId = step.parent_interaction_id; - - if (step.answer_option === "Red") { - newStep.isDeleted = true; - } - - newInteractionSteps.push(newStep); - } - - /** - * Normalize is_deleted field due to various possible truthy values in different databases types - * @param {array} is Interaction steps - */ - function normalizeIsDeleted(step) { - // eslint-disable-next-line no-param-reassign - step.is_deleted = !!step.is_deleted; - } - - return function(interactionStepsBefore) { - expect(interactionStepsBefore).toHaveLength(0); - - return wrappedComponent.setState( - { - expandedSection: 3, - campaignFormValues: { - ...queryResults.campaignData.campaign, - interactionSteps - } - }, - callback1 - ); - }; - } - - describe("when there are no action handlers", () => { - beforeEach(async () => { - interactionSteps = [mockInteractionSteps]; - - StyleSheetTestUtils.suppressStyleInjection(); - wrappedComponent = mount( - - - - ); - }); - - it("doesn't render the answer actions", async () => { - const answerActionsComponents = wrappedComponent.findWhere( - cmpProp("data-test", "actionSelect") - ); - expect(answerActionsComponents.exists()).toEqual(false); - }); - }); - - describe("when there are action handlers and no answerActionsData", () => { - beforeEach(async () => { - interactionSteps = [ - { - id: 1, - questionText: "What is your favorite color", - script: "Hello {firstName}. Let's talk about your favorite color.", - answerOption: "", - answerActions: "", - answerActionsData: null, - parentInteractionId: null, - isDeleted: false - }, - { - id: 2, - questionText: "", - script: "Red is an awesome color, {firstName}!", - answerOption: "Red", - answerActionsData: null, - answerActions: "red-handler", - parentInteractionId: 1, - isDeleted: false - }, - { - id: 3, - questionText: "", - script: "Purple is an awesome color, {firstName}!", - answerOption: "Purple", - answerActions: "purple-handler", - answerActionsData: null, - parentInteractionId: 1, - isDeleted: false - }, - pinkInteractionStep - ]; - - StyleSheetTestUtils.suppressStyleInjection(); - wrappedComponent = mount( - - - - ); - }); - - it("renders the answer actions", async () => { - const cards = wrappedComponent.find(Card); - expect(cards.exists()).toEqual(true); - - // FIRST STEP VALIDATION - const step1 = cards.at(1); - const selectField1 = step1.find(GSSelectField); - const step1AnswerActionNodes = step1.findWhere( - cmpProp("data-test", "actionSelect") - ); - expect(step1AnswerActionNodes.first().props().value).toEqual( - "red-handler" - ); - - expect(selectField1.props().choices).toEqual([ - { - value: "", - label: "None" - }, - { - value: "red-handler", - label: "Red Action" - }, - { - value: "purple-handler", - label: "Purple Action" - } - ]); - - const step1ClientChoiceNodes = step1.findWhere( - cmpProp("data-test", "actionDataAutoComplete") - ); - - expect(step1ClientChoiceNodes.exists()).toEqual(false); - - // SECOND STEP VALIDATION - const step2 = cards.at(2); - const selectField2 = step2.find(GSSelectField); - const step2AnswerActionNodes = step2.findWhere( - cmpProp("data-test", "actionSelect") - ); - - expect(step2AnswerActionNodes.first().props().value).toEqual( - "purple-handler" - ); - - expect(selectField2.props().choices).toEqual([ - { - value: "", - label: "None" - }, - { - value: "red-handler", - label: "Red Action" - }, - { - value: "purple-handler", - label: "Purple Action" - } - ]); - - const step2ClientChoiceNodes = step2.findWhere( - cmpProp("data-test", "actionDataAutoComplete") - ); - - expect(step2ClientChoiceNodes.exists()).toEqual(false); - - // THIRD STEP VALIDATION - const step3 = cards.at(3); - const selectField3 = step3.find(GSSelectField); - const step3AnswerActionNodes = step3.findWhere( - cmpProp("data-test", "actionSelect") - ); - - expect(step3AnswerActionNodes.first().props().value).toEqual(""); - - expect(selectField3.props().choices).toEqual([ - { - value: "", - label: "None" - }, - { - value: "red-handler", - label: "Red Action" - }, - { - value: "purple-handler", - label: "Purple Action" - } - ]); - - const step3ClientChoiceNodes = step3.findWhere( - cmpProp("data-test", "actionDataAutoComplete") - ); - - expect(step3ClientChoiceNodes.exists()).toEqual(false); - }); - }); - - describe("when there are action handlers and answerActionsData", () => { - beforeEach(async () => { - interactionSteps = [ - { - id: 1, - questionText: "What is your favorite color", - script: "Hello {firstName}. Let's talk about your favorite color.", - answerOption: "", - answerActions: "", - answerActionsData: null, - parentInteractionId: null, - isDeleted: false - }, - { - id: 2, - questionText: "", - script: "Red is an awesome color, {firstName}!", - answerOption: "Red", - answerActionsData: JSON.stringify({ - value: "#FF0000", - label: "red" - }), - answerActions: "color-handler", - parentInteractionId: 1, - isDeleted: false - }, - { - id: 3, - questionText: "", - script: "Purple is an awesome color, {firstName}!", - answerOption: "Purple", - answerActions: "color-handler", - answerActionsData: JSON.stringify({ - value: "#800080", - label: "purple" - }), - parentInteractionId: 1, - isDeleted: false - }, - { ...pinkInteractionStep, answerActions: "pink-handler" }, - { - id: 5, - questionText: "", - script: "That's too bad, {firstName}!", - answerOption: "I don't have one", - answerActions: "", - answerActionsData: null, - parentInteractionId: 1, - isDeleted: false - } - ]; - - StyleSheetTestUtils.suppressStyleInjection(); - wrappedComponent = mount( - - - - ); - }); - - it("renders the answer actions and answer-actions data", async () => { - const cards = wrappedComponent.find(Card); - expect(cards.exists()).toEqual(true); - - // FIRST STEP VALIDATION - const step1 = cards.at(1); - const selectField1 = step1.find(GSSelectField); - const step1AnswerActionNodes = step1.findWhere( - cmpProp("data-test", "actionSelect") - ); - - expect(step1AnswerActionNodes.first().props().value).toEqual( - "color-handler" - ); - - expect(selectField1.props().choices).toEqual([ - { - value: "", - label: "None" - }, - { - value: "color-handler", - label: "Color Action" - }, - { - value: "pink-handler", - label: "Pink Action" - } - ]); - - const step1ClientChoiceNodes = step1.findWhere( - cmpProp("data-test", "actionDataAutoComplete") - ); - - expect(step1ClientChoiceNodes.at(2).props().options).toEqual([ - { - label: "red", - value: "#FF0000" - }, - { - label: "purple", - value: "#800080" - }, - { - label: "fuschsia", - value: "#FF00FF" - } - ]); - - // SECOND STEP VALIDATION - const step2 = cards.at(2); - const selectField2 = step2.find(GSSelectField); - const step2AnswerActionNodes = step2.findWhere( - cmpProp("data-test", "actionSelect") - ); - - expect(step2AnswerActionNodes.first().props().value).toEqual( - "color-handler" - ); - - expect(selectField2.props().choices).toEqual([ - { - value: "", - label: "None" - }, - { - value: "color-handler", - label: "Color Action" - }, - { - value: "pink-handler", - label: "Pink Action" - } - ]); - - const step2ClientChoiceNodes = step2.findWhere( - cmpProp("data-test", "actionDataAutoComplete") - ); - - expect(step2ClientChoiceNodes.first().props().value).toEqual({ - label: "purple", - value: "#800080" - }); - - expect(step2ClientChoiceNodes.first().props().options).toEqual([ - { - label: "red", - value: "#FF0000" - }, - { - label: "purple", - value: "#800080" - }, - { - label: "fuschsia", - value: "#FF00FF" - } - ]); - - // THIRD STEP VALIDATION - const step3 = cards.at(3); - const selectField3 = step3.find(GSSelectField); - const step3AnswerActionNodes = step3.findWhere( - cmpProp("data-test", "actionSelect") - ); - - expect(step3AnswerActionNodes.first().props().value).toEqual( - "pink-handler" - ); - - expect(selectField3.props().choices).toEqual([ - { - value: "", - label: "None" - }, - { - value: "color-handler", - label: "Color Action" - }, - { - value: "pink-handler", - label: "Pink Action" - } - ]); - - const step3ClientChoiceNodes = step3.findWhere( - cmpProp("data-test", "actionDataAutoComplete") - ); - - expect(step3ClientChoiceNodes.exists()).toEqual(false); - - // FOURTH STEP VALIDATION - const step4 = cards.at(4); - const selectField4 = step4.find(GSSelectField); - const step4AnswerActionNodes = step4.findWhere( - cmpProp("data-test", "actionSelect") - ); - - expect(step4AnswerActionNodes.first().props().value).toEqual(""); - - expect(selectField4.props().choices).toEqual([ - { - value: "", - label: "None" - }, - { - value: "color-handler", - label: "Color Action" - }, - { - value: "pink-handler", - label: "Pink Action" - } - ]); - - const step4ClientChoiceNodes = step4.findWhere( - cmpProp("data-test", "actionDataAutoComplete") - ); - - expect(step4ClientChoiceNodes.exists()).toEqual(false); - }); - }); - - describe("when CampaignInteractionStepsForm submits interaction steps through the editCampaign mutation", () => { - let wrappedComponent; - let adminUser; - let campaign; - let organization; - let interactionSteps; - let queryResults; - - beforeEach(async () => { - await setupTest(); - - adminUser = await createUser(); - const testOrganization = await createOrganization( - adminUser, - await createInvite() - ); - campaign = await createCampaign(adminUser, testOrganization); - organization = testOrganization.data.createOrganization; - }, global.DATABASE_SETUP_TEARDOWN_TIMEOUT); - - afterEach(async () => { - await cleanupTest(); - }, global.DATABASE_SETUP_TEARDOWN_TIMEOUT); - - beforeEach(async () => { - interactionSteps = [ - { - id: "new_72", - questionText: "What's your favorite color?", - script: "Hi {firstName}! Let's talk about colors.", - answerOption: "", - answerActions: "", - answerActionsData: null, - parentInteractionId: null, - isDeleted: false - }, - { - id: "new_73", - questionText: "What's your favorite shade of red?", - script: "Red is a great color, {firstName}!", - answerOption: "Red", - answerActions: "complex-test-action", - answerActionsData: - '{"value":"{\\"hex\\":\\"#B22222\\",\\"rgb\\":{\\"r\\":178,\\"g\\":34,\\"b\\":34}}","label":"firebrick"}', - parentInteractionId: "new_72", - isDeleted: false - }, - { - id: "new_74", - questionText: "", - script: "Purple is a great color, {firstName}!", - answerOption: "Purple", - answerActions: "complex-test-action", - answerActionsData: - '{"value":"{\\"hex\\":\\"#4B0082\\",\\"rgb\\":{\\"r\\":75,\\"g\\":0,\\"b\\":130}}","label":"indigo"}', - parentInteractionId: "new_72", - isDeleted: false - }, - { - id: "new_75", - questionText: "", - script: "Crimson is a great shade of red, {firstName}!", - answerOption: "Crimson", - answerActions: "", - answerActionsData: "", - parentInteractionId: "new_73", - isDeleted: false - }, - { - id: "new_76", - questionText: "", - script: "Cherry is a great shade of red, {firstName}!", - answerOption: "Cherry", - answerActions: "", - answerActionsData: "", - parentInteractionId: "new_73", - isDeleted: false - } - ]; - - const params = { - campaignId: campaign.id, - organizationId: organization.id, - adminPerms: true - }; - - const ownProps = { - params: { - ...params - } - }; - - queryResults = await runComponentQueries( - adminCampaignEditOps.queries, - adminUser, - ownProps - ); - - const wrappedMutations = makeRunnableMutations( - adminCampaignEditOps.mutations, - adminUser, - ownProps - ); - - StyleSheetTestUtils.suppressStyleInjection(); - wrappedComponent = mount( - - - - ); - }); - - it("saves the interaction steps with onSave is invoked", done => { - expect(wrappedComponent.exists()).toEqual(true); - r.knex("interaction_step") - .where({ campaign_id: campaign.id }) - .then( - saveInteractionSteps( - campaign, - done, - interactionSteps, - queryResults, - wrappedComponent - ) - ); - }); - }); - }); -}); From acede3f019c8cf25f22e83f98c8ae6951b0caf5b Mon Sep 17 00:00:00 2001 From: Kathy Nguyen Date: Fri, 23 Feb 2024 16:34:07 -0800 Subject: [PATCH 22/38] Implement zip caching --- src/server/api/mutations/getOptOutMessage.js | 35 ++-------- src/server/models/cacheable_queries/README.md | 4 +- src/server/models/cacheable_queries/zip.js | 65 +++++++++++++++++++ 3 files changed, 74 insertions(+), 30 deletions(-) create mode 100644 src/server/models/cacheable_queries/zip.js diff --git a/src/server/api/mutations/getOptOutMessage.js b/src/server/api/mutations/getOptOutMessage.js index 3b37e2505..541ee18c0 100644 --- a/src/server/api/mutations/getOptOutMessage.js +++ b/src/server/api/mutations/getOptOutMessage.js @@ -1,40 +1,17 @@ -import { getConfig } from "../lib/config"; -import SmartyStreetsSDK from "smartystreets-javascript-sdk"; import optOutMessageCache from "../../models/cacheable_queries/opt-out-message"; - -const SmartyStreetsCore = SmartyStreetsSDK.core; -const Lookup = SmartyStreetsSDK.usZipcode.Lookup; - -const clientBuilder = new SmartyStreetsCore.ClientBuilder( - new SmartyStreetsCore.StaticCredentials( - getConfig("SMARTY_AUTH_ID"), - getConfig("SMARTY_AUTH_TOKEN") - ) -); -const client = clientBuilder.buildUsZipcodeClient(); +import zipStateCache from "../../models/cacheable_queries/zip"; export const getOptOutMessage = async ( _, { organizationId, zip, defaultMessage } ) => { - const lookup = new Lookup(); - - lookup.zipCode = zip; - try { - const res = await client.send(lookup); - const lookupRes = res.lookups[0].result[0]; + const queryResult = await optOutMessageCache.query({ + organizationId: organizationId, + state: await zipStateCache.query({ zip: zip }) + }); - if (lookupRes.valid) { - const queryResult = await optOutMessageCache.query({ - organizationId: organizationId, - state: lookupRes.zipcodes[0].stateAbbreviation - }); - - return queryResult || defaultMessage; - } - - return defaultMessage; + return queryResult || defaultMessage; } catch (e) { console.error(e); return defaultMessage; diff --git a/src/server/models/cacheable_queries/README.md b/src/server/models/cacheable_queries/README.md index 584008fc4..faa7226c1 100644 --- a/src/server/models/cacheable_queries/README.md +++ b/src/server/models/cacheable_queries/README.md @@ -109,7 +109,7 @@ manually referencing a key inline. All root keys are prefixed by the environmen * SET `optouts${-orgId|}` * if OPTOUTS_SHARE_ALL_ORGS is set, then orgId='' * optOutMessage - * SET `optoutmessages-${orgId}` + * KEY `optoutmessages-${orgId}` * campaign-contact (only when `REDIS_CONTACT_CACHE=1`) * KEY `contact-${contactId}` * Besides contact data, also includes `organization_id`, `messageservice_sid`, `zip.city`, `zip.state` @@ -130,3 +130,5 @@ manually referencing a key inline. All root keys are prefixed by the environmen * message (only when `REDIS_CONTACT_CACHE=1`) * LIST `messages-${contactId}` * Includes all message data +* zip + * KEY `state-of-${zip}` diff --git a/src/server/models/cacheable_queries/zip.js b/src/server/models/cacheable_queries/zip.js new file mode 100644 index 000000000..9b47498fa --- /dev/null +++ b/src/server/models/cacheable_queries/zip.js @@ -0,0 +1,65 @@ +import { getConfig } from "../../api/lib/config"; +import { r } from ".."; +import SmartyStreetsSDK from "smartystreets-javascript-sdk"; + +// SmartyStreets +const SmartyStreetsCore = SmartyStreetsSDK.core; +const Lookup = SmartyStreetsSDK.usZipcode.Lookup; + +const clientBuilder = new SmartyStreetsCore.ClientBuilder( + new SmartyStreetsCore.StaticCredentials( + getConfig("SMARTY_AUTH_ID"), + getConfig("SMARTY_AUTH_TOKEN") + ) +); +const client = clientBuilder.buildUsZipcodeClient(); + +// Cache +const cacheKey = zip => `${process.env.CACHE_PREFIX || ""}state-of-${zip}`; + +const zipStateCache = { + clearQuery: async ({ zip }) => { + if (r.redis) { + await r.redis.delAsync(cacheKey(zip)); + } + }, + query: async ({ zip }) => { + async function getState() { + const lookup = new Lookup(); + + lookup.zipCode = zip; + + const res = await client.send(lookup); + const lookupRes = res.lookups[0].result[0]; + + if (lookupRes.valid) { + return lookupRes.zipcodes[0].stateAbbreviation; + } else { + throw new Error(`State not found for zip code ${zip}`); + } + } + + if (r.redis) { + const key = cacheKey(zip); + let state = await r.redis.getAsync(key); + + if (state !== null) { + return state; + } + + state = await getState(); + + await r.redis + .multi() + .set(key, state) + .expire(key, 15780000) // 6 months + .execAsync(); + + return state; + } + + return await getState(); + } +}; + +export default zipStateCache; From bfee0006d1eea360a836eda0c0533293b746b7d6 Mon Sep 17 00:00:00 2001 From: Kathy Nguyen Date: Mon, 4 Mar 2024 16:52:39 -0800 Subject: [PATCH 23/38] Update test --- dev-tools/.env.test | 1 + 1 file changed, 1 insertion(+) diff --git a/dev-tools/.env.test b/dev-tools/.env.test index 341b8b3d4..bb9e37c82 100644 --- a/dev-tools/.env.test +++ b/dev-tools/.env.test @@ -26,3 +26,4 @@ PHONE_INVENTORY=1 ALLOW_SEND_ALL=false DST_REFERENCE_TIMEZONE='America/New_York' PASSPORT_STRATEGY=local +ASSIGNMENT_LOAD_LIMIT=1 From f9d00b79b67e4cd0e2c45ff6337e551c9780a5b0 Mon Sep 17 00:00:00 2001 From: Kathy Nguyen Date: Wed, 6 Mar 2024 12:43:05 -0800 Subject: [PATCH 24/38] Add jest tests --- __test__/server/api/assignment.test.js | 25 +++++++++++++++++++++++++ 1 file changed, 25 insertions(+) diff --git a/__test__/server/api/assignment.test.js b/__test__/server/api/assignment.test.js index 67508111c..be1831457 100644 --- a/__test__/server/api/assignment.test.js +++ b/__test__/server/api/assignment.test.js @@ -219,4 +219,29 @@ describe("test getContacts timezone stuff only", () => { /^select \* from .campaign_contact. where .assignment_id. = 1.*/ ); }); // it + + it("returns the correct query -- assignment load limit not set", () => { + let query = getContacts( + assignment, + { validTimezone: null }, + organization, + campaign + ); + expect(query.toString()).not.toMatch( + /^select \* from .campaign_contact. where .assignment_id. = 1.* limit 1/ + ); + }); // it + + it("returns the correct query -- assignment load limit set", () => { + global["ASSIGNMENT_LOAD_LIMIT"] = 1; + let query = getContacts( + assignment, + { validTimezone: null }, + organization, + campaign + ); + expect(query.toString()).toMatch( + /^select \* from .campaign_contact. where .assignment_id. = 1.* limit 1/ + ); + }); // it }); // describe From 19de49aa3e46122fad453baab4df99e4cdafd7d0 Mon Sep 17 00:00:00 2001 From: Kathy Nguyen Date: Fri, 8 Mar 2024 11:46:01 -0800 Subject: [PATCH 25/38] Update build-image.yaml --- .github/workflows/build-image.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/build-image.yaml b/.github/workflows/build-image.yaml index 372a645bf..e2c196942 100644 --- a/.github/workflows/build-image.yaml +++ b/.github/workflows/build-image.yaml @@ -27,7 +27,7 @@ jobs: uses: docker/metadata-action@v3 with: images: | - ghcr.io/statevoicesnational/spoke + ghcr.io/moveonorg/spoke tags: | type=semver,pattern={{version}} type=semver,pattern={{major}}.{{minor}} From 6a9238b3032bf8824415e63a2b2ca1eff4ac0ef1 Mon Sep 17 00:00:00 2001 From: Kathy Nguyen Date: Fri, 8 Mar 2024 11:57:03 -0800 Subject: [PATCH 26/38] Test GHCR --- .github/workflows/build-image.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/build-image.yaml b/.github/workflows/build-image.yaml index e2c196942..2bdbf4557 100644 --- a/.github/workflows/build-image.yaml +++ b/.github/workflows/build-image.yaml @@ -3,7 +3,7 @@ name: Build on: push: branches: - - 'main' + - 'kathy-ghcr' release: types: - created From a0ab2ea62d098f512e8f19f6f330ce507ff3591b Mon Sep 17 00:00:00 2001 From: Kathy Nguyen Date: Fri, 8 Mar 2024 12:47:19 -0800 Subject: [PATCH 27/38] Update build-image.yaml --- .github/workflows/build-image.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/build-image.yaml b/.github/workflows/build-image.yaml index 2bdbf4557..1624ebee3 100644 --- a/.github/workflows/build-image.yaml +++ b/.github/workflows/build-image.yaml @@ -27,7 +27,7 @@ jobs: uses: docker/metadata-action@v3 with: images: | - ghcr.io/moveonorg/spoke + ghcr.io/moveonorg/moveon-spoke tags: | type=semver,pattern={{version}} type=semver,pattern={{major}}.{{minor}} From 7cf51d4686caeb8a7e61c44bf85b15f6ecfb4c34 Mon Sep 17 00:00:00 2001 From: Kathy Nguyen Date: Fri, 8 Mar 2024 12:54:10 -0800 Subject: [PATCH 28/38] Change branch name to main --- .github/workflows/build-image.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/build-image.yaml b/.github/workflows/build-image.yaml index 1624ebee3..0a2dfebf3 100644 --- a/.github/workflows/build-image.yaml +++ b/.github/workflows/build-image.yaml @@ -3,7 +3,7 @@ name: Build on: push: branches: - - 'kathy-ghcr' + - 'main' release: types: - created From 9caf12b22a3049068e002251a49864a1d11f505c Mon Sep 17 00:00:00 2001 From: Kathy Nguyen Date: Fri, 8 Mar 2024 12:54:45 -0800 Subject: [PATCH 29/38] Revert "Update build-image.yaml" This reverts commit 19de49aa3e46122fad453baab4df99e4cdafd7d0. --- .github/workflows/build-image.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/build-image.yaml b/.github/workflows/build-image.yaml index e2c196942..372a645bf 100644 --- a/.github/workflows/build-image.yaml +++ b/.github/workflows/build-image.yaml @@ -27,7 +27,7 @@ jobs: uses: docker/metadata-action@v3 with: images: | - ghcr.io/moveonorg/spoke + ghcr.io/statevoicesnational/spoke tags: | type=semver,pattern={{version}} type=semver,pattern={{major}}.{{minor}} From 5e31328ac229331243d27caedcff1109c797c1dd Mon Sep 17 00:00:00 2001 From: sjwmoveon Date: Fri, 3 May 2024 13:31:51 -0400 Subject: [PATCH 30/38] Dynamic replies initial implementation Generates a special link used to reassign replies. Known bugs: replies can be reassigned indefinitely because the `updated_at` field in `campaign_contact` does not actually update on reassignment. --- src/api/campaign.js | 2 + src/api/schema.js | 6 ++ .../CampaignDynamicAssignmentForm.jsx | 53 ++++++++++- src/components/OrganizationReassignLink.jsx | 22 +++++ src/containers/AdminCampaignEdit.jsx | 6 +- src/containers/AssignReplies.jsx | 88 +++++++++++++++++++ src/routes.jsx | 6 ++ src/server/api/campaign.js | 9 ++ src/server/api/conversations.js | 11 +++ src/server/api/schema.js | 75 +++++++++++++++- 10 files changed, 273 insertions(+), 5 deletions(-) create mode 100644 src/components/OrganizationReassignLink.jsx create mode 100644 src/containers/AssignReplies.jsx diff --git a/src/api/campaign.js b/src/api/campaign.js index 4a70ea5d1..e6427127b 100644 --- a/src/api/campaign.js +++ b/src/api/campaign.js @@ -137,6 +137,8 @@ export const schema = gql` messageServiceLink: String phoneNumbers: [String] inventoryPhoneNumberCounts: [CampaignPhoneNumberCount] + useDynamicReplies: Boolean + replyBatchSize: Int } type CampaignsList { diff --git a/src/api/schema.js b/src/api/schema.js index ad2370bee..7e312c0a1 100644 --- a/src/api/schema.js +++ b/src/api/schema.js @@ -99,6 +99,8 @@ const rootSchema = gql` texterUIConfig: TexterUIConfigInput timezone: String inventoryPhoneNumberCounts: [CampaignPhoneNumberInput!] + useDynamicReplies: Boolean + replyBatchSize: Int } input OrganizationInput { @@ -395,6 +397,10 @@ const rootSchema = gql` messageTextFilter: String newTexterUserId: String! ): [CampaignIdAssignmentId] + dynamicReassign( + organizationUuid: String! + campaignId: String! + ): String importCampaignScript(campaignId: String!, url: String!): Int createTag(organizationId: String!, tagData: TagInput!): Tag editTag(organizationId: String!, id: String!, tagData: TagInput!): Tag diff --git a/src/components/CampaignDynamicAssignmentForm.jsx b/src/components/CampaignDynamicAssignmentForm.jsx index eadb2f92d..e12f8c56a 100644 --- a/src/components/CampaignDynamicAssignmentForm.jsx +++ b/src/components/CampaignDynamicAssignmentForm.jsx @@ -7,6 +7,7 @@ import GSTextField from "../components/forms/GSTextField"; import * as yup from "yup"; import Form from "react-formal"; import OrganizationJoinLink from "./OrganizationJoinLink"; +import OrganizationReassignLink from "./OrganizationReassignLink"; import { dataTest } from "../lib/attributes"; import cloneDeep from "lodash/cloneDeep"; import TagChips from "./TagChips"; @@ -53,7 +54,7 @@ class CampaignDynamicAssignmentForm extends React.Component { render() { const { joinToken, campaignId, organization } = this.props; - const { useDynamicAssignment, batchPolicies } = this.state; + const { useDynamicAssignment, batchPolicies, useDynamicReplies } = this.state; const unselectedPolicies = organization.batchPolicies .filter(p => !batchPolicies.find(cur => cur === p)) .map(p => ({ id: p, name: p })); @@ -73,6 +74,7 @@ class CampaignDynamicAssignmentForm extends React.Component { label="Allow texters with a link to join and start texting when the campaign is started?" labelPlacement="start" /> +
+ { + console.log(toggler, val); + this.toggleChange("useDynamicReplies", val); + }} + /> + } + label="Allow texters with a link to dynamically get assigned replies?" + labelPlacement="start" + /> + + {!useDynamicReplies ? null : ( +
+
    +
  • + {joinToken ? ( + + ) : ( + "Please save the campaign and reload the page to get the reply link to share with texters." + )} +
  • +
  • + You can turn off dynamic assignment after starting a campaign + to disallow more new texters to receive replies. +
  • +
+ + +
+ ) + + } {organization.batchPolicies.length > 1 ? (

Batch Strategy

@@ -211,7 +259,8 @@ CampaignDynamicAssignmentForm.propTypes = { saveDisabled: type.bool, joinToken: type.string, responseWindow: type.number, - batchSize: type.string + batchSize: type.string, + replyBatchSize: type.string }; export default compose(withMuiTheme)(CampaignDynamicAssignmentForm); diff --git a/src/components/OrganizationReassignLink.jsx b/src/components/OrganizationReassignLink.jsx new file mode 100644 index 000000000..1a3fd388b --- /dev/null +++ b/src/components/OrganizationReassignLink.jsx @@ -0,0 +1,22 @@ +import PropTypes from "prop-types"; +import React from "react"; +import DisplayLink from "./DisplayLink"; + +const OrganizationReassignLink = ({ organizationUuid, campaignId }) => { + let baseUrl = "http://base"; + if (typeof window !== "undefined") { + baseUrl = window.location.origin; + } + + const replyUrl = `${baseUrl}/${organizationUuid}/replies/${campaignId}`; + const textContent = `Send your texting volunteers this link! Once they sign up, they\'ll be automatically assigned replies for this campaign.`; + + return ; +}; + +OrganizationReassignLink.propTypes = { + organizationUuid: PropTypes.string, + campaignId: PropTypes.string +}; + +export default OrganizationReassignLink; diff --git a/src/containers/AdminCampaignEdit.jsx b/src/containers/AdminCampaignEdit.jsx index 5931189b3..2c1746284 100644 --- a/src/containers/AdminCampaignEdit.jsx +++ b/src/containers/AdminCampaignEdit.jsx @@ -139,6 +139,8 @@ const campaignInfoFragment = ` state count } + useDynamicReplies + replyBatchSize `; export const campaignDataQuery = gql`query getCampaign($campaignId: String!) { @@ -514,7 +516,9 @@ export class AdminCampaignEditBase extends React.Component { "batchSize", "useDynamicAssignment", "responseWindow", - "batchPolicies" + "batchPolicies", + "useDynamicReplies", + "replyBatchSize" ], checkCompleted: () => true, blocksStarting: false, diff --git a/src/containers/AssignReplies.jsx b/src/containers/AssignReplies.jsx new file mode 100644 index 000000000..aca51dad8 --- /dev/null +++ b/src/containers/AssignReplies.jsx @@ -0,0 +1,88 @@ +import PropTypes from "prop-types"; +import React from "react"; +import loadData from "./hoc/load-data"; +import gql from "graphql-tag"; +import { withRouter } from "react-router"; +import { StyleSheet, css } from "aphrodite"; +import theme from "../styles/theme"; + +const styles = StyleSheet.create({ + greenBox: { + ...theme.layouts.greenBox + } +}); + +class AssignReplies extends React.Component { + state = { + errors: null + }; + + async componentWillMount() { + console.log("Props",this.props); + try { + + const organizationId = (await this.props.mutations.dynamicReassign( + this.props.params.organizationUuid, + this.props.params.campaignId + )).data.dynamicReassign; + console.log("ID:", organizationId); + + this.props.router.push(`/app/${organizationId}`); + } catch (err) { + console.log("error assigning replies", err); + const texterMessage = (err && + err.message && + err.message.match(/(Sorry,.+)$/)) || [ + 0, + "Something went wrong trying to assign replies. Please contact your administrator." + ]; + this.setState({ + errors: texterMessage[1] + }); + } + } + renderErrors() { + if (this.state.errors) { + return
{this.state.errors}
; + } + return
; + } + + render() { + return
{this.renderErrors()}
; + } +} + +AssignReplies.propTypes = { + mutations: PropTypes.object, + router: PropTypes.object, + params: PropTypes.object, + campaign: PropTypes.object +}; + +export const dynamicReassignMutation = gql` + mutation dynamicReassign( + $organizationUuid: String! + $campaignId: String! + ) { + dynamicReassign( + organizationUuid: $organizationUuid + campaignId: $campaignId + ) + } +`; + +const mutations = { + dynamicReassign: ownProps => ( + organizationUuid, + campaignId + ) => ({ + mutation: dynamicReassignMutation, + variables: { + organizationUuid, + campaignId + } + }) +}; + +export default loadData({ mutations })(withRouter(AssignReplies)); diff --git a/src/routes.jsx b/src/routes.jsx index 8b82f290c..47685bfa1 100644 --- a/src/routes.jsx +++ b/src/routes.jsx @@ -23,6 +23,7 @@ import CreateOrganization from "./containers/CreateOrganization"; import CreateAdditionalOrganization from "./containers/CreateAdditionalOrganization"; import AdminOrganizationsDashboard from "./containers/AdminOrganizationsDashboard"; import JoinTeam from "./containers/JoinTeam"; +import AssignReplies from "./containers/AssignReplies"; import Home from "./containers/Home"; import Settings from "./containers/Settings"; import Tags from "./containers/Tags"; @@ -275,6 +276,11 @@ export default function makeRoutes(requireAuth = () => {}) { component={CreateAdditionalOrganization} onEnter={requireAuth} /> + { + const features = getFeatures(campaign); + return features.REPLY_BATCH_SIZE || 200; + }, + useDynamicReplies: campaign => { + const features = getFeatures(campaign); + return features.USE_DYNAMIC_REPLIES ? features.USE_DYNAMIC_REPLIES : false; + }, responseWindow: campaign => campaign.response_window || 48, organization: async (campaign, _, { loaders }) => campaign.organization || diff --git a/src/server/api/conversations.js b/src/server/api/conversations.js index 969769fd3..202019768 100644 --- a/src/server/api/conversations.js +++ b/src/server/api/conversations.js @@ -74,6 +74,13 @@ function getConversationsJoinsAndWhereClause( contactsFilter && contactsFilter.messageStatus ); + if (contactsFilter.updatedAtGt) { + query = query.andWhere(function() {this.where('updated_at', '>', contactsFilter.updatedAtGt)}) + } + if (contactsFilter.updatedAtLt) { + query = query.andWhere(function() {this.where('updated_at', '<', contactsFilter.updatedAtLt)}) + } + if (contactsFilter) { if ("isOptedOut" in contactsFilter) { query.where("is_opted_out", contactsFilter.isOptedOut); @@ -126,6 +133,10 @@ function getConversationsJoinsAndWhereClause( ); } } + + if (contactsFilter.orderByRaw) { + query = query.orderByRaw(contactsFilter.orderByRaw); + } } return query; diff --git a/src/server/api/schema.js b/src/server/api/schema.js index a7d61fb36..d0674b731 100644 --- a/src/server/api/schema.js +++ b/src/server/api/schema.js @@ -2,7 +2,7 @@ import GraphQLDate from "graphql-date"; import GraphQLJSON from "graphql-type-json"; import { GraphQLError } from "graphql/error"; import isUrl from "is-url"; -import _ from "lodash"; +import _, { orderBy } from "lodash"; import { gzip, makeTree, getHighestRole } from "../../lib"; import { capitalizeWord, groupCannedResponses } from "./lib/utils"; import httpRequest from "../lib/http-request"; @@ -193,7 +193,9 @@ async function editCampaign(id, campaign, loaders, user, origCampaignRecord) { textingHoursStart, textingHoursEnd, timezone, - serviceManagers + serviceManagers, + useDynamicReplies, + replyBatchSize } = campaign; // some changes require ADMIN and we recheck below const organizationId = @@ -259,6 +261,17 @@ async function editCampaign(id, campaign, loaders, user, origCampaignRecord) { }); campaignUpdates.features = JSON.stringify(features); } + if (useDynamicReplies) { + Object.assign(features, { + "USE_DYNAMIC_REPLIES": true, + "REPLY_BATCH_SIZE": replyBatchSize + }) + } else { + Object.assign(features, { + "USE_DYNAMIC_REPLIES": false + }) + } + campaignUpdates.features = JSON.stringify(features); let changed = Boolean(Object.keys(campaignUpdates).length); if (changed) { @@ -1425,6 +1438,64 @@ const rootMutations = { newTexterUserId ); }, + dynamicReassign: async ( + _, + { + organizationUuid, + campaignId + }, + { user } + ) => { + // verify permissions + const campaign = await r + .knex("campaign") + .where({ + id: campaignId, + join_token: organizationUuid, + }) + .first(); + const INVALID_REASSIGN = () => { + const error = new GraphQLError("Invalid reassign request - organization not found"); + error.code = "INVALID_REASSIGN"; + return error; + }; + if (!campaign) { + throw INVALID_REASSIGN(); + } + const organization = await cacheableData.organization.load( + campaign.organization_id + ); + if (!organization) { + throw INVALID_REASSIGN(); + } + + const maxContacts = getConfig("MAX_REPLIES_PER_TEXTER", organization); + let d = new Date(); + d.setHours(d.getHours() - 1); + const contactsFilter = { messageStatus: 'needsResponse', isOptedOut: false, listSize: maxContacts, orderByRaw: "updated_at DESC", updatedAtLt: d} + const campaignsFilter = { + campaignId: campaignId + }; + + await accessRequired( + user, + organization.id, + "TEXTER", + /* superadmin*/ true + ); + const { campaignIdContactIdsMap } = await getCampaignIdContactIdsMaps( + organization.id, + { + campaignsFilter, + contactsFilter, + } + ); + await reassignConversations( + campaignIdContactIdsMap, + user.id + ); + return organization.id; + }, importCampaignScript: async (_, { campaignId, url }, { user }) => { const campaign = await cacheableData.campaign.load(campaignId); await accessRequired(user, campaign.organization_id, "ADMIN", true); From ddcd4448111b31d20db1a4ab3689f9923896d375 Mon Sep 17 00:00:00 2001 From: sjwmoveon Date: Fri, 3 May 2024 14:23:29 -0400 Subject: [PATCH 31/38] Update campaign_contact.updated_at on change --- ...0240503180901_campaigncontactsupdatedat.js | 32 +++++++++++++++++++ migrations/helpers/index.js | 8 +++++ 2 files changed, 40 insertions(+) create mode 100644 migrations/20240503180901_campaigncontactsupdatedat.js diff --git a/migrations/20240503180901_campaigncontactsupdatedat.js b/migrations/20240503180901_campaigncontactsupdatedat.js new file mode 100644 index 000000000..1ef891573 --- /dev/null +++ b/migrations/20240503180901_campaigncontactsupdatedat.js @@ -0,0 +1,32 @@ + +/** + * @param { import("knex").Knex } knex + * @returns { Promise } + */ + +const { onUpdateTrigger } = require('./helpers/index') +const ON_UPDATE_TIMESTAMP_FUNCTION = ` + CREATE OR REPLACE FUNCTION on_update_timestamp() + RETURNS trigger AS $$ + BEGIN + NEW.updated_at = now(); + RETURN NEW; + END; +$$ language 'plpgsql'; +` + +const DROP_ON_UPDATE_TIMESTAMP_FUNCTION = `DROP FUNCTION on_update_timestamp` + +exports.up = async function(knex) { + await knex.raw(ON_UPDATE_TIMESTAMP_FUNCTION); + await knex.raw(onUpdateTrigger('campaign_contact')); +}; + +/** + * @param { import("knex").Knex } knex + * @returns { Promise } + */ +exports.down = async function(knex) { + await knex.raw("DROP TRIGGER campaign_contact_updated_at on campaign_contact"); + await knex.raw(DROP_ON_UPDATE_TIMESTAMP_FUNCTION); +}; \ No newline at end of file diff --git a/migrations/helpers/index.js b/migrations/helpers/index.js index 5570a8aea..6eca405f0 100644 --- a/migrations/helpers/index.js +++ b/migrations/helpers/index.js @@ -11,3 +11,11 @@ exports.redefineSqliteTable = async (knex, tableName, newTableFn) => { await knex.schema.dropTable(tableName); await knex.schema.createTable(tableName, newTableFn); }; + + +exports.onUpdateTrigger = table => ` +CREATE TRIGGER ${table}_updated_at +BEFORE UPDATE ON ${table} +FOR EACH ROW +EXECUTE PROCEDURE on_update_timestamp(); +` \ No newline at end of file From 226f4e069dc8a22a7b41da7c41a978b988da8ccd Mon Sep 17 00:00:00 2001 From: sjwmoveon Date: Fri, 3 May 2024 14:32:57 -0400 Subject: [PATCH 32/38] Only run migration on postgres, not sqlite --- .../20240503180901_campaigncontactsupdatedat.js | 14 ++++++++++---- 1 file changed, 10 insertions(+), 4 deletions(-) diff --git a/migrations/20240503180901_campaigncontactsupdatedat.js b/migrations/20240503180901_campaigncontactsupdatedat.js index 1ef891573..eb483c4ae 100644 --- a/migrations/20240503180901_campaigncontactsupdatedat.js +++ b/migrations/20240503180901_campaigncontactsupdatedat.js @@ -18,8 +18,11 @@ $$ language 'plpgsql'; const DROP_ON_UPDATE_TIMESTAMP_FUNCTION = `DROP FUNCTION on_update_timestamp` exports.up = async function(knex) { - await knex.raw(ON_UPDATE_TIMESTAMP_FUNCTION); - await knex.raw(onUpdateTrigger('campaign_contact')); + const isSqlite = /sqlite/.test(knex.client.config.client); + if (!isSqlite) { + await knex.raw(ON_UPDATE_TIMESTAMP_FUNCTION); + await knex.raw(onUpdateTrigger('campaign_contact')); + } }; /** @@ -27,6 +30,9 @@ exports.up = async function(knex) { * @returns { Promise } */ exports.down = async function(knex) { - await knex.raw("DROP TRIGGER campaign_contact_updated_at on campaign_contact"); - await knex.raw(DROP_ON_UPDATE_TIMESTAMP_FUNCTION); + const isSqlite = /sqlite/.test(knex.client.config.client); + if (!isSqlite) { + await knex.raw("DROP TRIGGER campaign_contact_updated_at on campaign_contact"); + await knex.raw(DROP_ON_UPDATE_TIMESTAMP_FUNCTION); + } }; \ No newline at end of file From 454e1dbb2ff7df75b310b2958524066a4c1bf13c Mon Sep 17 00:00:00 2001 From: Sophie Waldman <62553142+sjwmoveon@users.noreply.github.com> Date: Fri, 3 May 2024 14:41:50 -0400 Subject: [PATCH 33/38] Fix comments --- .../20240503180901_campaigncontactsupdatedat.js | 11 ++++------- 1 file changed, 4 insertions(+), 7 deletions(-) diff --git a/migrations/20240503180901_campaigncontactsupdatedat.js b/migrations/20240503180901_campaigncontactsupdatedat.js index eb483c4ae..39f79d864 100644 --- a/migrations/20240503180901_campaigncontactsupdatedat.js +++ b/migrations/20240503180901_campaigncontactsupdatedat.js @@ -1,9 +1,4 @@ -/** - * @param { import("knex").Knex } knex - * @returns { Promise } - */ - const { onUpdateTrigger } = require('./helpers/index') const ON_UPDATE_TIMESTAMP_FUNCTION = ` CREATE OR REPLACE FUNCTION on_update_timestamp() @@ -17,6 +12,9 @@ $$ language 'plpgsql'; const DROP_ON_UPDATE_TIMESTAMP_FUNCTION = `DROP FUNCTION on_update_timestamp` +/** + * @param { import("knex").Knex } knex + */ exports.up = async function(knex) { const isSqlite = /sqlite/.test(knex.client.config.client); if (!isSqlite) { @@ -27,7 +25,6 @@ exports.up = async function(knex) { /** * @param { import("knex").Knex } knex - * @returns { Promise } */ exports.down = async function(knex) { const isSqlite = /sqlite/.test(knex.client.config.client); @@ -35,4 +32,4 @@ exports.down = async function(knex) { await knex.raw("DROP TRIGGER campaign_contact_updated_at on campaign_contact"); await knex.raw(DROP_ON_UPDATE_TIMESTAMP_FUNCTION); } -}; \ No newline at end of file +}; From 4be13a6037abb6630fa435cdd423da79fe651677 Mon Sep 17 00:00:00 2001 From: sjwmoveon Date: Fri, 3 May 2024 16:55:54 -0400 Subject: [PATCH 34/38] Add test for dynamicReassign method --- __test__/server/api/campaign/campaign.test.js | 102 ++++++++++++++++++ __test__/test_helpers.js | 1 + src/api/schema.js | 1 + src/server/api/schema.js | 3 +- 4 files changed, 105 insertions(+), 2 deletions(-) diff --git a/__test__/server/api/campaign/campaign.test.js b/__test__/server/api/campaign/campaign.test.js index 2589c5b19..da3b0eac3 100644 --- a/__test__/server/api/campaign/campaign.test.js +++ b/__test__/server/api/campaign/campaign.test.js @@ -37,6 +37,7 @@ import { sleep, startCampaign } from "../../../test_helpers"; +import { dynamicReassignMutation } from "../../../../src/containers/AssignReplies"; let testAdminUser; let testInvite; @@ -828,6 +829,8 @@ describe("Reassignments", () => { }, testTexterUser2 ); + // TEXTER 1 (60 needsMessage, 2 needsResponse, 4 messaged) + // TEXTER 2 (25 needsMessage, 3 convo, 1 messaged) expect(texterCampaignDataResults.data.assignment.contacts.length).toEqual( 2 ); @@ -840,6 +843,105 @@ describe("Reassignments", () => { expect(texterCampaignDataResults2.data.assignment.allContactsCount).toEqual( 29 ); + await runGql( + dynamicReassignMutation, + { + organizationUuid: testCampaign.joinToken, + campaignId: testCampaign.id, + }, + testTexterUser2 + ); + // TEXTER 1 (60 needsMessage, 4 messaged) + // TEXTER 2 (25 needsMessage, 2 needsResponse, 3 convo, 1 messaged) + texterCampaignDataResults = await runGql( + TexterTodoQuery, + { + contactsFilter: { + messageStatus: "needsResponse", + isOptedOut: false, + validTimezone: true + }, + assignmentId, + organizationId + }, + testTexterUser + ); + + texterCampaignDataResults2 = await runGql( + TexterTodoQuery, + { + contactsFilter: { + messageStatus: "needsResponse", + isOptedOut: false, + validTimezone: true + }, + assignmentId: assignmentId2, + organizationId + }, + testTexterUser2 + ); + expect(texterCampaignDataResults.data.assignment.contacts.length).toEqual( + 2 + ); + expect(texterCampaignDataResults.data.assignment.allContactsCount).toEqual( + 66 + ); + expect(texterCampaignDataResults2.data.assignment.contacts.length).toEqual( + 0 + ); + expect(texterCampaignDataResults2.data.assignment.allContactsCount).toEqual( + 29 + ); + jest.useFakeTimers() + jest.advanceTimersByTime(4000000) + await runGql( + dynamicReassignMutation, + { + organizationUuid: testCampaign.joinToken, + campaignId: testCampaign.id, + }, + testTexterUser2 + ); + jest.useRealTimers() + texterCampaignDataResults = await runGql( + TexterTodoQuery, + { + contactsFilter: { + messageStatus: "needsResponse", + isOptedOut: false, + validTimezone: true + }, + assignmentId, + organizationId + }, + testTexterUser + ); + + texterCampaignDataResults2 = await runGql( + TexterTodoQuery, + { + contactsFilter: { + messageStatus: "needsResponse", + isOptedOut: false, + validTimezone: true + }, + assignmentId: assignmentId2, + organizationId + }, + testTexterUser2 + ); + expect(texterCampaignDataResults.data.assignment.contacts.length).toEqual( + 0 + ); + expect(texterCampaignDataResults.data.assignment.allContactsCount).toEqual( + 64 + ); + expect(texterCampaignDataResults2.data.assignment.contacts.length).toEqual( + 2 + ); + expect(texterCampaignDataResults2.data.assignment.allContactsCount).toEqual( + 31 + ); }, 10000); // long test can exceed default 5seconds }); diff --git a/__test__/test_helpers.js b/__test__/test_helpers.js index 6c81b11bf..12215882f 100644 --- a/__test__/test_helpers.js +++ b/__test__/test_helpers.js @@ -359,6 +359,7 @@ export async function createCampaign( const campaignQuery = `mutation createCampaign($input: CampaignInput!) { createCampaign(campaign: $input) { id + joinToken } }`; const variables = { diff --git a/src/api/schema.js b/src/api/schema.js index 7e312c0a1..a87157cbb 100644 --- a/src/api/schema.js +++ b/src/api/schema.js @@ -101,6 +101,7 @@ const rootSchema = gql` inventoryPhoneNumberCounts: [CampaignPhoneNumberInput!] useDynamicReplies: Boolean replyBatchSize: Int + joinToken: String } input OrganizationInput { diff --git a/src/server/api/schema.js b/src/server/api/schema.js index d0674b731..14f7d3127 100644 --- a/src/server/api/schema.js +++ b/src/server/api/schema.js @@ -1468,8 +1468,7 @@ const rootMutations = { if (!organization) { throw INVALID_REASSIGN(); } - - const maxContacts = getConfig("MAX_REPLIES_PER_TEXTER", organization); + const maxContacts = getConfig("MAX_REPLIES_PER_TEXTER", organization) ?? 200; let d = new Date(); d.setHours(d.getHours() - 1); const contactsFilter = { messageStatus: 'needsResponse', isOptedOut: false, listSize: maxContacts, orderByRaw: "updated_at DESC", updatedAtLt: d} From 0dfcea59a092b1d136bf3f26b5410f1c1b94313e Mon Sep 17 00:00:00 2001 From: sjwmoveon Date: Fri, 3 May 2024 18:12:37 -0400 Subject: [PATCH 35/38] Use 'joinToken' in place of 'organizationUuid' since we are actually using the joinToken value Also remove an unnecessary import and fix a comment --- __test__/server/api/campaign/campaign.test.js | 8 ++++---- src/api/schema.js | 2 +- src/components/CampaignDynamicAssignmentForm.jsx | 2 +- src/components/OrganizationReassignLink.jsx | 6 +++--- src/containers/AssignReplies.jsx | 10 +++++----- src/routes.jsx | 2 +- src/server/api/schema.js | 6 +++--- 7 files changed, 18 insertions(+), 18 deletions(-) diff --git a/__test__/server/api/campaign/campaign.test.js b/__test__/server/api/campaign/campaign.test.js index da3b0eac3..086a32c72 100644 --- a/__test__/server/api/campaign/campaign.test.js +++ b/__test__/server/api/campaign/campaign.test.js @@ -846,13 +846,11 @@ describe("Reassignments", () => { await runGql( dynamicReassignMutation, { - organizationUuid: testCampaign.joinToken, + joinToken: testCampaign.joinToken, campaignId: testCampaign.id, }, testTexterUser2 ); - // TEXTER 1 (60 needsMessage, 4 messaged) - // TEXTER 2 (25 needsMessage, 2 needsResponse, 3 convo, 1 messaged) texterCampaignDataResults = await runGql( TexterTodoQuery, { @@ -897,7 +895,7 @@ describe("Reassignments", () => { await runGql( dynamicReassignMutation, { - organizationUuid: testCampaign.joinToken, + joinToken: testCampaign.joinToken, campaignId: testCampaign.id, }, testTexterUser2 @@ -930,6 +928,8 @@ describe("Reassignments", () => { }, testTexterUser2 ); + // TEXTER 1 (60 needsMessage, 4 messaged) + // TEXTER 2 (25 needsMessage, 2 needsResponse, 3 convo, 1 messaged) expect(texterCampaignDataResults.data.assignment.contacts.length).toEqual( 0 ); diff --git a/src/api/schema.js b/src/api/schema.js index a87157cbb..fee8d89a5 100644 --- a/src/api/schema.js +++ b/src/api/schema.js @@ -399,7 +399,7 @@ const rootSchema = gql` newTexterUserId: String! ): [CampaignIdAssignmentId] dynamicReassign( - organizationUuid: String! + joinToken: String! campaignId: String! ): String importCampaignScript(campaignId: String!, url: String!): Int diff --git a/src/components/CampaignDynamicAssignmentForm.jsx b/src/components/CampaignDynamicAssignmentForm.jsx index e12f8c56a..ae25dbab5 100644 --- a/src/components/CampaignDynamicAssignmentForm.jsx +++ b/src/components/CampaignDynamicAssignmentForm.jsx @@ -156,7 +156,7 @@ class CampaignDynamicAssignmentForm extends React.Component {
  • {joinToken ? ( ) : ( diff --git a/src/components/OrganizationReassignLink.jsx b/src/components/OrganizationReassignLink.jsx index 1a3fd388b..bdcc1a315 100644 --- a/src/components/OrganizationReassignLink.jsx +++ b/src/components/OrganizationReassignLink.jsx @@ -2,20 +2,20 @@ import PropTypes from "prop-types"; import React from "react"; import DisplayLink from "./DisplayLink"; -const OrganizationReassignLink = ({ organizationUuid, campaignId }) => { +const OrganizationReassignLink = ({ joinToken, campaignId }) => { let baseUrl = "http://base"; if (typeof window !== "undefined") { baseUrl = window.location.origin; } - const replyUrl = `${baseUrl}/${organizationUuid}/replies/${campaignId}`; + const replyUrl = `${baseUrl}/${joinToken}/replies/${campaignId}`; const textContent = `Send your texting volunteers this link! Once they sign up, they\'ll be automatically assigned replies for this campaign.`; return ; }; OrganizationReassignLink.propTypes = { - organizationUuid: PropTypes.string, + joinToken: PropTypes.string, campaignId: PropTypes.string }; diff --git a/src/containers/AssignReplies.jsx b/src/containers/AssignReplies.jsx index aca51dad8..3e50c5418 100644 --- a/src/containers/AssignReplies.jsx +++ b/src/containers/AssignReplies.jsx @@ -22,7 +22,7 @@ class AssignReplies extends React.Component { try { const organizationId = (await this.props.mutations.dynamicReassign( - this.props.params.organizationUuid, + this.props.params.joinToken, this.props.params.campaignId )).data.dynamicReassign; console.log("ID:", organizationId); @@ -62,11 +62,11 @@ AssignReplies.propTypes = { export const dynamicReassignMutation = gql` mutation dynamicReassign( - $organizationUuid: String! + $joinToken: String! $campaignId: String! ) { dynamicReassign( - organizationUuid: $organizationUuid + joinToken: $joinToken campaignId: $campaignId ) } @@ -74,12 +74,12 @@ export const dynamicReassignMutation = gql` const mutations = { dynamicReassign: ownProps => ( - organizationUuid, + joinToken, campaignId ) => ({ mutation: dynamicReassignMutation, variables: { - organizationUuid, + joinToken, campaignId } }) diff --git a/src/routes.jsx b/src/routes.jsx index 47685bfa1..7fce13cfc 100644 --- a/src/routes.jsx +++ b/src/routes.jsx @@ -277,7 +277,7 @@ export default function makeRoutes(requireAuth = () => {}) { onEnter={requireAuth} /> diff --git a/src/server/api/schema.js b/src/server/api/schema.js index 14f7d3127..c0abaa43b 100644 --- a/src/server/api/schema.js +++ b/src/server/api/schema.js @@ -2,7 +2,7 @@ import GraphQLDate from "graphql-date"; import GraphQLJSON from "graphql-type-json"; import { GraphQLError } from "graphql/error"; import isUrl from "is-url"; -import _, { orderBy } from "lodash"; +import _ from "lodash"; import { gzip, makeTree, getHighestRole } from "../../lib"; import { capitalizeWord, groupCannedResponses } from "./lib/utils"; import httpRequest from "../lib/http-request"; @@ -1441,7 +1441,7 @@ const rootMutations = { dynamicReassign: async ( _, { - organizationUuid, + joinToken, campaignId }, { user } @@ -1451,7 +1451,7 @@ const rootMutations = { .knex("campaign") .where({ id: campaignId, - join_token: organizationUuid, + join_token: joinToken, }) .first(); const INVALID_REASSIGN = () => { From 716a1542d69837b24c0f980eec4e1e8ee7b42ba9 Mon Sep 17 00:00:00 2001 From: Sophie Waldman <62553142+sjwmoveon@users.noreply.github.com> Date: Wed, 15 May 2024 15:15:08 -0400 Subject: [PATCH 36/38] Fix SonarCloud alert SonarCloud doesn't like raw http links, preferring https. In this case the link should never be used so it makes no difference to switch to https. --- src/components/OrganizationReassignLink.jsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/components/OrganizationReassignLink.jsx b/src/components/OrganizationReassignLink.jsx index bdcc1a315..f610b452c 100644 --- a/src/components/OrganizationReassignLink.jsx +++ b/src/components/OrganizationReassignLink.jsx @@ -3,7 +3,7 @@ import React from "react"; import DisplayLink from "./DisplayLink"; const OrganizationReassignLink = ({ joinToken, campaignId }) => { - let baseUrl = "http://base"; + let baseUrl = "https://base"; if (typeof window !== "undefined") { baseUrl = window.location.origin; } From 14a56734d88cb29b44ddd0a5bf5645edfc62feba Mon Sep 17 00:00:00 2001 From: sjwmoveon Date: Wed, 15 May 2024 15:54:41 -0400 Subject: [PATCH 37/38] retrigger checks From f01f203c775eeaf973fe4129a6b72cfa1a60bc64 Mon Sep 17 00:00:00 2001 From: sjwmoveon Date: Wed, 15 May 2024 16:00:36 -0400 Subject: [PATCH 38/38] retrigger checks x2