From a2bc86eecec60cbc45172f353bad1613e7b6f676 Mon Sep 17 00:00:00 2001 From: Ian Krieger Date: Tue, 15 Aug 2023 16:51:27 -0400 Subject: [PATCH] feat: allow use of existing creatives --- src/components/Creatives/CreativeFields.tsx | 57 +++++ src/components/Creatives/CreativeList.tsx | 71 ++++++ src/components/Creatives/NewCreative.tsx | 117 ++++++++++ .../Creatives/NotificationFields.tsx | 26 +++ src/components/Drawer/MiniSideBar.tsx | 3 +- src/components/EnhancedTable/renderers.tsx | 39 +--- src/components/Navigation/Navbar.tsx | 40 ++-- .../Navigation/NewCampaignButton.tsx | 31 +++ .../Navigation/NewCreativeButton.tsx | 24 ++ src/graphql/ad-set.generated.tsx | 212 +++++++++++++----- src/graphql/ad-set.graphql | 6 - src/graphql/campaign.generated.tsx | 156 +++++++++++++ src/graphql/creative.generated.tsx | 178 +++++++++++++++ src/graphql/creative.graphql | 47 ++++ src/user/User.tsx | 6 + src/user/campaignList/CampaignList.tsx | 1 - src/validation/CampaignSchema.tsx | 11 +- src/validation/CreativeSchema.test.ts | 65 ++++++ src/validation/CreativeSchema.tsx | 48 ++++ src/validation/regex.ts | 4 + 20 files changed, 1013 insertions(+), 129 deletions(-) create mode 100644 src/components/Creatives/CreativeFields.tsx create mode 100644 src/components/Creatives/CreativeList.tsx create mode 100644 src/components/Creatives/NewCreative.tsx create mode 100644 src/components/Creatives/NotificationFields.tsx create mode 100644 src/components/Navigation/NewCampaignButton.tsx create mode 100644 src/components/Navigation/NewCreativeButton.tsx create mode 100644 src/validation/CreativeSchema.test.ts create mode 100644 src/validation/CreativeSchema.tsx create mode 100644 src/validation/regex.ts diff --git a/src/components/Creatives/CreativeFields.tsx b/src/components/Creatives/CreativeFields.tsx new file mode 100644 index 000000000..5091ff7fc --- /dev/null +++ b/src/components/Creatives/CreativeFields.tsx @@ -0,0 +1,57 @@ +import { + FormControl, + Box, + FormLabel, + FormControlLabel, + Radio, +} from "@mui/material"; +import { useFormikContext } from "formik"; +import { CreativeInput } from "graphql/types"; +import { NotificationFields } from "./NotificationFields"; +import { FormikRadioGroup, FormikTextField } from "form/FormikHelpers"; + +interface Props { + allowTypeChange: boolean; +} + +export function CreativeFields({ allowTypeChange }: Props) { + const formik = useFormikContext(); + const creativeType = formik.values.type?.code; + + return ( + <> + + + + + + Creative Type + + + } + label="Push Notification" + /> + + + + + + + ); +} + +const CreativeTypeSpecificFields = ({ + creativeType, +}: { + creativeType?: string; +}) => { + if (creativeType === "notification_all_v1") return ; + + return null; +}; diff --git a/src/components/Creatives/CreativeList.tsx b/src/components/Creatives/CreativeList.tsx new file mode 100644 index 000000000..f71dc66b4 --- /dev/null +++ b/src/components/Creatives/CreativeList.tsx @@ -0,0 +1,71 @@ +import { EnhancedTable, StandardRenderers } from "components/EnhancedTable"; +import { useAdvertiserCreativesQuery } from "graphql/creative.generated"; +import { uiTextForCreativeTypeCode } from "user/library"; +import { CardContainer } from "components/Card/CardContainer"; +import { useAdvertiser } from "auth/hooks/queries/useAdvertiser"; +import { ErrorDetail } from "components/Error/ErrorDetail"; +import MiniSideBar from "components/Drawer/MiniSideBar"; + +export function CreativeList() { + const { advertiser } = useAdvertiser(); + const { data, error } = useAdvertiserCreativesQuery({ + variables: { + advertiserId: advertiser.id, + }, + }); + + if (error) + return ( + + ); + + return ( + + + c.modifiedAt, + renderer: StandardRenderers.date, + }, + { + title: "Name", + value: (c) => c.name, + }, + { + title: "Type", + value: (c) => uiTextForCreativeTypeCode(c.type), + }, + { + title: "Title", + value: (c) => + c.payloadInlineContent?.title ?? + c.payloadNotification?.title ?? + c.payloadSearch?.title ?? + c.payloadSearchHomepage?.title, + }, + { + title: "Body", + value: (c) => + c.payloadInlineContent?.ctaText ?? + c.payloadNotification?.body ?? + c.payloadSearch?.body ?? + c.payloadSearchHomepage?.body, + }, + ]} + /> + + + ); +} diff --git a/src/components/Creatives/NewCreative.tsx b/src/components/Creatives/NewCreative.tsx new file mode 100644 index 000000000..06b0960ec --- /dev/null +++ b/src/components/Creatives/NewCreative.tsx @@ -0,0 +1,117 @@ +import { Box, Snackbar } from "@mui/material"; +import { Form, Formik } from "formik"; +import { useHistory, useLocation, useParams } from "react-router-dom"; +import { CreativeInput } from "graphql/types"; +import { CreativeFields } from "./CreativeFields"; +import { + AdvertiserCreativesDocument, + useCreateCreativeMutation, +} from "graphql/creative.generated"; +import { useState } from "react"; +import { CardContainer } from "components/Card/CardContainer"; +import { ErrorDetail } from "components/Error/ErrorDetail"; +import { FormikSubmitButton } from "form/FormikHelpers"; +import { CreativeSchema } from "validation/CreativeSchema"; + +interface Params { + advertiserId: string; +} + +function wait(ms: number) { + return new Promise((resolve) => window.setTimeout(resolve, ms)); +} + +export const NewCreative: React.FC = () => { + const params = useParams(); + const history = useHistory(); + const location = useLocation(); + + const defaultValue: CreativeInput & { targetUrlValid: boolean } = { + advertiserId: params.advertiserId, + state: "active", + name: "", + type: { + code: "", + name: "", + }, + targetUrlValid: false, + payloadNotification: { + body: "", + targetUrl: "", + title: "", + }, + startAt: null, + endAt: null, + }; + + const initialValue = location.state ?? defaultValue; + + const [createCreativeMutation, { error }] = useCreateCreativeMutation({ + refetchQueries: [ + { + query: AdvertiserCreativesDocument, + variables: { advertiserId: params.advertiserId }, + }, + ], + }); + + const [id, setId] = useState(""); + + const doSubmit = async (values: CreativeInput) => { + const input: CreativeInput = { + advertiserId: values.advertiserId, + name: values.name, + payloadNotification: values.payloadNotification, + startAt: values.startAt, + endAt: values.endAt, + state: values.state, + type: values.type, + }; + + const response = await createCreativeMutation({ + variables: { input }, + }); + const id = response.data?.createCreative.id; + if (id) { + setId(id); + await wait(2000); + history.replace(id); + } + }; + + return ( + + + +
+ + + + + + + + +
+ + +
+
+ ); +}; diff --git a/src/components/Creatives/NotificationFields.tsx b/src/components/Creatives/NotificationFields.tsx new file mode 100644 index 000000000..10d8fc90a --- /dev/null +++ b/src/components/Creatives/NotificationFields.tsx @@ -0,0 +1,26 @@ +import { FormikTextField } from "form/FormikHelpers"; +import { UrlResolver } from "components/Url/UrlResolver"; + +export function NotificationFields() { + return ( + <> + + + + + + ); +} diff --git a/src/components/Drawer/MiniSideBar.tsx b/src/components/Drawer/MiniSideBar.tsx index 01335fc12..53e84d29e 100644 --- a/src/components/Drawer/MiniSideBar.tsx +++ b/src/components/Drawer/MiniSideBar.tsx @@ -43,7 +43,6 @@ export default function MiniSideBar({ children }: PropsWithChildren) { /> ), }, - // Possible future enhancements, not visible to user but help keep spacing { label: "Creatives", href: "/user/main/creatives", @@ -53,8 +52,8 @@ export default function MiniSideBar({ children }: PropsWithChildren) { sx={{ color: "text.secondary" }} /> ), - disabled: true, }, + // Possible future enhancements, not visible to user but help keep spacing { label: "Assets", href: "/user/main/assets", diff --git a/src/components/EnhancedTable/renderers.tsx b/src/components/EnhancedTable/renderers.tsx index cb10af4c6..82c1eff45 100644 --- a/src/components/EnhancedTable/renderers.tsx +++ b/src/components/EnhancedTable/renderers.tsx @@ -11,12 +11,8 @@ import { useUpdateCampaignMutation, } from "graphql/campaign.generated"; import { AdvertiserCampaignsDocument } from "graphql/advertiser.generated"; -import { - useUpdateAdMutation, - useUpdateAdSetMutation, -} from "graphql/ad-set.generated"; +import { useUpdateAdSetMutation } from "graphql/ad-set.generated"; import { OnOff } from "../Switch/OnOff"; -import { AdDetails } from "user/ads/AdList"; import { displayFromCampaignState } from "util/displayState"; import { AdSetDetails } from "user/adSet/AdSetList"; @@ -163,36 +159,3 @@ export function adSetOnOffState(c: AdSetDetails): ReactNode { /> ); } - -export function adOnOffState(c: AdDetails): ReactNode { - const [updateAd, { loading }] = useUpdateAdMutation({ - refetchQueries: [ - { - query: AdvertiserCampaignsDocument, - variables: { id: c.campaignId }, - }, - ], - }); - - return ( - { - { - updateAd({ - variables: { - updateAdInput: { - id: c.id, - state: s, - }, - }, - }); - } - }} - loading={loading} - state={c.state} - end={c.campaignEnd} - source={c.campaignSource} - type="Ad" - /> - ); -} diff --git a/src/components/Navigation/Navbar.tsx b/src/components/Navigation/Navbar.tsx index b068b0304..e0efa6fc1 100644 --- a/src/components/Navigation/Navbar.tsx +++ b/src/components/Navigation/Navbar.tsx @@ -1,23 +1,28 @@ -import { useHistory, useRouteMatch } from "react-router-dom"; - import { AppBar, Button, Divider, Stack, Toolbar } from "@mui/material"; import { DraftMenu } from "components/Navigation/DraftMenu"; -import moment from "moment"; import ads from "../../../branding.svg"; import { useAdvertiser } from "auth/hooks/queries/useAdvertiser"; import { useSignOut } from "auth/hooks/mutations/useSignOut"; +import { useHistory } from "react-router-dom"; +import { NewCampaignButton } from "components/Navigation/NewCampaignButton"; +import { NewCreativeButton } from "components/Navigation/NewCreativeButton"; export function Navbar() { + const history = useHistory(); const { signOut } = useSignOut(); const { advertiser } = useAdvertiser(); - const history = useHistory(); - const { url } = useRouteMatch(); - const isNewCampaignPage = url.includes("/user/main/adsmanager/advanced"); - const isCompletePage = url.includes("/user/main/complete/new"); - const newUrl = `/user/main/adsmanager/advanced/new/${moment() - .utc() - .valueOf()}/settings`; + + const routeButtons = [ + { + component: , + route: "/user/main/campaign", + }, + { + component: , + route: "/user/main/creatives", + }, + ]; return ( }
- {advertiser.selfServiceCreate && ( - - )} + { + routeButtons.find((r) => history.location.pathname.includes(r.route)) + ?.component + } diff --git a/src/components/Navigation/NewCampaignButton.tsx b/src/components/Navigation/NewCampaignButton.tsx new file mode 100644 index 000000000..936746084 --- /dev/null +++ b/src/components/Navigation/NewCampaignButton.tsx @@ -0,0 +1,31 @@ +import { useAdvertiser } from "auth/hooks/queries/useAdvertiser"; +import { Button } from "@mui/material"; +import moment from "moment/moment"; +import { Link as RouterLink, useRouteMatch } from "react-router-dom"; + +export function NewCampaignButton() { + const { url } = useRouteMatch(); + const { advertiser } = useAdvertiser(); + const isCompletePage = url.includes("/user/main/complete/new"); + const isNewCampaignPage = url.includes("/user/main/adsmanager/advanced"); + const newUrl = `/user/main/adsmanager/advanced/new/${moment() + .utc() + .valueOf()}/settings`; + + if (!advertiser.selfServiceCreate) { + return null; + } + + return ( + + ); +} diff --git a/src/components/Navigation/NewCreativeButton.tsx b/src/components/Navigation/NewCreativeButton.tsx new file mode 100644 index 000000000..12521b390 --- /dev/null +++ b/src/components/Navigation/NewCreativeButton.tsx @@ -0,0 +1,24 @@ +import { useAdvertiser } from "auth/hooks/queries/useAdvertiser"; +import { Button } from "@mui/material"; +import { Link as RouterLink } from "react-router-dom"; + +export function NewCreativeButton() { + const { advertiser } = useAdvertiser(); + + if (!advertiser.selfServiceCreate) { + return null; + } + + return ( + + ); +} diff --git a/src/graphql/ad-set.generated.tsx b/src/graphql/ad-set.generated.tsx index e04e0f5b3..6a95a7d0c 100644 --- a/src/graphql/ad-set.generated.tsx +++ b/src/graphql/ad-set.generated.tsx @@ -51,6 +51,45 @@ export type AdSetFragment = { title: string; targetUrl: string; } | null; + payloadNewTabPage?: { + __typename?: "NewTabPagePayload"; + logo?: { + __typename?: "Logo"; + imageUrl: string; + alt: string; + companyName: string; + destinationUrl: string; + } | null; + wallpapers?: Array<{ + __typename?: "Wallpaper"; + imageUrl: string; + focalPoint: { __typename?: "FocalPoint"; x: number; y: number }; + }> | null; + } | null; + payloadInlineContent?: { + __typename?: "InlineContentPayload"; + title: string; + ctaText: string; + imageUrl: string; + targetUrl: string; + dimensions: string; + description: string; + } | null; + payloadSearch?: { + __typename?: "SearchPayload"; + body: string; + title: string; + targetUrl: string; + } | null; + payloadSearchHomepage?: { + __typename?: "SearchHomepagePayload"; + body: string; + imageUrl: string; + imageDarkModeUrl?: string | null; + targetUrl: string; + title: string; + ctaText: string; + } | null; }; }> | null; }; @@ -75,6 +114,45 @@ export type AdFragment = { title: string; targetUrl: string; } | null; + payloadNewTabPage?: { + __typename?: "NewTabPagePayload"; + logo?: { + __typename?: "Logo"; + imageUrl: string; + alt: string; + companyName: string; + destinationUrl: string; + } | null; + wallpapers?: Array<{ + __typename?: "Wallpaper"; + imageUrl: string; + focalPoint: { __typename?: "FocalPoint"; x: number; y: number }; + }> | null; + } | null; + payloadInlineContent?: { + __typename?: "InlineContentPayload"; + title: string; + ctaText: string; + imageUrl: string; + targetUrl: string; + dimensions: string; + description: string; + } | null; + payloadSearch?: { + __typename?: "SearchPayload"; + body: string; + title: string; + targetUrl: string; + } | null; + payloadSearchHomepage?: { + __typename?: "SearchHomepagePayload"; + body: string; + imageUrl: string; + imageDarkModeUrl?: string | null; + targetUrl: string; + title: string; + ctaText: string; + } | null; }; }; @@ -131,6 +209,45 @@ export type CreateAdSetMutation = { title: string; targetUrl: string; } | null; + payloadNewTabPage?: { + __typename?: "NewTabPagePayload"; + logo?: { + __typename?: "Logo"; + imageUrl: string; + alt: string; + companyName: string; + destinationUrl: string; + } | null; + wallpapers?: Array<{ + __typename?: "Wallpaper"; + imageUrl: string; + focalPoint: { __typename?: "FocalPoint"; x: number; y: number }; + }> | null; + } | null; + payloadInlineContent?: { + __typename?: "InlineContentPayload"; + title: string; + ctaText: string; + imageUrl: string; + targetUrl: string; + dimensions: string; + description: string; + } | null; + payloadSearch?: { + __typename?: "SearchPayload"; + body: string; + title: string; + targetUrl: string; + } | null; + payloadSearchHomepage?: { + __typename?: "SearchHomepagePayload"; + body: string; + imageUrl: string; + imageDarkModeUrl?: string | null; + targetUrl: string; + title: string; + ctaText: string; + } | null; }; }> | null; }; @@ -189,20 +306,50 @@ export type UpdateAdSetMutation = { title: string; targetUrl: string; } | null; + payloadNewTabPage?: { + __typename?: "NewTabPagePayload"; + logo?: { + __typename?: "Logo"; + imageUrl: string; + alt: string; + companyName: string; + destinationUrl: string; + } | null; + wallpapers?: Array<{ + __typename?: "Wallpaper"; + imageUrl: string; + focalPoint: { __typename?: "FocalPoint"; x: number; y: number }; + }> | null; + } | null; + payloadInlineContent?: { + __typename?: "InlineContentPayload"; + title: string; + ctaText: string; + imageUrl: string; + targetUrl: string; + dimensions: string; + description: string; + } | null; + payloadSearch?: { + __typename?: "SearchPayload"; + body: string; + title: string; + targetUrl: string; + } | null; + payloadSearchHomepage?: { + __typename?: "SearchHomepagePayload"; + body: string; + imageUrl: string; + imageDarkModeUrl?: string | null; + targetUrl: string; + title: string; + ctaText: string; + } | null; }; }> | null; }; }; -export type UpdateAdMutationVariables = Types.Exact<{ - updateAdInput: Types.UpdateAdInput; -}>; - -export type UpdateAdMutation = { - __typename?: "Mutation"; - updateCreativeInstanceState: { __typename?: "Ad"; id: string }; -}; - export const AdFragmentDoc = gql` fragment Ad on Ad { id @@ -351,50 +498,3 @@ export type UpdateAdSetMutationOptions = Apollo.BaseMutationOptions< UpdateAdSetMutation, UpdateAdSetMutationVariables >; -export const UpdateAdDocument = gql` - mutation updateAd($updateAdInput: UpdateAdInput!) { - updateCreativeInstanceState(updateAdInput: $updateAdInput) { - id - } - } -`; -export type UpdateAdMutationFn = Apollo.MutationFunction< - UpdateAdMutation, - UpdateAdMutationVariables ->; - -/** - * __useUpdateAdMutation__ - * - * To run a mutation, you first call `useUpdateAdMutation` within a React component and pass it any options that fit your needs. - * When your component renders, `useUpdateAdMutation` returns a tuple that includes: - * - A mutate function that you can call at any time to execute the mutation - * - An object with fields that represent the current status of the mutation's execution - * - * @param baseOptions options that will be passed into the mutation, supported options are listed on: https://www.apollographql.com/docs/react/api/react-hooks/#options-2; - * - * @example - * const [updateAdMutation, { data, loading, error }] = useUpdateAdMutation({ - * variables: { - * updateAdInput: // value for 'updateAdInput' - * }, - * }); - */ -export function useUpdateAdMutation( - baseOptions?: Apollo.MutationHookOptions< - UpdateAdMutation, - UpdateAdMutationVariables - >, -) { - const options = { ...defaultOptions, ...baseOptions }; - return Apollo.useMutation( - UpdateAdDocument, - options, - ); -} -export type UpdateAdMutationHookResult = ReturnType; -export type UpdateAdMutationResult = Apollo.MutationResult; -export type UpdateAdMutationOptions = Apollo.BaseMutationOptions< - UpdateAdMutation, - UpdateAdMutationVariables ->; diff --git a/src/graphql/ad-set.graphql b/src/graphql/ad-set.graphql index 4a6e6745e..081439530 100644 --- a/src/graphql/ad-set.graphql +++ b/src/graphql/ad-set.graphql @@ -51,9 +51,3 @@ mutation updateAdSet($updateAdSetInput: UpdateAdSetInput!) { ...AdSet } } - -mutation updateAd($updateAdInput: UpdateAdInput!) { - updateCreativeInstanceState(updateAdInput: $updateAdInput) { - id - } -} diff --git a/src/graphql/campaign.generated.tsx b/src/graphql/campaign.generated.tsx index ac8bba696..d63848dcf 100644 --- a/src/graphql/campaign.generated.tsx +++ b/src/graphql/campaign.generated.tsx @@ -87,6 +87,45 @@ export type CampaignFragment = { title: string; targetUrl: string; } | null; + payloadNewTabPage?: { + __typename?: "NewTabPagePayload"; + logo?: { + __typename?: "Logo"; + imageUrl: string; + alt: string; + companyName: string; + destinationUrl: string; + } | null; + wallpapers?: Array<{ + __typename?: "Wallpaper"; + imageUrl: string; + focalPoint: { __typename?: "FocalPoint"; x: number; y: number }; + }> | null; + } | null; + payloadInlineContent?: { + __typename?: "InlineContentPayload"; + title: string; + ctaText: string; + imageUrl: string; + targetUrl: string; + dimensions: string; + description: string; + } | null; + payloadSearch?: { + __typename?: "SearchPayload"; + body: string; + title: string; + targetUrl: string; + } | null; + payloadSearchHomepage?: { + __typename?: "SearchHomepagePayload"; + body: string; + imageUrl: string; + imageDarkModeUrl?: string | null; + targetUrl: string; + title: string; + ctaText: string; + } | null; }; }> | null; }>; @@ -176,6 +215,45 @@ export type CampaignAdsFragment = { title: string; targetUrl: string; } | null; + payloadNewTabPage?: { + __typename?: "NewTabPagePayload"; + logo?: { + __typename?: "Logo"; + imageUrl: string; + alt: string; + companyName: string; + destinationUrl: string; + } | null; + wallpapers?: Array<{ + __typename?: "Wallpaper"; + imageUrl: string; + focalPoint: { __typename?: "FocalPoint"; x: number; y: number }; + }> | null; + } | null; + payloadInlineContent?: { + __typename?: "InlineContentPayload"; + title: string; + ctaText: string; + imageUrl: string; + targetUrl: string; + dimensions: string; + description: string; + } | null; + payloadSearch?: { + __typename?: "SearchPayload"; + body: string; + title: string; + targetUrl: string; + } | null; + payloadSearchHomepage?: { + __typename?: "SearchHomepagePayload"; + body: string; + imageUrl: string; + imageDarkModeUrl?: string | null; + targetUrl: string; + title: string; + ctaText: string; + } | null; }; }> | null; }>; @@ -270,6 +348,45 @@ export type LoadCampaignQuery = { title: string; targetUrl: string; } | null; + payloadNewTabPage?: { + __typename?: "NewTabPagePayload"; + logo?: { + __typename?: "Logo"; + imageUrl: string; + alt: string; + companyName: string; + destinationUrl: string; + } | null; + wallpapers?: Array<{ + __typename?: "Wallpaper"; + imageUrl: string; + focalPoint: { __typename?: "FocalPoint"; x: number; y: number }; + }> | null; + } | null; + payloadInlineContent?: { + __typename?: "InlineContentPayload"; + title: string; + ctaText: string; + imageUrl: string; + targetUrl: string; + dimensions: string; + description: string; + } | null; + payloadSearch?: { + __typename?: "SearchPayload"; + body: string; + title: string; + targetUrl: string; + } | null; + payloadSearchHomepage?: { + __typename?: "SearchHomepagePayload"; + body: string; + imageUrl: string; + imageDarkModeUrl?: string | null; + targetUrl: string; + title: string; + ctaText: string; + } | null; }; }> | null; }>; @@ -341,6 +458,45 @@ export type LoadCampaignAdsQuery = { title: string; targetUrl: string; } | null; + payloadNewTabPage?: { + __typename?: "NewTabPagePayload"; + logo?: { + __typename?: "Logo"; + imageUrl: string; + alt: string; + companyName: string; + destinationUrl: string; + } | null; + wallpapers?: Array<{ + __typename?: "Wallpaper"; + imageUrl: string; + focalPoint: { __typename?: "FocalPoint"; x: number; y: number }; + }> | null; + } | null; + payloadInlineContent?: { + __typename?: "InlineContentPayload"; + title: string; + ctaText: string; + imageUrl: string; + targetUrl: string; + dimensions: string; + description: string; + } | null; + payloadSearch?: { + __typename?: "SearchPayload"; + body: string; + title: string; + targetUrl: string; + } | null; + payloadSearchHomepage?: { + __typename?: "SearchHomepagePayload"; + body: string; + imageUrl: string; + imageDarkModeUrl?: string | null; + targetUrl: string; + title: string; + ctaText: string; + } | null; }; }> | null; }>; diff --git a/src/graphql/creative.generated.tsx b/src/graphql/creative.generated.tsx index 8a4a81268..3594e8700 100644 --- a/src/graphql/creative.generated.tsx +++ b/src/graphql/creative.generated.tsx @@ -17,6 +17,45 @@ export type CreativeFragment = { title: string; targetUrl: string; } | null; + payloadNewTabPage?: { + __typename?: "NewTabPagePayload"; + logo?: { + __typename?: "Logo"; + imageUrl: string; + alt: string; + companyName: string; + destinationUrl: string; + } | null; + wallpapers?: Array<{ + __typename?: "Wallpaper"; + imageUrl: string; + focalPoint: { __typename?: "FocalPoint"; x: number; y: number }; + }> | null; + } | null; + payloadInlineContent?: { + __typename?: "InlineContentPayload"; + title: string; + ctaText: string; + imageUrl: string; + targetUrl: string; + dimensions: string; + description: string; + } | null; + payloadSearch?: { + __typename?: "SearchPayload"; + body: string; + title: string; + targetUrl: string; + } | null; + payloadSearchHomepage?: { + __typename?: "SearchHomepagePayload"; + body: string; + imageUrl: string; + imageDarkModeUrl?: string | null; + targetUrl: string; + title: string; + ctaText: string; + } | null; }; export type AdvertiserCreativesQueryVariables = Types.Exact<{ @@ -42,6 +81,45 @@ export type AdvertiserCreativesQuery = { title: string; targetUrl: string; } | null; + payloadNewTabPage?: { + __typename?: "NewTabPagePayload"; + logo?: { + __typename?: "Logo"; + imageUrl: string; + alt: string; + companyName: string; + destinationUrl: string; + } | null; + wallpapers?: Array<{ + __typename?: "Wallpaper"; + imageUrl: string; + focalPoint: { __typename?: "FocalPoint"; x: number; y: number }; + }> | null; + } | null; + payloadInlineContent?: { + __typename?: "InlineContentPayload"; + title: string; + ctaText: string; + imageUrl: string; + targetUrl: string; + dimensions: string; + description: string; + } | null; + payloadSearch?: { + __typename?: "SearchPayload"; + body: string; + title: string; + targetUrl: string; + } | null; + payloadSearchHomepage?: { + __typename?: "SearchHomepagePayload"; + body: string; + imageUrl: string; + imageDarkModeUrl?: string | null; + targetUrl: string; + title: string; + ctaText: string; + } | null; }>; } | null; }; @@ -73,6 +151,15 @@ export type UpdateNotificationCreativeMutation = { updateNotificationCreative: { __typename?: "Creative"; id: string }; }; +export type CreateCreativeMutationVariables = Types.Exact<{ + input: Types.CreativeInput; +}>; + +export type CreateCreativeMutation = { + __typename?: "Mutation"; + createCreative: { __typename?: "Creative"; id: string }; +}; + export const CreativeFragmentDoc = gql` fragment Creative on Creative { id @@ -88,6 +175,47 @@ export const CreativeFragmentDoc = gql` title targetUrl } + payloadNewTabPage { + logo { + imageUrl + alt + companyName + destinationUrl + } + wallpapers { + imageUrl + focalPoint { + x + y + } + } + } + payloadInlineContent { + title + ctaText + imageUrl + targetUrl + dimensions + description + } + payloadNotification { + body + title + targetUrl + } + payloadSearch { + body + title + targetUrl + } + payloadSearchHomepage { + body + imageUrl + imageDarkModeUrl + targetUrl + title + ctaText + } } `; export const AdvertiserCreativesDocument = gql` @@ -268,3 +396,53 @@ export type UpdateNotificationCreativeMutationOptions = UpdateNotificationCreativeMutation, UpdateNotificationCreativeMutationVariables >; +export const CreateCreativeDocument = gql` + mutation createCreative($input: CreativeInput!) { + createCreative(creative: $input) { + id + } + } +`; +export type CreateCreativeMutationFn = Apollo.MutationFunction< + CreateCreativeMutation, + CreateCreativeMutationVariables +>; + +/** + * __useCreateCreativeMutation__ + * + * To run a mutation, you first call `useCreateCreativeMutation` within a React component and pass it any options that fit your needs. + * When your component renders, `useCreateCreativeMutation` returns a tuple that includes: + * - A mutate function that you can call at any time to execute the mutation + * - An object with fields that represent the current status of the mutation's execution + * + * @param baseOptions options that will be passed into the mutation, supported options are listed on: https://www.apollographql.com/docs/react/api/react-hooks/#options-2; + * + * @example + * const [createCreativeMutation, { data, loading, error }] = useCreateCreativeMutation({ + * variables: { + * input: // value for 'input' + * }, + * }); + */ +export function useCreateCreativeMutation( + baseOptions?: Apollo.MutationHookOptions< + CreateCreativeMutation, + CreateCreativeMutationVariables + >, +) { + const options = { ...defaultOptions, ...baseOptions }; + return Apollo.useMutation< + CreateCreativeMutation, + CreateCreativeMutationVariables + >(CreateCreativeDocument, options); +} +export type CreateCreativeMutationHookResult = ReturnType< + typeof useCreateCreativeMutation +>; +export type CreateCreativeMutationResult = + Apollo.MutationResult; +export type CreateCreativeMutationOptions = Apollo.BaseMutationOptions< + CreateCreativeMutation, + CreateCreativeMutationVariables +>; diff --git a/src/graphql/creative.graphql b/src/graphql/creative.graphql index 80ad3102d..94f8017a9 100644 --- a/src/graphql/creative.graphql +++ b/src/graphql/creative.graphql @@ -12,6 +12,47 @@ fragment Creative on Creative { title targetUrl } + payloadNewTabPage { + logo { + imageUrl + alt + companyName + destinationUrl + } + wallpapers { + imageUrl + focalPoint { + x + y + } + } + } + payloadInlineContent { + title + ctaText + imageUrl + targetUrl + dimensions + description + } + payloadNotification { + body + title + targetUrl + } + payloadSearch { + body + title + targetUrl + } + payloadSearchHomepage { + body + imageUrl + imageDarkModeUrl + targetUrl + title + ctaText + } } query advertiserCreatives($advertiserId: String!) { @@ -39,3 +80,9 @@ mutation updateNotificationCreative($input: UpdateNotificationCreativeInput!) { id } } + +mutation createCreative($input: CreativeInput!) { + createCreative(creative: $input) { + id + } +} diff --git a/src/user/User.tsx b/src/user/User.tsx index 2b4e7c646..e186147c2 100644 --- a/src/user/User.tsx +++ b/src/user/User.tsx @@ -18,6 +18,7 @@ import { Navbar } from "components/Navigation/Navbar"; import { CampaignView } from "user/views/user/CampaignView"; import { CampaignReportView } from "user/views/user/CampaignReportView"; import { Profile } from "user/views/user/Profile"; +import { CreativeList } from "components/Creatives/CreativeList"; const buildApolloClient = () => { const httpLink = createHttpLink({ @@ -72,6 +73,11 @@ export function User() { + + void; } const CampaignCheckBox = (props: CheckBoxProps) => { - console.log(props.selectedCampaigns); const campaignSelected = props.selectedCampaigns.some( (c) => c === props.campaign.id, ); diff --git a/src/validation/CampaignSchema.tsx b/src/validation/CampaignSchema.tsx index 33f2fbb4b..953a3486e 100644 --- a/src/validation/CampaignSchema.tsx +++ b/src/validation/CampaignSchema.tsx @@ -2,11 +2,12 @@ import { array, boolean, date, number, object, ref, string } from "yup"; import { startOfDay } from "date-fns"; import { twoDaysOut } from "form/DateFieldHelpers"; import _ from "lodash"; - -export const SimpleUrlRegexp = /https:\/\/.+\.[a-zA-Z]{2,}\/?.*/g; -const NoSpacesRegex = /^\S*$/; -const TrailingAsteriskRegex = /.*\*$/; -const HttpsRegex = /^https:\/\//; +import { + HttpsRegex, + NoSpacesRegex, + SimpleUrlRegexp, + TrailingAsteriskRegex, +} from "validation/regex"; export const MIN_PER_DAY = 33; export const MIN_PER_CAMPAIGN = 100; diff --git a/src/validation/CreativeSchema.test.ts b/src/validation/CreativeSchema.test.ts new file mode 100644 index 000000000..b98250766 --- /dev/null +++ b/src/validation/CreativeSchema.test.ts @@ -0,0 +1,65 @@ +import { CreativeSchema } from "./CreativeSchema"; +import { produce } from "immer"; + +const validPushCreative = { + name: "some creative", + type: { code: "notification_all_v1", name: "" }, + state: "active", + payloadNotification: { + body: "abc", + title: "xyz", + targetUrl: "https://hello.com", + }, +}; + +it("should pass on a valid object", () => { + CreativeSchema.validateSync(validPushCreative); +}); + +it.each([ + "https://example.com", + "https://www.secure2.sophos.com/en-us/security-news-trends/whitepapers/gated-wp/endpoint-buyers-guide.aspx?cmp=134766&utm_source=Brave&utm_campaign=ASEAN%7CBrave%7CEndpointBuyer%27sGuide%7CITFocus&utm_medium=cpc&utm_content=SM116529", + "https://test.io?bar=baz#foo", +])("should pass if push notification is selected for %s", (value) => { + const c = produce(validPushCreative, (draft) => { + draft.payloadNotification.targetUrl = value; + }); + + expect(() => CreativeSchema.validateSync(c)); +}); + +it.each(["notAUrl", "gopher://blah.com", "httpx://balh.com"])( + "should reject as invalid url if push notification is selected for %s", + (value) => { + const c = produce(validPushCreative, (draft) => { + draft.payloadNotification.targetUrl = value; + }); + expect(() => CreativeSchema.validateSync(c)).toThrowError( + "URL must start with https:// or be an approved brave:// url", + ); + }, +); + +it.each(["https://with a space"])( + "should reject as invalid input if push notification is selected for %s", + (value) => { + const c = produce(validPushCreative, (draft) => { + draft.payloadNotification.targetUrl = value; + }); + expect(() => CreativeSchema.validateSync(c)).toThrowError( + "URL must not contain any whitespace", + ); + }, +); + +it.each(["http://example.com"])( + "should reject as not secure if push notification is selected for %s", + (value) => { + const c = produce(validPushCreative, (draft) => { + draft.payloadNotification.targetUrl = value; + }); + expect(() => CreativeSchema.validateSync(c)).toThrowError( + "URL must start with https://", + ); + }, +); diff --git a/src/validation/CreativeSchema.tsx b/src/validation/CreativeSchema.tsx new file mode 100644 index 000000000..1abbbc113 --- /dev/null +++ b/src/validation/CreativeSchema.tsx @@ -0,0 +1,48 @@ +import { object, string } from "yup"; +import { HttpsRegex, NoSpacesRegex, SimpleUrlRegexp } from "validation/regex"; +import _ from "lodash"; + +export const CreativeSchema = object().shape({ + name: string().label("Creative Name").required(), + type: object().shape({ + code: string() + .oneOf([ + "notification_all_v1", + "new_tab_page_all_v1", + "inline_content_all_v1", + "search_all_v1", + "search_homepage_all_v1", + ]) + .label("Creative Type") + .required("Creative Type is required"), + name: string(), + }), + state: string() + .oneOf(["draft", "under_review"]) + .label("State") + .required() + .default("draft"), + targetUrlValid: string().test({ + test: (value) => _.isEmpty(value), + message: ({ value }) => value, + }), + payloadNotification: object() + .nullable() + .when("type.code", { + is: "notification_all_v1", + then: (schema) => + schema.required().shape({ + body: string().label("Body").required().max(60), + targetUrl: string() + .label("Target Url") + .required("URL is required") + .matches(NoSpacesRegex, `URL must not contain any whitespace`) + .matches(HttpsRegex, `URL must start with https://`) + .matches( + SimpleUrlRegexp, + `Please enter a valid Ad URL, for example https://brave.com`, + ), + title: string().label("Title").required().max(30), + }), + }), +}); diff --git a/src/validation/regex.ts b/src/validation/regex.ts new file mode 100644 index 000000000..4d1713325 --- /dev/null +++ b/src/validation/regex.ts @@ -0,0 +1,4 @@ +export const SimpleUrlRegexp = /https:\/\/.+\.[a-zA-Z]{2,}\/?.*/g; +export const NoSpacesRegex = /^\S*$/; +export const TrailingAsteriskRegex = /.*\*$/; +export const HttpsRegex = /^https:\/\//;