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:
Sebastian Rindom
2025-09-01 19:04:18 +02:00
committed by GitHub
parent f8d8eeace1
commit c717535ca2
22 changed files with 1735 additions and 384 deletions

View File

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

View File

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