From 6ec5ded6c8a533c5b67b321ddfffb9db1f70d8ec Mon Sep 17 00:00:00 2001 From: Riqwan Thamir Date: Fri, 10 May 2024 10:39:01 +0200 Subject: [PATCH] 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 --- .../public/locales/en-US/translation.json | 10 + .../dashboard/src/hooks/api/campaigns.tsx | 22 +- .../dashboard/src/lib/client/campaigns.ts | 12 + .../providers/router-provider/route-map.tsx | 7 + .../add-campaign-promotions.tsx | 19 ++ .../add-campaign-promotions-form.tsx | 223 ++++++++++++++++++ .../components/index.ts | 1 + .../add-campaign-promotions/index.ts | 1 + .../campaign-detail/campaign-detail.tsx | 4 +- .../campaign-promotion-section.tsx | 28 +-- .../add-or-remove-campaign-promotions.ts | 64 +++++ .../types/src/promotion/common/promotion.ts | 5 + .../admin/campaigns/[id]/promotions/route.ts | 2 +- 13 files changed, 374 insertions(+), 24 deletions(-) create mode 100644 packages/admin-next/dashboard/src/v2-routes/campaigns/add-campaign-promotions/add-campaign-promotions.tsx create mode 100644 packages/admin-next/dashboard/src/v2-routes/campaigns/add-campaign-promotions/components/add-campaign-promotions-form.tsx create mode 100644 packages/admin-next/dashboard/src/v2-routes/campaigns/add-campaign-promotions/components/index.ts create mode 100644 packages/admin-next/dashboard/src/v2-routes/campaigns/add-campaign-promotions/index.ts create mode 100644 packages/core/core-flows/src/promotion/steps/add-or-remove-campaign-promotions.ts diff --git a/packages/admin-next/dashboard/public/locales/en-US/translation.json b/packages/admin-next/dashboard/public/locales/en-US/translation.json index 591ba613e4..89eb9c41db 100644 --- a/packages/admin-next/dashboard/public/locales/en-US/translation.json +++ b/packages/admin-next/dashboard/public/locales/en-US/translation.json @@ -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}} <1>{{currency}}" }, diff --git a/packages/admin-next/dashboard/src/hooks/api/campaigns.tsx b/packages/admin-next/dashboard/src/hooks/api/campaigns.tsx index d7add98267..2a558612aa 100644 --- a/packages/admin-next/dashboard/src/hooks/api/campaigns.tsx +++ b/packages/admin-next/dashboard/src/hooks/api/campaigns.tsx @@ -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 +) => { + 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, + }) +} diff --git a/packages/admin-next/dashboard/src/lib/client/campaigns.ts b/packages/admin-next/dashboard/src/lib/client/campaigns.ts index 50b17a0571..c47c835879 100644 --- a/packages/admin-next/dashboard/src/lib/client/campaigns.ts +++ b/packages/admin-next/dashboard/src/lib/client/campaigns.ts @@ -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(`/admin/campaigns/${id}`) } +async function addOrRemoveCampaignPromotions( + id: string, + payload: LinkMethodRequest +) { + return postRequest( + `/admin/campaigns/${id}/promotions`, + payload + ) +} + export const campaigns = { retrieve: retrieveCampaign, list: listCampaigns, create: createCampaign, update: updateCampaign, delete: deleteCampaign, + addOrRemovePromotions: addOrRemoveCampaignPromotions, } diff --git a/packages/admin-next/dashboard/src/providers/router-provider/route-map.tsx b/packages/admin-next/dashboard/src/providers/router-provider/route-map.tsx index 4128f1ed09..0ddf799cfc 100644 --- a/packages/admin-next/dashboard/src/providers/router-provider/route-map.tsx +++ b/packages/admin-next/dashboard/src/providers/router-provider/route-map.tsx @@ -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" + ), + }, ], }, ], diff --git a/packages/admin-next/dashboard/src/v2-routes/campaigns/add-campaign-promotions/add-campaign-promotions.tsx b/packages/admin-next/dashboard/src/v2-routes/campaigns/add-campaign-promotions/add-campaign-promotions.tsx new file mode 100644 index 0000000000..910fb8166a --- /dev/null +++ b/packages/admin-next/dashboard/src/v2-routes/campaigns/add-campaign-promotions/add-campaign-promotions.tsx @@ -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 ( + + {campaign && } + + ) +} diff --git a/packages/admin-next/dashboard/src/v2-routes/campaigns/add-campaign-promotions/components/add-campaign-promotions-form.tsx b/packages/admin-next/dashboard/src/v2-routes/campaigns/add-campaign-promotions/components/add-campaign-promotions-form.tsx new file mode 100644 index 0000000000..2c2d395fed --- /dev/null +++ b/packages/admin-next/dashboard/src/v2-routes/campaigns/add-campaign-promotions/components/add-campaign-promotions-form.tsx @@ -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>({ + defaultValues: { promotion_ids: [] }, + resolver: zodResolver(AddCampaignPromotionsSchema), + }) + + const { setValue } = form + const { mutateAsync, isPending } = useAddOrRemoveCampaignPromotions( + campaign.id + ) + const [rowSelection, setRowSelection] = useState({}) + + const updater: OnChangeFn = (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 ( + +
+ +
+ {form.formState.errors.promotion_ids && ( + + {form.formState.errors.promotion_ids.message} + + )} + + + + +
+
+ + + +
+
+ ) +} + +const columnHelper = createColumnHelper() + +const useColumns = () => { + const base = usePromotionTableColumns() + const { t } = useTranslation() + + return useMemo( + () => [ + columnHelper.display({ + id: "select", + header: ({ table }) => { + return ( + + 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 = ( + row.toggleSelected(!!value)} + onClick={(e) => { + e.stopPropagation() + }} + /> + ) + + if (isAdded) { + return ( + + {Component} + + ) + } + + return Component + }, + }), + ...base, + ], + [t, base] + ) +} diff --git a/packages/admin-next/dashboard/src/v2-routes/campaigns/add-campaign-promotions/components/index.ts b/packages/admin-next/dashboard/src/v2-routes/campaigns/add-campaign-promotions/components/index.ts new file mode 100644 index 0000000000..7e4a29e338 --- /dev/null +++ b/packages/admin-next/dashboard/src/v2-routes/campaigns/add-campaign-promotions/components/index.ts @@ -0,0 +1 @@ +export * from "./add-campaign-promotions-form" diff --git a/packages/admin-next/dashboard/src/v2-routes/campaigns/add-campaign-promotions/index.ts b/packages/admin-next/dashboard/src/v2-routes/campaigns/add-campaign-promotions/index.ts new file mode 100644 index 0000000000..6754a0c2f0 --- /dev/null +++ b/packages/admin-next/dashboard/src/v2-routes/campaigns/add-campaign-promotions/index.ts @@ -0,0 +1 @@ +export { AddCampaignPromotions as Component } from "./add-campaign-promotions" diff --git a/packages/admin-next/dashboard/src/v2-routes/campaigns/campaign-detail/campaign-detail.tsx b/packages/admin-next/dashboard/src/v2-routes/campaigns/campaign-detail/campaign-detail.tsx index 6120e12c96..30d35e37ec 100644 --- a/packages/admin-next/dashboard/src/v2-routes/campaigns/campaign-detail/campaign-detail.tsx +++ b/packages/admin-next/dashboard/src/v2-routes/campaigns/campaign-detail/campaign-detail.tsx @@ -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 = () => {
- {/* TODO: enable this when missing APIs are available */} - {/* */} +
diff --git a/packages/admin-next/dashboard/src/v2-routes/campaigns/campaign-detail/components/campaign-promotion-section/campaign-promotion-section.tsx b/packages/admin-next/dashboard/src/v2-routes/campaigns/campaign-detail/components/campaign-promotion-section/campaign-promotion-section.tsx index 8f4f6d9868..6f86b3a68e 100644 --- a/packages/admin-next/dashboard/src/v2-routes/campaigns/campaign-detail/components/campaign-promotion-section/campaign-promotion-section.tsx +++ b/packages/admin-next/dashboard/src/v2-routes/campaigns/campaign-detail/components/campaign-promotion-section/campaign-promotion-section.tsx @@ -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({}) 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() diff --git a/packages/core/core-flows/src/promotion/steps/add-or-remove-campaign-promotions.ts b/packages/core/core-flows/src/promotion/steps/add-or-remove-campaign-promotions.ts new file mode 100644 index 0000000000..f94495ce5d --- /dev/null +++ b/packages/core/core-flows/src/promotion/steps/add-or-remove-campaign-promotions.ts @@ -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, { container }) => { + const { + id: campaignId, + add: promotionIdsToAdd = [], + remove: promotionIdsToRemove = [], + } = input + const promotionModule = container.resolve( + 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( + ModuleRegistrationName.PROMOTION + ) + + if (promotionIdsToAdd.length) { + promotionModule.addPromotionsToCampaign({ + id: campaignId, + promotion_ids: promotionIdsToAdd, + }) + } + + if (promotionIdsToRemove.length) { + promotionModule.removePromotionsFromCampaign({ + id: campaignId, + promotion_ids: promotionIdsToRemove, + }) + } + } +) diff --git a/packages/core/types/src/promotion/common/promotion.ts b/packages/core/types/src/promotion/common/promotion.ts index e3d9723c60..50e12b28fd 100644 --- a/packages/core/types/src/promotion/common/promotion.ts +++ b/packages/core/types/src/promotion/common/promotion.ts @@ -51,6 +51,11 @@ export interface PromotionDTO { */ rules?: PromotionRuleDTO[] + /** + * The associated campaign. + */ + campaign_id?: string | null + /** * The associated campaign. */ diff --git a/packages/medusa/src/api-v2/admin/campaigns/[id]/promotions/route.ts b/packages/medusa/src/api-v2/admin/campaigns/[id]/promotions/route.ts index 7fc71e3165..05cd7251d4 100644 --- a/packages/medusa/src/api-v2/admin/campaigns/[id]/promotions/route.ts +++ b/packages/medusa/src/api-v2/admin/campaigns/[id]/promotions/route.ts @@ -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 (