From 87a2a67d2d2ea3091181ba10ab77ba37f641b685 Mon Sep 17 00:00:00 2001
From: Ian Krieger <48930920+IanKrieger@users.noreply.github.com>
Date: Tue, 15 Aug 2023 13:00:59 -0400
Subject: [PATCH] feat: clone campaign (#859)
* feat: clone campaigns
* feat: clone campaign
* fix: payment type
---
src/components/Campaigns/CloneCampaign.tsx | 111 ++++++++
src/components/Card/CardContainer.tsx | 2 +-
src/components/Steps/ActionButtons.tsx | 21 +-
src/components/Steps/StepDrawer.tsx | 2 +-
src/form/fragmentUtil.ts | 78 ++++++
src/graphql/ad-set.generated.tsx | 19 +-
src/graphql/ad-set.graphql | 4 +
src/graphql/advertiser.generated.tsx | 3 +-
src/graphql/analytics-overview.generated.tsx | 3 +-
src/graphql/campaign.generated.tsx | 36 ++-
src/graphql/campaign.graphql | 5 +
src/graphql/common.generated.tsx | 3 +-
src/graphql/creative.generated.tsx | 3 +-
src/graphql/types.ts | 10 +-
src/graphql/url.generated.tsx | 3 +-
src/graphql/user.generated.tsx | 3 +-
src/user/campaignList/CampaignList.tsx | 279 ++++++++++---------
src/user/views/user/CampaignView.tsx | 82 +++++-
18 files changed, 499 insertions(+), 168 deletions(-)
create mode 100644 src/components/Campaigns/CloneCampaign.tsx
create mode 100644 src/form/fragmentUtil.ts
diff --git a/src/components/Campaigns/CloneCampaign.tsx b/src/components/Campaigns/CloneCampaign.tsx
new file mode 100644
index 00000000..67d3a4c7
--- /dev/null
+++ b/src/components/Campaigns/CloneCampaign.tsx
@@ -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 (
+
+ {useChip ? (
+ {
+ setOpen(true);
+ }}
+ disabled={loading || !campaignFragment}
+ icon={}
+ />
+ ) : (
+
+ )}
+
+
+ );
+}
diff --git a/src/components/Card/CardContainer.tsx b/src/components/Card/CardContainer.tsx
index 31dd92b4..343eabd8 100644
--- a/src/components/Card/CardContainer.tsx
+++ b/src/components/Card/CardContainer.tsx
@@ -4,7 +4,7 @@ import { SxProps } from "@mui/system";
export function CardContainer(
props: {
- header?: string;
+ header?: ReactNode;
additionalAction?: ReactNode;
sx?: SxProps;
} & PropsWithChildren,
diff --git a/src/components/Steps/ActionButtons.tsx b/src/components/Steps/ActionButtons.tsx
index 6b0b9b78..362b0fd5 100644
--- a/src/components/Steps/ActionButtons.tsx
+++ b/src/components/Steps/ActionButtons.tsx
@@ -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();
const { setDrafts } = useContext(DraftContext);
return (
-
{values.draftId !== undefined && (
}
+ component={RouterLink}
size="small"
color="error"
- sx={{ mr: 1 }}
+ to="/user/main"
onClick={() => {
localStorage.removeItem(values.draftId!);
setDrafts();
- history.push("/user/main");
}}
>
Discard campaign
)}
+ }
+ >
+ Return to dashboard
+
);
}
diff --git a/src/components/Steps/StepDrawer.tsx b/src/components/Steps/StepDrawer.tsx
index eb8712fe..c89c70fc 100644
--- a/src/components/Steps/StepDrawer.tsx
+++ b/src/components/Steps/StepDrawer.tsx
@@ -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: {
diff --git a/src/form/fragmentUtil.ts b/src/form/fragmentUtil.ts
new file mode 100644
index 00000000..b7db3465
--- /dev/null
+++ b/src/form/fragmentUtil.ts
@@ -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,
+ };
+}
diff --git a/src/graphql/ad-set.generated.tsx b/src/graphql/ad-set.generated.tsx
index c3d64ee9..e04e0f5b 100644
--- a/src/graphql/ad-set.generated.tsx
+++ b/src/graphql/ad-set.generated.tsx
@@ -1,9 +1,8 @@
import * as Types from "./types";
-import * as Apollo from "@apollo/client";
import { gql } from "@apollo/client";
import { CreativeFragmentDoc } from "./creative.generated";
-
+import * as Apollo from "@apollo/client";
const defaultOptions = {} as const;
export type AdSetFragment = {
__typename?: "AdSet";
@@ -15,6 +14,10 @@ export type AdSetFragment = {
perDay: number;
state: string;
execution: string;
+ keywords?: Array | null;
+ keywordSimilarity?: number | null;
+ negativeKeywords?: Array | null;
+ bannedKeywords?: Array | null;
segments?: Array<{
__typename?: "Segment";
code: string;
@@ -91,6 +94,10 @@ export type CreateAdSetMutation = {
perDay: number;
state: string;
execution: string;
+ keywords?: Array | null;
+ keywordSimilarity?: number | null;
+ negativeKeywords?: Array | null;
+ bannedKeywords?: Array | null;
segments?: Array<{
__typename?: "Segment";
code: string;
@@ -145,6 +152,10 @@ export type UpdateAdSetMutation = {
perDay: number;
state: string;
execution: string;
+ keywords?: Array | null;
+ keywordSimilarity?: number | null;
+ negativeKeywords?: Array | null;
+ bannedKeywords?: Array | null;
segments?: Array<{
__typename?: "Segment";
code: string;
@@ -214,6 +225,10 @@ export const AdSetFragmentDoc = gql`
perDay
state
execution
+ keywords
+ keywordSimilarity
+ negativeKeywords
+ bannedKeywords
segments {
code
name
diff --git a/src/graphql/ad-set.graphql b/src/graphql/ad-set.graphql
index 858e072d..4a6e6745 100644
--- a/src/graphql/ad-set.graphql
+++ b/src/graphql/ad-set.graphql
@@ -7,6 +7,10 @@ fragment AdSet on AdSet {
perDay
state
execution
+ keywords
+ keywordSimilarity
+ negativeKeywords
+ bannedKeywords
segments {
code
name
diff --git a/src/graphql/advertiser.generated.tsx b/src/graphql/advertiser.generated.tsx
index ca1215a3..82cde162 100644
--- a/src/graphql/advertiser.generated.tsx
+++ b/src/graphql/advertiser.generated.tsx
@@ -1,9 +1,8 @@
import * as Types from "./types";
-import * as Apollo from "@apollo/client";
import { gql } from "@apollo/client";
import { CampaignSummaryFragmentDoc } from "./campaign.generated";
-
+import * as Apollo from "@apollo/client";
const defaultOptions = {} as const;
export type AdvertiserSummaryFragment = {
__typename?: "Advertiser";
diff --git a/src/graphql/analytics-overview.generated.tsx b/src/graphql/analytics-overview.generated.tsx
index 8c4490ce..4fc01ff7 100644
--- a/src/graphql/analytics-overview.generated.tsx
+++ b/src/graphql/analytics-overview.generated.tsx
@@ -1,8 +1,7 @@
import * as Types from "./types";
-import * as Apollo from "@apollo/client";
import { gql } from "@apollo/client";
-
+import * as Apollo from "@apollo/client";
const defaultOptions = {} as const;
export type EngagementFragment = {
__typename?: "Engagement";
diff --git a/src/graphql/campaign.generated.tsx b/src/graphql/campaign.generated.tsx
index bb7443a4..ac8bba69 100644
--- a/src/graphql/campaign.generated.tsx
+++ b/src/graphql/campaign.generated.tsx
@@ -1,9 +1,8 @@
import * as Types from "./types";
-import * as Apollo from "@apollo/client";
import { gql } from "@apollo/client";
import { AdSetFragmentDoc } from "./ad-set.generated";
-
+import * as Apollo from "@apollo/client";
const defaultOptions = {} as const;
export type CampaignFragment = {
__typename?: "Campaign";
@@ -30,6 +29,12 @@ export type CampaignFragment = {
dayProportion?: number | null;
stripePaymentId?: string | null;
hasPaymentIntent?: boolean | null;
+ dayPartings?: Array<{
+ __typename?: "DayParting";
+ dow: string;
+ startMinute: number;
+ endMinute: number;
+ }> | null;
geoTargets?: Array<{
__typename?: "Geocode";
code: string;
@@ -45,6 +50,10 @@ export type CampaignFragment = {
perDay: number;
state: string;
execution: string;
+ keywords?: Array | null;
+ keywordSimilarity?: number | null;
+ negativeKeywords?: Array | null;
+ bannedKeywords?: Array | null;
segments?: Array<{
__typename?: "Segment";
code: string;
@@ -130,6 +139,10 @@ export type CampaignAdsFragment = {
perDay: number;
state: string;
execution: string;
+ keywords?: Array | null;
+ keywordSimilarity?: number | null;
+ negativeKeywords?: Array | null;
+ bannedKeywords?: Array | null;
segments?: Array<{
__typename?: "Segment";
code: string;
@@ -199,6 +212,12 @@ export type LoadCampaignQuery = {
dayProportion?: number | null;
stripePaymentId?: string | null;
hasPaymentIntent?: boolean | null;
+ dayPartings?: Array<{
+ __typename?: "DayParting";
+ dow: string;
+ startMinute: number;
+ endMinute: number;
+ }> | null;
geoTargets?: Array<{
__typename?: "Geocode";
code: string;
@@ -214,6 +233,10 @@ export type LoadCampaignQuery = {
perDay: number;
state: string;
execution: string;
+ keywords?: Array | null;
+ keywordSimilarity?: number | null;
+ negativeKeywords?: Array | null;
+ bannedKeywords?: Array | null;
segments?: Array<{
__typename?: "Segment";
code: string;
@@ -281,6 +304,10 @@ export type LoadCampaignAdsQuery = {
perDay: number;
state: string;
execution: string;
+ keywords?: Array | null;
+ keywordSimilarity?: number | null;
+ negativeKeywords?: Array | null;
+ bannedKeywords?: Array | null;
segments?: Array<{
__typename?: "Segment";
code: string;
@@ -373,6 +400,11 @@ export const CampaignFragmentDoc = gql`
stripePaymentId
paymentType
hasPaymentIntent
+ dayPartings {
+ dow
+ startMinute
+ endMinute
+ }
geoTargets {
code
name
diff --git a/src/graphql/campaign.graphql b/src/graphql/campaign.graphql
index 1ca98e5d..4d7331bf 100644
--- a/src/graphql/campaign.graphql
+++ b/src/graphql/campaign.graphql
@@ -23,6 +23,11 @@ fragment Campaign on Campaign {
stripePaymentId
paymentType
hasPaymentIntent
+ dayPartings {
+ dow
+ startMinute
+ endMinute
+ }
geoTargets {
code
name
diff --git a/src/graphql/common.generated.tsx b/src/graphql/common.generated.tsx
index 129af17d..49be7de1 100644
--- a/src/graphql/common.generated.tsx
+++ b/src/graphql/common.generated.tsx
@@ -1,8 +1,7 @@
import * as Types from "./types";
-import * as Apollo from "@apollo/client";
import { gql } from "@apollo/client";
-
+import * as Apollo from "@apollo/client";
const defaultOptions = {} as const;
export type GeocodeFragment = {
__typename?: "ActiveGeocodesEntry";
diff --git a/src/graphql/creative.generated.tsx b/src/graphql/creative.generated.tsx
index 2b2975b6..8a4a8126 100644
--- a/src/graphql/creative.generated.tsx
+++ b/src/graphql/creative.generated.tsx
@@ -1,8 +1,7 @@
import * as Types from "./types";
-import * as Apollo from "@apollo/client";
import { gql } from "@apollo/client";
-
+import * as Apollo from "@apollo/client";
const defaultOptions = {} as const;
export type CreativeFragment = {
__typename?: "Creative";
diff --git a/src/graphql/types.ts b/src/graphql/types.ts
index d1345779..810f71d6 100644
--- a/src/graphql/types.ts
+++ b/src/graphql/types.ts
@@ -108,7 +108,8 @@ export enum ConfirmationType {
}
export type CreateAdInput = {
- creativeId: Scalars["String"];
+ creative?: InputMaybe;
+ creativeId?: InputMaybe;
creativeSetId?: InputMaybe;
id?: InputMaybe;
/** The price in the owning campaign's currency for each single confirmation of the priceType specified. Note therefore that the caller is responsible for dividing cost-per-mille by 1000. */
@@ -148,6 +149,13 @@ export type CreateAddressInput = {
zipcode: Scalars["String"];
};
+export type CreateAdvertiserImageInput = {
+ advertiserId: Scalars["String"];
+ format: CampaignFormat;
+ imageUrl: Scalars["String"];
+ name: Scalars["String"];
+};
+
export type CreateAdvertiserInput = {
additionalBillingEmails?: InputMaybe>;
billingAddress: CreateAddressInput;
diff --git a/src/graphql/url.generated.tsx b/src/graphql/url.generated.tsx
index 5aea178d..52c4f0db 100644
--- a/src/graphql/url.generated.tsx
+++ b/src/graphql/url.generated.tsx
@@ -1,8 +1,7 @@
import * as Types from "./types";
-import * as Apollo from "@apollo/client";
import { gql } from "@apollo/client";
-
+import * as Apollo from "@apollo/client";
const defaultOptions = {} as const;
export type ValidateTargetUrlQueryVariables = Types.Exact<{
url: Types.Scalars["String"];
diff --git a/src/graphql/user.generated.tsx b/src/graphql/user.generated.tsx
index 67fb303f..6b6cb91c 100644
--- a/src/graphql/user.generated.tsx
+++ b/src/graphql/user.generated.tsx
@@ -1,8 +1,7 @@
import * as Types from "./types";
-import * as Apollo from "@apollo/client";
import { gql } from "@apollo/client";
-
+import * as Apollo from "@apollo/client";
const defaultOptions = {} as const;
export type UserFragment = {
__typename?: "User";
diff --git a/src/user/campaignList/CampaignList.tsx b/src/user/campaignList/CampaignList.tsx
index abf9b924..8066612f 100644
--- a/src/user/campaignList/CampaignList.tsx
+++ b/src/user/campaignList/CampaignList.tsx
@@ -1,11 +1,15 @@
import { useState } from "react";
-import { EnhancedTable, StandardRenderers } from "components/EnhancedTable";
-import { IconButton, Link, Stack, Tooltip } from "@mui/material";
+import {
+ ColumnDescriptor,
+ EnhancedTable,
+ StandardRenderers,
+} from "components/EnhancedTable";
+import { Checkbox, Link } from "@mui/material";
import {
campaignOnOffState,
renderMonetaryAmount,
} from "components/EnhancedTable/renderers";
-import { Link as RouterLink, useHistory } from "react-router-dom";
+import { Link as RouterLink } from "react-router-dom";
import { Status } from "components/Campaigns/Status";
import { isAfterEndDate } from "util/isAfterEndDate";
import { AdvertiserCampaignsFragment } from "graphql/advertiser.generated";
@@ -18,15 +22,20 @@ import {
import _ from "lodash";
import { uiTextForCampaignFormat } from "user/library";
import { CampaignSummaryFragment } from "graphql/campaign.generated";
-import { CampaignFormat, CampaignSource } from "graphql/types";
-import EditIcon from "@mui/icons-material/Edit";
interface Props {
advertiser?: AdvertiserCampaignsFragment | null;
fromDate: Date | null;
+ selectedCampaigns: string[];
+ onCampaignSelect: (c: string, insert: boolean) => void;
}
-export function CampaignList({ advertiser, fromDate }: Props) {
+export function CampaignList({
+ advertiser,
+ fromDate,
+ selectedCampaigns,
+ onCampaignSelect,
+}: Props) {
const [engagementData, setEngagementData] =
useState