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:
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -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} />
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user