Feat/datatable core enhancements (#13193)

**What**

This PR adds core DataTable enhancements to support view configurations in the admin dashboard. This is part of a set of stacked PRs to add the feature to Medusa.

- Puts handlers in place to update the visible columns in a table and the order in which they appear.
- Adds a ViewPills component for displaying and switching between saved view configurations
- Integrated view configuration hooks (useViewConfigurations) with the DataTable

Note: Column drag-and-drop reordering and the column visibility UI controls are not included in this PR as they require additional UI library updates - which will come in the next PR.

Example of what this looks like with the feature flag turned on - note the view pills with "default" in the top. This will expand when the data table behavior adds configuration.
<img width="2492" height="758" alt="CleanShot 2025-08-13 at 2  31 35@2x" src="https://github.com/user-attachments/assets/ee770f1c-dae1-49da-b255-1a6d615789de" />
This commit is contained in:
Sebastian Rindom
2025-09-01 12:30:05 +02:00
committed by GitHub
parent fa10c78ed3
commit 5a46372afd
12 changed files with 1368 additions and 83 deletions

View File

@@ -1,19 +1,19 @@
import {
Button,
clx,
DataTable as UiDataTable,
useDataTable,
DataTableColumnDef,
DataTableCommand,
DataTableEmptyStateProps,
DataTableFilter,
DataTableFilteringState,
DataTablePaginationState,
DataTableRow,
DataTableRowSelectionState,
DataTableSortingState,
Heading,
DataTable as Primitive,
Text,
useDataTable,
Button,
DataTableFilteringState,
DataTablePaginationState,
DataTableSortingState,
clx,
} from "@medusajs/ui"
import React, { ReactNode, useCallback, useMemo } from "react"
import { useTranslation } from "react-i18next"
@@ -21,31 +21,37 @@ import { Link, useNavigate, useSearchParams } from "react-router-dom"
import { useQueryParams } from "../../hooks/use-query-params"
import { ActionMenu } from "../common/action-menu"
import { ViewPills } from "../table/view-selector"
import { useFeatureFlag } from "../../providers/feature-flag-provider"
// Types for column visibility and ordering
type VisibilityState = Record<string, boolean>
type ColumnOrderState = string[]
type DataTableActionProps = {
label: string
disabled?: boolean
} & (
| {
| {
to: string
}
| {
| {
onClick: () => void
}
)
)
type DataTableActionMenuActionProps = {
label: string
icon: ReactNode
disabled?: boolean
} & (
| {
| {
to: string
}
| {
| {
onClick: () => void
}
)
)
type DataTableActionMenuGroupProps = {
actions: DataTableActionMenuActionProps[]
@@ -80,6 +86,19 @@ interface DataTableProps<TData> {
enableRowSelection?: boolean | ((row: DataTableRow<TData>) => boolean)
}
layout?: "fill" | "auto"
enableColumnVisibility?: boolean
initialColumnVisibility?: VisibilityState
onColumnVisibilityChange?: (visibility: VisibilityState) => void
columnOrder?: ColumnOrderState
onColumnOrderChange?: (order: ColumnOrderState) => void
enableViewSelector?: boolean
entity?: string
onViewChange?: (view: any) => void
currentColumns?: {
visible: string[]
order: string[]
}
filterBarContent?: React.ReactNode
}
export const DataTable = <TData,>({
@@ -103,13 +122,52 @@ export const DataTable = <TData,>({
rowSelection,
isLoading = false,
layout = "auto",
enableColumnVisibility = false,
initialColumnVisibility = {},
onColumnVisibilityChange,
columnOrder,
onColumnOrderChange,
enableViewSelector = false,
entity,
onViewChange,
currentColumns,
filterBarContent,
}: DataTableProps<TData>) => {
const { t } = useTranslation()
const isViewConfigEnabled = useFeatureFlag("view_configurations")
// If view config is disabled, don't use column visibility features
const effectiveEnableColumnVisibility = isViewConfigEnabled && enableColumnVisibility
const effectiveEnableViewSelector = isViewConfigEnabled && enableViewSelector
const enableFiltering = filters && filters.length > 0
const enableCommands = commands && commands.length > 0
const enableSorting = columns.some((column) => column.enableSorting)
const [columnVisibility, setColumnVisibility] = React.useState<VisibilityState>(initialColumnVisibility)
// Update column visibility when initial visibility changes
React.useEffect(() => {
// Deep compare to check if the visibility has actually changed
const currentKeys = Object.keys(columnVisibility).sort()
const newKeys = Object.keys(initialColumnVisibility).sort()
const hasChanged = currentKeys.length !== newKeys.length ||
currentKeys.some((key, index) => key !== newKeys[index]) ||
Object.entries(initialColumnVisibility).some(([key, value]) => columnVisibility[key] !== value)
if (hasChanged) {
setColumnVisibility(initialColumnVisibility)
}
}, [initialColumnVisibility])
// Wrapper function to handle column visibility changes
const handleColumnVisibilityChange = React.useCallback((visibility: VisibilityState) => {
setColumnVisibility(visibility)
onColumnVisibilityChange?.(visibility)
}, [onColumnVisibilityChange])
// Extract filter IDs for query param management
const filterIds = useMemo(() => filters?.map((f) => f.id) ?? [], [filters])
const prefixedFilterIds = filterIds.map((id) => getQueryParamKey(id, prefix))
@@ -167,18 +225,24 @@ export const DataTable = <TData,>({
const handleFilteringChange = (value: DataTableFilteringState) => {
setSearchParams((prev) => {
// Remove filters that are no longer in the state
Array.from(prev.keys()).forEach((key) => {
if (prefixedFilterIds.includes(key) && !(key in value)) {
prev.delete(key)
if (prefixedFilterIds.includes(key)) {
// Extract the unprefixed key
const unprefixedKey = prefix ? key.replace(`${prefix}_`, '') : key
if (!(unprefixedKey in value)) {
prev.delete(key)
}
}
})
// Add or update filters in the state
Object.entries(value).forEach(([key, filter]) => {
if (
prefixedFilterIds.includes(getQueryParamKey(key, prefix)) &&
filter
) {
prev.set(getQueryParamKey(key, prefix), JSON.stringify(filter))
const prefixedKey = getQueryParamKey(key, prefix)
if (filter !== undefined) {
prev.set(prefixedKey, JSON.stringify(filter))
} else {
prev.delete(prefixedKey)
}
})
@@ -190,6 +254,13 @@ export const DataTable = <TData,>({
return order ? parseSortingState(order) : null
}, [order])
// Memoize current configuration to prevent infinite loops
const currentConfiguration = useMemo(() => ({
filters: filtering,
sorting: sorting,
search: search,
}), [filtering, sorting, search])
const handleSortingChange = (value: DataTableSortingState) => {
setSearchParams((prev) => {
if (value) {
@@ -242,92 +313,99 @@ export const DataTable = <TData,>({
onRowClick: rowHref ? onRowClick : undefined,
pagination: enablePagination
? {
state: pagination,
onPaginationChange: handlePaginationChange,
}
state: pagination,
onPaginationChange: handlePaginationChange,
}
: undefined,
filtering: enableFiltering
? {
state: filtering,
onFilteringChange: handleFilteringChange,
}
state: filtering,
onFilteringChange: handleFilteringChange,
}
: undefined,
sorting: enableSorting
? {
state: sorting,
onSortingChange: handleSortingChange,
}
state: sorting,
onSortingChange: handleSortingChange,
}
: undefined,
search: enableSearch
? {
state: search,
onSearchChange: handleSearchChange,
}
state: search,
onSearchChange: handleSearchChange,
}
: undefined,
rowSelection,
isLoading,
columnVisibility: effectiveEnableColumnVisibility
? {
state: columnVisibility,
onColumnVisibilityChange: handleColumnVisibilityChange,
}
: undefined,
columnOrder: effectiveEnableColumnVisibility && columnOrder && onColumnOrderChange
? {
state: columnOrder,
onColumnOrderChange: onColumnOrderChange,
}
: undefined,
})
const shouldRenderHeading = heading || subHeading
return (
<Primitive
<UiDataTable
instance={instance}
className={clx({
"h-full [&_tr]:last-of-type:!border-b": layout === "fill",
})}
className={layout === "fill" ? "h-full [&_tr]:last-of-type:!border-b" : undefined}
>
<Primitive.Toolbar
<UiDataTable.Toolbar
className="flex flex-col items-start justify-between gap-2 md:flex-row md:items-center"
translations={toolbarTranslations}
filterBarContent={filterBarContent}
>
<div className="flex w-full items-center justify-between gap-2">
{shouldRenderHeading && (
<div>
{heading && <Heading>{heading}</Heading>}
{subHeading && (
<Text size="small" className="text-ui-fg-subtle">
{subHeading}
</Text>
)}
</div>
)}
<div className="flex items-center justify-end gap-x-2 md:hidden">
{enableFiltering && (
<Primitive.FilterMenu tooltip={t("filters.filterLabel")} />
<div className="flex items-center gap-x-4">
{shouldRenderHeading && (
<div>
{heading && <Heading>{heading}</Heading>}
{subHeading && (
<Text size="small" className="text-ui-fg-subtle">
{subHeading}
</Text>
)}
</div>
)}
<Primitive.SortingMenu tooltip={t("filters.sortLabel")} />
{actionMenu && <ActionMenu variant="primary" {...actionMenu} />}
{action && <DataTableAction {...action} />}
</div>
</div>
<div className="flex w-full items-center gap-2 md:justify-end">
{enableSearch && (
<div className="w-full md:w-auto">
<Primitive.Search
placeholder={t("filters.searchLabel")}
autoFocus={autoFocusSearch}
{effectiveEnableViewSelector && entity && (
<ViewPills
entity={entity}
onViewChange={onViewChange}
currentColumns={currentColumns}
currentConfiguration={currentConfiguration}
/>
</div>
)}
<div className="hidden items-center gap-x-2 md:flex">
{enableFiltering && (
<Primitive.FilterMenu tooltip={t("filters.filterLabel")} />
)}
<Primitive.SortingMenu tooltip={t("filters.sortLabel")} />
</div>
<div className="flex items-center gap-x-2">
{enableSearch && (
<div className="w-full md:w-auto">
<UiDataTable.Search
placeholder={t("filters.searchLabel")}
autoFocus={autoFocusSearch}
/>
</div>
)}
{actionMenu && <ActionMenu variant="primary" {...actionMenu} />}
{action && <DataTableAction {...action} />}
</div>
</div>
</Primitive.Toolbar>
<Primitive.Table emptyState={emptyState} />
</UiDataTable.Toolbar>
<UiDataTable.Table emptyState={emptyState} />
{enablePagination && (
<Primitive.Pagination translations={paginationTranslations} />
<UiDataTable.Pagination translations={paginationTranslations} />
)}
{enableCommands && (
<Primitive.CommandBar selectedLabel={(count) => `${count} selected`} />
<UiDataTable.CommandBar selectedLabel={(count) => `${count} selected`} />
)}
</Primitive>
</UiDataTable>
)
}
@@ -367,7 +445,7 @@ function parseFilterState(
for (const id of filterIds) {
const filterValue = value[id]
if (filterValue) {
if (filterValue !== undefined) {
filters[id] = JSON.parse(filterValue)
}
}
@@ -392,6 +470,8 @@ const useDataTableTranslations = () => {
const toolbarTranslations = {
clearAll: t("actions.clearAll"),
sort: t("filters.sortLabel"),
columns: "Columns",
}
return {
@@ -426,3 +506,4 @@ const DataTableAction = ({
</Button>
)
}

View File

@@ -0,0 +1 @@
export * from "./save-view-dialog"

View File

@@ -0,0 +1,246 @@
import React, { useState } from "react"
import {
Button,
Input,
Label,
Switch,
FocusModal,
Hint,
toast,
} from "@medusajs/ui"
import { useForm, Controller } from "react-hook-form"
import { useViewConfigurations, useViewConfiguration } from "../../../hooks/use-view-configurations"
import type { ViewConfiguration } from "../../../hooks/use-view-configurations"
type SaveViewFormData = {
name: string
isSystemDefault: boolean
}
interface SaveViewDialogProps {
entity: string
currentColumns?: {
visible: string[]
order: string[]
}
currentConfiguration?: {
filters?: Record<string, unknown>
sorting?: { id: string; desc: boolean } | null
search?: string
}
editingView?: ViewConfiguration | null
onClose: () => void
onSaved: (view: ViewConfiguration) => void
}
export const SaveViewDialog: React.FC<SaveViewDialogProps> = ({
entity,
currentColumns,
currentConfiguration,
editingView,
onClose,
onSaved,
}) => {
const { createView } = useViewConfigurations(entity)
const { updateView } = useViewConfiguration(entity, editingView?.id || '')
const [isLoading, setIsLoading] = useState(false)
const {
register,
handleSubmit,
formState: { errors },
watch,
control,
} = useForm<SaveViewFormData>({
mode: "onChange",
defaultValues: {
name: editingView?.name || "",
isSystemDefault: editingView?.is_system_default || false,
},
})
const isSystemDefault = watch("isSystemDefault")
const isAdmin = true // TODO: Get from auth context
const onSubmit = async (data: SaveViewFormData) => {
// Manual validation for new views
if (!editingView && !data.isSystemDefault && (!data.name || !data.name.trim())) {
toast.error("Name is required unless setting as system default")
return
}
// Validation for editing views - if converting to system default without a name, that's ok
if (editingView && !data.isSystemDefault && !editingView.name && (!data.name || !data.name.trim())) {
toast.error("Name is required for personal views")
return
}
if (!currentColumns && !editingView) {
toast.error("No column configuration to save")
return
}
setIsLoading(true)
try {
if (editingView) {
// Update existing view
const updateData: any = {
is_system_default: data.isSystemDefault,
set_active: true, // Always set updated view as active
}
// Only include name if it was provided and changed (empty string means keep current)
if (data.name && data.name.trim() !== "" && data.name !== editingView.name) {
updateData.name = data.name
}
// Only update configuration if currentColumns is provided
if (currentColumns) {
updateData.configuration = {
visible_columns: currentColumns.visible,
column_order: currentColumns.order,
filters: currentConfiguration?.filters || {},
sorting: currentConfiguration?.sorting || null,
search: currentConfiguration?.search || "",
}
}
const result = await updateView.mutateAsync(updateData)
onSaved(result.view_configuration)
} else {
// Create new view
if (!currentColumns) {
toast.error("No column configuration to save")
return
}
// Only include name if provided (not required for system defaults)
const createData: any = {
entity,
is_system_default: data.isSystemDefault,
set_active: true, // Always set newly created view as active
configuration: {
visible_columns: currentColumns.visible,
column_order: currentColumns.order,
filters: currentConfiguration?.filters || {},
sorting: currentConfiguration?.sorting || null,
search: currentConfiguration?.search || "",
},
}
// Only add name if it's provided and not empty
if (data.name && data.name.trim()) {
createData.name = data.name.trim()
}
const result = await createView.mutateAsync(createData)
onSaved(result.view_configuration)
}
} catch (error) {
// Error is handled by the hook
} finally {
setIsLoading(false)
}
}
return (
<FocusModal open onOpenChange={onClose}>
<FocusModal.Content>
<FocusModal.Header>
<div className="flex items-center justify-between">
<FocusModal.Title>
{editingView ? "Edit View" : "Save View"}
</FocusModal.Title>
</div>
</FocusModal.Header>
<form onSubmit={handleSubmit(onSubmit)} noValidate>
<FocusModal.Body className="flex flex-col gap-y-4">
<div className="flex flex-col gap-y-2">
<Label htmlFor="name" weight="plus">
View Name {(editingView || isSystemDefault) && <span className="text-ui-fg-muted font-normal">(optional)</span>}
</Label>
<Input
id="name"
{...register("name")}
placeholder={
editingView
? editingView.name
: isSystemDefault
? "Leave empty for no name"
: "e.g., My Custom View"
}
autoComplete="off"
/>
</div>
{isAdmin && (
<div className="flex items-center justify-between">
<div className="flex flex-col gap-y-1">
<Label htmlFor="isSystemDefault" weight="plus">
Set as System Default
</Label>
<Hint>
This view will be the default for all users
</Hint>
</div>
<Controller
name="isSystemDefault"
control={control}
render={({ field }) => (
<Switch
id="isSystemDefault"
checked={field.value}
onCheckedChange={field.onChange}
/>
)}
/>
</div>
)}
{editingView && (
<div className="rounded-md bg-ui-bg-subtle p-3">
<p className="text-ui-fg-subtle text-sm">
You are editing the view "{editingView.name}".
{editingView.is_system_default && (
<span className="block mt-1 text-ui-fg-warning">
This is a system default view.
</span>
)}
</p>
</div>
)}
{!isAdmin && isSystemDefault && (
<Hint variant="error">
Only administrators can create system default views
</Hint>
)}
</FocusModal.Body>
<FocusModal.Footer>
<div className="flex items-center gap-x-2">
<Button
variant="secondary"
size="small"
type="button"
onClick={onClose}
>
Cancel
</Button>
<Button
variant="primary"
size="small"
type="submit"
isLoading={isLoading}
disabled={!isAdmin && isSystemDefault}
>
{editingView ? "Update" : "Save"} View
</Button>
</div>
</FocusModal.Footer>
</form>
</FocusModal.Content>
</FocusModal>
)
}

View File

@@ -0,0 +1 @@
export { SaveViewDropdown } from "./save-view-dropdown"

View File

@@ -0,0 +1,88 @@
import React, { useState, useEffect } from "react"
import {
DropdownMenu,
Button,
toast,
usePrompt,
} from "@medusajs/ui"
import {
Plus,
CloudArrowUp,
SquarePlusMicro,
} from "@medusajs/icons"
interface SaveViewDropdownProps {
isDefaultView: boolean
currentViewId?: string | null
currentViewName?: string | null
onSaveAsDefault?: () => void
onUpdateExisting?: () => void
onSaveAsNew?: () => void
}
export const SaveViewDropdown: React.FC<SaveViewDropdownProps> = ({
isDefaultView,
currentViewId,
currentViewName,
onSaveAsDefault,
onUpdateExisting,
onSaveAsNew,
}) => {
const prompt = usePrompt()
const handleSaveAsDefault = async () => {
const result = await prompt({
title: "Save as system default",
description: "This will save the current configuration as the system default. All users will see this configuration by default unless they have their own personal views. Are you sure?",
confirmText: "Save as default",
cancelText: "Cancel",
})
if (result && onSaveAsDefault) {
onSaveAsDefault()
}
}
const handleUpdateExisting = async () => {
const result = await prompt({
title: "Update existing view",
description: `Update "${currentViewName}" with the current configuration?`,
confirmText: "Update",
cancelText: "Cancel",
})
if (result && onUpdateExisting) {
onUpdateExisting()
}
}
return (
<DropdownMenu>
<DropdownMenu.Trigger asChild>
<Button variant="secondary" size="small">
Save
</Button>
</DropdownMenu.Trigger>
<DropdownMenu.Content>
{isDefaultView && onSaveAsDefault && (
<DropdownMenu.Item onClick={handleSaveAsDefault}>
<CloudArrowUp className="h-4 w-4" />
Save as system default
</DropdownMenu.Item>
)}
{!isDefaultView && currentViewId && onUpdateExisting && (
<DropdownMenu.Item onClick={handleUpdateExisting}>
<CloudArrowUp className="h-4 w-4" />
Update "{currentViewName}"
</DropdownMenu.Item>
)}
{onSaveAsNew && (
<DropdownMenu.Item onClick={onSaveAsNew}>
<SquarePlusMicro className="h-4 w-4" />
Save as new view
</DropdownMenu.Item>
)}
</DropdownMenu.Content>
</DropdownMenu>
)
}

View File

@@ -0,0 +1,2 @@
export * from "./view-selector"
export * from "./view-pills"

View File

@@ -0,0 +1,294 @@
import React, { useEffect, useState, useRef } from "react"
import {
Badge,
usePrompt,
toast,
DropdownMenu,
} from "@medusajs/ui"
import {
Trash,
PencilSquare,
ArrowUturnLeft,
} from "@medusajs/icons"
import { useViewConfigurations, useViewConfiguration } from "../../../hooks/use-view-configurations"
import type { ViewConfiguration } from "../../../hooks/use-view-configurations"
import { SaveViewDialog } from "../save-view-dialog"
interface ViewPillsProps {
entity: string
onViewChange?: (view: ViewConfiguration | null) => void
currentColumns?: {
visible: string[]
order: string[]
}
currentConfiguration?: {
filters?: Record<string, unknown>
sorting?: { id: string; desc: boolean } | null
search?: string
}
}
export const ViewPills: React.FC<ViewPillsProps> = ({
entity,
onViewChange,
currentColumns,
currentConfiguration,
}) => {
const {
listViews,
activeView,
setActiveView,
isDefaultViewActive,
} = useViewConfigurations(entity)
const views = listViews.data?.view_configurations || []
const [saveDialogOpen, setSaveDialogOpen] = useState(false)
const [editingView, setEditingView] = useState<ViewConfiguration | null>(null)
const [contextMenuOpen, setContextMenuOpen] = useState<string | null>(null)
const [contextMenuPosition, setContextMenuPosition] = useState({ x: 0, y: 0 })
const [deletingViewId, setDeletingViewId] = useState<string | null>(null)
const prompt = usePrompt()
const currentActiveView = activeView.data?.view_configuration || null
// Track if we've notified parent of initial view
const hasNotifiedInitialView = useRef(false)
// Get delete mutation for the current deleting view
const { deleteView } = useViewConfiguration(entity, deletingViewId || '')
// Notify parent of initial view once
useEffect(() => {
if (!hasNotifiedInitialView.current && activeView.isSuccess) {
hasNotifiedInitialView.current = true
// Use setTimeout to ensure this happens after render
setTimeout(() => {
if (onViewChange) {
onViewChange(currentActiveView)
}
}, 0)
}
}, [activeView.isSuccess, currentActiveView]) // Remove onViewChange from dependencies
const handleViewSelect = async (viewId: string | null) => {
if (viewId === null) {
// Select default view - clear the active view
await setActiveView.mutateAsync(null)
if (onViewChange) {
onViewChange(null)
}
return
}
const view = views.find(v => v.id === viewId)
if (view) {
await setActiveView.mutateAsync(viewId)
if (onViewChange) {
onViewChange(view)
}
}
}
const handleDeleteView = async (view: ViewConfiguration) => {
const result = await prompt({
title: "Delete view",
description: `Are you sure you want to delete "${view.name}"? This action cannot be undone.`,
confirmText: "Delete",
cancelText: "Cancel",
})
if (result) {
setDeletingViewId(view.id)
// The actual deletion will happen in the effect below
}
}
// Handle deletion when deletingViewId is set
useEffect(() => {
if (deletingViewId && deleteView.mutateAsync) {
deleteView.mutateAsync().then(() => {
if (currentActiveView?.id === deletingViewId) {
if (onViewChange) {
onViewChange(null)
}
}
setDeletingViewId(null)
}).catch(() => {
setDeletingViewId(null)
// Error is handled by the hook
})
}
}, [deletingViewId, deleteView.mutateAsync, currentActiveView?.id, onViewChange])
const handleEditView = (view: ViewConfiguration) => {
setEditingView(view)
setSaveDialogOpen(true)
}
const handleResetSystemDefault = async (systemDefaultView: ViewConfiguration) => {
const result = await prompt({
title: "Reset system default",
description: "This will delete the saved system default and revert to the original code-level defaults. All users will be affected. Are you sure?",
confirmText: "Reset",
cancelText: "Cancel",
})
if (result) {
setDeletingViewId(systemDefaultView.id)
// The actual deletion will happen in the effect above
}
}
const systemDefaultView = views.find(v => v.is_system_default)
const personalViews = views.filter(v => !v.is_system_default)
// Determine if we're showing default
const isDefaultActive = isDefaultViewActive
const defaultLabel = "Default"
return (
<>
<div className="flex items-center gap-2">
{/* Default view badge (either code-level or system default) */}
<div className="relative inline-block">
<Badge
color={isDefaultActive ? "blue" : "grey"}
size="xsmall"
className="cursor-pointer"
onClick={() => handleViewSelect(null)}
onContextMenu={(e) => {
e.preventDefault()
if (systemDefaultView) {
setContextMenuPosition({ x: e.clientX, y: e.clientY })
setContextMenuOpen('default')
}
}}
>
{defaultLabel}
</Badge>
{systemDefaultView && contextMenuOpen === 'default' && (
<DropdownMenu
open={true}
onOpenChange={(open) => {
if (!open) setContextMenuOpen(null)
}}
>
<DropdownMenu.Trigger asChild>
<div
style={{
position: 'fixed',
left: contextMenuPosition.x,
top: contextMenuPosition.y,
width: 0,
height: 0
}}
/>
</DropdownMenu.Trigger>
<DropdownMenu.Content align="start" sideOffset={0}>
<DropdownMenu.Item
onClick={() => {
handleResetSystemDefault(systemDefaultView)
setContextMenuOpen(null)
}}
className="flex items-center gap-x-2"
>
<ArrowUturnLeft className="text-ui-fg-subtle" />
<span>Reset to code defaults</span>
</DropdownMenu.Item>
</DropdownMenu.Content>
</DropdownMenu>
)}
</div>
{/* Separator */}
{personalViews.length > 0 && <div className="text-ui-fg-muted">|</div>}
{/* Personal view badges */}
{personalViews.map((view) => (
<div key={view.id} className="relative inline-block">
<Badge
color={currentActiveView?.id === view.id ? "blue" : "grey"}
size="xsmall"
className="cursor-pointer"
onClick={() => handleViewSelect(view.id)}
onContextMenu={(e) => {
e.preventDefault()
setContextMenuPosition({ x: e.clientX, y: e.clientY })
setContextMenuOpen(view.id)
}}
>
{view.name}
</Badge>
{contextMenuOpen === view.id && (
<DropdownMenu
open={true}
onOpenChange={(open) => {
if (!open) setContextMenuOpen(null)
}}
>
<DropdownMenu.Trigger asChild>
<div
style={{
position: 'fixed',
left: contextMenuPosition.x,
top: contextMenuPosition.y,
width: 0,
height: 0
}}
/>
</DropdownMenu.Trigger>
<DropdownMenu.Content align="start" sideOffset={0}>
<DropdownMenu.Item
onClick={() => {
handleEditView(view)
setContextMenuOpen(null)
}}
className="flex items-center gap-x-2"
>
<PencilSquare className="text-ui-fg-subtle" />
<span>Edit name</span>
</DropdownMenu.Item>
<DropdownMenu.Item
onClick={() => {
handleDeleteView(view)
setContextMenuOpen(null)
}}
className="flex items-center gap-x-2 text-ui-fg-error"
>
<Trash />
<span>Delete</span>
</DropdownMenu.Item>
</DropdownMenu.Content>
</DropdownMenu>
)}
</div>
))}
</div>
{saveDialogOpen && (
<SaveViewDialog
entity={entity}
currentColumns={currentColumns}
currentConfiguration={currentConfiguration}
editingView={editingView}
onClose={() => {
setSaveDialogOpen(false)
setEditingView(null)
}}
onSaved={async (newView) => {
setSaveDialogOpen(false)
setEditingView(null)
toast.success(`View "${newView.name}" saved successfully`)
// The view is already set as active in SaveViewDialog
// Notify parent of the new active view
if (onViewChange) {
onViewChange(newView)
}
}}
/>
)}
</>
)
}

View File

@@ -0,0 +1,253 @@
import React, { useEffect, useState } from "react"
import {
Select,
Button,
Tooltip,
DropdownMenu,
Badge,
usePrompt,
toast,
} from "@medusajs/ui"
import {
Eye,
EyeSlash,
Plus,
Trash,
PencilSquare,
Star,
CheckCircleSolid,
ArrowUturnLeft,
} from "@medusajs/icons"
import { useViewConfigurations, useViewConfiguration } from "../../../hooks/use-view-configurations"
import type { ViewConfiguration } from "../../../hooks/use-view-configurations"
import { SaveViewDialog } from "../save-view-dialog"
interface ViewSelectorProps {
entity: string
onViewChange?: (view: ViewConfiguration | null) => void
currentColumns?: {
visible: string[]
order: string[]
}
}
export const ViewSelector: React.FC<ViewSelectorProps> = ({
entity,
onViewChange,
currentColumns,
}) => {
const {
listViews,
activeView,
setActiveView,
isDefaultViewActive,
} = useViewConfigurations(entity)
const [saveDialogOpen, setSaveDialogOpen] = useState(false)
const [editingView, setEditingView] = useState<ViewConfiguration | null>(null)
const [deletingViewId, setDeletingViewId] = useState<string | null>(null)
const prompt = usePrompt()
const views = listViews.data?.view_configurations || []
const currentActiveView = activeView.data?.view_configuration || null
// Get delete mutation for the current deleting view
const { deleteView } = useViewConfiguration(entity, deletingViewId || '')
// Load views and active view
useEffect(() => {
if (activeView.isSuccess && onViewChange) {
onViewChange(currentActiveView)
}
}, [activeView.isSuccess, currentActiveView, onViewChange])
const handleViewSelect = async (viewId: string) => {
const view = views.find(v => v.id === viewId)
if (view) {
await setActiveView.mutateAsync(viewId)
if (onViewChange) {
onViewChange(view)
}
}
}
const handleDeleteView = async (view: ViewConfiguration) => {
const result = await prompt({
title: "Delete view",
description: `Are you sure you want to delete "${view.name}"? This action cannot be undone.`,
confirmText: "Delete",
cancelText: "Cancel",
})
if (result) {
setDeletingViewId(view.id)
}
}
// Handle deletion when deletingViewId is set
useEffect(() => {
if (deletingViewId && deleteView.mutateAsync) {
deleteView.mutateAsync().then(() => {
if (currentActiveView?.id === deletingViewId) {
if (onViewChange) {
onViewChange(null)
}
}
setDeletingViewId(null)
}).catch(() => {
setDeletingViewId(null)
})
}
}, [deletingViewId, deleteView.mutateAsync, currentActiveView?.id, onViewChange])
const handleSaveView = () => {
setSaveDialogOpen(true)
setEditingView(null)
}
const handleEditView = (view: ViewConfiguration) => {
setEditingView(view)
setSaveDialogOpen(true)
}
const handleResetSystemDefault = async (systemDefaultView: ViewConfiguration) => {
const result = await prompt({
title: "Reset system default",
description: "This will delete the saved system default and revert to the original code-level defaults. All users will be affected. Are you sure?",
confirmText: "Reset",
cancelText: "Cancel",
})
if (result) {
setDeletingViewId(systemDefaultView.id)
}
}
const systemDefaultView = views.find(v => v.is_system_default)
const personalViews = views.filter(v => !v.is_system_default)
return (
<>
<div className="flex items-center gap-2">
<DropdownMenu>
<DropdownMenu.Trigger asChild>
<Button variant="secondary" size="small">
<Eye className="h-4 w-4" />
{currentActiveView ? currentActiveView.name : "Default View"}
</Button>
</DropdownMenu.Trigger>
<DropdownMenu.Content className="w-[260px]">
{systemDefaultView && (
<>
<DropdownMenu.Label>System Default</DropdownMenu.Label>
<DropdownMenu.Item
onClick={() => handleViewSelect(systemDefaultView.id)}
className="justify-between group"
>
<span className="flex items-center gap-2">
<Star className="h-4 w-4" />
{systemDefaultView.name || "System Default"}
</span>
<div className="flex items-center gap-1">
{currentActiveView?.id === systemDefaultView.id && (
<CheckCircleSolid className="h-4 w-4 text-ui-fg-positive" />
)}
<div className="opacity-0 group-hover:opacity-100">
<Tooltip content="Reset to code defaults">
<Button
variant="transparent"
size="small"
onClick={(e) => {
e.stopPropagation()
handleResetSystemDefault(systemDefaultView)
}}
>
<ArrowUturnLeft className="h-3 w-3" />
</Button>
</Tooltip>
</div>
</div>
</DropdownMenu.Item>
{personalViews.length > 0 && <DropdownMenu.Separator />}
</>
)}
{personalViews.length > 0 && (
<>
<DropdownMenu.Label>Personal Views</DropdownMenu.Label>
{personalViews.map((view) => (
<DropdownMenu.Item
key={view.id}
onClick={() => handleViewSelect(view.id)}
className="justify-between group"
>
<span className="flex-1">{view.name}</span>
<div className="flex items-center gap-1">
{currentActiveView?.id === view.id && (
<CheckCircleSolid className="h-4 w-4 text-ui-fg-positive" />
)}
<div className="opacity-0 group-hover:opacity-100 flex items-center gap-1">
<Tooltip content="Edit view">
<Button
variant="transparent"
size="small"
onClick={(e) => {
e.stopPropagation()
handleEditView(view)
}}
>
<PencilSquare className="h-3 w-3" />
</Button>
</Tooltip>
<Tooltip content="Delete view">
<Button
variant="transparent"
size="small"
onClick={(e) => {
e.stopPropagation()
handleDeleteView(view)
}}
>
<Trash className="h-3 w-3" />
</Button>
</Tooltip>
</div>
</div>
</DropdownMenu.Item>
))}
</>
)}
<DropdownMenu.Separator />
<DropdownMenu.Item
onClick={handleSaveView}
className="text-ui-fg-interactive"
>
<Plus className="h-4 w-4" />
Save current view
</DropdownMenu.Item>
</DropdownMenu.Content>
</DropdownMenu>
</div>
{saveDialogOpen && (
<SaveViewDialog
entity={entity}
currentColumns={currentColumns}
editingView={editingView}
onClose={() => {
setSaveDialogOpen(false)
setEditingView(null)
}}
onSaved={(newView) => {
setSaveDialogOpen(false)
setEditingView(null)
if (onViewChange) {
onViewChange(newView)
}
}}
/>
)}
</>
)
}

View File

@@ -0,0 +1,160 @@
import { useState, useCallback, useMemo, useEffect, useRef } from "react"
import { HttpTypes } from "@medusajs/types"
import type { ViewConfiguration } from "../../../hooks/use-view-configurations"
interface UseColumnStateReturn {
visibleColumns: Record<string, boolean>
columnOrder: string[]
currentColumns: {
visible: string[]
order: string[]
}
setVisibleColumns: (visibility: Record<string, boolean>) => void
setColumnOrder: (order: string[]) => void
handleColumnVisibilityChange: (visibility: Record<string, boolean>) => void
handleViewChange: (view: ViewConfiguration | null, apiColumns: HttpTypes.AdminViewColumn[]) => void
initializeColumns: (apiColumns: HttpTypes.AdminViewColumn[]) => void
}
export function useColumnState(
apiColumns: HttpTypes.AdminViewColumn[] | undefined,
activeView?: ViewConfiguration | null
): UseColumnStateReturn {
// Initialize state lazily to avoid unnecessary re-renders
const [visibleColumns, setVisibleColumns] = useState<Record<string, boolean>>(() => {
if (apiColumns?.length && activeView) {
// If there's an active view, initialize with its configuration
const visibility: Record<string, boolean> = {}
apiColumns.forEach(column => {
visibility[column.field] = activeView.configuration.visible_columns.includes(column.field)
})
return visibility
} else if (apiColumns?.length) {
return getInitialColumnVisibility(apiColumns)
}
return {}
})
const [columnOrder, setColumnOrder] = useState<string[]>(() => {
if (activeView) {
// If there's an active view, use its column order
return activeView.configuration.column_order || []
} else if (apiColumns?.length) {
return getInitialColumnOrder(apiColumns)
}
return []
})
const currentColumns = useMemo(() => {
const visible = Object.entries(visibleColumns)
.filter(([_, isVisible]) => isVisible)
.map(([field]) => field)
return {
visible,
order: columnOrder,
}
}, [visibleColumns, columnOrder])
const handleColumnVisibilityChange = useCallback((visibility: Record<string, boolean>) => {
setVisibleColumns(visibility)
}, [])
const handleViewChange = useCallback((
view: ViewConfiguration | null,
apiColumns: HttpTypes.AdminViewColumn[]
) => {
if (view) {
// Apply view configuration
const newVisibility: Record<string, boolean> = {}
apiColumns.forEach(column => {
newVisibility[column.field] = view.configuration.visible_columns.includes(column.field)
})
setVisibleColumns(newVisibility)
setColumnOrder(view.configuration.column_order)
} else {
// Reset to default visibility when no view is selected
setVisibleColumns(getInitialColumnVisibility(apiColumns))
setColumnOrder(getInitialColumnOrder(apiColumns))
}
}, [])
const initializeColumns = useCallback((apiColumns: HttpTypes.AdminViewColumn[]) => {
// Only initialize if we don't already have column state
if (Object.keys(visibleColumns).length === 0) {
setVisibleColumns(getInitialColumnVisibility(apiColumns))
}
if (columnOrder.length === 0) {
setColumnOrder(getInitialColumnOrder(apiColumns))
}
}, [])
// Track previous active view to detect changes
const prevActiveViewRef = useRef<ViewConfiguration | null | undefined>()
// Sync local state when active view updates (e.g., after saving)
useEffect(() => {
if (apiColumns?.length && activeView && prevActiveViewRef.current) {
// Check if the active view has been updated (same ID but different updated_at)
if (
prevActiveViewRef.current.id === activeView.id &&
prevActiveViewRef.current.updated_at !== activeView.updated_at
) {
// Sync local state with the updated view configuration
const newVisibility: Record<string, boolean> = {}
apiColumns.forEach(column => {
newVisibility[column.field] = activeView.configuration.visible_columns.includes(column.field)
})
setVisibleColumns(newVisibility)
setColumnOrder(activeView.configuration.column_order)
}
}
prevActiveViewRef.current = activeView
}, [activeView, apiColumns])
return {
visibleColumns,
columnOrder,
currentColumns,
setVisibleColumns,
setColumnOrder,
handleColumnVisibilityChange,
handleViewChange,
initializeColumns,
}
}
// Utility functions
const DEFAULT_COLUMN_ORDER = 500
/**
* Gets the initial column visibility state from API columns
*/
function getInitialColumnVisibility(
apiColumns: HttpTypes.AdminViewColumn[]
): 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
*/
function getInitialColumnOrder(
apiColumns: HttpTypes.AdminViewColumn[]
): 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)
}

View File

@@ -1,19 +1,108 @@
import { Container, Heading } from "@medusajs/ui"
import { keepPreviousData } from "@tanstack/react-query"
import { useTranslation } from "react-i18next"
import { useState, useEffect } from "react"
import { DataTable } from "../../../../../components/data-table"
import { useOrders } from "../../../../../hooks/api/orders"
import { useOrderTableColumns } from "../../../../../hooks/table/columns/use-order-table-columns"
import { useOrderTableFilters } from "../../../../../hooks/table/filters/use-order-table-filters"
import { useOrderTableQuery } from "../../../../../hooks/table/query/use-order-table-query"
import { DEFAULT_FIELDS } from "../../const"
const PAGE_SIZE = 20
export const ConfigurableOrderListTable = () => {
const { t } = useTranslation()
const [columnVisibility, setColumnVisibility] = useState<Record<string, boolean>>({})
const [columnOrder, setColumnOrder] = useState<string[]>([])
const { searchParams, raw } = useOrderTableQuery({
pageSize: PAGE_SIZE,
})
const { orders, count, isError, error, isLoading } = useOrders(
{
fields: DEFAULT_FIELDS,
...searchParams,
},
{
placeholderData: keepPreviousData,
}
)
const filters = useOrderTableFilters()
const columns = useOrderTableColumns({})
const handleViewChange = (view: any) => {
if (view) {
// Apply view configuration
const visibilityState: Record<string, boolean> = {}
const allColumns = columns.map(c => c.id!)
// Set all columns to hidden first
allColumns.forEach(col => {
visibilityState[col] = false
})
// Then show only the visible columns from the view
if (view.configuration?.visible_columns) {
view.configuration.visible_columns.forEach((col: string) => {
visibilityState[col] = true
})
}
setColumnVisibility(visibilityState)
if (view.configuration?.column_order) {
setColumnOrder(view.configuration.column_order)
}
} else {
// Reset to default (all visible)
setColumnVisibility({})
setColumnOrder([])
}
}
if (isError) {
throw error
}
return (
<Container className="divide-y p-0">
<div className="flex items-center justify-between px-6 py-4">
<Heading>{t("orders.domain")}</Heading>
</div>
<div className="px-6 py-4">
<p className="text-ui-fg-muted">
View configurations feature is enabled. Full implementation coming soon.
</p>
</div>
<DataTable
data={orders ?? []}
columns={columns}
filters={filters}
getRowId={(row) => row.id}
rowCount={count}
enablePagination
enableSearch
pageSize={PAGE_SIZE}
isLoading={isLoading}
layout="fill"
heading={t("orders.domain")}
enableColumnVisibility={true}
initialColumnVisibility={columnVisibility}
onColumnVisibilityChange={setColumnVisibility}
columnOrder={columnOrder}
onColumnOrderChange={setColumnOrder}
enableViewSelector={true}
entity="orders"
onViewChange={handleViewChange}
currentColumns={{
visible: Object.entries(columnVisibility)
.filter(([_, visible]) => visible !== false)
.map(([col]) => col),
order: columnOrder.length > 0 ? columnOrder : columns.map(c => c.id!).filter(Boolean)
}}
rowHref={(row) => `/orders/${row.id}`}
emptyState={{
message: t("orders.list.noRecordsMessage"),
}}
/>
</Container>
)
}

View File

@@ -0,0 +1,71 @@
import { HttpTypes } from "@medusajs/types"
export enum ColumnAlignment {
LEFT = "left",
CENTER = "center",
RIGHT = "right",
}
const DEFAULT_COLUMN_ORDER = 500
/**
* Determines the appropriate column alignment based on the column metadata
*/
export function getColumnAlignment(column: HttpTypes.AdminViewColumn): 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: HttpTypes.AdminViewColumn[]
): 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: HttpTypes.AdminViewColumn[]
): 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)
}