feat(admin): add configurable order views (#13211)
Adds support for configurable order views. https://github.com/user-attachments/assets/ed4a5f61-1667-4ed7-9478-423894f3eba6
This commit is contained in:
@@ -3,19 +3,17 @@ import {
|
||||
Button,
|
||||
Input,
|
||||
Label,
|
||||
Switch,
|
||||
FocusModal,
|
||||
Hint,
|
||||
toast,
|
||||
Drawer,
|
||||
Heading,
|
||||
Text,
|
||||
} from "@medusajs/ui"
|
||||
import { useForm, Controller } from "react-hook-form"
|
||||
import { useForm } 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 {
|
||||
@@ -50,35 +48,14 @@ export const SaveViewDialog: React.FC<SaveViewDialogProps> = ({
|
||||
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")
|
||||
if (!data.name.trim()) {
|
||||
return
|
||||
}
|
||||
|
||||
@@ -86,56 +63,30 @@ export const SaveViewDialog: React.FC<SaveViewDialogProps> = ({
|
||||
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)
|
||||
const result = await updateView.mutateAsync({
|
||||
name: data.name.trim(),
|
||||
configuration: {
|
||||
visible_columns: currentColumns?.visible || editingView.configuration.visible_columns,
|
||||
column_order: currentColumns?.order || editingView.configuration.column_order,
|
||||
filters: currentConfiguration?.filters || editingView.configuration.filters || {},
|
||||
sorting: currentConfiguration?.sorting || editingView.configuration.sorting || null,
|
||||
search: currentConfiguration?.search || editingView.configuration.search || "",
|
||||
},
|
||||
})
|
||||
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
|
||||
const result = await createView.mutateAsync({
|
||||
name: data.name.trim(),
|
||||
set_active: true,
|
||||
configuration: {
|
||||
visible_columns: currentColumns.visible,
|
||||
column_order: currentColumns.order,
|
||||
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) {
|
||||
@@ -146,101 +97,67 @@ export const SaveViewDialog: React.FC<SaveViewDialogProps> = ({
|
||||
}
|
||||
|
||||
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">
|
||||
<Drawer open onOpenChange={onClose}>
|
||||
<Drawer.Content className="flex flex-col">
|
||||
<Drawer.Header>
|
||||
<Drawer.Title asChild>
|
||||
<Heading>
|
||||
{editingView ? "Edit View Name" : "Save as New View"}
|
||||
</Heading>
|
||||
</Drawer.Title>
|
||||
<Drawer.Description asChild>
|
||||
<Text>
|
||||
{editingView
|
||||
? "Change the name of your saved view"
|
||||
: "Save your current configuration as a new view"}
|
||||
</Text>
|
||||
</Drawer.Description>
|
||||
</Drawer.Header>
|
||||
|
||||
<form onSubmit={handleSubmit(onSubmit)} className="flex flex-1 flex-col">
|
||||
<Drawer.Body className="flex-1">
|
||||
<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>}
|
||||
View Name
|
||||
</Label>
|
||||
<Input
|
||||
id="name"
|
||||
{...register("name")}
|
||||
placeholder={
|
||||
editingView
|
||||
? editingView.name
|
||||
: isSystemDefault
|
||||
? "Leave empty for no name"
|
||||
: "e.g., My Custom View"
|
||||
}
|
||||
autoComplete="off"
|
||||
{...register("name", {
|
||||
required: "Name is required",
|
||||
validate: value => value.trim().length > 0 || "Name cannot be empty"
|
||||
})}
|
||||
type="text"
|
||||
placeholder="Enter view name"
|
||||
autoFocus
|
||||
/>
|
||||
{errors.name && (
|
||||
<span className="text-sm text-ui-fg-error">
|
||||
{errors.name.message}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</Drawer.Body>
|
||||
|
||||
{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">
|
||||
<Drawer.Footer>
|
||||
<Drawer.Close asChild>
|
||||
<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>
|
||||
</Drawer.Close>
|
||||
<Button
|
||||
variant="primary"
|
||||
size="small"
|
||||
type="submit"
|
||||
isLoading={isLoading}
|
||||
>
|
||||
{editingView ? "Update" : "Save"}
|
||||
</Button>
|
||||
</Drawer.Footer>
|
||||
</form>
|
||||
</FocusModal.Content>
|
||||
</FocusModal>
|
||||
</Drawer.Content>
|
||||
</Drawer>
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -16,7 +16,6 @@ import { SaveViewDialog } from "../save-view-dialog"
|
||||
|
||||
interface ViewPillsProps {
|
||||
entity: string
|
||||
onViewChange?: (view: ViewConfiguration | null) => void
|
||||
currentColumns?: {
|
||||
visible: string[]
|
||||
order: string[]
|
||||
@@ -30,7 +29,6 @@ interface ViewPillsProps {
|
||||
|
||||
export const ViewPills: React.FC<ViewPillsProps> = ({
|
||||
entity,
|
||||
onViewChange,
|
||||
currentColumns,
|
||||
currentConfiguration,
|
||||
}) => {
|
||||
@@ -40,8 +38,8 @@ export const ViewPills: React.FC<ViewPillsProps> = ({
|
||||
setActiveView,
|
||||
isDefaultViewActive,
|
||||
} = useViewConfigurations(entity)
|
||||
|
||||
const views = listViews.data?.view_configurations || []
|
||||
|
||||
const views = listViews?.view_configurations || []
|
||||
|
||||
const [saveDialogOpen, setSaveDialogOpen] = useState(false)
|
||||
const [editingView, setEditingView] = useState<ViewConfiguration | null>(null)
|
||||
@@ -50,43 +48,25 @@ export const ViewPills: React.FC<ViewPillsProps> = ({
|
||||
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)
|
||||
const currentActiveView = activeView?.view_configuration || null
|
||||
|
||||
// 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)
|
||||
try {
|
||||
if (viewId === null) {
|
||||
// Select default view - clear the active view
|
||||
await setActiveView.mutateAsync(null)
|
||||
return
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
const view = views.find(v => v.id === viewId)
|
||||
if (view) {
|
||||
await setActiveView.mutateAsync(viewId)
|
||||
if (onViewChange) {
|
||||
onViewChange(view)
|
||||
const view = views.find(v => v.id === viewId)
|
||||
if (view) {
|
||||
await setActiveView.mutateAsync(viewId)
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Error in handleViewSelect:", error)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -108,18 +88,13 @@ export const ViewPills: React.FC<ViewPillsProps> = ({
|
||||
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])
|
||||
}, [deletingViewId, deleteView.mutateAsync])
|
||||
|
||||
const handleEditView = (view: ViewConfiguration) => {
|
||||
setEditingView(view)
|
||||
@@ -282,13 +257,9 @@ export const ViewPills: React.FC<ViewPillsProps> = ({
|
||||
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)
|
||||
}
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user