feat(dashboard): variant details page (#7767)

* wip: setup

* feat: finish inventory, prices section

* feat: finish prices section pagination

* fix: move edit variants to variants details, fix loader

* fix: suggestion

* feat: price editor flow
This commit is contained in:
Frane Polić
2024-06-19 13:56:35 +02:00
committed by GitHub
parent fd87858bd9
commit ef5719a3d8
24 changed files with 524 additions and 11 deletions

View File

@@ -174,6 +174,7 @@ export const useUpdateProductVariantsBatch = (
}),
onSuccess: (data: any, variables: any, context: any) => {
queryClient.invalidateQueries({ queryKey: variantsQueryKeys.lists() })
queryClient.invalidateQueries({ queryKey: variantsQueryKeys.details() })
queryClient.invalidateQueries({
queryKey: productsQueryKeys.detail(productId),
})

View File

@@ -76,6 +76,7 @@
"viewDetails": "View details",
"back": "Back",
"close": "Close",
"showMore": "Show more",
"continue": "Continue",
"reset": "Reset",
"confirm": "Confirm",
@@ -325,6 +326,7 @@
"create": {
"header": "Create Variant"
},
"pricesPagination": "1 - {{current}} of {{total}} prices",
"tableItemAvailable": "{{availableCount}} available",
"tableItem_one": "{{availableCount}} available at {{locationCount}} location",
"tableItem_other": "{{availableCount}} available at {{locationCount}} locations",
@@ -1677,6 +1679,10 @@
"enabled": "Enabled",
"disabled": "Disabled"
},
"labels": {
"productVariant": "Product Variant",
"prices": "Prices"
},
"fields": {
"amount": "Amount",
"refundAmount": "Refund amount",
@@ -1688,6 +1694,8 @@
"customTitle": "Custom title",
"manageInventory": "Manage inventory",
"inventoryKit": "Inventory kit",
"inventoryItems": "Inventory items",
"requiredQuantity": "Required quantity",
"description": "Description",
"email": "Email",
"password": "Password",

View File

@@ -110,10 +110,25 @@ export const RouteMap: RouteObject[] = [
lazy: () =>
import("../../routes/products/product-create-variant"),
},
],
},
{
path: ":id/variants/:variant_id",
lazy: () =>
import(
"../../routes/product-variants/product-variant-detail"
),
children: [
{
path: "variants/:variant_id/edit",
path: "edit",
lazy: () =>
import("../../routes/products/product-edit-variant"),
import(
"../../routes/product-variants/product-variant-edit"
),
},
{
path: "prices",
lazy: () => import("../../routes/products/product-prices"),
},
],
},

View File

@@ -0,0 +1 @@
export * from "./variant-general-section"

View File

@@ -0,0 +1,97 @@
import { ProductVariantDTO } from "@medusajs/types"
import { Badge, Container, Heading, usePrompt } from "@medusajs/ui"
import { Component, PencilSquare, Trash } from "@medusajs/icons"
import { useTranslation } from "react-i18next"
import { useNavigate } from "react-router-dom"
import { ActionMenu } from "../../../../../components/common/action-menu"
import { SectionRow } from "../../../../../components/common/section"
import { useDeleteVariant } from "../../../../../hooks/api/products"
type VariantGeneralSectionProps = {
variant: ProductVariantDTO
}
export function VariantGeneralSection({ variant }: VariantGeneralSectionProps) {
const { t } = useTranslation()
const prompt = usePrompt()
const navigate = useNavigate()
const hasInventoryKit = variant.inventory?.length > 1
const { mutateAsync } = useDeleteVariant(variant.product_id, variant.id)
const handleDelete = async () => {
const res = await prompt({
title: t("general.areYouSure"),
description: t("products.variant.deleteWarning", {
title: variant.title,
}),
confirmText: t("actions.delete"),
cancelText: t("actions.cancel"),
})
if (!res) {
return
}
await mutateAsync(undefined, {
onSuccess: () => {
navigate("..", { replace: true })
},
})
}
return (
<Container className="divide-y p-0">
<div className="flex items-center justify-between px-6 py-4">
<div>
<div className="flex items-center gap-2">
<Heading>{variant.title}</Heading>
{hasInventoryKit && (
<span className="text-ui-fg-muted font-normal">
<Component />
</span>
)}
</div>
<span className="text-ui-fg-subtle txt-small mt-2">
{t("labels.productVariant")}
</span>
</div>
<div className="flex items-center gap-x-4">
<ActionMenu
groups={[
{
actions: [
{
label: t("actions.edit"),
to: "edit",
icon: <PencilSquare />,
},
],
},
{
actions: [
{
label: t("actions.delete"),
onClick: handleDelete,
icon: <Trash />,
},
],
},
]}
/>
</div>
</div>
<SectionRow title={t("fields.sku")} value={variant.sku} />
{variant.options.map((o) => (
<SectionRow
key={o.id}
title={o.option?.title}
value={<Badge size="2xsmall">{o.value}</Badge>}
/>
))}
</Container>
)
}

View File

@@ -0,0 +1 @@
export * from "./variant-inventory-section"

View File

@@ -0,0 +1,26 @@
import { useTranslation } from "react-i18next"
import { Buildings } from "@medusajs/icons"
import { InventoryItemDTO } from "@medusajs/types"
import { ActionMenu } from "../../../../../components/common/action-menu"
export const InventoryActions = ({ item }: { item: InventoryItemDTO }) => {
const { t } = useTranslation()
return (
<ActionMenu
groups={[
{
actions: [
{
icon: <Buildings />,
label: t("product.variant.inventory.navigateToItem"),
to: `/inventory/${item.id}`,
},
],
},
]}
/>
)
}

View File

@@ -0,0 +1,104 @@
import { InventoryNext, ProductVariantDTO } from "@medusajs/types"
import { InventoryActions } from "./inventory-actions"
import { PlaceholderCell } from "../../../../../components/table/table-cells/common/placeholder-cell"
import { createColumnHelper } from "@tanstack/react-table"
import { useMemo } from "react"
import { useTranslation } from "react-i18next"
interface ExtendedInventoryItem extends InventoryNext.InventoryItemDTO {
variants: ProductVariantDTO[]
}
const columnHelper = createColumnHelper<ExtendedInventoryItem>()
export const useInventoryTableColumns = () => {
const { t } = useTranslation()
return useMemo(
() => [
columnHelper.accessor("title", {
header: t("fields.title"),
cell: ({ getValue }) => {
const title = getValue()
if (!title) {
return <PlaceholderCell />
}
return (
<div className="flex size-full items-center overflow-hidden">
<span className="truncate">{title}</span>
</div>
)
},
}),
columnHelper.accessor("sku", {
header: t("fields.sku"),
cell: ({ getValue }) => {
const sku = getValue() as string
if (!sku) {
return <PlaceholderCell />
}
return (
<div className="flex size-full items-center overflow-hidden">
<span className="truncate">{sku}</span>
</div>
)
},
}),
columnHelper.accessor("required_quantity", {
header: t("fields.requiredQuantity"),
cell: ({ getValue }) => {
const quantity = getValue()
if (Number.isNaN(quantity)) {
return <PlaceholderCell />
}
return (
<div className="flex size-full items-center overflow-hidden">
<span className="truncate">{quantity}</span>
</div>
)
},
}),
columnHelper.display({
id: "inventory_quantity",
header: t("fields.inventory"),
cell: ({ getValue, row: { original: inventory } }) => {
if (!inventory.location_levels?.length) {
return <PlaceholderCell />
}
let quantity = 0
let locations = 0
inventory.location_levels.forEach((level) => {
quantity += level.available_quantity
locations += 1
})
return (
<div className="flex size-full items-center overflow-hidden">
<span className="truncate">
{t("products.variant.tableItem", {
availableCount: quantity,
locationCount: locations,
count: locations,
})}
</span>
</div>
)
},
}),
columnHelper.display({
id: "actions",
cell: ({ row }) => <InventoryActions item={row.original} />,
}),
],
[t]
)
}

View File

@@ -0,0 +1,67 @@
import { useTranslation } from "react-i18next"
import { Container, Heading } from "@medusajs/ui"
import { InventoryItemDTO } from "@medusajs/types"
import { ActionMenu } from "../../../../../components/common/action-menu"
import { DataTable } from "../../../../../components/table/data-table"
import { useDataTable } from "../../../../../hooks/use-data-table"
import { useInventoryTableColumns } from "./use-inventory-table-columns"
const PAGE_SIZE = 20
type VariantInventorySectionProps = {
inventoryItems: InventoryItemDTO[]
}
export function VariantInventorySection({
inventoryItems,
}: VariantInventorySectionProps) {
const { t } = useTranslation()
const columns = useInventoryTableColumns()
const { table } = useDataTable({
data: inventoryItems ?? [],
columns,
count: inventoryItems.length,
enablePagination: true,
getRowId: (row) => row.id,
pageSize: PAGE_SIZE,
})
return (
<Container className="divide-y p-0">
<div className="flex items-center justify-between px-6 py-4">
<div className="flex items-center gap-2">
<Heading level="h2">{t("fields.inventoryItems")}</Heading>
</div>
<div className="flex items-center gap-x-4">
{/*TODO: add inventory management*/}
{/*<ActionMenu*/}
{/* groups={[*/}
{/* {*/}
{/* actions: [*/}
{/* {*/}
{/* label: t("actions.manageInventoryItems"),*/}
{/* to: "edit",*/}
{/* icon: <Component />,*/}
{/* },*/}
{/* ],*/}
{/* },*/}
{/* ]}*/}
{/*/>*/}
</div>
</div>
<DataTable
table={table}
columns={columns}
pageSize={PAGE_SIZE}
count={inventoryItems.length}
navigateTo={(row) => `/inventory/${row.id}`}
/>
</Container>
)
}

View File

@@ -0,0 +1 @@
export * from "./variant-prices-section"

View File

@@ -0,0 +1,82 @@
import { useTranslation } from "react-i18next"
import { useState } from "react"
import { CurrencyDollar } from "@medusajs/icons"
import { Button, Container, Heading } from "@medusajs/ui"
import { MoneyAmountDTO, ProductVariantDTO } from "@medusajs/types"
import { ActionMenu } from "../../../../../components/common/action-menu"
import { getLocaleAmount } from "../../../../../lib/money-amount-helpers"
import { NoRecords } from "../../../../../components/common/empty-table-content"
type VariantPricesSectionProps = {
variant: ProductVariantDTO & { prices: MoneyAmountDTO[] }
}
export function VariantPricesSection({ variant }: VariantPricesSectionProps) {
const { t } = useTranslation()
const prices = variant.prices
.filter((p) => !p.rules?.length)
.sort((p1, p2) => p1.currency_code?.localeCompare(p2.currency_code)) // display just currency prices
const [current, setCurrent] = useState(Math.min(prices.length, 3))
const hasPrices = !!prices.length
const displayPrices = prices.slice(0, current)
const onShowMore = () => {
setCurrent(Math.min(current + 3, prices.length))
}
return (
<Container className="flex flex-col divide-y p-0">
<div className="flex items-center justify-between px-6 py-4">
<Heading level="h2">{t("labels.prices")}</Heading>
<ActionMenu
groups={[
{
actions: [
{
label: t("actions.edit"),
to: `/products/${variant.product_id}/variants/${variant.id}/prices`,
icon: <CurrencyDollar />,
},
],
},
]}
/>
</div>
{!hasPrices && <NoRecords className="h-60" />}
{displayPrices.map((price) => {
return (
<div className="txt-small text-ui-fg-subtle flex justify-between px-6 py-4">
<span className="font-medium">
{price.currency_code.toUpperCase()}
</span>
<span>{getLocaleAmount(price.amount, price.currency_code)}</span>
</div>
)
})}
{hasPrices && (
<div className="txt-small text-ui-fg-subtle flex items-center justify-between px-6 py-4">
<span className="font-medium">
{t("products.variant.pricesPagination", {
total: prices.length,
current,
})}
</span>
<Button
onClick={onShowMore}
disabled={current >= prices.length}
className="-mr-3 text-blue-500"
variant="transparent"
>
{t("actions.showMore")}
</Button>
</div>
)}
</Container>
)
}

View File

@@ -0,0 +1,2 @@
export const VARIANT_DETAIL_FIELDS =
"*inventory_items,*inventory_items.inventory,*inventory_items.inventory.location_levels,*options,*options.option,*prices,*prices.price_rules"

View File

@@ -0,0 +1,2 @@
export { variantLoader as loader } from "./loader"
export { ProductVariantDetail as Component } from "./product-variant-detail"

View File

@@ -0,0 +1,26 @@
import { LoaderFunctionArgs } from "react-router-dom"
import { variantsQueryKeys } from "../../../hooks/api/products"
import { queryClient } from "../../../lib/query-client"
import { VARIANT_DETAIL_FIELDS } from "./constants"
import { sdk } from "../../../lib/client"
const variantDetailQuery = (productId: string, variantId: string) => ({
queryKey: variantsQueryKeys.detail(variantId),
queryFn: async () =>
sdk.admin.product.retrieveVariant(productId, variantId, {
fields: VARIANT_DETAIL_FIELDS,
}),
})
export const variantLoader = async ({ params }: LoaderFunctionArgs) => {
const productId = params.id
const variantId = params.variant_id
const query = variantDetailQuery(productId!, variantId!)
return (
queryClient.getQueryData<any>(query.queryKey) ??
(await queryClient.fetchQuery(query))
)
}

View File

@@ -0,0 +1,66 @@
import { Outlet, useLoaderData, useParams } from "react-router-dom"
import { JsonViewSection } from "../../../components/common/json-view-section"
import { useProductVariant } from "../../../hooks/api/products"
import { variantLoader } from "./loader"
import { VARIANT_DETAIL_FIELDS } from "./constants"
import { VariantGeneralSection } from "./components/variant-general-section"
import { VariantInventorySection } from "./components/variant-inventory-section"
import { VariantPricesSection } from "./components/variant-prices-section"
export const ProductVariantDetail = () => {
const initialData = useLoaderData() as Awaited<
ReturnType<typeof variantLoader>
>
const { id, variant_id } = useParams()
const { variant, isLoading, isError, error } = useProductVariant(
id!,
variant_id,
{ fields: VARIANT_DETAIL_FIELDS },
{
initialData: initialData,
}
)
if (isLoading || !variant) {
return <div>Loading...</div>
}
if (isError) {
throw error
}
return (
<div className="flex flex-col gap-y-2">
<div className="flex flex-col gap-x-4 gap-y-3 xl:flex-row xl:items-start">
<div className="flex w-full flex-col gap-y-3">
<VariantGeneralSection variant={variant} />
<VariantInventorySection
inventoryItems={variant.inventory_items.map((i) => {
return {
...i.inventory,
required_quantity: i.required_quantity,
variant,
}
})}
/>
<div className="hidden xl:block">
<JsonViewSection data={variant} root="product" />
</div>
</div>
<div className="flex w-full max-w-[100%] flex-col gap-y-3 xl:mt-0 xl:max-w-[400px]">
<VariantPricesSection variant={variant} />
<div className="xl:hidden">
<JsonViewSection data={variant} />
</div>
</div>
</div>
<Outlet />
</div>
)
}

View File

@@ -0,0 +1,2 @@
export { ProductVariantEdit as Component } from "./product-variant-edit"
export { editProductVariantLoader as loader } from "./loader"

View File

@@ -7,7 +7,7 @@ import { ProductEditVariantForm } from "./components/product-edit-variant-form"
import { editProductVariantLoader } from "./loader"
import { HttpTypes } from "@medusajs/types"
export const ProductEditVariant = () => {
export const ProductVariantEdit = () => {
const initialData = useLoaderData() as Awaited<
ReturnType<typeof editProductVariantLoader>
>

View File

@@ -87,6 +87,9 @@ export const ProductVariantSection = ({
pageSize={PAGE_SIZE}
isLoading={isLoading}
orderBy={["title", "created_at", "updated_at"]}
navigateTo={(row) =>
`/products/${row.original.product_id}/variants/${row.id}`
}
pagination
search
queryObject={raw}

View File

@@ -1 +0,0 @@
export { ProductEditVariant as Component } from "./product-edit-variant"

View File

@@ -3,11 +3,12 @@ import { Button } from "@medusajs/ui"
import { useForm } from "react-hook-form"
import { useTranslation } from "react-i18next"
import * as zod from "zod"
import { HttpTypes } from "@medusajs/types"
import { RouteFocusModal, useRouteModal } from "../../../components/route-modal"
import { useUpdateProductVariantsBatch } from "../../../hooks/api/products"
import { VariantPricingForm } from "../common/variant-pricing-form"
import { castNumber } from "../../../lib/cast-number"
import { HttpTypes } from "@medusajs/types"
export const UpdateVariantPricesSchema = zod.object({
variants: zod.array(
@@ -25,14 +26,21 @@ export type UpdateVariantPricesSchemaType = zod.infer<
export const PricingEdit = ({
product,
variantId,
}: {
product: HttpTypes.AdminProduct
variantId?: string
}) => {
const { t } = useTranslation()
const { handleSuccess } = useRouteModal()
const variants = variantId
? product.variants.filter((v) => v.id === variantId)
: product.variants
const form = useForm<UpdateVariantPricesSchemaType>({
defaultValues: {
variants: product.variants.map((variant: any) => ({
variants: variants.map((variant: any) => ({
title: variant.title,
prices: variant.prices.reduce((acc: any, price: any) => {
acc[price.currency_code] = price.amount
@@ -49,10 +57,10 @@ export const PricingEdit = ({
const handleSubmit = form.handleSubmit(
async (values) => {
const reqData = values.variants.map((variant, ind) => ({
id: product.variants[ind].id,
id: variants[ind].id,
prices: Object.entries(variant.prices || {}).map(
([currency_code, value]: any) => {
const id = product.variants[ind].prices.find(
const id = variants[ind].prices.find(
(p) => p.currency_code === currency_code
)?.id
@@ -66,7 +74,7 @@ export const PricingEdit = ({
}))
await mutateAsync(reqData, {
onSuccess: () => {
handleSuccess(`/products/${product.id}`)
handleSuccess("..")
},
})
},

View File

@@ -5,7 +5,7 @@ import { PricingEdit } from "./pricing-edit"
import { RouteFocusModal } from "../../../components/route-modal"
export const ProductPrices = () => {
const { id } = useParams()
const { id, variant_id } = useParams()
const { product, isLoading, isError, error } = useProduct(id!)
@@ -15,7 +15,9 @@ export const ProductPrices = () => {
return (
<RouteFocusModal>
{!isLoading && product && <PricingEdit product={product} />}
{!isLoading && product && (
<PricingEdit product={product} variantId={variant_id} />
)}
</RouteFocusModal>
)
}