Skip to content

Commit

Permalink
feat: add existing creative modals to campaign create
Browse files Browse the repository at this point in the history
  • Loading branch information
IanKrieger committed Aug 17, 2023
1 parent 6fc7cc3 commit babddc4
Show file tree
Hide file tree
Showing 7 changed files with 247 additions and 20 deletions.
94 changes: 94 additions & 0 deletions src/components/Creatives/CreativeAutocomplete.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,94 @@
import {
Autocomplete,
Box,
Button,
Checkbox,
TextField,
Typography,
} from "@mui/material";
import { CreativeFragment } from "graphql/creative.generated";
import { uiTextForCreativeTypeCode } from "user/library";
import CheckBoxIcon from "@mui/icons-material/CheckBox";
import CheckBoxOutlineBlankIcon from "@mui/icons-material/CheckBoxOutlineBlank";
import moment from "moment";
import { useFormikContext } from "formik";
import { CampaignForm } from "user/views/adsManager/types";
import _ from "lodash";
import { useState } from "react";

interface CreativeAutocompleteProps {
label: string;
options: readonly CreativeFragment[];
alreadyAssociatedCreativeIds: string[];
onSetValue: () => void;
}

export function CreativeAutocomplete(params: CreativeAutocompleteProps) {
const { setFieldValue } = useFormikContext<CampaignForm>();
const label = params.label;
const [alreadyAdded, setAlreadyAdded] = useState<string[]>(
params.alreadyAssociatedCreativeIds,
);

return (
<Box display="flex" flexDirection="column">
<Autocomplete
fullWidth
multiple
color="secondary"
autoComplete
disableCloseOnSelect
options={params.options}
onChange={async (_ev, value) => {
const mapped = value.map((c) => c.id);
setAlreadyAdded(mapped);
await setFieldValue("creatives", _.uniq(mapped));
}}
value={params.options.filter((o) => alreadyAdded.includes(o.id))}
renderInput={(params) => (
<TextField
variant="outlined"
margin="normal"
label={label}
{...params}
/>
)}
renderOption={(props, option, { selected }) => {
return (
<li {...props}>
<Checkbox
icon={<CheckBoxOutlineBlankIcon fontSize="small" />}
checkedIcon={<CheckBoxIcon fontSize="small" />}
style={{ marginRight: 8 }}
checked={
selected ||
params.alreadyAssociatedCreativeIds.includes(option?.id)
}
/>
{option.name}
<Typography variant="caption" marginLeft={1} color="GrayText">
created {moment(option.createdAt).fromNow()}
</Typography>
</li>
);
}}
getOptionLabel={(opt) => opt?.name ?? ""}
getOptionDisabled={(opt) =>
params.alreadyAssociatedCreativeIds.includes(opt?.id)
}
groupBy={(opt) => uiTextForCreativeTypeCode(opt.type)}
/>
<Button
size="large"
variant="contained"
sx={{ maxWidth: "165px", alignSelf: "end" }}
onClick={async (e) => {
e.preventDefault();
params.onSetValue();
}}
>
Close
</Button>
</Box>
);
}
29 changes: 13 additions & 16 deletions src/components/Creatives/NewCreative.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,21 +7,21 @@ import {
AdvertiserCreativesDocument,
useCreateCreativeMutation,
} from "graphql/creative.generated";
import { useState } from "react";
import { CardContainer } from "components/Card/CardContainer";
import { ErrorDetail } from "components/Error/ErrorDetail";
import { FormikSubmitButton } from "form/FormikHelpers";
import { CreativeSchema } from "validation/CreativeSchema";
import MiniSideBar from "components/Drawer/MiniSideBar";
import { PersistCreativeValues } from "form/PersistCreativeValues";
import {
clearCreativeValues,
PersistCreativeValues,
} from "form/PersistCreativeValues";
import { CreativeTypePreview } from "components/Creatives/CreativeTypePreview";
import { useAdvertiser } from "auth/hooks/queries/useAdvertiser";

function wait(ms: number) {
return new Promise((resolve) => window.setTimeout(resolve, ms));
}
import { useState } from "react";

export function NewCreative() {
const [id, setId] = useState<string>();
const { advertiser } = useAdvertiser();
const history = useHistory();
const location = useLocation<CreativeInput>();
Expand Down Expand Up @@ -50,10 +50,13 @@ export function NewCreative() {
variables: { advertiserId: advertiser.id },
},
],
onCompleted(data) {
setId(data.createCreative.id);
history.replace("/user/main/creatives");
clearCreativeValues();
},
});

const [id, setId] = useState("");

const doSubmit = async (values: CreativeInput) => {
const input: CreativeInput = {
advertiserId: advertiser.id,
Expand All @@ -63,15 +66,9 @@ export function NewCreative() {
type: values.type,
};

const response = await createCreativeMutation({
void createCreativeMutation({
variables: { input },
});
const id = response.data?.createCreative.id;
if (id) {
setId(id);
await wait(2000);
history.replace(id);
}
};

return (
Expand Down Expand Up @@ -112,7 +109,7 @@ export function NewCreative() {
</Formik>

<Snackbar
message={`Creative ${id} successfully created`}
message={`Creative successfully created`}
open={!!id}
autoHideDuration={5000}
/>
Expand Down
8 changes: 7 additions & 1 deletion src/form/PersistCreativeValues.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,8 @@ import _ from "lodash";
import { CreativeInput } from "graphql/types";

export const PersistCreativeValues = () => {
const { values, setValues, dirty } = useFormikContext<CreativeInput>();
const { values, setValues, dirty, initialStatus } =
useFormikContext<CreativeInput>();

// read the values from localStorage on load
useEffect(() => {
Expand All @@ -21,6 +22,11 @@ export const PersistCreativeValues = () => {
}
}, [values, dirty]);

// save the values to localStorage on update
useEffect(() => {
console.log(initialStatus);
}, [initialStatus]);

return null;
};

Expand Down
65 changes: 65 additions & 0 deletions src/user/ads/AdsNewAd.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
import { Typography, Divider } from "@mui/material";
import { useFormikContext } from "formik";
import { CampaignFormat } from "graphql/types";
import _ from "lodash";
import {
CreativeFragment,
useAdvertiserCreativesQuery,
} from "graphql/creative.generated";
import { isCreativeTypeApplicableToCampaignFormat } from "user/library";
import { useAdvertiser } from "auth/hooks/queries/useAdvertiser";
import { CampaignForm } from "user/views/adsManager/types";
import { CreativeAutocomplete } from "components/Creatives/CreativeAutocomplete";
import { CardContainer } from "components/Card/CardContainer";

function filterCreativesBasedOnCampaignFormat(
creatives: CreativeFragment[],
campaignFormat: CampaignFormat | null,
): CreativeFragment[] {
if (!campaignFormat) return creatives;

return creatives.filter((c) =>
isCreativeTypeApplicableToCampaignFormat(c.type, campaignFormat),
);
}

export function AdsNewAd(props: { onAddCreative: () => void }) {
const { values } = useFormikContext<CampaignForm>();
const { advertiser } = useAdvertiser();
const { data } = useAdvertiserCreativesQuery({
variables: { advertiserId: advertiser.id },
});

const allCreativesForAdvertiser = data?.advertiser?.creatives ?? [];
const associatedCreatives = values.creatives ?? [];
const creativeOptionList = _.orderBy(
filterCreativesBasedOnCampaignFormat(
allCreativesForAdvertiser,
values.format,
),
["type.code", "createdAt"],
["asc", "desc"],
) as CreativeFragment[];

return (
<CardContainer header="Existing creative">
<Typography variant="h1">Add an existing creative</Typography>

<Divider sx={{ mt: 2, mb: 2 }} />

<Typography variant="subtitle2">
Creatives are modular building blocks that can be paired with ad sets to
build ads.
</Typography>

<CreativeAutocomplete
label="Creative"
options={creativeOptionList}
alreadyAssociatedCreativeIds={associatedCreatives}
onSetValue={() => {
props.onAddCreative();
}}
/>
</CardContainer>
);
}
47 changes: 44 additions & 3 deletions src/user/ads/NewAd.tsx
Original file line number Diff line number Diff line change
@@ -1,17 +1,21 @@
import { useRecentlyCreatedAdvertiserCreatives } from "user/hooks/useAdvertiserCreatives";
import { CardContainer } from "components/Card/CardContainer";
import { Box, Button, Stack } from "@mui/material";
import { Box, Button, IconButton, Link, Stack } from "@mui/material";
import { useState } from "react";
import { BoxContainer } from "components/Box/BoxContainer";
import AddCircleOutlineIcon from "@mui/icons-material/AddCircleOutline";
import RemoveCircleOutlineIcon from "@mui/icons-material/RemoveCircleOutline";
import { NotificationAd } from "user/ads/NotificationAd";
import { useField } from "formik";
import { useField, useFormikContext } from "formik";
import { NotificationPreview } from "components/Creatives/NotificationPreview";
import { AdsNewAd } from "user/ads/AdsNewAd";
import { CampaignForm, Creative } from "user/views/adsManager/types";
import _ from "lodash";

export function NewAd() {
const [, meta, helper] = useField<boolean>("isCreating");
const [showForm, setShowForm] = useState(false);
const [useExisting, setUseExisting] = useState(false);
const creatives = useRecentlyCreatedAdvertiserCreatives();

return (
Expand All @@ -24,7 +28,7 @@ export function NewAd() {
flexWrap="wrap"
>
{(creatives ?? []).map((c, idx) => (
<BoxContainer header={c.name} key={idx}>
<BoxContainer header={<RemoveHeader creative={c} />} key={idx}>
<NotificationPreview title={c.title} body={c.body} />
</BoxContainer>
))}
Expand All @@ -38,6 +42,7 @@ export function NewAd() {
onClick={() => {
helper.setValue(!meta.value);
setShowForm(!showForm);
setUseExisting(false);
}}
>
{showForm ? (
Expand All @@ -48,7 +53,22 @@ export function NewAd() {
</Box>
</BoxContainer>
</Stack>
{!useExisting && (
<Link
onClick={() => {
setUseExisting(true);
setShowForm(false);
}}
underline="none"
sx={{ cursor: "pointer" }}
>
Choose a previously made Creative
</Link>
)}
</CardContainer>
{useExisting && (
<AdsNewAd onAddCreative={() => setUseExisting(!useExisting)} />
)}
{showForm && (
<NotificationAd
onCreate={() => {
Expand All @@ -60,3 +80,24 @@ export function NewAd() {
</>
);
}

const RemoveHeader = (props: { creative: Creative }) => {
const { values, setFieldValue } = useFormikContext<CampaignForm>();

const onRemoveCreative = async (c: Creative, v: string[] | undefined) => {
const removed = _.filter(v ?? [], (n) => n !== c.id);
void setFieldValue("creatives", removed);
};

return (
<Box display="flex" justifyContent="space-between" alignItems="center">
{props.creative.name}
<IconButton
onClick={() => onRemoveCreative(props.creative, values.creatives)}
sx={{ p: 0 }}
>
<RemoveCircleOutlineIcon color="error" fontSize="small" />
</IconButton>
</Box>
);
};
1 change: 1 addition & 0 deletions src/user/hooks/useAdvertiserCreatives.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ export function useAdvertiserCreatives(): Creative[] {
const { data } = useAdvertiserCreativesQuery({
variables: { advertiserId: advertiser.id },
});

return (data?.advertiser?.creatives ?? []).map((c) => ({
id: c.id,
name: c.name,
Expand Down
23 changes: 23 additions & 0 deletions src/user/library/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -255,3 +255,26 @@ export function uiTextForCreativeTypeCode(creativeTypeCode: {
}): string {
return uiTextForCreativeType(creativeTypeCode.code);
}

export function isCreativeTypeApplicableToCampaignFormat(
creativeTypeCode: {
code: string;
},
format: CampaignFormat,
): boolean {
const { code } = creativeTypeCode;
switch (code) {
case "notification_all_v1":
return format === CampaignFormat.PushNotification;
case "new_tab_page_all_v1":
return format === CampaignFormat.NtpSi;
case "inline_content_all_v1":
return format === CampaignFormat.NewsDisplayAd;
case "search_all_v1":
return format === CampaignFormat.Search;
case "search_homepage_all_v1":
return format === CampaignFormat.SearchHomepage;
default:
return false;
}
}

0 comments on commit babddc4

Please sign in to comment.