feat(dashboard): refactor location list UI to use data table (#13571)

* wip: convert location list to a table

* chore: changeset

* fix: rm search bluring on loading change

* feat: translations and palceholders, cleanup, make content more compact

* fix: delete message

* chore: optimise use memo

* fix: update toast

* feat: make stock location address searchable

* fix: search input blur on load finish
This commit is contained in:
Frane Polić
2025-09-24 10:29:13 +02:00
committed by GitHub
parent 6e806942c7
commit 10787c865f
15 changed files with 373 additions and 134 deletions

View File

@@ -0,0 +1,5 @@
---
"@medusajs/dashboard": patch
---
feat(dashboard): refactor location list UI to use data table

View File

@@ -31,26 +31,26 @@ type DataTableActionProps = {
label: string
disabled?: boolean
} & (
| {
| {
to: string
}
| {
| {
onClick: () => void
}
)
)
type DataTableActionMenuActionProps = {
label: string
icon: ReactNode
disabled?: boolean
} & (
| {
| {
to: string
}
| {
| {
onClick: () => void
}
)
)
type DataTableActionMenuGroupProps = {
actions: DataTableActionMenuActionProps[]
@@ -138,15 +138,18 @@ export const DataTable = <TData,>({
const isViewConfigEnabled = useFeatureFlag("view_configurations")
// If view config is disabled, don't use column visibility features
const effectiveEnableColumnVisibility = isViewConfigEnabled && enableColumnVisibility
const effectiveEnableColumnVisibility =
isViewConfigEnabled && enableColumnVisibility
const effectiveEnableViewSelector = isViewConfigEnabled && enableViewSelector
const enableFiltering = filters && filters.length > 0
const showFilterMenu = enableFilterMenu !== undefined ? enableFilterMenu : enableFiltering
const showFilterMenu =
enableFilterMenu !== undefined ? enableFilterMenu : enableFiltering
const enableCommands = commands && commands.length > 0
const enableSorting = columns.some((column) => column.enableSorting)
const [columnVisibility, setColumnVisibility] = React.useState<VisibilityState>(initialColumnVisibility)
const [columnVisibility, setColumnVisibility] =
React.useState<VisibilityState>(initialColumnVisibility)
// Update column visibility when initial visibility changes
React.useEffect(() => {
@@ -154,9 +157,12 @@ export const DataTable = <TData,>({
const currentKeys = Object.keys(columnVisibility).sort()
const newKeys = Object.keys(initialColumnVisibility).sort()
const hasChanged = currentKeys.length !== newKeys.length ||
const hasChanged =
currentKeys.length !== newKeys.length ||
currentKeys.some((key, index) => key !== newKeys[index]) ||
Object.entries(initialColumnVisibility).some(([key, value]) => columnVisibility[key] !== value)
Object.entries(initialColumnVisibility).some(
([key, value]) => columnVisibility[key] !== value
)
if (hasChanged) {
setColumnVisibility(initialColumnVisibility)
@@ -164,10 +170,13 @@ export const DataTable = <TData,>({
}, [initialColumnVisibility])
// Wrapper function to handle column visibility changes
const handleColumnVisibilityChange = React.useCallback((visibility: VisibilityState) => {
setColumnVisibility(visibility)
onColumnVisibilityChange?.(visibility)
}, [onColumnVisibilityChange])
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])
@@ -231,7 +240,7 @@ export const DataTable = <TData,>({
Array.from(prev.keys()).forEach((key) => {
if (prefixedFilterIds.includes(key)) {
// Extract the unprefixed key
const unprefixedKey = prefix ? key.replace(`${prefix}_`, '') : key
const unprefixedKey = prefix ? key.replace(`${prefix}_`, "") : key
if (!(unprefixedKey in value)) {
prev.delete(key)
}
@@ -257,11 +266,14 @@ export const DataTable = <TData,>({
}, [order])
// Memoize current configuration to prevent infinite loops
const currentConfiguration = useMemo(() => ({
filters: filtering,
sorting: sorting,
search: search,
}), [filtering, sorting, search])
const currentConfiguration = useMemo(
() => ({
filters: filtering,
sorting: sorting,
search: search,
}),
[filtering, sorting, search]
)
const handleSortingChange = (value: DataTableSortingState) => {
setSearchParams((prev) => {
@@ -315,42 +327,43 @@ export const DataTable = <TData,>({
onRowClick: rowHref ? onRowClick : undefined,
pagination: enablePagination
? {
state: pagination,
onPaginationChange: handlePaginationChange,
}
state: pagination,
onPaginationChange: handlePaginationChange,
}
: undefined,
filtering: enableFiltering
? {
state: filtering,
onFilteringChange: handleFilteringChange,
}
state: filtering,
onFilteringChange: handleFilteringChange,
}
: undefined,
sorting: enableSorting
? {
state: sorting,
onSortingChange: handleSortingChange,
}
state: sorting,
onSortingChange: handleSortingChange,
}
: undefined,
search: enableSearch
? {
state: search,
onSearchChange: handleSearchChange,
}
state: search,
onSearchChange: handleSearchChange,
}
: undefined,
rowSelection,
isLoading,
columnVisibility: effectiveEnableColumnVisibility
? {
state: columnVisibility,
onColumnVisibilityChange: handleColumnVisibilityChange,
}
: undefined,
columnOrder: effectiveEnableColumnVisibility && columnOrder && onColumnOrderChange
? {
state: columnOrder,
onColumnOrderChange: onColumnOrderChange,
}
state: columnVisibility,
onColumnVisibilityChange: handleColumnVisibilityChange,
}
: undefined,
columnOrder:
effectiveEnableColumnVisibility && columnOrder && onColumnOrderChange
? {
state: columnOrder,
onColumnOrderChange: onColumnOrderChange,
}
: undefined,
})
const shouldRenderHeading = heading || subHeading
@@ -358,7 +371,9 @@ export const DataTable = <TData,>({
return (
<UiDataTable
instance={instance}
className={layout === "fill" ? "h-full [&_tr]:last-of-type:!border-b" : undefined}
className={
layout === "fill" ? "h-full [&_tr]:last-of-type:!border-b" : undefined
}
>
<UiDataTable.Toolbar
className="flex flex-col items-start justify-between gap-2 md:flex-row md:items-center"
@@ -397,7 +412,9 @@ export const DataTable = <TData,>({
</div>
)}
{actionMenu && <ActionMenu variant="primary" {...actionMenu} />}
{actions && actions.length > 0 && <DataTableActions actions={actions} />}
{actions && actions.length > 0 && (
<DataTableActions actions={actions} />
)}
{!actions && action && <DataTableAction {...action} />}
</div>
</div>
@@ -407,7 +424,9 @@ export const DataTable = <TData,>({
<UiDataTable.Pagination translations={paginationTranslations} />
)}
{enableCommands && (
<UiDataTable.CommandBar selectedLabel={(count) => `${count} selected`} />
<UiDataTable.CommandBar
selectedLabel={(count) => `${count} selected`}
/>
)}
</UiDataTable>
)
@@ -520,4 +539,3 @@ const DataTableActions = ({ actions }: { actions: DataTableActionProps[] }) => {
</div>
)
}

View File

@@ -5834,9 +5834,23 @@
"properties": {
"description": {
"type": "string"
},
"noRecordsMessage": {
"type": "string"
},
"noRecordsMessageEmpty": {
"type": "string"
},
"noRecordsMessageFiltered": {
"type": "string"
}
},
"required": ["description"],
"required": [
"description",
"noRecordsMessage",
"noRecordsMessageEmpty",
"noRecordsMessageFiltered"
],
"additionalProperties": false
},
"create": {
@@ -5876,9 +5890,12 @@
"properties": {
"confirmation": {
"type": "string"
},
"successToast": {
"type": "string"
}
},
"required": ["confirmation"],
"required": ["confirmation", "successToast"],
"additionalProperties": false
},
"fulfillmentProviders": {

View File

@@ -1535,7 +1535,10 @@
"stockLocations": {
"domain": "Locations & Shipping",
"list": {
"description": "Manage your store's stock locations and shipping options."
"description": "Manage your store's stock locations and shipping options.",
"noRecordsMessage": "No records",
"noRecordsMessageEmpty": "No locations found",
"noRecordsMessageFiltered": "No locations found matching the filters"
},
"create": {
"header": "Create Stock Location",
@@ -1548,7 +1551,8 @@
"successToast": "Location {{name}} was successfully updated."
},
"delete": {
"confirmation": "You are about to delete the stock location \"{{name}}\". This action cannot be undone."
"confirmation": "You are about to delete the stock location \"{{name}}\". This action cannot be undone.",
"successToast": "Location \"{{name}}\" was successfully deleted."
},
"fulfillmentProviders": {
"header": "Fulfillment Providers",

View File

@@ -62,7 +62,7 @@ export const EditLocationForm = ({ location }: EditLocationFormProps) => {
},
{
onSuccess: () => {
toast.success(t("stockLocations.edit.successToast"))
toast.success(t("stockLocations.edit.successToast", { name: name }))
handleSuccess()
},
onError: (e) => {

View File

@@ -1 +0,0 @@
export * from "./location-list-header"

View File

@@ -1,21 +0,0 @@
import { Button, Container, Heading, Text } from "@medusajs/ui"
import { useTranslation } from "react-i18next"
import { Link } from "react-router-dom"
export const LocationListHeader = () => {
const { t } = useTranslation()
return (
<Container className="flex h-fit items-center justify-between gap-x-4 px-6 py-4">
<div>
<Heading>{t("stockLocations.domain")}</Heading>
<Text className="text-ui-fg-subtle txt-small">
{t("stockLocations.list.description")}
</Text>
</div>
<Button size="small" className="shrink-0" variant="secondary" asChild>
<Link to="create">{t("actions.create")}</Link>
</Button>
</Container>
)
}

View File

@@ -1,2 +1 @@
export { shippingListLoader as loader } from "./loader"
export { LocationList as Component } from "./location-list"

View File

@@ -1,36 +0,0 @@
import { FetchError } from "@medusajs/js-sdk"
import { LoaderFunctionArgs, redirect } from "react-router-dom"
import { HttpTypes } from "@medusajs/types"
import { stockLocationsQueryKeys } from "../../../hooks/api/stock-locations"
import { sdk } from "../../../lib/client"
import { queryClient } from "../../../lib/query-client"
import { LOCATION_LIST_FIELDS } from "./constants"
const shippingListQuery = () => ({
queryKey: stockLocationsQueryKeys.lists(),
queryFn: async () => {
return await sdk.admin.stockLocation
.list({
// TODO: change this when RQ is fixed
fields: LOCATION_LIST_FIELDS,
})
.catch((error: FetchError) => {
if (error.status === 401) {
throw redirect("/login")
}
throw error
})
},
})
export const shippingListLoader = async (_: LoaderFunctionArgs) => {
const query = shippingListQuery()
return (
queryClient.getQueryData<HttpTypes.AdminStockLocationListResponse>(
query.queryKey
) ?? (await queryClient.fetchQuery(query))
)
}

View File

@@ -1,34 +1,46 @@
import { ShoppingBag, TruckFast } from "@medusajs/icons"
import { Container, Heading } from "@medusajs/ui"
import { useTranslation } from "react-i18next"
import { useLoaderData } from "react-router-dom"
import { useStockLocations } from "../../../hooks/api/stock-locations"
import LocationListItem from "./components/location-list-item/location-list-item"
import { LOCATION_LIST_FIELDS } from "./constants"
import { shippingListLoader } from "./loader"
import { useLocationListTableColumns } from "./use-location-list-table-columns"
import { useLocationListTableQuery } from "./use-location-list-table-query"
import { DataTable } from "../../../components/data-table"
import { SidebarLink } from "../../../components/common/sidebar-link/sidebar-link"
import { TwoColumnPage } from "../../../components/layout/pages"
import { useExtension } from "../../../providers/extension-provider"
import { LocationListHeader } from "./components/location-list-header"
import { keepPreviousData } from "@tanstack/react-query"
const PAGE_SIZE = 20
const PREFIX = "loc"
export function LocationList() {
const initialData = useLoaderData() as Awaited<
ReturnType<typeof shippingListLoader>
>
const { t } = useTranslation()
const searchParams = useLocationListTableQuery({
pageSize: PAGE_SIZE,
prefix: PREFIX,
})
const {
stock_locations: stockLocations = [],
count,
isError,
error,
isLoading,
} = useStockLocations(
{
fields: LOCATION_LIST_FIELDS,
...searchParams,
},
{ initialData }
{
placeholderData: keepPreviousData,
}
)
const columns = useLocationListTableColumns()
const { getWidgets } = useExtension()
if (isError) {
@@ -46,12 +58,38 @@ export function LocationList() {
showJSON
>
<TwoColumnPage.Main>
<LocationListHeader />
<div className="flex flex-col gap-3 lg:col-span-2">
{stockLocations.map((location) => (
<LocationListItem key={location.id} location={location} />
))}
</div>
<Container className="flex flex-col divide-y p-0">
<DataTable
data={stockLocations}
columns={columns}
rowCount={count}
pageSize={PAGE_SIZE}
getRowId={(row) => row.id}
heading={t("stockLocations.domain")}
subHeading={t("stockLocations.list.description")}
emptyState={{
empty: {
heading: t("stockLocations.list.noRecordsMessage"),
description: t("stockLocations.list.noRecordsMessageEmpty"),
},
filtered: {
heading: t("stockLocations.list.noRecordsMessage"),
description: t("stockLocations.list.noRecordsMessageFiltered"),
},
}}
actions={[
{
label: t("actions.create"),
to: "create",
},
]}
isLoading={isLoading}
rowHref={(row) => `/settings/locations/${row.id}`}
enableSearch={true}
prefix={PREFIX}
layout="fill"
/>
</Container>
</TwoColumnPage.Main>
<TwoColumnPage.Sidebar>
<LinksSection />

View File

@@ -0,0 +1,185 @@
import { HttpTypes } from "@medusajs/types"
import { PencilSquare, Trash } from "@medusajs/icons"
import {
createDataTableColumnHelper,
StatusBadge,
toast,
usePrompt,
} from "@medusajs/ui"
import { useTranslation } from "react-i18next"
import { useMemo } from "react"
import { useNavigate } from "react-router-dom"
import { FetchError } from "@medusajs/js-sdk"
import { PlaceholderCell } from "../../../components/table/table-cells/common/placeholder-cell"
import { getFormattedAddress } from "../../../lib/addresses"
import { FulfillmentSetType } from "../common/constants"
import { queryClient } from "../../../lib/query-client"
import { stockLocationsQueryKeys } from "../../../hooks/api/stock-locations"
import { ListSummary } from "../../../components/common/list-summary"
import { sdk } from "../../../lib/client"
const columnHelper = createDataTableColumnHelper<HttpTypes.AdminStockLocation>()
export const useLocationListTableColumns = () => {
const { t } = useTranslation()
const navigate = useNavigate()
const prompt = usePrompt()
const handleDelete = async (location: HttpTypes.AdminStockLocation) => {
const result = await prompt({
title: t("general.areYouSure"),
description: t("stockLocations.delete.confirmation", {
name: location.name,
}),
confirmText: t("actions.remove"),
cancelText: t("actions.cancel"),
})
if (!result) {
return
}
try {
await sdk.admin.stockLocation.delete(location.id)
queryClient.invalidateQueries({
queryKey: stockLocationsQueryKeys.lists(),
})
queryClient.invalidateQueries({
queryKey: stockLocationsQueryKeys.detail(location.id),
})
toast.success(
t("stockLocations.delete.successToast", {
name: location.name,
})
)
} catch (e) {
toast.error((e as FetchError).message)
}
}
return useMemo(
() => [
columnHelper.accessor("name", {
header: t("fields.name"),
cell: ({ getValue }) => {
const name = getValue()
if (!name) {
return <PlaceholderCell />
}
return (
<span className="text-ui-fg-subtle text-small truncate">
{name}
</span>
)
},
}),
columnHelper.accessor("address", {
header: t("fields.address"),
cell: ({ getValue, row }) => {
const address = getValue()
const location = row.original
if (!address) {
return <PlaceholderCell />
}
return (
<div className="flex flex-col">
<span className="text-ui-fg-subtle text-small truncate">
{getFormattedAddress({
address: location.address as HttpTypes.AdminOrderAddress,
}).join(", ")}
</span>
</div>
)
},
}),
columnHelper.accessor("fulfillment_sets", {
id: "shipping_fulfillment",
header: t("stockLocations.fulfillmentSets.shipping.header"),
cell: ({ getValue }) => {
const fulfillmentSets = getValue()
const shippingSet = fulfillmentSets?.find(
(f) => f.type === FulfillmentSetType.Shipping
)
const fulfillmentSetExists = !!shippingSet
return (
<StatusBadge color={fulfillmentSetExists ? "green" : "grey"}>
{t(
fulfillmentSetExists ? "statuses.enabled" : "statuses.disabled"
)}
</StatusBadge>
)
},
}),
columnHelper.accessor("fulfillment_sets", {
id: "pickup_fulfillment",
header: t("stockLocations.fulfillmentSets.pickup.header"),
cell: ({ getValue }) => {
const fulfillmentSets = getValue()
const pickupSet = fulfillmentSets?.find(
(f) => f.type === FulfillmentSetType.Pickup
)
const fulfillmentSetExists = !!pickupSet
return (
<StatusBadge color={fulfillmentSetExists ? "green" : "grey"}>
{t(
fulfillmentSetExists ? "statuses.enabled" : "statuses.disabled"
)}
</StatusBadge>
)
},
}),
columnHelper.accessor("sales_channels", {
header: t("stockLocations.salesChannels.label"),
cell: ({ getValue }) => {
const salesChannels = getValue()
if (!salesChannels?.length) {
return <PlaceholderCell />
}
return (
<div className="flex items-center">
<ListSummary
inline
n={1}
list={salesChannels.map((s) => s.name)}
/>
</div>
)
},
}),
columnHelper.action({
actions: (ctx) => {
const location = ctx.row.original
return [
[
{
icon: <PencilSquare />,
label: t("actions.edit"),
onClick: () => {
navigate(`/settings/locations/${location.id}/edit`)
},
},
],
[
{
icon: <Trash />,
label: t("actions.delete"),
onClick: () => handleDelete(location),
},
],
]
},
}),
],
[]
)
}

View File

@@ -0,0 +1,22 @@
import { HttpTypes } from "@medusajs/types"
import { useQueryParams } from "../../../hooks/use-query-params"
export const useLocationListTableQuery = ({
pageSize = 20,
prefix,
}: {
pageSize?: number
prefix?: string
}) => {
const queryObject = useQueryParams(["order", "offset", "q"], prefix)
const { offset, ...rest } = queryObject
const searchParams: HttpTypes.AdminStockLocationListParams = {
limit: pageSize,
offset: offset ? Number(offset) : 0,
...rest,
}
return searchParams
}

View File

@@ -1,12 +1,20 @@
import { PencilSquare, Trash } from "@medusajs/icons"
import { HttpTypes } from "@medusajs/types"
import { Container, createDataTableColumnHelper, toast, usePrompt, } from "@medusajs/ui"
import {
Container,
createDataTableColumnHelper,
toast,
usePrompt,
} from "@medusajs/ui"
import { keepPreviousData } from "@tanstack/react-query"
import { useCallback, useMemo } from "react"
import { useTranslation } from "react-i18next"
import { useNavigate } from "react-router-dom"
import { DataTable } from "../../../../../components/data-table"
import { useDeleteRefundReasonLazy, useRefundReasons, } from "../../../../../hooks/api"
import {
useDeleteRefundReasonLazy,
useRefundReasons,
} from "../../../../../hooks/api"
import { useRefundReasonTableColumns } from "../../../../../hooks/table/columns"
import { useRefundReasonTableQuery } from "../../../../../hooks/table/query"
@@ -18,7 +26,7 @@ export const RefundReasonListTable = () => {
pageSize: PAGE_SIZE,
})
const { refund_reasons, count, isPending, isError, error } = useRefundReasons(
const { refund_reasons, count, isLoading, isError, error } = useRefundReasons(
searchParams,
{
placeholderData: keepPreviousData,
@@ -56,7 +64,7 @@ export const RefundReasonListTable = () => {
to: "create",
},
]}
isLoading={isPending}
isLoading={isLoading}
enableSearch={true}
/>
</Container>

View File

@@ -4,14 +4,14 @@ import { StockLocation } from "@models"
const StockLocationAddress = model
.define("StockLocationAddress", {
id: model.id({ prefix: "laddr" }).primaryKey(),
address_1: model.text(),
address_2: model.text().nullable(),
address_1: model.text().searchable(),
address_2: model.text().searchable().nullable(),
company: model.text().nullable(),
city: model.text().nullable(),
country_code: model.text(),
city: model.text().searchable().nullable(),
country_code: model.text().searchable(),
phone: model.text().nullable(),
province: model.text().nullable(),
postal_code: model.text().nullable(),
province: model.text().searchable().nullable(),
postal_code: model.text().searchable().nullable(),
metadata: model.json().nullable(),
stock_locations: model.hasOne(() => StockLocation, {
mappedBy: "address",

View File

@@ -9,6 +9,7 @@ const StockLocation = model.define("StockLocation", {
.belongsTo(() => StockLocationAddress, {
mappedBy: "stock_locations",
})
.searchable()
.nullable(),
})