feat(admin): add configurable order views (#13211)

Adds support for configurable order views.

https://github.com/user-attachments/assets/ed4a5f61-1667-4ed7-9478-423894f3eba6
This commit is contained in:
Sebastian Rindom
2025-09-01 19:04:18 +02:00
committed by GitHub
parent f8d8eeace1
commit c717535ca2
22 changed files with 1735 additions and 384 deletions
@@ -1,10 +1,16 @@
import { useState, useCallback, useMemo, useEffect, useRef } from "react"
import { HttpTypes } from "@medusajs/types"
// import { HttpTypes } from "@medusajs/types"
import type { ViewConfiguration } from "../../../hooks/use-view-configurations"
interface ColumnState {
visibility: Record<string, boolean>
order: string[]
}
interface UseColumnStateReturn {
visibleColumns: Record<string, boolean>
columnOrder: string[]
columnState: ColumnState
currentColumns: {
visible: string[]
order: string[]
@@ -12,7 +18,10 @@ interface UseColumnStateReturn {
setVisibleColumns: (visibility: Record<string, boolean>) => void
setColumnOrder: (order: string[]) => void
handleColumnVisibilityChange: (visibility: Record<string, boolean>) => void
handleViewChange: (view: ViewConfiguration | null, apiColumns: HttpTypes.AdminViewColumn[]) => void
handleViewChange: (
view: ViewConfiguration | null,
apiColumns: HttpTypes.AdminViewColumn[]
) => void
initializeColumns: (apiColumns: HttpTypes.AdminViewColumn[]) => void
}
@@ -21,30 +30,42 @@ export function useColumnState(
activeView?: ViewConfiguration | null
): UseColumnStateReturn {
// Initialize state lazily to avoid unnecessary re-renders
const [visibleColumns, setVisibleColumns] = useState<Record<string, boolean>>(() => {
if (apiColumns?.length && activeView) {
// If there's an active view, initialize with its configuration
const visibility: Record<string, boolean> = {}
apiColumns.forEach(column => {
visibility[column.field] = activeView.configuration.visible_columns.includes(column.field)
})
return visibility
} else if (apiColumns?.length) {
return getInitialColumnVisibility(apiColumns)
const [visibleColumns, setVisibleColumns] = useState<Record<string, boolean>>(
() => {
if (apiColumns?.length && activeView?.configuration) {
// If there's an active view, initialize with its configuration
const visibility: Record<string, boolean> = {}
apiColumns.forEach((column) => {
visibility[column.field] =
activeView.configuration.visible_columns?.includes(column.field) ||
false
})
return visibility
} else if (apiColumns?.length) {
return getInitialColumnVisibility(apiColumns)
}
return {}
}
return {}
})
)
const [columnOrder, setColumnOrder] = useState<string[]>(() => {
if (activeView) {
if (activeView?.configuration?.column_order) {
// If there's an active view, use its column order
return activeView.configuration.column_order || []
return activeView.configuration.column_order
} else if (apiColumns?.length) {
return getInitialColumnOrder(apiColumns)
}
return []
})
const columnState = useMemo<ColumnState>(
() => ({
visibility: visibleColumns,
order: columnOrder,
}),
[visibleColumns, columnOrder]
)
const currentColumns = useMemo(() => {
const visible = Object.entries(visibleColumns)
.filter(([_, isVisible]) => isVisible)
@@ -56,66 +77,89 @@ export function useColumnState(
}
}, [visibleColumns, columnOrder])
const handleColumnVisibilityChange = useCallback((visibility: Record<string, boolean>) => {
setVisibleColumns(visibility)
}, [])
const handleColumnVisibilityChange = useCallback(
(visibility: Record<string, boolean>) => {
setVisibleColumns(visibility)
},
[]
)
const handleViewChange = useCallback((
view: ViewConfiguration | null,
apiColumns: HttpTypes.AdminViewColumn[]
) => {
if (view) {
// Apply view configuration
const newVisibility: Record<string, boolean> = {}
apiColumns.forEach(column => {
newVisibility[column.field] = view.configuration.visible_columns.includes(column.field)
})
setVisibleColumns(newVisibility)
setColumnOrder(view.configuration.column_order)
} else {
// Reset to default visibility when no view is selected
setVisibleColumns(getInitialColumnVisibility(apiColumns))
setColumnOrder(getInitialColumnOrder(apiColumns))
}
}, [])
const handleViewChange = useCallback(
(
view: ViewConfiguration | null,
apiColumns: HttpTypes.AdminViewColumn[]
) => {
if (view?.configuration) {
// Apply view configuration
const newVisibility: Record<string, boolean> = {}
apiColumns.forEach((column) => {
newVisibility[column.field] =
view.configuration.visible_columns?.includes(column.field) || false
})
setVisibleColumns(newVisibility)
setColumnOrder(view.configuration.column_order || [])
} else {
// Reset to default visibility when no view is selected
setVisibleColumns(getInitialColumnVisibility(apiColumns))
setColumnOrder(getInitialColumnOrder(apiColumns))
}
},
[]
)
const initializeColumns = useCallback((apiColumns: HttpTypes.AdminViewColumn[]) => {
// Only initialize if we don't already have column state
if (Object.keys(visibleColumns).length === 0) {
setVisibleColumns(getInitialColumnVisibility(apiColumns))
}
if (columnOrder.length === 0) {
setColumnOrder(getInitialColumnOrder(apiColumns))
}
}, [])
const initializeColumns = useCallback(
(apiColumns: HttpTypes.AdminViewColumn[]) => {
// Only initialize if we don't already have column state
if (Object.keys(visibleColumns).length === 0) {
setVisibleColumns(getInitialColumnVisibility(apiColumns))
}
if (columnOrder.length === 0) {
setColumnOrder(getInitialColumnOrder(apiColumns))
}
},
[]
)
// Track previous active view to detect changes
const prevActiveViewRef = useRef<ViewConfiguration | null | undefined>()
// Sync local state when active view updates (e.g., after saving)
// Sync local state when active view changes
useEffect(() => {
if (apiColumns?.length && activeView && prevActiveViewRef.current) {
// Check if the active view has been updated (same ID but different updated_at)
if (
prevActiveViewRef.current.id === activeView.id &&
if (apiColumns?.length) {
// Check if this is a different view or an update to the same view
const viewChanged = prevActiveViewRef.current?.id !== activeView?.id
const viewUpdated =
activeView &&
prevActiveViewRef.current?.id === activeView.id &&
prevActiveViewRef.current.updated_at !== activeView.updated_at
) {
// Sync local state with the updated view configuration
const newVisibility: Record<string, boolean> = {}
apiColumns.forEach(column => {
newVisibility[column.field] = activeView.configuration.visible_columns.includes(column.field)
})
setVisibleColumns(newVisibility)
setColumnOrder(activeView.configuration.column_order)
if (viewChanged || viewUpdated) {
if (activeView?.configuration) {
// Apply the active view's configuration
const newVisibility: Record<string, boolean> = {}
apiColumns.forEach((column) => {
newVisibility[column.field] =
activeView.configuration?.visible_columns?.includes(
column.field
) || false
})
setVisibleColumns(newVisibility)
setColumnOrder(activeView.configuration?.column_order || [])
} else {
// No active view - reset to defaults
setVisibleColumns(getInitialColumnVisibility(apiColumns))
setColumnOrder(getInitialColumnOrder(apiColumns))
}
}
}
prevActiveViewRef.current = activeView
}, [activeView, apiColumns])
return {
visibleColumns,
columnOrder,
columnState,
currentColumns,
setVisibleColumns,
setColumnOrder,
@@ -135,12 +179,16 @@ const DEFAULT_COLUMN_ORDER = 500
function getInitialColumnVisibility(
apiColumns: HttpTypes.AdminViewColumn[]
): Record<string, boolean> {
if (!apiColumns || apiColumns.length === 0) {
return {}
}
const visibility: Record<string, boolean> = {}
apiColumns.forEach(column => {
visibility[column.field] = column.default_visible
apiColumns.forEach((column) => {
visibility[column.field] = column.default_visible ?? true
})
return visibility
}
@@ -150,11 +198,15 @@ function getInitialColumnVisibility(
function getInitialColumnOrder(
apiColumns: HttpTypes.AdminViewColumn[]
): string[] {
if (!apiColumns || apiColumns.length === 0) {
return []
}
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)
}
return sortedColumns.map((col) => col.field)
}
@@ -0,0 +1,49 @@
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,234 @@
import { HttpTypes } from "@medusajs/types"
import { createColumnHelper } from "@tanstack/react-table"
import { useMemo } from "react"
import { useTranslation } from "react-i18next"
import {
DateCell,
DateHeader,
} from "../../../components/table/table-cells/common/date-cell"
import { CountryCell } from "../../../components/table/table-cells/order/country-cell"
import {
CustomerCell,
CustomerHeader,
} from "../../../components/table/table-cells/order/customer-cell"
import {
DisplayIdCell,
DisplayIdHeader,
} from "../../../components/table/table-cells/order/display-id-cell"
import {
FulfillmentStatusCell,
FulfillmentStatusHeader,
} from "../../../components/table/table-cells/order/fulfillment-status-cell"
import {
PaymentStatusCell,
PaymentStatusHeader,
} from "../../../components/table/table-cells/order/payment-status-cell"
import {
SalesChannelCell,
SalesChannelHeader,
} from "../../../components/table/table-cells/order/sales-channel-cell"
import {
TotalCell,
TotalHeader,
} from "../../../components/table/table-cells/order/total-cell"
import { TextCell, TextHeader } from "../../../components/table/table-cells/common/text-cell"
const columnHelper = createColumnHelper<HttpTypes.AdminOrder>()
/**
* Hook to build columns dynamically based on API columns response
*/
export const useOrderDataTableColumns = (
apiColumns: HttpTypes.AdminOrderColumn[] | undefined,
visibleColumns: string[]
) => {
const { t } = useTranslation()
return useMemo(() => {
if (!apiColumns || apiColumns.length === 0) {
// Return default columns if no API columns
return [
columnHelper.accessor("display_id", {
header: () => <DisplayIdHeader />,
cell: ({ getValue }) => {
const id = getValue()
return <DisplayIdCell displayId={id!} />
},
}),
columnHelper.accessor("created_at", {
header: () => <DateHeader />,
cell: ({ getValue }) => {
const date = new Date(getValue())
return <DateCell date={date} />
},
}),
columnHelper.accessor("customer", {
header: () => <CustomerHeader />,
cell: ({ getValue }) => {
const customer = getValue()
return <CustomerCell customer={customer} />
},
}),
columnHelper.accessor("sales_channel", {
header: () => <SalesChannelHeader />,
cell: ({ getValue }) => {
const channel = getValue()
return <SalesChannelCell channel={channel} />
},
}),
columnHelper.accessor("payment_status", {
header: () => <PaymentStatusHeader />,
cell: ({ getValue }) => {
const status = getValue()
return <PaymentStatusCell status={status} />
},
}),
columnHelper.accessor("fulfillment_status", {
header: () => <FulfillmentStatusHeader />,
cell: ({ getValue }) => {
const status = getValue()
return <FulfillmentStatusCell status={status} />
},
}),
columnHelper.accessor("total", {
header: () => <TotalHeader />,
cell: ({ getValue, row }) => {
const total = getValue()
const currencyCode = row.original.currency_code
return <TotalCell currencyCode={currencyCode} total={total} />
},
}),
columnHelper.display({
id: "country",
cell: ({ row }) => {
const country = row.original.shipping_address?.country
return <CountryCell country={country} />
},
}),
]
}
// Build columns from API response
return apiColumns
.filter((col) => visibleColumns.includes(col.id))
.sort((a, b) => {
const aIndex = visibleColumns.indexOf(a.id)
const bIndex = visibleColumns.indexOf(b.id)
return aIndex - bIndex
})
.map((col) => {
// Handle special columns with custom cells
switch (col.id) {
case "display_id":
return columnHelper.accessor("display_id", {
header: () => <DisplayIdHeader />,
cell: ({ getValue }) => {
const id = getValue()
return <DisplayIdCell displayId={id!} />
},
})
case "created_at":
case "updated_at":
return columnHelper.accessor(col.field as any, {
header: () => <DateHeader />,
cell: ({ getValue }) => {
const date = getValue() ? new Date(getValue() as string) : null
return date ? <DateCell date={date} /> : null
},
})
case "email":
return columnHelper.accessor("email", {
header: () => <TextHeader text={col.name} />,
cell: ({ getValue }) => {
const email = getValue()
return <TextCell text={email || ""} />
},
})
case "customer_display":
return columnHelper.accessor("customer", {
header: () => <CustomerHeader />,
cell: ({ getValue }) => {
const customer = getValue()
return <CustomerCell customer={customer} />
},
})
case "sales_channel.name":
return columnHelper.accessor("sales_channel", {
header: () => <SalesChannelHeader />,
cell: ({ getValue }) => {
const channel = getValue()
return <SalesChannelCell channel={channel} />
},
})
case "payment_status":
return columnHelper.accessor("payment_status", {
header: () => <PaymentStatusHeader />,
cell: ({ getValue }) => {
const status = getValue()
return <PaymentStatusCell status={status} />
},
})
case "fulfillment_status":
return columnHelper.accessor("fulfillment_status", {
header: () => <FulfillmentStatusHeader />,
cell: ({ getValue }) => {
const status = getValue()
return <FulfillmentStatusCell status={status} />
},
})
case "total":
return columnHelper.accessor("total", {
header: () => <TotalHeader />,
cell: ({ getValue, row }) => {
const total = getValue()
const currencyCode = row.original.currency_code
return <TotalCell currencyCode={currencyCode} total={total} />
},
})
case "country":
return columnHelper.display({
id: "country",
cell: ({ row }) => {
const country = row.original.shipping_address?.country
return <CountryCell country={country} />
},
})
default:
// Handle relationship fields (e.g., customer.email)
if (col.field.includes(".")) {
const [relation, field] = col.field.split(".")
return columnHelper.accessor((row: any) => {
const relationData = row[relation]
return relationData?.[field] || ""
}, {
id: col.id,
header: () => <TextHeader text={col.name} />,
cell: ({ getValue }) => {
const value = getValue()
return <TextCell text={value || ""} />
},
})
}
// Default text column
return columnHelper.accessor(col.field as any, {
header: () => <TextHeader text={col.name} />,
cell: ({ getValue }) => {
const value = getValue()
return <TextCell text={value || ""} />
},
})
}
})
}, [apiColumns, visibleColumns, t])
}