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 09:40:08 +01:00
committed by GitHub
parent 81208b6e1d
commit 904f0926f1
12 changed files with 138 additions and 116 deletions

View File

@@ -0,0 +1,5 @@
---
"@medusajs/dashboard": patch
---
fix(dashboard): Fix query key for product details and load product variant data correctly

View File

@@ -87,16 +87,21 @@ export const useDeleteProductOption = (
export const useProductVariant = (
productId: string,
variantId: string,
query?: Record<string, any>,
query?: HttpTypes.AdminProductVariantParams,
options?: Omit<
UseQueryOptions<any, FetchError, any, QueryKey>,
UseQueryOptions<
HttpTypes.AdminProductVariantResponse,
FetchError,
HttpTypes.AdminProductVariantResponse,
QueryKey
>,
"queryFn" | "queryKey"
>
) => {
const { data, ...rest } = useQuery({
queryFn: () =>
sdk.admin.product.retrieveVariant(productId, variantId, query),
queryKey: variantsQueryKeys.detail(variantId),
queryKey: variantsQueryKeys.detail(variantId, query),
...options,
})
@@ -238,7 +243,7 @@ export const useProduct = (
) => {
const { data, ...rest } = useQuery({
queryFn: () => sdk.admin.product.retrieve(id, query),
queryKey: productsQueryKeys.detail(id),
queryKey: productsQueryKeys.detail(id, query),
...options,
})

View File

@@ -5,10 +5,7 @@ export type TQueryKey<TKey, TListQuery = any, TDetailQuery = string> = {
lists: () => readonly [...TQueryKey<TKey>["all"], "list"]
list: (
query?: TListQuery
) => readonly [
...ReturnType<TQueryKey<TKey>["lists"]>,
{ query: TListQuery | undefined },
]
) => readonly [...ReturnType<TQueryKey<TKey>["lists"]>, { query: TListQuery }]
details: () => readonly [...TQueryKey<TKey>["all"], "detail"]
detail: (
id: TDetailQuery,
@@ -16,7 +13,7 @@ export type TQueryKey<TKey, TListQuery = any, TDetailQuery = string> = {
) => readonly [
...ReturnType<TQueryKey<TKey>["details"]>,
TDetailQuery,
{ query: TListQuery | undefined },
{ query: TListQuery }
]
}
@@ -26,7 +23,7 @@ export type UseQueryOptionsWrapper<
// Type thrown in case the queryFn rejects
E = Error,
// Query key type
TQueryKey extends QueryKey = QueryKey,
TQueryKey extends QueryKey = QueryKey
> = Omit<
UseQueryOptions<TQueryFn, E, TQueryFn, TQueryKey>,
"queryKey" | "queryFn"
@@ -35,20 +32,22 @@ export type UseQueryOptionsWrapper<
export const queryKeysFactory = <
T,
TListQueryType = any,
TDetailQueryType = string,
TDetailQueryType = string
>(
globalKey: T
) => {
const queryKeyFactory: TQueryKey<T, TListQueryType, TDetailQueryType> = {
all: [globalKey],
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"],
detail: (id: TDetailQueryType, query?: TListQueryType) => [
...queryKeyFactory.details(),
id,
{ query },
],
detail: (id: TDetailQueryType, query?: TListQueryType) =>
[...queryKeyFactory.details(), id, query ? { query } : undefined].filter(
(k) => !!k
),
}
return queryKeyFactory
}

View File

@@ -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 { HttpTypes } from "@medusajs/types"
import { Badge, Container, Heading, usePrompt } from "@medusajs/ui"
import { useTranslation } from "react-i18next"
import { useNavigate } from "react-router-dom"
@@ -9,7 +9,7 @@ import { SectionRow } from "../../../../../components/common/section"
import { useDeleteVariant } from "../../../../../hooks/api/products"
type VariantGeneralSectionProps = {
variant: ProductVariantDTO
variant: HttpTypes.AdminProductVariant
}
export function VariantGeneralSection({ variant }: VariantGeneralSectionProps) {
@@ -19,7 +19,7 @@ export function VariantGeneralSection({ variant }: VariantGeneralSectionProps) {
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 res = await prompt({
@@ -85,10 +85,10 @@ export function VariantGeneralSection({ variant }: VariantGeneralSectionProps) {
</div>
<SectionRow title={t("fields.sku")} value={variant.sku} />
{variant.options.map((o) => (
{variant.options?.map((o) => (
<SectionRow
key={o.id}
title={o.option?.title}
title={o.option?.title!}
value={<Badge size="2xsmall">{o.value}</Badge>}
/>
))}

View File

@@ -1,27 +1,27 @@
import { useTranslation } from "react-i18next"
import { useState } from "react"
import { useTranslation } from "react-i18next"
import { CurrencyDollar } from "@medusajs/icons"
import { HttpTypes } from "@medusajs/types"
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"
import { getLocaleAmount } from "../../../../../lib/money-amount-helpers"
type VariantPricesSectionProps = {
variant: ProductVariantDTO & { prices: MoneyAmountDTO[] }
variant: HttpTypes.AdminProductVariant
}
export function VariantPricesSection({ variant }: VariantPricesSectionProps) {
const { t } = useTranslation()
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))
const hasPrices = !!prices.length
const hasPrices = !!prices?.length
const [pageSize, setPageSize] = useState(3)
const displayPrices = prices.slice(0, pageSize)
const displayPrices = prices?.slice(0, pageSize)
const onShowMore = () => {
setPageSize(pageSize + 3)
@@ -46,7 +46,7 @@ export function VariantPricesSection({ variant }: VariantPricesSectionProps) {
/>
</div>
{!hasPrices && <NoRecords className="h-60" />}
{displayPrices.map((price) => {
{displayPrices?.map((price) => {
return (
<div
key={price.id}

View File

@@ -1,12 +1,14 @@
import { LoaderFunctionArgs } from "react-router-dom"
import { variantsQueryKeys } from "../../../hooks/api/products"
import { sdk } from "../../../lib/client"
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),
queryKey: variantsQueryKeys.detail(variantId, {
fields: VARIANT_DETAIL_FIELDS,
}),
queryFn: async () =>
sdk.admin.product.retrieveVariant(productId, variantId, {
fields: VARIANT_DETAIL_FIELDS,

View File

@@ -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 { variantLoader } from "./loader"
import { VARIANT_DETAIL_FIELDS } from "./constants"
import { TwoColumnPageSkeleton } from "../../../components/common/skeleton"
import { TwoColumnPage } from "../../../components/layout/pages"
import { VariantGeneralSection } from "./components/variant-general-section"
import {
InventorySectionPlaceholder,
VariantInventorySection,
} from "./components/variant-inventory-section"
import { VariantPricesSection } from "./components/variant-prices-section"
import { VARIANT_DETAIL_FIELDS } from "./constants"
import { variantLoader } from "./loader"
export const ProductVariantDetail = () => {
const initialData = useLoaderData() as Awaited<
@@ -20,15 +21,22 @@ export const ProductVariantDetail = () => {
const { id, variant_id } = useParams()
const { variant, isLoading, isError, error } = useProductVariant(
id!,
variant_id,
variant_id!,
{ fields: VARIANT_DETAIL_FIELDS },
{
initialData: initialData,
initialData,
}
)
if (isLoading || !variant) {
return <div>Loading...</div>
return (
<TwoColumnPageSkeleton
mainSections={2}
sidebarSections={1}
showJSON
showMetadata
/>
)
}
if (isError) {
@@ -36,38 +44,38 @@ export const ProductVariantDetail = () => {
}
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} />
{!variant.manage_inventory ? (
<InventorySectionPlaceholder />
) : (
<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>
<TwoColumnPage
data={variant}
hasOutlet
showJSON
showMetadata
// TODO: Add widgets zones for variant detail page
widgets={{
after: [],
before: [],
sideAfter: [],
sideBefore: [],
}}
>
<TwoColumnPage.Main>
<VariantGeneralSection variant={variant} />
{!variant.manage_inventory ? (
<InventorySectionPlaceholder />
) : (
<VariantInventorySection
inventoryItems={variant.inventory_items.map((i) => {
return {
...i.inventory,
required_quantity: i.required_quantity,
variant,
}
})}
/>
)}
</TwoColumnPage.Main>
<TwoColumnPage.Sidebar>
<VariantPricesSection variant={variant} />
</TwoColumnPage.Sidebar>
</TwoColumnPage>
)
}

View File

@@ -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
export const ProductEditVariantForm = ({
product,
variant,
product,
}: ProductEditVariantFormProps) => {
const { t } = useTranslation()
const { handleSuccess } = useRouteModal()
@@ -63,7 +63,6 @@ export const ProductEditVariantForm = ({
ean: variant.ean || "",
upc: variant.upc || "",
barcode: variant.barcode || "",
inventory_quantity: variant.inventory_quantity || "",
manage_inventory: variant.manage_inventory || false,
allow_backorder: variant.allow_backorder || false,
weight: variant.weight || "",
@@ -79,7 +78,7 @@ export const ProductEditVariantForm = ({
})
const { mutateAsync, isPending } = useUpdateProductVariant(
product.id,
variant.product_id!,
variant.id
)

View File

@@ -1,27 +1,30 @@
import { LoaderFunctionArgs } from "react-router-dom"
import { productsQueryKeys } from "../../../hooks/api/products"
import { productVariantQueryKeys } from "../../../hooks/api"
import { sdk } from "../../../lib/client"
import { queryClient } from "../../../lib/query-client"
const queryKey = (id: string) => {
return [productsQueryKeys.detail(id)]
const queryFn = async (id: string, variantId: string) => {
return await sdk.admin.product.retrieveVariant(id, variantId)
}
const queryFn = async (id: string) => {
return await sdk.admin.product.retrieve(id)
}
const editProductVariantQuery = (id: string) => ({
queryKey: queryKey(id),
queryFn: async () => queryFn(id),
const editProductVariantQuery = (id: string, variantId: string) => ({
queryKey: productVariantQueryKeys.detail(variantId),
queryFn: async () => queryFn(id, variantId),
})
export const editProductVariantLoader = async ({
params,
request,
}: LoaderFunctionArgs) => {
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 (
queryClient.getQueryData<ReturnType<typeof queryFn>>(query.queryKey) ??

View File

@@ -1,14 +1,8 @@
import { HttpTypes } from "@medusajs/types"
import { Heading } from "@medusajs/ui"
import { useTranslation } from "react-i18next"
import {
json,
useLoaderData,
useParams,
useSearchParams,
} from "react-router-dom"
import { useLoaderData, useParams, useSearchParams } from "react-router-dom"
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 { editProductVariantLoader } from "./loader"
@@ -22,41 +16,46 @@ export const ProductVariantEdit = () => {
const [URLSearchParms] = useSearchParams()
const searchVariantId = URLSearchParms.get("variant_id")
const { product, isPending, isFetching, isError, error } = useProduct(
const { variant, isPending, isError, error } = useProductVariant(
id!,
variant_id || searchVariantId!,
undefined,
{
initialData,
}
)
const variant = product?.variants.find(
(v: HttpTypes.AdminProductVariant) =>
v.id === (variant_id || searchVariantId)
const {
product,
isPending: isProductPending,
isError: isProductError,
error: productError,
} = useProduct(
variant?.product_id!,
{
fields: "-variants",
},
{
enabled: !!variant?.product_id,
}
)
if (!isPending && !isFetching && !variant) {
throw json({
status: 404,
message: `Variant with ID ${variant_id || searchVariantId} was not found.`,
})
}
const ready = !isPending && !!variant && !isProductPending && !!product
if (isError) {
throw error
}
if (isProductError) {
throw productError
}
return (
<RouteDrawer>
<RouteDrawer.Header>
<Heading>{t("products.variant.edit.header")}</Heading>
</RouteDrawer.Header>
{variant && (
<ProductEditVariantForm
product={product}
variant={variant as unknown as HttpTypes.AdminProductVariant}
/>
)}
{ready && <ProductEditVariantForm variant={variant} product={product} />}
</RouteDrawer>
)
}

View File

@@ -51,12 +51,12 @@ export const PricingEdit = ({
}, [regions])
const variants = variantId
? product.variants.filter((v) => v.id === variantId)
? product.variants?.filter((v) => v.id === variantId)
: product.variants
const form = useForm<UpdateVariantPricesSchemaType>({
defaultValues: {
variants: variants.map((variant: any) => ({
variants: variants?.map((variant: any) => ({
title: variant.title,
prices: variant.prices.reduce((acc: any, price: any) => {
if (price.rules?.region_id) {
@@ -90,11 +90,11 @@ export const PricingEdit = ({
let existingId = undefined
if (regionId) {
existingId = variants[ind].prices.find(
existingId = variants?.[ind]?.prices?.find(
(p) => p.rules["region_id"] === regionId
)?.id
} else {
existingId = variants[ind].prices.find(
existingId = variants?.[ind]?.prices?.find(
(p) =>
p.currency_code === currencyCode &&
Object.keys(p.rules ?? {}).length === 0

View File

@@ -7,7 +7,9 @@ import { PricingEdit } from "./pricing-edit"
export const ProductPrices = () => {
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) {
throw error