From 23d5a902b134ab7cc5f4cae83336207a0bcb3aff Mon Sep 17 00:00:00 2001 From: Sebastian Rindom Date: Mon, 15 Sep 2025 10:59:00 +0200 Subject: [PATCH] feat(dashboard): reusable config datatable (#13389) * feat: add a reusable configurable data table * fix: cleanup * fix: cleanup * fix: cache invalidation * fix: test --------- Co-authored-by: Oli Juhl <59018053+olivermrbl@users.noreply.github.com> --- .../configurable-data-table.tsx | 447 ++++++++++++++++++ .../table/configurable-data-table/index.ts | 3 + .../save-view-dropdown.tsx | 87 ++++ .../admin/dashboard/src/hooks/api/views.tsx | 8 +- .../hooks/table/columns/use-column-state.ts | 25 +- .../use-configurable-order-table-columns.tsx | 49 -- .../use-configurable-table-columns.tsx | 105 ++++ .../hooks/table/use-table-configuration.tsx | 313 ++++++++++++ .../src/i18n/translations/$schema.json | 65 +++ .../dashboard/src/i18n/translations/en.json | 20 + .../src/lib/table/entity-adapters.tsx | 89 ++++ .../src/lib/table/entity-defaults.ts | 80 ++++ .../utils => lib/table}/field-utils.ts | 25 +- .../dashboard/src/lib/table/table-adapters.ts | 87 ++++ .../configurable-order-list-table.tsx | 395 +--------------- .../hooks/use-required-fields.ts | 12 - .../order-list-table/order-table-adapter.tsx | 68 +++ .../order-list-table/utils/column-utils.ts | 71 --- .../view-configuration/admin/responses.ts | 3 +- 19 files changed, 1407 insertions(+), 545 deletions(-) create mode 100644 packages/admin/dashboard/src/components/table/configurable-data-table/configurable-data-table.tsx create mode 100644 packages/admin/dashboard/src/components/table/configurable-data-table/index.ts create mode 100644 packages/admin/dashboard/src/components/table/configurable-data-table/save-view-dropdown.tsx delete mode 100644 packages/admin/dashboard/src/hooks/table/columns/use-configurable-order-table-columns.tsx create mode 100644 packages/admin/dashboard/src/hooks/table/columns/use-configurable-table-columns.tsx create mode 100644 packages/admin/dashboard/src/hooks/table/use-table-configuration.tsx create mode 100644 packages/admin/dashboard/src/lib/table/entity-adapters.tsx create mode 100644 packages/admin/dashboard/src/lib/table/entity-defaults.ts rename packages/admin/dashboard/src/{routes/orders/order-list/components/order-list-table/utils => lib/table}/field-utils.ts (70%) create mode 100644 packages/admin/dashboard/src/lib/table/table-adapters.ts delete mode 100644 packages/admin/dashboard/src/routes/orders/order-list/components/order-list-table/hooks/use-required-fields.ts create mode 100644 packages/admin/dashboard/src/routes/orders/order-list/components/order-list-table/order-table-adapter.tsx delete mode 100644 packages/admin/dashboard/src/routes/orders/order-list/components/order-list-table/utils/column-utils.ts diff --git a/packages/admin/dashboard/src/components/table/configurable-data-table/configurable-data-table.tsx b/packages/admin/dashboard/src/components/table/configurable-data-table/configurable-data-table.tsx new file mode 100644 index 0000000000..f8d9a117c2 --- /dev/null +++ b/packages/admin/dashboard/src/components/table/configurable-data-table/configurable-data-table.tsx @@ -0,0 +1,447 @@ +import React, { useState, ReactNode } from "react" +import { Container, Button } from "@medusajs/ui" +import { useTranslation } from "react-i18next" +import { DataTable } from "../../data-table" +import { SaveViewDialog } from "../save-view-dialog" +import { SaveViewDropdown } from "./save-view-dropdown" +import { useTableConfiguration } from "../../../hooks/table/use-table-configuration" +import { useOrderTableQuery } from "../../../hooks/table/query/use-order-table-query" +import { useConfigurableTableColumns } from "../../../hooks/table/columns/use-configurable-table-columns" +import { getEntityAdapter } from "../../../lib/table/entity-adapters" +import { DataTableColumnDef, DataTableEmptyStateProps, DataTableFilter } from "@medusajs/ui" +import { TableAdapter } from "../../../lib/table/table-adapters" + +export interface ConfigurableDataTableProps { + // Use adapter pattern for entity-specific configuration + adapter: TableAdapter + + // Optional overrides + heading?: string + subHeading?: string + pageSize?: number + queryPrefix?: string + layout?: "fill" | "auto" + actions?: ReactNode +} + +// Legacy props interface for backward compatibility +export interface LegacyConfigurableDataTableProps { + // Entity configuration + entity: string + entityName?: string + + // Data and columns + data: TData[] + columns: DataTableColumnDef[] + filters?: DataTableFilter[] + + // Table configuration + pageSize?: number + queryPrefix?: string + getRowId: (row: TData) => string + rowHref?: (row: TData) => string + + // UI configuration + heading?: string + subHeading?: string + emptyState?: DataTableEmptyStateProps + + // Loading and counts + isLoading?: boolean + rowCount?: number + + // Additional content + actions?: ReactNode + + // Layout + layout?: "fill" | "auto" +} + +// Internal component that handles adapter mode +function ConfigurableDataTableWithAdapter({ + adapter, + heading, + subHeading, + pageSize: pageSizeProp, + queryPrefix: queryPrefixProp, + layout = "fill", + // actions, // Currently unused +}: ConfigurableDataTableProps) { + const { t } = useTranslation() + const [saveDialogOpen, setSaveDialogOpen] = useState(false) + const [editingView, setEditingView] = useState(null) + + const entity = adapter.entity + const entityName = adapter.entityName + const filters = adapter.filters || [] + const pageSize = pageSizeProp || adapter.pageSize || 20 + const queryPrefix = queryPrefixProp || adapter.queryPrefix || "" + + // Get table configuration (single source of truth) + const { + activeView, + createView, + updateView, + isViewConfigEnabled, + visibleColumns, + columnOrder, + currentColumns, + setColumnOrder, + handleColumnVisibilityChange, + currentConfiguration, + hasConfigurationChanged, + handleClearConfiguration, + isLoadingColumns, + apiColumns, + requiredFields, + } = useTableConfiguration({ + entity, + pageSize, + queryPrefix, + filters, + }) + + // Get query params for data fetching + const { searchParams } = useOrderTableQuery({ + pageSize, + prefix: queryPrefix, + }) + + // Fetch data using adapter + const fetchResult = adapter.useData(requiredFields, searchParams) + + // Generate columns + // Use adapter's column adapter if provided, otherwise fall back to entity adapter + const columnAdapter = adapter.columnAdapter || getEntityAdapter(entity) + const generatedColumns = useConfigurableTableColumns(entity, apiColumns || [], columnAdapter) + const columns = (adapter.getColumns && apiColumns) + ? adapter.getColumns(apiColumns) + : generatedColumns + + // Handle errors + if (fetchResult.isError) { + throw fetchResult.error + } + + // View save handlers + const handleSaveAsDefault = async () => { + try { + if (activeView?.is_system_default) { + await updateView.mutateAsync({ + name: activeView.name || null, + configuration: { + visible_columns: currentColumns.visible, + column_order: currentColumns.order, + filters: currentConfiguration.filters || {}, + sorting: currentConfiguration.sorting || null, + search: currentConfiguration.search || "", + } + }) + } else { + await createView.mutateAsync({ + name: "Default", + is_system_default: true, + set_active: true, + configuration: { + visible_columns: currentColumns.visible, + column_order: currentColumns.order, + filters: currentConfiguration.filters || {}, + sorting: currentConfiguration.sorting || null, + search: currentConfiguration.search || "", + } + }) + } + } catch (_) { + // Error is handled by the hook + } + } + + const handleUpdateExisting = async () => { + if (!activeView) return + + try { + await updateView.mutateAsync({ + name: activeView.name, + configuration: { + visible_columns: currentColumns.visible, + column_order: currentColumns.order, + filters: currentConfiguration.filters || {}, + sorting: currentConfiguration.sorting || null, + search: currentConfiguration.search || "", + } + }) + } catch (_) { + // Error is handled by the hook + } + } + + const handleSaveAsNew = () => { + setSaveDialogOpen(true) + setEditingView(null) + } + + // Filter bar content with save controls + const filterBarContent = hasConfigurationChanged ? ( + <> + + + + ) : null + + return ( + + row.id)} + rowCount={fetchResult.count} + enablePagination + enableSearch + pageSize={pageSize} + isLoading={fetchResult.isLoading || isLoadingColumns} + layout={layout} + heading={heading || entityName || (entity ? t(`${entity}.domain` as any) : "")} + subHeading={subHeading} + enableColumnVisibility={isViewConfigEnabled} + initialColumnVisibility={visibleColumns} + onColumnVisibilityChange={handleColumnVisibilityChange} + columnOrder={columnOrder} + onColumnOrderChange={setColumnOrder} + enableViewSelector={isViewConfigEnabled} + entity={entity} + currentColumns={currentColumns} + filterBarContent={filterBarContent} + rowHref={adapter.getRowHref as ((row: any) => string) | undefined} + emptyState={adapter.emptyState || { + empty: { + heading: t(`${entity}.list.noRecordsMessage` as any), + } + }} + prefix={queryPrefix} + /> + + {saveDialogOpen && ( + { + setSaveDialogOpen(false) + setEditingView(null) + }} + onSaved={() => { + setSaveDialogOpen(false) + setEditingView(null) + }} + /> + )} + + ) +} + +// Internal component that handles legacy mode +function ConfigurableDataTableLegacy(props: LegacyConfigurableDataTableProps) { + const { t } = useTranslation() + const [saveDialogOpen, setSaveDialogOpen] = useState(false) + const [editingView, setEditingView] = useState(null) + + const { + entity, + entityName, + data, + columns, + filters = [], + pageSize = 20, + queryPrefix = "", + getRowId, + rowHref, + heading, + subHeading, + emptyState, + isLoading = false, + rowCount = 0, + // actions, // Currently unused + layout = "fill", + } = props + + // Get table configuration + const { + activeView, + createView, + updateView, + isViewConfigEnabled, + visibleColumns, + columnOrder, + currentColumns, + setColumnOrder, + handleColumnVisibilityChange, + currentConfiguration, + hasConfigurationChanged, + handleClearConfiguration, + isLoadingColumns, + } = useTableConfiguration({ + entity, + pageSize, + queryPrefix, + filters, + }) + + // View save handlers + const handleSaveAsDefault = async () => { + try { + if (activeView?.is_system_default) { + await updateView.mutateAsync({ + name: activeView.name || null, + configuration: { + visible_columns: currentColumns.visible, + column_order: currentColumns.order, + filters: currentConfiguration.filters || {}, + sorting: currentConfiguration.sorting || null, + search: currentConfiguration.search || "", + } + }) + } else { + await createView.mutateAsync({ + name: "Default", + is_system_default: true, + set_active: true, + configuration: { + visible_columns: currentColumns.visible, + column_order: currentColumns.order, + filters: currentConfiguration.filters || {}, + sorting: currentConfiguration.sorting || null, + search: currentConfiguration.search || "", + } + }) + } + } catch (_) { + // Error is handled by the hook + } + } + + const handleUpdateExisting = async () => { + if (!activeView) return + + try { + await updateView.mutateAsync({ + name: activeView.name, + configuration: { + visible_columns: currentColumns.visible, + column_order: currentColumns.order, + filters: currentConfiguration.filters || {}, + sorting: currentConfiguration.sorting || null, + search: currentConfiguration.search || "", + } + }) + } catch (_) { + // Error is handled by the hook + } + } + + const handleSaveAsNew = () => { + setSaveDialogOpen(true) + setEditingView(null) + } + + // Filter bar content with save controls + const filterBarContent = hasConfigurationChanged ? ( + <> + + + + ) : null + + return ( + + + + {saveDialogOpen && ( + { + setSaveDialogOpen(false) + setEditingView(null) + }} + onSaved={() => { + setSaveDialogOpen(false) + setEditingView(null) + }} + /> + )} + + ) +} + +// Main export that delegates to the appropriate component +export function ConfigurableDataTable( + props: ConfigurableDataTableProps | LegacyConfigurableDataTableProps +) { + // Check if using new adapter pattern or legacy props + if ('adapter' in props) { + return {...props} /> + } else { + return {...props} /> + } +} diff --git a/packages/admin/dashboard/src/components/table/configurable-data-table/index.ts b/packages/admin/dashboard/src/components/table/configurable-data-table/index.ts new file mode 100644 index 0000000000..52187f4020 --- /dev/null +++ b/packages/admin/dashboard/src/components/table/configurable-data-table/index.ts @@ -0,0 +1,3 @@ +export { ConfigurableDataTable } from "./configurable-data-table" +export type { ConfigurableDataTableProps } from "./configurable-data-table" +export { SaveViewDropdown } from "./save-view-dropdown" \ No newline at end of file diff --git a/packages/admin/dashboard/src/components/table/configurable-data-table/save-view-dropdown.tsx b/packages/admin/dashboard/src/components/table/configurable-data-table/save-view-dropdown.tsx new file mode 100644 index 0000000000..803712c0f2 --- /dev/null +++ b/packages/admin/dashboard/src/components/table/configurable-data-table/save-view-dropdown.tsx @@ -0,0 +1,87 @@ +import React from "react" +import { Button, DropdownMenu, usePrompt } from "@medusajs/ui" +import { ChevronDownMini } from "@medusajs/icons" +import { useTranslation } from "react-i18next" + +interface SaveViewDropdownProps { + isDefaultView: boolean + currentViewId?: string + currentViewName?: string + onSaveAsDefault: () => void + onUpdateExisting: () => void + onSaveAsNew: () => void +} + +export const SaveViewDropdown: React.FC = ({ + isDefaultView, + currentViewId, + currentViewName, + onSaveAsDefault, + onUpdateExisting, + onSaveAsNew, +}) => { + const { t } = useTranslation() + const prompt = usePrompt() + + const handleSaveAsDefault = async () => { + const result = await prompt({ + title: t("views.prompts.updateDefault.title"), + description: t("views.prompts.updateDefault.description"), + confirmText: t("views.prompts.updateDefault.confirmText"), + cancelText: t("views.prompts.updateDefault.cancelText"), + }) + + if (result) { + onSaveAsDefault() + } + } + + const handleUpdateExisting = async () => { + const result = await prompt({ + title: t("views.prompts.updateView.title"), + description: t("views.prompts.updateView.description", { name: currentViewName }), + confirmText: t("views.prompts.updateView.confirmText"), + cancelText: t("views.prompts.updateView.cancelText"), + }) + + if (result) { + onUpdateExisting() + } + } + + return ( + + + + + + {isDefaultView ? ( + <> + + {t("views.updateDefaultForEveryone")} + + + {t("views.saveAsNew")} + + + ) : ( + <> + + {t("views.updateViewName")} + + + {t("views.saveAsNew")} + + + )} + + + ) +} \ No newline at end of file diff --git a/packages/admin/dashboard/src/hooks/api/views.tsx b/packages/admin/dashboard/src/hooks/api/views.tsx index 62c27e6ed2..d224ac365c 100644 --- a/packages/admin/dashboard/src/hooks/api/views.tsx +++ b/packages/admin/dashboard/src/hooks/api/views.tsx @@ -28,7 +28,11 @@ _viewsKeys.active = function(entity: string) { } _viewsKeys.configurations = function(entity: string, query?: any) { - return [this.all, "configurations", entity, query] + const key = [this.all, "configurations", entity] + if (query !== undefined) { + key.push(query) + } + return key } export const viewsQueryKeys = _viewsKeys @@ -176,6 +180,8 @@ export const useUpdateViewConfiguration = ( onSuccess: (data, variables, context) => { queryClient.invalidateQueries({ queryKey: viewsQueryKeys.configurations(entity) }) queryClient.invalidateQueries({ queryKey: viewsQueryKeys.detail(id) }) + // Also invalidate active configuration if this view is currently active + queryClient.invalidateQueries({ queryKey: viewsQueryKeys.active(entity) }) options?.onSuccess?.(data, variables, context) }, }) diff --git a/packages/admin/dashboard/src/hooks/table/columns/use-column-state.ts b/packages/admin/dashboard/src/hooks/table/columns/use-column-state.ts index fd3bb8e23b..05c39dfffb 100644 --- a/packages/admin/dashboard/src/hooks/table/columns/use-column-state.ts +++ b/packages/admin/dashboard/src/hooks/table/columns/use-column-state.ts @@ -1,6 +1,8 @@ import { useState, useCallback, useMemo, useEffect, useRef } from "react" -// import { HttpTypes } from "@medusajs/types" -import type { ViewConfiguration } from "../../../hooks/use-view-configurations" +import { HttpTypes } from "@medusajs/types" + +type ViewConfiguration = + HttpTypes.AdminViewConfigurationResponse["view_configuration"] interface ColumnState { visibility: Record @@ -20,13 +22,13 @@ interface UseColumnStateReturn { handleColumnVisibilityChange: (visibility: Record) => void handleViewChange: ( view: ViewConfiguration | null, - apiColumns: HttpTypes.AdminViewColumn[] + apiColumns: HttpTypes.AdminColumn[] ) => void - initializeColumns: (apiColumns: HttpTypes.AdminViewColumn[]) => void + initializeColumns: (apiColumns: HttpTypes.AdminColumn[]) => void } export function useColumnState( - apiColumns: HttpTypes.AdminViewColumn[] | undefined, + apiColumns: HttpTypes.AdminColumn[] | undefined, activeView?: ViewConfiguration | null ): UseColumnStateReturn { // Initialize state lazily to avoid unnecessary re-renders @@ -85,10 +87,7 @@ export function useColumnState( ) const handleViewChange = useCallback( - ( - view: ViewConfiguration | null, - apiColumns: HttpTypes.AdminViewColumn[] - ) => { + (view: ViewConfiguration | null, apiColumns: HttpTypes.AdminColumn[]) => { if (view?.configuration) { // Apply view configuration const newVisibility: Record = {} @@ -108,7 +107,7 @@ export function useColumnState( ) const initializeColumns = useCallback( - (apiColumns: HttpTypes.AdminViewColumn[]) => { + (apiColumns: HttpTypes.AdminColumn[]) => { // Only initialize if we don't already have column state if (Object.keys(visibleColumns).length === 0) { setVisibleColumns(getInitialColumnVisibility(apiColumns)) @@ -177,7 +176,7 @@ const DEFAULT_COLUMN_ORDER = 500 * Gets the initial column visibility state from API columns */ function getInitialColumnVisibility( - apiColumns: HttpTypes.AdminViewColumn[] + apiColumns: HttpTypes.AdminColumn[] ): Record { if (!apiColumns || apiColumns.length === 0) { return {} @@ -195,9 +194,7 @@ function getInitialColumnVisibility( /** * Gets the initial column order from API columns */ -function getInitialColumnOrder( - apiColumns: HttpTypes.AdminViewColumn[] -): string[] { +function getInitialColumnOrder(apiColumns: HttpTypes.AdminColumn[]): string[] { if (!apiColumns || apiColumns.length === 0) { return [] } diff --git a/packages/admin/dashboard/src/hooks/table/columns/use-configurable-order-table-columns.tsx b/packages/admin/dashboard/src/hooks/table/columns/use-configurable-order-table-columns.tsx deleted file mode 100644 index 0b3ca6423f..0000000000 --- a/packages/admin/dashboard/src/hooks/table/columns/use-configurable-order-table-columns.tsx +++ /dev/null @@ -1,49 +0,0 @@ -import React, { useMemo } from "react" -import { createDataTableColumnHelper } from "@medusajs/ui" -import { HttpTypes } from "@medusajs/types" -import { getDisplayStrategy, getEntityAccessor } from "../../../lib/table-display-utils" -import { getColumnAlignment } from "../../../routes/orders/order-list/components/order-list-table/utils/column-utils" - -const columnHelper = createDataTableColumnHelper() - -export function useConfigurableOrderTableColumns(apiColumns: any[] | undefined) { - return useMemo(() => { - if (!apiColumns?.length) { - return [] - } - - return apiColumns.map(apiColumn => { - // Get the display strategy for this column - const displayStrategy = getDisplayStrategy(apiColumn) - - // Get the entity-specific accessor or use default - const accessor = getEntityAccessor('orders', apiColumn.field, apiColumn) - - // Determine header alignment - const headerAlign = getColumnAlignment(apiColumn) - - return columnHelper.accessor(accessor, { - id: apiColumn.field, - header: () => apiColumn.name, - cell: ({ getValue, row }) => { - const value = getValue() - - // If the value is already a React element (from computed columns), return it directly - if (React.isValidElement(value)) { - return value - } - - // Otherwise, use the display strategy to format the value - return displayStrategy(value, row.original) - }, - meta: { - name: apiColumn.name, - column: apiColumn, // Store column metadata for future use - }, - enableHiding: apiColumn.hideable, - enableSorting: false, // Disable sorting for all columns - headerAlign, // Pass the header alignment to the DataTable - } as any) - }) - }, [apiColumns]) -} diff --git a/packages/admin/dashboard/src/hooks/table/columns/use-configurable-table-columns.tsx b/packages/admin/dashboard/src/hooks/table/columns/use-configurable-table-columns.tsx new file mode 100644 index 0000000000..c8f9900729 --- /dev/null +++ b/packages/admin/dashboard/src/hooks/table/columns/use-configurable-table-columns.tsx @@ -0,0 +1,105 @@ +import React, { useMemo } from "react" +import { createDataTableColumnHelper } from "@medusajs/ui" +import { HttpTypes } from "@medusajs/types" +import { getDisplayStrategy, getEntityAccessor } from "../../../lib/table-display-utils" + +export interface ColumnAdapter { + getColumnAlignment?: (column: HttpTypes.AdminViewColumn) => "left" | "center" | "right" + getCustomAccessor?: (field: string, column: HttpTypes.AdminViewColumn) => any + transformCellValue?: (value: any, row: TData, column: HttpTypes.AdminViewColumn) => React.ReactNode +} + +export function useConfigurableTableColumns( + entity: string, + apiColumns: HttpTypes.AdminViewColumn[] | undefined, + adapter?: ColumnAdapter +) { + const columnHelper = createDataTableColumnHelper() + + return useMemo(() => { + if (!apiColumns?.length) { + return [] + } + + return apiColumns.map(apiColumn => { + // Get the display strategy for this column + const displayStrategy = getDisplayStrategy(apiColumn) + + // Get the entity-specific accessor or use adapter's custom accessor + const accessor = adapter?.getCustomAccessor + ? adapter.getCustomAccessor(apiColumn.field, apiColumn) + : getEntityAccessor(entity, apiColumn.field, apiColumn) + + // Determine header alignment + const headerAlign = adapter?.getColumnAlignment + ? adapter.getColumnAlignment(apiColumn) + : getDefaultColumnAlignment(apiColumn) + + return columnHelper.accessor(accessor, { + id: apiColumn.field, + header: () => apiColumn.name, + cell: ({ getValue, row }) => { + const value = getValue() + + // If the value is already a React element (from computed columns), return it directly + if (React.isValidElement(value)) { + return value + } + + // Allow adapter to transform the value + if (adapter?.transformCellValue) { + return adapter.transformCellValue(value, row.original, apiColumn) + } + + // Otherwise, use the display strategy to format the value + return displayStrategy(value, row.original) + }, + meta: { + name: apiColumn.name, + column: apiColumn, // Store column metadata for future use + }, + enableHiding: apiColumn.hideable, + enableSorting: false, // Disable sorting for all columns by default + headerAlign, // Pass the header alignment to the DataTable + } as any) + }) + }, [entity, apiColumns, adapter]) +} + +function getDefaultColumnAlignment(column: HttpTypes.AdminViewColumn): "left" | "center" | "right" { + // Currency columns should be right-aligned + if (column.semantic_type === "currency" || column.data_type === "currency") { + return "right" + } + + // Number columns should be right-aligned (except identifiers) + if (column.data_type === "number" && column.context !== "identifier") { + return "right" + } + + // Total/amount/price columns should be right-aligned + if ( + column.field.includes("total") || + column.field.includes("amount") || + column.field.includes("price") || + column.field.includes("quantity") || + column.field.includes("count") + ) { + return "right" + } + + // Status columns should be center-aligned + if (column.semantic_type === "status") { + return "center" + } + + // Country columns should be center-aligned + if (column.computed?.type === "country_code" || + column.field === "country" || + column.field.includes("country_code")) { + return "center" + } + + // Default to left alignment + return "left" +} \ No newline at end of file diff --git a/packages/admin/dashboard/src/hooks/table/use-table-configuration.tsx b/packages/admin/dashboard/src/hooks/table/use-table-configuration.tsx new file mode 100644 index 0000000000..941ef57640 --- /dev/null +++ b/packages/admin/dashboard/src/hooks/table/use-table-configuration.tsx @@ -0,0 +1,313 @@ +import { useState, useMemo, useCallback, useEffect } from "react" +import { useSearchParams } from "react-router-dom" +import { HttpTypes } from "@medusajs/types" +import { useViewConfigurations, useViewConfiguration } from "../use-view-configurations" +import { useEntityColumns } from "../api/views" +import { useFeatureFlag } from "../../providers/feature-flag-provider" +import { useColumnState } from "./columns/use-column-state" +import { useQueryParams } from "../use-query-params" +import { calculateRequiredFields } from "../../lib/table/field-utils" + +export interface TableConfiguration { + filters: Record + sorting: { id: string; desc: boolean } | null + search: string + visible_columns?: string[] + column_order?: string[] +} + +export interface UseTableConfigurationOptions { + entity: string + pageSize?: number + queryPrefix?: string + filters?: Array<{ id: string }> +} + +export interface UseTableConfigurationReturn { + // View configuration + activeView: any + createView: any + updateView: any + isViewConfigEnabled: boolean + + // Column state + visibleColumns: Record + columnOrder: string[] + currentColumns: { + visible: string[] + order: string[] + } + setColumnOrder: (order: string[]) => void + handleColumnVisibilityChange: (visibility: Record) => void + + // Configuration state + currentConfiguration: TableConfiguration + hasConfigurationChanged: boolean + handleClearConfiguration: () => void + + // API columns + apiColumns: HttpTypes.AdminViewColumn[] | undefined + isLoadingColumns: boolean + + // Query params + queryParams: Record + + // Required fields for API calls + requiredFields: string +} + +function parseSortingState(value: string) { + return value.startsWith("-") + ? { id: value.slice(1), desc: true } + : { id: value, desc: false } +} + +export function useTableConfiguration({ + entity, + pageSize = 20, + queryPrefix = "", + filters = [], +}: UseTableConfigurationOptions): UseTableConfigurationReturn { + const isViewConfigEnabled = useFeatureFlag("view_configurations") + const [_, setSearchParams] = useSearchParams() + + // View configurations + const { activeView, createView } = useViewConfigurations(entity) + const currentActiveView = activeView?.view_configuration || null + const { updateView } = useViewConfiguration(entity, currentActiveView?.id || "") + + // Entity columns + const { columns: apiColumns, isLoading: isLoadingColumns } = useEntityColumns(entity, { + enabled: isViewConfigEnabled, + }) + + // Query params + const queryParams = useQueryParams( + ["q", "order", ...filters.map(f => f.id)], + queryPrefix + ) + + // Column state + const { + visibleColumns, + columnOrder, + currentColumns, + setColumnOrder, + handleColumnVisibilityChange, + handleViewChange: originalHandleViewChange, + } = useColumnState(apiColumns, currentActiveView) + + // Sync view configuration with URL and column state + useEffect(() => { + if (!apiColumns) return + originalHandleViewChange(currentActiveView, apiColumns) + setSearchParams((prev) => { + // Clear existing query params + const keysToDelete = Array.from(prev.keys()).filter(key => + key.startsWith(queryPrefix + "_") || key === queryPrefix + "_q" || key === queryPrefix + "_order" + ) + keysToDelete.forEach(key => prev.delete(key)) + + // Apply view configuration + if (currentActiveView) { + const viewConfig = currentActiveView.configuration + + if (viewConfig.filters) { + Object.entries(viewConfig.filters).forEach(([key, value]) => { + prev.set(`${queryPrefix}_${key}`, JSON.stringify(value)) + }) + } + + if (viewConfig.sorting) { + const sortValue = viewConfig.sorting.desc + ? `-${viewConfig.sorting.id}` + : viewConfig.sorting.id + prev.set(`${queryPrefix}_order`, sortValue) + } + + if (viewConfig.search) { + prev.set(`${queryPrefix}_q`, viewConfig.search) + } + } + + return prev + }) + }, [currentActiveView, apiColumns]) + + // Current configuration from URL + const currentConfiguration = useMemo(() => { + const currentFilters: Record = {} + filters.forEach(filter => { + if (queryParams[filter.id] !== undefined) { + currentFilters[filter.id] = JSON.parse(queryParams[filter.id] || "") + } + }) + + return { + filters: currentFilters, + sorting: queryParams.order ? parseSortingState(queryParams.order) : null, + search: queryParams.q || "", + } + }, [filters, queryParams]) + + // Check if configuration has changed from view + const [debouncedHasConfigChanged, setDebouncedHasConfigChanged] = useState(false) + + const hasConfigurationChanged = useMemo(() => { + const currentFilters = currentConfiguration.filters + const currentSorting = currentConfiguration.sorting + const currentSearch = currentConfiguration.search + const currentVisibleColumns = Object.entries(visibleColumns) + .filter(([_, isVisible]) => isVisible) + .map(([field]) => field) + .sort() + + if (currentActiveView) { + const viewFilters = currentActiveView.configuration.filters || {} + const viewSorting = currentActiveView.configuration.sorting + const viewSearch = currentActiveView.configuration.search || "" + const viewVisibleColumns = [...(currentActiveView.configuration.visible_columns || [])].sort() + const viewColumnOrder = currentActiveView.configuration.column_order || [] + + // Check filters + const filterKeys = new Set([...Object.keys(currentFilters), ...Object.keys(viewFilters)]) + for (const key of filterKeys) { + if (JSON.stringify(currentFilters[key]) !== JSON.stringify(viewFilters[key])) { + return true + } + } + + // Check sorting + const normalizedCurrentSorting = currentSorting || undefined + const normalizedViewSorting = viewSorting || undefined + if (JSON.stringify(normalizedCurrentSorting) !== JSON.stringify(normalizedViewSorting)) { + return true + } + + // Check search + if (currentSearch !== viewSearch) { + return true + } + + // Check visible columns + if (JSON.stringify(currentVisibleColumns) !== JSON.stringify(viewVisibleColumns)) { + return true + } + + // Check column order + if (JSON.stringify(columnOrder) !== JSON.stringify(viewColumnOrder)) { + return true + } + } else { + // Check against defaults + if (Object.keys(currentFilters).length > 0) return true + if (currentSorting !== null) return true + if (currentSearch !== "") return true + + if (apiColumns) { + const currentVisibleSet = new Set(currentVisibleColumns) + const defaultVisibleSet = new Set( + apiColumns + .filter(col => col.default_visible) + .map(col => col.field) + ) + + if (currentVisibleSet.size !== defaultVisibleSet.size || + [...currentVisibleSet].some(field => !defaultVisibleSet.has(field))) { + return true + } + + const defaultOrder = apiColumns + .sort((a, b) => (a.default_order ?? 500) - (b.default_order ?? 500)) + .map(col => col.field) + + if (JSON.stringify(columnOrder) !== JSON.stringify(defaultOrder)) { + return true + } + } + } + + return false + }, [currentActiveView, visibleColumns, columnOrder, currentConfiguration, apiColumns]) + + // Debounce configuration change detection + useEffect(() => { + const timer = setTimeout(() => { + setDebouncedHasConfigChanged(hasConfigurationChanged) + }, 50) + + return () => clearTimeout(timer) + }, [hasConfigurationChanged]) + + // Clear configuration handler + const handleClearConfiguration = useCallback(() => { + if (apiColumns) { + originalHandleViewChange(currentActiveView, apiColumns) + } + + setSearchParams((prev) => { + const keysToDelete = Array.from(prev.keys()).filter(key => + key.startsWith(queryPrefix + "_") || key === queryPrefix + "_q" || key === queryPrefix + "_order" + ) + keysToDelete.forEach(key => prev.delete(key)) + + if (currentActiveView?.configuration) { + const viewConfig = currentActiveView.configuration + + if (viewConfig.filters) { + Object.entries(viewConfig.filters).forEach(([key, value]) => { + prev.set(`${queryPrefix}_${key}`, JSON.stringify(value)) + }) + } + + if (viewConfig.sorting) { + const sortValue = viewConfig.sorting.desc + ? `-${viewConfig.sorting.id}` + : viewConfig.sorting.id + prev.set(`${queryPrefix}_order`, sortValue) + } + + if (viewConfig.search) { + prev.set(`${queryPrefix}_q`, viewConfig.search) + } + } + + return prev + }) + }, [currentActiveView, apiColumns, queryPrefix]) + + // Calculate required fields based on visible columns + const requiredFields = useMemo(() => { + return calculateRequiredFields(entity, apiColumns, visibleColumns) + }, [entity, apiColumns, visibleColumns]) + + return { + // View configuration + activeView: currentActiveView, + createView, + updateView, + isViewConfigEnabled, + + // Column state + visibleColumns, + columnOrder, + currentColumns, + setColumnOrder, + handleColumnVisibilityChange, + + // Configuration state + currentConfiguration, + hasConfigurationChanged: debouncedHasConfigChanged, + handleClearConfiguration, + + // API columns + apiColumns, + isLoadingColumns, + + // Query params + queryParams, + + // Required fields + requiredFields, + } +} \ No newline at end of file diff --git a/packages/admin/dashboard/src/i18n/translations/$schema.json b/packages/admin/dashboard/src/i18n/translations/$schema.json index 643fb44273..cda8d4adf4 100644 --- a/packages/admin/dashboard/src/i18n/translations/$schema.json +++ b/packages/admin/dashboard/src/i18n/translations/$schema.json @@ -11505,6 +11505,70 @@ ], "additionalProperties": false }, + "views": { + "type": "object", + "properties": { + "save": { + "type": "string" + }, + "saveAsNew": { + "type": "string" + }, + "updateDefaultForEveryone": { + "type": "string" + }, + "updateViewName": { + "type": "string" + }, + "prompts": { + "type": "object", + "properties": { + "updateDefault": { + "type": "object", + "properties": { + "title": { + "type": "string" + }, + "description": { + "type": "string" + }, + "confirmText": { + "type": "string" + }, + "cancelText": { + "type": "string" + } + }, + "required": ["title", "description", "confirmText", "cancelText"], + "additionalProperties": false + }, + "updateView": { + "type": "object", + "properties": { + "title": { + "type": "string" + }, + "description": { + "type": "string" + }, + "confirmText": { + "type": "string" + }, + "cancelText": { + "type": "string" + } + }, + "required": ["title", "description", "confirmText", "cancelText"], + "additionalProperties": false + } + }, + "required": ["updateDefault", "updateView"], + "additionalProperties": false + } + }, + "required": ["save", "saveAsNew", "updateDefaultForEveryone", "updateViewName", "prompts"], + "additionalProperties": false + }, "dateTime": { "type": "object", "properties": { @@ -11623,6 +11687,7 @@ "statuses", "labels", "fields", + "views", "dateTime" ], "additionalProperties": false diff --git a/packages/admin/dashboard/src/i18n/translations/en.json b/packages/admin/dashboard/src/i18n/translations/en.json index 4a2ea633ee..658e41109a 100644 --- a/packages/admin/dashboard/src/i18n/translations/en.json +++ b/packages/admin/dashboard/src/i18n/translations/en.json @@ -3098,5 +3098,25 @@ "minutes_other": "Minutes", "seconds_one": "Second", "seconds_other": "Seconds" + }, + "views": { + "save": "Save", + "saveAsNew": "Save as new view", + "updateDefaultForEveryone": "Update default for everyone", + "updateViewName": "Update view", + "prompts": { + "updateDefault": { + "title": "Update default view", + "description": "This will update the default view for all users. Are you sure?", + "confirmText": "Update for everyone", + "cancelText": "Cancel" + }, + "updateView": { + "title": "Update view", + "description": "Are you sure you want to update \"{{name}}\"?", + "confirmText": "Update", + "cancelText": "Cancel" + } + } } } diff --git a/packages/admin/dashboard/src/lib/table/entity-adapters.tsx b/packages/admin/dashboard/src/lib/table/entity-adapters.tsx new file mode 100644 index 0000000000..41eee26d6f --- /dev/null +++ b/packages/admin/dashboard/src/lib/table/entity-adapters.tsx @@ -0,0 +1,89 @@ +import { HttpTypes } from "@medusajs/types" +import { ColumnAdapter } from "../../hooks/table/columns/use-configurable-table-columns" + +// Order-specific column adapter +export const orderColumnAdapter: ColumnAdapter = { + getColumnAlignment: (column) => { + // Custom alignment for order columns + if (column.field === "display_id") return "center" + if (column.semantic_type === "currency") return "right" + if (column.semantic_type === "status") return "center" + if (column.computed?.type === "country_code") return "center" + return "left" + } +} + +// Product-specific column adapter +export const productColumnAdapter: ColumnAdapter = { + getColumnAlignment: (column) => { + // Custom alignment for product columns + if (column.field === "sku") return "center" + if (column.field === "stock") return "right" + if (column.semantic_type === "currency") return "right" + if (column.semantic_type === "status") return "center" + return "left" + }, + + transformCellValue: (value, row, column) => { + // Custom transformation for product-specific fields + if (column.field === "variants_count") { + return `${value || 0} variants` + } + + if (column.field === "status" && value === "draft") { + return Draft + } + + // Default to standard display + return null + } +} + +// Customer-specific column adapter +export const customerColumnAdapter: ColumnAdapter = { + getColumnAlignment: (column) => { + if (column.field === "orders_count") return "right" + if (column.semantic_type === "currency") return "right" + if (column.semantic_type === "status") return "center" + return "left" + }, + + transformCellValue: (value, row, column) => { + // Format customer name + if (column.field === "name") { + const { first_name, last_name } = row + if (first_name || last_name) { + return `${first_name || ""} ${last_name || ""}`.trim() + } + return "-" + } + + return null + } +} + +// Inventory-specific column adapter +export const inventoryColumnAdapter: ColumnAdapter = { + getColumnAlignment: (column) => { + if (column.field === "stocked_quantity") return "right" + if (column.field === "reserved_quantity") return "right" + if (column.field === "available_quantity") return "right" + if (column.semantic_type === "status") return "center" + return "left" + } +} + +// Registry of entity adapters +export const entityAdapters = { + orders: orderColumnAdapter, + products: productColumnAdapter, + customers: customerColumnAdapter, + inventory: inventoryColumnAdapter, +} as const + +export type EntityType = keyof typeof entityAdapters + +// Helper function to get adapter for an entity +export function getEntityAdapter(entity: string): ColumnAdapter | undefined { + return entityAdapters[entity as EntityType] as ColumnAdapter +} \ No newline at end of file diff --git a/packages/admin/dashboard/src/lib/table/entity-defaults.ts b/packages/admin/dashboard/src/lib/table/entity-defaults.ts new file mode 100644 index 0000000000..682b3ac777 --- /dev/null +++ b/packages/admin/dashboard/src/lib/table/entity-defaults.ts @@ -0,0 +1,80 @@ +/** + * Default fields configuration for each entity type + * These fields are always fetched to ensure basic functionality + */ +export const ENTITY_DEFAULT_FIELDS = { + orders: { + properties: [ + "id", + "status", + "created_at", + "email", + "display_id", + "payment_status", + "fulfillment_status", + "total", + "currency_code", + ], + relations: ["*customer", "*sales_channel"] + }, + + products: { + properties: [ + "id", + "title", + "handle", + "status", + "created_at", + "updated_at", + "thumbnail", + ], + relations: ["*variants", "*categories", "*collections"] + }, + + customers: { + properties: [ + "id", + "email", + "first_name", + "last_name", + "created_at", + "updated_at", + "has_account", + ], + relations: ["*groups"] + }, + + inventory: { + properties: [ + "id", + "sku", + "title", + "description", + "stocked_quantity", + "reserved_quantity", + "created_at", + "updated_at", + ], + relations: ["*location_levels"] + }, + + // Default configuration for entities without specific defaults + default: { + properties: ["id", "created_at", "updated_at"], + relations: [] + } +} as const + +export type EntityType = keyof typeof ENTITY_DEFAULT_FIELDS + +/** + * Get default fields for an entity + */ +export function getEntityDefaultFields(entity: string) { + const config = ENTITY_DEFAULT_FIELDS[entity as EntityType] || ENTITY_DEFAULT_FIELDS.default + return { + properties: config.properties, + relations: config.relations, + formatted: [...config.properties, ...config.relations].join(",") + } +} \ No newline at end of file diff --git a/packages/admin/dashboard/src/routes/orders/order-list/components/order-list-table/utils/field-utils.ts b/packages/admin/dashboard/src/lib/table/field-utils.ts similarity index 70% rename from packages/admin/dashboard/src/routes/orders/order-list/components/order-list-table/utils/field-utils.ts rename to packages/admin/dashboard/src/lib/table/field-utils.ts index b5b6c1868a..1093fe3df6 100644 --- a/packages/admin/dashboard/src/routes/orders/order-list/components/order-list-table/utils/field-utils.ts +++ b/packages/admin/dashboard/src/lib/table/field-utils.ts @@ -1,15 +1,20 @@ import { HttpTypes } from "@medusajs/types" -import { DEFAULT_FIELDS, DEFAULT_PROPERTIES, DEFAULT_RELATIONS } from "../../../const" +import { getEntityDefaultFields } from "./entity-defaults" /** - * Calculates the required fields based on visible columns + * Calculates the required fields based on visible columns and entity defaults */ export function calculateRequiredFields( - apiColumns: any[] | undefined, + entity: string, + apiColumns: HttpTypes.AdminViewColumn[] | undefined, visibleColumns: Record ): string { + // Get entity-specific default fields + const defaults = getEntityDefaultFields(entity) + const defaultFields = defaults.formatted + if (!apiColumns?.length) { - return DEFAULT_FIELDS + return defaultFields } // Get all visible columns @@ -27,8 +32,8 @@ export function calculateRequiredFields( visibleColumnObjects.forEach(column => { if (column.computed) { // For computed columns, add all required and optional fields - column.computed.required_fields?.forEach(field => requiredFieldsSet.add(field)) - column.computed.optional_fields?.forEach(field => requiredFieldsSet.add(field)) + column.computed.required_fields?.forEach((field: string) => requiredFieldsSet.add(field)) + column.computed.optional_fields?.forEach((field: string) => requiredFieldsSet.add(field)) } else if (!column.field.includes('.')) { // Direct field requiredFieldsSet.add(column.field) @@ -46,7 +51,7 @@ export function calculateRequiredFields( // Check which relationship fields need to be added const additionalRelationshipFields = visibleRelationshipFields.filter(field => { const [relationName] = field.split('.') - const isAlreadyCovered = DEFAULT_RELATIONS.some(rel => + const isAlreadyCovered = defaults.relations.some(rel => rel === `*${relationName}` || rel === relationName ) return !isAlreadyCovered @@ -54,7 +59,7 @@ export function calculateRequiredFields( // Check which direct fields need to be added const additionalDirectFields = visibleDirectFields.filter(field => { - const isAlreadyIncluded = DEFAULT_PROPERTIES.includes(field) + const isAlreadyIncluded = defaults.properties.includes(field) return !isAlreadyIncluded }) @@ -63,8 +68,8 @@ export function calculateRequiredFields( // Combine default fields with additional needed fields if (additionalFields.length > 0) { - return `${DEFAULT_FIELDS},${additionalFields.join(',')}` + return `${defaultFields},${additionalFields.join(',')}` } - return DEFAULT_FIELDS + return defaultFields } \ No newline at end of file diff --git a/packages/admin/dashboard/src/lib/table/table-adapters.ts b/packages/admin/dashboard/src/lib/table/table-adapters.ts new file mode 100644 index 0000000000..cb69d50ad8 --- /dev/null +++ b/packages/admin/dashboard/src/lib/table/table-adapters.ts @@ -0,0 +1,87 @@ +import { DataTableColumnDef, DataTableEmptyStateProps, DataTableFilter } from "@medusajs/ui" +import { ColumnAdapter } from "../../hooks/table/columns/use-configurable-table-columns" + +/** + * Adapter interface for configurable tables. + * Defines how to fetch and display data for a specific entity type. + */ +export interface TableAdapter { + /** + * The entity type (e.g., "orders", "products", "customers") + */ + entity: string + + /** + * Hook to fetch data with the calculated required fields. + * Called inside ConfigurableDataTable with the fields and search params. + */ + useData: (fields: string, params: any) => { + data: TData[] | undefined + count: number + isLoading: boolean + isError: boolean + error: any + } + + /** + * Extract unique ID from a row. Defaults to row.id if not provided. + */ + getRowId?: (row: TData) => string + + /** + * Generate href for row navigation. Return undefined for non-clickable rows. + */ + getRowHref?: (row: TData) => string | undefined + + /** + * Table filters configuration + */ + filters?: DataTableFilter[] + + /** + * Transform API columns to table columns. + * If not provided, will use default column generation. + */ + getColumns?: (apiColumns: any[]) => DataTableColumnDef[] + + /** + * Column adapter for customizing column behavior (alignment, formatting, etc.) + * If not provided, will use entity's default column adapter if available. + */ + columnAdapter?: ColumnAdapter + + /** + * Empty state configuration + */ + emptyState?: DataTableEmptyStateProps + + /** + * Default page size + */ + pageSize?: number + + /** + * Query parameter prefix for URL state management + */ + queryPrefix?: string + + /** + * Optional entity display name for headings + */ + entityName?: string +} + +/** + * Helper to create a type-safe table adapter + */ +export function createTableAdapter( + adapter: TableAdapter +): TableAdapter { + return { + // Provide smart defaults + getRowId: (row: any) => row.id, + pageSize: 20, + queryPrefix: "", + ...adapter, + } +} \ No newline at end of file diff --git a/packages/admin/dashboard/src/routes/orders/order-list/components/order-list-table/configurable-order-list-table.tsx b/packages/admin/dashboard/src/routes/orders/order-list/components/order-list-table/configurable-order-list-table.tsx index cc3e604c22..b5b5a5c8a8 100644 --- a/packages/admin/dashboard/src/routes/orders/order-list/components/order-list-table/configurable-order-list-table.tsx +++ b/packages/admin/dashboard/src/routes/orders/order-list/components/order-list-table/configurable-order-list-table.tsx @@ -1,395 +1,16 @@ -import { useState, useMemo, useCallback, useEffect } from "react" -import { Container, Button } from "@medusajs/ui" -import { keepPreviousData } from "@tanstack/react-query" import { useTranslation } from "react-i18next" -import { useSearchParams } from "react-router-dom" - -import { DataTable } from "../../../../../components/data-table" -import { useOrders } from "../../../../../hooks/api/orders" -import { useConfigurableOrderTableColumns } from "../../../../../hooks/table/columns/use-configurable-order-table-columns" -import { useOrderTableFilters } from "./use-order-table-filters" -import { useOrderTableQuery } from "../../../../../hooks/table/query/use-order-table-query" -import { useViewConfigurations, useViewConfiguration } from "../../../../../hooks/use-view-configurations" -import { useEntityColumns } from "../../../../../hooks/api/views" -import { useFeatureFlag } from "../../../../../providers/feature-flag-provider" -import { useColumnState } from "../../../../../hooks/table/columns/use-column-state" -import { useQueryParams } from "../../../../../hooks/use-query-params" -import { SaveViewDropdown } from "./components/save-view-dropdown" -import { SaveViewDialog } from "../../../../../components/table/save-view-dialog" -import { useRequiredFields } from "./hooks/use-required-fields" - -const PAGE_SIZE = 20 -const QUERY_PREFIX = "o" - -function parseSortingState(value: string) { - return value.startsWith("-") - ? { id: value.slice(1), desc: true } - : { id: value, desc: false } -} +import { ConfigurableDataTable } from "../../../../../components/table/configurable-data-table" +import { useOrderTableAdapter } from "./order-table-adapter" export const ConfigurableOrderListTable = () => { const { t } = useTranslation() - const isViewConfigEnabled = useFeatureFlag("view_configurations") - - const { - activeView, - createView, - } = useViewConfigurations("orders") - - const currentActiveView = activeView?.view_configuration || null - - const { updateView } = useViewConfiguration("orders", currentActiveView?.id || "") - - const { columns: apiColumns, isLoading: isLoadingColumns } = useEntityColumns("orders", { - enabled: isViewConfigEnabled, - }) - - const filters = useOrderTableFilters() - - const queryParams = useQueryParams( - ["q", "order", ...filters.map(f => f.id)], - QUERY_PREFIX - ) - - const [_, setSearchParams] = useSearchParams() - - const { - visibleColumns, - columnOrder, - currentColumns, - setColumnOrder, - handleColumnVisibilityChange, - handleViewChange: originalHandleViewChange, - } = useColumnState(apiColumns, currentActiveView) - - useEffect(() => { - if (!apiColumns) return - originalHandleViewChange(currentActiveView, apiColumns) - setSearchParams((prev) => { - const keysToDelete = Array.from(prev.keys()).filter(key => - key.startsWith(QUERY_PREFIX + "_") || key === QUERY_PREFIX + "_q" || key === QUERY_PREFIX + "_order" - ) - keysToDelete.forEach(key => prev.delete(key)) - - if (currentActiveView) { - const viewConfig = currentActiveView.configuration - - if (viewConfig.filters) { - Object.entries(viewConfig.filters).forEach(([key, value]) => { - prev.set(`${QUERY_PREFIX}_${key}`, JSON.stringify(value)) - }) - } - - if (viewConfig.sorting) { - const sortValue = viewConfig.sorting.desc - ? `-${viewConfig.sorting.id}` - : viewConfig.sorting.id - prev.set(`${QUERY_PREFIX}_order`, sortValue) - } - - if (viewConfig.search) { - prev.set(`${QUERY_PREFIX}_q`, viewConfig.search) - } - } - - return prev - }) - }, [currentActiveView, apiColumns]) - - const [debouncedHasConfigChanged, setDebouncedHasConfigChanged] = useState(false) - - const hasConfigurationChanged = useMemo(() => { - const currentFilters: Record = {} - filters.forEach(filter => { - if (queryParams[filter.id] !== undefined) { - currentFilters[filter.id] = JSON.parse(queryParams[filter.id] || "") - } - }) - - const currentSorting = queryParams.order ? parseSortingState(queryParams.order) : null - const currentSearch = queryParams.q || "" - const currentVisibleColumns = Object.entries(visibleColumns) - .filter(([_, isVisible]) => isVisible) - .map(([field]) => field) - .sort() - - if (currentActiveView) { - const viewFilters = currentActiveView.configuration.filters || {} - const viewSorting = currentActiveView.configuration.sorting - const viewSearch = currentActiveView.configuration.search || "" - const viewVisibleColumns = [...(currentActiveView.configuration.visible_columns || [])].sort() - const viewColumnOrder = currentActiveView.configuration.column_order || [] - - const filterKeys = new Set([...Object.keys(currentFilters), ...Object.keys(viewFilters)]) - for (const key of filterKeys) { - if (JSON.stringify(currentFilters[key]) !== JSON.stringify(viewFilters[key])) { - return true - } - } - - const normalizedCurrentSorting = currentSorting || undefined - const normalizedViewSorting = viewSorting || undefined - if (JSON.stringify(normalizedCurrentSorting) !== JSON.stringify(normalizedViewSorting)) { - return true - } - - if (currentSearch !== viewSearch) { - return true - } - - if (JSON.stringify(currentVisibleColumns) !== JSON.stringify(viewVisibleColumns)) { - return true - } - - if (JSON.stringify(columnOrder) !== JSON.stringify(viewColumnOrder)) { - return true - } - } else { - if (Object.keys(currentFilters).length > 0) return true - if (currentSorting !== null) return true - if (currentSearch !== "") return true - - if (apiColumns) { - const currentVisibleSet = new Set( - Object.entries(visibleColumns) - .filter(([_, isVisible]) => isVisible) - .map(([field]) => field) - ) - - const defaultVisibleSet = new Set( - apiColumns - .filter(col => col.default_visible) - .map(col => col.field) - ) - - if (currentVisibleSet.size !== defaultVisibleSet.size || - [...currentVisibleSet].some(field => !defaultVisibleSet.has(field))) { - return true - } - - const defaultOrder = apiColumns - .sort((a, b) => (a.default_order ?? 500) - (b.default_order ?? 500)) - .map(col => col.field) - - if (JSON.stringify(columnOrder) !== JSON.stringify(defaultOrder)) { - return true - } - } - } - - return false - }, [currentActiveView, visibleColumns, columnOrder, filters, queryParams, apiColumns]) - - useEffect(() => { - const timer = setTimeout(() => { - setDebouncedHasConfigChanged(hasConfigurationChanged) - }, 50) - - return () => clearTimeout(timer) - }, [hasConfigurationChanged]) - - const handleClearConfiguration = useCallback(() => { - if (apiColumns) { - originalHandleViewChange(currentActiveView, apiColumns) - } - - setSearchParams((prev) => { - const keysToDelete = Array.from(prev.keys()).filter(key => - key.startsWith(QUERY_PREFIX + "_") || key === QUERY_PREFIX + "_q" || key === QUERY_PREFIX + "_order" - ) - keysToDelete.forEach(key => prev.delete(key)) - - if (currentActiveView?.configuration) { - const viewConfig = currentActiveView.configuration - - if (viewConfig.filters) { - Object.entries(viewConfig.filters).forEach(([key, value]) => { - prev.set(`${QUERY_PREFIX}_${key}`, JSON.stringify(value)) - }) - } - - if (viewConfig.sorting) { - const sortValue = viewConfig.sorting.desc - ? `-${viewConfig.sorting.id}` - : viewConfig.sorting.id - prev.set(`${QUERY_PREFIX}_order`, sortValue) - } - - if (viewConfig.search) { - prev.set(`${QUERY_PREFIX}_q`, viewConfig.search) - } - } - - return prev - }) - }, [currentActiveView, apiColumns]) - - const currentConfiguration = useMemo(() => { - const currentFilters: Record = {} - filters.forEach(filter => { - if (queryParams[filter.id] !== undefined) { - currentFilters[filter.id] = JSON.parse(queryParams[filter.id] || "") - } - }) - - return { - filters: currentFilters, - sorting: queryParams.order ? parseSortingState(queryParams.order) : null, - search: queryParams.q || "", - } - }, [filters, queryParams]) - - const [saveDialogOpen, setSaveDialogOpen] = useState(false) - const [editingView, setEditingView] = useState(null) - - const handleSaveAsDefault = async () => { - try { - if (currentActiveView?.is_system_default) { - await updateView.mutateAsync({ - name: currentActiveView.name || null, - configuration: { - visible_columns: currentColumns.visible, - column_order: currentColumns.order, - filters: currentConfiguration.filters || {}, - sorting: currentConfiguration.sorting || null, - search: currentConfiguration.search || "", - } - }) - } else { - await createView.mutateAsync({ - name: "Default", - is_system_default: true, - set_active: true, - configuration: { - visible_columns: currentColumns.visible, - column_order: currentColumns.order, - filters: currentConfiguration.filters || {}, - sorting: currentConfiguration.sorting || null, - search: currentConfiguration.search || "", - } - }) - } - } catch (_) { - // Error is handled by the hook - } - } - - const handleUpdateExisting = async () => { - if (!currentActiveView) return - - try { - await updateView.mutateAsync({ - name: currentActiveView.name, - configuration: { - visible_columns: currentColumns.visible, - column_order: currentColumns.order, - filters: currentConfiguration.filters || {}, - sorting: currentConfiguration.sorting || null, - search: currentConfiguration.search || "", - } - }) - } catch (_) { - // Error is handled by the hook - } - } - - const handleSaveAsNew = () => { - setSaveDialogOpen(true) - setEditingView(null) - } - - const requiredFields = useRequiredFields(apiColumns, visibleColumns) - - const filterBarContent = debouncedHasConfigChanged ? ( - <> - - - - ) : null - - const { searchParams } = useOrderTableQuery({ - pageSize: PAGE_SIZE, - prefix: QUERY_PREFIX, - }) - - const { orders, count, isError, error, isLoading } = useOrders( - { - fields: requiredFields, - ...searchParams, - }, - { - placeholderData: keepPreviousData, - } - ) - - const columns = useConfigurableOrderTableColumns(apiColumns) - - if (isError) { - throw error - } + const orderAdapter = useOrderTableAdapter() return ( - - row.id} - rowCount={count} - enablePagination - enableSearch - pageSize={PAGE_SIZE} - isLoading={isLoading || isLoadingColumns} - layout="fill" - heading={t("orders.domain")} - enableColumnVisibility={isViewConfigEnabled} - initialColumnVisibility={visibleColumns} - onColumnVisibilityChange={handleColumnVisibilityChange} - columnOrder={columnOrder} - onColumnOrderChange={setColumnOrder} - enableViewSelector={isViewConfigEnabled} - entity="orders" - currentColumns={currentColumns} - filterBarContent={filterBarContent} - rowHref={(row) => `/orders/${row.id}`} - emptyState={{ - empty: { - heading: t("orders.list.noRecordsMessage"), - } - }} - prefix={QUERY_PREFIX} - /> - - {saveDialogOpen && ( - { - setSaveDialogOpen(false) - setEditingView(null) - }} - onSaved={() => { - setSaveDialogOpen(false) - setEditingView(null) - }} - /> - )} - + ) } diff --git a/packages/admin/dashboard/src/routes/orders/order-list/components/order-list-table/hooks/use-required-fields.ts b/packages/admin/dashboard/src/routes/orders/order-list/components/order-list-table/hooks/use-required-fields.ts deleted file mode 100644 index 963b9837c5..0000000000 --- a/packages/admin/dashboard/src/routes/orders/order-list/components/order-list-table/hooks/use-required-fields.ts +++ /dev/null @@ -1,12 +0,0 @@ -import { useMemo } from "react" -import { HttpTypes } from "@medusajs/types" -import { calculateRequiredFields } from "../utils/field-utils" - -export function useRequiredFields( - apiColumns: any[] | undefined, - visibleColumns: Record -): string { - return useMemo(() => { - return calculateRequiredFields(apiColumns, visibleColumns) - }, [apiColumns, visibleColumns]) -} \ No newline at end of file diff --git a/packages/admin/dashboard/src/routes/orders/order-list/components/order-list-table/order-table-adapter.tsx b/packages/admin/dashboard/src/routes/orders/order-list/components/order-list-table/order-table-adapter.tsx new file mode 100644 index 0000000000..d195e8cc9f --- /dev/null +++ b/packages/admin/dashboard/src/routes/orders/order-list/components/order-list-table/order-table-adapter.tsx @@ -0,0 +1,68 @@ +import { HttpTypes } from "@medusajs/types" +import { createTableAdapter, TableAdapter } from "../../../../../lib/table/table-adapters" +import { useOrders } from "../../../../../hooks/api/orders" +import { useOrderTableFilters } from "./use-order-table-filters" +import { orderColumnAdapter } from "../../../../../lib/table/entity-adapters" + +/** + * Create the order table adapter with all order-specific logic + */ +export function createOrderTableAdapter(): TableAdapter { + return createTableAdapter({ + entity: "orders", + queryPrefix: "o", + pageSize: 20, + columnAdapter: orderColumnAdapter, + + useData: (fields, params) => { + const { orders, count, isError, error, isLoading } = useOrders( + { + fields, + ...params, + }, + { + placeholderData: (previousData, previousQuery) => { + // Only keep placeholder data if the fields haven't changed + const prevFields = previousQuery?.[previousQuery.length - 1]?.query?.fields + if (prevFields && prevFields !== fields) { + // Fields changed, don't use placeholder data + return undefined + } + // Fields are the same, keep previous data for smooth transitions + return previousData + }, + } + ) + + return { + data: orders, + count, + isLoading, + isError, + error, + } + }, + + getRowHref: (row) => `/orders/${row.id}`, + + emptyState: { + empty: { + heading: "No orders found", + } + } + }) +} + +/** + * Hook to get the order table adapter with filters + */ +export function useOrderTableAdapter(): TableAdapter { + const filters = useOrderTableFilters() + const adapter = createOrderTableAdapter() + + // Add dynamic filters to the adapter + return { + ...adapter, + filters, + } +} diff --git a/packages/admin/dashboard/src/routes/orders/order-list/components/order-list-table/utils/column-utils.ts b/packages/admin/dashboard/src/routes/orders/order-list/components/order-list-table/utils/column-utils.ts deleted file mode 100644 index 7596973632..0000000000 --- a/packages/admin/dashboard/src/routes/orders/order-list/components/order-list-table/utils/column-utils.ts +++ /dev/null @@ -1,71 +0,0 @@ -import { HttpTypes } from "@medusajs/types" - -export enum ColumnAlignment { - LEFT = "left", - CENTER = "center", - RIGHT = "right", -} - -export const DEFAULT_COLUMN_ORDER = 500 - -/** - * Determines the appropriate column alignment based on the column metadata - */ -export function getColumnAlignment(column: any): 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: any[] -): Record { - const visibility: Record = {} - - apiColumns.forEach(column => { - visibility[column.field] = column.default_visible - }) - - return visibility -} - -/** - * Gets the initial column order from API columns - */ -export function getInitialColumnOrder( - apiColumns: any[] -): string[] { - const sortedColumns = [...apiColumns].sort((a, b) => { - const orderA = a.default_order ?? DEFAULT_COLUMN_ORDER - const orderB = b.default_order ?? DEFAULT_COLUMN_ORDER - return orderA - orderB - }) - - return sortedColumns.map(col => col.field) -} \ No newline at end of file diff --git a/packages/core/types/src/http/view-configuration/admin/responses.ts b/packages/core/types/src/http/view-configuration/admin/responses.ts index 1ad56d8451..463055ce99 100644 --- a/packages/core/types/src/http/view-configuration/admin/responses.ts +++ b/packages/core/types/src/http/view-configuration/admin/responses.ts @@ -77,4 +77,5 @@ export type AdminViewConfigurationListResponse = PaginatedResponse<{ view_configurations: AdminViewConfiguration[] }> -export type AdminViewConfigurationDeleteResponse = DeleteResponse<"view_configuration"> \ No newline at end of file +export type AdminViewConfigurationDeleteResponse = + DeleteResponse<"view_configuration">