feat(dashboard): add promotions to campaign UI (#7283)

* feat(core-flows,types,medusa): API to add promotions to campaign

* chore: consolidate specs

* chore: split workflows step into 2

* chore: fix tests

* chore: fix specs

* chore: add promotions to campaign UI

* chore: fix bug wrt to not refreshing

* chore: address review comments
This commit is contained in:
Riqwan Thamir
2024-05-10 10:39:01 +02:00
committed by GitHub
parent ea872cbda9
commit 6ec5ded6c8
13 changed files with 374 additions and 24 deletions

View File

@@ -1086,6 +1086,16 @@
}
}
},
"promotions": {
"remove": {
"title": "Remove promotion from campaign",
"description": "You are about to remove {{count}} promotion(s) from the campaign. This action cannot be undone."
},
"alreadyAdded": "This promotion has already been added to the campaign.",
"toast": {
"success": "Successfully added {{count}} promotion(s) to campaign"
}
},
"deleteCampaignWarning": "You are about to delete the campaign {{name}}. This action cannot be undone.",
"totalSpend": "<0>{{amount}}</0> <1>{{currency}}</1>"
},

View File

@@ -1,6 +1,7 @@
import {
AdminCampaignListResponse,
AdminCampaignResponse,
LinkMethodRequest,
} from "@medusajs/types"
import {
QueryKey,
@@ -14,6 +15,7 @@ import { queryClient } from "../../lib/medusa"
import { queryKeysFactory } from "../../lib/query-key-factory"
import { CreateCampaignReq, UpdateCampaignReq } from "../../types/api-payloads"
import { CampaignDeleteRes } from "../../types/api-responses"
import { promotionsQueryKeys } from "./promotions"
const REGIONS_QUERY_KEY = "campaigns" as const
export const campaignsQueryKeys = queryKeysFactory(REGIONS_QUERY_KEY)
@@ -82,7 +84,7 @@ export const useUpdateCampaign = (
mutationFn: (payload) => client.campaigns.update(id, payload),
onSuccess: (data, variables, context) => {
queryClient.invalidateQueries({ queryKey: campaignsQueryKeys.lists() })
queryClient.invalidateQueries({ queryKey: campaignsQueryKeys.detail(id) })
queryClient.invalidateQueries({ queryKey: campaignsQueryKeys.details() })
options?.onSuccess?.(data, variables, context)
},
@@ -98,10 +100,26 @@ export const useDeleteCampaign = (
mutationFn: () => client.campaigns.delete(id),
onSuccess: (data, variables, context) => {
queryClient.invalidateQueries({ queryKey: campaignsQueryKeys.lists() })
queryClient.invalidateQueries({ queryKey: campaignsQueryKeys.detail(id) })
queryClient.invalidateQueries({ queryKey: campaignsQueryKeys.details() })
options?.onSuccess?.(data, variables, context)
},
...options,
})
}
export const useAddOrRemoveCampaignPromotions = (
id: string,
options?: UseMutationOptions<AdminCampaignResponse, Error, LinkMethodRequest>
) => {
return useMutation({
mutationFn: (payload) =>
client.campaigns.addOrRemovePromotions(id, payload),
onSuccess: (data, variables, context) => {
queryClient.invalidateQueries({ queryKey: campaignsQueryKeys.details() })
queryClient.invalidateQueries({ queryKey: promotionsQueryKeys.lists() })
options?.onSuccess?.(data, variables, context)
},
...options,
})
}

View File

@@ -2,6 +2,7 @@ import {
AdminCampaignListResponse,
AdminCampaignResponse,
CreateCampaignDTO,
LinkMethodRequest,
UpdateCampaignDTO,
} from "@medusajs/types"
import { CampaignDeleteRes } from "../../types/api-responses"
@@ -27,10 +28,21 @@ async function deleteCampaign(id: string) {
return deleteRequest<CampaignDeleteRes>(`/admin/campaigns/${id}`)
}
async function addOrRemoveCampaignPromotions(
id: string,
payload: LinkMethodRequest
) {
return postRequest<AdminCampaignResponse>(
`/admin/campaigns/${id}/promotions`,
payload
)
}
export const campaigns = {
retrieve: retrieveCampaign,
list: listCampaigns,
create: createCampaign,
update: updateCampaign,
delete: deleteCampaign,
addOrRemovePromotions: addOrRemoveCampaignPromotions,
}

View File

@@ -237,6 +237,13 @@ export const RouteMap: RouteObject[] = [
lazy: () =>
import("../../v2-routes/campaigns/campaign-budget-edit"),
},
{
path: "add-promotions",
lazy: () =>
import(
"../../v2-routes/campaigns/add-campaign-promotions"
),
},
],
},
],

View File

@@ -0,0 +1,19 @@
import { useParams } from "react-router-dom"
import { RouteFocusModal } from "../../../components/route-modal"
import { useCampaign } from "../../../hooks/api/campaigns"
import { AddCampaignPromotionsForm } from "./components"
export const AddCampaignPromotions = () => {
const { id } = useParams()
const { campaign, isError, error } = useCampaign(id!)
if (isError) {
throw error
}
return (
<RouteFocusModal>
{campaign && <AddCampaignPromotionsForm campaign={campaign} />}
</RouteFocusModal>
)
}

View File

@@ -0,0 +1,223 @@
import { zodResolver } from "@hookform/resolvers/zod"
import { CampaignResponse, PromotionDTO } from "@medusajs/types"
import { Button, Checkbox, Hint, toast, Tooltip } from "@medusajs/ui"
import { keepPreviousData } from "@tanstack/react-query"
import {
createColumnHelper,
OnChangeFn,
RowSelectionState,
} from "@tanstack/react-table"
import { useMemo, useState } from "react"
import { useForm } from "react-hook-form"
import { useTranslation } from "react-i18next"
import * as zod from "zod"
import {
RouteFocusModal,
useRouteModal,
} from "../../../../components/route-modal"
import { DataTable } from "../../../../components/table/data-table"
import { useAddOrRemoveCampaignPromotions } from "../../../../hooks/api/campaigns"
import { usePromotions } from "../../../../hooks/api/promotions"
import { usePromotionTableColumns } from "../../../../hooks/table/columns-v2/use-promotion-table-columns"
import { usePromotionTableFilters } from "../../../../hooks/table/filters/use-promotion-table-filters"
import { usePromotionTableQuery } from "../../../../hooks/table/query-v2/use-promotion-table-query"
import { useDataTable } from "../../../../hooks/use-data-table"
type AddCampaignPromotionsFormProps = {
campaign: CampaignResponse
}
const AddCampaignPromotionsSchema = zod.object({
promotion_ids: zod.array(zod.string()).min(1),
})
const PAGE_SIZE = 50
export const AddCampaignPromotionsForm = ({
campaign,
}: AddCampaignPromotionsFormProps) => {
const { t } = useTranslation()
const { handleSuccess } = useRouteModal()
const form = useForm<zod.infer<typeof AddCampaignPromotionsSchema>>({
defaultValues: { promotion_ids: [] },
resolver: zodResolver(AddCampaignPromotionsSchema),
})
const { setValue } = form
const { mutateAsync, isPending } = useAddOrRemoveCampaignPromotions(
campaign.id
)
const [rowSelection, setRowSelection] = useState<RowSelectionState>({})
const updater: OnChangeFn<RowSelectionState> = (fn) => {
const state = typeof fn === "function" ? fn(rowSelection) : fn
const ids = Object.keys(state)
setValue("promotion_ids", ids, {
shouldDirty: true,
shouldTouch: true,
})
setRowSelection(state)
}
const { searchParams, raw } = usePromotionTableQuery({ pageSize: PAGE_SIZE })
const {
promotions,
count,
isPending: isLoading,
isError,
error,
} = usePromotions(
{ ...searchParams, campaign_id: "null" },
{ placeholderData: keepPreviousData }
)
const columns = useColumns()
const filters = usePromotionTableFilters()
const { table } = useDataTable({
data: promotions ?? [],
columns,
enableRowSelection: (row) => {
return row.original.campaign_id !== campaign.id
},
enablePagination: true,
getRowId: (row) => row.id,
pageSize: PAGE_SIZE,
count,
rowSelection: {
state: rowSelection,
updater,
},
meta: { campaignId: campaign.id },
})
const handleSubmit = form.handleSubmit(async (values) => {
await mutateAsync(
{ add: values.promotion_ids },
{
onSuccess: () => {
toast.success(t("general.success"), {
description: t("campaigns.promotions.toast.success", {
count: values.promotion_ids.length,
}),
dismissLabel: t("actions.close"),
})
handleSuccess()
},
onError: (error) =>
toast.error(t("general.error"), {
description: error.message,
dismissLabel: t("actions.close"),
}),
}
)
})
return (
<RouteFocusModal.Form form={form}>
<form
onSubmit={handleSubmit}
className="flex h-full flex-col overflow-hidden"
>
<RouteFocusModal.Header>
<div className="flex items-center justify-end gap-x-2">
{form.formState.errors.promotion_ids && (
<Hint variant="error">
{form.formState.errors.promotion_ids.message}
</Hint>
)}
<RouteFocusModal.Close asChild>
<Button size="small" variant="secondary">
{t("actions.cancel")}
</Button>
</RouteFocusModal.Close>
<Button size="small" type="submit" isLoading={isPending}>
{t("actions.save")}
</Button>
</div>
</RouteFocusModal.Header>
<RouteFocusModal.Body className="flex size-full flex-col overflow-y-auto">
<DataTable
table={table}
count={count}
columns={columns}
pageSize={PAGE_SIZE}
isLoading={isLoading}
filters={filters}
orderBy={["title", "status", "created_at", "updated_at"]}
queryObject={raw}
layout="fill"
pagination
search
/>
</RouteFocusModal.Body>
</form>
</RouteFocusModal.Form>
)
}
const columnHelper = createColumnHelper<PromotionDTO>()
const useColumns = () => {
const base = usePromotionTableColumns()
const { t } = useTranslation()
return useMemo(
() => [
columnHelper.display({
id: "select",
header: ({ table }) => {
return (
<Checkbox
checked={
table.getIsSomePageRowsSelected()
? "indeterminate"
: table.getIsAllPageRowsSelected()
}
onCheckedChange={(value) =>
table.toggleAllPageRowsSelected(!!value)
}
/>
)
},
cell: ({ row, table }) => {
const { campaignId } = table.options.meta as {
campaignId: string
}
const isAdded = row.original.campaign_id === campaignId
const isSelected = row.getIsSelected() || isAdded
const Component = (
<Checkbox
checked={isSelected}
disabled={isAdded}
onCheckedChange={(value) => row.toggleSelected(!!value)}
onClick={(e) => {
e.stopPropagation()
}}
/>
)
if (isAdded) {
return (
<Tooltip
content={t("campaigns.promotions.alreadyAdded")}
side="right"
>
{Component}
</Tooltip>
)
}
return Component
},
}),
...base,
],
[t, base]
)
}

View File

@@ -0,0 +1 @@
export * from "./add-campaign-promotions-form"

View File

@@ -0,0 +1 @@
export { AddCampaignPromotions as Component } from "./add-campaign-promotions"

View File

@@ -3,6 +3,7 @@ import { JsonViewSection } from "../../../components/common/json-view-section"
import { useCampaign } from "../../../hooks/api/campaigns"
import { CampaignBudget } from "./components/campaign-budget"
import { CampaignGeneralSection } from "./components/campaign-general-section"
import { CampaignPromotionSection } from "./components/campaign-promotion-section"
import { CampaignSpend } from "./components/campaign-spend"
import { campaignLoader } from "./loader"
@@ -31,8 +32,7 @@ export const CampaignDetail = () => {
<div className="flex flex-col gap-x-4 xl:flex-row xl:items-start">
<div className="flex w-full flex-col gap-y-2">
<CampaignGeneralSection campaign={campaign} />
{/* TODO: enable this when missing APIs are available */}
{/* <CampaignPromotionSection campaign={campaign} /> */}
<CampaignPromotionSection campaign={campaign} />
<JsonViewSection data={campaign} />
</div>

View File

@@ -8,7 +8,7 @@ import { Link } from "react-router-dom"
import { ActionMenu } from "../../../../../components/common/action-menu"
import { DataTable } from "../../../../../components/table/data-table"
import { useRemovePromotionsFromCampaign } from "../../../../../hooks/api/campaigns"
import { useAddOrRemoveCampaignPromotions } from "../../../../../hooks/api/campaigns"
import { usePromotions } from "../../../../../hooks/api/promotions"
import { usePromotionTableColumns } from "../../../../../hooks/table/columns-v2/use-promotion-table-columns"
import { usePromotionTableFilters } from "../../../../../hooks/table/filters/use-promotion-table-filters"
@@ -27,16 +27,14 @@ export const CampaignPromotionSection = ({
const [rowSelection, setRowSelection] = useState<RowSelectionState>({})
const { t } = useTranslation()
const prompt = usePrompt()
const columns = useColumns()
const filters = usePromotionTableFilters()
const { searchParams, raw } = usePromotionTableQuery({ pageSize: PAGE_SIZE })
const { promotions, count, isLoading, isError, error } = usePromotions({
...searchParams,
// TODO: This should be scoped by currency_code
campaign_id: campaign.id,
})
const columns = useColumns()
const filters = usePromotionTableFilters()
const { table } = useDataTable({
data: promotions ?? [],
columns,
@@ -49,16 +47,14 @@ export const CampaignPromotionSection = ({
state: rowSelection,
updater: setRowSelection,
},
meta: {
campaignId: campaign.id,
},
meta: { campaignId: campaign.id },
})
if (isError) {
throw error
}
const { mutateAsync } = useRemovePromotionsFromCampaign(campaign.id)
const { mutateAsync } = useAddOrRemoveCampaignPromotions(campaign.id)
const handleRemove = async () => {
const keys = Object.keys(rowSelection)
@@ -77,14 +73,8 @@ export const CampaignPromotionSection = ({
}
await mutateAsync(
{
remove: keys,
},
{
onSuccess: () => {
setRowSelection({})
},
}
{ remove: keys },
{ onSuccess: () => setRowSelection({}) }
)
}
@@ -138,7 +128,7 @@ const PromotionActions = ({
campaignId: string
}) => {
const { t } = useTranslation()
const { mutateAsync } = useRemovePromotionsFromCampaign(campaignId)
const { mutateAsync } = useAddOrRemoveCampaignPromotions(campaignId)
const prompt = usePrompt()

View File

@@ -0,0 +1,64 @@
import { ModuleRegistrationName } from "@medusajs/modules-sdk"
import { IPromotionModuleService, LinkWorkflowInput } from "@medusajs/types"
import { StepResponse, WorkflowData, createStep } from "@medusajs/workflows-sdk"
export const addOrRemoveCampaignPromotionsStepId =
"add-or-remove-campaign-promotions"
export const addOrRemoveCampaignPromotionsStep = createStep(
addOrRemoveCampaignPromotionsStepId,
async (input: WorkflowData<LinkWorkflowInput>, { container }) => {
const {
id: campaignId,
add: promotionIdsToAdd = [],
remove: promotionIdsToRemove = [],
} = input
const promotionModule = container.resolve<IPromotionModuleService>(
ModuleRegistrationName.PROMOTION
)
if (promotionIdsToAdd.length) {
await promotionModule.addPromotionsToCampaign({
id: campaignId,
promotion_ids: promotionIdsToAdd,
})
}
if (promotionIdsToRemove.length) {
await promotionModule.removePromotionsFromCampaign({
id: campaignId,
promotion_ids: promotionIdsToRemove,
})
}
return new StepResponse(null, input)
},
async (data, { container }) => {
if (!data) {
return
}
const {
id: campaignId,
add: promotionIdsToRemove = [],
remove: promotionIdsToAdd = [],
} = data
const promotionModule = container.resolve<IPromotionModuleService>(
ModuleRegistrationName.PROMOTION
)
if (promotionIdsToAdd.length) {
promotionModule.addPromotionsToCampaign({
id: campaignId,
promotion_ids: promotionIdsToAdd,
})
}
if (promotionIdsToRemove.length) {
promotionModule.removePromotionsFromCampaign({
id: campaignId,
promotion_ids: promotionIdsToRemove,
})
}
}
)

View File

@@ -51,6 +51,11 @@ export interface PromotionDTO {
*/
rules?: PromotionRuleDTO[]
/**
* The associated campaign.
*/
campaign_id?: string | null
/**
* The associated campaign.
*/

View File

@@ -4,7 +4,7 @@ import {
} from "../../../../../types/routing"
import { addOrRemoveCampaignPromotionsWorkflow } from "@medusajs/core-flows"
import { LinkMethodRequest } from "@medusajs/types/src"
import { LinkMethodRequest } from "@medusajs/types"
import { refetchCampaign } from "../../helpers"
export const POST = async (