Skip to content

Commit

Permalink
feat: clone campaign (#859)
Browse files Browse the repository at this point in the history
* feat: clone campaigns

* feat: clone campaign

* fix: payment type
  • Loading branch information
IanKrieger authored Aug 15, 2023
1 parent 1f2ac78 commit 87a2a67
Show file tree
Hide file tree
Showing 18 changed files with 499 additions and 168 deletions.
111 changes: 111 additions & 0 deletions src/components/Campaigns/CloneCampaign.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,111 @@
import {
Box,
Button,
Chip,
Dialog,
DialogActions,
DialogContent,
DialogContentText,
DialogTitle,
LinearProgress,
} from "@mui/material";
import {
CampaignFragment,
useCreateCampaignMutation,
} from "graphql/campaign.generated";
import { useHistory } from "react-router-dom";
import { useState } from "react";
import { AdvertiserCampaignsDocument } from "graphql/advertiser.generated";
import { createCampaignFromFragment } from "form/fragmentUtil";
import { useAdvertiser } from "auth/hooks/queries/useAdvertiser";
import ContentCopyIcon from "@mui/icons-material/ContentCopy";

interface Props {
campaignFragment?: CampaignFragment | null;
useChip?: boolean;
}

export function CloneCampaign({ campaignFragment, useChip }: Props) {
const { advertiser } = useAdvertiser();
const history = useHistory();
const [open, setOpen] = useState(false);

const [copyCampaign, { loading }] = useCreateCampaignMutation({
refetchQueries: [
{
query: AdvertiserCampaignsDocument,
variables: { id: advertiser.id },
},
],
onCompleted(data) {
history.push(
`/user/main/adsmanager/advanced/${data.createCampaign.id}/settings`,
);
},
onError() {
alert(`Unable to clone campaign`);
},
});

return (
<Box>
{useChip ? (
<Chip
color="primary"
label="Clone"
onClick={() => {
setOpen(true);
}}
disabled={loading || !campaignFragment}
icon={<ContentCopyIcon fontSize="small" />}
/>
) : (
<Button
color="primary"
variant="text"
size="small"
onClick={(e) => {
e.preventDefault();
setOpen(true);
}}
disabled={loading || !campaignFragment}
startIcon={<ContentCopyIcon />}
>
Clone Campaign
</Button>
)}
<Dialog open={open} onClose={() => setOpen(false)}>
<DialogTitle>{`Copy campaign: "${campaignFragment?.name}"?`}</DialogTitle>
<DialogContent>
<DialogContentText>
Copying a campaign will take all properties including ad sets and
ads, and create a new draft campaign with them.
</DialogContentText>
{loading && <LinearProgress />}
</DialogContent>
<DialogActions>
<Button onClick={() => setOpen(false)} disabled={loading}>
Cancel
</Button>
<Button
disabled={loading}
onClick={(e) => {
e.preventDefault();
if (campaignFragment) {
copyCampaign({
variables: {
input: createCampaignFromFragment(campaignFragment),
},
});
} else {
alert("No campaign selected");
}
}}
>
Clone
</Button>
</DialogActions>
</Dialog>
</Box>
);
}
2 changes: 1 addition & 1 deletion src/components/Card/CardContainer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ import { SxProps } from "@mui/system";

export function CardContainer(
props: {
header?: string;
header?: ReactNode;
additionalAction?: ReactNode;
sx?: SxProps;
} & PropsWithChildren,
Expand Down
21 changes: 14 additions & 7 deletions src/components/Steps/ActionButtons.tsx
Original file line number Diff line number Diff line change
@@ -1,34 +1,41 @@
import { Button, Stack } from "@mui/material";
import { useContext } from "react";
import { DraftContext } from "state/context";
import { useHistory } from "react-router-dom";
import { useFormikContext } from "formik";
import { CampaignForm } from "user/views/adsManager/types";
import ArrowBackIcon from "@mui/icons-material/ArrowBack";
import RemoveIcon from "@mui/icons-material/Remove";
import { Link as RouterLink } from "react-router-dom";

export function ActionButtons() {
const history = useHistory();
const { values } = useFormikContext<CampaignForm>();
const { setDrafts } = useContext(DraftContext);

return (
<Stack mt={3} spacing={2} mr={1}>
<Button size="small" onClick={() => history.push("/user/main")}>
Return to dashboard
</Button>
{values.draftId !== undefined && (
<Button
startIcon={<RemoveIcon />}
component={RouterLink}
size="small"
color="error"
sx={{ mr: 1 }}
to="/user/main"
onClick={() => {
localStorage.removeItem(values.draftId!);
setDrafts();
history.push("/user/main");
}}
>
Discard campaign
</Button>
)}
<Button
size="small"
component={RouterLink}
to="/user/main"
startIcon={<ArrowBackIcon />}
>
Return to dashboard
</Button>
</Stack>
);
}
2 changes: 1 addition & 1 deletion src/components/Steps/StepDrawer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ import { NextAndBack } from "components/Steps/NextAndBack";
import { useHistory } from "react-router-dom";
import { ActionButtons } from "components/Steps/ActionButtons";

const drawerWidth = 250;
const drawerWidth = 222;

interface Props {
steps: {
Expand Down
78 changes: 78 additions & 0 deletions src/form/fragmentUtil.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,78 @@
import { CreateAdSetInput, CreateCampaignInput } from "graphql/types";
import moment from "moment";
import { CampaignFragment } from "graphql/campaign.generated";
import { AdSetFragment } from "graphql/ad-set.generated";

export function createCampaignFromFragment(
data: CampaignFragment,
): CreateCampaignInput {
const adSets: CreateAdSetInput[] = data.adSets.map((adSet) =>
createAdSetFromFragment(adSet),
);

const two = moment().utc().add(3, "days");
return {
adSets: adSets && adSets.length > 0 ? adSets : undefined,
advertiserId: data.advertiser.id,
budget: data.budget,
currency: data.currency,
dailyBudget: data.dailyBudget,
dailyCap: data.dailyCap,
dayPartings: (data.dayPartings ?? []).map((d) => ({
dow: d.dow,
startMinute: d.startMinute,
endMinute: d.endMinute,
})),
dayProportion: data.dayProportion,
startAt: two.startOf("day").toISOString(),
endAt: two.endOf("day").toISOString(),
externalId: data.externalId,
format: data.format,
geoTargets: (data.geoTargets ?? []).map((g) => ({
code: g.code,
name: g.name,
})),
name: `${data.name} - Copy`,
pacingStrategy: data.pacingStrategy,
source: data.source.toLowerCase(),
state: "draft",
type: data.type,
paymentType: data.paymentType,
};
}

export function createAdSetFromFragment(
data: AdSetFragment,
campaignId?: string,
): CreateAdSetInput {
return {
campaignId,
ads: (data.ads ?? [])
.filter((ad) => ad.state !== "deleted")
.map((ad) => ({
creativeId: ad.creative.id,
price: ad.price,
priceType: ad.priceType,
})),
bannedKeywords: data.bannedKeywords,
billingType: data.billingType ?? "cpm",
conversions: (data.conversions ?? []).map((c) => ({
observationWindow: c.observationWindow,
type: c.type,
urlPattern: c.urlPattern,
})),
execution: data.execution,
keywordSimilarity: data.keywordSimilarity,
keywords: data.keywords,
name: `${data.name ? data.name : data.id.split("-")[0]} - Copy`,
negativeKeywords: data.negativeKeywords,
oses: (data.oses ?? []).map((o) => ({ name: o.name, code: o.code })),
perDay: data.perDay,
segments: (data.segments ?? []).map((o) => ({
name: o.name,
code: o.code,
})),
state: data.state,
totalMax: data.totalMax,
};
}
19 changes: 17 additions & 2 deletions src/graphql/ad-set.generated.tsx

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

4 changes: 4 additions & 0 deletions src/graphql/ad-set.graphql
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,10 @@ fragment AdSet on AdSet {
perDay
state
execution
keywords
keywordSimilarity
negativeKeywords
bannedKeywords
segments {
code
name
Expand Down
3 changes: 1 addition & 2 deletions src/graphql/advertiser.generated.tsx

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

3 changes: 1 addition & 2 deletions src/graphql/analytics-overview.generated.tsx

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Loading

0 comments on commit 87a2a67

Please sign in to comment.