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:
Sebastian Rindom
2025-09-15 10:59:00 +02:00
committed by GitHub
co-authored by Oli Juhl
parent c066fe993f
commit 23d5a902b1
19 changed files with 1407 additions and 545 deletions
@@ -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,
}
}