feat(medusa-react,medusa,types,dashboard): added empty state + table for promotions list page (#6827)

what:

- adds empty state for promotions list page
- lists all promotions with pagination

<img width="1663" alt="Screenshot 2024-03-26 at 14 19 27" src="https://github.com/medusajs/medusa/assets/5105988/ed0d5c65-d003-40f5-b899-540970d892f5">


<img width="1664" alt="Screenshot 2024-03-27 at 20 46 17" src="https://github.com/medusajs/medusa/assets/5105988/4aa40f09-fe3f-4f34-af7a-f5c183254c76">
This commit is contained in:
Riqwan Thamir
2024-03-29 12:22:42 +01:00
committed by GitHub
parent 6113af0a66
commit 0c0b425de7
33 changed files with 542 additions and 11 deletions

View File

@@ -0,0 +1,7 @@
---
"medusa-react": patch
"@medusajs/medusa": patch
"@medusajs/types": patch
---
feat(medusa-react,medusa,types,dashboard): added empty state for promotions list page

View File

@@ -46,6 +46,11 @@ export const injectionZones = [
"discount.details.after",
"discount.list.before",
"discount.list.after",
// Promotion injection zones
"promotion.details.before",
"promotion.details.after",
"promotion.list.before",
"promotion.list.after",
// Gift card injection zones
"gift_card.details.before",
"gift_card.details.after",

View File

@@ -131,6 +131,15 @@
},
"required": ["domain"]
},
"promotions": {
"type": "object",
"properties": {
"domain": {
"type": "string"
}
},
"required": ["domain"]
},
"giftCards": {
"type": "object",
"properties": {
@@ -176,6 +185,23 @@
},
"required": ["domain", "role", "roles"]
},
"statuses": {
"type": "object",
"properties": {
"scheduled": {
"type": "string"
},
"expired": {
"type": "string"
},
"active": {
"type": "string"
},
"disabled": {
"type": "string"
}
}
},
"fields": {
"type": "object",
"properties": {

View File

@@ -502,6 +502,16 @@
"disabled": "Disabled"
}
},
"promotions": {
"domain": "Promotions",
"fields": {
"method": "Method",
"campaign": "Campaign"
},
"deleteWarning": "You are about to delete the promotion {{code}}. This action cannot be undone.",
"createPromotionTitle": "Create Promotion",
"type": "Promotion type"
},
"pricing": {
"domain": "Pricing",
"deletePriceListWarning": "You are about to delete the price list {{name}}. This action cannot be undone.",
@@ -801,6 +811,12 @@
"serverError": "Server error - Try again later.",
"invalidCredentials": "Wrong email or password"
},
"statuses": {
"scheduled": "Scheduled",
"expired": "Expired",
"active": "Active",
"disabled": "Disabled"
},
"fields": {
"amount": "Amount",
"name": "Name",

View File

@@ -135,8 +135,8 @@ const useCoreRoutes = (): Omit<NavItemProps, "pathname">[] => {
},
{
icon: <ReceiptPercent />,
label: t("discounts.domain"),
to: "/discounts",
label: t("promotions.domain"),
to: "/promotions",
},
{
icon: <CurrencyDollar />,

View File

@@ -0,0 +1,26 @@
type CellProps = {
code: string
}
type HeaderProps = {
text: string
}
export const CodeCell = ({ code }: CellProps) => {
return (
<div className="flex h-full w-full items-center gap-x-3 overflow-hidden">
{/* // TODO: border color inversion*/}
<span className="bg-ui-tag-neutral-bg truncate rounded-md border border-neutral-200 p-1 text-xs">
{code}
</span>
</div>
)
}
export const CodeHeader = ({ text }: HeaderProps) => {
return (
<div className=" flex h-full w-full items-center ">
<span>{text}</span>
</div>
)
}

View File

@@ -0,0 +1 @@
export * from "./code-cell"

View File

@@ -0,0 +1 @@
export * from "./text-cell"

View File

@@ -0,0 +1,23 @@
type CellProps = {
text?: string | number
}
type HeaderProps = {
text: string
}
export const TextCell = ({ text }: CellProps) => {
return (
<div className="flex h-full w-full items-center gap-x-3 overflow-hidden">
<span className="truncate">{text}</span>
</div>
)
}
export const TextHeader = ({ text }: HeaderProps) => {
return (
<div className=" flex h-full w-full items-center">
<span>{text}</span>
</div>
)
}

View File

@@ -0,0 +1 @@
export * from "./status-cell"

View File

@@ -0,0 +1,28 @@
import { PromotionDTO } from "@medusajs/types"
import { useTranslation } from "react-i18next"
import {
getPromotionStatus,
PromotionStatus,
} from "../../../../../lib/promotions"
import { StatusCell as StatusCell_ } from "../../common/status-cell"
type PromotionCellProps = {
promotion: PromotionDTO
}
type StatusColors = "grey" | "orange" | "green" | "red"
type StatusMap = Record<string, [StatusColors, string]>
export const StatusCell = ({ promotion }: PromotionCellProps) => {
const { t } = useTranslation()
const statusMap: StatusMap = {
[PromotionStatus.DISABLED]: ["grey", t("statuses.disabled")],
[PromotionStatus.ACTIVE]: ["green", t("statuses.active")],
[PromotionStatus.SCHEDULED]: ["orange", t("statuses.scheduled")],
[PromotionStatus.EXPIRED]: ["red", t("statuses.expired")],
}
const [color, text] = statusMap[getPromotionStatus(promotion)]
return <StatusCell_ color={color}>{text}</StatusCell_>
}

View File

@@ -0,0 +1,55 @@
import { PromotionDTO } from "@medusajs/types"
import { ColumnDef, createColumnHelper } from "@tanstack/react-table"
import { useMemo } from "react"
import { useTranslation } from "react-i18next"
import {
CodeCell,
CodeHeader,
} from "../../../components/table/table-cells/common/code-cell"
import {
TextCell,
TextHeader,
} from "../../../components/table/table-cells/common/text-cell"
import { StatusCell } from "../../../components/table/table-cells/promotion/status-cell"
const columnHelper = createColumnHelper<PromotionDTO>()
export const usePromotionTableColumns = () => {
const { t } = useTranslation()
return useMemo(
() => [
columnHelper.display({
id: "code",
header: () => <CodeHeader text={t("fields.code")} />,
cell: ({ row }) => <CodeCell code={row.original.code!} />,
}),
columnHelper.display({
id: "campaign",
header: () => <TextHeader text={t("promotions.fields.campaign")} />,
cell: ({ row }) => <TextCell text={row.original.campaign?.name} />,
}),
columnHelper.display({
id: "method",
header: () => <TextHeader text={t("promotions.fields.method")} />,
cell: ({ row }) => {
const text = row.original.is_automatic
? "Automatic"
: "Promotion Code"
return <TextCell text={text} />
},
}),
columnHelper.display({
id: "status",
header: () => <TextHeader text={t("fields.status")} />,
cell: ({ row }) => <StatusCell promotion={row.original} />,
}),
],
[]
) as ColumnDef<PromotionDTO>[]
}

View File

@@ -0,0 +1,13 @@
import { useTranslation } from "react-i18next"
import { Filter } from "../../../components/table/data-table"
export const usePromotionTableFilters = () => {
const { t } = useTranslation()
let filters: Filter[] = [
{ label: t("fields.createdAt"), key: "created_at", type: "date" },
{ label: t("fields.updatedAt"), key: "updated_at", type: "date" },
]
return filters
}

View File

@@ -0,0 +1,32 @@
import { AdminGetPromotionsParams } from "@medusajs/medusa"
import { useQueryParams } from "../../use-query-params"
type UsePromotionTableQueryProps = {
prefix?: string
pageSize?: number
}
export const usePromotionTableQuery = ({
prefix,
pageSize = 20,
}: UsePromotionTableQueryProps) => {
const queryObject = useQueryParams(
["offset", "q", "created_at", "updated_at"],
prefix
)
const { offset, q, created_at, updated_at } = queryObject
const searchParams: AdminGetPromotionsParams = {
limit: pageSize,
created_at: created_at ? JSON.parse(created_at) : undefined,
updated_at: updated_at ? JSON.parse(updated_at) : undefined,
offset: offset ? Number(offset) : 0,
q,
}
return {
searchParams,
raw: queryObject,
}
}

View File

@@ -1,2 +1,3 @@
export * from "./auth"
export * from "./promotion"
export * from "./store"

View File

@@ -0,0 +1,23 @@
import {
AdminGetPromotionsParams,
AdminPromotionsListRes,
} from "@medusajs/medusa"
import { queryKeysFactory, useAdminCustomQuery } from "medusa-react"
const QUERY_KEY = "admin_promotions"
export const adminPromotionKeys = queryKeysFactory<
typeof QUERY_KEY,
AdminGetPromotionsParams
>(QUERY_KEY)
export const useV2Promotions = (
query?: AdminGetPromotionsParams,
options?: object
) => {
const { data, ...rest } = useAdminCustomQuery<
AdminGetPromotionsParams,
AdminPromotionsListRes
>("/admin/promotions", adminPromotionKeys.list(query), query, options)
return { ...data, ...rest }
}

View File

@@ -0,0 +1,31 @@
import { PromotionDTO } from "@medusajs/types"
export enum PromotionStatus {
SCHEDULED = "SCHEDULED",
EXPIRED = "EXPIRED",
ACTIVE = "ACTIVE",
DISABLED = "DISABLED",
}
export const getPromotionStatus = (promotion: PromotionDTO) => {
const date = new Date()
const campaign = promotion.campaign
if (!campaign) {
return PromotionStatus.ACTIVE
}
if (new Date(campaign.starts_at!) > date) {
return PromotionStatus.SCHEDULED
}
const campaignBudget = campaign.budget
const overBudget =
campaignBudget && campaignBudget.used! > campaignBudget.limit!
if ((campaign.ends_at && new Date(campaign.ends_at) < date) || overBudget) {
return PromotionStatus.EXPIRED
}
return PromotionStatus.ACTIVE
}

View File

@@ -77,6 +77,18 @@ export const v2Routes: RouteObject[] = [
},
],
},
{
path: "/promotions",
handle: {
crumb: () => "Promotions",
},
children: [
{
path: "",
lazy: () => import("../../v2-routes/promotions/promotion-list"),
},
],
},
],
},
],

View File

@@ -0,0 +1 @@
export * from "./promotion-list-table"

View File

@@ -0,0 +1,147 @@
import { PencilSquare, Trash } from "@medusajs/icons"
import { PromotionDTO } from "@medusajs/types"
import { Button, Container, Heading, usePrompt } from "@medusajs/ui"
import { createColumnHelper } from "@tanstack/react-table"
import { useAdminDeleteDiscount } from "medusa-react"
import { useMemo } from "react"
import { useTranslation } from "react-i18next"
import { Link, Outlet, useLoaderData } from "react-router-dom"
import { ActionMenu } from "../../../../../components/common/action-menu"
import { DataTable } from "../../../../../components/table/data-table"
import { usePromotionTableColumns } from "../../../../../hooks/table/columns-v2/use-promotion-table-columns"
import { usePromotionTableFilters } from "../../../../../hooks/table/filters-v2/use-promotion-table-filters"
import { usePromotionTableQuery } from "../../../../../hooks/table/query-v2/use-promotion-table-query"
import { useDataTable } from "../../../../../hooks/use-data-table"
import { useV2Promotions } from "../../../../../lib/api-v2"
import { promotionsLoader } from "../../loader"
const PAGE_SIZE = 20
export const PromotionListTable = () => {
const { t } = useTranslation()
const initialData = useLoaderData() as Awaited<
ReturnType<ReturnType<typeof promotionsLoader>>
>
const { searchParams, raw } = usePromotionTableQuery({ pageSize: PAGE_SIZE })
const { promotions, count, isLoading, isError, error } = useV2Promotions(
{ ...searchParams },
{
initialData,
keepPreviousData: true,
}
)
const filters = usePromotionTableFilters()
const columns = useColumns()
const { table } = useDataTable({
data: (promotions ?? []) as PromotionDTO[],
columns,
count,
enablePagination: true,
pageSize: PAGE_SIZE,
getRowId: (row) => row.id,
})
if (isError) {
throw error
}
return (
<Container className="divide-y p-0">
<div className="flex items-center justify-between px-6 py-4">
<Heading level="h2">{t("promotions.domain")}</Heading>
<Button size="small" variant="secondary" asChild>
<Link to="create">{t("actions.create")}</Link>
</Button>
</div>
<DataTable
table={table}
columns={columns}
count={count}
pageSize={PAGE_SIZE}
filters={filters}
search
pagination
isLoading={isLoading}
queryObject={raw}
navigateTo={(row) => `${row.original.id}`}
orderBy={["created_at", "updated_at"]}
/>
<Outlet />
</Container>
)
}
const PromotionActions = ({ promotion }: { promotion: PromotionDTO }) => {
const { t } = useTranslation()
const prompt = usePrompt()
// TODO: change to promotions delete endpoint
const { mutateAsync } = useAdminDeleteDiscount(promotion.id)
const handleDelete = async () => {
const res = await prompt({
title: t("general.areYouSure"),
description: t("promotions.deleteWarning", {
code: promotion.code,
}),
confirmText: t("actions.delete"),
cancelText: t("actions.cancel"),
})
if (!res) {
return
}
// TODO: handle error scenario here
await mutateAsync()
}
return (
<ActionMenu
groups={[
{
actions: [
{
icon: <PencilSquare />,
label: t("actions.edit"),
to: `/promotions/${promotion.id}/edit`,
},
],
},
{
actions: [
{
icon: <Trash />,
label: t("actions.delete"),
onClick: handleDelete,
},
],
},
]}
/>
)
}
const columnHelper = createColumnHelper<PromotionDTO>()
const useColumns = () => {
const base = usePromotionTableColumns()
return useMemo(
() => [
...base,
columnHelper.display({
id: "actions",
cell: ({ row }) => {
return <PromotionActions promotion={row.original} />
},
}),
],
[base]
)
}

View File

@@ -0,0 +1,2 @@
export { promotionsLoader } from "./loader"
export { PromotionsList as Component } from "./promotions-list.tsx"

View File

@@ -0,0 +1,22 @@
import { AdminPromotionsListRes } from "@medusajs/medusa"
import { Response } from "@medusajs/medusa-js"
import { QueryClient } from "@tanstack/react-query"
import { adminPromotionKeys, useV2Promotions } from "../../../lib/api-v2"
import { queryClient } from "../../../lib/medusa"
const promotionsListQuery = () => ({
queryKey: adminPromotionKeys.list(),
queryFn: async () => useV2Promotions({ limit: 20, offset: 0 }),
})
export const promotionsLoader = (client: QueryClient) => {
return async () => {
const query = promotionsListQuery()
return (
queryClient.getQueryData<Response<AdminPromotionsListRes>>(
query.queryKey
) ?? (await client.fetchQuery(query))
)
}
}

View File

@@ -0,0 +1,20 @@
import after from "medusa-admin:widgets/promotion/list/after"
import before from "medusa-admin:widgets/promotion/list/before"
import { PromotionListTable } from "./components/promotion-list-table"
export const PromotionsList = () => {
return (
<div className="flex flex-col gap-y-2">
{before.widgets.map((w, i) => (
<w.Component key={i} />
))}
<PromotionListTable />
{after.widgets.map((w, i) => (
<w.Component key={i} />
))}
</div>
)
}

View File

@@ -1,2 +1,3 @@
export * from "./store/"
export * from "./admin/"
export * from "./admin"
export * from "./store"
export * from "./utils"

View File

@@ -1,3 +1,4 @@
export * from "./contexts"
export * from "./hooks/"
export * from "./helpers"
export * from "./hooks"
export * from "./types"

View File

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

View File

@@ -0,0 +1,2 @@
export * from "./types"
export * from "./validators"

View File

@@ -1,12 +1,11 @@
import { createPromotionsWorkflow } from "@medusajs/core-flows"
import { ModuleRegistrationName } from "@medusajs/modules-sdk"
import { CreatePromotionDTO, IPromotionModuleService } from "@medusajs/types"
import {
AuthenticatedMedusaRequest,
MedusaResponse,
} from "../../../types/routing"
import { createPromotionsWorkflow } from "@medusajs/core-flows"
import { ModuleRegistrationName } from "@medusajs/modules-sdk"
export const GET = async (
req: AuthenticatedMedusaRequest,
res: MedusaResponse

View File

@@ -0,0 +1,5 @@
import { PaginatedResponse, PromotionDTO } from "@medusajs/types"
export type AdminPromotionsListRes = PaginatedResponse<{
promotions: PromotionDTO[]
}>

View File

@@ -20,7 +20,11 @@ import {
ValidateIf,
ValidateNested,
} from "class-validator"
import { FindParams, extendedFindParamsMixin } from "../../../types/common"
import {
DateComparisonOperator,
FindParams,
extendedFindParamsMixin,
} from "../../../types/common"
import { XorConstraint } from "../../../types/validators/xor"
import { AdminPostCampaignsReq } from "../campaigns/validators"
@@ -33,6 +37,29 @@ export class AdminGetPromotionsParams extends extendedFindParamsMixin({
@IsString()
@IsOptional()
code?: string
/**
* Search terms to search promotions' code fields.
*/
@IsString()
@IsOptional()
q?: string
/**
* Date filters to apply on the promotions' `created_at` date.
*/
@IsOptional()
@ValidateNested()
@Type(() => DateComparisonOperator)
created_at?: DateComparisonOperator
/**
* Date filters to apply on the promotions' `updated_at` date.
*/
@IsOptional()
@ValidateNested()
@Type(() => DateComparisonOperator)
updated_at?: DateComparisonOperator
}
export class AdminPostPromotionsReq {

View File

@@ -0,0 +1 @@
export * from "./admin"

View File

@@ -1,4 +1,5 @@
export * from "./api"
export * from "./api-v2"
export * from "./api/middlewares"
export * from "./interfaces"
export * from "./joiner-config"

View File

@@ -230,7 +230,7 @@ export type RequestQueryFields = {
*
* Fields included in the response if it's paginated.
*/
export type PaginatedResponse = {
export type PaginatedResponse<T = unknown> = {
/**
* The limit applied on the retrieved items.
*/
@@ -245,7 +245,7 @@ export type PaginatedResponse = {
* The total count of items.
*/
count: number
}
} & T
/**
* The fields returned in the response of a DELETE request.