diff --git a/.eslintrc b/.eslintrc index a6796f1ec..2f45ceb40 100644 --- a/.eslintrc +++ b/.eslintrc @@ -1,5 +1,11 @@ { "extends": ["airbnb", "prettier"], "parser": "@babel/eslint-parser", - "env": { "jest": true, "node": true, "browser": true, "jasmine": true } + "env": { "jest": true, "node": true, "browser": true, "jasmine": true }, + "rules": { + "no-console": [ + "warn", + { "allow": ["warn", "error", "info", "time", "timeEnd"] } + ] + } } diff --git a/.github/ISSUE_TEMPLATE/bug_report.md b/.github/ISSUE_TEMPLATE/bug_report.md index 653166a42..8408fa5dd 100644 --- a/.github/ISSUE_TEMPLATE/bug_report.md +++ b/.github/ISSUE_TEMPLATE/bug_report.md @@ -27,7 +27,7 @@ If applicable, add screenshots to help explain your problem. - OS: [e.g. iOS] - Browser [e.g. chrome, safari] - Desktop or Mobile? - - Version [e.g. 22] + - Spoke Version: [e.g. 14.0.1] **Additional context** Add any other context about the problem here. diff --git a/.github/workflows/cypress-tests.yaml b/.github/workflows/cypress-tests.yaml index b33a2a12f..3c4ad42f2 100644 --- a/.github/workflows/cypress-tests.yaml +++ b/.github/workflows/cypress-tests.yaml @@ -5,7 +5,7 @@ on: [push] jobs: test: runs-on: ubuntu-latest - timeout-minutes: 10 + timeout-minutes: 15 strategy: matrix: node-version: [16.x] @@ -53,13 +53,13 @@ jobs: build: npm run prod-build start: npm start wait-on: 'http://localhost:3001' - - uses: actions/upload-artifact@v2 + - uses: actions/upload-artifact@v4 if: failure() with: name: cypress-screenshots path: cypress/screenshots - - uses: actions/upload-artifact@v2 + - uses: actions/upload-artifact@v4 if: failure() with: name: cypress-videos - path: cypress/videos \ No newline at end of file + path: cypress/videos diff --git a/.github/workflows/jest-tests.yaml b/.github/workflows/jest-tests.yaml index 16bcd831b..8aaf26613 100644 --- a/.github/workflows/jest-tests.yaml +++ b/.github/workflows/jest-tests.yaml @@ -5,7 +5,7 @@ on: [push, pull_request] jobs: test: runs-on: ubuntu-latest - timeout-minutes: 10 + timeout-minutes: 15 strategy: matrix: node-version: [14.x, 15.x, 16.x, 18.x, 20.x] @@ -44,7 +44,7 @@ jobs: run: yarn test test-rediscache-contactcache: runs-on: ubuntu-latest - timeout-minutes: 10 + timeout-minutes: 15 services: redis: image: redis @@ -85,7 +85,7 @@ jobs: run: yarn test-rediscache-contactcache test-rediscache: runs-on: ubuntu-latest - timeout-minutes: 10 + timeout-minutes: 15 services: redis: image: redis @@ -126,7 +126,7 @@ jobs: run: yarn test-rediscache test-sqlite: runs-on: ubuntu-latest - timeout-minutes: 10 + timeout-minutes: 15 services: redis: image: redis diff --git a/README.md b/README.md index dab209be0..a22bfb4e5 100644 --- a/README.md +++ b/README.md @@ -8,15 +8,15 @@ Spoke was created by Saikat Chakrabarti and Sheena Pakanati. On November 19th, 2023, the repo Spoke was transferred from MoveOn to State Voices. -The latest version is [14.0.1](https://github.com/StateVoicesNational/Spoke/tree/v14.0.1) (see [release notes](https://github.com/StateVoicesNational/Spoke/blob/main/docs/RELEASE_NOTES.md#v1401)) +The latest version is [14.0.2](https://github.com/StateVoicesNational/Spoke/tree/v14.0.2) (see [release notes](https://github.com/StateVoicesNational/Spoke/blob/main/docs/RELEASE_NOTES.md#v1402)) ## Setting up Spoke -The easiest way to get started is with Heroku. You can also learn about Spoke through the [texter](https://youtu.be/EqE1UDvKGco) and [admin](https://youtu.be/PTMykMX8gII) video demos or in the explanation on [how to decide if Spoke is right for you.](/docs/EXPLANATION_DECIDING_ON_SPOKE.md) +The easiest way to get started is with Heroku. You can also learn about Spoke through the [texter](https://youtu.be/EqE1UDvKGco) and [admin](https://youtu.be/PTMykMX8gII) video demos or in the explanation on [how to decide if Spoke is right for you.](docs/EXPLANATION_DECIDING_ON_SPOKE.md) -For developers, please see our recommendations for [deploying locally for development](/docs/HOWTO_DEVELOPMENT_LOCAL_SETUP.md). +For developers, please see our recommendations for [deploying locally for development](docs/HOWTO_DEVELOPMENT_LOCAL_SETUP.md). Want to know more? [Click here to visit the Spoke Documentation microsite!](https://statevoicesnational.github.io/Spoke/) @@ -25,12 +25,12 @@ Want to know more? ### Quick Start with Heroku This version of Spoke suitable for testing and, potentially, for small campaigns. This won't cost any money and will not support production(aka large-scale) usage. It's a great way to practice deploying Spoke or see it in action. - + Deploy -Follow up instructions located [here](/docs/HOWTO_HEROKU_DEPLOY.md). +Follow up instructions located [here](docs/HOWTO_HEROKU_DEPLOY.md). **NOTE:** You can upgrade this deployment later for use in a production setting, but keep in mind you will need to migrate data from any prior campaigns. Thus it is best to upgrade before you start any live campaigns. This will cost ~$75 ($25 dyno + $50 postgres) a month and should be suitable for production level usage for most organizations. We recommend that if you plan to use Spoke at scale that you use [this link to deploy with a production infrastructure from the start!](https://heroku.com/deploy?template=https://github.com/StateVoicesNational/Spoke/tree/heroku-button-paid) diff --git a/__test__/components/CampaignInteractionStepsForm.test.js b/__test__/components/CampaignInteractionStepsForm.test.js index 43464da66..eef6d9e68 100644 --- a/__test__/components/CampaignInteractionStepsForm.test.js +++ b/__test__/components/CampaignInteractionStepsForm.test.js @@ -18,9 +18,13 @@ import { operations as adminCampaignEditOps } from "../../src/containers/AdminCampaignEdit"; import { + mockInteractionSteps, setupTest, cleanupTest, - createStartedCampaign, + createCampaign, + createInvite, + createOrganization, + createUser, makeRunnableMutations, runComponentQueries, muiTheme @@ -98,59 +102,233 @@ 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; + 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 = [ - { - 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( @@ -160,8 +338,8 @@ describe("CampaignInteractionStepsForm", () => { formValues={{ interactionSteps }} - onChange={() => {}} - onSubmit={() => {}} + onChange={dummyFunction} + onSubmit={dummyFunction} ensureComplete customFields={[]} saveLabel="save" @@ -174,7 +352,7 @@ describe("CampaignInteractionStepsForm", () => { it("doesn't render the answer actions", async () => { const answerActionsComponents = wrappedComponent.findWhere( - node => node.props()["data-test"] === "actionSelect" + cmpProp("data-test", "actionSelect") ); expect(answerActionsComponents.exists()).toEqual(false); }); @@ -213,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(); @@ -233,8 +402,8 @@ describe("CampaignInteractionStepsForm", () => { formValues={{ interactionSteps }} - onChange={() => {}} - onSubmit={() => {}} + onChange={dummyFunction} + onSubmit={dummyFunction} ensureComplete customFields={[]} saveLabel="save" @@ -264,7 +433,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" @@ -286,7 +455,7 @@ describe("CampaignInteractionStepsForm", () => { ]); const step1ClientChoiceNodes = step1.findWhere( - node => node.props()["data-test"] === "actionDataAutoComplete" + cmpProp("data-test", "actionDataAutoComplete") ); expect(step1ClientChoiceNodes.exists()).toEqual(false); @@ -295,7 +464,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( @@ -318,7 +487,7 @@ describe("CampaignInteractionStepsForm", () => { ]); const step2ClientChoiceNodes = step2.findWhere( - node => node.props()["data-test"] === "actionDataAutoComplete" + cmpProp("data-test", "actionDataAutoComplete") ); expect(step2ClientChoiceNodes.exists()).toEqual(false); @@ -327,7 +496,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(""); @@ -348,7 +517,7 @@ describe("CampaignInteractionStepsForm", () => { ]); const step3ClientChoiceNodes = step3.findWhere( - node => node.props()["data-test"] === "actionDataAutoComplete" + cmpProp("data-test", "actionDataAutoComplete") ); expect(step3ClientChoiceNodes.exists()).toEqual(false); @@ -394,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: "", @@ -424,8 +584,8 @@ describe("CampaignInteractionStepsForm", () => { formValues={{ interactionSteps }} - onChange={() => {}} - onSubmit={() => {}} + onChange={dummyFunction} + onSubmit={dummyFunction} ensureComplete customFields={[]} saveLabel="save" @@ -469,7 +629,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( @@ -492,7 +652,7 @@ describe("CampaignInteractionStepsForm", () => { ]); const step1ClientChoiceNodes = step1.findWhere( - node => node.props()["data-test"] === "actionDataAutoComplete" + cmpProp("data-test", "actionDataAutoComplete") ); expect(step1ClientChoiceNodes.at(2).props().options).toEqual([ @@ -514,7 +674,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( @@ -537,7 +697,7 @@ describe("CampaignInteractionStepsForm", () => { ]); const step2ClientChoiceNodes = step2.findWhere( - node => node.props()["data-test"] === "actionDataAutoComplete" + cmpProp("data-test", "actionDataAutoComplete") ); expect(step2ClientChoiceNodes.first().props().value).toEqual({ @@ -564,7 +724,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( @@ -587,7 +747,7 @@ describe("CampaignInteractionStepsForm", () => { ]); const step3ClientChoiceNodes = step3.findWhere( - node => node.props()["data-test"] === "actionDataAutoComplete" + cmpProp("data-test", "actionDataAutoComplete") ); expect(step3ClientChoiceNodes.exists()).toEqual(false); @@ -596,7 +756,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(""); @@ -617,7 +777,7 @@ describe("CampaignInteractionStepsForm", () => { ]); const step4ClientChoiceNodes = step4.findWhere( - node => node.props()["data-test"] === "actionDataAutoComplete" + cmpProp("data-test", "actionDataAutoComplete") ); expect(step4ClientChoiceNodes.exists()).toEqual(false); @@ -635,14 +795,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 () => { @@ -749,103 +908,15 @@ 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 }); - - interactionStepsAfter.forEach(step => { - // eslint-disable-next-line no-param-reassign - step.is_deleted = !!step.is_deleted; - }); - - 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}!" - }) - ]) - ); - - done(); - } - ); - }); + .then( + saveInteractionSteps( + campaign, + done, + interactionSteps, + queryResults, + wrappedComponent + ) + ); }); }); }); diff --git a/__test__/containers/AssignmentTexterContact.test.js b/__test__/containers/AssignmentTexterContact.test.js index a563c52ae..96e09e2ad 100644 --- a/__test__/containers/AssignmentTexterContact.test.js +++ b/__test__/containers/AssignmentTexterContact.test.js @@ -118,6 +118,7 @@ describe("when contact is not within texting hours...", () => { let component = mount( { mount( { /^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 diff --git a/__test__/server/api/campaign/campaign.test.js b/__test__/server/api/campaign/campaign.test.js index ae9939dbf..e77cfc055 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; @@ -576,6 +577,7 @@ describe("Reassignments", () => { text: "test text autorespond", assignmentId: assignmentId2 }); + return messageRes; } // does this sleep fix the "sometimes 4 instead of 5" below? await sleep(5); @@ -824,6 +826,55 @@ describe("Reassignments", () => { testTexterUser ); + texterCampaignDataResults2 = await runGql( + TexterTodoQuery, + { + contactsFilter: { + messageStatus: "needsResponse", + isOptedOut: false, + validTimezone: true + }, + assignmentId: assignmentId2, + organizationId + }, + 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 + ); + expect(texterCampaignDataResults.data.assignment.allContactsCount).toEqual( + 66 + ); + expect(texterCampaignDataResults2.data.assignment.contacts.length).toEqual( + 0 + ); + expect(texterCampaignDataResults2.data.assignment.allContactsCount).toEqual( + 29 + ); + await runGql( + dynamicReassignMutation, + { + joinToken: testCampaign.joinToken, + campaignId: testCampaign.id + }, + testTexterUser2 + ); + texterCampaignDataResults = await runGql( + TexterTodoQuery, + { + contactsFilter: { + messageStatus: "needsResponse", + isOptedOut: false, + validTimezone: true + }, + assignmentId, + organizationId + }, + testTexterUser + ); + texterCampaignDataResults2 = await runGql( TexterTodoQuery, { @@ -849,6 +900,58 @@ describe("Reassignments", () => { expect(texterCampaignDataResults2.data.assignment.allContactsCount).toEqual( 29 ); + jest.useFakeTimers(); + jest.advanceTimersByTime(4000000); + await runGql( + dynamicReassignMutation, + { + joinToken: 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 + ); + // TEXTER 1 (60 needsMessage, 4 messaged) + // TEXTER 2 (25 needsMessage, 2 needsResponse, 3 convo, 1 messaged) + 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__/server/api/campaign/updateQuestionResponses.test.js b/__test__/server/api/campaign/updateQuestionResponses.test.js index 94037755f..ca8f7ab3f 100644 --- a/__test__/server/api/campaign/updateQuestionResponses.test.js +++ b/__test__/server/api/campaign/updateQuestionResponses.test.js @@ -5,6 +5,8 @@ import { cleanupTest, createScript, createStartedCampaign, + mockInteractionSteps, + muiTheme, runGql, sendMessage, setupTest, @@ -33,7 +35,6 @@ import { contactDataFragment } from "../../../../src/containers/TexterTodo"; -import { muiTheme } from "../../../test_helpers"; import ThemeContext from "../../../../src/containers/context/ThemeContext"; describe("mutations.updateQuestionResponses", () => { @@ -165,65 +166,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, @@ -309,7 +252,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" }, { @@ -319,7 +262,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" } ]); @@ -429,7 +372,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, @@ -448,136 +391,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", () => { @@ -641,24 +456,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 }); ({ @@ -747,8 +559,8 @@ describe("mutations.updateQuestionResponses", () => { ); }); - describe("when a response is added", () => { - beforeEach(async () => { + const responseAdded = { + beforeEach: async () => { questionResponses = [ { campaignContactId: contacts[0].id, @@ -776,9 +588,8 @@ describe("mutations.updateQuestionResponses", () => { ComplexTestActionHandler, "processDeletedQuestionResponse" ); - }); - - it("calls the action handler for the new response", async () => { + }, + it1: async () => { await Mutations.updateQuestionResponses( undefined, { questionResponses, campaignContactId: contacts[0].id }, @@ -818,11 +629,115 @@ 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 () => { + 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 = [ { campaignContactId: contacts[0].id, @@ -850,115 +765,32 @@ describe("mutations.updateQuestionResponses", () => { ComplexTestActionHandler, "processDeletedQuestionResponse" ); - }); + }, + saved: () => { + it("calls processAction for the new question response", newResponse); + }, + updated: () => { + beforeEach(setQuestionResponseValue); - describe("when one of the question responses has already been saved with the same value", () => { - 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 for the updated response, and it passes in previousValue", updatedResponse); + }, + deleted: () => { + it("calls processDeletedQuestionResponse", deletedResponse); + } + } - describe("when one of the question responses was 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" - }) - ] - ]) - ); - }); - }); + describe("when responses are added, resubmitted with no change, updated, and deleted", () => { + beforeEach(responseResubmitted.beforeEach); - describe("when one of the question responses is 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"); - }); - }); + 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, @@ -968,9 +800,8 @@ describe("mutations.updateQuestionResponses", () => { inputInteractionStepsWithActionHandlers, 2 )); - }); - - it("exits early and logs an error", async () => { + }, + earlyExit: async () => { jest .spyOn(ActionHandlers, "rawAllActionHandlers") .mockReturnValue({}); @@ -983,11 +814,21 @@ 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 () => { + function throwError() { + throw new Error("foo"); + } + + const taskDispatchFails = { + beforeEach: async () => { ({ interactionSteps, redInteractionStep, @@ -997,13 +838,10 @@ 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"); - }); + jest.spyOn(jobRunner, "dispatchTask").mockImplementationOnce(throwError); await Mutations.updateQuestionResponses( {}, { questionResponses, campaignContactId: contacts[0].id }, @@ -1029,11 +867,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, @@ -1043,9 +887,8 @@ describe("mutations.updateQuestionResponses", () => { inputInteractionStepsWithActionHandlers, 2 )); - }); - - it("processes the other actions", async () => { + }, + processOtherActions: async () => { jest .spyOn(ComplexTestActionHandler, "processAction") .mockRejectedValueOnce(new Error("oh no")); @@ -1089,7 +932,13 @@ describe("mutations.updateQuestionResponses", () => { ] ]) ); - }); + } + } + + describe("when the action handler throws an exception", () => { + beforeEach(actionHandlerThrowsException.beforeEach); + + it("processes the other actions", actionHandlerThrowsException.processOtherActions); }); }); }); diff --git a/__test__/test_helpers.js b/__test__/test_helpers.js index f111833ec..bc5db483e 100644 --- a/__test__/test_helpers.js +++ b/__test__/test_helpers.js @@ -126,6 +126,75 @@ export async function runGql(operation, variableValues, 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, @@ -311,6 +380,7 @@ export async function createCampaign( const campaignQuery = `mutation createCampaign($input: CampaignInput!) { createCampaign(campaign: $input) { id + joinToken } }`; const variableValues = { diff --git a/cypress.config.js b/cypress.config.js new file mode 100644 index 000000000..1e837e499 --- /dev/null +++ b/cypress.config.js @@ -0,0 +1,18 @@ +require("@babel/register"); +require("babel-polyfill"); +const { defineConfig } = require('cypress') + +module.exports = defineConfig({ + e2e: { + baseUrl: "http://localhost:3001", + specPattern: "__test__/cypress/integration/*", + fixturesFolder: "__test__/cypress/fixtures", + supportFile: "__test__/cypress/support/e2e.js", + video: true, + setupNodeEvents(on, config) { + require("./__test__/cypress/plugins/tasks").defineTasks(on, config); + // bind to the event we care about + + }, + }, +}) \ No newline at end of file diff --git a/cypress.json b/cypress.json deleted file mode 100644 index d0ccaa08d..000000000 --- a/cypress.json +++ /dev/null @@ -1,9 +0,0 @@ -{ - "baseUrl": "http://localhost:3001", - "integrationFolder": "__test__/cypress/integration", - "fixturesFolder": "__test__/cypress/fixtures", - "pluginsFile": "__test__/cypress/plugins/index.js", - "supportFile": "__test__/cypress/support/index.js", - "testFiles": "*.test.js", - "video": true -} diff --git a/dev-tools/.env.test b/dev-tools/.env.test index 3c5c11994..033e06a5b 100644 --- a/dev-tools/.env.test +++ b/dev-tools/.env.test @@ -27,3 +27,4 @@ PHONE_INVENTORY=1 ALLOW_SEND_ALL=false DST_REFERENCE_TIMEZONE='America/New_York' PASSPORT_STRATEGY=local +ASSIGNMENT_LOAD_LIMIT=1 diff --git a/dev-tools/create-test-database b/dev-tools/create-test-database index 1aa4f61eb..080385d7a 100755 --- a/dev-tools/create-test-database +++ b/dev-tools/create-test-database @@ -6,3 +6,7 @@ docker compose exec -T postgres psql -h localhost -p 5432 -U spoke spokedev <` +9. Click "Create". +10. In the code block of the Actions Code Editor, update the `exports.onExecutePostLogin` code as follows: ```javascript -function (user, context, callback) { -context.idToken["https://spoke/user_metadata"] = user.user_metadata; -callback(null, user, context); -} +exports.onExecutePostLogin = async (event, api) => { + api.idToken.setCustomClaim("https://spoke/user_metadata", event.user.user_metadata); +}; ``` -7. Update the Auth0 [Universal Landing page](https://manage.auth0.com/#/login_page), click on the `Customize Login Page` toggle, and copy and paste following code in the drop down into the `Default Templates` space: +11. Click `Deploy`. +12. Navigate to [Actions Flows](https://manage.auth0.com/#/actions/flows). +13. Click "Login". +14. Add "Spoke Action" to the Login flow. +15. Click "Apply". +16. Update the Auth0 [Universal Landing page](https://manage.auth0.com/#/login_page), click on the `Customize Login Page` toggle, and copy and paste following code in the drop down into the `Default Templates` space:
Code to paste into Auth0 @@ -125,4 +136,4 @@ callback(null, user, context); ```
-8. Replace `YOUR_TOS_LINK_HERE` with the link to your Terms of Service and replace `YOUR_PRIVACY_POLICY_LINK_HERE` with the link to your Privacy Policy +17. Replace `YOUR_TOS_LINK_HERE` with the link to your Terms of Service and replace `YOUR_PRIVACY_POLICY_LINK_HERE` with the link to your Privacy Policy diff --git a/docs/HOWTO-use_bulk_sending.md b/docs/HOWTO-use_bulk_sending.md index 0e42c584c..e9b877df5 100644 --- a/docs/HOWTO-use_bulk_sending.md +++ b/docs/HOWTO-use_bulk_sending.md @@ -1,7 +1,5 @@ # How to configure Spoke for bulk sending outside the USA -#### We assume that bulk sending -- sending a text to more than one contact with a single click -- is not legal in the USA. Please consult with an attorney before you do that. - ## About bulk sending If Spoke is configured to do bulk sending, a `Send Bulk` button will appear on the texter's view for the first contact. When the texter clicks that button, the next chunk of contacts will receive the initial message. After Spoke queues up messages for those contacts, if there are any contacts left in the texter's assignment, the next chunk of contacts will receive messages the next time the texter clicks `Send Bulk`. diff --git a/docs/HOWTO_INTEGRATE_BANDWIDTH.md b/docs/HOWTO_INTEGRATE_BANDWIDTH.md index bdf64a257..15f11462d 100644 --- a/docs/HOWTO_INTEGRATE_BANDWIDTH.md +++ b/docs/HOWTO_INTEGRATE_BANDWIDTH.md @@ -1,6 +1,6 @@ # Bandwidth Integration -Bandwidth.com is a telephone service API company. To use Bandwidth, set `DEFAULT_SERVICE=bandwidth`. The `sticky-sender` and `num-picker` service managers are required for the Bandwidth extension to work. `sticky-sender` must come before `numpicker-basic` in the `SERVICE_MANAGERS` environment variable. +Bandwidth.com is a telephone service API company. To use Bandwidth, set `DEFAULT_SERVICE=bandwidth`. The `sticky-sender` and `numpicker-basic` service managers are required for the Bandwidth extension to work. `sticky-sender` must come before `numpicker-basic` in the `SERVICE_MANAGERS` environment variable. For setting up a development environment with Bandwidth, first read [this section](HOWTO_DEVELOPMENT_LOCAL_SETUP.md#ngrok). diff --git a/docs/REFERENCE-environment_variables.md b/docs/REFERENCE-environment_variables.md index 7609dc732..5d2594446 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_. | @@ -83,6 +84,7 @@ | NODE_ENV | Node environment type. _Options_: development, production. | | NOT_IN_USA | A flag to affirmatively indicate the ability to use features that are discouraged or not legally usable in the United States. Consult with an attorney about the implications for doing so. _Default_: false (i.e. default assumes a USA legal context) | | OPT_OUT_MESSAGE | Spoke instance-wide default for opt out message. | +| OPT_OUT_PER_STATE | Have different opt-out messages per state and org. Defaults to the organization's default opt-out message for non-specified states or when the Smarty Zip Code API is down. Requires the `SMARTY_AUTH_ID` and `SMARTY_AUTH_TOKEN` environment variables. | | OPTOUTS_SHARE_ALL_ORGS | Can be set to true if opt outs should be respected per instance and across organizations | | OUTPUT_DIR | Directory path for packaged files should be saved to. _Required_. | | OWNER_CONFIGURABLE | If set to `ALL` then organization owners will be able to configure all available options from their Settings section (otherwise only superadmins will). You can also put a comma-separated list of environment variables to white-list specific settable variables here. This gives organization owners a lot of control of internal settings, so enable at your own risk. | @@ -105,6 +107,8 @@ | SESSION_SECRET | Unique key used to encrypt sessions. _Required_. | | SHOW_SERVER_ERROR | Best practice is to hide errors in production for security purposes which can reveal internal database/system state (even in an open-source project where the code paths are known) | | SLACK_NOTIFY_URL | If set, then on post-install (often from deploying) a message will be posted to a slack channel's `#spoke` channel | +| SMARTY_AUTH_ID | Smarty API authentication ID. Required when the `OPT_OUT_PER_STATE` environment variable is enabled. | +| SMARTY_AUTH_TOKEN | Smarty API authentication token. Required when the `OPT_OUT_PER_STATE` environment variable is enabled. | | SUPPRESS_SELF_INVITE | Boolean value to prevent self-invitations. Recommend setting before making sites available to public. _Default_: false. | | SUPPRESS_DATABASE_AUTOCREATE | Suppress database auto-creation on first start. Mostly just used for test context | | TERMS_REQUIRE | Require texters to accept the [Terms page](../src/containers/Terms.jsx#L85) before they can start texting. _Default_: false | diff --git a/docs/RELEASE_NOTES.md b/docs/RELEASE_NOTES.md index 294ed109e..f66c68949 100644 --- a/docs/RELEASE_NOTES.md +++ b/docs/RELEASE_NOTES.md @@ -1,5 +1,18 @@ # Release Notes +## v14.0.2 +_August 2024:_ Version 14.0.2 + +14.0.2 is a patch release. + +### Bug Fixes +- [#2410](https://github.com/StateVoicesNational/Spoke/pull/2410) - Data exports bug +- [#2424](https://github.com/StateVoicesNational/Spoke/pull/2424) - Reset Password Bug +- [#2425](https://github.com/StateVoicesNational/Spoke/pull/2425) - Message Review Reassignment Bug + +### Appreciations +[Maureen Zitouni](https://github.com/mau11), [Ruby Engelhart](https://github.com/engelhartrueben) + ## v14.0.1 _July 2024:_ Version 14.0.1 diff --git a/docs/_sidebar.md b/docs/_sidebar.md index a915012d8..50dae8709 100644 --- a/docs/_sidebar.md +++ b/docs/_sidebar.md @@ -29,6 +29,7 @@ - [How to configure Auth0 for authentication](HOWTO-configure-auth0.md) - [Instructions for using Redis in Development and Production](HOWTO_CONNECT_WITH_REDIS.md) - [How to configure Slack Authentication](HOWTO_INTEGRATE_SLACK_AUTH.md) + - [How to integrate Bandwidth](HOWTO_INTEGRATE_BANDWIDTH.md) - [How to integrate Twilio](HOWTO_INTEGRATE_TWILIO.md) - [How to handle high volume using Twilio Functions & Amazon SQS](HOWTO_setup_twilio_amazon_SQS.md) - [How to Integrate with Action Kit](HOWTO_INTEGRATE_WITH_ACTIONKIT.md) 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"); + }); +}; diff --git a/migrations/20240116233906_opt_out_message.js b/migrations/20240116233906_opt_out_message.js new file mode 100644 index 000000000..bc322759a --- /dev/null +++ b/migrations/20240116233906_opt_out_message.js @@ -0,0 +1,31 @@ +/** + * @param { import("knex").Knex } knex + * @returns { Promise } + */ +exports.up = async function(knex) { + await knex.schema.createTable("opt_out_message", table => { + table.increments(); + table.timestamp("created_at").defaultTo(knex.fn.now()); + table.text("message").notNullable(); + table + .integer("organization_id") + .references("id") + .inTable("organization") + .notNullable(); + table.string("state", 2).notNullable(); + + table.index( + ["organization_id", "state"], + "opt_out_message_organization_id_state" + ); + table.unique(["organization_id", "state"]); + }); +}; + +/** + * @param { import("knex").Knex } knex + * @returns { Promise } + */ +exports.down = async function(knex) { + await knex.schema.dropTableIfExists("opt_out_message"); +}; diff --git a/migrations/20240503180901_campaigncontactsupdatedat.js b/migrations/20240503180901_campaigncontactsupdatedat.js new file mode 100644 index 000000000..39f79d864 --- /dev/null +++ b/migrations/20240503180901_campaigncontactsupdatedat.js @@ -0,0 +1,35 @@ + +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` + +/** + * @param { import("knex").Knex } knex + */ +exports.up = async function(knex) { + const isSqlite = /sqlite/.test(knex.client.config.client); + if (!isSqlite) { + await knex.raw(ON_UPDATE_TIMESTAMP_FUNCTION); + await knex.raw(onUpdateTrigger('campaign_contact')); + } +}; + +/** + * @param { import("knex").Knex } knex + */ +exports.down = async function(knex) { + 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); + } +}; 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 diff --git a/package.json b/package.json index 1f0c7e9af..39620e66e 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "spoke", - "version": "14.0.1", + "version": "14.0.2", "description": "Spoke", "main": "src/server", "engines": { @@ -166,6 +166,7 @@ "request": "^2.81.0", "rethink-knex-adapter": "0.4.20", "rollbar": "^2.26.4", + "smartystreets-javascript-sdk": "^5.0.0", "terser-webpack-plugin": "^5.3.10", "thinky": "^2.3.3", "timezonecomplete": "^5.13.1", @@ -183,9 +184,9 @@ "@babel/eslint-parser": "^7.24.7", "babel-jest": "^29.3.1", "babel-preset-es2017": "^6.24.1", - "cypress": "5.6.0", - "cypress-file-upload": "^4.0.6", - "cypress-wait-until": "^1.7.1", + "cypress": "^13.11.0", + "cypress-file-upload": "^5.0.8", + "cypress-wait-until": "^3.0.1", "enzyme": "^3.11.0", "enzyme-adapter-react-16": "^1.15.8", "eslint": "^8.56.0", @@ -193,7 +194,7 @@ "eslint-config-prettier": "^6.10.0", "eslint-plugin-import": "^2.29.1", "eslint-plugin-jsx-a11y": "^6.9.0", - "eslint-plugin-react": "^7.34.3", + "eslint-plugin-react": "^7.35.0", "fakeredis": "^2.0.0", "foreman": "^3.0.1", "husky": "4", diff --git a/src/api/campaign.js b/src/api/campaign.js index 58fcf97eb..a6197dfa3 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 58894be7e..46e4ad8fb 100644 --- a/src/api/schema.js +++ b/src/api/schema.js @@ -99,6 +99,9 @@ const rootSchema = gql` texterUIConfig: TexterUIConfigInput timezone: String inventoryPhoneNumberCounts: [CampaignPhoneNumberInput!] + useDynamicReplies: Boolean + replyBatchSize: Int + joinToken: String } input OrganizationInput { @@ -291,6 +294,11 @@ const rootSchema = gql` organizationId: String! textingHoursEnforced: Boolean! ): Organization + getOptOutMessage( + organizationId: String + zip: String + defaultMessage: String + ): String updateOptOutMessage( organizationId: String! optOutMessage: String! @@ -390,6 +398,10 @@ const rootSchema = gql` messageTextFilter: String newTexterUserId: String! ): [CampaignIdAssignmentId] + dynamicReassign( + joinToken: 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/AssignmentTexter/BulkSendButton.jsx b/src/components/AssignmentTexter/BulkSendButton.jsx index a73b22581..5ddca8660 100644 --- a/src/components/AssignmentTexter/BulkSendButton.jsx +++ b/src/components/AssignmentTexter/BulkSendButton.jsx @@ -1,73 +1,141 @@ import PropTypes from "prop-types"; -import React, { Component } from "react"; +import React, { useState, useEffect } from "react"; import { StyleSheet, css } from "aphrodite"; import Button from "@material-ui/core/Button"; - +import LinearProgress from "@material-ui/core/LinearProgress"; +import Typography from '@material-ui/core/Typography'; +import Snackbar from '@material-ui/core/Snackbar'; +import Alert from '@material-ui/lab/Alert'; + // This is because the Toolbar from material-ui seems to only apply the correct margins if the // immediate child is a Button or other type it recognizes. Can get rid of this if we remove material-ui const styles = StyleSheet.create({ container: { - display: "block", - width: "25ex", - marginLeft: "auto", - marginRight: "auto" + display: "flex", + flexFlow: "column", + alignItems: "center", + width: "30%", + maxWidth: 300, + height: "100%", + marginTop: 10, + marginRight: "auto", + marginLeft: "auto" + }, + progressContainer: { + width: "100%" + }, + progressText: { + position: "relative", + textAlign: "center", + color: "white" + }, + progressBarRoot: { + height: 10, + borderRadius: 5 } }); -export default class BulkSendButton extends Component { - state = { - isSending: false - }; +function BulkSendButton({ + assignment, setDisabled, bulkSendMessages, refreshData, onFinishContact +}) { + const totalChunkSize = window.BULK_SEND_CHUNK_SIZE; + const [isSending, setIsSending] = useState(false); + const [totalSentMessages, setTotalSentMessages] = useState(0); + const [progress, setProgress] = useState(0); + const [errorMessage, setErrorMessage] = useState(''); - sendMessages = async () => { - let sentMessages = 0; + const sendMessages = async () => { + try { + const { data } = await bulkSendMessages(assignment.id); + const sentMessages = data.bulkSendMessages.length; + const updatedTotalSent = totalSentMessages + sentMessages; - this.setState({ isSending: true }); - this.props.setDisabled(true); + if (!sentMessages) { + /* end sending if no messages were left to send */ + setProgress(100); + } else { + setTotalSentMessages(updatedTotalSent); + setProgress((updatedTotalSent / totalChunkSize) * 100); + } + } catch (err) { + console.error(err); + setErrorMessage(err.message); + } + } - console.log(`Start bulk sending messages ${new Date()}`); - while (sentMessages < window.BULK_SEND_CHUNK_SIZE) { - const res = await this.props.bulkSendMessages(this.props.assignment.id); + const handleEndSend = () => { + refreshData(); + setErrorMessage(''); + setIsSending(false); + setProgress(0); + setTotalSentMessages(0); + setDisabled(false); + onFinishContact(); + } - // Check if all messages have been sent - if (!res.data.bulkSendMessages.length) { - break; + useEffect(() => { + if (isSending) { + /* sendMessages will be called the first time when isSending is set to true + and only called again when the progress state is updated and not complete */ + if (progress < 100) { + sendMessages(); + } else { + /* display "sent all" message for half a sec */ + setTimeout(handleEndSend, 500); } - - // Print progress to console - sentMessages += res.data.bulkSendMessages.length; - console.log(`Bulk sent ${sentMessages} messages ${new Date()}`); } - this.props.refreshData(); - console.log(`Finish bulk sending messages ${new Date()}`); - - this.setState({ isSending: false }); - this.props.setDisabled(false); - this.props.onFinishContact(); - }; + }, [isSending, progress]); - render() { - return ( -
+ return ( +
+ {isSending ? ( +
+
+ + {progress === 100 + ? 'Sent all messages!' + : `Sent ${totalSentMessages} of ${totalChunkSize} messages...`} + +
+ +
+ ) : ( -
- ); - } + )} + + + {errorMessage} + + +
+ ); } BulkSendButton.propTypes = { - assignment: PropTypes.object, - onFinishContact: PropTypes.func, - bulkSendMessages: PropTypes.func, - refreshData: PropTypes.func, - setDisabled: PropTypes.func + assignment: PropTypes.shape({ id: PropTypes.number }).isRequired, + onFinishContact: PropTypes.func.isRequired, + bulkSendMessages: PropTypes.func.isRequired, + refreshData: PropTypes.func.isRequired, + setDisabled: PropTypes.func.isRequired }; + +export default BulkSendButton; diff --git a/src/components/AssignmentTexter/Controls.jsx b/src/components/AssignmentTexter/Controls.jsx index 8ce4fa799..3374c3884 100644 --- a/src/components/AssignmentTexter/Controls.jsx +++ b/src/components/AssignmentTexter/Controls.jsx @@ -64,9 +64,10 @@ export class AssignmentTexterContactControls extends React.Component { } this.state = { + contactOptOutMessage: "", questionResponses, filteredCannedResponses: props.campaign.cannedResponses, - optOutMessageText: props.campaign.organization.optOutMessage, + optOutMessageText: "", responsePopoverOpen: false, answerPopoverOpen: false, sideboxCloses: {}, @@ -343,10 +344,18 @@ export class AssignmentTexterContactControls extends React.Component { } }; - handleOpenDialog = () => { + handleOpenDialog = async () => { // delay to avoid accidental tap pass-through with focusing on // the text field -- this is annoying on mobile where the keyboard // pops up, inadvertantly + const optOutMessage = ( + await this.props.getOptOutMessage( + this.props.organizationId, + this.props.contact.zip, + this.props.campaign.organization.optOutMessage + ) + ).data.getOptOutMessage; + this.setState({contactOptOutMessage: optOutMessage, optOutMessageText: optOutMessage}); const update = { optOutDialogOpen: true }; if (this.refs.answerButtons) { // store this, because on-close, we lose this @@ -663,19 +672,18 @@ export class AssignmentTexterContactControls extends React.Component { margin: "9px", color: this.state.optOutMessageText === - this.props.campaign.organization.optOutMessage + this.state.contactOptOutMessage ? "white" : "#494949", backgroundColor: this.state.optOutMessageText === - this.props.campaign.organization.optOutMessage + this.state.contactOptOutMessage ? "#727272" : "white" }} onClick={() => { this.setState({ - optOutMessageText: this.props.campaign.organization - .optOutMessage + optOutMessageText: this.state.contactOptOutMessage }); }} variant="contained" @@ -825,8 +833,8 @@ export class AssignmentTexterContactControls extends React.Component { currentQuestionAnswered = questionResponses[currentInteractionStep.id]; const dupeTester = {}; const shortener = answerValue => { - // label is for one-word values or e.g. "Yes: ...." - const label = answerValue.match(/^(\w+)([^\s\w]|$)/); + // label is for one-word values or e.g. "Yes: ...." or "[Yes] ..." + const label = answerValue.match(/^\W?(\w+)([^\s\w]|$)/); return label ? label[1] : answerValue; }; currentQuestionOptions = currentQuestion.answerOptions @@ -915,8 +923,7 @@ export class AssignmentTexterContactControls extends React.Component { : opt.answer.value, nextScript: (!isCurrentAnswer(opt) && - opt.answer.nextInteractionStep && - opt.answer.nextInteractionStep.script) || + opt.answer.nextInteractionStep?.script) || null }); }} @@ -1267,6 +1274,7 @@ AssignmentTexterContactControls.propTypes = { review: PropTypes.string, // parent config/callbacks + getOptOutMessage: PropTypes.func, startingMessage: PropTypes.string, onMessageFormSubmit: PropTypes.func, onOptOut: PropTypes.func, diff --git a/src/components/AssignmentTexter/StyleControls.js b/src/components/AssignmentTexter/StyleControls.js index 201ed145a..852d2cf27 100644 --- a/src/components/AssignmentTexter/StyleControls.js +++ b/src/components/AssignmentTexter/StyleControls.js @@ -7,20 +7,25 @@ export const messageListStyles = { messageList: { overflow: "hidden", overflow: "-moz-scrollbars-vertical", - padding: 8 + padding: 8, + display: "flex", + flexDirection: "column", + width: "100%" }, messageSent: { textAlign: "left", + alignSelf: "flex-end", marginLeft: "20%", marginRight: "10px", backgroundColor: "white", borderRadius: "16px", marginBottom: "10px", fontSize: "95%", - width: "auto", + width: "fit-content", maxWidth: "500px" }, messageReceived: { + alignSelf: "flex-start", marginRight: "20%", marginLeft: "10px", color: "white", @@ -29,7 +34,7 @@ export const messageListStyles = { fontSize: "110%", lineHeight: "120%", marginBottom: "10px", - width: "auto", + width: "fit-content", maxWidth: "500px" } }; @@ -62,12 +67,10 @@ export const flexStyles = StyleSheet.create({ right: 0, display: "flex", flexDirection: "column", - //messages can be scrolled through and height/width is responsive + //messages can be scrolled through and height/width is responsive height: "100%", "@media(max-width: 420px)": { - fontFamily: "Poppins", - overflowY: "scroll !important", - width: "100%" + fontFamily: "Poppins" } }, popoverSideboxesInner: { @@ -116,15 +119,15 @@ export const flexStyles = StyleSheet.create({ }, sectionSideBoxContent: { padding: 24, - borderLeft: "1px solid #C1C3CC", - height: "100%" + borderLeft: "1px solid #C1C3CC" }, superSectionMessageBox: { + height: "100%", "@media(min-height: 300px) and (max-Height: 700px)": { - height: "100%", + height: "100%" }, "@media(min-height: 701px) and (max-Height: 1000px)": { - height: "53%", + height: "53%" }, overflowY: "scroll", overflow: "-moz-scrollbars-vertical", diff --git a/src/components/CampaignDynamicAssignmentForm.jsx b/src/components/CampaignDynamicAssignmentForm.jsx index de98d6b56..851194641 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

@@ -214,7 +262,8 @@ CampaignDynamicAssignmentForm.propTypes = { saveDisabled: type.bool, joinToken: type.string, responseWindow: type.number, - batchSize: type.string + batchSize: type.string, + replyBatchSize: type.string }; export default withMuiTheme(CampaignDynamicAssignmentForm); diff --git a/src/components/CollapsibleCard.jsx b/src/components/CollapsibleCard.jsx new file mode 100644 index 000000000..b30439dfd --- /dev/null +++ b/src/components/CollapsibleCard.jsx @@ -0,0 +1,73 @@ +import PropTypes from "prop-types"; +import React from "react"; +import Chart from "../components/Chart"; + +import Card from "@material-ui/core/Card"; +import CardHeader from "@material-ui/core/CardHeader"; +import Collapse from "@material-ui/core/Collapse"; + +import ExpandMoreIcon from "@material-ui/icons/ExpandMore"; +import IconButton from "@material-ui/core/IconButton"; + +import withMuiTheme from "../containers/hoc/withMuiTheme"; +import theme from "../styles/theme"; + + +export class CollapsibleCard extends React.Component { + constructor(props) { + super(props); + this.state = { + open: !props.startCollapsed + }; + } + + render() { + const { children, title, colorTheme, muiTheme } = this.props; + const { open } = this.state; + const cardHeaderStyle = {}; + + if (colorTheme === "warning") { + cardHeaderStyle.backgroundColor = muiTheme.palette.warning.light; + cardHeaderStyle.color = muiTheme.palette.warning.contrastText; + } else { + cardHeaderStyle.backgroundColor = muiTheme.palette.primary.main; + cardHeaderStyle.color = muiTheme.palette.primary.contrastText; + } + + return ( + + + + + )} + onClick={newExpandedState => { + this.setState({ open: !open }); + }} + /> + + {children} + + + ); + } +} + +CollapsibleCard.propTypes = { + title: PropTypes.string, + startCollapsed: PropTypes.bool, + colorTheme: PropTypes.string +}; + +const ThemedCollapsibleCard = withMuiTheme(CollapsibleCard); + +export default ThemedCollapsibleCard; diff --git a/src/components/OrganizationReassignLink.jsx b/src/components/OrganizationReassignLink.jsx new file mode 100644 index 000000000..f610b452c --- /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 = ({ joinToken, campaignId }) => { + let baseUrl = "https://base"; + if (typeof window !== "undefined") { + baseUrl = window.location.origin; + } + + 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 = { + joinToken: PropTypes.string, + campaignId: PropTypes.string +}; + +export default OrganizationReassignLink; diff --git a/src/containers/AdminCampaignEdit.jsx b/src/containers/AdminCampaignEdit.jsx index ab8a9eeb3..acd7fed08 100644 --- a/src/containers/AdminCampaignEdit.jsx +++ b/src/containers/AdminCampaignEdit.jsx @@ -138,6 +138,8 @@ const campaignInfoFragment = ` state count } + useDynamicReplies + replyBatchSize `; export const campaignDataQuery = gql`query getCampaign($campaignId: String!) { @@ -513,7 +515,9 @@ export class AdminCampaignEditBase extends React.Component { "batchSize", "useDynamicAssignment", "responseWindow", - "batchPolicies" + "batchPolicies", + "useDynamicReplies", + "replyBatchSize" ], checkCompleted: () => true, blocksStarting: false, diff --git a/src/containers/AdminCampaignStats.jsx b/src/containers/AdminCampaignStats.jsx index 7db3bc25a..c37e7f1b8 100644 --- a/src/containers/AdminCampaignStats.jsx +++ b/src/containers/AdminCampaignStats.jsx @@ -15,6 +15,7 @@ import TexterStats from "../components/TexterStats"; import OrganizationJoinLink from "../components/OrganizationJoinLink"; import CampaignServiceManagers from "../components/CampaignServiceManagers"; import AdminCampaignCopy from "./AdminCampaignCopy"; +import CollapsibleCard from "../components/CollapsibleCard"; import { withRouter, Link as RouterLink } from "react-router"; import { StyleSheet, css } from "aphrodite"; import loadData from "./hoc/load-data"; @@ -103,6 +104,33 @@ class AdminCampaignStats extends React.Component { disableExportButton: false }; + renderPieChart(id, text, count, options) { + return ( +
+ {text} + {count > 0 ? ( +
+
+ +
+
+
+ [ + answer.value, + answer.responderCount + ])} + /> +
+
+
+ ) : ( + "No responses yet" + )} +
+ ); + } + renderSurveyStats() { const { interactionSteps } = this.props.data.campaign; @@ -112,38 +140,106 @@ class AdminCampaignStats extends React.Component { if (step.question === "") { return
; } - const totalResponseCount = step.question.answerOptions.reduce( (prev, answer) => prev + answer.responderCount, 0 ); - return ( -
- {step.question.text} - {totalResponseCount > 0 ? ( -
-
- -
-
-
- [ - answer.value, - answer.responderCount - ])} - /> -
-
-
- ) : ( - "No responses yet" - )} -
+ return this.renderPieChart( + step.id, step.question.text, totalResponseCount, step.question.answerOptions ); }); } + findGroupedAnswerData() { + const groupedAnswerResponses = {}; + const groupedQuestionResponses = {}; + let totalGroupedResponsesCount = 0; + let hasGroupedResponses = false; + const { interactionSteps } = this.props.data.campaign; + interactionSteps.forEach(step => { + step.question.answerOptions.forEach(answer => { + // Grouped Answer Responses + answer.value.replace(/\[(.*?)\]/g, (match) => { + hasGroupedResponses = true; + if (!groupedAnswerResponses[match]) { + groupedAnswerResponses[match] = { + total: 0, + questionAnswers: [] + }; + } + groupedAnswerResponses[match].total += answer.responderCount; + totalGroupedResponsesCount += answer.responderCount; + groupedAnswerResponses[match].questionAnswers.push({ + qid: step.id, + answer: answer.value, + count: answer.responderCount + }); + }); + // Grouped Question Responses + step.question.text.replace(/\[(.*?)\]/g, (match) => { + hasGroupedResponses = true; + if (!groupedQuestionResponses[match]) { + groupedQuestionResponses[match] = { + total: 0, + questionAnswers: {} + }; + } + groupedQuestionResponses[match].total += answer.responderCount; + const qA = groupedQuestionResponses[match].questionAnswers; + if (!qA[answer.value]) { + qA[answer.value] = { + value: answer.value, + responderCount: 0 + }; + } + qA[answer.value].responderCount += answer.responderCount; + }); + + }); + }); + + return { + groupedAnswerResponses, + groupedQuestionResponses, + totalGroupedResponsesCount, + hasGroupedResponses, + totalQuestions: interactionSteps.length + }; + } + + renderGroupedAnswerStats(groupedResponses) { + const { groupedAnswerResponses, totalGroupedResponsesCount } = groupedResponses; + const keys = Object.keys(groupedAnswerResponses); + return ( + keys.length + ? this.renderPieChart( + "groupedResponses", + "Responses Grouped by Answers (with []'s)", + totalGroupedResponsesCount, + keys.map(k => ({ + value: k, + responderCount: groupedAnswerResponses[k].total + })) + ) + : null + ); + } + + renderGroupedQuestionStats(groupedResponses) { + const { groupedQuestionResponses } = groupedResponses; + const keys = Object.keys(groupedQuestionResponses); + return keys.map(k => + this.renderPieChart( + `grouped${k}`, + `Grouped Questions with ${k}`, + groupedQuestionResponses[k].total, + Object.keys(groupedQuestionResponses[k].questionAnswers).map(qkey => ( + groupedQuestionResponses[k].questionAnswers[qkey] + )) + ) + ); + } + renderErrorCounts() { const { campaignId, organizationId } = this.props.params; const { errorCounts } = this.props.data.campaign.stats; @@ -199,6 +295,7 @@ class AdminCampaignStats extends React.Component { } = this.props.organizationData.organization; const showReleaseNumbers = campaign.isArchived && campaignPhoneNumbersEnabled; + const groupedResponses = this.findGroupedAnswerData(); return (
@@ -346,8 +443,8 @@ class AdminCampaignStats extends React.Component { {campaign.exportResults.error && (
Export failed: {campaign.exportResults.error}
)} - {campaign.exportResults.campaignExportUrl && - campaign.exportResults.campaignExportUrl.startsWith("http") ? ( + {campaign.exportResults.campaignExportUrl && ( + (campaign.exportResults.campaignExportUrl.startsWith("http")) ? ( - ) : ( -
- Local export was successful, saved on the server at: -
- {campaign.exportResults.campaignExportUrl} -
- {campaign.exportResults.campaignMessagesExportUrl} -
- )} + ) : (campaign.exportResults.campaignExportUrl.startsWith("file://") && ( +
+ Local export was successful, saved on the server at: +
+ {campaign.exportResults.campaignExportUrl} +
+ {campaign.exportResults.campaignMessagesExportUrl} +
+ ) + ))}
)} {campaign.joinToken && campaign.useDynamicAssignment && ( @@ -404,41 +502,54 @@ class AdminCampaignStats extends React.Component {
+ {groupedResponses.hasGroupedResponses ? ( + +
{this.renderGroupedAnswerStats(groupedResponses)}
+
{this.renderGroupedQuestionStats(groupedResponses)}
+
+ ) : null} {global.HIDE_BRANCHED_SCRIPTS ? ( "" ) : ( - Survey Questions + 15 /* to avoid default large renders */} + > + {this.renderSurveyStats()} + )} - {this.renderSurveyStats()} + {campaign.stats.errorCounts.length > 0 && ( -
- Sending Errors + {this.renderErrorCounts()}{" "} -
+ )} - Texter stats - % of first texts sent - + 30 /* to avoid default large renders */} + > + + Export started - - {this.props.organizationData && - this.props.organizationData.emailEnabled && - " we'll e-mail you when it's done. "} - {campaign.cacheable && ( + {(this.props.organizationData && + this.props.organizationData.organization.emailEnabled) ? + " we'll e-mail you when it's done. " : + (campaign.cacheable && ( { this.props.data.refetch(); }} > - Reload the page + {" Reload the page"} {/*Hacky way to add a space at the beginning */} {" "} to see a download link when its ready. - )} + ))} } autoHideDuration={campaign.cacheable ? null : 5000} diff --git a/src/containers/AdminIncomingMessageList.jsx b/src/containers/AdminIncomingMessageList.jsx index 46f892c36..0732c5eef 100644 --- a/src/containers/AdminIncomingMessageList.jsx +++ b/src/containers/AdminIncomingMessageList.jsx @@ -28,6 +28,11 @@ export class AdminIncomingMessageList extends Component { props.location.query, props.organization.organization.tags ); + // Make sure campaignIds is an array of numbers + filters.campaignsFilter = { + campaignIds: filters.campaignsFilter.campaignIds?.map(id => Number(id)) + }; + this.state = { page: 0, pageSize: 10, @@ -207,10 +212,17 @@ export class AdminIncomingMessageList extends Component { }; handleReassignRequested = async newTexterUserId => { + const updatedCampaignIdsContactIds = this.state.campaignIdsContactIds.map( + campaign => { + campaign.campaignContactId = Number(campaign.campaignContactId); + campaign.messageIds = campaign.messageIds.map(id => Number(id)); + return campaign; + } + ); await this.props.mutations.reassignCampaignContacts( this.props.params.organizationId, - this.state.campaignIdsContactIds, - newTexterUserId + updatedCampaignIdsContactIds, + newTexterUserId.toString() ); this.setState({ utc: Date.now().toString(), diff --git a/src/containers/AdminPhoneNumberInventory.js b/src/containers/AdminPhoneNumberInventory.js index 48fbb1511..40caee89e 100644 --- a/src/containers/AdminPhoneNumberInventory.js +++ b/src/containers/AdminPhoneNumberInventory.js @@ -29,6 +29,9 @@ import GSTextField from "../components/forms/GSTextField"; import theme from "../styles/theme"; import { dataTest } from "../lib/attributes"; import loadData from "./hoc/load-data"; +import { Snackbar } from "@material-ui/core"; +import { Alert } from "@material-ui/lab"; +import { getConfig } from "../server/api/lib/config"; const inlineStyles = { column: { @@ -69,10 +72,64 @@ class AdminPhoneNumberInventory extends React.Component { filters: {}, deleteNumbersDialogOpen: false, queriedShortcodes: false, - queriedTollfree: false + totalShortcodes: this.getAvailablePhoneNumbersByAreaCode(this.props, "Shortcode"), + queriedTollfree: false, + totalTollfree: this.getAvailablePhoneNumbersByAreaCode(this.props, "Tollfree") }; } + // Including props because at one point previous props was queried + getAvailablePhoneNumbersByAreaCode(props, areaCode) { + const check = props.data.organization.phoneNumberCounts.filter(j => { + return j.areaCode == areaCode + }) + return check?.length ? check[0].availableCount : 0 + } + + componentDidUpdate(prevProps) { + const { pendingPhoneNumberJobs } = this.props.data.organization; + const prevPendingPHoneNumberJobs = prevProps.data.organization.pendingPhoneNumberJobs; + let completedShortCodeJobs; + let completedTollFreeJobs; + + // If a job has completed + if (pendingPhoneNumberJobs.length < prevPendingPHoneNumberJobs.length) { + + // Checks if one of the previous jobs is for Short codes/Toll free + // AND that this job is not pending + completedShortCodeJobs = prevPendingPHoneNumberJobs.filter(j => { + return ( + j.areaCode === "Shortcode" && + !pendingPhoneNumberJobs.map(p => p.id).includes(j.id) + ); + }); + + completedTollFreeJobs = prevPendingPHoneNumberJobs.filter(j => { + return ( + j.areaCode === "Tollfree" && + !pendingPhoneNumberJobs.map(p => p.id).includes(j.id)); + }); + } + + // if a Short Code job completes, update the total short codes + // and alert the user + if (completedShortCodeJobs && completedShortCodeJobs.length) { + this.setState({ + totalShortCodes: this.getAvailablePhoneNumbersByAreaCode(this.props, "Shortcode"), + queriedShortcodes: true + }) + } + + // if a Toll Free job complets, update the total toll free numbers + // and alert the user + if (completedTollFreeJobs && completedTollFreeJobs.length) { + this.setState({ + totalTollfree: this.getAvailablePhoneNumbersByAreaCode(this.props, "Tollfree"), + queriedTollfree: true + }) + } + } + buyNumbersFormSchema() { return yup.object({ areaCode: yup @@ -129,18 +186,12 @@ class AdminPhoneNumberInventory extends React.Component { }; handleGetShortcodes = async() => { - this.setState({ - queriedShortcodes: true - }); await this.props.mutations.getShortCodes(); }; handleGetTollFreeNumbers = async() => { - this.setState({ - queriedTollfree: true - }); await this.props.mutations.getTollFreeNumbers(); - }; + } handleDeleteNumbersOpen = ([areaCode, , , availableCount]) => { this.setState({ @@ -322,11 +373,16 @@ class AdminPhoneNumberInventory extends React.Component { render() { const { phoneNumberCounts, - pendingPhoneNumberJobs + pendingPhoneNumberJobs, + serviceVendor: { name } } = this.props.data.organization; const { filters } = this.state; + const isTwilio = name == "twilio"; + const isFakeservice = name == "fakeservice"; + const isTwilioOrFakeservice = isTwilio || isFakeservice; + // Push rows for pending jobs as a simple visual indication that counts are // being updated. // In the future we may want to add a header with more data about pending @@ -347,14 +403,6 @@ class AdminPhoneNumberInventory extends React.Component { tableData = tableData.filter(data => data.state === filters.state); } - if (this.state.queriedShortcodes){ - this.numShortcodes = ownedAreaCodes.filter(j => ownedAreaCodes.indexOf('Shortcode') === -1).length - } - - if (this.state.queriedTollfree){ - this.numTollfreeNumbers = ownedAreaCodes.filter(j => ownedAreaCodes.indexOf('Tollfree') === -1).length - } - this.sortTable(tableData, this.state.sortCol, this.state.sortOrder); const handleSortOrderChange = (key, order) => { this.setState({ @@ -417,7 +465,7 @@ class AdminPhoneNumberInventory extends React.Component { ) : null} - {this.props.params.ownerPerms ? ( + {(isTwilioOrFakeservice && this.props.params.ownerPerms) ? (