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