From 735f7ed380b141a47ae43b1ce6b85ba649e0c965 Mon Sep 17 00:00:00 2001 From: Aashish John Date: Fri, 11 Aug 2023 23:29:55 -0400 Subject: [PATCH] feat: allow users to add auto reply triggers --- libs/gql-schema/interaction-step.ts | 15 +++ .../forms/GSAutoReplyTokensField.tsx | 99 +++++++++++++++ src/components/forms/SpokeFormField.tsx | 3 + .../components/InteractionStepCard.tsx | 69 +++++----- .../CampaignInteractionStepsForm/index.tsx | 119 +++++++++++------- .../CampaignInteractionStepsForm/resolvers.ts | 11 +- .../CampaignInteractionStepsForm/utils.ts | 13 +- src/global.d.ts | 1 + src/schema.graphql | 16 +++ 9 files changed, 269 insertions(+), 77 deletions(-) create mode 100644 src/components/forms/GSAutoReplyTokensField.tsx diff --git a/libs/gql-schema/interaction-step.ts b/libs/gql-schema/interaction-step.ts index 6066870f2..dee49749a 100644 --- a/libs/gql-schema/interaction-step.ts +++ b/libs/gql-schema/interaction-step.ts @@ -25,5 +25,20 @@ export const schema = ` createdAt: Date interactionSteps: [InteractionStepInput] } + + type InteractionStepWithChildren { + id: ID! + question: Question + questionText: String + scriptOptions: [String]! + answerOption: String + parentInteractionId: String + autoReplyTokens: [String] + isDeleted: Boolean + answerActions: String + questionResponse(campaignContactId: String): QuestionResponse + createdAt: Date! + interactionSteps: [InteractionStep] + } `; export default schema; diff --git a/src/components/forms/GSAutoReplyTokensField.tsx b/src/components/forms/GSAutoReplyTokensField.tsx new file mode 100644 index 000000000..a5ff3a1d9 --- /dev/null +++ b/src/components/forms/GSAutoReplyTokensField.tsx @@ -0,0 +1,99 @@ +import uniqBy from "lodash/uniqBy"; +import type { KeyboardEventHandler } from "react"; +import React, { useEffect, useRef, useState } from "react"; +import CreatableSelect from "react-select/creatable"; + +import { optOutTriggers } from "../../lib/opt-out-triggers"; +import type { GSFormFieldProps } from "./GSFormField"; + +interface GSAutoReplyTokensFieldProps extends GSFormFieldProps { + selectedOptions?: Option[]; + disabled: boolean; + onChange: (...args: any[]) => any; +} + +interface Option { + readonly label: string; + readonly value: string; +} + +const createOption = (label: string) => ({ + label, + value: label +}); + +const GSAutoReplyTokensField: React.FC = ({ + disabled, + onChange, + value: selectedOptions +}) => { + const initialOptionValue = selectedOptions.map((token: string) => + createOption(token) + ); + const [optionValue, setOptionValue] = useState( + initialOptionValue + ); + + const [inputValue, setInputValue] = useState(""); + const onChangeValue = optionValue.map((option) => option.value); + const initialRender = useRef(true); + + useEffect(() => { + if (initialRender.current) initialRender.current = false; + else onChange(onChangeValue); + }, [optionValue]); + + const handleInputChange = (newValue: string) => { + setInputValue(newValue); + }; + + const handleChange = (newValue: Option[]) => { + setOptionValue(newValue); + }; + + const handleKeyDown: KeyboardEventHandler = (event) => { + if (!inputValue) return; + + const lowerInputValue = inputValue.toLowerCase().trim(); + if (optOutTriggers.includes(lowerInputValue)) { + setInputValue(""); + return; + } + switch (event.key) { + /* eslint-disable no-case-declarations */ + case ",": + case "Enter": + case "Tab": + const newValue = [...optionValue, createOption(lowerInputValue)]; + const uniqValue = uniqBy(newValue, "value"); + + setInputValue(""); + setOptionValue(uniqValue); + event.preventDefault(); + /* eslint-enable no-case-declarations */ + // no default + } + }; + + return ( +
+
+ Auto Replies + +
+ ); +}; + +export default GSAutoReplyTokensField; diff --git a/src/components/forms/SpokeFormField.tsx b/src/components/forms/SpokeFormField.tsx index 8da090640..dd2621f19 100644 --- a/src/components/forms/SpokeFormField.tsx +++ b/src/components/forms/SpokeFormField.tsx @@ -1,6 +1,7 @@ import React from "react"; import BaseForm from "react-formal"; +import GSAutoReplyTokensField from "./GSAutoReplyTokensField"; import GSDateField from "./GSDateField"; import GSPasswordField from "./GSPasswordField"; import GSScriptField from "./GSScriptField"; @@ -51,6 +52,8 @@ const SpokeFormField = React.forwardRef(function Component( Input = GSSelectField; } else if (type === "password") { Input = GSPasswordField; + } else if (type === "autoreplytokens") { + Input = GSAutoReplyTokensField; } else { Input = type || GSTextField; } diff --git a/src/containers/AdminCampaignEdit/sections/CampaignInteractionStepsForm/components/InteractionStepCard.tsx b/src/containers/AdminCampaignEdit/sections/CampaignInteractionStepsForm/components/InteractionStepCard.tsx index af01d3406..51c9e56cc 100644 --- a/src/containers/AdminCampaignEdit/sections/CampaignInteractionStepsForm/components/InteractionStepCard.tsx +++ b/src/containers/AdminCampaignEdit/sections/CampaignInteractionStepsForm/components/InteractionStepCard.tsx @@ -11,15 +11,14 @@ import DeleteIcon from "@material-ui/icons/Delete"; import ExpandLess from "@material-ui/icons/ExpandLess"; import ExpandMore from "@material-ui/icons/ExpandMore"; import HelpIconOutline from "@material-ui/icons/HelpOutline"; -import type { CampaignVariable } from "@spoke/spoke-codegen"; +import type { + CampaignVariable, + InteractionStepWithChildren +} from "@spoke/spoke-codegen"; import isNil from "lodash/isNil"; import React, { useCallback, useState } from "react"; import * as yup from "yup"; -import type { - InteractionStep, - InteractionStepWithChildren -} from "../../../../../api/interaction-step"; import { supportsClipboard } from "../../../../../client/lib"; import GSForm from "../../../../../components/forms/GSForm"; import SpokeFormField from "../../../../../components/forms/SpokeFormField"; @@ -47,7 +46,8 @@ const interactionStepSchema = yup.object({ scriptOptions: yup.array(yup.string()), questionText: yup.string(), answerOption: yup.string(), - answerActions: yup.string() + answerActions: yup.string(), + autoReplyTokens: yup.array(yup.string()) }); type BlockHandlerFactory = (stepId: string) => () => Promise | void; @@ -62,7 +62,7 @@ interface Props { title?: string; disabled?: boolean; onFormChange(e: any): void; - onCopyBlock(interactionStep: InteractionStep): void; + onCopyBlock(interactionStep: InteractionStepWithChildren): void; onRequestRootPaste(): void; deleteStepFactory: BlockHandlerFactory; addStepFactory: BlockHandlerFactory; @@ -111,7 +111,7 @@ export const InteractionStepCard: React.FC = (props) => { const stepCanHaveChildren = isRootStep || answerOption; const isAbleToAddResponse = stepHasQuestion && stepHasScript && stepCanHaveChildren; - const childStepsLength = childSteps?.length; + const childStepsLength = childSteps?.length ?? 0; const clipboardEnabled = supportsClipboard(); @@ -251,6 +251,13 @@ export const InteractionStepCard: React.FC = (props) => { multiLine disabled={disabled} /> + {window.ENABLE_AUTO_REPLIES && parentInteractionId && ( + + )} = (props) => { )} {expanded && (childSteps ?? []) - .filter((is) => !is.isDeleted) - .map((childStep) => ( - - ))} + .filter((is) => !is?.isDeleted) + .map((childStep) => { + if (childStep) { + const { __typename, ...childStepWithoutTypename } = childStep; + return ( + + ); + } + return null; + })} ); diff --git a/src/containers/AdminCampaignEdit/sections/CampaignInteractionStepsForm/index.tsx b/src/containers/AdminCampaignEdit/sections/CampaignInteractionStepsForm/index.tsx index c1925e06d..7238ff0a1 100644 --- a/src/containers/AdminCampaignEdit/sections/CampaignInteractionStepsForm/index.tsx +++ b/src/containers/AdminCampaignEdit/sections/CampaignInteractionStepsForm/index.tsx @@ -6,18 +6,18 @@ import Dialog from "@material-ui/core/Dialog"; import DialogActions from "@material-ui/core/DialogActions"; import DialogContent from "@material-ui/core/DialogContent"; import DialogContentText from "@material-ui/core/DialogContentText"; -import type { CampaignVariablePage } from "@spoke/spoke-codegen"; +import type { + Action, + Campaign, + CampaignVariablePage, + InteractionStep, + InteractionStepWithChildren +} from "@spoke/spoke-codegen"; import produce from "immer"; import isEqual from "lodash/isEqual"; import React, { useEffect, useState } from "react"; import { compose } from "recompose"; -import type { Campaign } from "../../../../api/campaign"; -import type { - InteractionStep, - InteractionStepWithChildren -} from "../../../../api/interaction-step"; -import type { Action } from "../../../../api/types"; import { readClipboardText, writeClipboardText } from "../../../../client/lib"; import ScriptPreviewButton from "../../../../components/ScriptPreviewButton"; import { dataTest } from "../../../../lib/attributes"; @@ -43,7 +43,7 @@ import { generateId, GET_CAMPAIGN_INTERACTIONS } from "./resolvers"; -import { isBlock } from "./utils"; +import { hasDuplicateTriggerError, isBlock } from "./utils"; const DEFAULT_EMPTY_STEP_ID = "DEFAULT_EMPTY_STEP_ID"; @@ -69,7 +69,11 @@ interface HocProps { data: { campaign: Pick< Campaign, - "id" | "isStarted" | "customFields" | "externalSystem" + | "id" + | "isStarted" + | "customFields" + | "externalSystem" + | "invalidScriptFields" > & { interactionSteps: InteractionStepWithLocalState[]; campaignVariables: CampaignVariablePage; @@ -104,7 +108,7 @@ const CampaignInteractionStepsForm: React.FC = (props) => { const hasEmptyScript = (step: InteractionStep) => { const hasNoOptions = step.scriptOptions.length === 0; const hasEmptyScriptOption = - step.scriptOptions.find((version) => version.trim() === "") !== + step.scriptOptions.find((version) => version?.trim() === "") !== undefined; return hasNoOptions || hasEmptyScriptOption; }; @@ -159,8 +163,14 @@ const CampaignInteractionStepsForm: React.FC = (props) => { const response = await props.mutations.editCampaign({ interactionSteps }); - if (response.errors) throw response.errors; - } catch (err) { + if (response.errors) { + if (hasDuplicateTriggerError(response.errors)) { + throw new Error( + "Please double check your auto reply tokens! Each interaction step can only have 1 child step assigned to any particular auto reply token!" + ); + } else throw response.errors; + } + } catch (err: any) { props.onError(err.message); } finally { setIsWorking(false); @@ -227,11 +237,17 @@ const CampaignInteractionStepsForm: React.FC = (props) => { id: generateId() }); } - const { answerOption, questionText, scriptOptions } = changedStep; + const { + answerOption, + questionText, + scriptOptions, + autoReplyTokens + } = changedStep; props.mutations.stageUpdateInteractionStep(changedStep.id, { answerOption, questionText, - scriptOptions + scriptOptions, + autoReplyTokens }); }; @@ -255,14 +271,15 @@ const CampaignInteractionStepsForm: React.FC = (props) => { while (interactionStepsAdded !== 0) { interactionStepsAdded = 0; - for (const is of interactionSteps) { + for (const step of interactionSteps) { if ( - !interactionStepsInBlock.has(is.id) && - is.parentInteractionId && - interactionStepsInBlock.has(is.parentInteractionId) + !interactionStepsInBlock.has(step.id) && + step.parentInteractionId && + interactionStepsInBlock.has(step.parentInteractionId) ) { - block.push(is); - interactionStepsInBlock.add(is.id); + const { __typename, ...stepWithoutTypename } = step; + block.push(stepWithoutTypename); + interactionStepsInBlock.add(step.id); interactionStepsAdded += 1; } } @@ -302,16 +319,20 @@ const CampaignInteractionStepsForm: React.FC = (props) => { stripLocals: true }); + const stringCustomFields = customFields as string[]; + const invalidCampaignVariables = interactionSteps.reduce>( (acc, step) => { let result = acc; for (const scriptOption of step.scriptOptions) { - const { invalidCampaignVariablesUsed } = scriptToTokens({ - script: scriptOption ?? "", - customFields, - campaignVariables - }); - result = result.concat(invalidCampaignVariablesUsed); + if (customFields) { + const { invalidCampaignVariablesUsed } = scriptToTokens({ + script: scriptOption ?? "", + customFields: stringCustomFields, + campaignVariables + }); + result = result.concat(invalidCampaignVariablesUsed); + } } return result; }, @@ -341,25 +362,27 @@ const CampaignInteractionStepsForm: React.FC = (props) => { const campaignId = props.data?.campaign?.id; const renderInvalidScriptFields = () => { - if (invalidScriptFields.length === 0) { - return null; + if (invalidScriptFields) { + if (invalidScriptFields.length === 0) { + return null; + } + const invalidFields = invalidCampaignVariables.concat( + invalidScriptFields.map((field: string) => `{${field}}`) + ); + return ( +
+

+ Warning: Variable values are not all present for this script. You + can continue working on your script but you cannot start this + campaign. The following variables do not have values and will not + populate in your script: +

+

+ {invalidFields.join(", ")} +

+
+ ); } - const invalidFields = invalidCampaignVariables.concat( - invalidScriptFields.map((field: string) => `{${field}}`) - ); - return ( -
-

- Warning: Variable values are not all present for this script. You can - continue working on your script but you cannot start this campaign. - The following variables do not have values and will not populate in - your script: -

-

- {invalidFields.join(", ")} -

-
- ); }; return ( @@ -395,7 +418,7 @@ const CampaignInteractionStepsForm: React.FC = (props) => { {renderInvalidScriptFields()} = { variables }); const data = produce(old, (draft: any) => { - draft.campaign.interactionSteps = editCampaign.interactionSteps.map( + draft.campaign.interactionSteps = editCampaign?.interactionSteps.map( (step: InteractionStepWithLocalState) => ({ ...step, isModified: false @@ -524,6 +547,7 @@ const mutations: MutationMap = { $questionText: String $scriptOptions: [String] $answerOption: String + $autoReplyTokens: [String] ) { stageAddInteractionStep( campaignId: $campaignId @@ -532,6 +556,7 @@ const mutations: MutationMap = { questionText: $questionText scriptOptions: $scriptOptions answerOption: $answerOption + autoReplyTokens: $autoReplyTokens ) @client } `, @@ -547,12 +572,14 @@ const mutations: MutationMap = { $questionText: String $scriptOptions: [String] $answerOption: String + $autoReplyTokens: [String] ) { stageUpdateInteractionStep( iStepId: $iStepId questionText: $questionText scriptOptions: $scriptOptions answerOption: $answerOption + autoReplyTokens: $autoReplyTokens ) @client } `, diff --git a/src/containers/AdminCampaignEdit/sections/CampaignInteractionStepsForm/resolvers.ts b/src/containers/AdminCampaignEdit/sections/CampaignInteractionStepsForm/resolvers.ts index 167d120b9..00c9e94a8 100644 --- a/src/containers/AdminCampaignEdit/sections/CampaignInteractionStepsForm/resolvers.ts +++ b/src/containers/AdminCampaignEdit/sections/CampaignInteractionStepsForm/resolvers.ts @@ -1,8 +1,8 @@ import type { Resolver, Resolvers } from "@apollo/client"; import { gql } from "@apollo/client"; +import type { InteractionStep } from "@spoke/spoke-codegen"; import produce from "immer"; -import type { InteractionStep } from "../../../../api/interaction-step"; import { DateTime } from "../../../../lib/datetime"; import type { LocalResolverContext } from "../../../../network/types"; @@ -20,6 +20,7 @@ export const EditInteractionStepFragment = gql` answerActions parentInteractionId isDeleted + autoReplyTokens isModified @client } `; @@ -108,6 +109,7 @@ export type AddInteractionStepPayload = Partial< | "answerActions" | "questionText" | "scriptOptions" + | "autoReplyTokens" > >; @@ -134,6 +136,7 @@ export const stageAddInteractionStep: Resolver = ( scriptOptions: payload.scriptOptions ?? [""], answerOption: payload.answerOption ?? "", answerActions: payload.answerActions ?? "", + autoReplyTokens: payload.autoReplyTokens ?? [], isDeleted: false, isModified: true, createdAt: DateTime.local().toISO() @@ -148,7 +151,10 @@ export const stageAddInteractionStep: Resolver = ( }; export type UpdateInteractionStepPayload = Partial< - Pick + Pick< + InteractionStep, + "answerOption" | "questionText" | "scriptOptions" | "autoReplyTokens" + > >; export interface StageUpdateInteractionStepVars @@ -161,6 +167,7 @@ const EditableIStepFragment = gql` questionText scriptOptions answerOption + autoReplyTokens isModified @client } `; diff --git a/src/containers/AdminCampaignEdit/sections/CampaignInteractionStepsForm/utils.ts b/src/containers/AdminCampaignEdit/sections/CampaignInteractionStepsForm/utils.ts index a023f3abd..a2c0e69bd 100644 --- a/src/containers/AdminCampaignEdit/sections/CampaignInteractionStepsForm/utils.ts +++ b/src/containers/AdminCampaignEdit/sections/CampaignInteractionStepsForm/utils.ts @@ -1,4 +1,4 @@ -/* eslint-disable import/prefer-default-export */ +import type { GraphQLError } from "graphql"; export const isBlock = (text: string) => { try { @@ -8,3 +8,14 @@ export const isBlock = (text: string) => { return false; } }; + +export const hasDuplicateTriggerError = ( + errors: Error | readonly GraphQLError[] +) => { + return ( + Array.isArray(errors) && + errors[0].message.includes( + "Each interaction step can only have 1 child step assigned to any particular auto reply token" + ) + ); +}; diff --git a/src/global.d.ts b/src/global.d.ts index 560f0a41a..f9451ead2 100644 --- a/src/global.d.ts +++ b/src/global.d.ts @@ -16,6 +16,7 @@ interface Window { BASE_URL: string; ENABLE_TROLLBOT: boolean; SHOW_10DLC_REGISTRATION_NOTICES: boolean; + ENABLE_AUTO_REPLIES: boolean; AuthService: any; } diff --git a/src/schema.graphql b/src/schema.graphql index ca775fa5c..fcf390231 100644 --- a/src/schema.graphql +++ b/src/schema.graphql @@ -798,12 +798,28 @@ input InteractionStepInput { scriptOptions: [String]! answerOption: String answerActions: String + autoReplyTokens: [String] parentInteractionId: String isDeleted: Boolean createdAt: Date interactionSteps: [InteractionStepInput] } +type InteractionStepWithChildren { + id: ID! + question: Question + questionText: String + scriptOptions: [String]! + answerOption: String + parentInteractionId: String + autoReplyTokens: [String] + isDeleted: Boolean + answerActions: String + questionResponse(campaignContactId: String): QuestionResponse + createdAt: Date! + interactionSteps: [InteractionStep] +} + type OptOut {