feat(dashboard): configurable product views (#13408)

* feat: add a reusable configurable data table

* fix: cleanup

* fix: cleanup

* fix: cache invalidation

* fix: test

* fix: add configurable products

* feat: add configurable product table

* fix: build errors+table style

* fix: sticky header column

* add translations

* fix: cleanup counterenderer

* fix: formatting

* fix: client still skips nulls

* fix: test

* fix: cleanup

* fix: revert client bracket format

* fix: better typing

* fix: add placeholder data to product list
This commit is contained in:
Sebastian Rindom
2025-09-18 18:27:17 +02:00
committed by GitHub
parent 9563ee446f
commit 41047b3854
22 changed files with 865 additions and 433 deletions

View File

@@ -224,7 +224,7 @@ medusaIntegrationTestRunner({
id: "title",
name: "Title",
field: "title",
default_visible: true,
default_visible: false,
})
const handleColumn = response.data.columns.find(
@@ -235,7 +235,7 @@ medusaIntegrationTestRunner({
id: "handle",
name: "Handle",
field: "handle",
default_visible: true,
default_visible: false,
})
})
})

View File

@@ -66,12 +66,14 @@ interface DataTableProps<TData> {
filters?: DataTableFilter[]
commands?: DataTableCommand[]
action?: DataTableActionProps
actions?: DataTableActionProps[]
actionMenu?: DataTableActionMenuProps
rowCount?: number
getRowId: (row: TData) => string
enablePagination?: boolean
enableSearch?: boolean
autoFocusSearch?: boolean
enableFilterMenu?: boolean
rowHref?: (row: TData) => string
emptyState?: DataTableEmptyStateProps
heading?: string
@@ -105,12 +107,14 @@ export const DataTable = <TData,>({
filters,
commands,
action,
actions,
actionMenu,
getRowId,
rowCount = 0,
enablePagination = true,
enableSearch = true,
autoFocusSearch = false,
enableFilterMenu,
rowHref,
heading,
subHeading,
@@ -138,6 +142,7 @@ export const DataTable = <TData,>({
const effectiveEnableViewSelector = isViewConfigEnabled && enableViewSelector
const enableFiltering = filters && filters.length > 0
const showFilterMenu = enableFilterMenu !== undefined ? enableFilterMenu : enableFiltering
const enableCommands = commands && commands.length > 0
const enableSorting = columns.some((column) => column.enableSorting)
@@ -381,7 +386,7 @@ export const DataTable = <TData,>({
)}
</div>
<div className="flex items-center gap-x-2">
{enableFiltering && <UiDataTable.FilterMenu />}
{showFilterMenu && <UiDataTable.FilterMenu />}
{enableSorting && <UiDataTable.SortingMenu />}
{enableSearch && (
<div className="w-full md:w-auto">
@@ -392,7 +397,8 @@ export const DataTable = <TData,>({
</div>
)}
{actionMenu && <ActionMenu variant="primary" {...actionMenu} />}
{action && <DataTableAction {...action} />}
{actions && actions.length > 0 && <DataTableActions actions={actions} />}
{!actions && action && <DataTableAction {...action} />}
</div>
</div>
</UiDataTable.Toolbar>
@@ -505,3 +511,13 @@ const DataTableAction = ({
)
}
const DataTableActions = ({ actions }: { actions: DataTableActionProps[] }) => {
return (
<div className="flex items-center gap-x-2">
{actions.map((action, index) => (
<DataTableAction key={index} {...action} />
))}
</div>
)
}

View File

@@ -1,71 +1,45 @@
import React, { useState, ReactNode } from "react"
import { 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"
type DataTableActionProps = {
label: string
disabled?: boolean
} & (
| {
to: string
}
| {
onClick: () => void
}
)
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
actions?: DataTableActionProps[]
}
// 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>({
export function ConfigurableDataTable<TData>({
adapter,
heading,
subHeading,
pageSize: pageSizeProp,
queryPrefix: queryPrefixProp,
layout = "fill",
// actions, // Currently unused
actions,
}: ConfigurableDataTableProps<TData>) {
const { t } = useTranslation()
const [saveDialogOpen, setSaveDialogOpen] = useState(false)
@@ -77,7 +51,6 @@ function ConfigurableDataTableWithAdapter<TData>({
const pageSize = pageSizeProp || adapter.pageSize || 20
const queryPrefix = queryPrefixProp || adapter.queryPrefix || ""
// Get table configuration (single source of truth)
const {
activeView,
createView,
@@ -94,6 +67,7 @@ function ConfigurableDataTableWithAdapter<TData>({
isLoadingColumns,
apiColumns,
requiredFields,
queryParams,
} = useTableConfiguration({
entity,
pageSize,
@@ -101,29 +75,37 @@ function ConfigurableDataTableWithAdapter<TData>({
filters,
})
// Get query params for data fetching
const { searchParams } = useOrderTableQuery({
pageSize,
prefix: queryPrefix,
const parsedQueryParams = { ...queryParams }
filters.forEach(filter => {
const filterKey = filter.id
if (parsedQueryParams[filterKey] !== undefined) {
try {
parsedQueryParams[filterKey] = JSON.parse(parsedQueryParams[filterKey])
} catch {
// If parsing fails, keep the original value
}
}
})
// Fetch data using adapter
const searchParams = {
...parsedQueryParams,
fields: requiredFields,
limit: pageSize,
offset: parsedQueryParams.offset ? Number(parsedQueryParams.offset) : 0,
}
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) {
@@ -233,6 +215,8 @@ function ConfigurableDataTableWithAdapter<TData>({
}
}}
prefix={queryPrefix}
actions={actions}
enableFilterMenu={false}
/>
{saveDialogOpen && (
@@ -254,194 +238,3 @@ function ConfigurableDataTableWithAdapter<TData>({
</Container>
)
}
// Internal component that handles legacy mode
function ConfigurableDataTableLegacy<TData>(props: LegacyConfigurableDataTableProps<TData>) {
const { t } = useTranslation()
const [saveDialogOpen, setSaveDialogOpen] = useState(false)
const [editingView, setEditingView] = useState<any>(null)
const {
entity,
entityName,
data,
columns,
filters = [],
pageSize = 20,
queryPrefix = "",
getRowId,
rowHref,
heading,
subHeading,
emptyState,
isLoading = false,
rowCount = 0,
// actions, // Currently unused
layout = "fill",
} = props
// Get table configuration
const {
activeView,
createView,
updateView,
isViewConfigEnabled,
visibleColumns,
columnOrder,
currentColumns,
setColumnOrder,
handleColumnVisibilityChange,
currentConfiguration,
hasConfigurationChanged,
handleClearConfiguration,
isLoadingColumns,
} = useTableConfiguration({
entity,
pageSize,
queryPrefix,
filters,
})
// View save handlers
const handleSaveAsDefault = async () => {
try {
if (activeView?.is_system_default) {
await updateView.mutateAsync({
name: activeView.name || null,
configuration: {
visible_columns: currentColumns.visible,
column_order: currentColumns.order,
filters: currentConfiguration.filters || {},
sorting: currentConfiguration.sorting || null,
search: currentConfiguration.search || "",
}
})
} else {
await createView.mutateAsync({
name: "Default",
is_system_default: true,
set_active: true,
configuration: {
visible_columns: currentColumns.visible,
column_order: currentColumns.order,
filters: currentConfiguration.filters || {},
sorting: currentConfiguration.sorting || null,
search: currentConfiguration.search || "",
}
})
}
} catch (_) {
// Error is handled by the hook
}
}
const handleUpdateExisting = async () => {
if (!activeView) return
try {
await updateView.mutateAsync({
name: activeView.name,
configuration: {
visible_columns: currentColumns.visible,
column_order: currentColumns.order,
filters: currentConfiguration.filters || {},
sorting: currentConfiguration.sorting || null,
search: currentConfiguration.search || "",
}
})
} catch (_) {
// Error is handled by the hook
}
}
const handleSaveAsNew = () => {
setSaveDialogOpen(true)
setEditingView(null)
}
// Filter bar content with save controls
const filterBarContent = hasConfigurationChanged ? (
<>
<Button
variant="secondary"
size="small"
type="button"
onClick={handleClearConfiguration}
>
{t("actions.clear")}
</Button>
<SaveViewDropdown
isDefaultView={activeView?.is_system_default || !activeView}
currentViewId={activeView?.id}
currentViewName={activeView?.name}
onSaveAsDefault={handleSaveAsDefault}
onUpdateExisting={handleUpdateExisting}
onSaveAsNew={handleSaveAsNew}
/>
</>
) : null
return (
<Container className="divide-y p-0">
<DataTable
data={data}
columns={columns}
filters={filters}
getRowId={getRowId}
rowCount={rowCount}
enablePagination
enableSearch
pageSize={pageSize}
isLoading={isLoading || isLoadingColumns}
layout={layout}
heading={heading || entityName || (entity ? t(`${entity}.domain` as any) : "")}
subHeading={subHeading}
enableColumnVisibility={isViewConfigEnabled}
initialColumnVisibility={visibleColumns}
onColumnVisibilityChange={handleColumnVisibilityChange}
columnOrder={columnOrder}
onColumnOrderChange={setColumnOrder}
enableViewSelector={isViewConfigEnabled}
entity={entity}
currentColumns={currentColumns}
filterBarContent={filterBarContent}
rowHref={rowHref}
emptyState={emptyState || {
empty: {
heading: t(`${entity}.list.noRecordsMessage` as any),
}
}}
prefix={queryPrefix}
/>
{saveDialogOpen && (
<SaveViewDialog
entity={entity}
currentColumns={currentColumns}
currentConfiguration={currentConfiguration}
editingView={editingView}
onClose={() => {
setSaveDialogOpen(false)
setEditingView(null)
}}
onSaved={() => {
setSaveDialogOpen(false)
setEditingView(null)
}}
/>
)}
</Container>
)
}
// Main export that delegates to the appropriate component
export function ConfigurableDataTable<TData>(
props: ConfigurableDataTableProps<TData> | LegacyConfigurableDataTableProps<TData>
) {
// Check if using new adapter pattern or legacy props
if ('adapter' in props) {
return <ConfigurableDataTableWithAdapter<TData> {...props} />
} else {
return <ConfigurableDataTableLegacy<TData> {...props} />
}
}

View File

@@ -1,20 +1,22 @@
import React, { useMemo } from "react"
import { createDataTableColumnHelper } from "@medusajs/ui"
import { HttpTypes } from "@medusajs/types"
import { getDisplayStrategy, getEntityAccessor } from "../../../lib/table-display-utils"
import { useTranslation } from "react-i18next"
import { getCellRenderer, getColumnValue } from "../../../lib/table/cell-renderers"
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
getColumnAlignment?: (column: HttpTypes.AdminColumn) => "left" | "center" | "right"
getCustomAccessor?: (field: string, column: HttpTypes.AdminColumn) => any
transformCellValue?: (value: any, row: TData, column: HttpTypes.AdminColumn) => React.ReactNode
}
export function useConfigurableTableColumns<TData = any>(
entity: string,
apiColumns: HttpTypes.AdminViewColumn[] | undefined,
apiColumns: HttpTypes.AdminColumn[] | undefined,
adapter?: ColumnAdapter<TData>
) {
const columnHelper = createDataTableColumnHelper<TData>()
const { t } = useTranslation()
return useMemo(() => {
if (!apiColumns?.length) {
@@ -22,37 +24,45 @@ export function useConfigurableTableColumns<TData = any>(
}
return apiColumns.map(apiColumn => {
// Get the display strategy for this column
const displayStrategy = getDisplayStrategy(apiColumn)
let renderType = apiColumn.computed?.type
// 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)
if (!renderType) {
if (apiColumn.semantic_type === 'timestamp') {
renderType = 'timestamp'
} else if (apiColumn.field === 'display_id') {
renderType = 'display_id'
} else if (apiColumn.field === 'total') {
renderType = 'total'
} else if (apiColumn.semantic_type === 'currency') {
renderType = 'currency'
}
}
const renderer = getCellRenderer(
renderType,
apiColumn.data_type
)
// Determine header alignment
const headerAlign = adapter?.getColumnAlignment
? adapter.getColumnAlignment(apiColumn)
: getDefaultColumnAlignment(apiColumn)
const accessor = (row: TData) => getColumnValue(row, apiColumn)
return columnHelper.accessor(accessor, {
id: apiColumn.field,
header: () => apiColumn.name,
cell: ({ getValue, row }) => {
cell: ({ getValue, row }: { getValue: any, row: any }) => {
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)
const transformed = adapter.transformCellValue(value, row.original, apiColumn)
if (transformed !== null) {
return transformed
}
}
// Otherwise, use the display strategy to format the value
return displayStrategy(value, row.original)
return renderer(value, row.original, apiColumn, t)
},
meta: {
name: apiColumn.name,
@@ -63,21 +73,18 @@ export function useConfigurableTableColumns<TData = any>(
headerAlign, // Pass the header alignment to the DataTable
} as any)
})
}, [entity, apiColumns, adapter])
}, [entity, apiColumns, adapter, t])
}
function getDefaultColumnAlignment(column: HttpTypes.AdminViewColumn): "left" | "center" | "right" {
// Currency columns should be right-aligned
function getDefaultColumnAlignment(column: HttpTypes.AdminColumn): "left" | "center" | "right" {
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") ||
@@ -87,19 +94,16 @@ function getDefaultColumnAlignment(column: HttpTypes.AdminViewColumn): "left" |
) {
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")) {
if (column.computed?.type === "country_code" ||
column.field === "country" ||
column.field.includes("country_code")) {
return "center"
}
// Default to left alignment
return "left"
}
}

View File

@@ -84,6 +84,7 @@ export const useProductTableFilters = (
label: t("fields.type"),
type: "select",
multiple: true,
searchable: true,
options: product_types.map((t) => ({
label: t.value,
value: t.id,
@@ -99,6 +100,7 @@ export const useProductTableFilters = (
label: t("fields.tag"),
type: "select",
multiple: true,
searchable: true,
options: product_tags.map((t) => ({
label: t.value,
value: t.id,
@@ -114,6 +116,7 @@ export const useProductTableFilters = (
label: t("fields.salesChannel"),
type: "select",
multiple: true,
searchable: true,
options: sales_channels.map((s) => ({
label: s.name,
value: s.id,

View File

@@ -24,13 +24,10 @@ export interface UseTableConfigurationOptions {
}
export interface UseTableConfigurationReturn {
// View configuration
activeView: any
createView: any
updateView: any
isViewConfigEnabled: boolean
// Column state
visibleColumns: Record<string, boolean>
columnOrder: string[]
currentColumns: {
@@ -39,20 +36,12 @@ export interface UseTableConfigurationReturn {
}
setColumnOrder: (order: string[]) => void
handleColumnVisibilityChange: (visibility: Record<string, boolean>) => void
// Configuration state
currentConfiguration: TableConfiguration
hasConfigurationChanged: boolean
handleClearConfiguration: () => void
// API columns
apiColumns: HttpTypes.AdminViewColumn[] | undefined
apiColumns: HttpTypes.AdminColumn[] | undefined
isLoadingColumns: boolean
// Query params
queryParams: Record<string, any>
// Required fields for API calls
requiredFields: string
}
@@ -64,24 +53,20 @@ function parseSortingState(value: string) {
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
@@ -152,7 +137,7 @@ export function useTableConfiguration({
// Check if configuration has changed from view
const [debouncedHasConfigChanged, setDebouncedHasConfigChanged] = useState(false)
const hasConfigurationChanged = useMemo(() => {
const currentFilters = currentConfiguration.filters
const currentSorting = currentConfiguration.sorting
@@ -282,32 +267,21 @@ export function useTableConfiguration({
}, [entity, apiColumns, visibleColumns])
return {
// View configuration
activeView: currentActiveView,
createView,
updateView,
isViewConfigEnabled,
// Column state
visibleColumns,
columnOrder,
currentColumns,
setColumnOrder,
handleColumnVisibilityChange,
// Configuration state
currentConfiguration,
hasConfigurationChanged: debouncedHasConfigChanged,
handleClearConfiguration,
// API columns
apiColumns,
isLoadingColumns,
// Query params
queryParams,
// Required fields
requiredFields,
}
}
}

View File

@@ -2022,6 +2022,54 @@
"required": ["draft", "published", "proposed", "rejected"],
"additionalProperties": false
},
"columns": {
"type": "object",
"properties": {
"product_display": {
"type": "string"
},
"variants_count": {
"type": "string"
},
"sales_channels_display": {
"type": "string"
},
"collection": {
"type": "string"
},
"status": {
"type": "string"
},
"thumbnail": {
"type": "string"
},
"title": {
"type": "string"
},
"handle": {
"type": "string"
},
"created_at": {
"type": "string"
},
"updated_at": {
"type": "string"
}
},
"required": [
"product_display",
"variants_count",
"sales_channels_display",
"collection",
"status",
"thumbnail",
"title",
"handle",
"created_at",
"updated_at"
],
"additionalProperties": false
},
"fields": {
"type": "object",
"properties": {
@@ -2690,6 +2738,7 @@
"variantCount_other",
"deleteVariantWarning",
"productStatus",
"columns",
"fields",
"variant",
"options",

View File

@@ -536,6 +536,18 @@
"proposed": "Proposed",
"rejected": "Rejected"
},
"columns": {
"product_display": "Product",
"variants_count": "Variants",
"sales_channels_display": "Sales Channels",
"collection": "Collection",
"status": "Status",
"thumbnail": "Thumbnail",
"title": "Title",
"handle": "Handle",
"created_at": "Created",
"updated_at": "Updated"
},
"fields": {
"title": {
"label": "Title",

View File

@@ -0,0 +1,305 @@
import React from "react"
import { Badge, StatusBadge, Tooltip } from "@medusajs/ui"
import { HttpTypes } from "@medusajs/types"
import ReactCountryFlag from "react-country-flag"
import { getCountryByIso2 } from "../data/countries"
import { ProductCell } from "../../components/table/table-cells/product/product-cell"
import { CollectionCell } from "../../components/table/table-cells/product/collection-cell"
import { SalesChannelsCell } from "../../components/table/table-cells/product/sales-channels-cell"
import { VariantCell } from "../../components/table/table-cells/product/variant-cell"
import { ProductStatusCell } from "../../components/table/table-cells/product/product-status-cell"
import { DateCell } from "../../components/table/table-cells/common/date-cell"
import { DisplayIdCell } from "../../components/table/table-cells/order/display-id-cell"
import { TotalCell } from "../../components/table/table-cells/order/total-cell"
import { MoneyAmountCell } from "../../components/table/table-cells/common/money-amount-cell"
import { TFunction } from "i18next"
export type CellRenderer<TData = any> = (
value: any,
row: TData,
column: HttpTypes.AdminColumn,
t: TFunction
) => React.ReactNode
export type RendererRegistry = Map<string, CellRenderer>
const cellRenderers: RendererRegistry = new Map()
const getNestedValue = (obj: any, path: string) => {
return path.split('.').reduce((current, key) => current?.[key], obj)
}
const TextRenderer: CellRenderer = (value, _row, _column, _t) => {
if (value === null || value === undefined) return '-'
return String(value)
}
const CountRenderer: CellRenderer = (value, _row, _column, t) => {
const items = value || []
const count = Array.isArray(items) ? items.length : 0
return t('general.items', { count })
}
const StatusRenderer: CellRenderer = (value, row, column, t) => {
if (!value) return '-'
if (column.field === 'status' && row.status && (row.handle || row.is_giftcard !== undefined)) {
return <ProductStatusCell status={row.status} />
}
// Generic status badge
const getStatusColor = (status: string) => {
switch (status?.toLowerCase()) {
case 'active':
case 'published':
case 'fulfilled':
case 'paid':
return 'green'
case 'pending':
case 'proposed':
case 'processing':
return 'orange'
case 'draft':
return 'grey'
case 'rejected':
case 'failed':
case 'canceled':
return 'red'
default:
return 'grey'
}
}
// Use existing translation keys where available
const getTranslatedStatus = (status: string): string => {
if (!t) return status
const lowerStatus = status.toLowerCase()
switch (lowerStatus) {
case 'active':
return t('general.active', 'Active') as string
case 'published':
return t('products.productStatus.published', 'Published') as string
case 'draft':
return t('orders.status.draft', 'Draft') as string
case 'pending':
return t('orders.status.pending', 'Pending') as string
case 'canceled':
return t('orders.status.canceled', 'Canceled') as string
default:
// Try generic status translation with fallback
return t(`status.${lowerStatus}`, status) as string
}
}
const translatedValue = getTranslatedStatus(value)
return (
<StatusBadge color={getStatusColor(value)}>
{translatedValue}
</StatusBadge>
)
}
const BadgeListRenderer: CellRenderer = (value, row, column, t) => {
// For sales channels
if (column.field === 'sales_channels_display' || column.field === 'sales_channels') {
return <SalesChannelsCell salesChannels={row.sales_channels} />
}
// Generic badge list
if (!Array.isArray(value)) return '-'
const items = value.slice(0, 2)
const remaining = value.length - 2
return (
<div className="flex gap-1">
{items.map((item, index) => (
<Badge key={index} size="xsmall">
{typeof item === 'string' ? item : item.name || item.title || '-'}
</Badge>
))}
{remaining > 0 && (
<Badge size="xsmall" color="grey">
{t ? t('general.plusCountMore', '+ {{count}} more', { count: remaining }) : `+${remaining}`}
</Badge>
)}
</div>
)
}
const ProductInfoRenderer: CellRenderer = (_, row, _column, _t) => {
return <ProductCell product={row} />
}
const CollectionRenderer: CellRenderer = (_, row, _column, _t) => {
return <CollectionCell collection={row.collection} />
}
const VariantsRenderer: CellRenderer = (_, row, _column, _t) => {
return <VariantCell variants={row.variants} />
}
// Order-specific renderers
const CustomerNameRenderer: CellRenderer = (_, row, _column, t) => {
if (row.customer?.first_name || row.customer?.last_name) {
const fullName = `${row.customer.first_name || ''} ${row.customer.last_name || ''}`.trim()
if (fullName) return fullName
}
// Fall back to email
if (row.customer?.email) {
return row.customer.email
}
// Fall back to phone
if (row.customer?.phone) {
return row.customer.phone
}
return t ? t('customers.guest', 'Guest') : 'Guest'
}
const AddressSummaryRenderer: CellRenderer = (_, row, column, _t) => {
let address = null
if (column.field === 'shipping_address_display') {
address = row.shipping_address
} else if (column.field === 'billing_address_display') {
address = row.billing_address
} else {
address = row.shipping_address || row.billing_address
}
if (!address) return '-'
const parts = []
if (address.address_1) {
parts.push(address.address_1)
}
const locationParts = []
if (address.city) locationParts.push(address.city)
if (address.province) locationParts.push(address.province)
if (address.postal_code) locationParts.push(address.postal_code)
if (locationParts.length > 0) {
parts.push(locationParts.join(', '))
}
if (address.country_code) {
parts.push(address.country_code.toUpperCase())
}
return parts.join(' • ') || '-'
}
const CountryCodeRenderer: CellRenderer = (_, row, _column, _t) => {
const countryCode = row.shipping_address?.country_code
if (!countryCode) return <div className="flex w-full justify-center">-</div>
const country = getCountryByIso2(countryCode)
const displayName = country?.display_name || countryCode.toUpperCase()
return (
<div className="flex w-full items-center justify-center">
<Tooltip content={displayName}>
<div className="flex size-4 items-center justify-center overflow-hidden rounded-sm">
<ReactCountryFlag
countryCode={countryCode.toUpperCase()}
svg
style={{
width: "16px",
height: "16px",
}}
aria-label={displayName}
/>
</div>
</Tooltip>
</div>
)
}
const DateRenderer: CellRenderer = (value, _row, _column, _t) => {
return <DateCell date={value} />
}
const DisplayIdRenderer: CellRenderer = (value, _row, _column, _t) => {
return <DisplayIdCell displayId={value} />
}
const CurrencyRenderer: CellRenderer = (value, row, _column, _t) => {
const currencyCode = row.currency_code || 'USD'
return <MoneyAmountCell currencyCode={currencyCode} amount={value} align="right" />
}
const TotalRenderer: CellRenderer = (value, row, _column, _t) => {
const currencyCode = row.currency_code || 'USD'
return <TotalCell currencyCode={currencyCode} total={value} />
}
// Register built-in renderers
cellRenderers.set('text', TextRenderer)
cellRenderers.set('count', CountRenderer)
cellRenderers.set('status', StatusRenderer)
cellRenderers.set('badge_list', BadgeListRenderer)
cellRenderers.set('date', DateRenderer)
cellRenderers.set('timestamp', DateRenderer)
cellRenderers.set('currency', CurrencyRenderer)
cellRenderers.set('total', TotalRenderer)
// Register product-specific renderers
cellRenderers.set('product_info', ProductInfoRenderer)
cellRenderers.set('collection', CollectionRenderer)
cellRenderers.set('variants', VariantsRenderer)
cellRenderers.set('sales_channels_list', BadgeListRenderer)
// Register order-specific renderers
cellRenderers.set('customer_name', CustomerNameRenderer)
cellRenderers.set('address_summary', AddressSummaryRenderer)
cellRenderers.set('country_code', CountryCodeRenderer)
cellRenderers.set('display_id', DisplayIdRenderer)
export function getCellRenderer(
renderType?: string,
dataType?: string
): CellRenderer {
if (renderType && cellRenderers.has(renderType)) {
return cellRenderers.get(renderType)!
}
switch (dataType) {
case 'number':
case 'string':
return TextRenderer
case 'date':
return DateRenderer
case 'boolean':
return (value, _row, _column, t) => {
if (t) {
return value ? t('fields.yes', 'Yes') : t('fields.no', 'No')
}
return value ? 'Yes' : 'No'
}
case 'enum':
return StatusRenderer
case 'currency':
return CurrencyRenderer
default:
return TextRenderer
}
}
export function registerCellRenderer(type: string, renderer: CellRenderer) {
cellRenderers.set(type, renderer)
}
export function getColumnValue(row: any, column: HttpTypes.AdminColumn): any {
if (column.computed) {
return row
}
return getNestedValue(row, column.field)
}

View File

@@ -1,55 +1,98 @@
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"
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"
if (column.field === "product_display") {
return "left"
}
if (column.field === "collection.title") {
return "left"
}
if (column.field === "sales_channels_display") {
return "left"
}
if (column.field === "variants_count") {
return "left"
}
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 "left"
}
if (column.computed?.type === "product_info") {
return "left"
}
if (column.computed?.type === "count") {
return "left"
}
if (column.computed?.type === "sales_channels_list") {
return "left"
}
return "left"
},
transformCellValue: (value, row, column) => {
// Custom transformation for product-specific fields
if (column.field === "variants_count") {
return `${value || 0} variants`
transformCellValue: (_value, row, column) => {
if (column.field === "variants_count" || column.computed?.type === "count") {
const count = Array.isArray(row.variants) ? row.variants.length : 0
return `${count} ${count === 1 ? 'variant' : 'variants'}`
}
if (column.field === "status" && value === "draft") {
return <span className="text-ui-fg-muted">Draft</span>
if (column.field === "product_display" || column.computed?.type === "product_info") {
return null
}
// Default to standard display
if (column.field === "sales_channels_display" || column.computed?.type === "sales_channels_list") {
return null
}
if (column.field === "status") {
return null
}
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"
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
transformCellValue: (_value, row, column) => {
if (column.field === "name") {
const { first_name, last_name } = row
if (first_name || last_name) {
@@ -57,23 +100,30 @@ export const customerColumnAdapter: ColumnAdapter<HttpTypes.AdminCustomer> = {
}
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"
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,
@@ -83,7 +133,6 @@ export const entityAdapters = {
export type EntityType = keyof typeof entityAdapters
// Helper function to get adapter for an entity
export function getEntityAdapter<TData = any>(entity: string): ColumnAdapter<TData> | undefined {
return entityAdapters[entity as EntityType] as ColumnAdapter<TData>
}
}

View File

@@ -6,7 +6,7 @@ export const ENTITY_DEFAULT_FIELDS = {
orders: {
properties: [
"id",
"status",
"status",
"created_at",
"email",
"display_id",
@@ -15,22 +15,14 @@ export const ENTITY_DEFAULT_FIELDS = {
"total",
"currency_code",
],
relations: ["*customer", "*sales_channel"]
relations: ["*customer", "*sales_channel"],
},
products: {
properties: [
"id",
"title",
"handle",
"status",
"created_at",
"updated_at",
"thumbnail",
],
relations: ["*variants", "*categories", "*collections"]
properties: ["id", "title", "handle", "status", "thumbnail"],
relations: ["collection.title", "*sales_channels", "*variants"],
},
customers: {
properties: [
"id",
@@ -41,9 +33,9 @@ export const ENTITY_DEFAULT_FIELDS = {
"updated_at",
"has_account",
],
relations: ["*groups"]
relations: ["*groups"],
},
inventory: {
properties: [
"id",
@@ -55,14 +47,14 @@ export const ENTITY_DEFAULT_FIELDS = {
"created_at",
"updated_at",
],
relations: ["*location_levels"]
relations: ["*location_levels"],
},
// Default configuration for entities without specific defaults
default: {
properties: ["id", "created_at", "updated_at"],
relations: []
}
relations: [],
},
} as const
export type EntityType = keyof typeof ENTITY_DEFAULT_FIELDS
@@ -71,10 +63,11 @@ 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
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(",")
formatted: [...config.properties, ...config.relations].join(","),
}
}
}

View File

@@ -1,4 +1,8 @@
import { DataTableColumnDef, DataTableEmptyStateProps, DataTableFilter } from "@medusajs/ui"
import {
DataTableColumnDef,
DataTableEmptyStateProps,
DataTableFilter,
} from "@medusajs/ui"
import { ColumnAdapter } from "../../hooks/table/columns/use-configurable-table-columns"
/**
@@ -15,9 +19,12 @@ export interface TableAdapter<TData> {
* Hook to fetch data with the calculated required fields.
* Called inside ConfigurableDataTable with the fields and search params.
*/
useData: (fields: string, params: any) => {
useData: (
fields: string,
params: any
) => {
data: TData[] | undefined
count: number
count: number | undefined
isLoading: boolean
isError: boolean
error: any
@@ -39,7 +46,7 @@ export interface TableAdapter<TData> {
filters?: DataTableFilter[]
/**
* Transform API columns to table columns.
* Transform API columns to table columns.
* If not provided, will use default column generation.
*/
getColumns?: (apiColumns: any[]) => DataTableColumnDef<TData, any>[]
@@ -84,4 +91,4 @@ export function createTableAdapter<TData>(
queryPrefix: "",
...adapter,
}
}
}

View File

@@ -0,0 +1,26 @@
import { useTranslation } from "react-i18next"
import { Outlet, useLocation } from "react-router-dom"
import { ConfigurableDataTable } from "../../../../../components/table/configurable-data-table"
import { useProductTableAdapter } from "./product-table-adapter"
export const ConfigurableProductListTable = () => {
const { t } = useTranslation()
const location = useLocation()
const adapter = useProductTableAdapter()
return (
<>
<ConfigurableDataTable
adapter={adapter}
heading={t("products.domain")}
actions={[
{ label: t("actions.export"), to: `export${location.search}` },
{ label: t("actions.import"), to: "import" },
{ label: t("actions.create"), to: "create" }
]}
/>
<Outlet />
</>
)
}

View File

@@ -18,12 +18,20 @@ import { useProductTableFilters } from "../../../../../hooks/table/filters/use-p
import { useProductTableQuery } from "../../../../../hooks/table/query/use-product-table-query"
import { useDataTable } from "../../../../../hooks/use-data-table"
import { productsLoader } from "../../loader"
import { useFeatureFlag } from "../../../../../providers/feature-flag-provider"
import { ConfigurableProductListTable } from "./configurable-product-list-table"
const PAGE_SIZE = 20
export const ProductListTable = () => {
const { t } = useTranslation()
const location = useLocation()
const isViewConfigEnabled = useFeatureFlag("view_configurations")
// If feature flag is enabled, use the new configurable table
if (isViewConfigEnabled) {
return <ConfigurableProductListTable />
}
const initialData = useLoaderData() as Awaited<
ReturnType<ReturnType<typeof productsLoader>>

View File

@@ -0,0 +1,51 @@
import { HttpTypes } from "@medusajs/types"
import { useProducts } from "../../../../../hooks/api/products"
import { productColumnAdapter } from "../../../../../lib/table/entity-adapters"
import { createTableAdapter, TableAdapter } from "../../../../../lib/table/table-adapters"
import { useProductTableFilters } from "./use-product-table-filters"
export function createProductTableAdapter(): TableAdapter<HttpTypes.AdminProduct> {
return createTableAdapter<HttpTypes.AdminProduct>({
entity: "products",
queryPrefix: "p",
pageSize: 20,
columnAdapter: productColumnAdapter,
useData: (fields, params) => {
const { products, count, isError, error, isLoading } = useProducts(
{
fields,
...params,
is_giftcard: false, // Exclude gift cards from product list
},
{
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: products, count, isLoading, isError, error }
},
getRowHref: (row) => `/products/${row.id}`,
})
}
/**
* Hook to get the product table adapter with filters
*/
export function useProductTableAdapter(): TableAdapter<HttpTypes.AdminProduct> {
const filters = useProductTableFilters()
const adapter = createProductTableAdapter()
// Add dynamic filters to the adapter
return {
...adapter,
filters,
}
}

View File

@@ -0,0 +1,104 @@
import { useMemo } from "react"
import { useTranslation } from "react-i18next"
import { createDataTableFilterHelper } from "@medusajs/ui"
import { HttpTypes } from "@medusajs/types"
import { useDataTableDateFilters } from "../../../../../components/data-table/helpers/general/use-data-table-date-filters"
import { useProductTypes } from "../../../../../hooks/api/product-types"
import { useProductTags } from "../../../../../hooks/api"
import { useSalesChannels } from "../../../../../hooks/api/sales-channels"
const filterHelper = createDataTableFilterHelper<HttpTypes.AdminProduct>()
/**
* Hook to create filters in the format expected by @medusajs/ui DataTable
*/
export const useProductTableFilters = () => {
const { t } = useTranslation()
const dateFilters = useDataTableDateFilters()
const { product_types } = useProductTypes({
limit: 1000,
offset: 0,
})
const { product_tags } = useProductTags({
limit: 1000,
offset: 0,
})
const { sales_channels } = useSalesChannels({
limit: 1000,
fields: "id,name",
})
return useMemo(() => {
const filters = [...dateFilters]
if (product_types?.length) {
filters.push(
filterHelper.accessor("type_id", {
label: t("fields.type"),
type: "multiselect",
options: product_types.map((t) => ({
label: t.value,
value: t.id,
})),
})
)
}
if (product_tags?.length) {
filters.push(
filterHelper.accessor("tag_id", {
label: t("fields.tag"),
type: "multiselect",
options: product_tags.map((t) => ({
label: t.value,
value: t.id,
})),
})
)
}
if (sales_channels?.length) {
filters.push(
filterHelper.accessor("sales_channel_id", {
label: t("fields.salesChannel"),
type: "multiselect",
options: sales_channels.map((s) => ({
label: s.name,
value: s.id,
})),
})
)
}
// Status filter
filters.push(
filterHelper.accessor("status", {
label: t("fields.status"),
type: "multiselect",
options: [
{
label: t("products.productStatus.draft"),
value: "draft",
},
{
label: t("products.productStatus.proposed"),
value: "proposed",
},
{
label: t("products.productStatus.published"),
value: "published",
},
{
label: t("products.productStatus.rejected"),
value: "rejected",
},
],
})
)
return filters
}, [product_types, product_tags, sales_channels, dateFilters, t])
}

View File

@@ -11,7 +11,7 @@ const DEFAULT_COLUMN_ORDER = 500
/**
* Determines the appropriate column alignment based on the column metadata
*/
export function getColumnAlignment(column: HttpTypes.AdminViewColumn): ColumnAlignment {
export function getColumnAlignment(column: HttpTypes.AdminColumn): ColumnAlignment {
// Currency columns should be right-aligned
if (column.semantic_type === "currency" || column.data_type === "currency") {
return ColumnAlignment.RIGHT
@@ -44,7 +44,7 @@ export function getColumnAlignment(column: HttpTypes.AdminViewColumn): ColumnAli
* Gets the initial column visibility state from API columns
*/
export function getInitialColumnVisibility(
apiColumns: HttpTypes.AdminViewColumn[]
apiColumns: HttpTypes.AdminColumn[]
): Record<string, boolean> {
const visibility: Record<string, boolean> = {}
@@ -59,7 +59,7 @@ export function getInitialColumnVisibility(
* Gets the initial column order from API columns
*/
export function getInitialColumnOrder(
apiColumns: HttpTypes.AdminViewColumn[]
apiColumns: HttpTypes.AdminColumn[]
): string[] {
const sortedColumns = [...apiColumns].sort((a, b) => {
const orderA = a.default_order ?? DEFAULT_COLUMN_ORDER

View File

@@ -250,7 +250,10 @@ export class Client {
const params = Object.fromEntries(
normalizedInput.searchParams.entries()
)
const stringifiedQuery = stringify({ ...params, ...init.query }, { skipNulls: true })
const stringifiedQuery = stringify(
{ ...params, ...init.query },
{ skipNulls: true }
)
normalizedInput.search = stringifiedQuery
}
}

View File

@@ -35,7 +35,6 @@ export const DataTableNonSortableHeaderCell = React.forwardRef<
...propStyle,
transform: transformStyle ? CSS.Transform.toString(transformStyle) : undefined,
transition,
position: 'relative' as const,
}
const combineRefs = (element: HTMLTableCellElement | null) => {

View File

@@ -38,9 +38,7 @@ export const DataTableSortableHeaderCell = React.forwardRef<
transform: transformStyle ? CSS.Transform.toString(transformStyle) : undefined,
transition,
opacity: isDragging ? 0.8 : 1,
zIndex: isDragging ? 50 : undefined,
backgroundColor: "white",
position: 'relative' as const,
zIndex: isDragging ? 50 : isFirstColumn ? 1 : undefined,
}
const combineRefs = (element: HTMLTableCellElement | null) => {
@@ -58,7 +56,7 @@ export const DataTableSortableHeaderCell = React.forwardRef<
<Table.HeaderCell
ref={combineRefs}
style={style}
className={clx(className, "group/header-cell relative")}
className={clx(className, "group/header-cell bg-ui-bg-base")}
{...attributes}
{...listeners}
{...props}

View File

@@ -23,7 +23,7 @@ export const ENTITY_MAPPINGS = {
computedColumns: {
customer_display: {
name: "Customer",
computation_type: "customer_name",
render_type: "customer_name",
required_fields: [
"customer.first_name",
"customer.last_name",
@@ -34,7 +34,7 @@ export const ENTITY_MAPPINGS = {
},
shipping_address_display: {
name: "Shipping Address",
computation_type: "address_summary",
render_type: "address_summary",
required_fields: [
"shipping_address.city",
"shipping_address.country_code",
@@ -48,7 +48,7 @@ export const ENTITY_MAPPINGS = {
},
billing_address_display: {
name: "Billing Address",
computation_type: "address_summary",
render_type: "address_summary",
required_fields: [
"billing_address.city",
"billing_address.country_code",
@@ -62,7 +62,7 @@ export const ENTITY_MAPPINGS = {
},
country: {
name: "Country",
computation_type: "country_code",
render_type: "country_code",
required_fields: ["shipping_address.country_code"],
optional_fields: [],
default_visible: true,
@@ -73,18 +73,47 @@ export const ENTITY_MAPPINGS = {
serviceName: "product",
graphqlType: "Product",
defaultVisibleFields: [
"title",
"handle",
"product_display",
"collection.title",
"sales_channels_display",
"variants_count",
"status",
"created_at",
"updated_at",
],
fieldFilters: {
excludeSuffixes: ["_link"],
excludePrefixes: ["raw_"],
excludeFields: [],
},
computedColumns: {},
computedColumns: {
product_display: {
name: "Product",
render_type: "product_info",
required_fields: [
"title",
"thumbnail",
],
optional_fields: ["handle"],
default_visible: true,
},
variants_count: {
name: "Variants",
render_type: "count",
required_fields: [
"variants",
],
optional_fields: [],
default_visible: true,
},
sales_channels_display: {
name: "Sales Channels",
render_type: "sales_channels_list",
required_fields: [
"sales_channels",
],
optional_fields: [],
default_visible: true,
},
},
},
customers: {
serviceName: "customer",

View File

@@ -255,15 +255,31 @@ export const getTypeInfoFromGraphQLType = (
}
}
export const DEFAULT_COLUMN_ORDERS: Record<string, number> = {
display_id: 100,
created_at: 200,
customer_display: 300,
"sales_channel.name": 400,
fulfillment_status: 500,
payment_status: 600,
total: 700,
country: 800,
type Entities = keyof typeof ENTITY_MAPPINGS
export const DEFAULT_COLUMN_ORDERS: Record<Entities, Record<string, number>> = {
orders: {
display_id: 100,
created_at: 200,
customer_display: 300,
"sales_channel.name": 400,
fulfillment_status: 500,
payment_status: 600,
total: 700,
country: 800,
},
products: {
product_display: 100,
"collection.title": 200,
sales_channels_display: 300,
variants_count: 400,
status: 500,
},
// Add other entities as needed
customers: {},
users: {},
regions: {},
"sales-channels": {},
}
/**
@@ -274,7 +290,7 @@ export const DEFAULT_COLUMN_ORDERS: Record<string, number> = {
*/
export const generateEntityColumns = (
entity: string,
entityMapping: typeof ENTITY_MAPPINGS[keyof typeof ENTITY_MAPPINGS]
entityMapping: (typeof ENTITY_MAPPINGS)[keyof typeof ENTITY_MAPPINGS]
): HttpTypes.AdminColumn[] | null => {
const joinerConfigs = MedusaModule.getAllJoinerConfigs()
@@ -304,8 +320,7 @@ export const generateEntityColumns = (
const mergedSchemaAST = mergeTypeDefs(allSchemas)
const mergedSchemaString = print(mergedSchemaAST)
const { schema: cleanedSchemaString } =
cleanGraphQLSchema(mergedSchemaString)
const { schema: cleanedSchemaString } = cleanGraphQLSchema(mergedSchemaString)
const schema = makeExecutableSchema({
typeDefs: cleanedSchemaString,
@@ -393,9 +408,7 @@ export const generateEntityColumns = (
const directColumns = directFields.map((fieldName) => {
const displayName = formatFieldName(fieldName)
const type = schemaTypeMap[
entityMapping.graphqlType
] as GraphQLObjectType
const type = schemaTypeMap[entityMapping.graphqlType] as GraphQLObjectType
const fieldDef = type?.getFields()?.[fieldName]
const typeInfo = fieldDef
? getTypeInfoFromGraphQLType(fieldDef.type, fieldName)
@@ -406,8 +419,8 @@ export const generateEntityColumns = (
const isDefaultField =
entityMapping.defaultVisibleFields.includes(fieldName)
const defaultOrder =
DEFAULT_COLUMN_ORDERS[fieldName] || (isDefaultField ? 500 : 850)
const entityOrders = DEFAULT_COLUMN_ORDERS[entity] || {}
const defaultOrder = entityOrders[fieldName] || (isDefaultField ? 500 : 850)
const category = getColumnCategory(
fieldName,
typeInfo.data_type,
@@ -421,8 +434,7 @@ export const generateEntityColumns = (
field: fieldName,
sortable,
hideable: true,
default_visible:
entityMapping.defaultVisibleFields.includes(fieldName),
default_visible: entityMapping.defaultVisibleFields.includes(fieldName),
data_type: typeInfo.data_type,
semantic_type: typeInfo.semantic_type,
context: typeInfo.context,
@@ -442,9 +454,7 @@ export const generateEntityColumns = (
)
// Filter out problematic fields from related type
const relatedType = schemaTypeMap[
relatedTypeName
] as GraphQLObjectType
const relatedType = schemaTypeMap[relatedTypeName] as GraphQLObjectType
const relatedFields = allRelatedFields.filter((fieldName) => {
const field = relatedType?.getFields()[fieldName]
if (!field) return true
@@ -466,13 +476,11 @@ export const generateEntityColumns = (
limitedFields.forEach((fieldName) => {
const fieldPath = `${relationName}.${fieldName}`
const displayName = `${formatFieldName(
relationName
)} ${formatFieldName(fieldName)}`
const displayName = `${formatFieldName(relationName)} ${formatFieldName(
fieldName
)}`
const relatedType = schemaTypeMap[
relatedTypeName
] as GraphQLObjectType
const relatedType = schemaTypeMap[relatedTypeName] as GraphQLObjectType
const fieldDef = relatedType?.getFields()?.[fieldName]
const typeInfo = fieldDef
? getTypeInfoFromGraphQLType(fieldDef.type, fieldName)
@@ -493,8 +501,9 @@ export const generateEntityColumns = (
// If field is not in default visible fields, place it after country (850)
const isDefaultField =
entityMapping.defaultVisibleFields.includes(fieldPath)
const entityOrders = DEFAULT_COLUMN_ORDERS[entity] || {}
const defaultOrder =
DEFAULT_COLUMN_ORDERS[fieldPath] || (isDefaultField ? 700 : 850)
entityOrders[fieldPath] || (isDefaultField ? 700 : 850)
const category = getColumnCategory(
fieldPath,
typeInfo.data_type,
@@ -534,8 +543,9 @@ export const generateEntityColumns = (
// If field is not in default visible fields, place it after country (850)
const isDefaultField =
entityMapping.defaultVisibleFields.includes(columnId)
const entityOrders = DEFAULT_COLUMN_ORDERS[entity] || {}
const defaultOrder =
DEFAULT_COLUMN_ORDERS[columnId] || (isDefaultField ? 600 : 850)
entityOrders[columnId] || (isDefaultField ? 600 : 850)
const category = getColumnCategory(columnId, "string", "computed")
computedColumns.push({
@@ -545,13 +555,12 @@ export const generateEntityColumns = (
field: columnId,
sortable: false, // Computed columns can't be sorted server-side
hideable: true,
default_visible:
entityMapping.defaultVisibleFields.includes(columnId),
default_visible: entityMapping.defaultVisibleFields.includes(columnId),
data_type: "string", // Computed columns typically output strings
semantic_type: "computed",
context: "display",
computed: {
type: columnConfig.computation_type,
type: columnConfig.render_type,
required_fields: columnConfig.required_fields,
optional_fields: columnConfig.optional_fields || [],
},