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:
@@ -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,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user