fix(dashboard): Use derived state in DataTable (#11487)

**What**
- Uses derived state in DataTable, to prevent the state in the URL and component from going out of sync.
- Introduces a way for RouteModals to restore URL params on close.

Resolves CMRC-936
This commit is contained in:
Kasper Fabricius Kristensen authored and GitHub committed 2025-03-03 14:18:19 +00:00
1 parent 51b0af193c
commit c28ae573e5
7 files changed
+96 -32

No files matched your search

+5
View File
@@ -0,0 +1,5 @@
---
"@medusajs/dashboard": patch
---
fix(dashboard): Use derrived state in DataTable
@@ -15,7 +15,7 @@ import {
Text, Text,
useDataTable, useDataTable,
} from "@medusajs/ui" } from "@medusajs/ui"
import React, { ReactNode, useCallback, useState } from "react" import React, { ReactNode, useCallback, useMemo } from "react"
import { useTranslation } from "react-i18next" import { useTranslation } from "react-i18next"
import { Link, useNavigate, useSearchParams } from "react-router-dom" import { Link, useNavigate, useSearchParams } from "react-router-dom"
@@ -110,7 +110,7 @@ export const DataTable = <TData,>({
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 filterIds = filters?.map((f) => f.id) ?? [] 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))
const { offset, order, q, ...filterParams } = useQueryParams( const { offset, order, q, ...filterParams } = useQueryParams(
@@ -124,9 +124,11 @@ export const DataTable = <TData,>({
) )
const [_, setSearchParams] = useSearchParams() const [_, setSearchParams] = useSearchParams()
const [search, setSearch] = useState<string>(q ?? "") const search = useMemo(() => {
return q ?? ""
}, [q])
const handleSearchChange = (value: string) => { const handleSearchChange = (value: string) => {
setSearch(value)
setSearchParams((prev) => { setSearchParams((prev) => {
if (value) { if (value) {
prev.set(getQueryParamKey("q", prefix), value) prev.set(getQueryParamKey("q", prefix), value)
@@ -138,11 +140,13 @@ export const DataTable = <TData,>({
}) })
} }
const [pagination, setPagination] = useState<DataTablePaginationState>( const pagination: DataTablePaginationState = useMemo(() => {
offset ? parsePaginationState(offset, pageSize) : { pageIndex: 0, pageSize } return offset
) ? parsePaginationState(offset, pageSize)
: { pageIndex: 0, pageSize }
}, [offset, pageSize])
const handlePaginationChange = (value: DataTablePaginationState) => { const handlePaginationChange = (value: DataTablePaginationState) => {
setPagination(value)
setSearchParams((prev) => { setSearchParams((prev) => {
if (value.pageIndex === 0) { if (value.pageIndex === 0) {
prev.delete(getQueryParamKey("offset", prefix)) prev.delete(getQueryParamKey("offset", prefix))
@@ -152,18 +156,16 @@ export const DataTable = <TData,>({
transformPaginationState(value).toString() transformPaginationState(value).toString()
) )
} }
return prev return prev
}) })
} }
const [filtering, setFiltering] = useState<DataTableFilteringState>( const filtering: DataTableFilteringState = useMemo(
parseFilterState(filterIds, filterParams) () => parseFilterState(filterIds, filterParams),
[filterIds, filterParams]
) )
const handleFilteringChange = (value: DataTableFilteringState) => { const handleFilteringChange = (value: DataTableFilteringState) => {
setFiltering(value)
setSearchParams((prev) => { setSearchParams((prev) => {
Array.from(prev.keys()).forEach((key) => { Array.from(prev.keys()).forEach((key) => {
if (prefixedFilterIds.includes(key) && !(key in value)) { if (prefixedFilterIds.includes(key) && !(key in value)) {
@@ -184,11 +186,11 @@ export const DataTable = <TData,>({
}) })
} }
const [sorting, setSorting] = useState<DataTableSortingState | null>( const sorting: DataTableSortingState | null = useMemo(() => {
order ? parseSortingState(order) : null return order ? parseSortingState(order) : null
) }, [order])
const handleSortingChange = (value: DataTableSortingState) => { const handleSortingChange = (value: DataTableSortingState) => {
setSorting(value)
setSearchParams((prev) => { setSearchParams((prev) => {
if (value) { if (value) {
const valueToStore = transformSortingState(value) const valueToStore = transformSortingState(value)
@@ -0,0 +1,26 @@
import { useMemo } from "react"
import { Path, useLocation } from "react-router-dom"
/**
* Checks if the current location has a restore_params property.
* If it does, it will return a new path with the params added to it.
* Otherwise, it will return the previous path.
*
* This is useful if the modal needs to return to the original path, with
* the params that were present when the modal was opened.
*/
export const useStateAwareTo = (prev: string | Partial<Path>) => {
const location = useLocation()
const to = useMemo(() => {
const params = location.state?.restore_params
if (!params) {
return prev
}
return `${prev}?${params.toString()}`
}, [location.state, prev])
return to
}
@@ -1,12 +1,13 @@
import { Drawer, clx } from "@medusajs/ui" import { Drawer, clx } from "@medusajs/ui"
import { PropsWithChildren, useEffect, useState } from "react" import { PropsWithChildren, useEffect, useState } from "react"
import { useNavigate } from "react-router-dom" import { Path, useNavigate } from "react-router-dom"
import { useStateAwareTo } from "../hooks/use-state-aware-to"
import { RouteModalForm } from "../route-modal-form" import { RouteModalForm } from "../route-modal-form"
import { RouteModalProvider } from "../route-modal-provider/route-provider" import { RouteModalProvider } from "../route-modal-provider/route-provider"
import { StackedModalProvider } from "../stacked-modal-provider" import { StackedModalProvider } from "../stacked-modal-provider"
type RouteDrawerProps = PropsWithChildren<{ type RouteDrawerProps = PropsWithChildren<{
prev?: string prev?: string | Partial<Path>
}> }>
const Root = ({ prev = "..", children }: RouteDrawerProps) => { const Root = ({ prev = "..", children }: RouteDrawerProps) => {
@@ -14,6 +15,8 @@ const Root = ({ prev = "..", children }: RouteDrawerProps) => {
const [open, setOpen] = useState(false) const [open, setOpen] = useState(false)
const [stackedModalOpen, onStackedModalOpen] = useState(false) const [stackedModalOpen, onStackedModalOpen] = useState(false)
const to = useStateAwareTo(prev)
/** /**
* Open the modal when the component mounts. This * Open the modal when the component mounts. This
* ensures that the entry animation is played. * ensures that the entry animation is played.
@@ -30,7 +33,7 @@ const Root = ({ prev = "..", children }: RouteDrawerProps) => {
const handleOpenChange = (open: boolean) => { const handleOpenChange = (open: boolean) => {
if (!open) { if (!open) {
document.body.style.pointerEvents = "auto" document.body.style.pointerEvents = "auto"
navigate(prev, { replace: true }) navigate(to, { replace: true })
return return
} }
@@ -39,7 +42,7 @@ const Root = ({ prev = "..", children }: RouteDrawerProps) => {
return ( return (
<Drawer open={open} onOpenChange={handleOpenChange}> <Drawer open={open} onOpenChange={handleOpenChange}>
<RouteModalProvider prev={prev}> <RouteModalProvider prev={to}>
<StackedModalProvider onOpenChange={onStackedModalOpen}> <StackedModalProvider onOpenChange={onStackedModalOpen}>
<Drawer.Content <Drawer.Content
aria-describedby={undefined} aria-describedby={undefined}
@@ -1,13 +1,14 @@
import { FocusModal, clx } from "@medusajs/ui" import { FocusModal, clx } from "@medusajs/ui"
import { PropsWithChildren, useEffect, useState } from "react" import { PropsWithChildren, useEffect, useState } from "react"
import { useNavigate } from "react-router-dom" import { Path, useNavigate } from "react-router-dom"
import { useStateAwareTo } from "../hooks/use-state-aware-to"
import { RouteModalForm } from "../route-modal-form" import { RouteModalForm } from "../route-modal-form"
import { useRouteModal } from "../route-modal-provider" import { useRouteModal } from "../route-modal-provider"
import { RouteModalProvider } from "../route-modal-provider/route-provider" import { RouteModalProvider } from "../route-modal-provider/route-provider"
import { StackedModalProvider } from "../stacked-modal-provider" import { StackedModalProvider } from "../stacked-modal-provider"
type RouteFocusModalProps = PropsWithChildren<{ type RouteFocusModalProps = PropsWithChildren<{
prev?: string prev?: string | Partial<Path>
}> }>
const Root = ({ prev = "..", children }: RouteFocusModalProps) => { const Root = ({ prev = "..", children }: RouteFocusModalProps) => {
@@ -15,6 +16,8 @@ const Root = ({ prev = "..", children }: RouteFocusModalProps) => {
const [open, setOpen] = useState(false) const [open, setOpen] = useState(false)
const [stackedModalOpen, onStackedModalOpen] = useState(false) const [stackedModalOpen, onStackedModalOpen] = useState(false)
const to = useStateAwareTo(prev)
/** /**
* Open the modal when the component mounts. This * Open the modal when the component mounts. This
* ensures that the entry animation is played. * ensures that the entry animation is played.
@@ -31,7 +34,7 @@ const Root = ({ prev = "..", children }: RouteFocusModalProps) => {
const handleOpenChange = (open: boolean) => { const handleOpenChange = (open: boolean) => {
if (!open) { if (!open) {
document.body.style.pointerEvents = "auto" document.body.style.pointerEvents = "auto"
navigate(prev, { replace: true }) navigate(to, { replace: true })
return return
} }
@@ -40,7 +43,7 @@ const Root = ({ prev = "..", children }: RouteFocusModalProps) => {
return ( return (
<FocusModal open={open} onOpenChange={handleOpenChange}> <FocusModal open={open} onOpenChange={handleOpenChange}>
<RouteModalProvider prev={prev}> <RouteModalProvider prev={to}>
<StackedModalProvider onOpenChange={onStackedModalOpen}> <StackedModalProvider onOpenChange={onStackedModalOpen}>
<Content stackedModalOpen={stackedModalOpen}>{children}</Content> <Content stackedModalOpen={stackedModalOpen}>{children}</Content>
</StackedModalProvider> </StackedModalProvider>
@@ -1,9 +1,9 @@
import { PropsWithChildren, useCallback, useMemo, useState } from "react" import { PropsWithChildren, useCallback, useMemo, useState } from "react"
import { useNavigate } from "react-router-dom" import { Path, useNavigate } from "react-router-dom"
import { RouteModalProviderContext } from "./route-modal-context" import { RouteModalProviderContext } from "./route-modal-context"
type RouteModalProviderProps = PropsWithChildren<{ type RouteModalProviderProps = PropsWithChildren<{
prev: string prev: string | Partial<Path>
}> }>
export const RouteModalProvider = ({ export const RouteModalProvider = ({
@@ -16,7 +16,7 @@ import { useCallback, useMemo } from "react"
import { useTranslation } from "react-i18next" import { useTranslation } from "react-i18next"
import { CellContext } from "@tanstack/react-table" import { CellContext } from "@tanstack/react-table"
import { useNavigate } from "react-router-dom" import { useNavigate, useSearchParams } from "react-router-dom"
import { DataTable } from "../../../../../components/data-table" import { DataTable } from "../../../../../components/data-table"
import { useDataTableDateColumns } from "../../../../../components/data-table/helpers/general/use-data-table-date-columns" import { useDataTableDateColumns } from "../../../../../components/data-table/helpers/general/use-data-table-date-columns"
import { useDataTableDateFilters } from "../../../../../components/data-table/helpers/general/use-data-table-date-filters" import { useDataTableDateFilters } from "../../../../../components/data-table/helpers/general/use-data-table-date-filters"
@@ -32,6 +32,7 @@ type ProductVariantSectionProps = {
} }
const PAGE_SIZE = 10 const PAGE_SIZE = 10
const PREFIX = "pv"
export const ProductVariantSection = ({ export const ProductVariantSection = ({
product, product,
@@ -46,7 +47,8 @@ export const ProductVariantSection = ({
manage_inventory, manage_inventory,
created_at, created_at,
updated_at, updated_at,
} = useQueryParams([ } = useQueryParams(
[
"q", "q",
"order", "order",
"offset", "offset",
@@ -54,7 +56,9 @@ export const ProductVariantSection = ({
"allow_backorder", "allow_backorder",
"created_at", "created_at",
"updated_at", "updated_at",
]) ],
PREFIX
)
const columns = useColumns(product) const columns = useColumns(product)
const filters = useFilters() const filters = useFilters()
@@ -132,6 +136,7 @@ export const ProductVariantSection = ({
], ],
}} }}
commands={commands} commands={commands}
prefix={PREFIX}
/> />
</Container> </Container>
) )
@@ -145,6 +150,17 @@ const useColumns = (product: HttpTypes.AdminProduct) => {
const navigate = useNavigate() const navigate = useNavigate()
const { mutateAsync } = useDeleteVariantLazy(product.id) const { mutateAsync } = useDeleteVariantLazy(product.id)
const prompt = usePrompt() const prompt = usePrompt()
const [searchParams] = useSearchParams()
const tableSearchParams = useMemo(() => {
const filtered = new URLSearchParams()
for (const [key, value] of searchParams.entries()) {
if (key.startsWith(`${PREFIX}_`)) {
filtered.append(key, value)
}
}
return filtered
}, [searchParams])
const dateColumns = useDataTableDateColumns<HttpTypes.AdminProductVariant>() const dateColumns = useDataTableDateColumns<HttpTypes.AdminProductVariant>()
@@ -215,7 +231,16 @@ const useColumns = (product: HttpTypes.AdminProduct) => {
icon: <PencilSquare />, icon: <PencilSquare />,
label: t("actions.edit"), label: t("actions.edit"),
onClick: (row) => { onClick: (row) => {
navigate(`edit-variant?variant_id=${row.row.original.id}`) navigate(
`edit-variant?variant_id=${
row.row.original.id
}&${tableSearchParams.toString()}`,
{
state: {
restore_params: tableSearchParams.toString(),
},
}
)
}, },
}, },
] ]
@@ -271,7 +296,7 @@ const useColumns = (product: HttpTypes.AdminProduct) => {
return [mainActions, secondaryActions] return [mainActions, secondaryActions]
}, },
[handleDelete, navigate, t] [handleDelete, navigate, t, tableSearchParams]
) )
const getInventory = useCallback( const getInventory = useCallback(