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:
@@ -1,6 +1,8 @@
|
||||
import { useState, useCallback, useMemo, useEffect, useRef } from "react"
|
||||
// import { HttpTypes } from "@medusajs/types"
|
||||
import type { ViewConfiguration } from "../../../hooks/use-view-configurations"
|
||||
import { HttpTypes } from "@medusajs/types"
|
||||
|
||||
type ViewConfiguration =
|
||||
HttpTypes.AdminViewConfigurationResponse["view_configuration"]
|
||||
|
||||
interface ColumnState {
|
||||
visibility: Record<string, boolean>
|
||||
@@ -20,13 +22,13 @@ interface UseColumnStateReturn {
|
||||
handleColumnVisibilityChange: (visibility: Record<string, boolean>) => void
|
||||
handleViewChange: (
|
||||
view: ViewConfiguration | null,
|
||||
apiColumns: HttpTypes.AdminViewColumn[]
|
||||
apiColumns: HttpTypes.AdminColumn[]
|
||||
) => void
|
||||
initializeColumns: (apiColumns: HttpTypes.AdminViewColumn[]) => void
|
||||
initializeColumns: (apiColumns: HttpTypes.AdminColumn[]) => void
|
||||
}
|
||||
|
||||
export function useColumnState(
|
||||
apiColumns: HttpTypes.AdminViewColumn[] | undefined,
|
||||
apiColumns: HttpTypes.AdminColumn[] | undefined,
|
||||
activeView?: ViewConfiguration | null
|
||||
): UseColumnStateReturn {
|
||||
// Initialize state lazily to avoid unnecessary re-renders
|
||||
@@ -85,10 +87,7 @@ export function useColumnState(
|
||||
)
|
||||
|
||||
const handleViewChange = useCallback(
|
||||
(
|
||||
view: ViewConfiguration | null,
|
||||
apiColumns: HttpTypes.AdminViewColumn[]
|
||||
) => {
|
||||
(view: ViewConfiguration | null, apiColumns: HttpTypes.AdminColumn[]) => {
|
||||
if (view?.configuration) {
|
||||
// Apply view configuration
|
||||
const newVisibility: Record<string, boolean> = {}
|
||||
@@ -108,7 +107,7 @@ export function useColumnState(
|
||||
)
|
||||
|
||||
const initializeColumns = useCallback(
|
||||
(apiColumns: HttpTypes.AdminViewColumn[]) => {
|
||||
(apiColumns: HttpTypes.AdminColumn[]) => {
|
||||
// Only initialize if we don't already have column state
|
||||
if (Object.keys(visibleColumns).length === 0) {
|
||||
setVisibleColumns(getInitialColumnVisibility(apiColumns))
|
||||
@@ -177,7 +176,7 @@ const DEFAULT_COLUMN_ORDER = 500
|
||||
* Gets the initial column visibility state from API columns
|
||||
*/
|
||||
function getInitialColumnVisibility(
|
||||
apiColumns: HttpTypes.AdminViewColumn[]
|
||||
apiColumns: HttpTypes.AdminColumn[]
|
||||
): Record<string, boolean> {
|
||||
if (!apiColumns || apiColumns.length === 0) {
|
||||
return {}
|
||||
@@ -195,9 +194,7 @@ function getInitialColumnVisibility(
|
||||
/**
|
||||
* Gets the initial column order from API columns
|
||||
*/
|
||||
function getInitialColumnOrder(
|
||||
apiColumns: HttpTypes.AdminViewColumn[]
|
||||
): string[] {
|
||||
function getInitialColumnOrder(apiColumns: HttpTypes.AdminColumn[]): string[] {
|
||||
if (!apiColumns || apiColumns.length === 0) {
|
||||
return []
|
||||
}
|
||||
|
||||
@@ -1,49 +0,0 @@
|
||||
import React, { useMemo } from "react"
|
||||
import { createDataTableColumnHelper } from "@medusajs/ui"
|
||||
import { HttpTypes } from "@medusajs/types"
|
||||
import { getDisplayStrategy, getEntityAccessor } from "../../../lib/table-display-utils"
|
||||
import { getColumnAlignment } from "../../../routes/orders/order-list/components/order-list-table/utils/column-utils"
|
||||
|
||||
const columnHelper = createDataTableColumnHelper<HttpTypes.AdminOrder>()
|
||||
|
||||
export function useConfigurableOrderTableColumns(apiColumns: any[] | undefined) {
|
||||
return useMemo(() => {
|
||||
if (!apiColumns?.length) {
|
||||
return []
|
||||
}
|
||||
|
||||
return apiColumns.map(apiColumn => {
|
||||
// Get the display strategy for this column
|
||||
const displayStrategy = getDisplayStrategy(apiColumn)
|
||||
|
||||
// Get the entity-specific accessor or use default
|
||||
const accessor = getEntityAccessor('orders', apiColumn.field, apiColumn)
|
||||
|
||||
// Determine header alignment
|
||||
const headerAlign = getColumnAlignment(apiColumn)
|
||||
|
||||
return columnHelper.accessor(accessor, {
|
||||
id: apiColumn.field,
|
||||
header: () => apiColumn.name,
|
||||
cell: ({ getValue, row }) => {
|
||||
const value = getValue()
|
||||
|
||||
// If the value is already a React element (from computed columns), return it directly
|
||||
if (React.isValidElement(value)) {
|
||||
return value
|
||||
}
|
||||
|
||||
// Otherwise, use the display strategy to format the value
|
||||
return displayStrategy(value, row.original)
|
||||
},
|
||||
meta: {
|
||||
name: apiColumn.name,
|
||||
column: apiColumn, // Store column metadata for future use
|
||||
},
|
||||
enableHiding: apiColumn.hideable,
|
||||
enableSorting: false, // Disable sorting for all columns
|
||||
headerAlign, // Pass the header alignment to the DataTable
|
||||
} as any)
|
||||
})
|
||||
}, [apiColumns])
|
||||
}
|
||||
@@ -0,0 +1,105 @@
|
||||
import React, { useMemo } from "react"
|
||||
import { createDataTableColumnHelper } from "@medusajs/ui"
|
||||
import { HttpTypes } from "@medusajs/types"
|
||||
import { getDisplayStrategy, getEntityAccessor } from "../../../lib/table-display-utils"
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
export function useConfigurableTableColumns<TData = any>(
|
||||
entity: string,
|
||||
apiColumns: HttpTypes.AdminViewColumn[] | undefined,
|
||||
adapter?: ColumnAdapter<TData>
|
||||
) {
|
||||
const columnHelper = createDataTableColumnHelper<TData>()
|
||||
|
||||
return useMemo(() => {
|
||||
if (!apiColumns?.length) {
|
||||
return []
|
||||
}
|
||||
|
||||
return apiColumns.map(apiColumn => {
|
||||
// Get the display strategy for this column
|
||||
const displayStrategy = getDisplayStrategy(apiColumn)
|
||||
|
||||
// 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)
|
||||
|
||||
// Determine header alignment
|
||||
const headerAlign = adapter?.getColumnAlignment
|
||||
? adapter.getColumnAlignment(apiColumn)
|
||||
: getDefaultColumnAlignment(apiColumn)
|
||||
|
||||
return columnHelper.accessor(accessor, {
|
||||
id: apiColumn.field,
|
||||
header: () => apiColumn.name,
|
||||
cell: ({ getValue, row }) => {
|
||||
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)
|
||||
}
|
||||
|
||||
// Otherwise, use the display strategy to format the value
|
||||
return displayStrategy(value, row.original)
|
||||
},
|
||||
meta: {
|
||||
name: apiColumn.name,
|
||||
column: apiColumn, // Store column metadata for future use
|
||||
},
|
||||
enableHiding: apiColumn.hideable,
|
||||
enableSorting: false, // Disable sorting for all columns by default
|
||||
headerAlign, // Pass the header alignment to the DataTable
|
||||
} as any)
|
||||
})
|
||||
}, [entity, apiColumns, adapter])
|
||||
}
|
||||
|
||||
function getDefaultColumnAlignment(column: HttpTypes.AdminViewColumn): "left" | "center" | "right" {
|
||||
// Currency columns should be right-aligned
|
||||
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") ||
|
||||
column.field.includes("price") ||
|
||||
column.field.includes("quantity") ||
|
||||
column.field.includes("count")
|
||||
) {
|
||||
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")) {
|
||||
return "center"
|
||||
}
|
||||
|
||||
// Default to left alignment
|
||||
return "left"
|
||||
}
|
||||
@@ -0,0 +1,313 @@
|
||||
import { useState, useMemo, useCallback, useEffect } from "react"
|
||||
import { useSearchParams } from "react-router-dom"
|
||||
import { HttpTypes } from "@medusajs/types"
|
||||
import { useViewConfigurations, useViewConfiguration } from "../use-view-configurations"
|
||||
import { useEntityColumns } from "../api/views"
|
||||
import { useFeatureFlag } from "../../providers/feature-flag-provider"
|
||||
import { useColumnState } from "./columns/use-column-state"
|
||||
import { useQueryParams } from "../use-query-params"
|
||||
import { calculateRequiredFields } from "../../lib/table/field-utils"
|
||||
|
||||
export interface TableConfiguration {
|
||||
filters: Record<string, any>
|
||||
sorting: { id: string; desc: boolean } | null
|
||||
search: string
|
||||
visible_columns?: string[]
|
||||
column_order?: string[]
|
||||
}
|
||||
|
||||
export interface UseTableConfigurationOptions {
|
||||
entity: string
|
||||
pageSize?: number
|
||||
queryPrefix?: string
|
||||
filters?: Array<{ id: string }>
|
||||
}
|
||||
|
||||
export interface UseTableConfigurationReturn {
|
||||
// View configuration
|
||||
activeView: any
|
||||
createView: any
|
||||
updateView: any
|
||||
isViewConfigEnabled: boolean
|
||||
|
||||
// Column state
|
||||
visibleColumns: Record<string, boolean>
|
||||
columnOrder: string[]
|
||||
currentColumns: {
|
||||
visible: string[]
|
||||
order: string[]
|
||||
}
|
||||
setColumnOrder: (order: string[]) => void
|
||||
handleColumnVisibilityChange: (visibility: Record<string, boolean>) => void
|
||||
|
||||
// Configuration state
|
||||
currentConfiguration: TableConfiguration
|
||||
hasConfigurationChanged: boolean
|
||||
handleClearConfiguration: () => void
|
||||
|
||||
// API columns
|
||||
apiColumns: HttpTypes.AdminViewColumn[] | undefined
|
||||
isLoadingColumns: boolean
|
||||
|
||||
// Query params
|
||||
queryParams: Record<string, any>
|
||||
|
||||
// Required fields for API calls
|
||||
requiredFields: string
|
||||
}
|
||||
|
||||
function parseSortingState(value: string) {
|
||||
return value.startsWith("-")
|
||||
? { id: value.slice(1), desc: true }
|
||||
: { id: value, desc: false }
|
||||
}
|
||||
|
||||
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
|
||||
)
|
||||
|
||||
// Column state
|
||||
const {
|
||||
visibleColumns,
|
||||
columnOrder,
|
||||
currentColumns,
|
||||
setColumnOrder,
|
||||
handleColumnVisibilityChange,
|
||||
handleViewChange: originalHandleViewChange,
|
||||
} = useColumnState(apiColumns, currentActiveView)
|
||||
|
||||
// Sync view configuration with URL and column state
|
||||
useEffect(() => {
|
||||
if (!apiColumns) return
|
||||
originalHandleViewChange(currentActiveView, apiColumns)
|
||||
setSearchParams((prev) => {
|
||||
// Clear existing query params
|
||||
const keysToDelete = Array.from(prev.keys()).filter(key =>
|
||||
key.startsWith(queryPrefix + "_") || key === queryPrefix + "_q" || key === queryPrefix + "_order"
|
||||
)
|
||||
keysToDelete.forEach(key => prev.delete(key))
|
||||
|
||||
// Apply view configuration
|
||||
if (currentActiveView) {
|
||||
const viewConfig = currentActiveView.configuration
|
||||
|
||||
if (viewConfig.filters) {
|
||||
Object.entries(viewConfig.filters).forEach(([key, value]) => {
|
||||
prev.set(`${queryPrefix}_${key}`, JSON.stringify(value))
|
||||
})
|
||||
}
|
||||
|
||||
if (viewConfig.sorting) {
|
||||
const sortValue = viewConfig.sorting.desc
|
||||
? `-${viewConfig.sorting.id}`
|
||||
: viewConfig.sorting.id
|
||||
prev.set(`${queryPrefix}_order`, sortValue)
|
||||
}
|
||||
|
||||
if (viewConfig.search) {
|
||||
prev.set(`${queryPrefix}_q`, viewConfig.search)
|
||||
}
|
||||
}
|
||||
|
||||
return prev
|
||||
})
|
||||
}, [currentActiveView, apiColumns])
|
||||
|
||||
// Current configuration from URL
|
||||
const currentConfiguration = useMemo(() => {
|
||||
const currentFilters: Record<string, any> = {}
|
||||
filters.forEach(filter => {
|
||||
if (queryParams[filter.id] !== undefined) {
|
||||
currentFilters[filter.id] = JSON.parse(queryParams[filter.id] || "")
|
||||
}
|
||||
})
|
||||
|
||||
return {
|
||||
filters: currentFilters,
|
||||
sorting: queryParams.order ? parseSortingState(queryParams.order) : null,
|
||||
search: queryParams.q || "",
|
||||
}
|
||||
}, [filters, queryParams])
|
||||
|
||||
// Check if configuration has changed from view
|
||||
const [debouncedHasConfigChanged, setDebouncedHasConfigChanged] = useState(false)
|
||||
|
||||
const hasConfigurationChanged = useMemo(() => {
|
||||
const currentFilters = currentConfiguration.filters
|
||||
const currentSorting = currentConfiguration.sorting
|
||||
const currentSearch = currentConfiguration.search
|
||||
const currentVisibleColumns = Object.entries(visibleColumns)
|
||||
.filter(([_, isVisible]) => isVisible)
|
||||
.map(([field]) => field)
|
||||
.sort()
|
||||
|
||||
if (currentActiveView) {
|
||||
const viewFilters = currentActiveView.configuration.filters || {}
|
||||
const viewSorting = currentActiveView.configuration.sorting
|
||||
const viewSearch = currentActiveView.configuration.search || ""
|
||||
const viewVisibleColumns = [...(currentActiveView.configuration.visible_columns || [])].sort()
|
||||
const viewColumnOrder = currentActiveView.configuration.column_order || []
|
||||
|
||||
// Check filters
|
||||
const filterKeys = new Set([...Object.keys(currentFilters), ...Object.keys(viewFilters)])
|
||||
for (const key of filterKeys) {
|
||||
if (JSON.stringify(currentFilters[key]) !== JSON.stringify(viewFilters[key])) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
// Check sorting
|
||||
const normalizedCurrentSorting = currentSorting || undefined
|
||||
const normalizedViewSorting = viewSorting || undefined
|
||||
if (JSON.stringify(normalizedCurrentSorting) !== JSON.stringify(normalizedViewSorting)) {
|
||||
return true
|
||||
}
|
||||
|
||||
// Check search
|
||||
if (currentSearch !== viewSearch) {
|
||||
return true
|
||||
}
|
||||
|
||||
// Check visible columns
|
||||
if (JSON.stringify(currentVisibleColumns) !== JSON.stringify(viewVisibleColumns)) {
|
||||
return true
|
||||
}
|
||||
|
||||
// Check column order
|
||||
if (JSON.stringify(columnOrder) !== JSON.stringify(viewColumnOrder)) {
|
||||
return true
|
||||
}
|
||||
} else {
|
||||
// Check against defaults
|
||||
if (Object.keys(currentFilters).length > 0) return true
|
||||
if (currentSorting !== null) return true
|
||||
if (currentSearch !== "") return true
|
||||
|
||||
if (apiColumns) {
|
||||
const currentVisibleSet = new Set(currentVisibleColumns)
|
||||
const defaultVisibleSet = new Set(
|
||||
apiColumns
|
||||
.filter(col => col.default_visible)
|
||||
.map(col => col.field)
|
||||
)
|
||||
|
||||
if (currentVisibleSet.size !== defaultVisibleSet.size ||
|
||||
[...currentVisibleSet].some(field => !defaultVisibleSet.has(field))) {
|
||||
return true
|
||||
}
|
||||
|
||||
const defaultOrder = apiColumns
|
||||
.sort((a, b) => (a.default_order ?? 500) - (b.default_order ?? 500))
|
||||
.map(col => col.field)
|
||||
|
||||
if (JSON.stringify(columnOrder) !== JSON.stringify(defaultOrder)) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return false
|
||||
}, [currentActiveView, visibleColumns, columnOrder, currentConfiguration, apiColumns])
|
||||
|
||||
// Debounce configuration change detection
|
||||
useEffect(() => {
|
||||
const timer = setTimeout(() => {
|
||||
setDebouncedHasConfigChanged(hasConfigurationChanged)
|
||||
}, 50)
|
||||
|
||||
return () => clearTimeout(timer)
|
||||
}, [hasConfigurationChanged])
|
||||
|
||||
// Clear configuration handler
|
||||
const handleClearConfiguration = useCallback(() => {
|
||||
if (apiColumns) {
|
||||
originalHandleViewChange(currentActiveView, apiColumns)
|
||||
}
|
||||
|
||||
setSearchParams((prev) => {
|
||||
const keysToDelete = Array.from(prev.keys()).filter(key =>
|
||||
key.startsWith(queryPrefix + "_") || key === queryPrefix + "_q" || key === queryPrefix + "_order"
|
||||
)
|
||||
keysToDelete.forEach(key => prev.delete(key))
|
||||
|
||||
if (currentActiveView?.configuration) {
|
||||
const viewConfig = currentActiveView.configuration
|
||||
|
||||
if (viewConfig.filters) {
|
||||
Object.entries(viewConfig.filters).forEach(([key, value]) => {
|
||||
prev.set(`${queryPrefix}_${key}`, JSON.stringify(value))
|
||||
})
|
||||
}
|
||||
|
||||
if (viewConfig.sorting) {
|
||||
const sortValue = viewConfig.sorting.desc
|
||||
? `-${viewConfig.sorting.id}`
|
||||
: viewConfig.sorting.id
|
||||
prev.set(`${queryPrefix}_order`, sortValue)
|
||||
}
|
||||
|
||||
if (viewConfig.search) {
|
||||
prev.set(`${queryPrefix}_q`, viewConfig.search)
|
||||
}
|
||||
}
|
||||
|
||||
return prev
|
||||
})
|
||||
}, [currentActiveView, apiColumns, queryPrefix])
|
||||
|
||||
// Calculate required fields based on visible columns
|
||||
const requiredFields = useMemo(() => {
|
||||
return calculateRequiredFields(entity, apiColumns, visibleColumns)
|
||||
}, [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,
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user