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 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, + }); + } + } 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/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/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/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/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/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/Datagrid/CustomToolbar.tsx b/src/components/Datagrid/CustomToolbar.tsx new file mode 100644 index 00000000..515200eb --- /dev/null +++ b/src/components/Datagrid/CustomToolbar.tsx @@ -0,0 +1,20 @@ +import { + GridToolbarColumnsButton, + GridToolbarContainer, + GridToolbarFilterButton, + GridToolbarQuickFilter, +} from "@mui/x-data-grid"; +import { Box } from "@mui/material"; +import { PropsWithChildren } from "react"; + +export function CustomToolbar({ children }: PropsWithChildren) { + return ( + + {children} + + + + + + ); +} diff --git a/src/components/EnhancedTable/renderers.tsx b/src/components/Datagrid/renderers.tsx similarity index 93% rename from src/components/EnhancedTable/renderers.tsx rename to src/components/Datagrid/renderers.tsx index 5594de06..78efd319 100644 --- a/src/components/EnhancedTable/renderers.tsx +++ b/src/components/Datagrid/renderers.tsx @@ -1,8 +1,7 @@ import { Box, Tooltip } from "@mui/material"; import _ from "lodash"; import { format, formatDistanceToNow, parseISO } from "date-fns"; -import { CellValue } from "./EnhancedTable"; -import { ReactChild, ReactNode, useContext } from "react"; +import { ReactElement, ReactNode, useContext } from "react"; import { formatInTimeZone } from "date-fns-tz"; import enUS from "date-fns/locale/en-US"; import { @@ -12,18 +11,18 @@ import { useUpdateCampaignMutation, } from "graphql/campaign.generated"; import { useUpdateAdSetMutation } from "graphql/ad-set.generated"; -import { OnOff } from "../Switch/OnOff"; +import { OnOff } from "components/Switch/OnOff"; import { displayFromCampaignState } from "util/displayState"; import { CampaignExtras } from "user/adSet/AdSetList"; import { FilterContext } from "state/context"; import { refetchAdvertiserCampaignsQuery } from "graphql/advertiser.generated"; import { UpdateAdSetInput } from "graphql/types"; -export type CellValueRenderer = (value: CellValue) => ReactNode; +export type CellValueRenderer = (value: any) => ReactNode; const ADS_DEFAULT_TIMEZONE = "America/New_York"; const TOOLTIP_FORMAT = "E d LLL yyyy HH:mm:ss zzz"; -function formatDateForTooltip(dt: Date): ReactChild { +function formatDateForTooltip(dt: Date): ReactElement { return ( <> diff --git a/src/components/Drawer/MiniSideBar.tsx b/src/components/Drawer/MiniSideBar.tsx index 0f65225e..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: true, + disabled: !advertiser.selfServiceManageCampaign, }, { label: "Audiences", diff --git a/src/components/EnhancedTable/EnhancedTable.tsx b/src/components/EnhancedTable/EnhancedTable.tsx deleted file mode 100644 index dd6716d8..00000000 --- a/src/components/EnhancedTable/EnhancedTable.tsx +++ /dev/null @@ -1,258 +0,0 @@ -import { - Box, - Grid, - Skeleton, - SortDirection, - Stack, - Table, - TableBody, - TableCell, - TableCellProps, - TableContainer, - TableFooter, - TableHead, - TablePagination, - TableRow, -} from "@mui/material"; -import _ from "lodash"; -import { ChangeEvent, ReactNode, useState } from "react"; -import { EnhancedTableHeader } from "./EnhancedTableHeader"; -import { EnhancedTableRow } from "./EnhancedTableRow"; -import { FilterInput } from "./FilterInput"; -import { CellValueRenderer, StandardRenderers } from "./renderers"; -import { SxProps } from "@mui/system"; - -export type CellValue = string | string[] | number | boolean | undefined | null; - -export interface ColumnDescriptor { - // text title to be shown in header - title: string; - - // how to obtain the raw cell value from the row - // this is used both to pass to a CellValueRenderer, and used for - // filtering and sorting - value: (row: T) => CellValue; - - // how to convert the raw cell value into a react node - // default: StandardRenderers.string - renderer?: CellValueRenderer; - - // in some cases rendering a cell is not just a matter of taking the value and presenting it. - // for example, rendering a link typically requires the cell value and also the id. - // so an extendedRenderer, if defined, is used instead, which has access to the full row, - // but is less genericisable. - extendedRenderer?: undefined | ((row: T) => ReactNode); - - // is this column sortable? - // default: true - sortable?: boolean; - - // style to apply to this column's header and cells - // use this to e.g. make fixed width using maxWidth or width - sx?: SxProps; - - // default left - align?: TableCellProps["align"]; -} - -interface EnhancedTableProps { - rows: T[] | undefined; - columns: ColumnDescriptor[]; - // defaults to 0 - initialSortColumn?: number; - initialSortDirection?: SortDirection; - initialRowsPerPage?: 5 | 10 | 25 | 100 | -1; - - additionalFilters?: ReactNode; - filterable?: boolean; -} - -// internally, we use a column accessor rather than the descriptor directly. -// This has default values populated and some helper methods -export interface ColumnAccessor - extends Pick, "title" | "sx" | "align"> { - sortable: boolean; - render: (r: T) => ReactNode; - getSortValue: (r: T) => string | number | undefined; - matchesFilter: (r: T, filter: string) => boolean; -} - -function mkColumnAccessor( - descriptor: ColumnDescriptor, -): ColumnAccessor { - const { - title, - value, - renderer = StandardRenderers.string, - extendedRenderer, - sortable = true, - sx, - align, - } = descriptor; - return { - title, - sortable, - sx, - align, - render: (row) => { - return extendedRenderer ? extendedRenderer(row) : renderer(value(row)); - }, - getSortValue: (row) => { - const v = value(row); - if (_.isString(v)) { - return v.toLowerCase().trim(); - } - if (_.isNumber(v)) { - return v; - } - if (_.isBoolean(v)) { - return v ? 1 : 0; - } - return undefined; - }, - matchesFilter: (row, filter) => { - const columnValue = value(row); - return ( - _.isString(columnValue) && columnValue.toLowerCase().includes(filter) - ); - }, - }; -} - -const LoadingSkeleton = ({ cols, rows }: { cols: number; rows: number }) => { - return ( - <> - {_.times(rows, (idx) => ( - - {_.times(cols, (idx2) => ( - - - - ))} - - ))} - - ); -}; - -export function EnhancedTable(props: EnhancedTableProps) { - const [order, setOrder] = useState( - props.initialSortDirection ?? "asc", - ); - const [orderBy, setOrderBy] = useState(props.initialSortColumn ?? 0); - const [filter, setFilter] = useState(""); - const [page, setPage] = useState(0); - const [rowsPerPage, setRowsPerPage] = useState( - props.initialRowsPerPage ?? 25, - ); - - const { rows } = props; - - const columns = props.columns.map(mkColumnAccessor); - - const processSortUpdate = (col: number) => { - if (orderBy === col) { - setOrder((current) => (current === "asc" ? "desc" : "asc")); - } else { - setOrderBy(col); - setOrder("asc"); - setPage(0); - } - }; - - const handleChangePage = (_event: unknown, newPage: number) => { - setPage(newPage); - }; - - const handleChangeRowsPerPage = (event: ChangeEvent) => { - setRowsPerPage(parseInt(event.target.value, 10)); - setPage(0); - }; - - const handleFilterUpdate = (newFilter: string) => { - if (!_.isEmpty(newFilter)) { - setPage(0); - } - setFilter(newFilter.toLowerCase()); - }; - - const matchesFilter = (row: T) => { - if (_.isEmpty(filter)) return true; - return _.some(columns, (c) => c.matchesFilter(row, filter)); - }; - - const filteredRows = _(rows) - .filter((r) => matchesFilter(r)) - .orderBy((r) => columns[orderBy].getSortValue(r), order) - .valueOf(); - - const data = - rowsPerPage === -1 - ? filteredRows - : filteredRows.slice( - page * rowsPerPage, - page * rowsPerPage + rowsPerPage, - ); - - return ( - - - - {props.additionalFilters} - - {props.filterable !== false && ( - - - - )} - - - - - - {columns.map((c, idx) => ( - processSortUpdate(idx)} - /> - ))} - - - - {_.isNil(rows) ? ( - 0 ? rowsPerPage : 20} - /> - ) : ( - data.map((r, idx) => ( - // TODO: key here should be the idenfier of the row - - )) - )} - - - - - - -
-
-
- ); -} diff --git a/src/components/EnhancedTable/EnhancedTableHeader.tsx b/src/components/EnhancedTable/EnhancedTableHeader.tsx deleted file mode 100644 index d921721b..00000000 --- a/src/components/EnhancedTable/EnhancedTableHeader.tsx +++ /dev/null @@ -1,30 +0,0 @@ -import { SortDirection, TableCell, TableSortLabel } from "@mui/material"; -import { ColumnAccessor } from "./EnhancedTable"; - -interface EnhancedTableHeaderProps { - column: ColumnAccessor; - sortDirection: SortDirection; - onSortClick: () => void; -} - -export function EnhancedTableHeader(props: EnhancedTableHeaderProps) { - return ( - - {props.column.sortable ? ( - - {props.column.title} - - ) : ( - props.column.title - )} - - ); -} diff --git a/src/components/EnhancedTable/EnhancedTableRow.tsx b/src/components/EnhancedTable/EnhancedTableRow.tsx deleted file mode 100644 index 07b3c475..00000000 --- a/src/components/EnhancedTable/EnhancedTableRow.tsx +++ /dev/null @@ -1,22 +0,0 @@ -import { TableCell, TableRow } from "@mui/material"; -import { ColumnAccessor } from "./EnhancedTable"; - -interface EnhancedTableRowProps { - columns: ColumnAccessor[]; - row: T; -} - -export function EnhancedTableRow({ - columns, - row, -}: EnhancedTableRowProps) { - return ( - - {columns.map((c, idx) => ( - - {c.render(row)} - - ))} - - ); -} diff --git a/src/components/EnhancedTable/FilterInput.tsx b/src/components/EnhancedTable/FilterInput.tsx deleted file mode 100644 index ca6c786e..00000000 --- a/src/components/EnhancedTable/FilterInput.tsx +++ /dev/null @@ -1,48 +0,0 @@ -import { Box, TextField } from "@mui/material"; -import SearchIcon from "@mui/icons-material/Search"; -import { useMemo, useState } from "react"; -import _ from "lodash"; - -interface Props { - filter: string; - setFilter: (newValue: string) => void; -} - -export const FilterInput = (props: Props) => { - // to retain responsiveness of the form, whilst still not constantly re-filtering the list, - // we keep our own state here that updates immediately, and debounce the updates to - // the caller - const [filterField, setFilterField] = useState(props.filter); - - // HT: https://dmitripavlutin.com/react-throttle-debounce/ - const debouncedChangeHandler = useMemo( - () => _.debounce(props.setFilter, 300), - [props.setFilter], - ); - - const onChangeHandler = (value: string) => { - setFilterField(value); - debouncedChangeHandler(value); - }; - - return ( - - - onChangeHandler(e.target.value)} - spellCheck={false} - autoFocus - fullWidth - /> - - ); -}; diff --git a/src/components/EnhancedTable/index.ts b/src/components/EnhancedTable/index.ts deleted file mode 100644 index b0db561e..00000000 --- a/src/components/EnhancedTable/index.ts +++ /dev/null @@ -1,2 +0,0 @@ -export * from "./EnhancedTable"; -export { StandardRenderers } from "./renderers"; diff --git a/src/components/Navigation/Navbar.tsx b/src/components/Navigation/Navbar.tsx index 8b50cddc..150e358b 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 ( @@ -39,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 new file mode 100644 index 00000000..b8573c70 --- /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.selfServiceManageCampaign) { + return null; + } + + return ( + + ); +} 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/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} /> ) : ( 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/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 6357b476..dfdc488f 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; @@ -70,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; @@ -95,8 +93,7 @@ export type AdvertiserCampaignsQuery = { advertiserCampaigns?: { id: string; name: string; - selfServiceEdit: boolean; - selfServiceCreate: boolean; + selfServiceManageCampaign: boolean; selfServiceSetPrice: boolean; campaigns: Array<{ id: string; @@ -107,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; @@ -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/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/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/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 0ec82636..38bf05c2 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({ @@ -49,60 +51,76 @@ export function User() { setFromDate, }} > - - - - - - {/* /adsmanager */} - a.selfServiceCreate} - /> - - a.selfServiceEdit} - /> - - - - {/* /campaigns/:campaignId/analytics - */} - - - - - - - - - - - {/* default */} - - - + + + + + {/* /adsmanager */} + a.selfServiceManageCampaign} + /> + + a.selfServiceManageCampaign} + /> + + a.selfServiceManageCampaign} + /> + + + + {/* /campaigns/:campaignId/analytics - */} + + + + + + + + + + + + + {/* default */} + + diff --git a/src/user/adSet/AdSetList.tsx b/src/user/adSet/AdSetList.tsx index fcfabef2..06c5cee6 100644 --- a/src/user/adSet/AdSetList.tsx +++ b/src/user/adSet/AdSetList.tsx @@ -1,8 +1,10 @@ -import { ColumnDescriptor, StandardRenderers } from "components/EnhancedTable"; import { Chip } from "@mui/material"; import { Status } from "components/Campaigns/Status"; import _ from "lodash"; -import { adSetOnOffState } from "components/EnhancedTable/renderers"; +import { + adSetOnOffState, + StandardRenderers, +} from "components/Datagrid/renderers"; import { CampaignAdsFragment } from "graphql/campaign.generated"; import { CampaignSource } from "graphql/types"; import { StatsMetric } from "user/analytics/analyticsOverview/types"; @@ -10,6 +12,7 @@ import { AdSetFragment } from "graphql/ad-set.generated"; import { AdDetailTable } from "user/views/user/AdDetailTable"; import { displayFromCampaignState } from "util/displayState"; import { uiLabelsForBillingType } from "util/billingType"; +import { GridColDef } from "@mui/x-data-grid"; interface Props { loading: boolean; @@ -71,52 +74,70 @@ export function AdSetList({ campaign, loading, engagements }: Props) { advertiserId: campaign?.advertiser.id ?? "", })); - const columns: ColumnDescriptor[] = [ + const columns: GridColDef[] = [ { - title: "On/Off", - value: (c) => c.state, - extendedRenderer: (r) => adSetOnOffState(r), - sx: { width: "10px" }, + field: "switch", + type: "actions", + headerName: "On/Off", + valueGetter: ({ row }) => row.state, + renderCell: ({ row }) => adSetOnOffState(row), sortable: false, + filterable: false, + width: 100, }, { - title: "Created", - value: (c) => c.createdAt, - renderer: StandardRenderers.date, + field: "createdAt", + headerName: "Created", + valueGetter: ({ row }) => row.createdAt, + renderCell: ({ row }) => StandardRenderers.date(row.createdAt), + width: 120, }, { - title: "Name", - value: (c) => c.name || c.id.substring(0, 8), + field: "name", + headerName: "Name", + valueGetter: ({ row }) => row.name || row.id.substring(0, 8), + flex: 1, }, { - title: "Status", - value: (c) => displayFromCampaignState(c), - extendedRenderer: (r) => ( + field: "state", + headerName: "Status", + valueGetter: ({ row }) => displayFromCampaignState(row), + renderCell: ({ row }) => ( ), + width: 100, }, { - title: "Type", - value: (c) => uiLabelsForBillingType(c.billingType).longLabel, + field: "billingType", + headerName: "Type", + valueGetter: ({ row }) => + uiLabelsForBillingType(row.billingType).longLabel, + width: 150, }, { - title: "Platforms", - value: (c) => c.oses?.map((o: { name: string }) => o.name).join(", "), - extendedRenderer: (r) => , + field: "oses", + headerName: "Platforms", + valueGetter: ({ row }) => + row.oses?.map((o: { name: string }) => o.name).join(", "), + renderCell: ({ row }) => , + flex: 1, }, { - title: "Audiences", - value: (c) => c.segments?.map((o: { name: string }) => o.name).join(", "), - extendedRenderer: (r) => ( + field: "segments", + headerName: "Audiences", + valueGetter: ({ row }) => + row.segments?.map((o: { name: string }) => o.name).join(", "), + renderCell: ({ row }) => ( 100 ? 2 : 5} + items={row.segments} + max={(row.segments ?? []).join("").length > 100 ? 2 : 5} /> ), + flex: 1, }, ]; diff --git a/src/user/ads/AdList.tsx b/src/user/ads/AdList.tsx index 6ab70be3..06485a37 100644 --- a/src/user/ads/AdList.tsx +++ b/src/user/ads/AdList.tsx @@ -1,4 +1,3 @@ -import { ColumnDescriptor, StandardRenderers } from "components/EnhancedTable"; import _ from "lodash"; import { isAfterEndDate } from "util/isAfterEndDate"; import { AdFragment } from "graphql/ad-set.generated"; @@ -6,6 +5,9 @@ import { CampaignSource } from "graphql/types"; import { CampaignAdsFragment } from "graphql/campaign.generated"; import { StatsMetric } from "user/analytics/analyticsOverview/types"; import { AdDetailTable } from "user/views/user/AdDetailTable"; +import { GridColDef } from "@mui/x-data-grid"; +import { CreativeFragment } from "graphql/creative.generated"; +import { StandardRenderers } from "components/Datagrid/renderers"; interface Props { campaign?: CampaignAdsFragment | null; @@ -44,27 +46,36 @@ export function AdList({ campaign, loading, engagements }: Props) { const ads: AdDetails[] = _.flatMap(adSets, "ads"); - const columns: ColumnDescriptor[] = [ + const columns: GridColDef[] = [ { - title: "Created", - value: (c) => c.creative.createdAt, - renderer: StandardRenderers.date, + field: "createdAt", + headerName: "Created", + valueGetter: ({ row }) => row.creative.createdAt, + renderCell: ({ row }) => StandardRenderers.date(row.creative.createdAt), }, { - title: "Ad Name", - value: (c) => c.creative.name, + field: "name", + headerName: "Ad Name", + valueGetter: ({ row }) => row.creative.name, + flex: 1, }, { - title: "Ad Set Name", - value: (c) => c.adSetName, + field: "adSetName", + headerName: "Ad Set Name", + valueGetter: ({ row }) => row.adSetName, + flex: 1, }, { - title: "Title", - value: (c) => c.creative.payloadNotification?.title, + field: "title", + headerName: "Title", + valueGetter: ({ row }) => title(row.creative), + flex: 1, }, { - title: "Body", - value: (c) => c.creative.payloadNotification?.body, + field: "body", + headerName: "Body", + valueGetter: ({ row }) => body(row.creative), + flex: 1, }, ]; @@ -74,9 +85,18 @@ export function AdList({ campaign, loading, engagements }: Props) { columns={columns} engagements={engagements} loading={loading} - propOverride={{ - initialSortColumn: 0, - }} /> ); } + +const title = (c: CreativeFragment) => + c.payloadNotification?.title ?? + c.payloadInlineContent?.title ?? + c.payloadSearch?.title ?? + c.payloadSearchHomepage?.title; + +const body = (c: CreativeFragment) => + c.payloadNotification?.body ?? + c.payloadInlineContent?.ctaText ?? + c.payloadSearch?.body ?? + c.payloadSearchHomepage?.body; 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/analytics/analyticsOverview/components/BaseBarChart.tsx b/src/user/analytics/analyticsOverview/components/BaseBarChart.tsx index 778d6fe7..044334dd 100644 --- a/src/user/analytics/analyticsOverview/components/BaseBarChart.tsx +++ b/src/user/analytics/analyticsOverview/components/BaseBarChart.tsx @@ -1,8 +1,7 @@ import { Box, Tab, Tabs } from "@mui/material"; -import HighchartsReact from "highcharts-react-official"; -import * as Highcharts from "highcharts"; import { Options, SeriesOptionsType } from "highcharts"; import { Option } from "../types"; +import { HighchartsWrapper } from "user/analytics/analyticsOverview/components/HighchartsWrapper"; interface Props { categories: string[]; @@ -81,7 +80,7 @@ export function BaseBarChart({ ))} - + ); } diff --git a/src/user/analytics/analyticsOverview/components/BasePieChart.tsx b/src/user/analytics/analyticsOverview/components/BasePieChart.tsx index d1ee809c..8c79f26f 100644 --- a/src/user/analytics/analyticsOverview/components/BasePieChart.tsx +++ b/src/user/analytics/analyticsOverview/components/BasePieChart.tsx @@ -1,8 +1,7 @@ import { Box, Tab, Tabs } from "@mui/material"; -import HighchartsReact from "highcharts-react-official"; -import * as Highcharts from "highcharts"; import { Options, SeriesOptionsType } from "highcharts"; import { Option } from "../types"; +import { HighchartsWrapper } from "user/analytics/analyticsOverview/components/HighchartsWrapper"; interface Props { series: SeriesOptionsType[]; @@ -71,7 +70,7 @@ export function BasePieChart({ series, onSetType, extraOptions, type }: Props) { ))} - + ); } diff --git a/src/user/analytics/analyticsOverview/components/HighchartsWrapper.tsx b/src/user/analytics/analyticsOverview/components/HighchartsWrapper.tsx new file mode 100644 index 00000000..96d0e917 --- /dev/null +++ b/src/user/analytics/analyticsOverview/components/HighchartsWrapper.tsx @@ -0,0 +1,27 @@ +import { Box, BoxProps } from "@mui/material"; +import Highcharts from "highcharts"; +import { HighchartsReact } from "highcharts-react-official"; + +type Props = BoxProps & { + options: Highcharts.Options; +}; + +export const HighchartsWrapper = ({ options, ...rest }: Props) => { + return ( + + + + ); +}; diff --git a/src/user/analytics/analyticsOverview/lib/overview.library.ts b/src/user/analytics/analyticsOverview/lib/overview.library.ts index 3eef24c3..27a64b16 100644 --- a/src/user/analytics/analyticsOverview/lib/overview.library.ts +++ b/src/user/analytics/analyticsOverview/lib/overview.library.ts @@ -1,6 +1,6 @@ import _ from "lodash"; import moment from "moment"; -import { Options } from "highcharts"; +import Highcharts, { Options } from "highcharts"; import { BaseMetric, Metrics, @@ -76,7 +76,7 @@ export const baseOverviewChart: Options = { export const prepareChart = ( metrics: Metrics, processedData: MetricDataSet, -) => { +): Highcharts.Options => { const metricsEntries = Object.entries(metrics); return { ...baseOverviewChart, diff --git a/src/user/analytics/analyticsOverview/reports/campaign/EngagementsOverview.tsx b/src/user/analytics/analyticsOverview/reports/campaign/EngagementsOverview.tsx index 987ba58d..c778024a 100644 --- a/src/user/analytics/analyticsOverview/reports/campaign/EngagementsOverview.tsx +++ b/src/user/analytics/analyticsOverview/reports/campaign/EngagementsOverview.tsx @@ -1,6 +1,4 @@ import { Alert, Box, Divider, Skeleton } from "@mui/material"; -import HighchartsReact from "highcharts-react-official"; -import * as Highcharts from "highcharts"; import { useState } from "react"; import { CampaignWithEngagementsFragment, @@ -18,6 +16,7 @@ import { CampaignFormat } from "graphql/types"; import { ErrorDetail } from "components/Error/ErrorDetail"; import { ApolloError } from "@apollo/client"; import { usePersistMetricFilter } from "user/analytics/analyticsOverview/hooks/usePersistMetricFilter"; +import { HighchartsWrapper } from "user/analytics/analyticsOverview/components/HighchartsWrapper"; interface Props { loading: boolean; @@ -100,9 +99,7 @@ export function EngagementsOverview({ paddingTop="14px" paddingBottom="14px" > - - - + diff --git a/src/user/analytics/analyticsOverview/reports/creative/CreativeOverview.tsx b/src/user/analytics/analyticsOverview/reports/creative/CreativeOverview.tsx deleted file mode 100644 index 97144100..00000000 --- a/src/user/analytics/analyticsOverview/reports/creative/CreativeOverview.tsx +++ /dev/null @@ -1,172 +0,0 @@ -import * as Highcharts from "highcharts"; -import { Options } from "highcharts"; -import HighchartsReact from "highcharts-react-official"; -import { useState } from "react"; -import { Box, Stack, Tab, Tabs, Typography } from "@mui/material"; -import { decideValueAttribute } from "../../lib/overview.library"; -import { CampaignFormat } from "graphql/types"; -import { EngagementFragment } from "graphql/analytics-overview.generated"; -import { CampaignFragment } from "graphql/campaign.generated"; -import { creativeEngagements } from "../../lib/creative.library"; -import { CreativeMetric, StatsMetric } from "../../types"; -import { CardContainer } from "components/Card/CardContainer"; - -interface Props { - engagements: EngagementFragment[]; - campaign: CampaignFragment; -} - -export function CreativeOverview({ engagements, campaign }: Props) { - const [type, setType] = useState("ctr"); - const isNtp = campaign.format === CampaignFormat.NtpSi; - - const metrics = creativeEngagements(engagements, campaign.format); - - const mapMetricToSeries = (cm: CreativeMetric[], type: keyof StatsMetric) => { - return cm - .map((m) => { - const p = m.creativePayload; - const y = m[type]; - - return { - name: p.title, - y: y === Infinity ? 0 : y, - custom: { - name: p.body, - }, - }; - }) - .filter((p) => p.y > 0); - }; - - const [chart, setChart] = useState(mapMetricToSeries(metrics, "ctr")); - - const attrs = decideValueAttribute(type); - const options: Options = { - chart: { - type: "bar", - height: chart.length > 10 ? chart.length * 30 : undefined, - }, - title: { - text: undefined, - }, - xAxis: { - categories: chart.map((c) => c.name), - title: { - text: null, - }, - }, - accessibility: { - point: { - valueSuffix: attrs.suffix, - valuePrefix: attrs.prefix, - }, - }, - yAxis: { - labels: { - overflow: "justify", - }, - }, - plotOptions: { - bar: { - colorByPoint: true, - dataLabels: { - enabled: true, - inside: true, - }, - }, - }, - tooltip: { - valueDecimals: attrs.decimal, - valuePrefix: attrs.prefix, - valueSuffix: attrs.suffix, - }, - series: [ - { - name: "Ads", - type: "bar", - data: chart, - dataLabels: [ - { - align: "center", - format: isNtp ? "" : "{point.options.custom.name}", - color: "#FFFFFF", - style: { - fontSize: "12px", - fontFamily: "Verdana, sans-serif", - }, - }, - { - align: "right", - format: attrs.format, - color: "#FFFFFF", - style: { - fontSize: "12px", - fontFamily: "Verdana, sans-serif", - }, - }, - ], - }, - ], - }; - - const onChange = (metric: CreativeMetric[], type: keyof StatsMetric) => { - setType(type); - setChart(mapMetricToSeries(metric, type)); - }; - - return ( - - {isNtp && ( - - {metrics.map((ntp, idx) => ( - - {`Image - Image {idx + 1} - - ))} - - )} - - { - onChange(metrics, v); - }} - variant="scrollable" - scrollButtons="auto" - > - - - - {!isNtp && } - - - - {!isNtp && } - - - - - ); -} diff --git a/src/user/analytics/renderers/index.tsx b/src/user/analytics/renderers/index.tsx index 660296ba..f23a8f65 100644 --- a/src/user/analytics/renderers/index.tsx +++ b/src/user/analytics/renderers/index.tsx @@ -1,5 +1,5 @@ import { Box, Skeleton, Typography } from "@mui/material"; -import { renderMonetaryAmount } from "components/EnhancedTable/renderers"; +import { renderMonetaryAmount } from "components/Datagrid/renderers"; import { CampaignSummaryFragment } from "graphql/campaign.generated"; import { CampaignFormat } from "graphql/types"; import { StatsMetric } from "user/analytics/analyticsOverview/types"; diff --git a/src/user/campaignList/CampaignList.tsx b/src/user/campaignList/CampaignList.tsx index 01e3c81f..b4f3b149 100644 --- a/src/user/campaignList/CampaignList.tsx +++ b/src/user/campaignList/CampaignList.tsx @@ -1,14 +1,10 @@ import { useState } from "react"; -import { - ColumnDescriptor, - EnhancedTable, - StandardRenderers, -} from "components/EnhancedTable"; -import { Checkbox, Link } from "@mui/material"; +import { Link } from "@mui/material"; import { campaignOnOffState, renderMonetaryAmount, -} from "components/EnhancedTable/renderers"; + StandardRenderers, +} from "components/Datagrid/renderers"; import { Link as RouterLink } from "react-router-dom"; import { Status } from "components/Campaigns/Status"; import { isAfterEndDate } from "util/isAfterEndDate"; @@ -22,19 +18,17 @@ import { import _ from "lodash"; import { uiTextForCampaignFormat } from "user/library"; import { CampaignSummaryFragment } from "graphql/campaign.generated"; +import { DataGrid, GridColDef } from "@mui/x-data-grid"; +import { CustomToolbar } from "components/Datagrid/CustomToolbar"; +import { CloneCampaign } from "components/Campaigns/CloneCampaign"; +import { EditButton } from "user/campaignList/EditButton"; interface Props { advertiser?: AdvertiserCampaignsFragment | null; - selectedCampaigns: string[]; - onCampaignSelect: (c: string, insert: boolean) => void; } -export function CampaignList({ - advertiser, - selectedCampaigns, - onCampaignSelect, -}: Props) { - let initialSort = 9; +export function CampaignList({ advertiser }: Props) { + const [selectedCampaign, setSelectedCampaign] = useState(); const [engagementData, setEngagementData] = useState>(); @@ -52,138 +46,178 @@ export function CampaignList({ }, }); - const columns: ColumnDescriptor[] = [ - { - title: "On/Off", - value: (c) => c.state, - extendedRenderer: (r) => - campaignOnOffState({ - ...r, - advertiserId: advertiser?.id ?? "", - }), - sx: { width: "1px", p: 0 }, - sortable: false, - }, + const columns: GridColDef[] = [ { - title: "Campaign", - value: (c) => c.name, - extendedRenderer: (r) => ( + field: "name", + headerName: "Campaign", + renderCell: ({ row }) => ( - {r.name} + {row.name} ), + flex: 1, }, { - title: "Format", - value: (c) => uiTextForCampaignFormat(c.format), + field: "format", + headerName: "Format", + valueGetter: ({ row }) => uiTextForCampaignFormat(row.format), + align: "left", + headerAlign: "left", + width: 150, }, { - title: "Status", - value: (c) => (isAfterEndDate(c.endAt) ? "completed" : c.state), - extendedRenderer: (r) => ( - + field: "state", + headerName: "Status", + valueGetter: ({ row }) => + isAfterEndDate(row.endAt) ? "completed" : row.state, + renderCell: ({ row }) => ( + ), - sx: { width: "1px", p: 0 }, + minWidth: 120, + maxWidth: 150, }, { - title: "Budget", - value: (c) => c.budget, - extendedRenderer: (r) => renderMonetaryAmount(r.budget, r.currency), + field: "budget", + headerName: "Budget", + renderCell: ({ row }) => renderMonetaryAmount(row.budget, row.currency), align: "right", + headerAlign: "right", + minWidth: 100, + maxWidth: 150, }, { - title: "Spend", - value: (c) => c.spent, - extendedRenderer: (r) => - renderEngagementCell(loading, r, "spend", engagementData), + field: "spend", + headerName: "Spend", + valueGetter: ({ row }) => row.spent, + renderCell: ({ row }) => + renderEngagementCell(loading, row, "spend", engagementData), align: "right", + headerAlign: "right", + minWidth: 100, + maxWidth: 150, }, { - title: "Impressions", - value: (c) => engagementData?.get(c.id)?.["view"] ?? "N/A", - extendedRenderer: (r) => - renderEngagementCell(loading, r, "view", engagementData), + field: "view", + headerName: "Impressions", + valueGetter: ({ row }) => engagementData?.get(row.id)?.["view"] ?? "N/A", + renderCell: ({ row }) => + renderEngagementCell(loading, row, "view", engagementData), align: "right", + headerAlign: "right", + minWidth: 100, + maxWidth: 250, }, { - title: "Clicks", - value: (c) => engagementData?.get(c.id)?.["click"] ?? "N/A", - extendedRenderer: (r) => - renderEngagementCell(loading, r, "click", engagementData), + field: "click", + headerName: "Clicks", + valueGetter: ({ row }) => engagementData?.get(row.id)?.["click"] ?? "N/A", + renderCell: ({ row }) => + renderEngagementCell(loading, row, "click", engagementData), align: "right", + headerAlign: "right", + minWidth: 100, + maxWidth: 250, }, { - title: "10s Visits", - value: (c) => engagementData?.get(c.id)?.["landed"] ?? "N/A", - extendedRenderer: (r) => - renderEngagementCell(loading, r, "landed", engagementData), + field: "landed", + headerName: "10s Visits", + valueGetter: ({ row }) => + engagementData?.get(row.id)?.["landed"] ?? "N/A", + renderCell: ({ row }) => + renderEngagementCell(loading, row, "landed", engagementData), align: "right", + headerAlign: "right", + minWidth: 100, + maxWidth: 250, }, { - title: "Start", - value: (c) => c.startAt, - renderer: StandardRenderers.date, + field: "startAt", + headerName: "Start", + valueGetter: ({ row }) => row.startAt, + renderCell: ({ row }) => StandardRenderers.date(row.startAt), align: "right", + headerAlign: "right", + width: 120, }, { - title: "End", - value: (c) => c.endAt, - renderer: StandardRenderers.date, + field: "endAt", + headerName: "End", + valueGetter: ({ row }) => row.endAt, + renderCell: ({ row }) => StandardRenderers.date(row.endAt), align: "right", + headerAlign: "right", + width: 120, }, ]; - if (advertiser?.selfServiceCreate && advertiser.selfServiceEdit) { - initialSort += 1; + if (advertiser?.selfServiceManageCampaign) { columns.unshift({ - title: "", - value: (c) => c.id, + field: "switch", + headerName: "On/Off", + type: "actions", + valueGetter: ({ row }) => row.state, + renderCell: ({ row }) => + campaignOnOffState({ + ...row, + advertiserId: advertiser?.id ?? "", + }), + width: 100, sortable: false, - extendedRenderer: (r) => ( - - ), - align: "center", - sx: { width: "1px" }, + filterable: false, }); } - return ( - - ); -} - -interface CheckBoxProps { - campaign: CampaignSummaryFragment; - selectedCampaigns: string[]; - onCampaignSelect: (c: string, insert: boolean) => void; -} -const CampaignCheckBox = (props: CheckBoxProps) => { - const campaignSelected = props.selectedCampaigns.some( - (c) => c === props.campaign.id, - ); + const campaigns = advertiser?.campaigns ?? []; + const Toolbar = () => { + const campaign = campaigns.find((c) => c.id === selectedCampaign); + const isDisabled = selectedCampaign === undefined; + return ( + + {advertiser?.selfServiceManageCampaign && ( + + )} + {advertiser?.selfServiceManageCampaign && ( + + )} + + ); + }; return ( - - props.onCampaignSelect(props.campaign.id, e.target.checked) + { + if (rowSelectionModel.length === 1) { + setSelectedCampaign(rowSelectionModel[0]); + } else { + setSelectedCampaign(undefined); + } + }} + isRowSelectable={(params) => + selectedCampaign === undefined || params.id === selectedCampaign } + initialState={{ + sorting: { + sortModel: [{ field: "startAt", sort: "desc" }], + }, + pagination: { + paginationModel: { + pageSize: 10, + }, + }, + }} /> ); -}; +} diff --git a/src/user/campaignList/EditButton.tsx b/src/user/campaignList/EditButton.tsx new file mode 100644 index 00000000..dfe9b266 --- /dev/null +++ b/src/user/campaignList/EditButton.tsx @@ -0,0 +1,41 @@ +import { CampaignSummaryFragment } from "graphql/campaign.generated"; +import { CampaignFormat, CampaignSource } from "graphql/types"; +import { Button, Tooltip } from "@mui/material"; +import { Link as RouterLink } from "react-router-dom"; +import EditIcon from "@mui/icons-material/Edit"; + +export const EditButton = (props: { + campaign?: CampaignSummaryFragment; + disabled: boolean; +}) => { + const { campaign, disabled } = props; + const canEdit = + campaign && + campaign.source === CampaignSource.SelfServe && + [CampaignFormat.PushNotification, CampaignFormat.NewsDisplayAd].includes( + campaign.format, + ) && + campaign.state !== "completed"; + + return ( + + + + + + ); +}; 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", 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/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/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/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 850b94d8..48327b2c 100644 --- a/src/user/views/user/CampaignView.tsx +++ b/src/user/views/user/CampaignView.tsx @@ -1,41 +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 advertiserCanAction = - advertiser.selfServiceCreate && advertiser.selfServiceEdit; - - 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: { @@ -59,13 +35,8 @@ export function CampaignView() { return ( - ) : ( - "Campaigns" - ) - } + header="Campaigns" + useTypography sx={{ flexGrow: 1, overflowX: "auto", @@ -73,11 +44,7 @@ export function CampaignView() { additionalAction={} > {!loading ? ( - + ) : ( @@ -87,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 - /> - - - - - ); -} 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"),