Skip to content

Commit

Permalink
[SHEP-2] UI Created to Split a Single Campaign into Multiple Campaigns (
Browse files Browse the repository at this point in the history
#268)

## References

[SHEP-2](https://mozilla-hub.atlassian.net/browse/SHEP-2)

## Problem Statement

With the automatic creation of a child campaign when new booster deals
are imported, we need a UI that allows to split a single campaign into
multiple campaigns.

## Proposed Changes

Split Campaign Form Added to Allow Users to Split a Single Campaign into
Multiple Campaigns.

## Verification Steps

1. Click on the "Split Campaign" icon on the Campaigns list page.
2. Click on the plus icon to split the campaign into multiple campaigns.
3. Add details for the newly created campaign.
4. Ensure the total net spend for the campaigns matches the actual value
of the campaign before the split.

## Visuals


https://github.com/user-attachments/assets/0d9c82ce-3e5e-4c39-a891-96ad19c399e1



[SHEP-2]:
https://mozilla-hub.atlassian.net/browse/SHEP-2?atlOrigin=eyJpIjoiNWRkNTljNzYxNjVmNDY3MDlhMDU5Y2ZhYzA5YTRkZjUiLCJwIjoiZ2l0aHViLWNvbS1KU1cifQ
  • Loading branch information
akhan-mozilla authored Oct 7, 2024
1 parent fc0a2a1 commit 786392a
Show file tree
Hide file tree
Showing 20 changed files with 557 additions and 51 deletions.
9 changes: 9 additions & 0 deletions ad-ops-dashboard/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,15 @@ You can run this app together with the Django app in one `docker-compose up --bu

Visit the React frontend at http://0.0.0.0:5173/

We will need the following details to be added to settings.py in order to fix the CSRF permissions denied error for the APIs. This will be removed once CSRF authentication is implemented.

```
REST_FRAMEWORK = {
"DEFAULT_AUTHENTICATION_CLASSES": [],
"DEFAULT_PERMISSION_CLASSES": [],
}
```

# Next steps

Still missing from this setup:
Expand Down
3 changes: 3 additions & 0 deletions ad-ops-dashboard/src/components/Dialogs/FormDialog.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ interface FormDialogProps {
children: ReactNode;
open: boolean;
handleClose: () => void;
maxWidth?: "sm" | "md" | "lg" | "xl";
}

const Container = styled(Box)`
Expand All @@ -33,6 +34,7 @@ export default function FormDialog({
children,
open,
handleClose,
maxWidth,
}: FormDialogProps) {
return (
<Dialog
Expand All @@ -43,6 +45,7 @@ export default function FormDialog({
aria-labelledby="alert-dialog-title"
aria-describedby="alert-dialog-description"
aria-modal
maxWidth={maxWidth}
>
<DialogTitle>
<Container>
Expand Down
29 changes: 20 additions & 9 deletions ad-ops-dashboard/src/components/Forms/CampaignForm.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -34,9 +34,9 @@ export default function CampaignForm({
useForm<CampaignFormSchema>({
resolver: zodResolver(campaignFormSchema),
defaultValues: {
notes: formData.notes,
ad_ops_person: formData.ad_ops_person,
kevel_flight_id: formData.kevel_flight_id,
notes: formData.notes ?? "",
ad_ops_person: formData.ad_ops_person ?? "",
kevel_flight_id: formData.kevel_flight_id ?? "",
impressions_sold: formData.impressions_sold,
net_spend: formData.net_spend,
start_date: formData.start_date,
Expand All @@ -63,7 +63,7 @@ export default function CampaignForm({
selectedDeal = formData.deal === watchDeal ? formData.deal : watchDeal;
}

if (selectedDeal) {
if (Array.isArray(campaigns) && selectedDeal) {
const filtered = campaigns
.filter((item) => item.deal === selectedDeal)
.filter((item) => !isUpdate || item.id !== formData.id);
Expand Down Expand Up @@ -94,6 +94,10 @@ export default function CampaignForm({

const finalData = {
...data,
notes: data.notes?.trim() !== "" ? data.notes : null,
ad_ops_person:
data.ad_ops_person?.trim() !== "" ? data.ad_ops_person : null,
kevel_flight_id: data.kevel_flight_id !== 0 ? data.kevel_flight_id : null,
campaign_fields: updated_campaign_fields,
};

Expand All @@ -111,10 +115,12 @@ export default function CampaignForm({
}
};

const deals = boostrDeals?.map((deal: BoostrDeal) => ({
label: deal.name,
value: deal.id,
}));
const deals = Array.isArray(boostrDeals)
? boostrDeals.map((deal: BoostrDeal) => ({
label: deal.name,
value: deal.id,
}))
: [];

return (
<Box>
Expand All @@ -124,6 +130,7 @@ export default function CampaignForm({
name="ad_ops_person"
label="Ad Ops Person"
control={control}
fullWidth
/>
</Box>
<Box mt={2}>
Expand All @@ -132,6 +139,7 @@ export default function CampaignForm({
label="Kevel Flight Id"
control={control}
type="number"
fullWidth
/>
</Box>
<Box mt={2}>
Expand All @@ -140,6 +148,7 @@ export default function CampaignForm({
label="Impressions Sold"
control={control}
type="number"
fullWidth
/>
</Box>
<Box mt={2}>
Expand All @@ -148,10 +157,11 @@ export default function CampaignForm({
label="Net Spend"
control={control}
type="number"
fullWidth
/>
</Box>
<Box mt={2}>
<TextInput name="seller" label="Seller" control={control} />
<TextInput name="seller" label="Seller" control={control} fullWidth />
</Box>
<Box mt={2}>
<DateInput
Expand All @@ -176,6 +186,7 @@ export default function CampaignForm({
control={control}
rows={3}
multiline
fullWidth
/>
</Box>
<Box mt={2}>
Expand Down
219 changes: 219 additions & 0 deletions ad-ops-dashboard/src/components/Forms/SplitCampaignForm.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,219 @@
import React from "react";
import { Box, Button, Grid2 } from "@mui/material";
import { Add, Remove } from "@mui/icons-material";
import { useFieldArray, useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import { styled } from "@mui/system";
import {
CampaignFormSchema,
SplitFormSchema,
splitFormSchema,
} from "../../utils/schemas/campaignFormSchema";
import TextInput from "../Inputs/TextInput";
import DateInput from "../Inputs/DateInput";
import { useSplitCampaignMutation } from "../../data/campaigns";
import Tooltip from "@mui/material/Tooltip";

interface SplitCampaignProps {
handleClose: () => void;
formData: CampaignFormSchema;
}

const StyledBox = styled(Box)(() => ({
display: "flex",
justifyContent: "space-between",
alignItems: "center",
}));

const StyledButton = styled(Button)(() => ({
marginRight: "3.8rem",
}));

export default function SplitCampaignForm({
formData,
handleClose,
}: SplitCampaignProps) {
const splitMutation = useSplitCampaignMutation();

const { control, handleSubmit, reset } = useForm<SplitFormSchema>({
resolver: zodResolver(splitFormSchema),
defaultValues: {
campaigns: [
{
id: formData.id,
impressions_sold: formData.impressions_sold || "",
net_spend: formData.net_spend || "",
kevel_flight_id: formData.kevel_flight_id ?? "",
ad_ops_person: formData.ad_ops_person ?? "",
seller: formData.seller || "",
start_date: formData.start_date || "",
end_date: formData.end_date || "",
notes: formData.notes ?? "",
deal: formData.deal || undefined,
},
],
},
});

const onSubmitHandler = (data: SplitFormSchema) => {
const updatedData = {
...data,
campaigns: data.campaigns.map((campaign) => ({
...campaign,
notes: campaign.notes?.trim() !== "" ? campaign.notes : null,
ad_ops_person:
campaign.ad_ops_person?.trim() !== "" ? campaign.ad_ops_person : null,
kevel_flight_id:
campaign.kevel_flight_id !== 0 ? campaign.kevel_flight_id : null,
})),
deal: formData.deal,
};

splitMutation.mutate(updatedData, {
onSuccess: () => {
reset();
handleClose();
},
});
};

const { fields, append, remove } = useFieldArray({
control,
name: "campaigns",
});

return (
<Box mt="2rem">
<Box
component="form"
onSubmit={(event) => {
event.preventDefault();
handleSubmit(onSubmitHandler)();
}}
>
<Grid2 container spacing={2}>
{fields.map((field, index) => (
<React.Fragment key={field.id}>
<Grid2 size={2}>
<TextInput
name={`campaigns.${index.toString()}.ad_ops_person`}
label="Ad Ops Person"
control={control}
fullWidth
/>
</Grid2>
<Grid2 size={1}>
<TextInput
name={`campaigns.${index.toString()}.kevel_flight_id`}
label="Kevel Flight Id"
type="number"
control={control}
fullWidth
/>
</Grid2>
<Grid2 size={1}>
<TextInput
name={`campaigns.${index.toString()}.impressions_sold`}
label="Impressions Sold"
type="number"
control={control}
fullWidth
/>
</Grid2>
<Grid2 size={1}>
<TextInput
name={`campaigns.${index.toString()}.net_spend`}
label="Net spend"
type="number"
control={control}
fullWidth
/>
</Grid2>
<Grid2 size={2}>
<TextInput
name={`campaigns.${index.toString()}.seller`}
label="Seller"
control={control}
fullWidth
/>
</Grid2>
<Grid2 size={3}>
<StyledBox>
<Box>
<DateInput
control={control}
label="Start Date"
name={`campaigns.${index.toString()}.start_date`}
/>
</Box>
<Box sx={{ marginLeft: "1rem" }}>
<DateInput
control={control}
label="End Date"
name={`campaigns.${index.toString()}.end_date`}
/>
</Box>
</StyledBox>
</Grid2>
<Grid2 size={2}>
<StyledBox>
<Box>
<TextInput
name={`campaigns.${index.toString()}.notes`}
label="Notes"
control={control}
/>
</Box>
<Box
sx={{
display: "flex",
justifyContent: "center",
alignItems: "center",
}}
>
{index == 0 ? (
<Tooltip title="Split Campaign" placement="top" arrow>
<Button
onClick={() => {
append({
impressions_sold: "",
net_spend: "",
kevel_flight_id: "",
ad_ops_person: "",
seller: "",
start_date: "",
end_date: "",
notes: "",
deal: formData.deal,
});
}}
>
<Add fontSize="large" />
</Button>
</Tooltip>
) : (
<Tooltip title="Remove Campaign" placement="top" arrow>
<Button
onClick={() => {
remove(index);
}}
>
<Remove fontSize="large" color="error" />
</Button>
</Tooltip>
)}
</Box>
</StyledBox>
</Grid2>
</React.Fragment>
))}
</Grid2>
<Box mt="2rem" display="flex" justifyContent="flex-end" width="100%">
<StyledButton variant="contained" type="submit">
Save
</StyledButton>
</Box>
</Box>
</Box>
);
}
15 changes: 3 additions & 12 deletions ad-ops-dashboard/src/components/Inputs/TextInput.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -17,22 +17,13 @@ export default function TextInput({
<Controller
name={name}
control={control}
render={({
field: { onChange, onBlur, value, ref },
fieldState: { error },
}) => (
render={({ field, fieldState: { error } }) => (
<TextField
onChange={(e) =>
{ onChange(props.type === "number" ? +e.target.value : e.target.value); }
}
onBlur={onBlur}
value={value}
inputRef={ref}
fullWidth
label={label}
{...field}
{...props}
error={!!error}
helperText={error?.message}
label={label}
/>
)}
/>
Expand Down
4 changes: 2 additions & 2 deletions ad-ops-dashboard/src/config/routes.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,8 @@ const BASE_URL = import.meta.env.VITE_API_BASE_URL;

export const apiRoutes = {
campaigns: `${BASE_URL}/campaigns/`,
campaign: (id: number | undefined) =>
`${BASE_URL}/campaigns/${id}/`,
campaign: (id: number | undefined) => `${BASE_URL}/campaigns/${id}/`,
splitCampaigns: `${BASE_URL}/campaigns/split/`,
boostrDeals: `${BASE_URL}/deals/`,
products: `${BASE_URL}/products/`,
};
Loading

0 comments on commit 786392a

Please sign in to comment.