feat(dashboard): configurable product views (#13408)

* feat: add a reusable configurable data table

* fix: cleanup

* fix: cleanup

* fix: cache invalidation

* fix: test

* fix: add configurable products

* feat: add configurable product table

* fix: build errors+table style

* fix: sticky header column

* add translations

* fix: cleanup counterenderer

* fix: formatting

* fix: client still skips nulls

* fix: test

* fix: cleanup

* fix: revert client bracket format

* fix: better typing

* fix: add placeholder data to product list
This commit is contained in:
Sebastian Rindom
2025-09-18 18:27:17 +02:00
committed by GitHub
parent 9563ee446f
commit 41047b3854
22 changed files with 865 additions and 433 deletions
@@ -0,0 +1,26 @@
import { useTranslation } from "react-i18next"
import { Outlet, useLocation } from "react-router-dom"
import { ConfigurableDataTable } from "../../../../../components/table/configurable-data-table"
import { useProductTableAdapter } from "./product-table-adapter"
export const ConfigurableProductListTable = () => {
const { t } = useTranslation()
const location = useLocation()
const adapter = useProductTableAdapter()
return (
<>
<ConfigurableDataTable
adapter={adapter}
heading={t("products.domain")}
actions={[
{ label: t("actions.export"), to: `export${location.search}` },
{ label: t("actions.import"), to: "import" },
{ label: t("actions.create"), to: "create" }
]}
/>
<Outlet />
</>
)
}
@@ -18,12 +18,20 @@ import { useProductTableFilters } from "../../../../../hooks/table/filters/use-p
import { useProductTableQuery } from "../../../../../hooks/table/query/use-product-table-query"
import { useDataTable } from "../../../../../hooks/use-data-table"
import { productsLoader } from "../../loader"
import { useFeatureFlag } from "../../../../../providers/feature-flag-provider"
import { ConfigurableProductListTable } from "./configurable-product-list-table"
const PAGE_SIZE = 20
export const ProductListTable = () => {
const { t } = useTranslation()
const location = useLocation()
const isViewConfigEnabled = useFeatureFlag("view_configurations")
// If feature flag is enabled, use the new configurable table
if (isViewConfigEnabled) {
return <ConfigurableProductListTable />
}
const initialData = useLoaderData() as Awaited<
ReturnType<ReturnType<typeof productsLoader>>
@@ -0,0 +1,51 @@
import { HttpTypes } from "@medusajs/types"
import { useProducts } from "../../../../../hooks/api/products"
import { productColumnAdapter } from "../../../../../lib/table/entity-adapters"
import { createTableAdapter, TableAdapter } from "../../../../../lib/table/table-adapters"
import { useProductTableFilters } from "./use-product-table-filters"
export function createProductTableAdapter(): TableAdapter<HttpTypes.AdminProduct> {
return createTableAdapter<HttpTypes.AdminProduct>({
entity: "products",
queryPrefix: "p",
pageSize: 20,
columnAdapter: productColumnAdapter,
useData: (fields, params) => {
const { products, count, isError, error, isLoading } = useProducts(
{
fields,
...params,
is_giftcard: false, // Exclude gift cards from product list
},
{
placeholderData: (previousData, previousQuery) => {
// Only keep placeholder data if the fields haven't changed
const prevFields = previousQuery?.[previousQuery.length - 1]?.query?.fields
if (prevFields && prevFields !== fields) {
// Fields changed, don't use placeholder data
return undefined
}
// Fields are the same, keep previous data for smooth transitions
return previousData
},
}
)
return { data: products, count, isLoading, isError, error }
},
getRowHref: (row) => `/products/${row.id}`,
})
}
/**
* Hook to get the product table adapter with filters
*/
export function useProductTableAdapter(): TableAdapter<HttpTypes.AdminProduct> {
const filters = useProductTableFilters()
const adapter = createProductTableAdapter()
// Add dynamic filters to the adapter
return {
...adapter,
filters,
}
}
@@ -0,0 +1,104 @@
import { useMemo } from "react"
import { useTranslation } from "react-i18next"
import { createDataTableFilterHelper } from "@medusajs/ui"
import { HttpTypes } from "@medusajs/types"
import { useDataTableDateFilters } from "../../../../../components/data-table/helpers/general/use-data-table-date-filters"
import { useProductTypes } from "../../../../../hooks/api/product-types"
import { useProductTags } from "../../../../../hooks/api"
import { useSalesChannels } from "../../../../../hooks/api/sales-channels"
const filterHelper = createDataTableFilterHelper<HttpTypes.AdminProduct>()
/**
* Hook to create filters in the format expected by @medusajs/ui DataTable
*/
export const useProductTableFilters = () => {
const { t } = useTranslation()
const dateFilters = useDataTableDateFilters()
const { product_types } = useProductTypes({
limit: 1000,
offset: 0,
})
const { product_tags } = useProductTags({
limit: 1000,
offset: 0,
})
const { sales_channels } = useSalesChannels({
limit: 1000,
fields: "id,name",
})
return useMemo(() => {
const filters = [...dateFilters]
if (product_types?.length) {
filters.push(
filterHelper.accessor("type_id", {
label: t("fields.type"),
type: "multiselect",
options: product_types.map((t) => ({
label: t.value,
value: t.id,
})),
})
)
}
if (product_tags?.length) {
filters.push(
filterHelper.accessor("tag_id", {
label: t("fields.tag"),
type: "multiselect",
options: product_tags.map((t) => ({
label: t.value,
value: t.id,
})),
})
)
}
if (sales_channels?.length) {
filters.push(
filterHelper.accessor("sales_channel_id", {
label: t("fields.salesChannel"),
type: "multiselect",
options: sales_channels.map((s) => ({
label: s.name,
value: s.id,
})),
})
)
}
// Status filter
filters.push(
filterHelper.accessor("status", {
label: t("fields.status"),
type: "multiselect",
options: [
{
label: t("products.productStatus.draft"),
value: "draft",
},
{
label: t("products.productStatus.proposed"),
value: "proposed",
},
{
label: t("products.productStatus.published"),
value: "published",
},
{
label: t("products.productStatus.rejected"),
value: "rejected",
},
],
})
)
return filters
}, [product_types, product_tags, sales_channels, dateFilters, t])
}