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:
@@ -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<string, boolean>
|
||||
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<TData> {
|
||||
enableRowSelection?: boolean | ((row: DataTableRow<TData>) => 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 = <TData,>({
|
||||
@@ -103,13 +122,52 @@ export const DataTable = <TData,>({
|
||||
rowSelection,
|
||||
isLoading = false,
|
||||
layout = "auto",
|
||||
enableColumnVisibility = false,
|
||||
initialColumnVisibility = {},
|
||||
onColumnVisibilityChange,
|
||||
columnOrder,
|
||||
onColumnOrderChange,
|
||||
enableViewSelector = false,
|
||||
entity,
|
||||
onViewChange,
|
||||
currentColumns,
|
||||
filterBarContent,
|
||||
}: DataTableProps<TData>) => {
|
||||
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<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 prefixedFilterIds = filterIds.map((id) => getQueryParamKey(id, prefix))
|
||||
|
||||
@@ -167,18 +225,24 @@ export const DataTable = <TData,>({
|
||||
|
||||
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 = <TData,>({
|
||||
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 = <TData,>({
|
||||
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 (
|
||||
<Primitive
|
||||
<UiDataTable
|
||||
instance={instance}
|
||||
className={clx({
|
||||
"h-full [&_tr]:last-of-type:!border-b": layout === "fill",
|
||||
})}
|
||||
className={layout === "fill" ? "h-full [&_tr]:last-of-type:!border-b" : undefined}
|
||||
>
|
||||
<Primitive.Toolbar
|
||||
<UiDataTable.Toolbar
|
||||
className="flex flex-col items-start justify-between gap-2 md:flex-row md:items-center"
|
||||
translations={toolbarTranslations}
|
||||
filterBarContent={filterBarContent}
|
||||
>
|
||||
<div className="flex w-full items-center justify-between gap-2">
|
||||
{shouldRenderHeading && (
|
||||
<div>
|
||||
{heading && <Heading>{heading}</Heading>}
|
||||
{subHeading && (
|
||||
<Text size="small" className="text-ui-fg-subtle">
|
||||
{subHeading}
|
||||
</Text>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
<div className="flex items-center justify-end gap-x-2 md:hidden">
|
||||
{enableFiltering && (
|
||||
<Primitive.FilterMenu tooltip={t("filters.filterLabel")} />
|
||||
<div className="flex items-center gap-x-4">
|
||||
{shouldRenderHeading && (
|
||||
<div>
|
||||
{heading && <Heading>{heading}</Heading>}
|
||||
{subHeading && (
|
||||
<Text size="small" className="text-ui-fg-subtle">
|
||||
{subHeading}
|
||||
</Text>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
<Primitive.SortingMenu tooltip={t("filters.sortLabel")} />
|
||||
{actionMenu && <ActionMenu variant="primary" {...actionMenu} />}
|
||||
{action && <DataTableAction {...action} />}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex w-full items-center gap-2 md:justify-end">
|
||||
{enableSearch && (
|
||||
<div className="w-full md:w-auto">
|
||||
<Primitive.Search
|
||||
placeholder={t("filters.searchLabel")}
|
||||
autoFocus={autoFocusSearch}
|
||||
{effectiveEnableViewSelector && entity && (
|
||||
<ViewPills
|
||||
entity={entity}
|
||||
onViewChange={onViewChange}
|
||||
currentColumns={currentColumns}
|
||||
currentConfiguration={currentConfiguration}
|
||||
/>
|
||||
</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} />}
|
||||
{action && <DataTableAction {...action} />}
|
||||
</div>
|
||||
</div>
|
||||
</Primitive.Toolbar>
|
||||
<Primitive.Table emptyState={emptyState} />
|
||||
</UiDataTable.Toolbar>
|
||||
<UiDataTable.Table emptyState={emptyState} />
|
||||
{enablePagination && (
|
||||
<Primitive.Pagination translations={paginationTranslations} />
|
||||
<UiDataTable.Pagination translations={paginationTranslations} />
|
||||
)}
|
||||
{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) {
|
||||
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 = ({
|
||||
</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 { 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<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 (
|
||||
<Container className="divide-y p-0">
|
||||
<div className="flex items-center justify-between px-6 py-4">
|
||||
<Heading>{t("orders.domain")}</Heading>
|
||||
</div>
|
||||
<div className="px-6 py-4">
|
||||
<p className="text-ui-fg-muted">
|
||||
View configurations feature is enabled. Full implementation coming soon.
|
||||
</p>
|
||||
</div>
|
||||
<DataTable
|
||||
data={orders ?? []}
|
||||
columns={columns}
|
||||
filters={filters}
|
||||
getRowId={(row) => 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"),
|
||||
}}
|
||||
/>
|
||||
</Container>
|
||||
)
|
||||
}
|
||||
71
packages/admin/dashboard/src/utils/column-utils.ts
Normal file
71
packages/admin/dashboard/src/utils/column-utils.ts
Normal file
@@ -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)
|
||||
}
|
||||
Reference in New Issue
Block a user