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 authored and GitHub committed 2025-09-01 10:30:05 +00:00
1 parent fa10c78ed3
commit 5a46372afd
12 files changed
+1368 -83

No files matched your search

@@ -1,19 +1,19 @@
import { import {
Button, DataTable as UiDataTable,
clx, useDataTable,
DataTableColumnDef, DataTableColumnDef,
DataTableCommand, DataTableCommand,
DataTableEmptyStateProps, DataTableEmptyStateProps,
DataTableFilter, DataTableFilter,
DataTableFilteringState,
DataTablePaginationState,
DataTableRow, DataTableRow,
DataTableRowSelectionState, DataTableRowSelectionState,
DataTableSortingState,
Heading, Heading,
DataTable as Primitive,
Text, Text,
useDataTable, Button,
DataTableFilteringState,
DataTablePaginationState,
DataTableSortingState,
clx,
} from "@medusajs/ui" } from "@medusajs/ui"
import React, { ReactNode, useCallback, useMemo } from "react" import React, { ReactNode, useCallback, useMemo } from "react"
import { useTranslation } from "react-i18next" 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 { useQueryParams } from "../../hooks/use-query-params"
import { ActionMenu } from "../common/action-menu" 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 = { type DataTableActionProps = {
label: string label: string
disabled?: boolean disabled?: boolean
} & ( } & (
| { | {
to: string to: string
} }
| { | {
onClick: () => void onClick: () => void
} }
) )
type DataTableActionMenuActionProps = { type DataTableActionMenuActionProps = {
label: string label: string
icon: ReactNode icon: ReactNode
disabled?: boolean disabled?: boolean
} & ( } & (
| { | {
to: string to: string
} }
| { | {
onClick: () => void onClick: () => void
} }
) )
type DataTableActionMenuGroupProps = { type DataTableActionMenuGroupProps = {
actions: DataTableActionMenuActionProps[] actions: DataTableActionMenuActionProps[]
@@ -80,6 +86,19 @@ interface DataTableProps<TData> {
enableRowSelection?: boolean | ((row: DataTableRow<TData>) => boolean) enableRowSelection?: boolean | ((row: DataTableRow<TData>) => boolean)
} }
layout?: "fill" | "auto" 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,>({ export const DataTable = <TData,>({
@@ -103,13 +122,52 @@ export const DataTable = <TData,>({
rowSelection, rowSelection,
isLoading = false, isLoading = false,
layout = "auto", layout = "auto",
enableColumnVisibility = false,
initialColumnVisibility = {},
onColumnVisibilityChange,
columnOrder,
onColumnOrderChange,
enableViewSelector = false,
entity,
onViewChange,
currentColumns,
filterBarContent,
}: DataTableProps<TData>) => { }: DataTableProps<TData>) => {
const { t } = useTranslation() 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 enableFiltering = filters && filters.length > 0
const enableCommands = commands && commands.length > 0 const enableCommands = commands && commands.length > 0
const enableSorting = columns.some((column) => column.enableSorting) 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 filterIds = useMemo(() => filters?.map((f) => f.id) ?? [], [filters])
const prefixedFilterIds = filterIds.map((id) => getQueryParamKey(id, prefix)) const prefixedFilterIds = filterIds.map((id) => getQueryParamKey(id, prefix))
@@ -167,18 +225,24 @@ export const DataTable = <TData,>({
const handleFilteringChange = (value: DataTableFilteringState) => { const handleFilteringChange = (value: DataTableFilteringState) => {
setSearchParams((prev) => { setSearchParams((prev) => {
// Remove filters that are no longer in the state
Array.from(prev.keys()).forEach((key) => { Array.from(prev.keys()).forEach((key) => {
if (prefixedFilterIds.includes(key) && !(key in value)) { if (prefixedFilterIds.includes(key)) {
prev.delete(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]) => { Object.entries(value).forEach(([key, filter]) => {
if ( const prefixedKey = getQueryParamKey(key, prefix)
prefixedFilterIds.includes(getQueryParamKey(key, prefix)) && if (filter !== undefined) {
filter prev.set(prefixedKey, JSON.stringify(filter))
) { } else {
prev.set(getQueryParamKey(key, prefix), JSON.stringify(filter)) prev.delete(prefixedKey)
} }
}) })
@@ -190,6 +254,13 @@ export const DataTable = <TData,>({
return order ? parseSortingState(order) : null return order ? parseSortingState(order) : null
}, [order]) }, [order])
// Memoize current configuration to prevent infinite loops
const currentConfiguration = useMemo(() => ({
filters: filtering,
sorting: sorting,
search: search,
}), [filtering, sorting, search])
const handleSortingChange = (value: DataTableSortingState) => { const handleSortingChange = (value: DataTableSortingState) => {
setSearchParams((prev) => { setSearchParams((prev) => {
if (value) { if (value) {
@@ -242,92 +313,99 @@ export const DataTable = <TData,>({
onRowClick: rowHref ? onRowClick : undefined, onRowClick: rowHref ? onRowClick : undefined,
pagination: enablePagination pagination: enablePagination
? { ? {
state: pagination, state: pagination,
onPaginationChange: handlePaginationChange, onPaginationChange: handlePaginationChange,
} }
: undefined, : undefined,
filtering: enableFiltering filtering: enableFiltering
? { ? {
state: filtering, state: filtering,
onFilteringChange: handleFilteringChange, onFilteringChange: handleFilteringChange,
} }
: undefined, : undefined,
sorting: enableSorting sorting: enableSorting
? { ? {
state: sorting, state: sorting,
onSortingChange: handleSortingChange, onSortingChange: handleSortingChange,
} }
: undefined, : undefined,
search: enableSearch search: enableSearch
? { ? {
state: search, state: search,
onSearchChange: handleSearchChange, onSearchChange: handleSearchChange,
} }
: undefined, : undefined,
rowSelection, rowSelection,
isLoading, isLoading,
columnVisibility: effectiveEnableColumnVisibility
? {
state: columnVisibility,
onColumnVisibilityChange: handleColumnVisibilityChange,
}
: undefined,
columnOrder: effectiveEnableColumnVisibility && columnOrder && onColumnOrderChange
? {
state: columnOrder,
onColumnOrderChange: onColumnOrderChange,
}
: undefined,
}) })
const shouldRenderHeading = heading || subHeading const shouldRenderHeading = heading || subHeading
return ( return (
<Primitive <UiDataTable
instance={instance} instance={instance}
className={clx({ className={layout === "fill" ? "h-full [&_tr]:last-of-type:!border-b" : undefined}
"h-full [&_tr]:last-of-type:!border-b": layout === "fill",
})}
> >
<Primitive.Toolbar <UiDataTable.Toolbar
className="flex flex-col items-start justify-between gap-2 md:flex-row md:items-center" className="flex flex-col items-start justify-between gap-2 md:flex-row md:items-center"
translations={toolbarTranslations} translations={toolbarTranslations}
filterBarContent={filterBarContent}
> >
<div className="flex w-full items-center justify-between gap-2"> <div className="flex w-full items-center justify-between gap-2">
{shouldRenderHeading && ( <div className="flex items-center gap-x-4">
<div> {shouldRenderHeading && (
{heading && <Heading>{heading}</Heading>} <div>
{subHeading && ( {heading && <Heading>{heading}</Heading>}
<Text size="small" className="text-ui-fg-subtle"> {subHeading && (
{subHeading} <Text size="small" className="text-ui-fg-subtle">
</Text> {subHeading}
)} </Text>
</div> )}
)} </div>
<div className="flex items-center justify-end gap-x-2 md:hidden">
{enableFiltering && (
<Primitive.FilterMenu tooltip={t("filters.filterLabel")} />
)} )}
<Primitive.SortingMenu tooltip={t("filters.sortLabel")} /> {effectiveEnableViewSelector && entity && (
{actionMenu && <ActionMenu variant="primary" {...actionMenu} />} <ViewPills
{action && <DataTableAction {...action} />} entity={entity}
</div> onViewChange={onViewChange}
</div> currentColumns={currentColumns}
<div className="flex w-full items-center gap-2 md:justify-end"> currentConfiguration={currentConfiguration}
{enableSearch && (
<div className="w-full md:w-auto">
<Primitive.Search
placeholder={t("filters.searchLabel")}
autoFocus={autoFocusSearch}
/> />
</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} />} {actionMenu && <ActionMenu variant="primary" {...actionMenu} />}
{action && <DataTableAction {...action} />} {action && <DataTableAction {...action} />}
</div> </div>
</div> </div>
</Primitive.Toolbar> </UiDataTable.Toolbar>
<Primitive.Table emptyState={emptyState} /> <UiDataTable.Table emptyState={emptyState} />
{enablePagination && ( {enablePagination && (
<Primitive.Pagination translations={paginationTranslations} /> <UiDataTable.Pagination translations={paginationTranslations} />
)} )}
{enableCommands && ( {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) { for (const id of filterIds) {
const filterValue = value[id] const filterValue = value[id]
if (filterValue) { if (filterValue !== undefined) {
filters[id] = JSON.parse(filterValue) filters[id] = JSON.parse(filterValue)
} }
} }
@@ -392,6 +470,8 @@ const useDataTableTranslations = () => {
const toolbarTranslations = { const toolbarTranslations = {
clearAll: t("actions.clearAll"), clearAll: t("actions.clearAll"),
sort: t("filters.sortLabel"),
columns: "Columns",
} }
return { return {
@@ -426,3 +506,4 @@ const DataTableAction = ({
</Button> </Button>
) )
} }
@@ -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)
}
}}
/>
)}
</>
)
}
@@ -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)
}
@@ -1,19 +1,108 @@
import { Container, Heading } from "@medusajs/ui" import { Container, Heading } from "@medusajs/ui"
import { keepPreviousData } from "@tanstack/react-query"
import { useTranslation } from "react-i18next" 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 = () => { export const ConfigurableOrderListTable = () => {
const { t } = useTranslation() 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 ( return (
<Container className="divide-y p-0"> <Container className="divide-y p-0">
<div className="flex items-center justify-between px-6 py-4"> <DataTable
<Heading>{t("orders.domain")}</Heading> data={orders ?? []}
</div> columns={columns}
<div className="px-6 py-4"> filters={filters}
<p className="text-ui-fg-muted"> getRowId={(row) => row.id}
View configurations feature is enabled. Full implementation coming soon. rowCount={count}
</p> enablePagination
</div> 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> </Container>
) )
} }
@@ -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)
}
+1 -2
View File
@@ -19,7 +19,6 @@ export class Views {
}) })
} }
// View configurations // View configurations
async listConfigurations( async listConfigurations(
entity: string, entity: string,
@@ -122,4 +121,4 @@ export class Views {
} }
) )
} }
} }