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 parent
fa10c78ed3
commit
5a46372afd
12 files changed
+1368
-83
No files matched your search
@@ -1,19 +1,19 @@
|
|||||||
import {
|
import {
|
||||||
Button,
|
DataTable as UiDataTable,
|
||||||
clx,
|
useDataTable,
|
||||||
DataTableColumnDef,
|
DataTableColumnDef,
|
||||||
DataTableCommand,
|
DataTableCommand,
|
||||||
DataTableEmptyStateProps,
|
DataTableEmptyStateProps,
|
||||||
DataTableFilter,
|
DataTableFilter,
|
||||||
DataTableFilteringState,
|
|
||||||
DataTablePaginationState,
|
|
||||||
DataTableRow,
|
DataTableRow,
|
||||||
DataTableRowSelectionState,
|
DataTableRowSelectionState,
|
||||||
DataTableSortingState,
|
|
||||||
Heading,
|
Heading,
|
||||||
DataTable as Primitive,
|
|
||||||
Text,
|
Text,
|
||||||
useDataTable,
|
Button,
|
||||||
|
DataTableFilteringState,
|
||||||
|
DataTablePaginationState,
|
||||||
|
DataTableSortingState,
|
||||||
|
clx,
|
||||||
} from "@medusajs/ui"
|
} from "@medusajs/ui"
|
||||||
import React, { ReactNode, useCallback, useMemo } from "react"
|
import React, { ReactNode, useCallback, useMemo } from "react"
|
||||||
import { useTranslation } from "react-i18next"
|
import { useTranslation } from "react-i18next"
|
||||||
@@ -21,31 +21,37 @@ import { Link, useNavigate, useSearchParams } from "react-router-dom"
|
|||||||
|
|
||||||
import { useQueryParams } from "../../hooks/use-query-params"
|
import { useQueryParams } from "../../hooks/use-query-params"
|
||||||
import { ActionMenu } from "../common/action-menu"
|
import { ActionMenu } from "../common/action-menu"
|
||||||
|
import { ViewPills } from "../table/view-selector"
|
||||||
|
import { useFeatureFlag } from "../../providers/feature-flag-provider"
|
||||||
|
|
||||||
|
// Types for column visibility and ordering
|
||||||
|
type VisibilityState = Record<string, boolean>
|
||||||
|
type ColumnOrderState = string[]
|
||||||
|
|
||||||
type DataTableActionProps = {
|
type DataTableActionProps = {
|
||||||
label: string
|
label: string
|
||||||
disabled?: boolean
|
disabled?: boolean
|
||||||
} & (
|
} & (
|
||||||
| {
|
| {
|
||||||
to: string
|
to: string
|
||||||
}
|
}
|
||||||
| {
|
| {
|
||||||
onClick: () => void
|
onClick: () => void
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
type DataTableActionMenuActionProps = {
|
type DataTableActionMenuActionProps = {
|
||||||
label: string
|
label: string
|
||||||
icon: ReactNode
|
icon: ReactNode
|
||||||
disabled?: boolean
|
disabled?: boolean
|
||||||
} & (
|
} & (
|
||||||
| {
|
| {
|
||||||
to: string
|
to: string
|
||||||
}
|
}
|
||||||
| {
|
| {
|
||||||
onClick: () => void
|
onClick: () => void
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
type DataTableActionMenuGroupProps = {
|
type DataTableActionMenuGroupProps = {
|
||||||
actions: DataTableActionMenuActionProps[]
|
actions: DataTableActionMenuActionProps[]
|
||||||
@@ -80,6 +86,19 @@ interface DataTableProps<TData> {
|
|||||||
enableRowSelection?: boolean | ((row: DataTableRow<TData>) => boolean)
|
enableRowSelection?: boolean | ((row: DataTableRow<TData>) => boolean)
|
||||||
}
|
}
|
||||||
layout?: "fill" | "auto"
|
layout?: "fill" | "auto"
|
||||||
|
enableColumnVisibility?: boolean
|
||||||
|
initialColumnVisibility?: VisibilityState
|
||||||
|
onColumnVisibilityChange?: (visibility: VisibilityState) => void
|
||||||
|
columnOrder?: ColumnOrderState
|
||||||
|
onColumnOrderChange?: (order: ColumnOrderState) => void
|
||||||
|
enableViewSelector?: boolean
|
||||||
|
entity?: string
|
||||||
|
onViewChange?: (view: any) => void
|
||||||
|
currentColumns?: {
|
||||||
|
visible: string[]
|
||||||
|
order: string[]
|
||||||
|
}
|
||||||
|
filterBarContent?: React.ReactNode
|
||||||
}
|
}
|
||||||
|
|
||||||
export const DataTable = <TData,>({
|
export const DataTable = <TData,>({
|
||||||
@@ -103,13 +122,52 @@ export const DataTable = <TData,>({
|
|||||||
rowSelection,
|
rowSelection,
|
||||||
isLoading = false,
|
isLoading = false,
|
||||||
layout = "auto",
|
layout = "auto",
|
||||||
|
enableColumnVisibility = false,
|
||||||
|
initialColumnVisibility = {},
|
||||||
|
onColumnVisibilityChange,
|
||||||
|
columnOrder,
|
||||||
|
onColumnOrderChange,
|
||||||
|
enableViewSelector = false,
|
||||||
|
entity,
|
||||||
|
onViewChange,
|
||||||
|
currentColumns,
|
||||||
|
filterBarContent,
|
||||||
}: DataTableProps<TData>) => {
|
}: DataTableProps<TData>) => {
|
||||||
const { t } = useTranslation()
|
const { t } = useTranslation()
|
||||||
|
const isViewConfigEnabled = useFeatureFlag("view_configurations")
|
||||||
|
|
||||||
|
// If view config is disabled, don't use column visibility features
|
||||||
|
const effectiveEnableColumnVisibility = isViewConfigEnabled && enableColumnVisibility
|
||||||
|
const effectiveEnableViewSelector = isViewConfigEnabled && enableViewSelector
|
||||||
|
|
||||||
const enableFiltering = filters && filters.length > 0
|
const enableFiltering = filters && filters.length > 0
|
||||||
const enableCommands = commands && commands.length > 0
|
const enableCommands = commands && commands.length > 0
|
||||||
const enableSorting = columns.some((column) => column.enableSorting)
|
const enableSorting = columns.some((column) => column.enableSorting)
|
||||||
|
|
||||||
|
const [columnVisibility, setColumnVisibility] = React.useState<VisibilityState>(initialColumnVisibility)
|
||||||
|
|
||||||
|
// Update column visibility when initial visibility changes
|
||||||
|
React.useEffect(() => {
|
||||||
|
// Deep compare to check if the visibility has actually changed
|
||||||
|
const currentKeys = Object.keys(columnVisibility).sort()
|
||||||
|
const newKeys = Object.keys(initialColumnVisibility).sort()
|
||||||
|
|
||||||
|
const hasChanged = currentKeys.length !== newKeys.length ||
|
||||||
|
currentKeys.some((key, index) => key !== newKeys[index]) ||
|
||||||
|
Object.entries(initialColumnVisibility).some(([key, value]) => columnVisibility[key] !== value)
|
||||||
|
|
||||||
|
if (hasChanged) {
|
||||||
|
setColumnVisibility(initialColumnVisibility)
|
||||||
|
}
|
||||||
|
}, [initialColumnVisibility])
|
||||||
|
|
||||||
|
// Wrapper function to handle column visibility changes
|
||||||
|
const handleColumnVisibilityChange = React.useCallback((visibility: VisibilityState) => {
|
||||||
|
setColumnVisibility(visibility)
|
||||||
|
onColumnVisibilityChange?.(visibility)
|
||||||
|
}, [onColumnVisibilityChange])
|
||||||
|
|
||||||
|
// Extract filter IDs for query param management
|
||||||
const filterIds = useMemo(() => filters?.map((f) => f.id) ?? [], [filters])
|
const filterIds = useMemo(() => filters?.map((f) => f.id) ?? [], [filters])
|
||||||
const prefixedFilterIds = filterIds.map((id) => getQueryParamKey(id, prefix))
|
const prefixedFilterIds = filterIds.map((id) => getQueryParamKey(id, prefix))
|
||||||
|
|
||||||
@@ -167,18 +225,24 @@ export const DataTable = <TData,>({
|
|||||||
|
|
||||||
const handleFilteringChange = (value: DataTableFilteringState) => {
|
const handleFilteringChange = (value: DataTableFilteringState) => {
|
||||||
setSearchParams((prev) => {
|
setSearchParams((prev) => {
|
||||||
|
// Remove filters that are no longer in the state
|
||||||
Array.from(prev.keys()).forEach((key) => {
|
Array.from(prev.keys()).forEach((key) => {
|
||||||
if (prefixedFilterIds.includes(key) && !(key in value)) {
|
if (prefixedFilterIds.includes(key)) {
|
||||||
prev.delete(key)
|
// Extract the unprefixed key
|
||||||
|
const unprefixedKey = prefix ? key.replace(`${prefix}_`, '') : key
|
||||||
|
if (!(unprefixedKey in value)) {
|
||||||
|
prev.delete(key)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// Add or update filters in the state
|
||||||
Object.entries(value).forEach(([key, filter]) => {
|
Object.entries(value).forEach(([key, filter]) => {
|
||||||
if (
|
const prefixedKey = getQueryParamKey(key, prefix)
|
||||||
prefixedFilterIds.includes(getQueryParamKey(key, prefix)) &&
|
if (filter !== undefined) {
|
||||||
filter
|
prev.set(prefixedKey, JSON.stringify(filter))
|
||||||
) {
|
} else {
|
||||||
prev.set(getQueryParamKey(key, prefix), JSON.stringify(filter))
|
prev.delete(prefixedKey)
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -190,6 +254,13 @@ export const DataTable = <TData,>({
|
|||||||
return order ? parseSortingState(order) : null
|
return order ? parseSortingState(order) : null
|
||||||
}, [order])
|
}, [order])
|
||||||
|
|
||||||
|
// Memoize current configuration to prevent infinite loops
|
||||||
|
const currentConfiguration = useMemo(() => ({
|
||||||
|
filters: filtering,
|
||||||
|
sorting: sorting,
|
||||||
|
search: search,
|
||||||
|
}), [filtering, sorting, search])
|
||||||
|
|
||||||
const handleSortingChange = (value: DataTableSortingState) => {
|
const handleSortingChange = (value: DataTableSortingState) => {
|
||||||
setSearchParams((prev) => {
|
setSearchParams((prev) => {
|
||||||
if (value) {
|
if (value) {
|
||||||
@@ -242,92 +313,99 @@ export const DataTable = <TData,>({
|
|||||||
onRowClick: rowHref ? onRowClick : undefined,
|
onRowClick: rowHref ? onRowClick : undefined,
|
||||||
pagination: enablePagination
|
pagination: enablePagination
|
||||||
? {
|
? {
|
||||||
state: pagination,
|
state: pagination,
|
||||||
onPaginationChange: handlePaginationChange,
|
onPaginationChange: handlePaginationChange,
|
||||||
}
|
}
|
||||||
: undefined,
|
: undefined,
|
||||||
filtering: enableFiltering
|
filtering: enableFiltering
|
||||||
? {
|
? {
|
||||||
state: filtering,
|
state: filtering,
|
||||||
onFilteringChange: handleFilteringChange,
|
onFilteringChange: handleFilteringChange,
|
||||||
}
|
}
|
||||||
: undefined,
|
: undefined,
|
||||||
sorting: enableSorting
|
sorting: enableSorting
|
||||||
? {
|
? {
|
||||||
state: sorting,
|
state: sorting,
|
||||||
onSortingChange: handleSortingChange,
|
onSortingChange: handleSortingChange,
|
||||||
}
|
}
|
||||||
: undefined,
|
: undefined,
|
||||||
search: enableSearch
|
search: enableSearch
|
||||||
? {
|
? {
|
||||||
state: search,
|
state: search,
|
||||||
onSearchChange: handleSearchChange,
|
onSearchChange: handleSearchChange,
|
||||||
}
|
}
|
||||||
: undefined,
|
: undefined,
|
||||||
rowSelection,
|
rowSelection,
|
||||||
isLoading,
|
isLoading,
|
||||||
|
columnVisibility: effectiveEnableColumnVisibility
|
||||||
|
? {
|
||||||
|
state: columnVisibility,
|
||||||
|
onColumnVisibilityChange: handleColumnVisibilityChange,
|
||||||
|
}
|
||||||
|
: undefined,
|
||||||
|
columnOrder: effectiveEnableColumnVisibility && columnOrder && onColumnOrderChange
|
||||||
|
? {
|
||||||
|
state: columnOrder,
|
||||||
|
onColumnOrderChange: onColumnOrderChange,
|
||||||
|
}
|
||||||
|
: undefined,
|
||||||
})
|
})
|
||||||
|
|
||||||
const shouldRenderHeading = heading || subHeading
|
const shouldRenderHeading = heading || subHeading
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Primitive
|
<UiDataTable
|
||||||
instance={instance}
|
instance={instance}
|
||||||
className={clx({
|
className={layout === "fill" ? "h-full [&_tr]:last-of-type:!border-b" : undefined}
|
||||||
"h-full [&_tr]:last-of-type:!border-b": layout === "fill",
|
|
||||||
})}
|
|
||||||
>
|
>
|
||||||
<Primitive.Toolbar
|
<UiDataTable.Toolbar
|
||||||
className="flex flex-col items-start justify-between gap-2 md:flex-row md:items-center"
|
className="flex flex-col items-start justify-between gap-2 md:flex-row md:items-center"
|
||||||
translations={toolbarTranslations}
|
translations={toolbarTranslations}
|
||||||
|
filterBarContent={filterBarContent}
|
||||||
>
|
>
|
||||||
<div className="flex w-full items-center justify-between gap-2">
|
<div className="flex w-full items-center justify-between gap-2">
|
||||||
{shouldRenderHeading && (
|
<div className="flex items-center gap-x-4">
|
||||||
<div>
|
{shouldRenderHeading && (
|
||||||
{heading && <Heading>{heading}</Heading>}
|
<div>
|
||||||
{subHeading && (
|
{heading && <Heading>{heading}</Heading>}
|
||||||
<Text size="small" className="text-ui-fg-subtle">
|
{subHeading && (
|
||||||
{subHeading}
|
<Text size="small" className="text-ui-fg-subtle">
|
||||||
</Text>
|
{subHeading}
|
||||||
)}
|
</Text>
|
||||||
</div>
|
)}
|
||||||
)}
|
</div>
|
||||||
<div className="flex items-center justify-end gap-x-2 md:hidden">
|
|
||||||
{enableFiltering && (
|
|
||||||
<Primitive.FilterMenu tooltip={t("filters.filterLabel")} />
|
|
||||||
)}
|
)}
|
||||||
<Primitive.SortingMenu tooltip={t("filters.sortLabel")} />
|
{effectiveEnableViewSelector && entity && (
|
||||||
{actionMenu && <ActionMenu variant="primary" {...actionMenu} />}
|
<ViewPills
|
||||||
{action && <DataTableAction {...action} />}
|
entity={entity}
|
||||||
</div>
|
onViewChange={onViewChange}
|
||||||
</div>
|
currentColumns={currentColumns}
|
||||||
<div className="flex w-full items-center gap-2 md:justify-end">
|
currentConfiguration={currentConfiguration}
|
||||||
{enableSearch && (
|
|
||||||
<div className="w-full md:w-auto">
|
|
||||||
<Primitive.Search
|
|
||||||
placeholder={t("filters.searchLabel")}
|
|
||||||
autoFocus={autoFocusSearch}
|
|
||||||
/>
|
/>
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
<div className="hidden items-center gap-x-2 md:flex">
|
|
||||||
{enableFiltering && (
|
|
||||||
<Primitive.FilterMenu tooltip={t("filters.filterLabel")} />
|
|
||||||
)}
|
)}
|
||||||
<Primitive.SortingMenu tooltip={t("filters.sortLabel")} />
|
</div>
|
||||||
|
<div className="flex items-center gap-x-2">
|
||||||
|
{enableSearch && (
|
||||||
|
<div className="w-full md:w-auto">
|
||||||
|
<UiDataTable.Search
|
||||||
|
placeholder={t("filters.searchLabel")}
|
||||||
|
autoFocus={autoFocusSearch}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
{actionMenu && <ActionMenu variant="primary" {...actionMenu} />}
|
{actionMenu && <ActionMenu variant="primary" {...actionMenu} />}
|
||||||
{action && <DataTableAction {...action} />}
|
{action && <DataTableAction {...action} />}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</Primitive.Toolbar>
|
</UiDataTable.Toolbar>
|
||||||
<Primitive.Table emptyState={emptyState} />
|
<UiDataTable.Table emptyState={emptyState} />
|
||||||
{enablePagination && (
|
{enablePagination && (
|
||||||
<Primitive.Pagination translations={paginationTranslations} />
|
<UiDataTable.Pagination translations={paginationTranslations} />
|
||||||
)}
|
)}
|
||||||
{enableCommands && (
|
{enableCommands && (
|
||||||
<Primitive.CommandBar selectedLabel={(count) => `${count} selected`} />
|
<UiDataTable.CommandBar selectedLabel={(count) => `${count} selected`} />
|
||||||
)}
|
)}
|
||||||
</Primitive>
|
</UiDataTable>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -367,7 +445,7 @@ function parseFilterState(
|
|||||||
for (const id of filterIds) {
|
for (const id of filterIds) {
|
||||||
const filterValue = value[id]
|
const filterValue = value[id]
|
||||||
|
|
||||||
if (filterValue) {
|
if (filterValue !== undefined) {
|
||||||
filters[id] = JSON.parse(filterValue)
|
filters[id] = JSON.parse(filterValue)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -392,6 +470,8 @@ const useDataTableTranslations = () => {
|
|||||||
|
|
||||||
const toolbarTranslations = {
|
const toolbarTranslations = {
|
||||||
clearAll: t("actions.clearAll"),
|
clearAll: t("actions.clearAll"),
|
||||||
|
sort: t("filters.sortLabel"),
|
||||||
|
columns: "Columns",
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
@@ -426,3 +506,4 @@ const DataTableAction = ({
|
|||||||
</Button>
|
</Button>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
export * from "./save-view-dialog"
|
||||||
@@ -0,0 +1,246 @@
|
|||||||
|
import React, { useState } from "react"
|
||||||
|
import {
|
||||||
|
Button,
|
||||||
|
Input,
|
||||||
|
Label,
|
||||||
|
Switch,
|
||||||
|
FocusModal,
|
||||||
|
Hint,
|
||||||
|
toast,
|
||||||
|
} from "@medusajs/ui"
|
||||||
|
import { useForm, Controller } from "react-hook-form"
|
||||||
|
import { useViewConfigurations, useViewConfiguration } from "../../../hooks/use-view-configurations"
|
||||||
|
import type { ViewConfiguration } from "../../../hooks/use-view-configurations"
|
||||||
|
|
||||||
|
|
||||||
|
type SaveViewFormData = {
|
||||||
|
name: string
|
||||||
|
isSystemDefault: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
interface SaveViewDialogProps {
|
||||||
|
entity: string
|
||||||
|
currentColumns?: {
|
||||||
|
visible: string[]
|
||||||
|
order: string[]
|
||||||
|
}
|
||||||
|
currentConfiguration?: {
|
||||||
|
filters?: Record<string, unknown>
|
||||||
|
sorting?: { id: string; desc: boolean } | null
|
||||||
|
search?: string
|
||||||
|
}
|
||||||
|
editingView?: ViewConfiguration | null
|
||||||
|
onClose: () => void
|
||||||
|
onSaved: (view: ViewConfiguration) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
export const SaveViewDialog: React.FC<SaveViewDialogProps> = ({
|
||||||
|
entity,
|
||||||
|
currentColumns,
|
||||||
|
currentConfiguration,
|
||||||
|
editingView,
|
||||||
|
onClose,
|
||||||
|
onSaved,
|
||||||
|
}) => {
|
||||||
|
const { createView } = useViewConfigurations(entity)
|
||||||
|
const { updateView } = useViewConfiguration(entity, editingView?.id || '')
|
||||||
|
const [isLoading, setIsLoading] = useState(false)
|
||||||
|
|
||||||
|
const {
|
||||||
|
register,
|
||||||
|
handleSubmit,
|
||||||
|
formState: { errors },
|
||||||
|
watch,
|
||||||
|
control,
|
||||||
|
} = useForm<SaveViewFormData>({
|
||||||
|
mode: "onChange",
|
||||||
|
defaultValues: {
|
||||||
|
name: editingView?.name || "",
|
||||||
|
isSystemDefault: editingView?.is_system_default || false,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
const isSystemDefault = watch("isSystemDefault")
|
||||||
|
|
||||||
|
const isAdmin = true // TODO: Get from auth context
|
||||||
|
|
||||||
|
const onSubmit = async (data: SaveViewFormData) => {
|
||||||
|
// Manual validation for new views
|
||||||
|
if (!editingView && !data.isSystemDefault && (!data.name || !data.name.trim())) {
|
||||||
|
toast.error("Name is required unless setting as system default")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validation for editing views - if converting to system default without a name, that's ok
|
||||||
|
if (editingView && !data.isSystemDefault && !editingView.name && (!data.name || !data.name.trim())) {
|
||||||
|
toast.error("Name is required for personal views")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!currentColumns && !editingView) {
|
||||||
|
toast.error("No column configuration to save")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
setIsLoading(true)
|
||||||
|
try {
|
||||||
|
if (editingView) {
|
||||||
|
// Update existing view
|
||||||
|
const updateData: any = {
|
||||||
|
is_system_default: data.isSystemDefault,
|
||||||
|
set_active: true, // Always set updated view as active
|
||||||
|
}
|
||||||
|
|
||||||
|
// Only include name if it was provided and changed (empty string means keep current)
|
||||||
|
if (data.name && data.name.trim() !== "" && data.name !== editingView.name) {
|
||||||
|
updateData.name = data.name
|
||||||
|
}
|
||||||
|
|
||||||
|
// Only update configuration if currentColumns is provided
|
||||||
|
if (currentColumns) {
|
||||||
|
updateData.configuration = {
|
||||||
|
visible_columns: currentColumns.visible,
|
||||||
|
column_order: currentColumns.order,
|
||||||
|
filters: currentConfiguration?.filters || {},
|
||||||
|
sorting: currentConfiguration?.sorting || null,
|
||||||
|
search: currentConfiguration?.search || "",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await updateView.mutateAsync(updateData)
|
||||||
|
onSaved(result.view_configuration)
|
||||||
|
} else {
|
||||||
|
// Create new view
|
||||||
|
if (!currentColumns) {
|
||||||
|
toast.error("No column configuration to save")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Only include name if provided (not required for system defaults)
|
||||||
|
const createData: any = {
|
||||||
|
entity,
|
||||||
|
is_system_default: data.isSystemDefault,
|
||||||
|
set_active: true, // Always set newly created view as active
|
||||||
|
configuration: {
|
||||||
|
visible_columns: currentColumns.visible,
|
||||||
|
column_order: currentColumns.order,
|
||||||
|
filters: currentConfiguration?.filters || {},
|
||||||
|
sorting: currentConfiguration?.sorting || null,
|
||||||
|
search: currentConfiguration?.search || "",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
// Only add name if it's provided and not empty
|
||||||
|
if (data.name && data.name.trim()) {
|
||||||
|
createData.name = data.name.trim()
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await createView.mutateAsync(createData)
|
||||||
|
onSaved(result.view_configuration)
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
// Error is handled by the hook
|
||||||
|
} finally {
|
||||||
|
setIsLoading(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<FocusModal open onOpenChange={onClose}>
|
||||||
|
<FocusModal.Content>
|
||||||
|
<FocusModal.Header>
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<FocusModal.Title>
|
||||||
|
{editingView ? "Edit View" : "Save View"}
|
||||||
|
</FocusModal.Title>
|
||||||
|
</div>
|
||||||
|
</FocusModal.Header>
|
||||||
|
<form onSubmit={handleSubmit(onSubmit)} noValidate>
|
||||||
|
<FocusModal.Body className="flex flex-col gap-y-4">
|
||||||
|
<div className="flex flex-col gap-y-2">
|
||||||
|
<Label htmlFor="name" weight="plus">
|
||||||
|
View Name {(editingView || isSystemDefault) && <span className="text-ui-fg-muted font-normal">(optional)</span>}
|
||||||
|
</Label>
|
||||||
|
<Input
|
||||||
|
id="name"
|
||||||
|
{...register("name")}
|
||||||
|
placeholder={
|
||||||
|
editingView
|
||||||
|
? editingView.name
|
||||||
|
: isSystemDefault
|
||||||
|
? "Leave empty for no name"
|
||||||
|
: "e.g., My Custom View"
|
||||||
|
}
|
||||||
|
autoComplete="off"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{isAdmin && (
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div className="flex flex-col gap-y-1">
|
||||||
|
<Label htmlFor="isSystemDefault" weight="plus">
|
||||||
|
Set as System Default
|
||||||
|
</Label>
|
||||||
|
<Hint>
|
||||||
|
This view will be the default for all users
|
||||||
|
</Hint>
|
||||||
|
</div>
|
||||||
|
<Controller
|
||||||
|
name="isSystemDefault"
|
||||||
|
control={control}
|
||||||
|
render={({ field }) => (
|
||||||
|
<Switch
|
||||||
|
id="isSystemDefault"
|
||||||
|
checked={field.value}
|
||||||
|
onCheckedChange={field.onChange}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{editingView && (
|
||||||
|
<div className="rounded-md bg-ui-bg-subtle p-3">
|
||||||
|
<p className="text-ui-fg-subtle text-sm">
|
||||||
|
You are editing the view "{editingView.name}".
|
||||||
|
{editingView.is_system_default && (
|
||||||
|
<span className="block mt-1 text-ui-fg-warning">
|
||||||
|
This is a system default view.
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{!isAdmin && isSystemDefault && (
|
||||||
|
<Hint variant="error">
|
||||||
|
Only administrators can create system default views
|
||||||
|
</Hint>
|
||||||
|
)}
|
||||||
|
</FocusModal.Body>
|
||||||
|
<FocusModal.Footer>
|
||||||
|
<div className="flex items-center gap-x-2">
|
||||||
|
<Button
|
||||||
|
variant="secondary"
|
||||||
|
size="small"
|
||||||
|
type="button"
|
||||||
|
onClick={onClose}
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="primary"
|
||||||
|
size="small"
|
||||||
|
type="submit"
|
||||||
|
isLoading={isLoading}
|
||||||
|
disabled={!isAdmin && isSystemDefault}
|
||||||
|
>
|
||||||
|
{editingView ? "Update" : "Save"} View
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</FocusModal.Footer>
|
||||||
|
</form>
|
||||||
|
</FocusModal.Content>
|
||||||
|
</FocusModal>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
export { SaveViewDropdown } from "./save-view-dropdown"
|
||||||
+88
@@ -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)
|
||||||
|
}
|
||||||
+97
-8
@@ -1,19 +1,108 @@
|
|||||||
import { Container, Heading } from "@medusajs/ui"
|
import { Container, Heading } from "@medusajs/ui"
|
||||||
|
import { keepPreviousData } from "@tanstack/react-query"
|
||||||
import { useTranslation } from "react-i18next"
|
import { useTranslation } from "react-i18next"
|
||||||
|
import { useState, useEffect } from "react"
|
||||||
|
|
||||||
|
import { DataTable } from "../../../../../components/data-table"
|
||||||
|
import { useOrders } from "../../../../../hooks/api/orders"
|
||||||
|
import { useOrderTableColumns } from "../../../../../hooks/table/columns/use-order-table-columns"
|
||||||
|
import { useOrderTableFilters } from "../../../../../hooks/table/filters/use-order-table-filters"
|
||||||
|
import { useOrderTableQuery } from "../../../../../hooks/table/query/use-order-table-query"
|
||||||
|
|
||||||
|
import { DEFAULT_FIELDS } from "../../const"
|
||||||
|
|
||||||
|
const PAGE_SIZE = 20
|
||||||
|
|
||||||
export const ConfigurableOrderListTable = () => {
|
export const ConfigurableOrderListTable = () => {
|
||||||
const { t } = useTranslation()
|
const { t } = useTranslation()
|
||||||
|
|
||||||
|
const [columnVisibility, setColumnVisibility] = useState<Record<string, boolean>>({})
|
||||||
|
const [columnOrder, setColumnOrder] = useState<string[]>([])
|
||||||
|
|
||||||
|
const { searchParams, raw } = useOrderTableQuery({
|
||||||
|
pageSize: PAGE_SIZE,
|
||||||
|
})
|
||||||
|
|
||||||
|
const { orders, count, isError, error, isLoading } = useOrders(
|
||||||
|
{
|
||||||
|
fields: DEFAULT_FIELDS,
|
||||||
|
...searchParams,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
placeholderData: keepPreviousData,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
const filters = useOrderTableFilters()
|
||||||
|
const columns = useOrderTableColumns({})
|
||||||
|
|
||||||
|
const handleViewChange = (view: any) => {
|
||||||
|
if (view) {
|
||||||
|
// Apply view configuration
|
||||||
|
const visibilityState: Record<string, boolean> = {}
|
||||||
|
const allColumns = columns.map(c => c.id!)
|
||||||
|
|
||||||
|
// Set all columns to hidden first
|
||||||
|
allColumns.forEach(col => {
|
||||||
|
visibilityState[col] = false
|
||||||
|
})
|
||||||
|
|
||||||
|
// Then show only the visible columns from the view
|
||||||
|
if (view.configuration?.visible_columns) {
|
||||||
|
view.configuration.visible_columns.forEach((col: string) => {
|
||||||
|
visibilityState[col] = true
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
setColumnVisibility(visibilityState)
|
||||||
|
|
||||||
|
if (view.configuration?.column_order) {
|
||||||
|
setColumnOrder(view.configuration.column_order)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Reset to default (all visible)
|
||||||
|
setColumnVisibility({})
|
||||||
|
setColumnOrder([])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isError) {
|
||||||
|
throw error
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Container className="divide-y p-0">
|
<Container className="divide-y p-0">
|
||||||
<div className="flex items-center justify-between px-6 py-4">
|
<DataTable
|
||||||
<Heading>{t("orders.domain")}</Heading>
|
data={orders ?? []}
|
||||||
</div>
|
columns={columns}
|
||||||
<div className="px-6 py-4">
|
filters={filters}
|
||||||
<p className="text-ui-fg-muted">
|
getRowId={(row) => row.id}
|
||||||
View configurations feature is enabled. Full implementation coming soon.
|
rowCount={count}
|
||||||
</p>
|
enablePagination
|
||||||
</div>
|
enableSearch
|
||||||
|
pageSize={PAGE_SIZE}
|
||||||
|
isLoading={isLoading}
|
||||||
|
layout="fill"
|
||||||
|
heading={t("orders.domain")}
|
||||||
|
enableColumnVisibility={true}
|
||||||
|
initialColumnVisibility={columnVisibility}
|
||||||
|
onColumnVisibilityChange={setColumnVisibility}
|
||||||
|
columnOrder={columnOrder}
|
||||||
|
onColumnOrderChange={setColumnOrder}
|
||||||
|
enableViewSelector={true}
|
||||||
|
entity="orders"
|
||||||
|
onViewChange={handleViewChange}
|
||||||
|
currentColumns={{
|
||||||
|
visible: Object.entries(columnVisibility)
|
||||||
|
.filter(([_, visible]) => visible !== false)
|
||||||
|
.map(([col]) => col),
|
||||||
|
order: columnOrder.length > 0 ? columnOrder : columns.map(c => c.id!).filter(Boolean)
|
||||||
|
}}
|
||||||
|
rowHref={(row) => `/orders/${row.id}`}
|
||||||
|
emptyState={{
|
||||||
|
message: t("orders.list.noRecordsMessage"),
|
||||||
|
}}
|
||||||
|
/>
|
||||||
</Container>
|
</Container>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,71 @@
|
|||||||
|
import { HttpTypes } from "@medusajs/types"
|
||||||
|
|
||||||
|
export enum ColumnAlignment {
|
||||||
|
LEFT = "left",
|
||||||
|
CENTER = "center",
|
||||||
|
RIGHT = "right",
|
||||||
|
}
|
||||||
|
|
||||||
|
const DEFAULT_COLUMN_ORDER = 500
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Determines the appropriate column alignment based on the column metadata
|
||||||
|
*/
|
||||||
|
export function getColumnAlignment(column: HttpTypes.AdminViewColumn): ColumnAlignment {
|
||||||
|
// Currency columns should be right-aligned
|
||||||
|
if (column.semantic_type === "currency" || column.data_type === "currency") {
|
||||||
|
return ColumnAlignment.RIGHT
|
||||||
|
}
|
||||||
|
|
||||||
|
// Number columns should be right-aligned (except identifiers)
|
||||||
|
if (column.data_type === "number" && column.context !== "identifier") {
|
||||||
|
return ColumnAlignment.RIGHT
|
||||||
|
}
|
||||||
|
|
||||||
|
// Total/amount/price columns should be right-aligned
|
||||||
|
if (
|
||||||
|
column.field.includes("total") ||
|
||||||
|
column.field.includes("amount") ||
|
||||||
|
column.field.includes("price")
|
||||||
|
) {
|
||||||
|
return ColumnAlignment.RIGHT
|
||||||
|
}
|
||||||
|
|
||||||
|
// Country columns should be center-aligned
|
||||||
|
if (column.field === "country" || column.field.includes("country_code")) {
|
||||||
|
return ColumnAlignment.CENTER
|
||||||
|
}
|
||||||
|
|
||||||
|
// Default to left alignment
|
||||||
|
return ColumnAlignment.LEFT
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Gets the initial column visibility state from API columns
|
||||||
|
*/
|
||||||
|
export function getInitialColumnVisibility(
|
||||||
|
apiColumns: HttpTypes.AdminViewColumn[]
|
||||||
|
): Record<string, boolean> {
|
||||||
|
const visibility: Record<string, boolean> = {}
|
||||||
|
|
||||||
|
apiColumns.forEach(column => {
|
||||||
|
visibility[column.field] = column.default_visible
|
||||||
|
})
|
||||||
|
|
||||||
|
return visibility
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Gets the initial column order from API columns
|
||||||
|
*/
|
||||||
|
export function getInitialColumnOrder(
|
||||||
|
apiColumns: HttpTypes.AdminViewColumn[]
|
||||||
|
): string[] {
|
||||||
|
const sortedColumns = [...apiColumns].sort((a, b) => {
|
||||||
|
const orderA = a.default_order ?? DEFAULT_COLUMN_ORDER
|
||||||
|
const orderB = b.default_order ?? DEFAULT_COLUMN_ORDER
|
||||||
|
return orderA - orderB
|
||||||
|
})
|
||||||
|
|
||||||
|
return sortedColumns.map(col => col.field)
|
||||||
|
}
|
||||||
@@ -19,7 +19,6 @@ export class Views {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
// View configurations
|
// View configurations
|
||||||
async listConfigurations(
|
async listConfigurations(
|
||||||
entity: string,
|
entity: string,
|
||||||
@@ -122,4 +121,4 @@ export class Views {
|
|||||||
}
|
}
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Reference in new issue
Block a user