diff --git a/package-lock.json b/package-lock.json index 186ee243..ae33dd91 100644 --- a/package-lock.json +++ b/package-lock.json @@ -17,6 +17,7 @@ "@mui/icons-material": "5.14.9", "@mui/lab": "5.0.0-alpha.145", "@mui/material": "5.14.10", + "@mui/x-data-grid": "6.16.1", "@mui/x-date-pickers": "5.0.20", "axios": "1.5.0", "base64url": "3.0.1", @@ -3265,6 +3266,39 @@ } } }, + "node_modules/@mui/x-data-grid": { + "version": "6.16.1", + "resolved": "https://registry.npmjs.org/@mui/x-data-grid/-/x-data-grid-6.16.1.tgz", + "integrity": "sha512-jiV4kMegueNiaB3Qs0VpHG0Cp+eIZa5upMr9fcdPMPNLhOYnkNtexTyezfptJyfD8Adbjrjt4bbRktBcDCC5DA==", + "dependencies": { + "@babel/runtime": "^7.23.1", + "@mui/utils": "^5.14.11", + "clsx": "^2.0.0", + "prop-types": "^15.8.1", + "reselect": "^4.1.8" + }, + "engines": { + "node": ">=14.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/mui" + }, + "peerDependencies": { + "@mui/material": "^5.4.1", + "@mui/system": "^5.4.1", + "react": "^17.0.0 || ^18.0.0", + "react-dom": "^17.0.0 || ^18.0.0" + } + }, + "node_modules/@mui/x-data-grid/node_modules/clsx": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/clsx/-/clsx-2.0.0.tgz", + "integrity": "sha512-rQ1+kcj+ttHG0MKVGBUXwayCCF1oh39BF5COIpRzuCEv8Mwjv0XucrI2ExNTOn9IlLifGClWQcU9BrZORvtw6Q==", + "engines": { + "node": ">=6" + } + }, "node_modules/@mui/x-date-pickers": { "version": "5.0.20", "resolved": "https://registry.npmjs.org/@mui/x-date-pickers/-/x-date-pickers-5.0.20.tgz", @@ -9273,6 +9307,11 @@ "optional": true, "peer": true }, + "node_modules/reselect": { + "version": "4.1.8", + "resolved": "https://registry.npmjs.org/reselect/-/reselect-4.1.8.tgz", + "integrity": "sha512-ab9EmR80F/zQTMNeneUr4cv+jSwPJgIlvEmVwLerwrWVbpLlBuls9XHzIeTFy4cegU2NHBp3va0LKOzU5qFEYQ==" + }, "node_modules/resolve": { "version": "1.22.6", "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.6.tgz", diff --git a/package.json b/package.json index 08b669fb..b5a90731 100644 --- a/package.json +++ b/package.json @@ -12,6 +12,7 @@ "@mui/icons-material": "5.14.9", "@mui/lab": "5.0.0-alpha.145", "@mui/material": "5.14.10", + "@mui/x-data-grid": "6.16.1", "@mui/x-date-pickers": "5.0.20", "axios": "1.5.0", "base64url": "3.0.1", diff --git a/src/auth/registration/Register.tsx b/src/auth/registration/Register.tsx index 7d09f619..a23818a0 100644 --- a/src/auth/registration/Register.tsx +++ b/src/auth/registration/Register.tsx @@ -5,7 +5,7 @@ import { RegistrationSchema } from "validation/RegistrationSchema"; import { initialValues, RegistrationForm } from "auth/registration/types"; import { NameField } from "auth/registration/NameField"; import { AddressField } from "auth/registration/AddressField"; -import { FormikSubmitButton } from "form/FormikHelpers"; +import { FormikSubmitButton } from "form/FormikButton"; import { useRegister } from "auth/hooks/mutations/useRegister"; import { AdvertiserRegistered } from "auth/registration/AdvertiserRegistered"; import { NextAndBack } from "components/Steps/NextAndBack"; diff --git a/src/components/Assets/ImageAutocomplete.tsx b/src/components/Assets/ImageAutocomplete.tsx index 939fb623..66d45085 100644 --- a/src/components/Assets/ImageAutocomplete.tsx +++ b/src/components/Assets/ImageAutocomplete.tsx @@ -9,11 +9,9 @@ type ImageOption = { label: string; image?: string }; const filter = createFilterOptions(); -export function ImageAutocomplete() { +export function ImageAutocomplete(props: { name: string }) { const [createImage, setCreateImage] = useState(false); - const [, meta, imageUrl] = useField( - `newCreative.payloadInlineContent.imageUrl`, - ); + const [, meta, imageUrl] = useField(props.name); const hasError = Boolean(meta.error); const showError = hasError && meta.touched; const { advertiser } = useAdvertiser(); @@ -60,6 +58,7 @@ export function ImageAutocomplete() { imageUrl.setValue(nv ? nv.image : undefined); setCreateImage(nv != null && nv.image === undefined); }} + isOptionEqualToValue={(option, value) => option.image === value.image} /> + window.open( + "https://brave.com/brave-ads/ad-formats/", + "__blank", + "noopener", + ) + } + > + + + ); +} diff --git a/src/components/Button/SubmitPanel.tsx b/src/components/Button/SubmitPanel.tsx new file mode 100644 index 00000000..125abb04 --- /dev/null +++ b/src/components/Button/SubmitPanel.tsx @@ -0,0 +1,88 @@ +import { Box, Link, Paper, Slide } from "@mui/material"; +import { PropsWithChildren, ReactNode, useState } from "react"; +import { useFormikContext } from "formik"; +import { + extractErrors, + FormikDialogButton, + FormikSubmitButton, +} from "form/FormikButton"; + +function StatusMessage({ + errors, + isDirty, +}: { + errors: string[]; + isDirty: boolean; +}): ReactNode { + const [showErrors, setShowErrors] = useState(false); + + if (errors.length === 0) { + return isDirty ? "You have unsaved changes" : null; + } + + if (errors.length === 1) { + return errors[0]; + } + + return ( + + setShowErrors((state) => !state)}> + You have {errors.length} errors that must be fixed before submitting. + + {showErrors && ( +
    + {errors.map((v, idx) => ( +
  • {`${v}`}
  • + ))} +
+ )} +
+ ); +} + +interface Props { + isCreate: boolean; + hasDialog?: boolean; + dialogTitle?: string; + dialogMessage?: string; +} + +export function SubmitPanel(props: PropsWithChildren) { + const { dirty, errors, submitCount } = useFormikContext(); + // when creating a new item, we don't want to bombard with a whole load + // of validation errors. So wait until it's been submitted at least once + // before dumping the set of things that need to be completed. + const errorStrings = + props.isCreate && submitCount < 1 ? [] : extractErrors(errors); + + return ( + + + + + + + + {props.hasDialog && props.dialogTitle && props.dialogMessage && ( + + )} + {!props.hasDialog && } + + + + ); +} diff --git a/src/components/Card/CardContainer.tsx b/src/components/Card/CardContainer.tsx index ddcb2d87..a63d6aeb 100644 --- a/src/components/Card/CardContainer.tsx +++ b/src/components/Card/CardContainer.tsx @@ -8,6 +8,7 @@ export function CardContainer( additionalAction?: ReactNode; sx?: SxProps; childSx?: SxProps; + useTypography?: boolean; } & PropsWithChildren, ) { return ( @@ -19,7 +20,10 @@ export function CardContainer( alignItems="center" mb={1} > - {props.header && {props.header}} + {props.header && props.useTypography && ( + {props.header} + )} + {!props.useTypography && <>{props.header}} {props.additionalAction && {props.additionalAction}} )} diff --git a/src/components/Creatives/CreativeCampaigns.tsx b/src/components/Creatives/CreativeCampaigns.tsx new file mode 100644 index 00000000..60787de2 --- /dev/null +++ b/src/components/Creatives/CreativeCampaigns.tsx @@ -0,0 +1,69 @@ +import { CampaignsForCreativeQuery } from "graphql/creative.generated"; +import { Link as RouterLink } from "react-router-dom"; +import { + Link, + Table, + TableBody, + TableCell, + TableHead, + TableRow, +} from "@mui/material"; +import { ErrorDetail } from "components/Error/ErrorDetail"; +import { CardContainer } from "components/Card/CardContainer"; +import _ from "lodash"; +import { Status } from "components/Campaigns/Status"; +import { ApolloError } from "@apollo/client"; + +interface Props { + data?: CampaignsForCreativeQuery; + error?: ApolloError; + loading: boolean; +} + +export default function CreativeCampaigns({ data, error, loading }: Props) { + if (loading || !data || !data.creativeCampaigns) { + return null; + } + + if (error) { + return ( + + ); + } + + const campaigns = _.uniqBy(data.creativeCampaigns, "id"); + return ( + + + + + Name + Status + + + + {campaigns.map((c) => ( + + + + {c.name} + + + + + + + ))} + +
+
+ ); +} diff --git a/src/components/Creatives/CreativeForm.tsx b/src/components/Creatives/CreativeForm.tsx new file mode 100644 index 00000000..cb2f54fa --- /dev/null +++ b/src/components/Creatives/CreativeForm.tsx @@ -0,0 +1,145 @@ +import { Box, Container, LinearProgress } from "@mui/material"; +import { Form, Formik } from "formik"; +import { useParams } from "react-router-dom"; +import { CardContainer } from "components/Card/CardContainer"; +import { ErrorDetail } from "components/Error/ErrorDetail"; +import { CreativeSchema } from "validation/CreativeSchema"; +import MiniSideBar from "components/Drawer/MiniSideBar"; +import { CreativeType } from "components/Creatives/CreativeType"; +import { NotificationAd } from "user/ads/NotificationAd"; +import { InlineContentAd } from "user/ads/InlineContentAd"; +import { SubmitPanel } from "components/Button/SubmitPanel"; +import { useGetCreativeDetails } from "components/Creatives/hooks/useGetCreativeDetails"; +import { useSubmitCreative } from "components/Creatives/hooks/useSubmitCreative"; +import CreativeCampaigns from "components/Creatives/CreativeCampaigns"; +import { useCampaignsForCreativeQuery } from "graphql/creative.generated"; +import { useAdvertiser } from "auth/hooks/queries/useAdvertiser"; +import { CreativeInput } from "graphql/types"; +import { CampaignFragment } from "graphql/campaign.generated"; +import _ from "lodash"; +import { isReviewableState } from "util/displayState"; + +interface Params { + id: string; +} + +export function CreativeForm() { + const { advertiser } = useAdvertiser(); + const { id } = useParams(); + const isNew = id === "new"; + const { data, loading, error: getError } = useGetCreativeDetails({ id }); + + const { submit, error: submitError } = useSubmitCreative({ id }); + + const { + data: campaigns, + loading: cLoading, + error: cError, + } = useCampaignsForCreativeQuery({ + variables: { creativeId: id, advertiserId: advertiser.id }, + skip: id === "new", + }); + + if (loading || !data) { + return ; + } + + if (getError) { + return ( + + ); + } + + return ( + + + { + void submit(values, setSubmitting); + }} + validationSchema={CreativeSchema} + > + {({ values }) => ( +
+ + + + + + + + + + + + + +
+ )} +
+
+
+ ); +} + +const CreativeTypeSpecificFields = ({ + creativeType, +}: { + creativeType?: string; +}) => { + if (creativeType === "notification_all_v1") + return ; + if (creativeType === "inline_content_all_v1") + return ; + + return null; +}; + +const dialogProps = ( + creative: CreativeInput, + creativeCampaigns?: Partial[], +) => { + if (_.isEmpty(creativeCampaigns)) { + return { useDialog: false }; + } + const campaigns = creativeCampaigns ?? []; + const campaignLength = campaigns.length; + + let message = + "Modifying a creative will immediately put it into review. This means it will no longer be shown to users until it is approved."; + if (campaignLength > 1) { + message = `${message}. This creative is also shared across ${campaignLength} campaigns. Any modifications made will be effective for all campaigns using this creative.`; + } + + const hasDialog = + !isReviewableState(creative.state) && + campaigns.some((c) => !isReviewableState(c.state)); + return { + hasDialog, + dialogTitle: `Are you sure you want to modify "${creative.name}"?`, + dialogMessage: message, + }; +}; diff --git a/src/components/Creatives/CreativeList.tsx b/src/components/Creatives/CreativeList.tsx new file mode 100644 index 00000000..1ec08378 --- /dev/null +++ b/src/components/Creatives/CreativeList.tsx @@ -0,0 +1,203 @@ +import { + CreativeFragment, + 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"; +import { Box, Link, List, Typography } from "@mui/material"; +import { Status } from "components/Campaigns/Status"; +import { Link as RouterLink } from "react-router-dom"; +import { + DataGrid, + GridColDef, + GridToolbarColumnsButton, + GridToolbarContainer, + GridToolbarFilterButton, + GridToolbarQuickFilter, +} from "@mui/x-data-grid"; +import { CreativeStatusSwitch } from "components/Creatives/CreativeStatusSwitch"; + +const ALLOWED_TYPES = ["notification_all_v1", "inline_content_all_v1"]; +export function CreativeList() { + const { advertiser } = useAdvertiser(); + const { data, error, loading } = useAdvertiserCreativesQuery({ + variables: { + advertiserId: advertiser.id, + }, + pollInterval: 60_000, + }); + + const columns: GridColDef[] = [ + { + field: "switch", + type: "actions", + headerName: "On / Off", + renderCell: ({ row }) => , + filterable: false, + sortable: false, + }, + { + field: "name", + type: "string", + headerName: "Name", + renderCell: ({ row }) => ( + + {row.name} + + ), + flex: 1, + minWidth: 100, + maxWidth: 400, + }, + { + field: "type", + headerName: "Type", + valueGetter: ({ row }) => uiTextForCreativeTypeCode(row.type), + align: "left", + width: 200, + }, + { + field: "content", + headerName: "Content", + valueGetter: ({ row }) => creativeValuesGetter(row), + renderCell: ({ row }) => , + flex: 1, + sortable: false, + }, + { + field: "state", + headerName: "State", + renderCell: ({ row }) => , + width: 200, + }, + ]; + + function CustomToolbar() { + return ( + + + + + + + ); + } + + const creatives = [...(data?.advertiser?.creatives ?? [])].filter((c) => + ALLOWED_TYPES.includes(c.type.code), + ); + return ( + + {error && ( + + )} + + + + + ); +} + +function CreativePayloadList(props: { creative: CreativeFragment }) { + const c = props.creative; + let listItems; + switch (c.type.code) { + case "notification_all_v1": + listItems = ( + + ); + break; + case "inline_content_all_v1": + listItems = ( + + ); + break; + default: + listItems = null; + } + + if (!listItems) { + return null; + } + + return {listItems}; +} + +const ListItems = (props: { + items: { primary: string; secondary?: string }[]; +}) => { + return props.items.map((i, idx) => ( + + + {i.primary} + + + {i.secondary} + + + )); +}; + +const creativeValuesGetter = (c: CreativeFragment) => { + const title = c.payloadNotification?.title ?? c.payloadInlineContent?.title; + const body = c.payloadNotification?.title ?? c.payloadInlineContent?.ctaText; + return `${title} ${body}`; +}; diff --git a/src/components/Creatives/CreativeSpecificFields.tsx b/src/components/Creatives/CreativeSpecificFields.tsx index 4d1c46ea..7901169a 100644 --- a/src/components/Creatives/CreativeSpecificFields.tsx +++ b/src/components/Creatives/CreativeSpecificFields.tsx @@ -6,11 +6,12 @@ import { InlineContentAd } from "user/ads/InlineContentAd"; export const CreativeSpecificFields = () => { const { values } = useFormikContext(); + const name = "newCreative"; if (values.format === CampaignFormat.PushNotification) - return ; + return ; else if (values.format === CampaignFormat.NewsDisplayAd) - return ; + return ; return null; }; diff --git a/src/components/Creatives/CreativeStatusSwitch.tsx b/src/components/Creatives/CreativeStatusSwitch.tsx new file mode 100644 index 00000000..f9747fe8 --- /dev/null +++ b/src/components/Creatives/CreativeStatusSwitch.tsx @@ -0,0 +1,142 @@ +import { + CreativeFragment, + refetchAdvertiserCreativesQuery, + refetchCampaignsForCreativeQuery, + useCampaignsForCreativeLazyQuery, + useUpdateCreativeMutation, +} from "graphql/creative.generated"; +import { useAdvertiser } from "auth/hooks/queries/useAdvertiser"; +import { + Box, + Button, + Dialog, + DialogActions, + DialogContent, + DialogTitle, + List, + ListItemText, + Switch, + Typography, +} from "@mui/material"; +import { useState } from "react"; +import _ from "lodash"; +import { validCreativeFields } from "user/library"; +import { isReviewableState } from "util/displayState"; + +interface Props { + creative: CreativeFragment; +} + +export type RelatedCampaign = { id: string; name: string; state: string }; +export function CreativeStatusSwitch({ creative }: Props) { + const { advertiser } = useAdvertiser(); + const input = _.omit(validCreativeFields(creative, advertiser.id), [ + "targetUrlValid", + "included", + "id", + ]); + const [relatedCampaigns, setRelatedCampaigns] = useState( + [], + ); + const [creativeState, setCreativeState] = useState(input.state); + const [update, { loading: updateLoading }] = useUpdateCreativeMutation({ + refetchQueries: [ + refetchAdvertiserCreativesQuery({ advertiserId: advertiser.id }), + refetchCampaignsForCreativeQuery({ + creativeId: creative.id, + advertiserId: advertiser.id, + }), + ], + onCompleted() { + setRelatedCampaigns([]); + }, + onError() { + setCreativeState(input.state); + }, + }); + const [campaigns, { loading }] = useCampaignsForCreativeLazyQuery({ + variables: { creativeId: creative.id, advertiserId: advertiser.id }, + }); + + if (input.state !== "active" && input.state !== "paused") { + return -; + } + + return ( + + { + const theState = e.target.checked ? "active" : "paused"; + setCreativeState(theState); + campaigns({ + onCompleted(data) { + const campaigns = data.creativeCampaigns.filter( + (c) => !isReviewableState(c.state), + ); + if (campaigns.length > 1) { + setRelatedCampaigns(_.uniqBy(campaigns, "id")); + } else { + update({ + variables: { + id: creative.id, + input: { ...input, state: theState }, + }, + }); + } + }, + }); + }} + checked={creativeState === "active"} + disabled={loading || updateLoading} + /> + 0}> + + Are you sure you want to{" "} + {creativeState === "active" ? "activate" : "pause"}{" "} + {`"${input.name}"`} + + + + Modifying the state of this creative will effect more than one + campaign: + + + {relatedCampaigns.map((r) => ( + + ))} + + + + + + + + + ); +} diff --git a/src/components/Creatives/CreativeType.tsx b/src/components/Creatives/CreativeType.tsx new file mode 100644 index 00000000..80b4daf7 --- /dev/null +++ b/src/components/Creatives/CreativeType.tsx @@ -0,0 +1,48 @@ +import { Box, ListItemButton, List, Typography, Stack } from "@mui/material"; +import { useFormikContext } from "formik"; +import { CreativeInput } from "graphql/types"; +import { FormatHelp } from "components/Button/FormatHelp"; + +export function CreativeType(props: { allowTypeChange?: boolean }) { + const formik = useFormikContext(); + + const supportedTypes = [ + { + value: "notification_all_v1", + label: "Push Notification", + }, + { + value: "inline_content_all_v1", + label: "News Display", + }, + ]; + + return ( + + + Creative Format + + + + {supportedTypes.map((s) => ( + formik.setFieldValue("type.code", s.value)} + > + {s.label} + + ))} + + + ); +} diff --git a/src/components/Creatives/NewsPreview.tsx b/src/components/Creatives/NewsPreview.tsx index 29a4ca97..fab15f6e 100644 --- a/src/components/Creatives/NewsPreview.tsx +++ b/src/components/Creatives/NewsPreview.tsx @@ -1,14 +1,15 @@ import { Box, Card, Typography } from "@mui/material"; import { ImagePreview } from "components/Assets/ImagePreview"; -import { useField } from "formik"; -import { Creative } from "user/views/adsManager/types"; +import { useField, useFormikContext } from "formik"; import { useAdvertiser } from "auth/hooks/queries/useAdvertiser"; +import { CreativeInput } from "graphql/types"; export function NewsPreview() { const { advertiser } = useAdvertiser(); - const [, creative] = useField("newCreative"); + const { values } = useFormikContext(); + const [, meta, ,] = useField("newCreative"); - const value = creative.value.payloadInlineContent; + const value = values.payloadInlineContent ?? meta.value.payloadInlineContent; return ( (); const [, meta, ,] = useField("newCreative"); + const value = values.payloadNotification ?? meta.value.payloadNotification; return ( - {props.title || - meta.value?.payloadNotification?.title || - "Title Preview"} + {props.title || value?.title || "Title Preview"} - {props.body || - meta.value?.payloadNotification?.body || - "Body Preview"} + {props.body || value?.body || "Body Preview"} diff --git a/src/components/Creatives/hooks/useGetCreativeDetails.tsx b/src/components/Creatives/hooks/useGetCreativeDetails.tsx new file mode 100644 index 00000000..38866462 --- /dev/null +++ b/src/components/Creatives/hooks/useGetCreativeDetails.tsx @@ -0,0 +1,52 @@ +import { useLoadCreativeQuery } from "graphql/creative.generated"; +import { CreativeInput } from "graphql/types"; +import { useAdvertiser } from "auth/hooks/queries/useAdvertiser"; + +export function useGetCreativeDetails(props: { id: string }) { + const { advertiser } = useAdvertiser(); + const isNew = props.id === "new"; + const { data, loading, error } = useLoadCreativeQuery({ + variables: { id: props.id }, + skip: isNew, + }); + + const defaultValue: CreativeInput & { targetUrlValid?: string } = { + advertiserId: advertiser.id, + state: "under_review", + name: "", + type: { + code: "notification_all_v1", + }, + targetUrlValid: "Target URL not validated", + payloadNotification: { + body: "", + targetUrl: "", + title: "", + }, + payloadInlineContent: { + title: "", + targetUrl: "", + ctaText: "", + description: "", + imageUrl: "", + dimensions: "900x750", + }, + }; + + if (props.id === "new") { + return { data: defaultValue, loading: false, error: undefined }; + } + + return { + data: + data && data.creative + ? { + ...data.creative, + advertiserId: advertiser.id, + targetUrlValid: undefined, + } + : undefined, + loading, + error, + }; +} diff --git a/src/components/Creatives/hooks/useSubmitCreative.tsx b/src/components/Creatives/hooks/useSubmitCreative.tsx new file mode 100644 index 00000000..61f403cf --- /dev/null +++ b/src/components/Creatives/hooks/useSubmitCreative.tsx @@ -0,0 +1,69 @@ +import { + refetchAdvertiserCreativesQuery, + useCreateCreativeMutation, + useUpdateCreativeMutation, +} from "graphql/creative.generated"; +import { useCallback } from "react"; +import { CreativeInput } from "graphql/types"; +import { useAdvertiser } from "auth/hooks/queries/useAdvertiser"; +import { useHistory } from "react-router-dom"; +import { validCreativeFields } from "user/library"; +import _ from "lodash"; + +export function useSubmitCreative(props: { id: string }) { + const history = useHistory(); + const isNew = props.id === "new"; + const { advertiser } = useAdvertiser(); + const refetchQueries = [ + refetchAdvertiserCreativesQuery({ advertiserId: advertiser.id }), + ]; + const onCompleted = () => history.replace("/user/main/creatives"); + + const [createCreative, { error: createError, loading: createLoading }] = + useCreateCreativeMutation({ + refetchQueries, + onCompleted, + }); + + const [updateCreative, { error: updateError, loading: updateLoading }] = + useUpdateCreativeMutation({ + refetchQueries, + onCompleted, + }); + + const submit = useCallback( + async (values: CreativeInput, submitting: (s: boolean) => void) => { + submitting(true); + const valid = validCreativeFields( + { id: props.id, ...values }, + advertiser.id, + ); + + const input = { + ..._.omit(valid, ["id", "targetUrlValid", "included"]), + state: "under_review", + }; + + try { + if (isNew) { + await createCreative({ + variables: { input: input }, + }); + } else { + await updateCreative({ + variables: { input: input, id: props.id }, + }); + } + } finally { + submitting(false); + } + }, + [createCreative, updateCreative, props.id], + ); + + return { + submit, + error: createError ?? updateError, + loading: createLoading ?? updateLoading, + }; +} diff --git a/src/components/Drawer/MiniSideBar.tsx b/src/components/Drawer/MiniSideBar.tsx index 0f65225e..88f16990 100644 --- a/src/components/Drawer/MiniSideBar.tsx +++ b/src/components/Drawer/MiniSideBar.tsx @@ -65,7 +65,7 @@ export default function MiniSideBar({ children }: PropsWithChildren) { sx={{ color: "text.secondary" }} /> ), - disabled: true, + disabled: !advertiser.selfServiceCreate, }, { label: "Audiences", diff --git a/src/components/Navigation/Navbar.tsx b/src/components/Navigation/Navbar.tsx index 8b50cddc..9716b9b2 100644 --- a/src/components/Navigation/Navbar.tsx +++ b/src/components/Navigation/Navbar.tsx @@ -7,6 +7,7 @@ import { useSignOut } from "auth/hooks/mutations/useSignOut"; import { NewCampaignButton } from "components/Navigation/NewCampaignButton"; import { UploadImage } from "components/Assets/UploadImage"; import { useHistory } from "react-router-dom"; +import { NewCreativeButton } from "components/Navigation/NewCreativeButton"; export function Navbar() { const { signOut } = useSignOut(); @@ -22,6 +23,10 @@ export function Navbar() { route: "user/main/assets", component: , }, + { + route: "user/main/creatives", + component: , + }, ]; return ( diff --git a/src/components/Navigation/NewCreativeButton.tsx b/src/components/Navigation/NewCreativeButton.tsx new file mode 100644 index 00000000..d95529a9 --- /dev/null +++ b/src/components/Navigation/NewCreativeButton.tsx @@ -0,0 +1,27 @@ +import { useAdvertiser } from "auth/hooks/queries/useAdvertiser"; +import { Button } from "@mui/material"; +import { Link as RouterLink, useRouteMatch } from "react-router-dom"; + +export function NewCreativeButton() { + const newUrl = "/user/main/creative/new"; + const { url } = useRouteMatch(); + const { advertiser } = useAdvertiser(); + const isNewCreativePage = url.includes(newUrl); + + if (!advertiser.selfServiceCreate) { + return null; + } + + return ( + + ); +} diff --git a/src/components/Url/UrlResolver.tsx b/src/components/Url/UrlResolver.tsx index a3a24a49..6eb11152 100644 --- a/src/components/Url/UrlResolver.tsx +++ b/src/components/Url/UrlResolver.tsx @@ -50,19 +50,14 @@ export const UrlResolver = ({ useEffect(() => { const { isValid } = urlValidation; - if (_.isUndefined(isValid)) { - isValidHelper.setValue("Target URL Validation did not complete"); - } else if (isValid) { + if (isValid === false) { + isValidHelper.setValue("Target URL is not valid"); + } else if (isValid === true) { isValidHelper.setValue(undefined); - } else { - const violations = extractViolations(urlValidation); - const errorMessage = violations.map((err) => err.summary).join("#"); - isValidHelper.setValue(errorMessage); } - }, [urlValidation.isValid, urlValidation.response]); + }, [urlValidation.isValid]); const urlViolations = extractViolations(urlValidation); - return ( <> + _.isString(o) ? [o] : extractErrors(o), + ); +} + +const useSave = (props: { isCreate: boolean }) => { + const formik = useFormikContext(); + let saveButtonTooltip: TooltipProps["title"] = ""; + let saveEnabled = true; + + if (formik.isSubmitting) { + saveEnabled = false; + } else if (!props.isCreate && !formik.dirty) { + saveEnabled = false; + saveButtonTooltip = "Disabled because you haven’t made any changes"; + } else if (props.isCreate && formik.submitCount < 1) { + // on create, initially enable the button so users can reveal all the required fields + saveEnabled = true; + } else if (!formik.isValid) { + saveEnabled = false; + saveButtonTooltip = ( + <> + Disabled due to validation errors +
    + {extractErrors(formik.errors).map((v, idx) => ( +
  • {`${v}`}
  • + ))} +
+ + ); + } + + return { + saveButtonTooltip, + saveEnabled, + isSubmitting: formik.isSubmitting, + submitForm: formik.submitForm, + }; +}; + +export const FormikSubmitButton = ({ + label = "Save", + inProgressLabel = "Saving...", + isCreate, +}: FormikSubmitButtonProps) => { + const { saveButtonTooltip, saveEnabled, isSubmitting, submitForm } = useSave({ + isCreate, + }); + + return ( + +
+ +
+
+ ); +}; + +interface DialogProps { + dialogTitle: string; + dialogMessage: string; +} + +export const FormikDialogButton = ( + props: FormikSubmitButtonProps & DialogProps, +) => { + const { saveButtonTooltip, saveEnabled, isSubmitting } = useSave({ + isCreate: props.isCreate, + }); + const [open, setOpen] = useState(false); + + return ( + <> + +
+ +
+
+ + {props.dialogTitle} + {props.dialogMessage} + + + + + + + ); +}; diff --git a/src/form/FormikHelpers.tsx b/src/form/FormikHelpers.tsx index 12bc4226..d44f7cf4 100644 --- a/src/form/FormikHelpers.tsx +++ b/src/form/FormikHelpers.tsx @@ -6,7 +6,6 @@ import { } from "react"; import { Box, - Button, FormControl, FormControlLabel, FormHelperText, @@ -16,8 +15,6 @@ import { Switch, TextField, TextFieldProps, - Tooltip, - TooltipProps, } from "@mui/material"; import { ErrorMessage, useField, useFormikContext } from "formik"; import _ from "lodash"; @@ -164,66 +161,6 @@ export const FormikRadioControl = (props: FormikRadioControlProps) => { ); }; -interface FormikSubmitButtonProps { - label?: string; - inProgressLabel?: string; - isCreate: boolean; -} - -function extractErrors(errorObject: any): string[] { - return Object.values(errorObject) - .filter((v) => !!v) - .flatMap((o) => (_.isString(o) ? [o] : extractErrors(o))); -} - -export const FormikSubmitButton = ({ - label = "Save", - inProgressLabel = "Saving...", - isCreate, -}: FormikSubmitButtonProps) => { - const formik = useFormikContext(); - let saveButtonTooltip: TooltipProps["title"] = ""; - let saveEnabled = true; - - if (formik.isSubmitting) { - saveEnabled = false; - } else if (!isCreate && !formik.dirty) { - saveEnabled = false; - saveButtonTooltip = "Disabled because you haven’t made any changes"; - } else if (isCreate && formik.submitCount < 1) { - // on create, initially enable the button so users can reveal all the required fields - saveEnabled = true; - } else if (!formik.isValid) { - saveEnabled = false; - saveButtonTooltip = ( - <> - Disabled due to validation errors -
    - {extractErrors(formik.errors).map((v, idx) => ( -
  • {`${v}`}
  • - ))} -
- - ); - } - - return ( - -
- -
-
- ); -}; - export function useIsEdit() { const { values } = useFormikContext(); const isEdit = values.id !== undefined && values.id.trim() !== ""; diff --git a/src/graphql/creative.generated.tsx b/src/graphql/creative.generated.tsx index 8f46b4d8..d4e75e08 100644 --- a/src/graphql/creative.generated.tsx +++ b/src/graphql/creative.generated.tsx @@ -147,6 +147,114 @@ export type CreateCreativeMutation = { }; }; +export type UpdateCreativeMutationVariables = Types.Exact<{ + id: Types.Scalars["String"]; + input: Types.CreativeInput; +}>; + +export type UpdateCreativeMutation = { + updateCreative: { + id: string; + createdAt: any; + modifiedAt: any; + name: string; + state: string; + type: { code: string }; + payloadNotification?: { + body: string; + title: string; + targetUrl: string; + } | null; + payloadNewTabPage?: { + logo?: { + imageUrl: string; + alt: string; + companyName: string; + destinationUrl: string; + } | null; + wallpapers?: Array<{ + imageUrl: string; + focalPoint: { x: number; y: number }; + }> | null; + } | null; + payloadInlineContent?: { + title: string; + ctaText: string; + imageUrl: string; + targetUrl: string; + dimensions: string; + description: string; + } | null; + payloadSearch?: { body: string; title: string; targetUrl: string } | null; + payloadSearchHomepage?: { + body: string; + imageUrl: string; + imageDarkModeUrl?: string | null; + targetUrl: string; + title: string; + ctaText: string; + } | null; + }; +}; + +export type LoadCreativeQueryVariables = Types.Exact<{ + id: Types.Scalars["String"]; +}>; + +export type LoadCreativeQuery = { + creative?: { + id: string; + createdAt: any; + modifiedAt: any; + name: string; + state: string; + type: { code: string }; + payloadNotification?: { + body: string; + title: string; + targetUrl: string; + } | null; + payloadNewTabPage?: { + logo?: { + imageUrl: string; + alt: string; + companyName: string; + destinationUrl: string; + } | null; + wallpapers?: Array<{ + imageUrl: string; + focalPoint: { x: number; y: number }; + }> | null; + } | null; + payloadInlineContent?: { + title: string; + ctaText: string; + imageUrl: string; + targetUrl: string; + dimensions: string; + description: string; + } | null; + payloadSearch?: { body: string; title: string; targetUrl: string } | null; + payloadSearchHomepage?: { + body: string; + imageUrl: string; + imageDarkModeUrl?: string | null; + targetUrl: string; + title: string; + ctaText: string; + } | null; + } | null; +}; + +export type CampaignsForCreativeQueryVariables = Types.Exact<{ + creativeId: Types.Scalars["String"]; + advertiserId: Types.Scalars["String"]; +}>; + +export type CampaignsForCreativeQuery = { + creativeCampaigns: Array<{ id: string; name: string; state: string }>; +}; + export const CreativeFragmentDoc = gql` fragment Creative on Creative { id @@ -323,3 +431,185 @@ export type CreateCreativeMutationOptions = Apollo.BaseMutationOptions< CreateCreativeMutation, CreateCreativeMutationVariables >; +export const UpdateCreativeDocument = gql` + mutation updateCreative($id: String!, $input: CreativeInput!) { + updateCreative(id: $id, creative: $input) { + ...Creative + } + } + ${CreativeFragmentDoc} +`; +export type UpdateCreativeMutationFn = Apollo.MutationFunction< + UpdateCreativeMutation, + UpdateCreativeMutationVariables +>; + +/** + * __useUpdateCreativeMutation__ + * + * To run a mutation, you first call `useUpdateCreativeMutation` within a React component and pass it any options that fit your needs. + * When your component renders, `useUpdateCreativeMutation` 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 [updateCreativeMutation, { data, loading, error }] = useUpdateCreativeMutation({ + * variables: { + * id: // value for 'id' + * input: // value for 'input' + * }, + * }); + */ +export function useUpdateCreativeMutation( + baseOptions?: Apollo.MutationHookOptions< + UpdateCreativeMutation, + UpdateCreativeMutationVariables + >, +) { + const options = { ...defaultOptions, ...baseOptions }; + return Apollo.useMutation< + UpdateCreativeMutation, + UpdateCreativeMutationVariables + >(UpdateCreativeDocument, options); +} +export type UpdateCreativeMutationHookResult = ReturnType< + typeof useUpdateCreativeMutation +>; +export type UpdateCreativeMutationResult = + Apollo.MutationResult; +export type UpdateCreativeMutationOptions = Apollo.BaseMutationOptions< + UpdateCreativeMutation, + UpdateCreativeMutationVariables +>; +export const LoadCreativeDocument = gql` + query loadCreative($id: String!) { + creative(id: $id) { + ...Creative + } + } + ${CreativeFragmentDoc} +`; + +/** + * __useLoadCreativeQuery__ + * + * To run a query within a React component, call `useLoadCreativeQuery` and pass it any options that fit your needs. + * When your component renders, `useLoadCreativeQuery` returns an object from Apollo Client that contains loading, error, and data properties + * you can use to render your UI. + * + * @param baseOptions options that will be passed into the query, supported options are listed on: https://www.apollographql.com/docs/react/api/react-hooks/#options; + * + * @example + * const { data, loading, error } = useLoadCreativeQuery({ + * variables: { + * id: // value for 'id' + * }, + * }); + */ +export function useLoadCreativeQuery( + baseOptions: Apollo.QueryHookOptions< + LoadCreativeQuery, + LoadCreativeQueryVariables + >, +) { + const options = { ...defaultOptions, ...baseOptions }; + return Apollo.useQuery( + LoadCreativeDocument, + options, + ); +} +export function useLoadCreativeLazyQuery( + baseOptions?: Apollo.LazyQueryHookOptions< + LoadCreativeQuery, + LoadCreativeQueryVariables + >, +) { + const options = { ...defaultOptions, ...baseOptions }; + return Apollo.useLazyQuery( + LoadCreativeDocument, + options, + ); +} +export type LoadCreativeQueryHookResult = ReturnType< + typeof useLoadCreativeQuery +>; +export type LoadCreativeLazyQueryHookResult = ReturnType< + typeof useLoadCreativeLazyQuery +>; +export type LoadCreativeQueryResult = Apollo.QueryResult< + LoadCreativeQuery, + LoadCreativeQueryVariables +>; +export function refetchLoadCreativeQuery( + variables: LoadCreativeQueryVariables, +) { + return { query: LoadCreativeDocument, variables: variables }; +} +export const CampaignsForCreativeDocument = gql` + query campaignsForCreative($creativeId: String!, $advertiserId: String!) { + creativeCampaigns(creativeId: $creativeId, advertiserId: $advertiserId) { + id + name + state + } + } +`; + +/** + * __useCampaignsForCreativeQuery__ + * + * To run a query within a React component, call `useCampaignsForCreativeQuery` and pass it any options that fit your needs. + * When your component renders, `useCampaignsForCreativeQuery` returns an object from Apollo Client that contains loading, error, and data properties + * you can use to render your UI. + * + * @param baseOptions options that will be passed into the query, supported options are listed on: https://www.apollographql.com/docs/react/api/react-hooks/#options; + * + * @example + * const { data, loading, error } = useCampaignsForCreativeQuery({ + * variables: { + * creativeId: // value for 'creativeId' + * advertiserId: // value for 'advertiserId' + * }, + * }); + */ +export function useCampaignsForCreativeQuery( + baseOptions: Apollo.QueryHookOptions< + CampaignsForCreativeQuery, + CampaignsForCreativeQueryVariables + >, +) { + const options = { ...defaultOptions, ...baseOptions }; + return Apollo.useQuery< + CampaignsForCreativeQuery, + CampaignsForCreativeQueryVariables + >(CampaignsForCreativeDocument, options); +} +export function useCampaignsForCreativeLazyQuery( + baseOptions?: Apollo.LazyQueryHookOptions< + CampaignsForCreativeQuery, + CampaignsForCreativeQueryVariables + >, +) { + const options = { ...defaultOptions, ...baseOptions }; + return Apollo.useLazyQuery< + CampaignsForCreativeQuery, + CampaignsForCreativeQueryVariables + >(CampaignsForCreativeDocument, options); +} +export type CampaignsForCreativeQueryHookResult = ReturnType< + typeof useCampaignsForCreativeQuery +>; +export type CampaignsForCreativeLazyQueryHookResult = ReturnType< + typeof useCampaignsForCreativeLazyQuery +>; +export type CampaignsForCreativeQueryResult = Apollo.QueryResult< + CampaignsForCreativeQuery, + CampaignsForCreativeQueryVariables +>; +export function refetchCampaignsForCreativeQuery( + variables: CampaignsForCreativeQueryVariables, +) { + return { query: CampaignsForCreativeDocument, variables: variables }; +} diff --git a/src/graphql/creative.graphql b/src/graphql/creative.graphql index a7b10adf..20b1e6f2 100644 --- a/src/graphql/creative.graphql +++ b/src/graphql/creative.graphql @@ -69,3 +69,23 @@ mutation createCreative($input: CreativeInput!) { ...Creative } } + +mutation updateCreative($id: String!, $input: CreativeInput!) { + updateCreative(id: $id, creative: $input) { + ...Creative + } +} + +query loadCreative($id: String!) { + creative(id: $id) { + ...Creative + } +} + +query campaignsForCreative($creativeId: String!, $advertiserId: String!) { + creativeCampaigns(creativeId: $creativeId, advertiserId: $advertiserId) { + id + name + state + } +} diff --git a/src/user/User.tsx b/src/user/User.tsx index 0ec82636..066857a1 100644 --- a/src/user/User.tsx +++ b/src/user/User.tsx @@ -22,6 +22,8 @@ import { IAdvertiser } from "auth/context/auth.interface"; import moment from "moment"; import { FilterContext } from "state/context"; import { AdvertiserAssets } from "components/Assets/AdvertiserAssets"; +import { CreativeList } from "components/Creatives/CreativeList"; +import { CreativeForm } from "components/Creatives/CreativeForm"; const buildApolloClient = () => { const httpLink = createHttpLink({ @@ -73,6 +75,12 @@ export function User() { validateAdvertiserProperty={(a) => a.selfServiceEdit} /> + a.selfServiceCreate} + /> + + + {/* default */} diff --git a/src/user/ads/InlineContentAd.tsx b/src/user/ads/InlineContentAd.tsx index 7e2fda5c..f5ba3938 100644 --- a/src/user/ads/InlineContentAd.tsx +++ b/src/user/ads/InlineContentAd.tsx @@ -9,11 +9,15 @@ import { CardContainer } from "components/Card/CardContainer"; import { ImageAutocomplete } from "components/Assets/ImageAutocomplete"; import { NewsPreview } from "components/Creatives/NewsPreview"; -export function InlineContentAd() { +export function InlineContentAd(props: { + name?: string; + useCustomButton?: boolean; +}) { + const withName = (s: string) => (props.name ? `${props.name}.${s}` : s); const { advertiser } = useAdvertiser(); - const [, , code] = useField("newCreative.type.code"); + const [, , code] = useField(withName("type.code")); const [, , description] = useField( - "newCreative.payloadInlineContent.description", + withName("payloadInlineContent.description"), ); useEffect(() => { code.setValue("inline_content_all_v1"); @@ -22,40 +26,42 @@ export function InlineContentAd() { return ( - - + + - + {advertiser.selfServiceSetPrice && ( )} - -
- - + {props.useCustomButton !== true && ( + +
+ + + )} diff --git a/src/user/ads/NotificationAd.tsx b/src/user/ads/NotificationAd.tsx index 5aab854c..c530ef8d 100644 --- a/src/user/ads/NotificationAd.tsx +++ b/src/user/ads/NotificationAd.tsx @@ -7,28 +7,32 @@ import { NotificationPreview } from "components/Creatives/NotificationPreview"; import { CreateCreativeButton } from "components/Creatives/CreateCreativeButton"; import { useEffect } from "react"; -export function NotificationAd() { - const [, , code] = useField("newCreative.type.code"); +export function NotificationAd(props: { + name?: string; + useCustomButton?: boolean; +}) { + const withName = (s: string) => (props.name ? `${props.name}.${s}` : s); + const [, , code] = useField(withName("type.code")); useEffect(() => { code.setValue("notification_all_v1"); }, []); return ( - - + + @@ -37,16 +41,18 @@ export function NotificationAd() { - -
- - + {props.useCustomButton !== true && ( + +
+ + + )} ); } diff --git a/src/user/library/index.ts b/src/user/library/index.ts index 4faf444b..f3e4ee78 100644 --- a/src/user/library/index.ts +++ b/src/user/library/index.ts @@ -165,11 +165,19 @@ function creativeList( ); } -export function validCreativeFields( - c: CreativeFragment | Creative, +type GenericCreative = Omit< + CreativeFragment, + | "createdAt" + | "modifiedAt" + | "payloadSearchHomepage" + | "payloadSearch" + | "payloadNewTabPage" +>; +export function validCreativeFields( + c: T, advertiserId: string, included?: boolean, -): Creative { +) { return { advertiserId, id: c.id, diff --git a/src/user/settings/UserForm.tsx b/src/user/settings/UserForm.tsx index 49fad274..8e64d5b3 100644 --- a/src/user/settings/UserForm.tsx +++ b/src/user/settings/UserForm.tsx @@ -3,11 +3,12 @@ import { useState } from "react"; import { useUser } from "auth/hooks/queries/useUser"; import { CardContainer } from "components/Card/CardContainer"; import { Form, Formik, FormikValues } from "formik"; -import { FormikSubmitButton, FormikTextField } from "form/FormikHelpers"; +import { FormikTextField } from "form/FormikHelpers"; import { useUpdateUserMutation } from "graphql/user.generated"; import { ErrorDetail } from "components/Error/ErrorDetail"; import { UserSchema } from "validation/UserSchema"; import _ from "lodash"; +import { FormikSubmitButton } from "form/FormikButton"; export function UserForm() { const user = useUser(); diff --git a/src/user/views/adsManager/types/index.ts b/src/user/views/adsManager/types/index.ts index 10b6b5ff..26446931 100644 --- a/src/user/views/adsManager/types/index.ts +++ b/src/user/views/adsManager/types/index.ts @@ -80,6 +80,7 @@ export const initialConversion: Conversion = { export const initialCreative: Creative = { name: "", advertiserId: "", + targetUrlValid: "Target URL validation incomplete", payloadNotification: { title: "", targetUrl: "", diff --git a/src/user/views/adsManager/views/advanced/components/campaign/fields/FormatField.tsx b/src/user/views/adsManager/views/advanced/components/campaign/fields/FormatField.tsx index 50e3ee50..343c83cf 100644 --- a/src/user/views/adsManager/views/advanced/components/campaign/fields/FormatField.tsx +++ b/src/user/views/adsManager/views/advanced/components/campaign/fields/FormatField.tsx @@ -1,18 +1,12 @@ -import { - IconButton, - List, - ListItemButton, - Stack, - Typography, -} from "@mui/material"; +import { List, ListItemButton, Stack, Typography } from "@mui/material"; import { CardContainer } from "components/Card/CardContainer"; import { useField } from "formik"; import { CampaignFormat } from "graphql/types"; import _ from "lodash"; -import HelpIcon from "@mui/icons-material/Help"; import { useIsEdit } from "form/FormikHelpers"; import { Billing } from "user/views/adsManager/types"; import { AdvertiserPriceFragment } from "graphql/advertiser.generated"; +import { FormatHelp } from "components/Button/FormatHelp"; interface PriceProps { prices: AdvertiserPriceFragment[]; @@ -25,18 +19,7 @@ export function FormatField({ prices }: PriceProps) { Choose a format for the campaign you would like to run - - window.open( - "https://brave.com/brave-ads/ad-formats/", - "__blank", - "noopener", - ) - } - > - - + diff --git a/src/util/displayState.ts b/src/util/displayState.ts index 1e67b355..c2f67b10 100644 --- a/src/util/displayState.ts +++ b/src/util/displayState.ts @@ -16,3 +16,6 @@ export const displayFromCampaignState = (c: { return c.state ?? "draft"; }; + +export const isReviewableState = (c?: string) => + c === "draft" || c === "under_review" || c === "completed"; diff --git a/src/validation/CreativeSchema.tsx b/src/validation/CreativeSchema.tsx index 93e204c5..4640e0d7 100644 --- a/src/validation/CreativeSchema.tsx +++ b/src/validation/CreativeSchema.tsx @@ -11,7 +11,7 @@ import * as Yup from "yup"; const validTargetUrl = (label: string) => Yup.string() .label(label) - .required("URL is required") + .required("Target URL is a required field") .matches(NoSpacesRegex, `URL must not contain any whitespace`) .matches(HttpsRegex, `URL must start with https://`) .matches( @@ -29,7 +29,14 @@ export const CreativeSchema = object().shape({ name: string(), }), state: string() - .oneOf(["draft", "under_review"]) + .oneOf([ + "draft", + "active", + "suspended", + "under_review", + "deleted", + "paused", + ]) .label("State") .required() .default("draft"),