Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add dynamic replies feature #14

Merged
merged 4 commits into from
May 7, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
102 changes: 102 additions & 0 deletions __test__/server/api/campaign/campaign.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,7 @@ import {
sleep,
startCampaign
} from "../../../test_helpers";
import { dynamicReassignMutation } from "../../../../src/containers/AssignReplies";

let testAdminUser;
let testInvite;
Expand Down Expand Up @@ -815,6 +816,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,
{
Expand All @@ -840,6 +890,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
});

Expand Down
1 change: 1 addition & 0 deletions __test__/test_helpers.js
Original file line number Diff line number Diff line change
Expand Up @@ -359,6 +359,7 @@ export async function createCampaign(
const campaignQuery = `mutation createCampaign($input: CampaignInput!) {
createCampaign(campaign: $input) {
id
joinToken
}
}`;
const variables = {
Expand Down
2 changes: 2 additions & 0 deletions src/api/campaign.js
Original file line number Diff line number Diff line change
Expand Up @@ -137,6 +137,8 @@ export const schema = gql`
messageServiceLink: String
phoneNumbers: [String]
inventoryPhoneNumberCounts: [CampaignPhoneNumberCount]
useDynamicReplies: Boolean
replyBatchSize: Int
}

type CampaignsList {
Expand Down
7 changes: 7 additions & 0 deletions src/api/schema.js
Original file line number Diff line number Diff line change
Expand Up @@ -99,6 +99,9 @@ const rootSchema = gql`
texterUIConfig: TexterUIConfigInput
timezone: String
inventoryPhoneNumberCounts: [CampaignPhoneNumberInput!]
useDynamicReplies: Boolean
replyBatchSize: Int
joinToken: String
}

input OrganizationInput {
Expand Down Expand Up @@ -395,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
Expand Down
53 changes: 51 additions & 2 deletions src/components/CampaignDynamicAssignmentForm.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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 }));
Expand All @@ -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"
/>
<br/>
<GSForm
schema={this.formSchema}
value={this.state}
Expand Down Expand Up @@ -133,6 +135,52 @@ class CampaignDynamicAssignmentForm extends React.Component {
message status filter in Message Review. You might set this to 48
hours for slower campaigns or 2 hours or less for GOTV campaigns.
</p>
<FormControlLabel
control={
<Switch
color="primary"
checked={useDynamicReplies || false}
onChange={(toggler, val) => {
console.log(toggler, val);
this.toggleChange("useDynamicReplies", val);
}}
/>
}
label="Allow texters with a link to dynamically get assigned replies?"
labelPlacement="start"
/>

{!useDynamicReplies ? null : (
<div>
<ul>
<li>
{joinToken ? (
<OrganizationReassignLink
joinToken={joinToken}
campaignId={campaignId}
/>
) : (
"Please save the campaign and reload the page to get the reply link to share with texters."
)}
</li>
<li>
You can turn off dynamic assignment after starting a campaign
to disallow more new texters to receive replies.
</li>
</ul>

<Form.Field
as={GSTextField}
fullWidth
name="replyBatchSize"
type="number"
label="How large should a batch of replies be?"
initialValue={200}
/>
</div>
)

}
{organization.batchPolicies.length > 1 ? (
<div>
<h3>Batch Strategy</h3>
Expand Down Expand Up @@ -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);
22 changes: 22 additions & 0 deletions src/components/OrganizationReassignLink.jsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
import PropTypes from "prop-types";
import React from "react";
import DisplayLink from "./DisplayLink";

const OrganizationReassignLink = ({ joinToken, campaignId }) => {
let baseUrl = "http://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 <DisplayLink url={replyUrl} textContent={textContent} />;
};

OrganizationReassignLink.propTypes = {
joinToken: PropTypes.string,
campaignId: PropTypes.string
};

export default OrganizationReassignLink;
6 changes: 5 additions & 1 deletion src/containers/AdminCampaignEdit.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -139,6 +139,8 @@ const campaignInfoFragment = `
state
count
}
useDynamicReplies
replyBatchSize
`;

export const campaignDataQuery = gql`query getCampaign($campaignId: String!) {
Expand Down Expand Up @@ -514,7 +516,9 @@ export class AdminCampaignEditBase extends React.Component {
"batchSize",
"useDynamicAssignment",
"responseWindow",
"batchPolicies"
"batchPolicies",
"useDynamicReplies",
"replyBatchSize"
],
checkCompleted: () => true,
blocksStarting: false,
Expand Down
88 changes: 88 additions & 0 deletions src/containers/AssignReplies.jsx
Original file line number Diff line number Diff line change
@@ -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.joinToken,
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 <div className={css(styles.greenBox)}>{this.state.errors}</div>;
}
return <div />;
}

render() {
return <div>{this.renderErrors()}</div>;
}
}

AssignReplies.propTypes = {
mutations: PropTypes.object,
router: PropTypes.object,
params: PropTypes.object,
campaign: PropTypes.object
};

export const dynamicReassignMutation = gql`
mutation dynamicReassign(
$joinToken: String!
$campaignId: String!
) {
dynamicReassign(
joinToken: $joinToken
campaignId: $campaignId
)
}
`;

const mutations = {
dynamicReassign: ownProps => (
joinToken,
campaignId
) => ({
mutation: dynamicReassignMutation,
variables: {
joinToken,
campaignId
}
})
};

export default loadData({ mutations })(withRouter(AssignReplies));
Loading
Loading