Skip to content

Commit 1354599

Browse files
authored
feat: add option to create campaign(s) from template (#1224)
Reviewed-by: @henryk1229 Reviewed-by: @ajohn25
1 parent 0c992e6 commit 1354599

File tree

7 files changed

+330
-125
lines changed

7 files changed

+330
-125
lines changed

libs/spoke-codegen/src/graphql/template-campaigns.graphql

+7
Original file line numberDiff line numberDiff line change
@@ -45,3 +45,10 @@ mutation CreateTemplateCampaign($organizationId: String!) {
4545
...TemplateCampaign
4646
}
4747
}
48+
49+
mutation CreateCampaignFromTemplate($templateId: String!, $quantity: Int!) {
50+
copyCampaigns(sourceCampaignId: $templateId, quantity: $quantity) {
51+
id
52+
title
53+
}
54+
}

src/api/schema.ts

+1
Original file line numberDiff line numberDiff line change
@@ -251,6 +251,7 @@ const rootSchema = `
251251
bulkUpdateScript(organizationId:String!, findAndReplace: BulkUpdateScriptInput!): [ScriptUpdateResult]
252252
deleteJob(campaignId:String!, id:String!): JobRequest
253253
copyCampaign(id: String!): Campaign
254+
copyCampaigns(sourceCampaignId: String!, quantity: Int!): [Campaign!]!
254255
exportCampaign(options: CampaignExportInput!): JobRequest
255256
createCannedResponse(cannedResponse:CannedResponseInput!): CannedResponse
256257
createOrganization(name: String!, userId: String!, inviteId: String!): Organization
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,137 @@
1+
import Button from "@material-ui/core/Button";
2+
import Dialog from "@material-ui/core/Dialog";
3+
import DialogActions from "@material-ui/core/DialogActions";
4+
import DialogContent from "@material-ui/core/DialogContent";
5+
import DialogContentText from "@material-ui/core/DialogContentText";
6+
import DialogTitle from "@material-ui/core/DialogTitle";
7+
import TextField from "@material-ui/core/TextField";
8+
import Autocomplete from "@material-ui/lab/Autocomplete";
9+
import {
10+
GetAdminCampaignsDocument,
11+
TemplateCampaignFragment,
12+
useCreateCampaignFromTemplateMutation,
13+
useGetTemplateCampaignsQuery
14+
} from "@spoke/spoke-codegen";
15+
import React, { useCallback, useMemo, useState } from "react";
16+
17+
export interface CreateCampaignFromTemplateDialogProps {
18+
organizationId: string;
19+
open: boolean;
20+
onClose?: () => Promise<void> | void;
21+
}
22+
23+
export const CreateCampaignFromTemplateDialog: React.FC<CreateCampaignFromTemplateDialogProps> = (
24+
props
25+
) => {
26+
const [
27+
selectedTemplate,
28+
setSelectedTemplate
29+
] = useState<TemplateCampaignFragment | null>(null);
30+
const [quantity, setQuantity] = useState<number | null>(1);
31+
const { data, error } = useGetTemplateCampaignsQuery({
32+
variables: { organizationId: props.organizationId }
33+
});
34+
const [
35+
createFromTemplate,
36+
{ loading: working }
37+
] = useCreateCampaignFromTemplateMutation({
38+
refetchQueries: [GetAdminCampaignsDocument]
39+
});
40+
41+
const templates =
42+
data?.organization?.templateCampaigns?.edges?.map(({ node }) => node) ?? [];
43+
44+
const handleChangeTemplate = useCallback(
45+
(
46+
_event: React.ChangeEvent<unknown>,
47+
value: TemplateCampaignFragment | null
48+
) => {
49+
setSelectedTemplate(value);
50+
},
51+
[setSelectedTemplate]
52+
);
53+
54+
const handleKeyDown: React.KeyboardEventHandler = useCallback((event) => {
55+
const badKey = event.keyCode === 69 || event.keyCode === 101;
56+
if (badKey) event.preventDefault();
57+
}, []);
58+
59+
const handleChangeQuantity: React.ChangeEventHandler<
60+
HTMLInputElement | HTMLTextAreaElement
61+
> = useCallback(
62+
(event) => {
63+
const textValue = event.target.value.replace(/\D/g, "");
64+
const intValue = parseInt(textValue, 10);
65+
const finalValue = Number.isNaN(intValue) ? null : Math.max(1, intValue);
66+
setQuantity(finalValue);
67+
},
68+
[setQuantity]
69+
);
70+
71+
const canCreate = useMemo(
72+
() => quantity !== null && selectedTemplate !== null && !working,
73+
[quantity, selectedTemplate, working]
74+
);
75+
76+
const handleClickCreate = useCallback(async () => {
77+
if (!(quantity !== null && selectedTemplate !== null && !working)) return;
78+
79+
await createFromTemplate({
80+
variables: { templateId: selectedTemplate.id, quantity }
81+
});
82+
props.onClose?.();
83+
}, [quantity, selectedTemplate, createFromTemplate, props.onClose]);
84+
85+
return (
86+
<Dialog
87+
onClose={props.onClose}
88+
aria-labelledby="create-from-template-dialog-title"
89+
open={props.open}
90+
>
91+
<DialogTitle id="create-from-template-dialog-title">
92+
Create Campaign from Template
93+
</DialogTitle>
94+
<DialogContent>
95+
<DialogContentText>
96+
Select a campaign template to create from
97+
</DialogContentText>
98+
{error && (
99+
<DialogContentText>
100+
Error fetching templates: {error.message}
101+
</DialogContentText>
102+
)}
103+
<Autocomplete
104+
options={templates}
105+
getOptionLabel={(template) => template.title}
106+
value={selectedTemplate}
107+
onChange={handleChangeTemplate}
108+
style={{ width: 300 }}
109+
renderInput={(params) => (
110+
<TextField {...params} label="Template campaign name" />
111+
)}
112+
/>
113+
<br />
114+
<TextField
115+
fullWidth
116+
label="Quantity"
117+
type="number"
118+
value={quantity ?? ""}
119+
onChange={handleChangeQuantity}
120+
onKeyDown={handleKeyDown}
121+
/>
122+
</DialogContent>
123+
<DialogActions>
124+
<Button onClick={props.onClose}>Cancel</Button>
125+
<Button
126+
color="primary"
127+
disabled={!canCreate}
128+
onClick={handleClickCreate}
129+
>
130+
Create
131+
</Button>
132+
</DialogActions>
133+
</Dialog>
134+
);
135+
};
136+
137+
export default CreateCampaignFromTemplateDialog;

src/containers/AdminCampaignList.jsx

+31-8
Original file line numberDiff line numberDiff line change
@@ -4,19 +4,22 @@ import Dialog from "@material-ui/core/Dialog";
44
import DialogActions from "@material-ui/core/DialogActions";
55
import DialogContent from "@material-ui/core/DialogContent";
66
import DialogTitle from "@material-ui/core/DialogTitle";
7-
import AddIcon from "@material-ui/icons/Add";
7+
import CreateIcon from "@material-ui/icons/Create";
8+
import FileCopyIcon from "@material-ui/icons/FileCopyOutlined";
9+
import SpeedDial from "@material-ui/lab/SpeedDial";
10+
import SpeedDialAction from "@material-ui/lab/SpeedDialAction";
11+
import SpeedDialIcon from "@material-ui/lab/SpeedDialIcon";
812
import { TextField, Toggle } from "material-ui";
913
import DropDownMenu from "material-ui/DropDownMenu";
10-
import FloatingActionButton from "material-ui/FloatingActionButton";
1114
import { MenuItem } from "material-ui/Menu";
1215
import PropTypes from "prop-types";
1316
import React from "react";
1417
import { withRouter } from "react-router-dom";
1518
import { compose } from "recompose";
1619

1720
import { withAuthzContext } from "../components/AuthzProvider";
21+
import CreateCampaignFromTemplateDialog from "../components/CreateCampaignFromTemplateDialog";
1822
import LoadingIndicator from "../components/LoadingIndicator";
19-
import { dataTest } from "../lib/attributes";
2023
import theme from "../styles/theme";
2124
import CampaignList from "./CampaignList";
2225
import { loadData } from "./hoc/with-operations";
@@ -35,6 +38,8 @@ const DEFAULT_PAGE_SIZE = 10;
3538

3639
class AdminCampaignList extends React.Component {
3740
state = {
41+
speedDialOpen: false,
42+
createFromTemplateOpen: false,
3843
isCreating: false,
3944
campaignsFilter: {
4045
isArchived: false
@@ -260,14 +265,32 @@ class AdminCampaignList extends React.Component {
260265
)}
261266

262267
{isAdmin ? (
263-
<FloatingActionButton
264-
{...dataTest("addCampaign")}
268+
<SpeedDial
269+
ariaLabel="SpeedDial example"
265270
style={theme.components.floatingButton}
266-
onClick={this.handleClickNewButton}
271+
icon={<SpeedDialIcon />}
272+
onClose={() => this.setState({ speedDialOpen: false })}
273+
onOpen={() => this.setState({ speedDialOpen: true })}
274+
open={this.state.speedDialOpen}
275+
direction="up"
267276
>
268-
<AddIcon />
269-
</FloatingActionButton>
277+
<SpeedDialAction
278+
icon={<CreateIcon />}
279+
tooltipTitle="Create Blank"
280+
onClick={this.handleClickNewButton}
281+
/>
282+
<SpeedDialAction
283+
icon={<FileCopyIcon />}
284+
tooltipTitle="Create from Template"
285+
onClick={() => this.setState({ createFromTemplateOpen: true })}
286+
/>
287+
</SpeedDial>
270288
) : null}
289+
<CreateCampaignFromTemplateDialog
290+
organizationId={organizationId}
291+
open={this.state.createFromTemplateOpen}
292+
onClose={() => this.setState({ createFromTemplateOpen: false })}
293+
/>
271294
</div>
272295
);
273296
}

src/schema.graphql

+1
Original file line numberDiff line numberDiff line change
@@ -219,6 +219,7 @@ type RootMutation {
219219
bulkUpdateScript(organizationId:String!, findAndReplace: BulkUpdateScriptInput!): [ScriptUpdateResult]
220220
deleteJob(campaignId:String!, id:String!): JobRequest
221221
copyCampaign(id: String!): Campaign
222+
copyCampaigns(sourceCampaignId: String!, quantity: Int!): [Campaign!]!
222223
exportCampaign(options: CampaignExportInput!): JobRequest
223224
createCannedResponse(cannedResponse:CannedResponseInput!): CannedResponse
224225
createOrganization(name: String!, userId: String!, inviteId: String!): Organization

0 commit comments

Comments
 (0)