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
parent c066fe993f
commit 23d5a902b1
19 changed files with 1407 additions and 545 deletions

View File

@@ -0,0 +1,447 @@
import React, { useState, ReactNode } from "react"
import { Container, Button } from "@medusajs/ui"
import { useTranslation } from "react-i18next"
import { DataTable } from "../../data-table"
import { SaveViewDialog } from "../save-view-dialog"
import { SaveViewDropdown } from "./save-view-dropdown"
import { useTableConfiguration } from "../../../hooks/table/use-table-configuration"
import { useOrderTableQuery } from "../../../hooks/table/query/use-order-table-query"
import { useConfigurableTableColumns } from "../../../hooks/table/columns/use-configurable-table-columns"
import { getEntityAdapter } from "../../../lib/table/entity-adapters"
import { DataTableColumnDef, DataTableEmptyStateProps, DataTableFilter } from "@medusajs/ui"
import { TableAdapter } from "../../../lib/table/table-adapters"
export interface ConfigurableDataTableProps<TData> {
// Use adapter pattern for entity-specific configuration
adapter: TableAdapter<TData>
// Optional overrides
heading?: string
subHeading?: string
pageSize?: number
queryPrefix?: string
layout?: "fill" | "auto"
actions?: ReactNode
}
// Legacy props interface for backward compatibility
export interface LegacyConfigurableDataTableProps<TData> {
// Entity configuration
entity: string
entityName?: string
// Data and columns
data: TData[]
columns: DataTableColumnDef<TData, any>[]
filters?: DataTableFilter[]
// Table configuration
pageSize?: number
queryPrefix?: string
getRowId: (row: TData) => string
rowHref?: (row: TData) => string
// UI configuration
heading?: string
subHeading?: string
emptyState?: DataTableEmptyStateProps
// Loading and counts
isLoading?: boolean
rowCount?: number
// Additional content
actions?: ReactNode
// Layout
layout?: "fill" | "auto"
}
// Internal component that handles adapter mode
function ConfigurableDataTableWithAdapter<TData>({
adapter,
heading,
subHeading,
pageSize: pageSizeProp,
queryPrefix: queryPrefixProp,
layout = "fill",
// actions, // Currently unused
}: ConfigurableDataTableProps<TData>) {
const { t } = useTranslation()
const [saveDialogOpen, setSaveDialogOpen] = useState(false)
const [editingView, setEditingView] = useState<any>(null)
const entity = adapter.entity
const entityName = adapter.entityName
const filters = adapter.filters || []
const pageSize = pageSizeProp || adapter.pageSize || 20
const queryPrefix = queryPrefixProp || adapter.queryPrefix || ""
// Get table configuration (single source of truth)
const {
activeView,
createView,
updateView,
isViewConfigEnabled,
visibleColumns,
columnOrder,
currentColumns,
setColumnOrder,
handleColumnVisibilityChange,
currentConfiguration,
hasConfigurationChanged,
handleClearConfiguration,
isLoadingColumns,
apiColumns,
requiredFields,
} = useTableConfiguration({
entity,
pageSize,
queryPrefix,
filters,
})
// Get query params for data fetching
const { searchParams } = useOrderTableQuery({
pageSize,
prefix: queryPrefix,
})
// Fetch data using adapter
const fetchResult = adapter.useData(requiredFields, searchParams)
// Generate columns
// Use adapter's column adapter if provided, otherwise fall back to entity adapter
const columnAdapter = adapter.columnAdapter || getEntityAdapter(entity)
const generatedColumns = useConfigurableTableColumns(entity, apiColumns || [], columnAdapter)
const columns = (adapter.getColumns && apiColumns)
? adapter.getColumns(apiColumns)
: generatedColumns
// Handle errors
if (fetchResult.isError) {
throw fetchResult.error
}
// View save handlers
const handleSaveAsDefault = async () => {
try {
if (activeView?.is_system_default) {
await updateView.mutateAsync({
name: activeView.name || null,
configuration: {
visible_columns: currentColumns.visible,
column_order: currentColumns.order,
filters: currentConfiguration.filters || {},
sorting: currentConfiguration.sorting || null,
search: currentConfiguration.search || "",
}
})
} else {
await createView.mutateAsync({
name: "Default",
is_system_default: true,
set_active: true,
configuration: {
visible_columns: currentColumns.visible,
column_order: currentColumns.order,
filters: currentConfiguration.filters || {},
sorting: currentConfiguration.sorting || null,
search: currentConfiguration.search || "",
}
})
}
} catch (_) {
// Error is handled by the hook
}
}
const handleUpdateExisting = async () => {
if (!activeView) return
try {
await updateView.mutateAsync({
name: activeView.name,
configuration: {
visible_columns: currentColumns.visible,
column_order: currentColumns.order,
filters: currentConfiguration.filters || {},
sorting: currentConfiguration.sorting || null,
search: currentConfiguration.search || "",
}
})
} catch (_) {
// Error is handled by the hook
}
}
const handleSaveAsNew = () => {
setSaveDialogOpen(true)
setEditingView(null)
}
// Filter bar content with save controls
const filterBarContent = hasConfigurationChanged ? (
<>
<Button
variant="secondary"
size="small"
type="button"
onClick={handleClearConfiguration}
>
{t("actions.clear")}
</Button>
<SaveViewDropdown
isDefaultView={activeView?.is_system_default || !activeView}
currentViewId={activeView?.id}
currentViewName={activeView?.name}
onSaveAsDefault={handleSaveAsDefault}
onUpdateExisting={handleUpdateExisting}
onSaveAsNew={handleSaveAsNew}
/>
</>
) : null
return (
<Container className="divide-y p-0">
<DataTable
data={fetchResult.data || []}
columns={columns}
filters={filters}
getRowId={adapter.getRowId || ((row: any) => row.id)}
rowCount={fetchResult.count}
enablePagination
enableSearch
pageSize={pageSize}
isLoading={fetchResult.isLoading || isLoadingColumns}
layout={layout}
heading={heading || entityName || (entity ? t(`${entity}.domain` as any) : "")}
subHeading={subHeading}
enableColumnVisibility={isViewConfigEnabled}
initialColumnVisibility={visibleColumns}
onColumnVisibilityChange={handleColumnVisibilityChange}
columnOrder={columnOrder}
onColumnOrderChange={setColumnOrder}
enableViewSelector={isViewConfigEnabled}
entity={entity}
currentColumns={currentColumns}
filterBarContent={filterBarContent}
rowHref={adapter.getRowHref as ((row: any) => string) | undefined}
emptyState={adapter.emptyState || {
empty: {
heading: t(`${entity}.list.noRecordsMessage` as any),
}
}}
prefix={queryPrefix}
/>
{saveDialogOpen && (
<SaveViewDialog
entity={entity}
currentColumns={currentColumns}
currentConfiguration={currentConfiguration}
editingView={editingView}
onClose={() => {
setSaveDialogOpen(false)
setEditingView(null)
}}
onSaved={() => {
setSaveDialogOpen(false)
setEditingView(null)
}}
/>
)}
</Container>
)
}
// Internal component that handles legacy mode
function ConfigurableDataTableLegacy<TData>(props: LegacyConfigurableDataTableProps<TData>) {
const { t } = useTranslation()
const [saveDialogOpen, setSaveDialogOpen] = useState(false)
const [editingView, setEditingView] = useState<any>(null)
const {
entity,
entityName,
data,
columns,
filters = [],
pageSize = 20,
queryPrefix = "",
getRowId,
rowHref,
heading,
subHeading,
emptyState,
isLoading = false,
rowCount = 0,
// actions, // Currently unused
layout = "fill",
} = props
// Get table configuration
const {
activeView,
createView,
updateView,
isViewConfigEnabled,
visibleColumns,
columnOrder,
currentColumns,
setColumnOrder,
handleColumnVisibilityChange,
currentConfiguration,
hasConfigurationChanged,
handleClearConfiguration,
isLoadingColumns,
} = useTableConfiguration({
entity,
pageSize,
queryPrefix,
filters,
})
// View save handlers
const handleSaveAsDefault = async () => {
try {
if (activeView?.is_system_default) {
await updateView.mutateAsync({
name: activeView.name || null,
configuration: {
visible_columns: currentColumns.visible,
column_order: currentColumns.order,
filters: currentConfiguration.filters || {},
sorting: currentConfiguration.sorting || null,
search: currentConfiguration.search || "",
}
})
} else {
await createView.mutateAsync({
name: "Default",
is_system_default: true,
set_active: true,
configuration: {
visible_columns: currentColumns.visible,
column_order: currentColumns.order,
filters: currentConfiguration.filters || {},
sorting: currentConfiguration.sorting || null,
search: currentConfiguration.search || "",
}
})
}
} catch (_) {
// Error is handled by the hook
}
}
const handleUpdateExisting = async () => {
if (!activeView) return
try {
await updateView.mutateAsync({
name: activeView.name,
configuration: {
visible_columns: currentColumns.visible,
column_order: currentColumns.order,
filters: currentConfiguration.filters || {},
sorting: currentConfiguration.sorting || null,
search: currentConfiguration.search || "",
}
})
} catch (_) {
// Error is handled by the hook
}
}
const handleSaveAsNew = () => {
setSaveDialogOpen(true)
setEditingView(null)
}
// Filter bar content with save controls
const filterBarContent = hasConfigurationChanged ? (
<>
<Button
variant="secondary"
size="small"
type="button"
onClick={handleClearConfiguration}
>
{t("actions.clear")}
</Button>
<SaveViewDropdown
isDefaultView={activeView?.is_system_default || !activeView}
currentViewId={activeView?.id}
currentViewName={activeView?.name}
onSaveAsDefault={handleSaveAsDefault}
onUpdateExisting={handleUpdateExisting}
onSaveAsNew={handleSaveAsNew}
/>
</>
) : null
return (
<Container className="divide-y p-0">
<DataTable
data={data}
columns={columns}
filters={filters}
getRowId={getRowId}
rowCount={rowCount}
enablePagination
enableSearch
pageSize={pageSize}
isLoading={isLoading || isLoadingColumns}
layout={layout}
heading={heading || entityName || (entity ? t(`${entity}.domain` as any) : "")}
subHeading={subHeading}
enableColumnVisibility={isViewConfigEnabled}
initialColumnVisibility={visibleColumns}
onColumnVisibilityChange={handleColumnVisibilityChange}
columnOrder={columnOrder}
onColumnOrderChange={setColumnOrder}
enableViewSelector={isViewConfigEnabled}
entity={entity}
currentColumns={currentColumns}
filterBarContent={filterBarContent}
rowHref={rowHref}
emptyState={emptyState || {
empty: {
heading: t(`${entity}.list.noRecordsMessage` as any),
}
}}
prefix={queryPrefix}
/>
{saveDialogOpen && (
<SaveViewDialog
entity={entity}
currentColumns={currentColumns}
currentConfiguration={currentConfiguration}
editingView={editingView}
onClose={() => {
setSaveDialogOpen(false)
setEditingView(null)
}}
onSaved={() => {
setSaveDialogOpen(false)
setEditingView(null)
}}
/>
)}
</Container>
)
}
// Main export that delegates to the appropriate component
export function ConfigurableDataTable<TData>(
props: ConfigurableDataTableProps<TData> | LegacyConfigurableDataTableProps<TData>
) {
// Check if using new adapter pattern or legacy props
if ('adapter' in props) {
return <ConfigurableDataTableWithAdapter<TData> {...props} />
} else {
return <ConfigurableDataTableLegacy<TData> {...props} />
}
}

View File

@@ -0,0 +1,3 @@
export { ConfigurableDataTable } from "./configurable-data-table"
export type { ConfigurableDataTableProps } from "./configurable-data-table"
export { SaveViewDropdown } from "./save-view-dropdown"

View File

@@ -0,0 +1,87 @@
import React from "react"
import { Button, DropdownMenu, usePrompt } from "@medusajs/ui"
import { ChevronDownMini } from "@medusajs/icons"
import { useTranslation } from "react-i18next"
interface SaveViewDropdownProps {
isDefaultView: boolean
currentViewId?: string
currentViewName?: string
onSaveAsDefault: () => void
onUpdateExisting: () => void
onSaveAsNew: () => void
}
export const SaveViewDropdown: React.FC<SaveViewDropdownProps> = ({
isDefaultView,
currentViewId,
currentViewName,
onSaveAsDefault,
onUpdateExisting,
onSaveAsNew,
}) => {
const { t } = useTranslation()
const prompt = usePrompt()
const handleSaveAsDefault = async () => {
const result = await prompt({
title: t("views.prompts.updateDefault.title"),
description: t("views.prompts.updateDefault.description"),
confirmText: t("views.prompts.updateDefault.confirmText"),
cancelText: t("views.prompts.updateDefault.cancelText"),
})
if (result) {
onSaveAsDefault()
}
}
const handleUpdateExisting = async () => {
const result = await prompt({
title: t("views.prompts.updateView.title"),
description: t("views.prompts.updateView.description", { name: currentViewName }),
confirmText: t("views.prompts.updateView.confirmText"),
cancelText: t("views.prompts.updateView.cancelText"),
})
if (result) {
onUpdateExisting()
}
}
return (
<DropdownMenu>
<DropdownMenu.Trigger asChild>
<Button
variant="secondary"
size="small"
type="button"
>
{t("views.save")}
<ChevronDownMini />
</Button>
</DropdownMenu.Trigger>
<DropdownMenu.Content align="end">
{isDefaultView ? (
<>
<DropdownMenu.Item onClick={handleSaveAsDefault}>
{t("views.updateDefaultForEveryone")}
</DropdownMenu.Item>
<DropdownMenu.Item onClick={onSaveAsNew}>
{t("views.saveAsNew")}
</DropdownMenu.Item>
</>
) : (
<>
<DropdownMenu.Item onClick={handleUpdateExisting}>
{t("views.updateViewName")}
</DropdownMenu.Item>
<DropdownMenu.Item onClick={onSaveAsNew}>
{t("views.saveAsNew")}
</DropdownMenu.Item>
</>
)}
</DropdownMenu.Content>
</DropdownMenu>
)
}

View File

@@ -28,7 +28,11 @@ _viewsKeys.active = function(entity: string) {
}
_viewsKeys.configurations = function(entity: string, query?: any) {
return [this.all, "configurations", entity, query]
const key = [this.all, "configurations", entity]
if (query !== undefined) {
key.push(query)
}
return key
}
export const viewsQueryKeys = _viewsKeys
@@ -176,6 +180,8 @@ export const useUpdateViewConfiguration = (
onSuccess: (data, variables, context) => {
queryClient.invalidateQueries({ queryKey: viewsQueryKeys.configurations(entity) })
queryClient.invalidateQueries({ queryKey: viewsQueryKeys.detail(id) })
// Also invalidate active configuration if this view is currently active
queryClient.invalidateQueries({ queryKey: viewsQueryKeys.active(entity) })
options?.onSuccess?.(data, variables, context)
},
})

View File

@@ -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 []
}

View File

@@ -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])
}

View File

@@ -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"
}

View File

@@ -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,
}
}

View File

@@ -11505,6 +11505,70 @@
],
"additionalProperties": false
},
"views": {
"type": "object",
"properties": {
"save": {
"type": "string"
},
"saveAsNew": {
"type": "string"
},
"updateDefaultForEveryone": {
"type": "string"
},
"updateViewName": {
"type": "string"
},
"prompts": {
"type": "object",
"properties": {
"updateDefault": {
"type": "object",
"properties": {
"title": {
"type": "string"
},
"description": {
"type": "string"
},
"confirmText": {
"type": "string"
},
"cancelText": {
"type": "string"
}
},
"required": ["title", "description", "confirmText", "cancelText"],
"additionalProperties": false
},
"updateView": {
"type": "object",
"properties": {
"title": {
"type": "string"
},
"description": {
"type": "string"
},
"confirmText": {
"type": "string"
},
"cancelText": {
"type": "string"
}
},
"required": ["title", "description", "confirmText", "cancelText"],
"additionalProperties": false
}
},
"required": ["updateDefault", "updateView"],
"additionalProperties": false
}
},
"required": ["save", "saveAsNew", "updateDefaultForEveryone", "updateViewName", "prompts"],
"additionalProperties": false
},
"dateTime": {
"type": "object",
"properties": {
@@ -11623,6 +11687,7 @@
"statuses",
"labels",
"fields",
"views",
"dateTime"
],
"additionalProperties": false

View File

@@ -3098,5 +3098,25 @@
"minutes_other": "Minutes",
"seconds_one": "Second",
"seconds_other": "Seconds"
},
"views": {
"save": "Save",
"saveAsNew": "Save as new view",
"updateDefaultForEveryone": "Update default for everyone",
"updateViewName": "Update view",
"prompts": {
"updateDefault": {
"title": "Update default view",
"description": "This will update the default view for all users. Are you sure?",
"confirmText": "Update for everyone",
"cancelText": "Cancel"
},
"updateView": {
"title": "Update view",
"description": "Are you sure you want to update \"{{name}}\"?",
"confirmText": "Update",
"cancelText": "Cancel"
}
}
}
}

View File

@@ -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>
}

View File

@@ -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(",")
}
}

View File

@@ -1,15 +1,20 @@
import { HttpTypes } from "@medusajs/types"
import { DEFAULT_FIELDS, DEFAULT_PROPERTIES, DEFAULT_RELATIONS } from "../../../const"
import { getEntityDefaultFields } from "./entity-defaults"
/**
* Calculates the required fields based on visible columns
* Calculates the required fields based on visible columns and entity defaults
*/
export function calculateRequiredFields(
apiColumns: any[] | undefined,
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 DEFAULT_FIELDS
return defaultFields
}
// Get all visible columns
@@ -27,8 +32,8 @@ export function calculateRequiredFields(
visibleColumnObjects.forEach(column => {
if (column.computed) {
// For computed columns, add all required and optional fields
column.computed.required_fields?.forEach(field => requiredFieldsSet.add(field))
column.computed.optional_fields?.forEach(field => requiredFieldsSet.add(field))
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)
@@ -46,7 +51,7 @@ export function calculateRequiredFields(
// Check which relationship fields need to be added
const additionalRelationshipFields = visibleRelationshipFields.filter(field => {
const [relationName] = field.split('.')
const isAlreadyCovered = DEFAULT_RELATIONS.some(rel =>
const isAlreadyCovered = defaults.relations.some(rel =>
rel === `*${relationName}` || rel === relationName
)
return !isAlreadyCovered
@@ -54,7 +59,7 @@ export function calculateRequiredFields(
// Check which direct fields need to be added
const additionalDirectFields = visibleDirectFields.filter(field => {
const isAlreadyIncluded = DEFAULT_PROPERTIES.includes(field)
const isAlreadyIncluded = defaults.properties.includes(field)
return !isAlreadyIncluded
})
@@ -63,8 +68,8 @@ export function calculateRequiredFields(
// Combine default fields with additional needed fields
if (additionalFields.length > 0) {
return `${DEFAULT_FIELDS},${additionalFields.join(',')}`
return `${defaultFields},${additionalFields.join(',')}`
}
return DEFAULT_FIELDS
return defaultFields
}

View File

@@ -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,
}
}

View File

@@ -1,395 +1,16 @@
import { useState, useMemo, useCallback, useEffect } from "react"
import { Container, Button } from "@medusajs/ui"
import { keepPreviousData } from "@tanstack/react-query"
import { useTranslation } from "react-i18next"
import { useSearchParams } from "react-router-dom"
import { DataTable } from "../../../../../components/data-table"
import { useOrders } from "../../../../../hooks/api/orders"
import { useConfigurableOrderTableColumns } from "../../../../../hooks/table/columns/use-configurable-order-table-columns"
import { useOrderTableFilters } from "./use-order-table-filters"
import { useOrderTableQuery } from "../../../../../hooks/table/query/use-order-table-query"
import { useViewConfigurations, useViewConfiguration } from "../../../../../hooks/use-view-configurations"
import { useEntityColumns } from "../../../../../hooks/api/views"
import { useFeatureFlag } from "../../../../../providers/feature-flag-provider"
import { useColumnState } from "../../../../../hooks/table/columns/use-column-state"
import { useQueryParams } from "../../../../../hooks/use-query-params"
import { SaveViewDropdown } from "./components/save-view-dropdown"
import { SaveViewDialog } from "../../../../../components/table/save-view-dialog"
import { useRequiredFields } from "./hooks/use-required-fields"
const PAGE_SIZE = 20
const QUERY_PREFIX = "o"
function parseSortingState(value: string) {
return value.startsWith("-")
? { id: value.slice(1), desc: true }
: { id: value, desc: false }
}
import { ConfigurableDataTable } from "../../../../../components/table/configurable-data-table"
import { useOrderTableAdapter } from "./order-table-adapter"
export const ConfigurableOrderListTable = () => {
const { t } = useTranslation()
const isViewConfigEnabled = useFeatureFlag("view_configurations")
const {
activeView,
createView,
} = useViewConfigurations("orders")
const currentActiveView = activeView?.view_configuration || null
const { updateView } = useViewConfiguration("orders", currentActiveView?.id || "")
const { columns: apiColumns, isLoading: isLoadingColumns } = useEntityColumns("orders", {
enabled: isViewConfigEnabled,
})
const filters = useOrderTableFilters()
const queryParams = useQueryParams(
["q", "order", ...filters.map(f => f.id)],
QUERY_PREFIX
)
const [_, setSearchParams] = useSearchParams()
const {
visibleColumns,
columnOrder,
currentColumns,
setColumnOrder,
handleColumnVisibilityChange,
handleViewChange: originalHandleViewChange,
} = useColumnState(apiColumns, currentActiveView)
useEffect(() => {
if (!apiColumns) return
originalHandleViewChange(currentActiveView, apiColumns)
setSearchParams((prev) => {
const keysToDelete = Array.from(prev.keys()).filter(key =>
key.startsWith(QUERY_PREFIX + "_") || key === QUERY_PREFIX + "_q" || key === QUERY_PREFIX + "_order"
)
keysToDelete.forEach(key => prev.delete(key))
if (currentActiveView) {
const viewConfig = currentActiveView.configuration
if (viewConfig.filters) {
Object.entries(viewConfig.filters).forEach(([key, value]) => {
prev.set(`${QUERY_PREFIX}_${key}`, JSON.stringify(value))
})
}
if (viewConfig.sorting) {
const sortValue = viewConfig.sorting.desc
? `-${viewConfig.sorting.id}`
: viewConfig.sorting.id
prev.set(`${QUERY_PREFIX}_order`, sortValue)
}
if (viewConfig.search) {
prev.set(`${QUERY_PREFIX}_q`, viewConfig.search)
}
}
return prev
})
}, [currentActiveView, apiColumns])
const [debouncedHasConfigChanged, setDebouncedHasConfigChanged] = useState(false)
const hasConfigurationChanged = useMemo(() => {
const currentFilters: Record<string, any> = {}
filters.forEach(filter => {
if (queryParams[filter.id] !== undefined) {
currentFilters[filter.id] = JSON.parse(queryParams[filter.id] || "")
}
})
const currentSorting = queryParams.order ? parseSortingState(queryParams.order) : null
const currentSearch = queryParams.q || ""
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 || []
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
}
}
const normalizedCurrentSorting = currentSorting || undefined
const normalizedViewSorting = viewSorting || undefined
if (JSON.stringify(normalizedCurrentSorting) !== JSON.stringify(normalizedViewSorting)) {
return true
}
if (currentSearch !== viewSearch) {
return true
}
if (JSON.stringify(currentVisibleColumns) !== JSON.stringify(viewVisibleColumns)) {
return true
}
if (JSON.stringify(columnOrder) !== JSON.stringify(viewColumnOrder)) {
return true
}
} else {
if (Object.keys(currentFilters).length > 0) return true
if (currentSorting !== null) return true
if (currentSearch !== "") return true
if (apiColumns) {
const currentVisibleSet = new Set(
Object.entries(visibleColumns)
.filter(([_, isVisible]) => isVisible)
.map(([field]) => field)
)
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, filters, queryParams, apiColumns])
useEffect(() => {
const timer = setTimeout(() => {
setDebouncedHasConfigChanged(hasConfigurationChanged)
}, 50)
return () => clearTimeout(timer)
}, [hasConfigurationChanged])
const handleClearConfiguration = useCallback(() => {
if (apiColumns) {
originalHandleViewChange(currentActiveView, apiColumns)
}
setSearchParams((prev) => {
const keysToDelete = Array.from(prev.keys()).filter(key =>
key.startsWith(QUERY_PREFIX + "_") || key === QUERY_PREFIX + "_q" || key === QUERY_PREFIX + "_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(`${QUERY_PREFIX}_${key}`, JSON.stringify(value))
})
}
if (viewConfig.sorting) {
const sortValue = viewConfig.sorting.desc
? `-${viewConfig.sorting.id}`
: viewConfig.sorting.id
prev.set(`${QUERY_PREFIX}_order`, sortValue)
}
if (viewConfig.search) {
prev.set(`${QUERY_PREFIX}_q`, viewConfig.search)
}
}
return prev
})
}, [currentActiveView, apiColumns])
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])
const [saveDialogOpen, setSaveDialogOpen] = useState(false)
const [editingView, setEditingView] = useState<any>(null)
const handleSaveAsDefault = async () => {
try {
if (currentActiveView?.is_system_default) {
await updateView.mutateAsync({
name: currentActiveView.name || null,
configuration: {
visible_columns: currentColumns.visible,
column_order: currentColumns.order,
filters: currentConfiguration.filters || {},
sorting: currentConfiguration.sorting || null,
search: currentConfiguration.search || "",
}
})
} else {
await createView.mutateAsync({
name: "Default",
is_system_default: true,
set_active: true,
configuration: {
visible_columns: currentColumns.visible,
column_order: currentColumns.order,
filters: currentConfiguration.filters || {},
sorting: currentConfiguration.sorting || null,
search: currentConfiguration.search || "",
}
})
}
} catch (_) {
// Error is handled by the hook
}
}
const handleUpdateExisting = async () => {
if (!currentActiveView) return
try {
await updateView.mutateAsync({
name: currentActiveView.name,
configuration: {
visible_columns: currentColumns.visible,
column_order: currentColumns.order,
filters: currentConfiguration.filters || {},
sorting: currentConfiguration.sorting || null,
search: currentConfiguration.search || "",
}
})
} catch (_) {
// Error is handled by the hook
}
}
const handleSaveAsNew = () => {
setSaveDialogOpen(true)
setEditingView(null)
}
const requiredFields = useRequiredFields(apiColumns, visibleColumns)
const filterBarContent = debouncedHasConfigChanged ? (
<>
<Button
variant="secondary"
size="small"
type="button"
onClick={handleClearConfiguration}
>
{t("actions.clear")}
</Button>
<SaveViewDropdown
isDefaultView={currentActiveView?.is_system_default || !currentActiveView}
currentViewId={currentActiveView?.id}
currentViewName={currentActiveView?.name}
onSaveAsDefault={handleSaveAsDefault}
onUpdateExisting={handleUpdateExisting}
onSaveAsNew={handleSaveAsNew}
/>
</>
) : null
const { searchParams } = useOrderTableQuery({
pageSize: PAGE_SIZE,
prefix: QUERY_PREFIX,
})
const { orders, count, isError, error, isLoading } = useOrders(
{
fields: requiredFields,
...searchParams,
},
{
placeholderData: keepPreviousData,
}
)
const columns = useConfigurableOrderTableColumns(apiColumns)
if (isError) {
throw error
}
const orderAdapter = useOrderTableAdapter()
return (
<Container className="divide-y p-0">
<DataTable
data={orders ?? []}
columns={columns}
filters={filters}
getRowId={(row) => row.id}
rowCount={count}
enablePagination
enableSearch
pageSize={PAGE_SIZE}
isLoading={isLoading || isLoadingColumns}
layout="fill"
heading={t("orders.domain")}
enableColumnVisibility={isViewConfigEnabled}
initialColumnVisibility={visibleColumns}
onColumnVisibilityChange={handleColumnVisibilityChange}
columnOrder={columnOrder}
onColumnOrderChange={setColumnOrder}
enableViewSelector={isViewConfigEnabled}
entity="orders"
currentColumns={currentColumns}
filterBarContent={filterBarContent}
rowHref={(row) => `/orders/${row.id}`}
emptyState={{
empty: {
heading: t("orders.list.noRecordsMessage"),
}
}}
prefix={QUERY_PREFIX}
/>
{saveDialogOpen && (
<SaveViewDialog
entity="orders"
currentColumns={currentColumns}
currentConfiguration={currentConfiguration}
editingView={editingView}
onClose={() => {
setSaveDialogOpen(false)
setEditingView(null)
}}
onSaved={() => {
setSaveDialogOpen(false)
setEditingView(null)
}}
/>
)}
</Container>
<ConfigurableDataTable
adapter={orderAdapter}
heading={t("orders.domain")}
layout="fill"
/>
)
}

View File

@@ -1,12 +0,0 @@
import { useMemo } from "react"
import { HttpTypes } from "@medusajs/types"
import { calculateRequiredFields } from "../utils/field-utils"
export function useRequiredFields(
apiColumns: any[] | undefined,
visibleColumns: Record<string, boolean>
): string {
return useMemo(() => {
return calculateRequiredFields(apiColumns, visibleColumns)
}, [apiColumns, visibleColumns])
}

View File

@@ -0,0 +1,68 @@
import { HttpTypes } from "@medusajs/types"
import { createTableAdapter, TableAdapter } from "../../../../../lib/table/table-adapters"
import { useOrders } from "../../../../../hooks/api/orders"
import { useOrderTableFilters } from "./use-order-table-filters"
import { orderColumnAdapter } from "../../../../../lib/table/entity-adapters"
/**
* Create the order table adapter with all order-specific logic
*/
export function createOrderTableAdapter(): TableAdapter<HttpTypes.AdminOrder> {
return createTableAdapter<HttpTypes.AdminOrder>({
entity: "orders",
queryPrefix: "o",
pageSize: 20,
columnAdapter: orderColumnAdapter,
useData: (fields, params) => {
const { orders, count, isError, error, isLoading } = useOrders(
{
fields,
...params,
},
{
placeholderData: (previousData, previousQuery) => {
// Only keep placeholder data if the fields haven't changed
const prevFields = previousQuery?.[previousQuery.length - 1]?.query?.fields
if (prevFields && prevFields !== fields) {
// Fields changed, don't use placeholder data
return undefined
}
// Fields are the same, keep previous data for smooth transitions
return previousData
},
}
)
return {
data: orders,
count,
isLoading,
isError,
error,
}
},
getRowHref: (row) => `/orders/${row.id}`,
emptyState: {
empty: {
heading: "No orders found",
}
}
})
}
/**
* Hook to get the order table adapter with filters
*/
export function useOrderTableAdapter(): TableAdapter<HttpTypes.AdminOrder> {
const filters = useOrderTableFilters()
const adapter = createOrderTableAdapter()
// Add dynamic filters to the adapter
return {
...adapter,
filters,
}
}

View File

@@ -1,71 +0,0 @@
import { HttpTypes } from "@medusajs/types"
export enum ColumnAlignment {
LEFT = "left",
CENTER = "center",
RIGHT = "right",
}
export const DEFAULT_COLUMN_ORDER = 500
/**
* Determines the appropriate column alignment based on the column metadata
*/
export function getColumnAlignment(column: any): ColumnAlignment {
// Currency columns should be right-aligned
if (column.semantic_type === "currency" || column.data_type === "currency") {
return ColumnAlignment.RIGHT
}
// Number columns should be right-aligned (except identifiers)
if (column.data_type === "number" && column.context !== "identifier") {
return ColumnAlignment.RIGHT
}
// Total/amount/price columns should be right-aligned
if (
column.field.includes("total") ||
column.field.includes("amount") ||
column.field.includes("price")
) {
return ColumnAlignment.RIGHT
}
// Country columns should be center-aligned
if (column.field === "country" || column.field.includes("country_code")) {
return ColumnAlignment.CENTER
}
// Default to left alignment
return ColumnAlignment.LEFT
}
/**
* Gets the initial column visibility state from API columns
*/
export function getInitialColumnVisibility(
apiColumns: any[]
): Record<string, boolean> {
const visibility: Record<string, boolean> = {}
apiColumns.forEach(column => {
visibility[column.field] = column.default_visible
})
return visibility
}
/**
* Gets the initial column order from API columns
*/
export function getInitialColumnOrder(
apiColumns: any[]
): string[] {
const sortedColumns = [...apiColumns].sort((a, b) => {
const orderA = a.default_order ?? DEFAULT_COLUMN_ORDER
const orderB = b.default_order ?? DEFAULT_COLUMN_ORDER
return orderA - orderB
})
return sortedColumns.map(col => col.field)
}