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 11:22:42 +00:00
committed by GitHub
parent 6113af0a66
commit 0c0b425de7
33 changed files with 542 additions and 11 deletions
@@ -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 />,
@@ -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>
)
}
@@ -0,0 +1 @@
export * from "./code-cell"
@@ -0,0 +1 @@
export * from "./text-cell"
@@ -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>
)
}
@@ -0,0 +1 @@
export * from "./status-cell"
@@ -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_>
}
@@ -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>[]
}
@@ -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
}
@@ -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,
}
}
@@ -1,2 +1,3 @@
export * from "./auth"
export * from "./promotion"
export * from "./store"
@@ -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 }
}
@@ -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
}
@@ -77,6 +77,18 @@ export const v2Routes: RouteObject[] = [
},
],
},
{
path: "/promotions",
handle: {
crumb: () => "Promotions",
},
children: [
{
path: "",
lazy: () => import("../../v2-routes/promotions/promotion-list"),
},
],
},
],
},
],
@@ -0,0 +1 @@
export * from "./promotion-list-table"
@@ -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]
)
}
@@ -0,0 +1,2 @@
export { promotionsLoader } from "./loader"
export { PromotionsList as Component } from "./promotions-list.tsx"
@@ -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))
)
}
}
@@ -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>
)
}