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,305 @@
import React from "react"
import { Badge, StatusBadge, Tooltip } from "@medusajs/ui"
import { HttpTypes } from "@medusajs/types"
import ReactCountryFlag from "react-country-flag"
import { getCountryByIso2 } from "../data/countries"
import { ProductCell } from "../../components/table/table-cells/product/product-cell"
import { CollectionCell } from "../../components/table/table-cells/product/collection-cell"
import { SalesChannelsCell } from "../../components/table/table-cells/product/sales-channels-cell"
import { VariantCell } from "../../components/table/table-cells/product/variant-cell"
import { ProductStatusCell } from "../../components/table/table-cells/product/product-status-cell"
import { DateCell } from "../../components/table/table-cells/common/date-cell"
import { DisplayIdCell } from "../../components/table/table-cells/order/display-id-cell"
import { TotalCell } from "../../components/table/table-cells/order/total-cell"
import { MoneyAmountCell } from "../../components/table/table-cells/common/money-amount-cell"
import { TFunction } from "i18next"
export type CellRenderer<TData = any> = (
value: any,
row: TData,
column: HttpTypes.AdminColumn,
t: TFunction
) => React.ReactNode
export type RendererRegistry = Map<string, CellRenderer>
const cellRenderers: RendererRegistry = new Map()
const getNestedValue = (obj: any, path: string) => {
return path.split('.').reduce((current, key) => current?.[key], obj)
}
const TextRenderer: CellRenderer = (value, _row, _column, _t) => {
if (value === null || value === undefined) return '-'
return String(value)
}
const CountRenderer: CellRenderer = (value, _row, _column, t) => {
const items = value || []
const count = Array.isArray(items) ? items.length : 0
return t('general.items', { count })
}
const StatusRenderer: CellRenderer = (value, row, column, t) => {
if (!value) return '-'
if (column.field === 'status' && row.status && (row.handle || row.is_giftcard !== undefined)) {
return <ProductStatusCell status={row.status} />
}
// Generic status badge
const getStatusColor = (status: string) => {
switch (status?.toLowerCase()) {
case 'active':
case 'published':
case 'fulfilled':
case 'paid':
return 'green'
case 'pending':
case 'proposed':
case 'processing':
return 'orange'
case 'draft':
return 'grey'
case 'rejected':
case 'failed':
case 'canceled':
return 'red'
default:
return 'grey'
}
}
// Use existing translation keys where available
const getTranslatedStatus = (status: string): string => {
if (!t) return status
const lowerStatus = status.toLowerCase()
switch (lowerStatus) {
case 'active':
return t('general.active', 'Active') as string
case 'published':
return t('products.productStatus.published', 'Published') as string
case 'draft':
return t('orders.status.draft', 'Draft') as string
case 'pending':
return t('orders.status.pending', 'Pending') as string
case 'canceled':
return t('orders.status.canceled', 'Canceled') as string
default:
// Try generic status translation with fallback
return t(`status.${lowerStatus}`, status) as string
}
}
const translatedValue = getTranslatedStatus(value)
return (
<StatusBadge color={getStatusColor(value)}>
{translatedValue}
</StatusBadge>
)
}
const BadgeListRenderer: CellRenderer = (value, row, column, t) => {
// For sales channels
if (column.field === 'sales_channels_display' || column.field === 'sales_channels') {
return <SalesChannelsCell salesChannels={row.sales_channels} />
}
// Generic badge list
if (!Array.isArray(value)) return '-'
const items = value.slice(0, 2)
const remaining = value.length - 2
return (
<div className="flex gap-1">
{items.map((item, index) => (
<Badge key={index} size="xsmall">
{typeof item === 'string' ? item : item.name || item.title || '-'}
</Badge>
))}
{remaining > 0 && (
<Badge size="xsmall" color="grey">
{t ? t('general.plusCountMore', '+ {{count}} more', { count: remaining }) : `+${remaining}`}
</Badge>
)}
</div>
)
}
const ProductInfoRenderer: CellRenderer = (_, row, _column, _t) => {
return <ProductCell product={row} />
}
const CollectionRenderer: CellRenderer = (_, row, _column, _t) => {
return <CollectionCell collection={row.collection} />
}
const VariantsRenderer: CellRenderer = (_, row, _column, _t) => {
return <VariantCell variants={row.variants} />
}
// Order-specific renderers
const CustomerNameRenderer: CellRenderer = (_, row, _column, t) => {
if (row.customer?.first_name || row.customer?.last_name) {
const fullName = `${row.customer.first_name || ''} ${row.customer.last_name || ''}`.trim()
if (fullName) return fullName
}
// Fall back to email
if (row.customer?.email) {
return row.customer.email
}
// Fall back to phone
if (row.customer?.phone) {
return row.customer.phone
}
return t ? t('customers.guest', 'Guest') : 'Guest'
}
const AddressSummaryRenderer: CellRenderer = (_, row, column, _t) => {
let address = null
if (column.field === 'shipping_address_display') {
address = row.shipping_address
} else if (column.field === 'billing_address_display') {
address = row.billing_address
} else {
address = row.shipping_address || row.billing_address
}
if (!address) return '-'
const parts = []
if (address.address_1) {
parts.push(address.address_1)
}
const locationParts = []
if (address.city) locationParts.push(address.city)
if (address.province) locationParts.push(address.province)
if (address.postal_code) locationParts.push(address.postal_code)
if (locationParts.length > 0) {
parts.push(locationParts.join(', '))
}
if (address.country_code) {
parts.push(address.country_code.toUpperCase())
}
return parts.join(' • ') || '-'
}
const CountryCodeRenderer: CellRenderer = (_, row, _column, _t) => {
const countryCode = row.shipping_address?.country_code
if (!countryCode) return <div className="flex w-full justify-center">-</div>
const country = getCountryByIso2(countryCode)
const displayName = country?.display_name || countryCode.toUpperCase()
return (
<div className="flex w-full items-center justify-center">
<Tooltip content={displayName}>
<div className="flex size-4 items-center justify-center overflow-hidden rounded-sm">
<ReactCountryFlag
countryCode={countryCode.toUpperCase()}
svg
style={{
width: "16px",
height: "16px",
}}
aria-label={displayName}
/>
</div>
</Tooltip>
</div>
)
}
const DateRenderer: CellRenderer = (value, _row, _column, _t) => {
return <DateCell date={value} />
}
const DisplayIdRenderer: CellRenderer = (value, _row, _column, _t) => {
return <DisplayIdCell displayId={value} />
}
const CurrencyRenderer: CellRenderer = (value, row, _column, _t) => {
const currencyCode = row.currency_code || 'USD'
return <MoneyAmountCell currencyCode={currencyCode} amount={value} align="right" />
}
const TotalRenderer: CellRenderer = (value, row, _column, _t) => {
const currencyCode = row.currency_code || 'USD'
return <TotalCell currencyCode={currencyCode} total={value} />
}
// Register built-in renderers
cellRenderers.set('text', TextRenderer)
cellRenderers.set('count', CountRenderer)
cellRenderers.set('status', StatusRenderer)
cellRenderers.set('badge_list', BadgeListRenderer)
cellRenderers.set('date', DateRenderer)
cellRenderers.set('timestamp', DateRenderer)
cellRenderers.set('currency', CurrencyRenderer)
cellRenderers.set('total', TotalRenderer)
// Register product-specific renderers
cellRenderers.set('product_info', ProductInfoRenderer)
cellRenderers.set('collection', CollectionRenderer)
cellRenderers.set('variants', VariantsRenderer)
cellRenderers.set('sales_channels_list', BadgeListRenderer)
// Register order-specific renderers
cellRenderers.set('customer_name', CustomerNameRenderer)
cellRenderers.set('address_summary', AddressSummaryRenderer)
cellRenderers.set('country_code', CountryCodeRenderer)
cellRenderers.set('display_id', DisplayIdRenderer)
export function getCellRenderer(
renderType?: string,
dataType?: string
): CellRenderer {
if (renderType && cellRenderers.has(renderType)) {
return cellRenderers.get(renderType)!
}
switch (dataType) {
case 'number':
case 'string':
return TextRenderer
case 'date':
return DateRenderer
case 'boolean':
return (value, _row, _column, t) => {
if (t) {
return value ? t('fields.yes', 'Yes') : t('fields.no', 'No')
}
return value ? 'Yes' : 'No'
}
case 'enum':
return StatusRenderer
case 'currency':
return CurrencyRenderer
default:
return TextRenderer
}
}
export function registerCellRenderer(type: string, renderer: CellRenderer) {
cellRenderers.set(type, renderer)
}
export function getColumnValue(row: any, column: HttpTypes.AdminColumn): any {
if (column.computed) {
return row
}
return getNestedValue(row, column.field)
}
@@ -1,55 +1,98 @@
import { HttpTypes } from "@medusajs/types"
import { ColumnAdapter } from "../../hooks/table/columns/use-configurable-table-columns"
// Order-specific column adapter
export const orderColumnAdapter: ColumnAdapter<HttpTypes.AdminOrder> = {
getColumnAlignment: (column) => {
// Custom alignment for order columns
if (column.field === "display_id") return "center"
if (column.semantic_type === "currency") return "right"
if (column.semantic_type === "status") return "center"
if (column.computed?.type === "country_code") return "center"
if (column.semantic_type === "currency") {
return "right"
}
if (column.semantic_type === "status") {
return "center"
}
if (column.computed?.type === "country_code") {
return "center"
}
return "left"
}
}
// Product-specific column adapter
export const productColumnAdapter: ColumnAdapter<HttpTypes.AdminProduct> = {
getColumnAlignment: (column) => {
// Custom alignment for product columns
if (column.field === "sku") return "center"
if (column.field === "stock") return "right"
if (column.semantic_type === "currency") return "right"
if (column.semantic_type === "status") return "center"
if (column.field === "product_display") {
return "left"
}
if (column.field === "collection.title") {
return "left"
}
if (column.field === "sales_channels_display") {
return "left"
}
if (column.field === "variants_count") {
return "left"
}
if (column.field === "sku") {
return "center"
}
if (column.field === "stock") {
return "right"
}
if (column.semantic_type === "currency") {
return "right"
}
if (column.semantic_type === "status") {
return "left"
}
if (column.computed?.type === "product_info") {
return "left"
}
if (column.computed?.type === "count") {
return "left"
}
if (column.computed?.type === "sales_channels_list") {
return "left"
}
return "left"
},
transformCellValue: (value, row, column) => {
// Custom transformation for product-specific fields
if (column.field === "variants_count") {
return `${value || 0} variants`
transformCellValue: (_value, row, column) => {
if (column.field === "variants_count" || column.computed?.type === "count") {
const count = Array.isArray(row.variants) ? row.variants.length : 0
return `${count} ${count === 1 ? 'variant' : 'variants'}`
}
if (column.field === "status" && value === "draft") {
return <span className="text-ui-fg-muted">Draft</span>
if (column.field === "product_display" || column.computed?.type === "product_info") {
return null
}
// Default to standard display
if (column.field === "sales_channels_display" || column.computed?.type === "sales_channels_list") {
return null
}
if (column.field === "status") {
return null
}
return null
}
}
// Customer-specific column adapter
export const customerColumnAdapter: ColumnAdapter<HttpTypes.AdminCustomer> = {
getColumnAlignment: (column) => {
if (column.field === "orders_count") return "right"
if (column.semantic_type === "currency") return "right"
if (column.semantic_type === "status") return "center"
if (column.field === "orders_count") {
return "right"
}
if (column.semantic_type === "currency") {
return "right"
}
if (column.semantic_type === "status") {
return "center"
}
return "left"
},
transformCellValue: (value, row, column) => {
// Format customer name
transformCellValue: (_value, row, column) => {
if (column.field === "name") {
const { first_name, last_name } = row
if (first_name || last_name) {
@@ -57,23 +100,30 @@ export const customerColumnAdapter: ColumnAdapter<HttpTypes.AdminCustomer> = {
}
return "-"
}
return null
}
}
// Inventory-specific column adapter
export const inventoryColumnAdapter: ColumnAdapter<HttpTypes.AdminInventoryItem> = {
getColumnAlignment: (column) => {
if (column.field === "stocked_quantity") return "right"
if (column.field === "reserved_quantity") return "right"
if (column.field === "available_quantity") return "right"
if (column.semantic_type === "status") return "center"
if (column.field === "stocked_quantity") {
return "right"
}
if (column.field === "reserved_quantity") {
return "right"
}
if (column.field === "available_quantity") {
return "right"
}
if (column.semantic_type === "status") {
return "center"
}
return "left"
}
}
// Registry of entity adapters
export const entityAdapters = {
orders: orderColumnAdapter,
products: productColumnAdapter,
@@ -83,7 +133,6 @@ export const entityAdapters = {
export type EntityType = keyof typeof entityAdapters
// Helper function to get adapter for an entity
export function getEntityAdapter<TData = any>(entity: string): ColumnAdapter<TData> | undefined {
return entityAdapters[entity as EntityType] as ColumnAdapter<TData>
}
}
@@ -6,7 +6,7 @@ export const ENTITY_DEFAULT_FIELDS = {
orders: {
properties: [
"id",
"status",
"status",
"created_at",
"email",
"display_id",
@@ -15,22 +15,14 @@ export const ENTITY_DEFAULT_FIELDS = {
"total",
"currency_code",
],
relations: ["*customer", "*sales_channel"]
relations: ["*customer", "*sales_channel"],
},
products: {
properties: [
"id",
"title",
"handle",
"status",
"created_at",
"updated_at",
"thumbnail",
],
relations: ["*variants", "*categories", "*collections"]
properties: ["id", "title", "handle", "status", "thumbnail"],
relations: ["collection.title", "*sales_channels", "*variants"],
},
customers: {
properties: [
"id",
@@ -41,9 +33,9 @@ export const ENTITY_DEFAULT_FIELDS = {
"updated_at",
"has_account",
],
relations: ["*groups"]
relations: ["*groups"],
},
inventory: {
properties: [
"id",
@@ -55,14 +47,14 @@ export const ENTITY_DEFAULT_FIELDS = {
"created_at",
"updated_at",
],
relations: ["*location_levels"]
relations: ["*location_levels"],
},
// Default configuration for entities without specific defaults
default: {
properties: ["id", "created_at", "updated_at"],
relations: []
}
relations: [],
},
} as const
export type EntityType = keyof typeof ENTITY_DEFAULT_FIELDS
@@ -71,10 +63,11 @@ export type EntityType = keyof typeof ENTITY_DEFAULT_FIELDS
* Get default fields for an entity
*/
export function getEntityDefaultFields(entity: string) {
const config = ENTITY_DEFAULT_FIELDS[entity as EntityType] || ENTITY_DEFAULT_FIELDS.default
const config =
ENTITY_DEFAULT_FIELDS[entity as EntityType] || ENTITY_DEFAULT_FIELDS.default
return {
properties: config.properties,
relations: config.relations,
formatted: [...config.properties, ...config.relations].join(",")
formatted: [...config.properties, ...config.relations].join(","),
}
}
}
@@ -1,4 +1,8 @@
import { DataTableColumnDef, DataTableEmptyStateProps, DataTableFilter } from "@medusajs/ui"
import {
DataTableColumnDef,
DataTableEmptyStateProps,
DataTableFilter,
} from "@medusajs/ui"
import { ColumnAdapter } from "../../hooks/table/columns/use-configurable-table-columns"
/**
@@ -15,9 +19,12 @@ export interface TableAdapter<TData> {
* Hook to fetch data with the calculated required fields.
* Called inside ConfigurableDataTable with the fields and search params.
*/
useData: (fields: string, params: any) => {
useData: (
fields: string,
params: any
) => {
data: TData[] | undefined
count: number
count: number | undefined
isLoading: boolean
isError: boolean
error: any
@@ -39,7 +46,7 @@ export interface TableAdapter<TData> {
filters?: DataTableFilter[]
/**
* Transform API columns to table columns.
* Transform API columns to table columns.
* If not provided, will use default column generation.
*/
getColumns?: (apiColumns: any[]) => DataTableColumnDef<TData, any>[]
@@ -84,4 +91,4 @@ export function createTableAdapter<TData>(
queryPrefix: "",
...adapter,
}
}
}