feat(dashboard): reusable config datatable (#13389)
* feat: add a reusable configurable data table * fix: cleanup * fix: cleanup * fix: cache invalidation * fix: test --------- Co-authored-by: Oli Juhl <59018053+olivermrbl@users.noreply.github.com>
This commit is contained in:
co-authored by
Oli Juhl
parent
c066fe993f
commit
23d5a902b1
@@ -0,0 +1,89 @@
|
||||
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"
|
||||
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"
|
||||
return "left"
|
||||
},
|
||||
|
||||
transformCellValue: (value, row, column) => {
|
||||
// Custom transformation for product-specific fields
|
||||
if (column.field === "variants_count") {
|
||||
return `${value || 0} variants`
|
||||
}
|
||||
|
||||
if (column.field === "status" && value === "draft") {
|
||||
return <span className="text-ui-fg-muted">Draft</span>
|
||||
}
|
||||
|
||||
// Default to standard display
|
||||
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"
|
||||
return "left"
|
||||
},
|
||||
|
||||
transformCellValue: (value, row, column) => {
|
||||
// Format customer name
|
||||
if (column.field === "name") {
|
||||
const { first_name, last_name } = row
|
||||
if (first_name || last_name) {
|
||||
return `${first_name || ""} ${last_name || ""}`.trim()
|
||||
}
|
||||
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"
|
||||
return "left"
|
||||
}
|
||||
}
|
||||
|
||||
// Registry of entity adapters
|
||||
export const entityAdapters = {
|
||||
orders: orderColumnAdapter,
|
||||
products: productColumnAdapter,
|
||||
customers: customerColumnAdapter,
|
||||
inventory: inventoryColumnAdapter,
|
||||
} as const
|
||||
|
||||
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>
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
/**
|
||||
* Default fields configuration for each entity type
|
||||
* These fields are always fetched to ensure basic functionality
|
||||
*/
|
||||
export const ENTITY_DEFAULT_FIELDS = {
|
||||
orders: {
|
||||
properties: [
|
||||
"id",
|
||||
"status",
|
||||
"created_at",
|
||||
"email",
|
||||
"display_id",
|
||||
"payment_status",
|
||||
"fulfillment_status",
|
||||
"total",
|
||||
"currency_code",
|
||||
],
|
||||
relations: ["*customer", "*sales_channel"]
|
||||
},
|
||||
|
||||
products: {
|
||||
properties: [
|
||||
"id",
|
||||
"title",
|
||||
"handle",
|
||||
"status",
|
||||
"created_at",
|
||||
"updated_at",
|
||||
"thumbnail",
|
||||
],
|
||||
relations: ["*variants", "*categories", "*collections"]
|
||||
},
|
||||
|
||||
customers: {
|
||||
properties: [
|
||||
"id",
|
||||
"email",
|
||||
"first_name",
|
||||
"last_name",
|
||||
"created_at",
|
||||
"updated_at",
|
||||
"has_account",
|
||||
],
|
||||
relations: ["*groups"]
|
||||
},
|
||||
|
||||
inventory: {
|
||||
properties: [
|
||||
"id",
|
||||
"sku",
|
||||
"title",
|
||||
"description",
|
||||
"stocked_quantity",
|
||||
"reserved_quantity",
|
||||
"created_at",
|
||||
"updated_at",
|
||||
],
|
||||
relations: ["*location_levels"]
|
||||
},
|
||||
|
||||
// Default configuration for entities without specific defaults
|
||||
default: {
|
||||
properties: ["id", "created_at", "updated_at"],
|
||||
relations: []
|
||||
}
|
||||
} as const
|
||||
|
||||
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
|
||||
return {
|
||||
properties: config.properties,
|
||||
relations: config.relations,
|
||||
formatted: [...config.properties, ...config.relations].join(",")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
import { HttpTypes } from "@medusajs/types"
|
||||
import { getEntityDefaultFields } from "./entity-defaults"
|
||||
|
||||
/**
|
||||
* Calculates the required fields based on visible columns and entity defaults
|
||||
*/
|
||||
export function calculateRequiredFields(
|
||||
entity: string,
|
||||
apiColumns: HttpTypes.AdminViewColumn[] | undefined,
|
||||
visibleColumns: Record<string, boolean>
|
||||
): string {
|
||||
// Get entity-specific default fields
|
||||
const defaults = getEntityDefaultFields(entity)
|
||||
const defaultFields = defaults.formatted
|
||||
|
||||
if (!apiColumns?.length) {
|
||||
return defaultFields
|
||||
}
|
||||
|
||||
// Get all visible columns
|
||||
const visibleColumnObjects = apiColumns.filter(column => {
|
||||
// If visibleColumns has data, use it; otherwise use default_visible
|
||||
if (Object.keys(visibleColumns).length > 0) {
|
||||
return visibleColumns[column.field] === true
|
||||
}
|
||||
return column.default_visible
|
||||
})
|
||||
|
||||
// Collect all required fields from visible columns
|
||||
const requiredFieldsSet = new Set<string>()
|
||||
|
||||
visibleColumnObjects.forEach(column => {
|
||||
if (column.computed) {
|
||||
// For computed columns, add all required and optional fields
|
||||
column.computed.required_fields?.forEach((field: string) => requiredFieldsSet.add(field))
|
||||
column.computed.optional_fields?.forEach((field: string) => requiredFieldsSet.add(field))
|
||||
} else if (!column.field.includes('.')) {
|
||||
// Direct field
|
||||
requiredFieldsSet.add(column.field)
|
||||
} else {
|
||||
// Relationship field
|
||||
requiredFieldsSet.add(column.field)
|
||||
}
|
||||
})
|
||||
|
||||
// Separate relationship fields from direct fields
|
||||
const allRequiredFields = Array.from(requiredFieldsSet)
|
||||
const visibleRelationshipFields = allRequiredFields.filter(field => field.includes('.'))
|
||||
const visibleDirectFields = allRequiredFields.filter(field => !field.includes('.'))
|
||||
|
||||
// Check which relationship fields need to be added
|
||||
const additionalRelationshipFields = visibleRelationshipFields.filter(field => {
|
||||
const [relationName] = field.split('.')
|
||||
const isAlreadyCovered = defaults.relations.some(rel =>
|
||||
rel === `*${relationName}` || rel === relationName
|
||||
)
|
||||
return !isAlreadyCovered
|
||||
})
|
||||
|
||||
// Check which direct fields need to be added
|
||||
const additionalDirectFields = visibleDirectFields.filter(field => {
|
||||
const isAlreadyIncluded = defaults.properties.includes(field)
|
||||
return !isAlreadyIncluded
|
||||
})
|
||||
|
||||
// Combine all additional fields
|
||||
const additionalFields = [...additionalRelationshipFields, ...additionalDirectFields]
|
||||
|
||||
// Combine default fields with additional needed fields
|
||||
if (additionalFields.length > 0) {
|
||||
return `${defaultFields},${additionalFields.join(',')}`
|
||||
}
|
||||
|
||||
return defaultFields
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
import { DataTableColumnDef, DataTableEmptyStateProps, DataTableFilter } from "@medusajs/ui"
|
||||
import { ColumnAdapter } from "../../hooks/table/columns/use-configurable-table-columns"
|
||||
|
||||
/**
|
||||
* Adapter interface for configurable tables.
|
||||
* Defines how to fetch and display data for a specific entity type.
|
||||
*/
|
||||
export interface TableAdapter<TData> {
|
||||
/**
|
||||
* The entity type (e.g., "orders", "products", "customers")
|
||||
*/
|
||||
entity: string
|
||||
|
||||
/**
|
||||
* Hook to fetch data with the calculated required fields.
|
||||
* Called inside ConfigurableDataTable with the fields and search params.
|
||||
*/
|
||||
useData: (fields: string, params: any) => {
|
||||
data: TData[] | undefined
|
||||
count: number
|
||||
isLoading: boolean
|
||||
isError: boolean
|
||||
error: any
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract unique ID from a row. Defaults to row.id if not provided.
|
||||
*/
|
||||
getRowId?: (row: TData) => string
|
||||
|
||||
/**
|
||||
* Generate href for row navigation. Return undefined for non-clickable rows.
|
||||
*/
|
||||
getRowHref?: (row: TData) => string | undefined
|
||||
|
||||
/**
|
||||
* Table filters configuration
|
||||
*/
|
||||
filters?: DataTableFilter[]
|
||||
|
||||
/**
|
||||
* Transform API columns to table columns.
|
||||
* If not provided, will use default column generation.
|
||||
*/
|
||||
getColumns?: (apiColumns: any[]) => DataTableColumnDef<TData, any>[]
|
||||
|
||||
/**
|
||||
* Column adapter for customizing column behavior (alignment, formatting, etc.)
|
||||
* If not provided, will use entity's default column adapter if available.
|
||||
*/
|
||||
columnAdapter?: ColumnAdapter<TData>
|
||||
|
||||
/**
|
||||
* Empty state configuration
|
||||
*/
|
||||
emptyState?: DataTableEmptyStateProps
|
||||
|
||||
/**
|
||||
* Default page size
|
||||
*/
|
||||
pageSize?: number
|
||||
|
||||
/**
|
||||
* Query parameter prefix for URL state management
|
||||
*/
|
||||
queryPrefix?: string
|
||||
|
||||
/**
|
||||
* Optional entity display name for headings
|
||||
*/
|
||||
entityName?: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Helper to create a type-safe table adapter
|
||||
*/
|
||||
export function createTableAdapter<TData>(
|
||||
adapter: TableAdapter<TData>
|
||||
): TableAdapter<TData> {
|
||||
return {
|
||||
// Provide smart defaults
|
||||
getRowId: (row: any) => row.id,
|
||||
pageSize: 20,
|
||||
queryPrefix: "",
|
||||
...adapter,
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user