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 17:04:18 +00:00
committed by GitHub
parent f8d8eeace1
commit c717535ca2
22 changed files with 1735 additions and 384 deletions
@@ -10,10 +10,28 @@ import {
import { sdk } from "../../lib/client"
import { queryClient } from "../../lib/query-client"
import { queryKeysFactory } from "../../lib/query-key-factory"
import { queryKeysFactory, TQueryKey } from "../../lib/query-key-factory"
const VIEWS_QUERY_KEY = "views" as const
export const viewsQueryKeys = queryKeysFactory(VIEWS_QUERY_KEY)
const _viewsKeys = queryKeysFactory(VIEWS_QUERY_KEY) as TQueryKey<"views"> & {
columns: (entity: string) => any
active: (entity: string) => any
configurations: (entity: string, query?: any) => any
}
_viewsKeys.columns = function(entity: string) {
return [this.all, "columns", entity]
}
_viewsKeys.active = function(entity: string) {
return [this.detail(entity), "active"]
}
_viewsKeys.configurations = function(entity: string, query?: any) {
return [this.all, "configurations", entity, query]
}
export const viewsQueryKeys = _viewsKeys
// Generic hook to get columns for any entity
export const useEntityColumns = (entity: string, options?: Omit<
@@ -27,7 +45,7 @@ export const useEntityColumns = (entity: string, options?: Omit<
>) => {
const { data, ...rest } = useQuery({
queryFn: () => sdk.admin.views.columns(entity),
queryKey: viewsQueryKeys.list(entity),
queryKey: viewsQueryKeys.columns(entity),
...options,
})
@@ -52,7 +70,7 @@ export const useViewConfigurations = (
) => {
const { data, ...rest } = useQuery({
queryFn: () => sdk.admin.views.listConfigurations(entity, query),
queryKey: viewsQueryKeys.list(entity, query),
queryKey: viewsQueryKeys.configurations(entity, query),
...options,
})
@@ -80,12 +98,14 @@ export const useActiveViewConfiguration = (
"queryFn" | "queryKey"
>
) => {
const { data, ...rest } = useQuery({
const query = useQuery({
queryFn: () => sdk.admin.views.retrieveActiveConfiguration(entity),
queryKey: [viewsQueryKeys.detail(entity), "active"],
queryKey: viewsQueryKeys.active(entity),
...options,
})
const { data, ...rest } = query
return { ...data, ...rest }
}
@@ -113,6 +133,7 @@ export const useViewConfiguration = (
return { ...data, ...rest }
}
// Create view configuration with toast notifications
export const useCreateViewConfiguration = (
entity: string,
options?: UseMutationOptions<
@@ -122,22 +143,23 @@ export const useCreateViewConfiguration = (
>
) => {
return useMutation({
mutationFn: (payload: HttpTypes.AdminCreateViewConfiguration) =>
mutationFn: (payload: Omit<HttpTypes.AdminCreateViewConfiguration, "entity">) =>
sdk.admin.views.createConfiguration(entity, payload),
...options,
onSuccess: (data, variables, context) => {
queryClient.invalidateQueries({ queryKey: viewsQueryKeys.list(entity) })
queryClient.invalidateQueries({ queryKey: viewsQueryKeys.configurations(entity) })
// If set_active was true, also invalidate the active configuration
if ((variables as any).set_active) {
queryClient.invalidateQueries({
queryKey: [...viewsQueryKeys.detail(entity, "active")]
queryKey: viewsQueryKeys.active(entity)
})
}
options?.onSuccess?.(data, variables, context)
},
...options,
})
}
// Update view configuration
export const useUpdateViewConfiguration = (
entity: string,
id: string,
@@ -150,12 +172,12 @@ export const useUpdateViewConfiguration = (
return useMutation({
mutationFn: (payload: HttpTypes.AdminUpdateViewConfiguration) =>
sdk.admin.views.updateConfiguration(entity, id, payload),
...options,
onSuccess: (data, variables, context) => {
queryClient.invalidateQueries({ queryKey: viewsQueryKeys.list(entity) })
queryClient.invalidateQueries({ queryKey: viewsQueryKeys.configurations(entity) })
queryClient.invalidateQueries({ queryKey: viewsQueryKeys.detail(id) })
options?.onSuccess?.(data, variables, context)
},
...options,
})
}
@@ -171,19 +193,20 @@ export const useDeleteViewConfiguration = (
) => {
return useMutation({
mutationFn: () => sdk.admin.views.deleteConfiguration(entity, id),
...options,
onSuccess: (data, variables, context) => {
queryClient.invalidateQueries({ queryKey: viewsQueryKeys.list(entity) })
queryClient.invalidateQueries({ queryKey: viewsQueryKeys.configurations(entity) })
queryClient.invalidateQueries({ queryKey: viewsQueryKeys.detail(id) })
// Also invalidate active configuration as it might have changed
queryClient.invalidateQueries({
queryKey: [...viewsQueryKeys.detail(entity, "active")]
queryKey: viewsQueryKeys.active(entity)
})
options?.onSuccess?.(data, variables, context)
},
...options,
})
}
// Set active view configuration
export const useSetActiveViewConfiguration = (
entity: string,
options?: UseMutationOptions<
@@ -193,17 +216,23 @@ export const useSetActiveViewConfiguration = (
>
) => {
return useMutation({
mutationFn: (viewConfigurationId: string | null) =>
sdk.admin.views.setActiveConfiguration(entity, {
mutationFn: (viewConfigurationId: string | null) => {
return sdk.admin.views.setActiveConfiguration(entity, {
view_configuration_id: viewConfigurationId
}),
onSuccess: (data, variables, context) => {
queryClient.invalidateQueries({
queryKey: [...viewsQueryKeys.detail(entity, "active")]
})
queryClient.invalidateQueries({ queryKey: viewsQueryKeys.list(entity) })
options?.onSuccess?.(data, variables, context)
},
...options,
onSuccess: async (data, variables, context) => {
// Invalidate active configuration
await queryClient.invalidateQueries({
queryKey: viewsQueryKeys.active(entity)
})
// Also invalidate the list as the active status might be shown there
await queryClient.invalidateQueries({ queryKey: viewsQueryKeys.configurations(entity) })
options?.onSuccess?.(data, variables, context)
},
onError: (error, variables, context) => {
options?.onError?.(error, variables, context)
},
})
}
@@ -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])
}
@@ -1,5 +1,4 @@
import { useMemo } from "react"
import { HttpTypes } from "@medusajs/types"
import { toast } from "@medusajs/ui"
import { FetchError } from "@medusajs/js-sdk"
import { useFeatureFlag } from "../providers/feature-flag-provider"
@@ -12,13 +11,8 @@ import {
useSetActiveViewConfiguration as useSetActiveViewConfigurationBase,
} from "./api/views"
// Re-export the type for convenience
export type ViewConfiguration = HttpTypes.AdminViewConfigurationResponse
// Common error handler
const handleError = (error: Error, message?: string) => {
console.error("View configuration error:", error)
let errorMessage = message
if (!errorMessage) {
if (error instanceof FetchError) {
@@ -36,11 +30,12 @@ const handleError = (error: Error, message?: string) => {
export const useViewConfigurations = (entity: string) => {
const isViewConfigEnabled = useFeatureFlag("view_configurations")
// List views
const listViews = useViewConfigurationsBase(entity, { limit: 100 }, {
enabled: isViewConfigEnabled && !!entity,
})
// Active view
const activeView = useActiveViewConfigurationBase(entity, {
enabled: isViewConfigEnabled && !!entity,
})
@@ -57,7 +52,8 @@ export const useViewConfigurations = (entity: string) => {
// Set active view mutation
const setActiveView = useSetActiveViewConfigurationBase(entity, {
onSuccess: () => { },
onSuccess: () => {
},
onError: (error) => {
handleError(error, "Failed to update active view")
},
@@ -69,7 +65,7 @@ export const useViewConfigurations = (entity: string) => {
activeView,
createView,
setActiveView,
isDefaultViewActive: activeView.data?.is_default_active ?? true,
isDefaultViewActive: activeView?.is_default_active ?? true,
}), [
isViewConfigEnabled,
listViews,
@@ -79,6 +75,7 @@ export const useViewConfigurations = (entity: string) => {
])
}
// Hook for update/delete operations on a specific view
export const useViewConfiguration = (entity: string, viewId: string) => {
const updateView = useUpdateViewConfigurationBase(entity, viewId, {
onSuccess: () => {
@@ -91,7 +88,7 @@ export const useViewConfiguration = (entity: string, viewId: string) => {
const deleteView = useDeleteViewConfigurationBase(entity, viewId, {
onSuccess: () => {
toast.success("View deleted")
toast.success("View deleted successfully")
},
onError: (error) => {
handleError(error, "Failed to delete view")