fix(dashboard): Load product variant edit page and fix product detail query key (#10029)

**What**
- Fixes Edit Variant form so it properly loads the product variant
- Fixes the query key for product details to prevent the cache from being shared between queries for the same ID but with different params.

Resolves CMRC-685

Co-authored-by: Frane Polić <16856471+fPolic@users.noreply.github.com>
This commit is contained in:
Kasper Fabricius Kristensen
2024-11-12 08:40:08 +00:00
committed by GitHub
co-authored by Frane Polić
parent 81208b6e1d
commit 904f0926f1
12 changed files with 138 additions and 116 deletions
+5
View File
@@ -0,0 +1,5 @@
---
"@medusajs/dashboard": patch
---
fix(dashboard): Fix query key for product details and load product variant data correctly
@@ -87,16 +87,21 @@ export const useDeleteProductOption = (
export const useProductVariant = ( export const useProductVariant = (
productId: string, productId: string,
variantId: string, variantId: string,
query?: Record<string, any>, query?: HttpTypes.AdminProductVariantParams,
options?: Omit< options?: Omit<
UseQueryOptions<any, FetchError, any, QueryKey>, UseQueryOptions<
HttpTypes.AdminProductVariantResponse,
FetchError,
HttpTypes.AdminProductVariantResponse,
QueryKey
>,
"queryFn" | "queryKey" "queryFn" | "queryKey"
> >
) => { ) => {
const { data, ...rest } = useQuery({ const { data, ...rest } = useQuery({
queryFn: () => queryFn: () =>
sdk.admin.product.retrieveVariant(productId, variantId, query), sdk.admin.product.retrieveVariant(productId, variantId, query),
queryKey: variantsQueryKeys.detail(variantId), queryKey: variantsQueryKeys.detail(variantId, query),
...options, ...options,
}) })
@@ -238,7 +243,7 @@ export const useProduct = (
) => { ) => {
const { data, ...rest } = useQuery({ const { data, ...rest } = useQuery({
queryFn: () => sdk.admin.product.retrieve(id, query), queryFn: () => sdk.admin.product.retrieve(id, query),
queryKey: productsQueryKeys.detail(id), queryKey: productsQueryKeys.detail(id, query),
...options, ...options,
}) })
@@ -5,10 +5,7 @@ export type TQueryKey<TKey, TListQuery = any, TDetailQuery = string> = {
lists: () => readonly [...TQueryKey<TKey>["all"], "list"] lists: () => readonly [...TQueryKey<TKey>["all"], "list"]
list: ( list: (
query?: TListQuery query?: TListQuery
) => readonly [ ) => readonly [...ReturnType<TQueryKey<TKey>["lists"]>, { query: TListQuery }]
...ReturnType<TQueryKey<TKey>["lists"]>,
{ query: TListQuery | undefined },
]
details: () => readonly [...TQueryKey<TKey>["all"], "detail"] details: () => readonly [...TQueryKey<TKey>["all"], "detail"]
detail: ( detail: (
id: TDetailQuery, id: TDetailQuery,
@@ -16,7 +13,7 @@ export type TQueryKey<TKey, TListQuery = any, TDetailQuery = string> = {
) => readonly [ ) => readonly [
...ReturnType<TQueryKey<TKey>["details"]>, ...ReturnType<TQueryKey<TKey>["details"]>,
TDetailQuery, TDetailQuery,
{ query: TListQuery | undefined }, { query: TListQuery }
] ]
} }
@@ -26,7 +23,7 @@ export type UseQueryOptionsWrapper<
// Type thrown in case the queryFn rejects // Type thrown in case the queryFn rejects
E = Error, E = Error,
// Query key type // Query key type
TQueryKey extends QueryKey = QueryKey, TQueryKey extends QueryKey = QueryKey
> = Omit< > = Omit<
UseQueryOptions<TQueryFn, E, TQueryFn, TQueryKey>, UseQueryOptions<TQueryFn, E, TQueryFn, TQueryKey>,
"queryKey" | "queryFn" "queryKey" | "queryFn"
@@ -35,20 +32,22 @@ export type UseQueryOptionsWrapper<
export const queryKeysFactory = < export const queryKeysFactory = <
T, T,
TListQueryType = any, TListQueryType = any,
TDetailQueryType = string, TDetailQueryType = string
>( >(
globalKey: T globalKey: T
) => { ) => {
const queryKeyFactory: TQueryKey<T, TListQueryType, TDetailQueryType> = { const queryKeyFactory: TQueryKey<T, TListQueryType, TDetailQueryType> = {
all: [globalKey], all: [globalKey],
lists: () => [...queryKeyFactory.all, "list"], lists: () => [...queryKeyFactory.all, "list"],
list: (query?: TListQueryType) => [...queryKeyFactory.lists(), { query }], list: (query?: TListQueryType) =>
[...queryKeyFactory.lists(), query ? { query } : undefined].filter(
(k) => !!k
),
details: () => [...queryKeyFactory.all, "detail"], details: () => [...queryKeyFactory.all, "detail"],
detail: (id: TDetailQueryType, query?: TListQueryType) => [ detail: (id: TDetailQueryType, query?: TListQueryType) =>
...queryKeyFactory.details(), [...queryKeyFactory.details(), id, query ? { query } : undefined].filter(
id, (k) => !!k
{ query }, ),
],
} }
return queryKeyFactory return queryKeyFactory
} }
@@ -1,6 +1,6 @@
import { ProductVariantDTO } from "@medusajs/types"
import { Badge, Container, Heading, usePrompt } from "@medusajs/ui"
import { Component, PencilSquare, Trash } from "@medusajs/icons" import { Component, PencilSquare, Trash } from "@medusajs/icons"
import { HttpTypes } from "@medusajs/types"
import { Badge, Container, Heading, usePrompt } from "@medusajs/ui"
import { useTranslation } from "react-i18next" import { useTranslation } from "react-i18next"
import { useNavigate } from "react-router-dom" import { useNavigate } from "react-router-dom"
@@ -9,7 +9,7 @@ import { SectionRow } from "../../../../../components/common/section"
import { useDeleteVariant } from "../../../../../hooks/api/products" import { useDeleteVariant } from "../../../../../hooks/api/products"
type VariantGeneralSectionProps = { type VariantGeneralSectionProps = {
variant: ProductVariantDTO variant: HttpTypes.AdminProductVariant
} }
export function VariantGeneralSection({ variant }: VariantGeneralSectionProps) { export function VariantGeneralSection({ variant }: VariantGeneralSectionProps) {
@@ -19,7 +19,7 @@ export function VariantGeneralSection({ variant }: VariantGeneralSectionProps) {
const hasInventoryKit = variant.inventory?.length > 1 const hasInventoryKit = variant.inventory?.length > 1
const { mutateAsync } = useDeleteVariant(variant.product_id, variant.id) const { mutateAsync } = useDeleteVariant(variant.product_id!, variant.id)
const handleDelete = async () => { const handleDelete = async () => {
const res = await prompt({ const res = await prompt({
@@ -85,10 +85,10 @@ export function VariantGeneralSection({ variant }: VariantGeneralSectionProps) {
</div> </div>
<SectionRow title={t("fields.sku")} value={variant.sku} /> <SectionRow title={t("fields.sku")} value={variant.sku} />
{variant.options.map((o) => ( {variant.options?.map((o) => (
<SectionRow <SectionRow
key={o.id} key={o.id}
title={o.option?.title} title={o.option?.title!}
value={<Badge size="2xsmall">{o.value}</Badge>} value={<Badge size="2xsmall">{o.value}</Badge>}
/> />
))} ))}
@@ -1,27 +1,27 @@
import { useTranslation } from "react-i18next"
import { useState } from "react" import { useState } from "react"
import { useTranslation } from "react-i18next"
import { CurrencyDollar } from "@medusajs/icons" import { CurrencyDollar } from "@medusajs/icons"
import { HttpTypes } from "@medusajs/types"
import { Button, Container, Heading } from "@medusajs/ui" import { Button, Container, Heading } from "@medusajs/ui"
import { MoneyAmountDTO, ProductVariantDTO } from "@medusajs/types"
import { ActionMenu } from "../../../../../components/common/action-menu" import { ActionMenu } from "../../../../../components/common/action-menu"
import { getLocaleAmount } from "../../../../../lib/money-amount-helpers"
import { NoRecords } from "../../../../../components/common/empty-table-content" import { NoRecords } from "../../../../../components/common/empty-table-content"
import { getLocaleAmount } from "../../../../../lib/money-amount-helpers"
type VariantPricesSectionProps = { type VariantPricesSectionProps = {
variant: ProductVariantDTO & { prices: MoneyAmountDTO[] } variant: HttpTypes.AdminProductVariant
} }
export function VariantPricesSection({ variant }: VariantPricesSectionProps) { export function VariantPricesSection({ variant }: VariantPricesSectionProps) {
const { t } = useTranslation() const { t } = useTranslation()
const prices = variant.prices const prices = variant.prices
.filter((p) => !Object.keys(p.rules || {}).length) // display just currency prices ?.filter((p) => !Object.keys(p.rules || {}).length)
.sort((p1, p2) => p1.currency_code?.localeCompare(p2.currency_code)) .sort((p1, p2) => p1.currency_code?.localeCompare(p2.currency_code))
const hasPrices = !!prices.length const hasPrices = !!prices?.length
const [pageSize, setPageSize] = useState(3) const [pageSize, setPageSize] = useState(3)
const displayPrices = prices.slice(0, pageSize) const displayPrices = prices?.slice(0, pageSize)
const onShowMore = () => { const onShowMore = () => {
setPageSize(pageSize + 3) setPageSize(pageSize + 3)
@@ -46,7 +46,7 @@ export function VariantPricesSection({ variant }: VariantPricesSectionProps) {
/> />
</div> </div>
{!hasPrices && <NoRecords className="h-60" />} {!hasPrices && <NoRecords className="h-60" />}
{displayPrices.map((price) => { {displayPrices?.map((price) => {
return ( return (
<div <div
key={price.id} key={price.id}
@@ -1,12 +1,14 @@
import { LoaderFunctionArgs } from "react-router-dom" import { LoaderFunctionArgs } from "react-router-dom"
import { variantsQueryKeys } from "../../../hooks/api/products" import { variantsQueryKeys } from "../../../hooks/api/products"
import { sdk } from "../../../lib/client"
import { queryClient } from "../../../lib/query-client" import { queryClient } from "../../../lib/query-client"
import { VARIANT_DETAIL_FIELDS } from "./constants" import { VARIANT_DETAIL_FIELDS } from "./constants"
import { sdk } from "../../../lib/client"
const variantDetailQuery = (productId: string, variantId: string) => ({ const variantDetailQuery = (productId: string, variantId: string) => ({
queryKey: variantsQueryKeys.detail(variantId), queryKey: variantsQueryKeys.detail(variantId, {
fields: VARIANT_DETAIL_FIELDS,
}),
queryFn: async () => queryFn: async () =>
sdk.admin.product.retrieveVariant(productId, variantId, { sdk.admin.product.retrieveVariant(productId, variantId, {
fields: VARIANT_DETAIL_FIELDS, fields: VARIANT_DETAIL_FIELDS,
@@ -1,16 +1,17 @@
import { Outlet, useLoaderData, useParams } from "react-router-dom" import { useLoaderData, useParams } from "react-router-dom"
import { JsonViewSection } from "../../../components/common/json-view-section"
import { useProductVariant } from "../../../hooks/api/products" import { useProductVariant } from "../../../hooks/api/products"
import { variantLoader } from "./loader" import { TwoColumnPageSkeleton } from "../../../components/common/skeleton"
import { VARIANT_DETAIL_FIELDS } from "./constants" import { TwoColumnPage } from "../../../components/layout/pages"
import { VariantGeneralSection } from "./components/variant-general-section" import { VariantGeneralSection } from "./components/variant-general-section"
import { import {
InventorySectionPlaceholder, InventorySectionPlaceholder,
VariantInventorySection, VariantInventorySection,
} from "./components/variant-inventory-section" } from "./components/variant-inventory-section"
import { VariantPricesSection } from "./components/variant-prices-section" import { VariantPricesSection } from "./components/variant-prices-section"
import { VARIANT_DETAIL_FIELDS } from "./constants"
import { variantLoader } from "./loader"
export const ProductVariantDetail = () => { export const ProductVariantDetail = () => {
const initialData = useLoaderData() as Awaited< const initialData = useLoaderData() as Awaited<
@@ -20,15 +21,22 @@ export const ProductVariantDetail = () => {
const { id, variant_id } = useParams() const { id, variant_id } = useParams()
const { variant, isLoading, isError, error } = useProductVariant( const { variant, isLoading, isError, error } = useProductVariant(
id!, id!,
variant_id, variant_id!,
{ fields: VARIANT_DETAIL_FIELDS }, { fields: VARIANT_DETAIL_FIELDS },
{ {
initialData: initialData, initialData,
} }
) )
if (isLoading || !variant) { if (isLoading || !variant) {
return <div>Loading...</div> return (
<TwoColumnPageSkeleton
mainSections={2}
sidebarSections={1}
showJSON
showMetadata
/>
)
} }
if (isError) { if (isError) {
@@ -36,38 +44,38 @@ export const ProductVariantDetail = () => {
} }
return ( return (
<div className="flex flex-col gap-y-2"> <TwoColumnPage
<div className="flex flex-col gap-x-4 gap-y-3 xl:flex-row xl:items-start"> data={variant}
<div className="flex w-full flex-col gap-y-3"> hasOutlet
<VariantGeneralSection variant={variant} /> showJSON
{!variant.manage_inventory ? ( showMetadata
<InventorySectionPlaceholder /> // TODO: Add widgets zones for variant detail page
) : ( widgets={{
<VariantInventorySection after: [],
inventoryItems={variant.inventory_items.map((i) => { before: [],
return { sideAfter: [],
...i.inventory, sideBefore: [],
required_quantity: i.required_quantity, }}
variant, >
} <TwoColumnPage.Main>
})} <VariantGeneralSection variant={variant} />
/> {!variant.manage_inventory ? (
)} <InventorySectionPlaceholder />
) : (
<div className="hidden xl:block"> <VariantInventorySection
<JsonViewSection data={variant} root="product" /> inventoryItems={variant.inventory_items.map((i) => {
</div> return {
</div> ...i.inventory,
required_quantity: i.required_quantity,
<div className="flex w-full max-w-[100%] flex-col gap-y-3 xl:mt-0 xl:max-w-[400px]"> variant,
<VariantPricesSection variant={variant} /> }
})}
<div className="xl:hidden"> />
<JsonViewSection data={variant} /> )}
</div> </TwoColumnPage.Main>
</div> <TwoColumnPage.Sidebar>
</div> <VariantPricesSection variant={variant} />
<Outlet /> </TwoColumnPage.Sidebar>
</div> </TwoColumnPage>
) )
} }
@@ -44,8 +44,8 @@ const ProductEditVariantSchema = z.object({
// TODO: Either pass option ID or make the backend handle options constraints differently to handle the lack of IDs // TODO: Either pass option ID or make the backend handle options constraints differently to handle the lack of IDs
export const ProductEditVariantForm = ({ export const ProductEditVariantForm = ({
product,
variant, variant,
product,
}: ProductEditVariantFormProps) => { }: ProductEditVariantFormProps) => {
const { t } = useTranslation() const { t } = useTranslation()
const { handleSuccess } = useRouteModal() const { handleSuccess } = useRouteModal()
@@ -63,7 +63,6 @@ export const ProductEditVariantForm = ({
ean: variant.ean || "", ean: variant.ean || "",
upc: variant.upc || "", upc: variant.upc || "",
barcode: variant.barcode || "", barcode: variant.barcode || "",
inventory_quantity: variant.inventory_quantity || "",
manage_inventory: variant.manage_inventory || false, manage_inventory: variant.manage_inventory || false,
allow_backorder: variant.allow_backorder || false, allow_backorder: variant.allow_backorder || false,
weight: variant.weight || "", weight: variant.weight || "",
@@ -79,7 +78,7 @@ export const ProductEditVariantForm = ({
}) })
const { mutateAsync, isPending } = useUpdateProductVariant( const { mutateAsync, isPending } = useUpdateProductVariant(
product.id, variant.product_id!,
variant.id variant.id
) )
@@ -1,27 +1,30 @@
import { LoaderFunctionArgs } from "react-router-dom" import { LoaderFunctionArgs } from "react-router-dom"
import { productsQueryKeys } from "../../../hooks/api/products" import { productVariantQueryKeys } from "../../../hooks/api"
import { sdk } from "../../../lib/client" import { sdk } from "../../../lib/client"
import { queryClient } from "../../../lib/query-client" import { queryClient } from "../../../lib/query-client"
const queryKey = (id: string) => { const queryFn = async (id: string, variantId: string) => {
return [productsQueryKeys.detail(id)] return await sdk.admin.product.retrieveVariant(id, variantId)
} }
const queryFn = async (id: string) => { const editProductVariantQuery = (id: string, variantId: string) => ({
return await sdk.admin.product.retrieve(id) queryKey: productVariantQueryKeys.detail(variantId),
} queryFn: async () => queryFn(id, variantId),
const editProductVariantQuery = (id: string) => ({
queryKey: queryKey(id),
queryFn: async () => queryFn(id),
}) })
export const editProductVariantLoader = async ({ export const editProductVariantLoader = async ({
params, params,
request,
}: LoaderFunctionArgs) => { }: LoaderFunctionArgs) => {
const id = params.id const id = params.id
const query = editProductVariantQuery(id!)
const searchParams = new URL(request.url).searchParams
const searchVariantId = searchParams.get("variant_id")
const variantId = params.variant_id || searchVariantId
const query = editProductVariantQuery(id!, variantId || searchVariantId!)
return ( return (
queryClient.getQueryData<ReturnType<typeof queryFn>>(query.queryKey) ?? queryClient.getQueryData<ReturnType<typeof queryFn>>(query.queryKey) ??
@@ -1,14 +1,8 @@
import { HttpTypes } from "@medusajs/types"
import { Heading } from "@medusajs/ui" import { Heading } from "@medusajs/ui"
import { useTranslation } from "react-i18next" import { useTranslation } from "react-i18next"
import { import { useLoaderData, useParams, useSearchParams } from "react-router-dom"
json,
useLoaderData,
useParams,
useSearchParams,
} from "react-router-dom"
import { RouteDrawer } from "../../../components/modals" import { RouteDrawer } from "../../../components/modals"
import { useProduct } from "../../../hooks/api/products" import { useProduct, useProductVariant } from "../../../hooks/api/products"
import { ProductEditVariantForm } from "./components/product-edit-variant-form" import { ProductEditVariantForm } from "./components/product-edit-variant-form"
import { editProductVariantLoader } from "./loader" import { editProductVariantLoader } from "./loader"
@@ -22,41 +16,46 @@ export const ProductVariantEdit = () => {
const [URLSearchParms] = useSearchParams() const [URLSearchParms] = useSearchParams()
const searchVariantId = URLSearchParms.get("variant_id") const searchVariantId = URLSearchParms.get("variant_id")
const { product, isPending, isFetching, isError, error } = useProduct( const { variant, isPending, isError, error } = useProductVariant(
id!, id!,
variant_id || searchVariantId!,
undefined, undefined,
{ {
initialData, initialData,
} }
) )
const variant = product?.variants.find( const {
(v: HttpTypes.AdminProductVariant) => product,
v.id === (variant_id || searchVariantId) isPending: isProductPending,
isError: isProductError,
error: productError,
} = useProduct(
variant?.product_id!,
{
fields: "-variants",
},
{
enabled: !!variant?.product_id,
}
) )
if (!isPending && !isFetching && !variant) { const ready = !isPending && !!variant && !isProductPending && !!product
throw json({
status: 404,
message: `Variant with ID ${variant_id || searchVariantId} was not found.`,
})
}
if (isError) { if (isError) {
throw error throw error
} }
if (isProductError) {
throw productError
}
return ( return (
<RouteDrawer> <RouteDrawer>
<RouteDrawer.Header> <RouteDrawer.Header>
<Heading>{t("products.variant.edit.header")}</Heading> <Heading>{t("products.variant.edit.header")}</Heading>
</RouteDrawer.Header> </RouteDrawer.Header>
{variant && ( {ready && <ProductEditVariantForm variant={variant} product={product} />}
<ProductEditVariantForm
product={product}
variant={variant as unknown as HttpTypes.AdminProductVariant}
/>
)}
</RouteDrawer> </RouteDrawer>
) )
} }
@@ -51,12 +51,12 @@ export const PricingEdit = ({
}, [regions]) }, [regions])
const variants = variantId const variants = variantId
? product.variants.filter((v) => v.id === variantId) ? product.variants?.filter((v) => v.id === variantId)
: product.variants : product.variants
const form = useForm<UpdateVariantPricesSchemaType>({ const form = useForm<UpdateVariantPricesSchemaType>({
defaultValues: { defaultValues: {
variants: variants.map((variant: any) => ({ variants: variants?.map((variant: any) => ({
title: variant.title, title: variant.title,
prices: variant.prices.reduce((acc: any, price: any) => { prices: variant.prices.reduce((acc: any, price: any) => {
if (price.rules?.region_id) { if (price.rules?.region_id) {
@@ -90,11 +90,11 @@ export const PricingEdit = ({
let existingId = undefined let existingId = undefined
if (regionId) { if (regionId) {
existingId = variants[ind].prices.find( existingId = variants?.[ind]?.prices?.find(
(p) => p.rules["region_id"] === regionId (p) => p.rules["region_id"] === regionId
)?.id )?.id
} else { } else {
existingId = variants[ind].prices.find( existingId = variants?.[ind]?.prices?.find(
(p) => (p) =>
p.currency_code === currencyCode && p.currency_code === currencyCode &&
Object.keys(p.rules ?? {}).length === 0 Object.keys(p.rules ?? {}).length === 0
@@ -7,7 +7,9 @@ import { PricingEdit } from "./pricing-edit"
export const ProductPrices = () => { export const ProductPrices = () => {
const { id, variant_id } = useParams() const { id, variant_id } = useParams()
const { product, isLoading, isError, error } = useProduct(id!) const { product, isLoading, isError, error } = useProduct(id!, {
fields: "+variants,+variants.prices",
})
if (isError) { if (isError) {
throw error throw error