From 57f947bced2171b62b825255f9e774e0805f5d22 Mon Sep 17 00:00:00 2001 From: Ian Krieger <48930920+IanKrieger@users.noreply.github.com> Date: Wed, 11 Oct 2023 08:38:20 -0400 Subject: [PATCH 1/8] chore: add prod PR maintenance script (#933) --- .../workflows/maintain-prod-release-pr.yaml | 189 ++++++++++++++++++ 1 file changed, 189 insertions(+) create mode 100644 .github/workflows/maintain-prod-release-pr.yaml diff --git a/.github/workflows/maintain-prod-release-pr.yaml b/.github/workflows/maintain-prod-release-pr.yaml new file mode 100644 index 00000000..71822448 --- /dev/null +++ b/.github/workflows/maintain-prod-release-pr.yaml @@ -0,0 +1,189 @@ +on: + # run manually from the actions panels + workflow_dispatch: + + # since we can't currently create a PR and have the jobs run + # successfully, run when one is manually created. + pull_request: + types: [opened] + branches: [prod] + + # update the PR text when new commits are merged to the master branch + push: + branches: + - master + + # and re-run early in the morning, mainly so the title gets updated with today's date + schedule: + - cron: "42 1 * * *" + +permissions: + contents: read + pull-requests: write + +jobs: + main: + name: Maintain Prod PR + runs-on: ubuntu-latest + steps: + - uses: actions/github-script@d7906e4ad0b1822421a7e6a35d5ca353c962f410 # v6 + with: + script: | + const owner = context.repo.owner; + const repo = context.repo.repo; + + function renderCommit(commitMsg) { + const prRegex = /\(#(\d+)\)$/m; + const allRefsRegex = /#(\d+)\s/g; + + const prRef = commitMsg.match(prRegex); + + if (!prRef) { + return `- ${"???" + commitMsg}`; + } + + const prNumber = prRef[1]; + + const otherReferences = [...commitMsg.matchAll(allRefsRegex)] + .map((m) => m[1]) + .filter((r) => r !== prNumber); + + const messages = [ + `- #${prNumber}`, + ...otherReferences.map((r) => ` - re #${r}`), + ]; + + return messages.join("\n"); + } + + function buildSection(title, commitMsgs) { + if (commitMsgs.length === 0) return ""; + + return `\n## ${title}\n\n${commitMsgs.map(renderCommit).join("\n")}`; + } + + function buildBody(messages) { + const groups = [ + { + type: "feat", + title: "Features", + }, + { + type: "fix", + title: "Fixes", + }, + { + type: "chore", + title: "Chores", + }, + { + type: "refactor", + title: "Refactors", + }, + ]; + + const knownSections = groups.map((group) => + buildSection( + group.title, + messages.filter((m) => m.startsWith(group.type)) + ) + ); + + const allKnownSections = groups.map((g) => g.type); + const unknownSections = buildSection( + "Other", + messages.filter((m) => !allKnownSections.some((s) => m.startsWith(s))) + ); + + return [...knownSections, unknownSections].join("\n"); + } + + async function findReleasePR() { + const { data: prs } = await github.rest.pulls.list({ + owner, + repo, + base: "prod", + state: "open", + }); + + if (prs.length === 0) { + console.log("no release PR found"); + return undefined; + } + + if (prs.length !== 1) { + throw new Error(`${prs.length} production PRs found...`); + } + + const pr = prs[0]; + + console.log( + `Found PR#${pr.number} ${pr.title} ${ + pr.draft ? "(draft)" : "(not draft)" + } - ${pr.html_url}` + ); + return pr; + } + + const pr = await findReleasePR(); + + if (!pr) { + console.log("checking commits..."); + const commits = await github.rest.repos.compareCommitsWithBasehead({ + owner, + repo, + basehead: "prod...master", + }); + + if (commits.data.commits.length === 0) { + console.log("no changes to release!"); + return; + } + + const commitMessages = commits.data.commits.map((c) => c.commit.message); + + const result = buildBody(commitMessages); + + console.log("need to create a draft release PR!", result); + + // but don't actually do it yet: if you create one using the github + // token, it doesn't run the security jobs. Create an empty one by hand, + // for now. + /* + const newPr = await github.rest.pulls.create({ + owner, + repo, + title: "Production Release (next)", + head: "master", + base: "prod", + draft: true, + body: result, + }); + + console.log("created: " + newPr.data.html_url); + */ + } else { + const { data: commits } = await github.rest.pulls.listCommits({ + owner, + repo, + pull_number: pr.number, + }); + + const commitMessages = commits.map((c) => c.commit.message); + const body = buildBody(commitMessages); + const title = `Production Release ${new Date().toISOString().slice(0, 10)}`; + + if (body === pr.body && title === pr.title) { + console.log("up to date!"); + } else { + console.log(`Updating PR!`, { title, body }); + + await github.rest.pulls.update({ + owner, + repo, + pull_number: pr.number, + body, + title, + }); + } + } From 1e542edd5634bd6c28eb37eb014d25b3a2eebaf5 Mon Sep 17 00:00:00 2001 From: Ian Krieger <48930920+IanKrieger@users.noreply.github.com> Date: Thu, 12 Oct 2023 09:46:48 -0400 Subject: [PATCH 2/8] feat: creative management screen (#928) * wip: add creative management screen * wip: add creative management screen * feat: create and edit view, change verbiage, introduce floating button * fix: schema * feat: fix dirty submit * fix: validation for inline creative * fix: submit from dialog * fix: make state rules more clear * fix: submit process * fix: spelling * fix: grammar --- package-lock.json | 39 +++ package.json | 1 + src/auth/registration/Register.tsx | 2 +- src/components/Assets/ImageAutocomplete.tsx | 7 +- src/components/Button/FormatHelp.tsx | 19 ++ src/components/Button/SubmitPanel.tsx | 88 ++++++ src/components/Card/CardContainer.tsx | 6 +- .../Creatives/CreativeCampaigns.tsx | 69 +++++ src/components/Creatives/CreativeForm.tsx | 145 +++++++++ src/components/Creatives/CreativeList.tsx | 203 ++++++++++++ .../Creatives/CreativeSpecificFields.tsx | 5 +- .../Creatives/CreativeStatusSwitch.tsx | 142 +++++++++ src/components/Creatives/CreativeType.tsx | 48 +++ src/components/Creatives/NewsPreview.tsx | 9 +- .../Creatives/NotificationPreview.tsx | 12 +- .../Creatives/hooks/useGetCreativeDetails.tsx | 52 ++++ .../Creatives/hooks/useSubmitCreative.tsx | 69 +++++ src/components/Drawer/MiniSideBar.tsx | 2 +- src/components/Navigation/Navbar.tsx | 5 + .../Navigation/NewCreativeButton.tsx | 27 ++ src/components/Url/UrlResolver.tsx | 13 +- src/form/FormikButton.tsx | 139 +++++++++ src/form/FormikHelpers.tsx | 63 ---- src/graphql/creative.generated.tsx | 290 ++++++++++++++++++ src/graphql/creative.graphql | 20 ++ src/user/User.tsx | 13 + src/user/ads/InlineContentAd.tsx | 36 ++- src/user/ads/NotificationAd.tsx | 36 ++- src/user/library/index.ts | 14 +- src/user/settings/UserForm.tsx | 3 +- src/user/views/adsManager/types/index.ts | 1 + .../campaign/fields/FormatField.tsx | 23 +- .../form/components/PaymentButton.tsx | 3 +- .../review/components/ReviewField.tsx | 6 +- src/util/displayState.ts | 3 + src/validation/CreativeSchema.tsx | 11 +- 36 files changed, 1474 insertions(+), 150 deletions(-) create mode 100644 src/components/Button/FormatHelp.tsx create mode 100644 src/components/Button/SubmitPanel.tsx create mode 100644 src/components/Creatives/CreativeCampaigns.tsx create mode 100644 src/components/Creatives/CreativeForm.tsx create mode 100644 src/components/Creatives/CreativeList.tsx create mode 100644 src/components/Creatives/CreativeStatusSwitch.tsx create mode 100644 src/components/Creatives/CreativeType.tsx create mode 100644 src/components/Creatives/hooks/useGetCreativeDetails.tsx create mode 100644 src/components/Creatives/hooks/useSubmitCreative.tsx create mode 100644 src/components/Navigation/NewCreativeButton.tsx create mode 100644 src/form/FormikButton.tsx 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"), From 74fd4e7a167f6e35f97add49812940986e5a1fd4 Mon Sep 17 00:00:00 2001 From: Ian Krieger <48930920+IanKrieger@users.noreply.github.com> Date: Thu, 12 Oct 2023 10:22:18 -0400 Subject: [PATCH 3/8] chore: simplify campaign management flags (#934) * chore: simplify campaign management flags * fix: rebase and update flags * fix: missed flags in different fragment --- src/auth/components/AdvertiserAgreed.tsx | 2 +- src/auth/context/auth.interface.ts | 3 +- src/auth/hooks/queries/useAdvertiser.ts | 3 +- src/auth/lib/index.ts | 3 +- src/components/Drawer/MiniSideBar.tsx | 4 +- src/components/Navigation/Navbar.tsx | 2 +- .../Navigation/NewCampaignButton.tsx | 2 +- .../Navigation/NewCreativeButton.tsx | 2 +- src/components/Navigation/NewImageButton.tsx | 2 +- src/graphql/advertiser.generated.tsx | 15 ++--- src/graphql/advertiser.graphql | 6 +- src/graphql/types.ts | 2 + src/user/User.tsx | 12 +++- src/user/campaignList/CampaignList.tsx | 56 ++++++++++--------- src/user/views/user/CampaignView.tsx | 4 +- 15 files changed, 58 insertions(+), 60 deletions(-) diff --git a/src/auth/components/AdvertiserAgreed.tsx b/src/auth/components/AdvertiserAgreed.tsx index d7512c92..3a61f215 100644 --- a/src/auth/components/AdvertiserAgreed.tsx +++ b/src/auth/components/AdvertiserAgreed.tsx @@ -20,7 +20,7 @@ import { PaymentType } from "graphql/types"; export function AdvertiserAgreed() { const { advertiser } = useAdvertiser(); const requiresPaymentAgree = - advertiser.selfServiceCreate && + advertiser.selfServiceManageCampaign && advertiser.selfServicePaymentType !== PaymentType.Netsuite; const history = useHistory(); const { setSessionUser } = useAuthContext(); diff --git a/src/auth/context/auth.interface.ts b/src/auth/context/auth.interface.ts index 5b76cd39..01def0b6 100644 --- a/src/auth/context/auth.interface.ts +++ b/src/auth/context/auth.interface.ts @@ -5,8 +5,7 @@ import { PaymentType } from "graphql/types"; export type IAdvertiser = { id: string; name: string; - selfServiceCreate: boolean; - selfServiceEdit: boolean; + selfServiceManageCampaign: boolean; selfServiceSetPrice: boolean; selfServicePaymentType: PaymentType; publicKey?: string | null; diff --git a/src/auth/hooks/queries/useAdvertiser.ts b/src/auth/hooks/queries/useAdvertiser.ts index fa2330f9..83164060 100644 --- a/src/auth/hooks/queries/useAdvertiser.ts +++ b/src/auth/hooks/queries/useAdvertiser.ts @@ -11,8 +11,7 @@ export function useAdvertiser(): { const defaultAdvertiser: IAdvertiser = { id: "", name: "", - selfServiceCreate: false, - selfServiceEdit: false, + selfServiceManageCampaign: false, selfServiceSetPrice: false, publicKey: null, selfServicePaymentType: PaymentType.Stripe, diff --git a/src/auth/lib/index.ts b/src/auth/lib/index.ts index f87048a9..037187eb 100644 --- a/src/auth/lib/index.ts +++ b/src/auth/lib/index.ts @@ -7,8 +7,7 @@ import { RegistrationForm } from "auth/registration/types"; export type Advertiser = Pick< AdvertiserFragment, | "selfServiceSetPrice" - | "selfServiceCreate" - | "selfServiceEdit" + | "selfServiceManageCampaign" | "id" | "name" | "publicKey" diff --git a/src/components/Drawer/MiniSideBar.tsx b/src/components/Drawer/MiniSideBar.tsx index 88f16990..f6deb44e 100644 --- a/src/components/Drawer/MiniSideBar.tsx +++ b/src/components/Drawer/MiniSideBar.tsx @@ -54,7 +54,7 @@ export default function MiniSideBar({ children }: PropsWithChildren) { sx={{ color: "text.secondary" }} /> ), - disabled: !advertiser.selfServiceCreate, + disabled: !advertiser.selfServiceManageCampaign, }, { label: "Creatives", @@ -65,7 +65,7 @@ export default function MiniSideBar({ children }: PropsWithChildren) { sx={{ color: "text.secondary" }} /> ), - disabled: !advertiser.selfServiceCreate, + disabled: !advertiser.selfServiceManageCampaign, }, { label: "Audiences", diff --git a/src/components/Navigation/Navbar.tsx b/src/components/Navigation/Navbar.tsx index 9716b9b2..150e358b 100644 --- a/src/components/Navigation/Navbar.tsx +++ b/src/components/Navigation/Navbar.tsx @@ -44,7 +44,7 @@ export function Navbar() { Ads - {advertiser.selfServiceCreate && } + {advertiser.selfServiceManageCampaign && }
{ diff --git a/src/components/Navigation/NewCampaignButton.tsx b/src/components/Navigation/NewCampaignButton.tsx index 93674608..9f5fe6c6 100644 --- a/src/components/Navigation/NewCampaignButton.tsx +++ b/src/components/Navigation/NewCampaignButton.tsx @@ -12,7 +12,7 @@ export function NewCampaignButton() { .utc() .valueOf()}/settings`; - if (!advertiser.selfServiceCreate) { + if (!advertiser.selfServiceManageCampaign) { return null; } diff --git a/src/components/Navigation/NewCreativeButton.tsx b/src/components/Navigation/NewCreativeButton.tsx index d95529a9..b8573c70 100644 --- a/src/components/Navigation/NewCreativeButton.tsx +++ b/src/components/Navigation/NewCreativeButton.tsx @@ -8,7 +8,7 @@ export function NewCreativeButton() { const { advertiser } = useAdvertiser(); const isNewCreativePage = url.includes(newUrl); - if (!advertiser.selfServiceCreate) { + if (!advertiser.selfServiceManageCampaign) { return null; } diff --git a/src/components/Navigation/NewImageButton.tsx b/src/components/Navigation/NewImageButton.tsx index 5ff6d836..aee62d2f 100644 --- a/src/components/Navigation/NewImageButton.tsx +++ b/src/components/Navigation/NewImageButton.tsx @@ -4,7 +4,7 @@ import { Button } from "@mui/material"; export function NewImageButton(props: { onClick: () => void }) { const { advertiser } = useAdvertiser(); - if (!advertiser.selfServiceCreate) { + if (!advertiser.selfServiceManageCampaign) { return null; } diff --git a/src/graphql/advertiser.generated.tsx b/src/graphql/advertiser.generated.tsx index 6357b476..26c792a2 100644 --- a/src/graphql/advertiser.generated.tsx +++ b/src/graphql/advertiser.generated.tsx @@ -18,8 +18,7 @@ export type AdvertiserSummaryFragment = { export type AdvertiserFragment = { referrer?: string | null; phone?: string | null; - selfServiceEdit: boolean; - selfServiceCreate: boolean; + selfServiceManageCampaign: boolean; selfServiceSetPrice: boolean; id: string; name: string; @@ -58,8 +57,7 @@ export type UpdateAdvertiserMutation = { export type AdvertiserCampaignsFragment = { id: string; name: string; - selfServiceEdit: boolean; - selfServiceCreate: boolean; + selfServiceManageCampaign: boolean; selfServiceSetPrice: boolean; campaigns: Array<{ id: string; @@ -95,8 +93,7 @@ export type AdvertiserCampaignsQuery = { advertiserCampaigns?: { id: string; name: string; - selfServiceEdit: boolean; - selfServiceCreate: boolean; + selfServiceManageCampaign: boolean; selfServiceSetPrice: boolean; campaigns: Array<{ id: string; @@ -193,8 +190,7 @@ export const AdvertiserFragmentDoc = gql` ...AdvertiserSummary referrer phone - selfServiceEdit - selfServiceCreate + selfServiceManageCampaign selfServiceSetPrice mailingAddress { street1 @@ -211,8 +207,7 @@ export const AdvertiserCampaignsFragmentDoc = gql` fragment AdvertiserCampaigns on Advertiser { id name - selfServiceEdit - selfServiceCreate + selfServiceManageCampaign selfServiceSetPrice campaigns { ...CampaignSummary diff --git a/src/graphql/advertiser.graphql b/src/graphql/advertiser.graphql index 9b1fd5f9..d455607b 100644 --- a/src/graphql/advertiser.graphql +++ b/src/graphql/advertiser.graphql @@ -13,8 +13,7 @@ fragment Advertiser on Advertiser { ...AdvertiserSummary referrer phone - selfServiceEdit - selfServiceCreate + selfServiceManageCampaign selfServiceSetPrice mailingAddress { street1 @@ -43,8 +42,7 @@ mutation updateAdvertiser($updateAdvertiserInput: UpdateAdvertiserInput!) { fragment AdvertiserCampaigns on Advertiser { id name - selfServiceEdit - selfServiceCreate + selfServiceManageCampaign selfServiceSetPrice campaigns { ...CampaignSummary diff --git a/src/graphql/types.ts b/src/graphql/types.ts index 24df2ce3..bb73892f 100644 --- a/src/graphql/types.ts +++ b/src/graphql/types.ts @@ -179,6 +179,7 @@ export type CreateAdvertiserInput = { referrer?: InputMaybe; selfServiceCreate?: InputMaybe; selfServiceEdit?: InputMaybe; + selfServiceManageCampaign?: InputMaybe; state?: InputMaybe; url?: InputMaybe; userId?: InputMaybe; @@ -459,6 +460,7 @@ export type UpdateAdvertiserInput = { referrer?: InputMaybe; selfServiceCreate?: InputMaybe; selfServiceEdit?: InputMaybe; + selfServiceManageCampaign?: InputMaybe; state?: InputMaybe; url?: InputMaybe; userId?: InputMaybe; diff --git a/src/user/User.tsx b/src/user/User.tsx index 066857a1..d6adea0e 100644 --- a/src/user/User.tsx +++ b/src/user/User.tsx @@ -66,19 +66,25 @@ export function User() { a.selfServiceCreate} + validateAdvertiserProperty={(a) => + a.selfServiceManageCampaign + } /> a.selfServiceEdit} + validateAdvertiserProperty={(a) => + a.selfServiceManageCampaign + } /> a.selfServiceCreate} + validateAdvertiserProperty={(a) => + a.selfServiceManageCampaign + } /> [] = [ - { - title: "On/Off", - value: (c) => c.state, - extendedRenderer: (r) => - campaignOnOffState({ - ...r, - advertiserId: advertiser?.id ?? "", - }), - sx: { width: "1px", p: 0 }, - sortable: false, - }, { title: "Campaign", value: (c) => c.name, @@ -137,22 +126,35 @@ export function CampaignList({ }, ]; - if (advertiser?.selfServiceCreate && advertiser.selfServiceEdit) { - initialSort += 1; - columns.unshift({ - title: "", - value: (c) => c.id, - sortable: false, - extendedRenderer: (r) => ( - - ), - align: "center", - sx: { width: "1px" }, - }); + if (advertiser?.selfServiceManageCampaign) { + initialSort += 2; + columns.unshift( + { + title: "On/Off", + value: (c) => c.state, + extendedRenderer: (r) => + campaignOnOffState({ + ...r, + advertiserId: advertiser?.id ?? "", + }), + sx: { width: "1px", p: 0 }, + sortable: false, + }, + { + title: "", + value: (c) => c.id, + sortable: false, + extendedRenderer: (r) => ( + + ), + align: "center", + sx: { width: "1px" }, + }, + ); } return ( diff --git a/src/user/views/user/CampaignView.tsx b/src/user/views/user/CampaignView.tsx index 850b94d8..12638300 100644 --- a/src/user/views/user/CampaignView.tsx +++ b/src/user/views/user/CampaignView.tsx @@ -18,8 +18,6 @@ export function CampaignView() { const { advertiser } = useAdvertiser(); const { fromDate } = useContext(FilterContext); const [selectedCampaigns, setSelectedCampaigns] = useState([]); - const advertiserCanAction = - advertiser.selfServiceCreate && advertiser.selfServiceEdit; const handleCampaignSelect = useCallback( (c: string, include: boolean) => { @@ -60,7 +58,7 @@ export function CampaignView() { ) : ( "Campaigns" From cafd27c80516e2f0549b73218c19fb0baccf236a Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Thu, 12 Oct 2023 11:38:38 -0400 Subject: [PATCH 4/8] chore(deps): update dependency node to v18.18.1 (#932) * chore(deps): update dependency node to v18.18.1 * fix: change order --------- Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> Co-authored-by: Ian Krieger --- .node-version | 2 +- src/user/campaignList/CampaignList.tsx | 22 +++++++++++----------- 2 files changed, 12 insertions(+), 12 deletions(-) diff --git a/.node-version b/.node-version index 02c8b485..f6610cad 100644 --- a/.node-version +++ b/.node-version @@ -1 +1 @@ -18.18.0 +18.18.1 diff --git a/src/user/campaignList/CampaignList.tsx b/src/user/campaignList/CampaignList.tsx index 97c3738e..668aa60a 100644 --- a/src/user/campaignList/CampaignList.tsx +++ b/src/user/campaignList/CampaignList.tsx @@ -129,17 +129,6 @@ export function CampaignList({ if (advertiser?.selfServiceManageCampaign) { initialSort += 2; columns.unshift( - { - title: "On/Off", - value: (c) => c.state, - extendedRenderer: (r) => - campaignOnOffState({ - ...r, - advertiserId: advertiser?.id ?? "", - }), - sx: { width: "1px", p: 0 }, - sortable: false, - }, { title: "", value: (c) => c.id, @@ -154,6 +143,17 @@ export function CampaignList({ align: "center", sx: { width: "1px" }, }, + { + title: "On/Off", + value: (c) => c.state, + extendedRenderer: (r) => + campaignOnOffState({ + ...r, + advertiserId: advertiser?.id ?? "", + }), + sx: { width: "1px", p: 0 }, + sortable: false, + }, ); } From 349a1addbd52a2705efc47a0725634f6e4613926 Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Fri, 13 Oct 2023 11:29:23 +0100 Subject: [PATCH 5/8] chore(deps): update github/codeql-action action to v2.22.2 (#930) Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> --- .github/workflows/codeql-analysis.yml | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/.github/workflows/codeql-analysis.yml b/.github/workflows/codeql-analysis.yml index 65c6f4d7..646493dd 100644 --- a/.github/workflows/codeql-analysis.yml +++ b/.github/workflows/codeql-analysis.yml @@ -42,7 +42,7 @@ jobs: # Initializes the CodeQL tools for scanning. - name: Initialize CodeQL - uses: github/codeql-action/init@ddccb873888234080b77e9bc2d4764d5ccaaccf9 # v2.21.9 + uses: github/codeql-action/init@d90b8d79de6dc1f58e83a1499aa58d6c93dc28de # v2.22.2 with: languages: ${{ matrix.language }} # If you wish to specify custom queries, you can do so here or in a config file. @@ -53,7 +53,7 @@ jobs: # Autobuild attempts to build any compiled languages (C/C++, C#, or Java). # If this step fails, then you should remove it and run the build manually (see below) - name: Autobuild - uses: github/codeql-action/autobuild@ddccb873888234080b77e9bc2d4764d5ccaaccf9 # v2.21.9 + uses: github/codeql-action/autobuild@d90b8d79de6dc1f58e83a1499aa58d6c93dc28de # v2.22.2 # ℹ️ Command-line programs to run using the OS shell. # 📚 https://git.io/JvXDl @@ -67,4 +67,4 @@ jobs: # make release - name: Perform CodeQL Analysis - uses: github/codeql-action/analyze@ddccb873888234080b77e9bc2d4764d5ccaaccf9 # v2.21.9 + uses: github/codeql-action/analyze@d90b8d79de6dc1f58e83a1499aa58d6c93dc28de # v2.22.2 From cba26db1db1b02e7db90a50be7ee3ec1d6dfe7c9 Mon Sep 17 00:00:00 2001 From: Graham Tackley Date: Fri, 13 Oct 2023 14:04:27 +0100 Subject: [PATCH 6/8] chore: remove fields from queries not required by the code (#937) - `execution` is long obsolete - search adset properties are not (yet) required --- src/form/fragmentUtil.ts | 5 - src/graphql/ad-set.generated.tsx | 62 ++++------- src/graphql/ad-set.graphql | 5 - src/graphql/advertiser.generated.tsx | 4 +- src/graphql/analytics-overview.generated.tsx | 12 +-- src/graphql/campaign.generated.tsx | 102 +++++++------------ src/user/library/index.test.ts | 3 +- 7 files changed, 68 insertions(+), 125 deletions(-) diff --git a/src/form/fragmentUtil.ts b/src/form/fragmentUtil.ts index 4329fa13..92b8d36f 100644 --- a/src/form/fragmentUtil.ts +++ b/src/form/fragmentUtil.ts @@ -54,18 +54,13 @@ export function createAdSetFromFragment( creativeId: ad.creative.id, })), price: ads[0].price ?? "6", - 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) => ({ diff --git a/src/graphql/ad-set.generated.tsx b/src/graphql/ad-set.generated.tsx index 21e666d7..a0963254 100644 --- a/src/graphql/ad-set.generated.tsx +++ b/src/graphql/ad-set.generated.tsx @@ -9,24 +9,19 @@ export type AdSetFragment = { price?: string | null; createdAt: any; billingType?: string | null; - name?: string | null; + name: string; totalMax: number; perDay: number; state: string; - execution?: string | null; - keywords?: Array | null; - keywordSimilarity?: number | null; - negativeKeywords?: Array | null; - bannedKeywords?: Array | null; - segments?: Array<{ code: string; name: string }> | null; - oses?: Array<{ code: string; name: string }> | null; - conversions?: Array<{ + segments: Array<{ code: string; name: string }>; + oses: Array<{ code: string; name: string }>; + conversions: Array<{ id: string; type: string; urlPattern: string; observationWindow: number; - }> | null; - ads?: Array<{ + }>; + ads: Array<{ id: string; state: string; price: string; @@ -73,7 +68,7 @@ export type AdSetFragment = { ctaText: string; } | null; }; - }> | null; + }>; }; export type AdFragment = { @@ -135,24 +130,19 @@ export type CreateAdSetMutation = { price?: string | null; createdAt: any; billingType?: string | null; - name?: string | null; + name: string; totalMax: number; perDay: number; state: string; - execution?: string | null; - keywords?: Array | null; - keywordSimilarity?: number | null; - negativeKeywords?: Array | null; - bannedKeywords?: Array | null; - segments?: Array<{ code: string; name: string }> | null; - oses?: Array<{ code: string; name: string }> | null; - conversions?: Array<{ + segments: Array<{ code: string; name: string }>; + oses: Array<{ code: string; name: string }>; + conversions: Array<{ id: string; type: string; urlPattern: string; observationWindow: number; - }> | null; - ads?: Array<{ + }>; + ads: Array<{ id: string; state: string; price: string; @@ -203,7 +193,7 @@ export type CreateAdSetMutation = { ctaText: string; } | null; }; - }> | null; + }>; }; }; @@ -217,24 +207,19 @@ export type UpdateAdSetMutation = { price?: string | null; createdAt: any; billingType?: string | null; - name?: string | null; + name: string; totalMax: number; perDay: number; state: string; - execution?: string | null; - keywords?: Array | null; - keywordSimilarity?: number | null; - negativeKeywords?: Array | null; - bannedKeywords?: Array | null; - segments?: Array<{ code: string; name: string }> | null; - oses?: Array<{ code: string; name: string }> | null; - conversions?: Array<{ + segments: Array<{ code: string; name: string }>; + oses: Array<{ code: string; name: string }>; + conversions: Array<{ id: string; type: string; urlPattern: string; observationWindow: number; - }> | null; - ads?: Array<{ + }>; + ads: Array<{ id: string; state: string; price: string; @@ -285,7 +270,7 @@ export type UpdateAdSetMutation = { ctaText: string; } | null; }; - }> | null; + }>; }; }; @@ -311,11 +296,6 @@ export const AdSetFragmentDoc = gql` totalMax 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 c25d3df0..5da7f0ed 100644 --- a/src/graphql/ad-set.graphql +++ b/src/graphql/ad-set.graphql @@ -7,11 +7,6 @@ fragment AdSet on AdSet { totalMax 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 26c792a2..dfdc488f 100644 --- a/src/graphql/advertiser.generated.tsx +++ b/src/graphql/advertiser.generated.tsx @@ -68,7 +68,7 @@ export type AdvertiserCampaignsFragment = { passThroughRate: number; pacingOverride: boolean; pacingStrategy: Types.CampaignPacingStrategies; - externalId: string; + externalId?: string | null; currency: string; budget: number; paymentType: Types.PaymentType; @@ -104,7 +104,7 @@ export type AdvertiserCampaignsQuery = { passThroughRate: number; pacingOverride: boolean; pacingStrategy: Types.CampaignPacingStrategies; - externalId: string; + externalId?: string | null; currency: string; budget: number; paymentType: Types.PaymentType; diff --git a/src/graphql/analytics-overview.generated.tsx b/src/graphql/analytics-overview.generated.tsx index 5690eb78..2144fc4f 100644 --- a/src/graphql/analytics-overview.generated.tsx +++ b/src/graphql/analytics-overview.generated.tsx @@ -38,9 +38,9 @@ export type CampaignWithEngagementsFragment = { pacingIndex?: number | null; format: Types.CampaignFormat; adSets: Array<{ - conversions?: Array<{ type: string; extractExternalId: boolean }> | null; + conversions: Array<{ type: string; extractExternalId: boolean }>; }>; - engagements?: Array<{ + engagements: Array<{ creativeinstanceid: string; createdat: any; type: string; @@ -59,7 +59,7 @@ export type CampaignWithEngagementsFragment = { linux: number; macos: number; windows: number; - }> | null; + }>; }; export type AnalyticOverviewQueryVariables = Types.Exact<{ @@ -81,9 +81,9 @@ export type AnalyticOverviewQuery = { pacingIndex?: number | null; format: Types.CampaignFormat; adSets: Array<{ - conversions?: Array<{ type: string; extractExternalId: boolean }> | null; + conversions: Array<{ type: string; extractExternalId: boolean }>; }>; - engagements?: Array<{ + engagements: Array<{ creativeinstanceid: string; createdat: any; type: string; @@ -102,7 +102,7 @@ export type AnalyticOverviewQuery = { linux: number; macos: number; windows: number; - }> | null; + }>; } | null; }; diff --git a/src/graphql/campaign.generated.tsx b/src/graphql/campaign.generated.tsx index a0091b7f..250a583f 100644 --- a/src/graphql/campaign.generated.tsx +++ b/src/graphql/campaign.generated.tsx @@ -13,7 +13,7 @@ export type CampaignFragment = { passThroughRate: number; pacingOverride: boolean; pacingStrategy: Types.CampaignPacingStrategies; - externalId: string; + externalId?: string | null; currency: string; budget: number; dailyBudget: number; @@ -27,36 +27,27 @@ export type CampaignFragment = { paymentType: Types.PaymentType; dayProportion?: number | null; stripePaymentId?: string | null; - hasPaymentIntent?: boolean | null; - dayPartings?: Array<{ - dow: string; - startMinute: number; - endMinute: number; - }> | null; - geoTargets?: Array<{ code: string; name: string }> | null; + hasPaymentIntent: boolean; + dayPartings: Array<{ dow: string; startMinute: number; endMinute: number }>; + geoTargets: Array<{ code: string; name: string }>; adSets: Array<{ id: string; price?: string | null; createdAt: any; billingType?: string | null; - name?: string | null; + name: string; totalMax: number; perDay: number; state: string; - execution?: string | null; - keywords?: Array | null; - keywordSimilarity?: number | null; - negativeKeywords?: Array | null; - bannedKeywords?: Array | null; - segments?: Array<{ code: string; name: string }> | null; - oses?: Array<{ code: string; name: string }> | null; - conversions?: Array<{ + segments: Array<{ code: string; name: string }>; + oses: Array<{ code: string; name: string }>; + conversions: Array<{ id: string; type: string; urlPattern: string; observationWindow: number; - }> | null; - ads?: Array<{ + }>; + ads: Array<{ id: string; state: string; price: string; @@ -107,7 +98,7 @@ export type CampaignFragment = { ctaText: string; } | null; }; - }> | null; + }>; }>; advertiser: { id: string }; }; @@ -121,7 +112,7 @@ export type CampaignSummaryFragment = { passThroughRate: number; pacingOverride: boolean; pacingStrategy: Types.CampaignPacingStrategies; - externalId: string; + externalId?: string | null; currency: string; budget: number; paymentType: Types.PaymentType; @@ -151,24 +142,19 @@ export type CampaignAdsFragment = { price?: string | null; createdAt: any; billingType?: string | null; - name?: string | null; + name: string; totalMax: number; perDay: number; state: string; - execution?: string | null; - keywords?: Array | null; - keywordSimilarity?: number | null; - negativeKeywords?: Array | null; - bannedKeywords?: Array | null; - segments?: Array<{ code: string; name: string }> | null; - oses?: Array<{ code: string; name: string }> | null; - conversions?: Array<{ + segments: Array<{ code: string; name: string }>; + oses: Array<{ code: string; name: string }>; + conversions: Array<{ id: string; type: string; urlPattern: string; observationWindow: number; - }> | null; - ads?: Array<{ + }>; + ads: Array<{ id: string; state: string; price: string; @@ -219,7 +205,7 @@ export type CampaignAdsFragment = { ctaText: string; } | null; }; - }> | null; + }>; }>; }; @@ -237,7 +223,7 @@ export type LoadCampaignQuery = { passThroughRate: number; pacingOverride: boolean; pacingStrategy: Types.CampaignPacingStrategies; - externalId: string; + externalId?: string | null; currency: string; budget: number; dailyBudget: number; @@ -251,36 +237,27 @@ export type LoadCampaignQuery = { paymentType: Types.PaymentType; dayProportion?: number | null; stripePaymentId?: string | null; - hasPaymentIntent?: boolean | null; - dayPartings?: Array<{ - dow: string; - startMinute: number; - endMinute: number; - }> | null; - geoTargets?: Array<{ code: string; name: string }> | null; + hasPaymentIntent: boolean; + dayPartings: Array<{ dow: string; startMinute: number; endMinute: number }>; + geoTargets: Array<{ code: string; name: string }>; adSets: Array<{ id: string; price?: string | null; createdAt: any; billingType?: string | null; - name?: string | null; + name: string; totalMax: number; perDay: number; state: string; - execution?: string | null; - keywords?: Array | null; - keywordSimilarity?: number | null; - negativeKeywords?: Array | null; - bannedKeywords?: Array | null; - segments?: Array<{ code: string; name: string }> | null; - oses?: Array<{ code: string; name: string }> | null; - conversions?: Array<{ + segments: Array<{ code: string; name: string }>; + oses: Array<{ code: string; name: string }>; + conversions: Array<{ id: string; type: string; urlPattern: string; observationWindow: number; - }> | null; - ads?: Array<{ + }>; + ads: Array<{ id: string; state: string; price: string; @@ -331,7 +308,7 @@ export type LoadCampaignQuery = { ctaText: string; } | null; }; - }> | null; + }>; }>; advertiser: { id: string }; } | null; @@ -357,24 +334,19 @@ export type LoadCampaignAdsQuery = { price?: string | null; createdAt: any; billingType?: string | null; - name?: string | null; + name: string; totalMax: number; perDay: number; state: string; - execution?: string | null; - keywords?: Array | null; - keywordSimilarity?: number | null; - negativeKeywords?: Array | null; - bannedKeywords?: Array | null; - segments?: Array<{ code: string; name: string }> | null; - oses?: Array<{ code: string; name: string }> | null; - conversions?: Array<{ + segments: Array<{ code: string; name: string }>; + oses: Array<{ code: string; name: string }>; + conversions: Array<{ id: string; type: string; urlPattern: string; observationWindow: number; - }> | null; - ads?: Array<{ + }>; + ads: Array<{ id: string; state: string; price: string; @@ -425,7 +397,7 @@ export type LoadCampaignAdsQuery = { ctaText: string; } | null; }; - }> | null; + }>; }>; } | null; }; diff --git a/src/user/library/index.test.ts b/src/user/library/index.test.ts index e22d2ec2..941cf973 100644 --- a/src/user/library/index.test.ts +++ b/src/user/library/index.test.ts @@ -41,6 +41,8 @@ const BASE_CPM_CAMPAIGN_FRAGMENT: Readonly = { type: "paid", format: CampaignFormat.PushNotification, paymentType: PaymentType.Stripe, + dayPartings: [], + hasPaymentIntent: false, geoTargets: [ { code: "US", @@ -57,7 +59,6 @@ const BASE_CPM_CAMPAIGN_FRAGMENT: Readonly = { totalMax: 10, perDay: 1, state: "active", - execution: "per_click", segments: [ { code: "elchqV0qNh", From fd1d3f8fb76163e7055a5092fd9f769e353148ff Mon Sep 17 00:00:00 2001 From: Ian Krieger <48930920+IanKrieger@users.noreply.github.com> Date: Fri, 13 Oct 2023 09:19:48 -0400 Subject: [PATCH 7/8] fix: state switches (#936) * fix: state switches * fix: no need for useEffect --- src/components/Creatives/CreativeStatusSwitch.tsx | 8 ++------ src/components/Switch/OnOff.tsx | 7 ++----- 2 files changed, 4 insertions(+), 11 deletions(-) diff --git a/src/components/Creatives/CreativeStatusSwitch.tsx b/src/components/Creatives/CreativeStatusSwitch.tsx index f9747fe8..9a2c593f 100644 --- a/src/components/Creatives/CreativeStatusSwitch.tsx +++ b/src/components/Creatives/CreativeStatusSwitch.tsx @@ -50,9 +50,6 @@ export function CreativeStatusSwitch({ creative }: Props) { onCompleted() { setRelatedCampaigns([]); }, - onError() { - setCreativeState(input.state); - }, }); const [campaigns, { loading }] = useCampaignsForCreativeLazyQuery({ variables: { creativeId: creative.id, advertiserId: advertiser.id }, @@ -86,13 +83,13 @@ export function CreativeStatusSwitch({ creative }: Props) { }, }); }} - checked={creativeState === "active"} + checked={creative.state === "active"} disabled={loading || updateLoading} /> 0}> Are you sure you want to{" "} - {creativeState === "active" ? "activate" : "pause"}{" "} + {creative.state === "active" ? "activate" : "pause"}{" "} {`"${input.name}"`} @@ -115,7 +112,6 @@ export function CreativeStatusSwitch({ creative }: Props) { variant="outlined" onClick={() => { setRelatedCampaigns([]); - setCreativeState(creative.state); }} disabled={updateLoading} > diff --git a/src/components/Switch/OnOff.tsx b/src/components/Switch/OnOff.tsx index 34cc9b20..01024a48 100644 --- a/src/components/Switch/OnOff.tsx +++ b/src/components/Switch/OnOff.tsx @@ -1,4 +1,3 @@ -import { useState } from "react"; import { isPast, parseISO } from "date-fns"; import { Switch, Tooltip, Typography } from "@mui/material"; import { CampaignSource } from "graphql/types"; @@ -22,7 +21,6 @@ export function OnOff({ source, isInline, }: Props) { - const [checked, setChecked] = useState(state === "active"); const isAfterEnd = isPast(parseISO(end)); const enabled = source === CampaignSource.SelfServe && @@ -33,7 +31,7 @@ export function OnOff({ isInline ? null : ( - ); - const tooltip = checked ? "Pause" : "Activate"; + const tooltip = state === "paused" ? "Pause" : "Activate"; return ( { const theState = e.target.checked ? "active" : "paused"; - setChecked(e.target.checked); onChange(theState); }} - checked={checked} + checked={state === "active"} disabled={loading} /> ) : ( From 7f6b46e223f576bd46fd290eb3741e953e926c30 Mon Sep 17 00:00:00 2001 From: Ian Krieger <48930920+IanKrieger@users.noreply.github.com> Date: Fri, 13 Oct 2023 11:27:55 -0400 Subject: [PATCH 8/8] chore: update to datagrid (#935) * wip: switch to datagrid * chore: make container flex better, switch ad set list to datagrid * fix: field mistake * fix: adjust width settings * fix: move clone and edit into datagrid table --- src/components/Campaigns/CloneCampaign.tsx | 99 ++++--- src/components/Creatives/CreativeList.tsx | 69 ++--- src/components/Datagrid/CustomToolbar.tsx | 20 ++ .../{EnhancedTable => Datagrid}/renderers.tsx | 9 +- .../EnhancedTable/EnhancedTable.tsx | 258 ------------------ .../EnhancedTable/EnhancedTableHeader.tsx | 30 -- .../EnhancedTable/EnhancedTableRow.tsx | 22 -- src/components/EnhancedTable/FilterInput.tsx | 48 ---- src/components/EnhancedTable/index.ts | 2 - src/user/User.tsx | 141 +++++----- src/user/adSet/AdSetList.tsx | 77 ++++-- src/user/ads/AdList.tsx | 52 ++-- .../components/BaseBarChart.tsx | 5 +- .../components/BasePieChart.tsx | 5 +- .../components/HighchartsWrapper.tsx | 27 ++ .../analyticsOverview/lib/overview.library.ts | 4 +- .../reports/campaign/EngagementsOverview.tsx | 7 +- .../reports/creative/CreativeOverview.tsx | 172 ------------ src/user/analytics/renderers/index.tsx | 2 +- src/user/campaignList/CampaignList.tsx | 248 +++++++++-------- src/user/campaignList/EditButton.tsx | 41 +++ .../advanced/components/adSet/AdSetFields.tsx | 2 +- src/user/views/user/AdDetailTable.tsx | 92 ++++--- src/user/views/user/CampaignView.tsx | 93 +------ 24 files changed, 546 insertions(+), 979 deletions(-) create mode 100644 src/components/Datagrid/CustomToolbar.tsx rename src/components/{EnhancedTable => Datagrid}/renderers.tsx (93%) delete mode 100644 src/components/EnhancedTable/EnhancedTable.tsx delete mode 100644 src/components/EnhancedTable/EnhancedTableHeader.tsx delete mode 100644 src/components/EnhancedTable/EnhancedTableRow.tsx delete mode 100644 src/components/EnhancedTable/FilterInput.tsx delete mode 100644 src/components/EnhancedTable/index.ts create mode 100644 src/user/analytics/analyticsOverview/components/HighchartsWrapper.tsx delete mode 100644 src/user/analytics/analyticsOverview/reports/creative/CreativeOverview.tsx create mode 100644 src/user/campaignList/EditButton.tsx diff --git a/src/components/Campaigns/CloneCampaign.tsx b/src/components/Campaigns/CloneCampaign.tsx index a8915a61..088c31ab 100644 --- a/src/components/Campaigns/CloneCampaign.tsx +++ b/src/components/Campaigns/CloneCampaign.tsx @@ -1,17 +1,18 @@ import { Box, Button, - Chip, Dialog, DialogActions, DialogContent, DialogContentText, DialogTitle, LinearProgress, + Tooltip, } from "@mui/material"; import { - CampaignFragment, + CampaignSummaryFragment, useCreateCampaignMutation, + useLoadCampaignLazyQuery, } from "graphql/campaign.generated"; import { useHistory } from "react-router-dom"; import { useContext, useState } from "react"; @@ -21,20 +22,21 @@ import { useAdvertiser } from "auth/hooks/queries/useAdvertiser"; import ContentCopyIcon from "@mui/icons-material/ContentCopy"; import { useUser } from "auth/hooks/queries/useUser"; import { FilterContext } from "state/context"; +import { CampaignFormat, CampaignSource } from "graphql/types"; interface Props { - campaignFragment?: CampaignFragment | null; - useChip?: boolean; + campaign?: CampaignSummaryFragment; disabled?: boolean; } -export function CloneCampaign({ campaignFragment, useChip, disabled }: Props) { +export function CloneCampaign({ campaign, disabled }: Props) { const { advertiser } = useAdvertiser(); const { fromDate } = useContext(FilterContext); const { userId } = useUser(); const history = useHistory(); const [open, setOpen] = useState(false); + const [getCampaign, { loading: getLoading }] = useLoadCampaignLazyQuery(); const [copyCampaign, { loading }] = useCreateCampaignMutation({ refetchQueries: [ { @@ -54,59 +56,72 @@ export function CloneCampaign({ campaignFragment, useChip, disabled }: Props) { }, }); + const doClone = async () => { + if (campaign) { + getCampaign({ + variables: { id: campaign.id }, + onCompleted(data) { + if (data.campaign) { + copyCampaign({ + variables: { + input: createCampaignFromFragment(data.campaign, userId), + }, + }); + } else { + alert("Unable to clone campaign"); + } + }, + }); + } + }; + + const canClone = + campaign && + campaign.source === CampaignSource.SelfServe && + [CampaignFormat.PushNotification, CampaignFormat.NewsDisplayAd].includes( + campaign.format, + ); return ( - {useChip ? ( - { - setOpen(true); - }} - disabled={loading || !campaignFragment || disabled} - icon={} - /> - ) : ( - - )} + + + + + setOpen(false)}> - {`Copy campaign: "${campaignFragment?.name}"?`} + {`Copy campaign: "${campaign?.name}"?`} Cloning a campaign will take all properties including ad sets and ads, and create a new draft campaign with them. - {loading && } + {(loading || getLoading) && } + + + ); +}; diff --git a/src/user/views/adsManager/views/advanced/components/adSet/AdSetFields.tsx b/src/user/views/adsManager/views/advanced/components/adSet/AdSetFields.tsx index 9daf4f26..393eeec1 100644 --- a/src/user/views/adsManager/views/advanced/components/adSet/AdSetFields.tsx +++ b/src/user/views/adsManager/views/advanced/components/adSet/AdSetFields.tsx @@ -4,7 +4,7 @@ import { CardContainer } from "components/Card/CardContainer"; import { useHistory } from "react-router-dom"; import { FormikTextField, useIsEdit } from "form/FormikHelpers"; import { AdSetAds } from "user/views/adsManager/views/advanced/components/adSet/fields/AdSetAds"; -import { adSetOnOffState } from "components/EnhancedTable/renderers"; +import { adSetOnOffState } from "components/Datagrid/renderers"; import { Stack } from "@mui/material"; import { useFormikContext } from "formik"; import { CampaignForm } from "user/views/adsManager/types"; diff --git a/src/user/views/user/AdDetailTable.tsx b/src/user/views/user/AdDetailTable.tsx index 7f9e30be..a57e442c 100644 --- a/src/user/views/user/AdDetailTable.tsx +++ b/src/user/views/user/AdDetailTable.tsx @@ -1,18 +1,15 @@ -import { ColumnDescriptor, EnhancedTable } from "components/EnhancedTable"; import { CampaignAdsFragment } from "graphql/campaign.generated"; import { CampaignFormat } from "graphql/types"; import { StatsMetric } from "user/analytics/analyticsOverview/types"; import { renderStatsCell } from "user/analytics/renderers"; +import { DataGrid, GridColDef, GridValidRowModel } from "@mui/x-data-grid"; -interface Props { +interface Props { rows: T[]; - columns: ColumnDescriptor[]; + columns: GridColDef[]; engagements: Map; loading: boolean; campaign?: Omit | null; - propOverride?: { - initialSortColumn?: number; - }; } export function AdDetailTable({ @@ -21,63 +18,94 @@ export function AdDetailTable({ campaign, engagements, loading, - propOverride, }: Props) { const displayColumns = [...columns]; if (campaign?.format !== CampaignFormat.NtpSi) { displayColumns.push( { - title: "Spend", - value: (c) => engagements.get(c.id)?.spend ?? "N/A", - extendedRenderer: (r) => + field: "spend", + headerName: "Spend", + valueGetter: ({ row }) => engagements.get(row.id)?.spend ?? "N/A", + renderCell: ({ row }) => renderStatsCell( loading, "spend", - engagements.get(r.id), + engagements.get(row.id), campaign?.currency, ), align: "right", + headerAlign: "right", + minWidth: 100, + maxWidth: 250, }, { - title: "Impressions", - value: (c) => engagements.get(c.id)?.views ?? "N/A", - extendedRenderer: (r) => - renderStatsCell(loading, "views", engagements.get(r.id)), + field: "view", + headerName: "Impressions", + valueGetter: ({ row }) => engagements.get(row.id)?.views ?? "N/A", + renderCell: ({ row }) => + renderStatsCell(loading, "views", engagements.get(row.id)), align: "right", + headerAlign: "right", + minWidth: 100, + maxWidth: 250, }, { - title: "Clicks", - value: (c) => engagements.get(c.id)?.clicks, - extendedRenderer: (r) => - renderStatsCell(loading, "clicks", engagements.get(r.id)), + field: "click", + headerName: "Clicks", + valueGetter: ({ row }) => engagements.get(row.id)?.clicks, + renderCell: ({ row }) => + renderStatsCell(loading, "clicks", engagements.get(row.id)), align: "right", + headerAlign: "right", + minWidth: 100, + maxWidth: 250, }, { - title: "10s Visits", - value: (c) => engagements.get(c.id)?.landings, - extendedRenderer: (r) => - renderStatsCell(loading, "landings", engagements.get(r.id)), + field: "landed", + headerName: "10s Visits", + valueGetter: ({ row }) => engagements.get(row.id)?.landings, + renderCell: ({ row }) => + renderStatsCell(loading, "landings", engagements.get(row.id)), align: "right", + headerAlign: "right", + minWidth: 100, + maxWidth: 250, }, { - title: "CTR", - value: (c) => engagements.get(c.id)?.ctr, - extendedRenderer: (r) => - renderStatsCell(loading, "ctr", engagements.get(r.id)), + field: "ctr", + headerName: "CTR", + valueGetter: ({ row }) => engagements.get(row.id)?.ctr, + renderCell: ({ row }) => + renderStatsCell(loading, "ctr", engagements.get(row.id)), align: "right", + headerAlign: "right", + minWidth: 100, + maxWidth: 250, }, ); } return ( - ); } diff --git a/src/user/views/user/CampaignView.tsx b/src/user/views/user/CampaignView.tsx index 12638300..48327b2c 100644 --- a/src/user/views/user/CampaignView.tsx +++ b/src/user/views/user/CampaignView.tsx @@ -1,39 +1,17 @@ -import { Box, Chip, Skeleton, Stack, Tooltip, Typography } from "@mui/material"; -import { useCallback, useContext, useState } from "react"; +import { Box, Skeleton } from "@mui/material"; +import { useContext } from "react"; import { useAdvertiserCampaignsQuery } from "graphql/advertiser.generated"; import { CampaignAgeFilter } from "components/Campaigns/CampaignAgeFilter"; import { CampaignList } from "user/campaignList/CampaignList"; import { ErrorDetail } from "components/Error/ErrorDetail"; import { CardContainer } from "components/Card/CardContainer"; import MiniSideBar from "components/Drawer/MiniSideBar"; -import { useLoadCampaignQuery } from "graphql/campaign.generated"; -import { CampaignFormat, CampaignSource } from "graphql/types"; import { useAdvertiser } from "auth/hooks/queries/useAdvertiser"; -import { Link as RouterLink } from "react-router-dom"; -import { CloneCampaign } from "components/Campaigns/CloneCampaign"; -import EditIcon from "@mui/icons-material/Edit"; import { FilterContext } from "state/context"; export function CampaignView() { const { advertiser } = useAdvertiser(); const { fromDate } = useContext(FilterContext); - const [selectedCampaigns, setSelectedCampaigns] = useState([]); - - const handleCampaignSelect = useCallback( - (c: string, include: boolean) => { - const indexOfId = selectedCampaigns.findIndex((summary) => c === summary); - if (include && indexOfId === -1) { - setSelectedCampaigns([...selectedCampaigns, c]); - } else if (!include && indexOfId >= 0) { - const res = - selectedCampaigns.length === 1 - ? [] - : selectedCampaigns.splice(indexOfId, 1); - setSelectedCampaigns(res); - } - }, - [selectedCampaigns], - ); const { loading, data, error } = useAdvertiserCampaignsQuery({ variables: { @@ -57,13 +35,8 @@ export function CampaignView() { return ( - ) : ( - "Campaigns" - ) - } + header="Campaigns" + useTypography sx={{ flexGrow: 1, overflowX: "auto", @@ -71,11 +44,7 @@ export function CampaignView() { additionalAction={} > {!loading ? ( - + ) : ( @@ -85,55 +54,3 @@ export function CampaignView() { ); } - -function CampaignHeader(props: { selectedCampaigns: string[] }) { - const editableCampaigns = [ - CampaignFormat.PushNotification, - CampaignFormat.NewsDisplayAd, - ]; - const oneCampaignSelected = props.selectedCampaigns.length === 1; - const firstCampaign = oneCampaignSelected ? props.selectedCampaigns[0] : null; - const { data, loading } = useLoadCampaignQuery({ - variables: { id: firstCampaign ?? "" }, - skip: !oneCampaignSelected || !firstCampaign, - }); - - let canClone = false; - let canEdit = false; - if (!loading && data?.campaign) { - canClone = - data.campaign.source === CampaignSource.SelfServe && - editableCampaigns.includes(data.campaign.format); - canEdit = canClone && data.campaign.state !== "completed"; - } - - return ( - - Campaigns - - - - - - - - - } - clickable - /> - - - - - ); -}