From 5a46372afd408faaae94eee8ded087843dc2c0f9 Mon Sep 17 00:00:00 2001 From: Sebastian Rindom Date: Mon, 1 Sep 2025 12:30:05 +0200 Subject: [PATCH] Feat/datatable core enhancements (#13193) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit **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. CleanShot 2025-08-13 at 2  31 35@2x --- .../src/components/data-table/data-table.tsx | 227 +++++++++----- .../table/save-view-dialog/index.ts | 1 + .../save-view-dialog/save-view-dialog.tsx | 246 +++++++++++++++ .../table/save-view-dropdown/index.ts | 1 + .../save-view-dropdown/save-view-dropdown.tsx | 88 ++++++ .../components/table/view-selector/index.ts | 2 + .../table/view-selector/view-pills.tsx | 294 ++++++++++++++++++ .../table/view-selector/view-selector.tsx | 253 +++++++++++++++ .../hooks/table/columns/use-column-state.ts | 160 ++++++++++ .../configurable-order-list-table.tsx | 105 ++++++- .../admin/dashboard/src/utils/column-utils.ts | 71 +++++ packages/core/js-sdk/src/admin/views.ts | 3 +- 12 files changed, 1368 insertions(+), 83 deletions(-) create mode 100644 packages/admin/dashboard/src/components/table/save-view-dialog/index.ts create mode 100644 packages/admin/dashboard/src/components/table/save-view-dialog/save-view-dialog.tsx create mode 100644 packages/admin/dashboard/src/components/table/save-view-dropdown/index.ts create mode 100644 packages/admin/dashboard/src/components/table/save-view-dropdown/save-view-dropdown.tsx create mode 100644 packages/admin/dashboard/src/components/table/view-selector/index.ts create mode 100644 packages/admin/dashboard/src/components/table/view-selector/view-pills.tsx create mode 100644 packages/admin/dashboard/src/components/table/view-selector/view-selector.tsx create mode 100644 packages/admin/dashboard/src/hooks/table/columns/use-column-state.ts create mode 100644 packages/admin/dashboard/src/utils/column-utils.ts diff --git a/packages/admin/dashboard/src/components/data-table/data-table.tsx b/packages/admin/dashboard/src/components/data-table/data-table.tsx index da207ae0ec..96751861be 100644 --- a/packages/admin/dashboard/src/components/data-table/data-table.tsx +++ b/packages/admin/dashboard/src/components/data-table/data-table.tsx @@ -1,19 +1,19 @@ import { - Button, - clx, + DataTable as UiDataTable, + useDataTable, DataTableColumnDef, DataTableCommand, DataTableEmptyStateProps, DataTableFilter, - DataTableFilteringState, - DataTablePaginationState, DataTableRow, DataTableRowSelectionState, - DataTableSortingState, Heading, - DataTable as Primitive, Text, - useDataTable, + Button, + DataTableFilteringState, + DataTablePaginationState, + DataTableSortingState, + clx, } from "@medusajs/ui" import React, { ReactNode, useCallback, useMemo } from "react" 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 { 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 +type ColumnOrderState = string[] type DataTableActionProps = { label: string disabled?: boolean } & ( - | { + | { to: string } - | { + | { onClick: () => void } -) + ) type DataTableActionMenuActionProps = { label: string icon: ReactNode disabled?: boolean } & ( - | { + | { to: string } - | { + | { onClick: () => void } -) + ) type DataTableActionMenuGroupProps = { actions: DataTableActionMenuActionProps[] @@ -80,6 +86,19 @@ interface DataTableProps { enableRowSelection?: boolean | ((row: DataTableRow) => boolean) } 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 = ({ @@ -103,13 +122,52 @@ export const DataTable = ({ rowSelection, isLoading = false, layout = "auto", + enableColumnVisibility = false, + initialColumnVisibility = {}, + onColumnVisibilityChange, + columnOrder, + onColumnOrderChange, + enableViewSelector = false, + entity, + onViewChange, + currentColumns, + filterBarContent, }: DataTableProps) => { 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 enableCommands = commands && commands.length > 0 const enableSorting = columns.some((column) => column.enableSorting) + const [columnVisibility, setColumnVisibility] = React.useState(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 prefixedFilterIds = filterIds.map((id) => getQueryParamKey(id, prefix)) @@ -167,18 +225,24 @@ export const DataTable = ({ const handleFilteringChange = (value: DataTableFilteringState) => { setSearchParams((prev) => { + // Remove filters that are no longer in the state Array.from(prev.keys()).forEach((key) => { - if (prefixedFilterIds.includes(key) && !(key in value)) { - prev.delete(key) + if (prefixedFilterIds.includes(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]) => { - if ( - prefixedFilterIds.includes(getQueryParamKey(key, prefix)) && - filter - ) { - prev.set(getQueryParamKey(key, prefix), JSON.stringify(filter)) + const prefixedKey = getQueryParamKey(key, prefix) + if (filter !== undefined) { + prev.set(prefixedKey, JSON.stringify(filter)) + } else { + prev.delete(prefixedKey) } }) @@ -190,6 +254,13 @@ export const DataTable = ({ return order ? parseSortingState(order) : null }, [order]) + // Memoize current configuration to prevent infinite loops + const currentConfiguration = useMemo(() => ({ + filters: filtering, + sorting: sorting, + search: search, + }), [filtering, sorting, search]) + const handleSortingChange = (value: DataTableSortingState) => { setSearchParams((prev) => { if (value) { @@ -242,92 +313,99 @@ export const DataTable = ({ onRowClick: rowHref ? onRowClick : undefined, pagination: enablePagination ? { - state: pagination, - onPaginationChange: handlePaginationChange, - } + state: pagination, + onPaginationChange: handlePaginationChange, + } : undefined, filtering: enableFiltering ? { - state: filtering, - onFilteringChange: handleFilteringChange, - } + state: filtering, + onFilteringChange: handleFilteringChange, + } : undefined, sorting: enableSorting ? { - state: sorting, - onSortingChange: handleSortingChange, - } + state: sorting, + onSortingChange: handleSortingChange, + } : undefined, search: enableSearch ? { - state: search, - onSearchChange: handleSearchChange, - } + state: search, + onSearchChange: handleSearchChange, + } : undefined, rowSelection, isLoading, + columnVisibility: effectiveEnableColumnVisibility + ? { + state: columnVisibility, + onColumnVisibilityChange: handleColumnVisibilityChange, + } + : undefined, + columnOrder: effectiveEnableColumnVisibility && columnOrder && onColumnOrderChange + ? { + state: columnOrder, + onColumnOrderChange: onColumnOrderChange, + } + : undefined, }) const shouldRenderHeading = heading || subHeading return ( - -
- {shouldRenderHeading && ( -
- {heading && {heading}} - {subHeading && ( - - {subHeading} - - )} -
- )} -
- {enableFiltering && ( - +
+ {shouldRenderHeading && ( +
+ {heading && {heading}} + {subHeading && ( + + {subHeading} + + )} +
)} - - {actionMenu && } - {action && } -
-
-
- {enableSearch && ( -
- -
- )} -
- {enableFiltering && ( - )} - +
+
+ {enableSearch && ( +
+ +
+ )} {actionMenu && } {action && }
- - + + {enablePagination && ( - + )} {enableCommands && ( - `${count} selected`} /> + `${count} selected`} /> )} - + ) } @@ -367,7 +445,7 @@ function parseFilterState( for (const id of filterIds) { const filterValue = value[id] - if (filterValue) { + if (filterValue !== undefined) { filters[id] = JSON.parse(filterValue) } } @@ -392,6 +470,8 @@ const useDataTableTranslations = () => { const toolbarTranslations = { clearAll: t("actions.clearAll"), + sort: t("filters.sortLabel"), + columns: "Columns", } return { @@ -426,3 +506,4 @@ const DataTableAction = ({ ) } + diff --git a/packages/admin/dashboard/src/components/table/save-view-dialog/index.ts b/packages/admin/dashboard/src/components/table/save-view-dialog/index.ts new file mode 100644 index 0000000000..cbd00a5533 --- /dev/null +++ b/packages/admin/dashboard/src/components/table/save-view-dialog/index.ts @@ -0,0 +1 @@ +export * from "./save-view-dialog" \ No newline at end of file diff --git a/packages/admin/dashboard/src/components/table/save-view-dialog/save-view-dialog.tsx b/packages/admin/dashboard/src/components/table/save-view-dialog/save-view-dialog.tsx new file mode 100644 index 0000000000..ac777802c6 --- /dev/null +++ b/packages/admin/dashboard/src/components/table/save-view-dialog/save-view-dialog.tsx @@ -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 + sorting?: { id: string; desc: boolean } | null + search?: string + } + editingView?: ViewConfiguration | null + onClose: () => void + onSaved: (view: ViewConfiguration) => void +} + +export const SaveViewDialog: React.FC = ({ + 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({ + 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 ( + + + +
+ + {editingView ? "Edit View" : "Save View"} + +
+
+
+ +
+ + +
+ + {isAdmin && ( +
+
+ + + This view will be the default for all users + +
+ ( + + )} + /> +
+ )} + + {editingView && ( +
+

+ You are editing the view "{editingView.name}". + {editingView.is_system_default && ( + + This is a system default view. + + )} +

+
+ )} + + {!isAdmin && isSystemDefault && ( + + Only administrators can create system default views + + )} +
+ +
+ + +
+
+
+
+
+ ) +} \ No newline at end of file diff --git a/packages/admin/dashboard/src/components/table/save-view-dropdown/index.ts b/packages/admin/dashboard/src/components/table/save-view-dropdown/index.ts new file mode 100644 index 0000000000..eacf8d907d --- /dev/null +++ b/packages/admin/dashboard/src/components/table/save-view-dropdown/index.ts @@ -0,0 +1 @@ +export { SaveViewDropdown } from "./save-view-dropdown" \ No newline at end of file diff --git a/packages/admin/dashboard/src/components/table/save-view-dropdown/save-view-dropdown.tsx b/packages/admin/dashboard/src/components/table/save-view-dropdown/save-view-dropdown.tsx new file mode 100644 index 0000000000..87774a6d60 --- /dev/null +++ b/packages/admin/dashboard/src/components/table/save-view-dropdown/save-view-dropdown.tsx @@ -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 = ({ + 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 ( + + + + + + {isDefaultView && onSaveAsDefault && ( + + + Save as system default + + )} + {!isDefaultView && currentViewId && onUpdateExisting && ( + + + Update "{currentViewName}" + + )} + {onSaveAsNew && ( + + + Save as new view + + )} + + + ) +} \ No newline at end of file diff --git a/packages/admin/dashboard/src/components/table/view-selector/index.ts b/packages/admin/dashboard/src/components/table/view-selector/index.ts new file mode 100644 index 0000000000..b297f544ab --- /dev/null +++ b/packages/admin/dashboard/src/components/table/view-selector/index.ts @@ -0,0 +1,2 @@ +export * from "./view-selector" +export * from "./view-pills" \ No newline at end of file diff --git a/packages/admin/dashboard/src/components/table/view-selector/view-pills.tsx b/packages/admin/dashboard/src/components/table/view-selector/view-pills.tsx new file mode 100644 index 0000000000..2932e97550 --- /dev/null +++ b/packages/admin/dashboard/src/components/table/view-selector/view-pills.tsx @@ -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 + sorting?: { id: string; desc: boolean } | null + search?: string + } +} + +export const ViewPills: React.FC = ({ + 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(null) + const [contextMenuOpen, setContextMenuOpen] = useState(null) + const [contextMenuPosition, setContextMenuPosition] = useState({ x: 0, y: 0 }) + const [deletingViewId, setDeletingViewId] = useState(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 ( + <> +
+ {/* Default view badge (either code-level or system default) */} +
+ handleViewSelect(null)} + onContextMenu={(e) => { + e.preventDefault() + if (systemDefaultView) { + setContextMenuPosition({ x: e.clientX, y: e.clientY }) + setContextMenuOpen('default') + } + }} + > + {defaultLabel} + + {systemDefaultView && contextMenuOpen === 'default' && ( + { + if (!open) setContextMenuOpen(null) + }} + > + +
+ + + { + handleResetSystemDefault(systemDefaultView) + setContextMenuOpen(null) + }} + className="flex items-center gap-x-2" + > + + Reset to code defaults + + + + )} +
+ + {/* Separator */} + {personalViews.length > 0 &&
|
} + + {/* Personal view badges */} + {personalViews.map((view) => ( +
+ handleViewSelect(view.id)} + onContextMenu={(e) => { + e.preventDefault() + setContextMenuPosition({ x: e.clientX, y: e.clientY }) + setContextMenuOpen(view.id) + }} + > + {view.name} + + {contextMenuOpen === view.id && ( + { + if (!open) setContextMenuOpen(null) + }} + > + +
+ + + { + handleEditView(view) + setContextMenuOpen(null) + }} + className="flex items-center gap-x-2" + > + + Edit name + + { + handleDeleteView(view) + setContextMenuOpen(null) + }} + className="flex items-center gap-x-2 text-ui-fg-error" + > + + Delete + + + + )} +
+ ))} + +
+ + {saveDialogOpen && ( + { + 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) + } + }} + /> + )} + + ) +} \ No newline at end of file diff --git a/packages/admin/dashboard/src/components/table/view-selector/view-selector.tsx b/packages/admin/dashboard/src/components/table/view-selector/view-selector.tsx new file mode 100644 index 0000000000..e0bd2fd87d --- /dev/null +++ b/packages/admin/dashboard/src/components/table/view-selector/view-selector.tsx @@ -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 = ({ + entity, + onViewChange, + currentColumns, +}) => { + const { + listViews, + activeView, + setActiveView, + isDefaultViewActive, + } = useViewConfigurations(entity) + + const [saveDialogOpen, setSaveDialogOpen] = useState(false) + const [editingView, setEditingView] = useState(null) + const [deletingViewId, setDeletingViewId] = useState(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 ( + <> +
+ + + + + + {systemDefaultView && ( + <> + System Default + handleViewSelect(systemDefaultView.id)} + className="justify-between group" + > + + + {systemDefaultView.name || "System Default"} + +
+ {currentActiveView?.id === systemDefaultView.id && ( + + )} +
+ + + +
+
+
+ {personalViews.length > 0 && } + + )} + + {personalViews.length > 0 && ( + <> + Personal Views + {personalViews.map((view) => ( + handleViewSelect(view.id)} + className="justify-between group" + > + {view.name} +
+ {currentActiveView?.id === view.id && ( + + )} +
+ + + + + + +
+
+
+ ))} + + )} + + + + + Save current view + +
+
+
+ + {saveDialogOpen && ( + { + setSaveDialogOpen(false) + setEditingView(null) + }} + onSaved={(newView) => { + setSaveDialogOpen(false) + setEditingView(null) + if (onViewChange) { + onViewChange(newView) + } + }} + /> + )} + + ) +} \ No newline at end of file diff --git a/packages/admin/dashboard/src/hooks/table/columns/use-column-state.ts b/packages/admin/dashboard/src/hooks/table/columns/use-column-state.ts new file mode 100644 index 0000000000..b1bf871513 --- /dev/null +++ b/packages/admin/dashboard/src/hooks/table/columns/use-column-state.ts @@ -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 + columnOrder: string[] + currentColumns: { + visible: string[] + order: string[] + } + setVisibleColumns: (visibility: Record) => void + setColumnOrder: (order: string[]) => void + handleColumnVisibilityChange: (visibility: Record) => 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>(() => { + if (apiColumns?.length && activeView) { + // If there's an active view, initialize with its configuration + const visibility: Record = {} + 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(() => { + 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) => { + setVisibleColumns(visibility) + }, []) + + const handleViewChange = useCallback(( + view: ViewConfiguration | null, + apiColumns: HttpTypes.AdminViewColumn[] + ) => { + if (view) { + // Apply view configuration + const newVisibility: Record = {} + 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() + + // 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 = {} + 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 { + const visibility: Record = {} + + 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) +} \ No newline at end of file diff --git a/packages/admin/dashboard/src/routes/orders/order-list/components/order-list-table/configurable-order-list-table.tsx b/packages/admin/dashboard/src/routes/orders/order-list/components/order-list-table/configurable-order-list-table.tsx index 8d1fb07cb0..dfa0b1fd29 100644 --- a/packages/admin/dashboard/src/routes/orders/order-list/components/order-list-table/configurable-order-list-table.tsx +++ b/packages/admin/dashboard/src/routes/orders/order-list/components/order-list-table/configurable-order-list-table.tsx @@ -1,19 +1,108 @@ import { Container, Heading } from "@medusajs/ui" +import { keepPreviousData } from "@tanstack/react-query" 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 = () => { const { t } = useTranslation() + + const [columnVisibility, setColumnVisibility] = useState>({}) + const [columnOrder, setColumnOrder] = useState([]) + + 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 = {} + 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 ( -
- {t("orders.domain")} -
-
-

- View configurations feature is enabled. Full implementation coming soon. -

-
+ row.id} + rowCount={count} + enablePagination + 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"), + }} + />
) } \ No newline at end of file diff --git a/packages/admin/dashboard/src/utils/column-utils.ts b/packages/admin/dashboard/src/utils/column-utils.ts new file mode 100644 index 0000000000..cfa54dc8ef --- /dev/null +++ b/packages/admin/dashboard/src/utils/column-utils.ts @@ -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 { + const visibility: Record = {} + + 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) +} \ No newline at end of file diff --git a/packages/core/js-sdk/src/admin/views.ts b/packages/core/js-sdk/src/admin/views.ts index cec1093d03..85e0be57a9 100644 --- a/packages/core/js-sdk/src/admin/views.ts +++ b/packages/core/js-sdk/src/admin/views.ts @@ -19,7 +19,6 @@ export class Views { }) } - // View configurations async listConfigurations( entity: string, @@ -122,4 +121,4 @@ export class Views { } ) } -} \ No newline at end of file +}