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

View File

@@ -1,20 +1,22 @@
import React, { useMemo } from "react"
import { createDataTableColumnHelper } from "@medusajs/ui"
import { HttpTypes } from "@medusajs/types"
import { getDisplayStrategy, getEntityAccessor } from "../../../lib/table-display-utils"
import { useTranslation } from "react-i18next"
import { getCellRenderer, getColumnValue } from "../../../lib/table/cell-renderers"
export interface ColumnAdapter<TData> {
getColumnAlignment?: (column: HttpTypes.AdminViewColumn) => "left" | "center" | "right"
getCustomAccessor?: (field: string, column: HttpTypes.AdminViewColumn) => any
transformCellValue?: (value: any, row: TData, column: HttpTypes.AdminViewColumn) => React.ReactNode
getColumnAlignment?: (column: HttpTypes.AdminColumn) => "left" | "center" | "right"
getCustomAccessor?: (field: string, column: HttpTypes.AdminColumn) => any
transformCellValue?: (value: any, row: TData, column: HttpTypes.AdminColumn) => React.ReactNode
}
export function useConfigurableTableColumns<TData = any>(
entity: string,
apiColumns: HttpTypes.AdminViewColumn[] | undefined,
apiColumns: HttpTypes.AdminColumn[] | undefined,
adapter?: ColumnAdapter<TData>
) {
const columnHelper = createDataTableColumnHelper<TData>()
const { t } = useTranslation()
return useMemo(() => {
if (!apiColumns?.length) {
@@ -22,37 +24,45 @@ export function useConfigurableTableColumns<TData = any>(
}
return apiColumns.map(apiColumn => {
// Get the display strategy for this column
const displayStrategy = getDisplayStrategy(apiColumn)
let renderType = apiColumn.computed?.type
// Get the entity-specific accessor or use adapter's custom accessor
const accessor = adapter?.getCustomAccessor
? adapter.getCustomAccessor(apiColumn.field, apiColumn)
: getEntityAccessor(entity, apiColumn.field, apiColumn)
if (!renderType) {
if (apiColumn.semantic_type === 'timestamp') {
renderType = 'timestamp'
} else if (apiColumn.field === 'display_id') {
renderType = 'display_id'
} else if (apiColumn.field === 'total') {
renderType = 'total'
} else if (apiColumn.semantic_type === 'currency') {
renderType = 'currency'
}
}
const renderer = getCellRenderer(
renderType,
apiColumn.data_type
)
// Determine header alignment
const headerAlign = adapter?.getColumnAlignment
? adapter.getColumnAlignment(apiColumn)
: getDefaultColumnAlignment(apiColumn)
const accessor = (row: TData) => getColumnValue(row, apiColumn)
return columnHelper.accessor(accessor, {
id: apiColumn.field,
header: () => apiColumn.name,
cell: ({ getValue, row }) => {
cell: ({ getValue, row }: { getValue: any, row: any }) => {
const value = getValue()
// If the value is already a React element (from computed columns), return it directly
if (React.isValidElement(value)) {
return value
}
// Allow adapter to transform the value
if (adapter?.transformCellValue) {
return adapter.transformCellValue(value, row.original, apiColumn)
const transformed = adapter.transformCellValue(value, row.original, apiColumn)
if (transformed !== null) {
return transformed
}
}
// Otherwise, use the display strategy to format the value
return displayStrategy(value, row.original)
return renderer(value, row.original, apiColumn, t)
},
meta: {
name: apiColumn.name,
@@ -63,21 +73,18 @@ export function useConfigurableTableColumns<TData = any>(
headerAlign, // Pass the header alignment to the DataTable
} as any)
})
}, [entity, apiColumns, adapter])
}, [entity, apiColumns, adapter, t])
}
function getDefaultColumnAlignment(column: HttpTypes.AdminViewColumn): "left" | "center" | "right" {
// Currency columns should be right-aligned
function getDefaultColumnAlignment(column: HttpTypes.AdminColumn): "left" | "center" | "right" {
if (column.semantic_type === "currency" || column.data_type === "currency") {
return "right"
}
// Number columns should be right-aligned (except identifiers)
if (column.data_type === "number" && column.context !== "identifier") {
return "right"
}
// Total/amount/price columns should be right-aligned
if (
column.field.includes("total") ||
column.field.includes("amount") ||
@@ -87,19 +94,16 @@ function getDefaultColumnAlignment(column: HttpTypes.AdminViewColumn): "left" |
) {
return "right"
}
// Status columns should be center-aligned
if (column.semantic_type === "status") {
return "center"
}
// Country columns should be center-aligned
if (column.computed?.type === "country_code" ||
column.field === "country" ||
column.field.includes("country_code")) {
if (column.computed?.type === "country_code" ||
column.field === "country" ||
column.field.includes("country_code")) {
return "center"
}
// Default to left alignment
return "left"
}
}

View File

@@ -84,6 +84,7 @@ export const useProductTableFilters = (
label: t("fields.type"),
type: "select",
multiple: true,
searchable: true,
options: product_types.map((t) => ({
label: t.value,
value: t.id,
@@ -99,6 +100,7 @@ export const useProductTableFilters = (
label: t("fields.tag"),
type: "select",
multiple: true,
searchable: true,
options: product_tags.map((t) => ({
label: t.value,
value: t.id,
@@ -114,6 +116,7 @@ export const useProductTableFilters = (
label: t("fields.salesChannel"),
type: "select",
multiple: true,
searchable: true,
options: sales_channels.map((s) => ({
label: s.name,
value: s.id,

View File

@@ -24,13 +24,10 @@ export interface UseTableConfigurationOptions {
}
export interface UseTableConfigurationReturn {
// View configuration
activeView: any
createView: any
updateView: any
isViewConfigEnabled: boolean
// Column state
visibleColumns: Record<string, boolean>
columnOrder: string[]
currentColumns: {
@@ -39,20 +36,12 @@ export interface UseTableConfigurationReturn {
}
setColumnOrder: (order: string[]) => void
handleColumnVisibilityChange: (visibility: Record<string, boolean>) => void
// Configuration state
currentConfiguration: TableConfiguration
hasConfigurationChanged: boolean
handleClearConfiguration: () => void
// API columns
apiColumns: HttpTypes.AdminViewColumn[] | undefined
apiColumns: HttpTypes.AdminColumn[] | undefined
isLoadingColumns: boolean
// Query params
queryParams: Record<string, any>
// Required fields for API calls
requiredFields: string
}
@@ -64,24 +53,20 @@ function parseSortingState(value: string) {
export function useTableConfiguration({
entity,
pageSize = 20,
queryPrefix = "",
filters = [],
}: UseTableConfigurationOptions): UseTableConfigurationReturn {
const isViewConfigEnabled = useFeatureFlag("view_configurations")
const [_, setSearchParams] = useSearchParams()
// View configurations
const { activeView, createView } = useViewConfigurations(entity)
const currentActiveView = activeView?.view_configuration || null
const { updateView } = useViewConfiguration(entity, currentActiveView?.id || "")
// Entity columns
const { columns: apiColumns, isLoading: isLoadingColumns } = useEntityColumns(entity, {
enabled: isViewConfigEnabled,
})
// Query params
const queryParams = useQueryParams(
["q", "order", ...filters.map(f => f.id)],
queryPrefix
@@ -152,7 +137,7 @@ export function useTableConfiguration({
// Check if configuration has changed from view
const [debouncedHasConfigChanged, setDebouncedHasConfigChanged] = useState(false)
const hasConfigurationChanged = useMemo(() => {
const currentFilters = currentConfiguration.filters
const currentSorting = currentConfiguration.sorting
@@ -282,32 +267,21 @@ export function useTableConfiguration({
}, [entity, apiColumns, visibleColumns])
return {
// View configuration
activeView: currentActiveView,
createView,
updateView,
isViewConfigEnabled,
// Column state
visibleColumns,
columnOrder,
currentColumns,
setColumnOrder,
handleColumnVisibilityChange,
// Configuration state
currentConfiguration,
hasConfigurationChanged: debouncedHasConfigChanged,
handleClearConfiguration,
// API columns
apiColumns,
isLoadingColumns,
// Query params
queryParams,
// Required fields
requiredFields,
}
}
}