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

@@ -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)
}
}}
/>
)}
</>
)
}