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:
@@ -0,0 +1 @@
|
||||
export * from "./save-view-dialog"
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export { SaveViewDropdown } from "./save-view-dropdown"
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
export * from "./view-selector"
|
||||
export * from "./view-pills"
|
||||
@@ -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)
|
||||
}
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user