diff --git a/.changeset/weak-candles-melt.md b/.changeset/weak-candles-melt.md new file mode 100644 index 0000000000..b4f65f9e22 --- /dev/null +++ b/.changeset/weak-candles-melt.md @@ -0,0 +1,5 @@ +--- +"@medusajs/dashboard": patch +--- + +feat(dashboard): refactor location list UI to use data table diff --git a/packages/admin/dashboard/src/components/data-table/data-table.tsx b/packages/admin/dashboard/src/components/data-table/data-table.tsx index 90ceffab6c..770baef32d 100644 --- a/packages/admin/dashboard/src/components/data-table/data-table.tsx +++ b/packages/admin/dashboard/src/components/data-table/data-table.tsx @@ -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 = ({ 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(initialColumnVisibility) + const [columnVisibility, setColumnVisibility] = + React.useState(initialColumnVisibility) // Update column visibility when initial visibility changes React.useEffect(() => { @@ -154,9 +157,12 @@ export const DataTable = ({ 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 = ({ }, [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 = ({ 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 = ({ }, [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 = ({ 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 = ({ return ( ({ )} {actionMenu && } - {actions && actions.length > 0 && } + {actions && actions.length > 0 && ( + + )} {!actions && action && } @@ -407,7 +424,9 @@ export const DataTable = ({ )} {enableCommands && ( - `${count} selected`} /> + `${count} selected`} + /> )} ) @@ -520,4 +539,3 @@ const DataTableActions = ({ actions }: { actions: DataTableActionProps[] }) => { ) } - diff --git a/packages/admin/dashboard/src/i18n/translations/$schema.json b/packages/admin/dashboard/src/i18n/translations/$schema.json index 41209b6049..1db0f2d58a 100644 --- a/packages/admin/dashboard/src/i18n/translations/$schema.json +++ b/packages/admin/dashboard/src/i18n/translations/$schema.json @@ -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": { diff --git a/packages/admin/dashboard/src/i18n/translations/en.json b/packages/admin/dashboard/src/i18n/translations/en.json index 30eb795f18..7bcc278506 100644 --- a/packages/admin/dashboard/src/i18n/translations/en.json +++ b/packages/admin/dashboard/src/i18n/translations/en.json @@ -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", diff --git a/packages/admin/dashboard/src/routes/locations/location-edit/components/edit-location-form/edit-location-form.tsx b/packages/admin/dashboard/src/routes/locations/location-edit/components/edit-location-form/edit-location-form.tsx index 482220dc97..26bb8ba9b6 100644 --- a/packages/admin/dashboard/src/routes/locations/location-edit/components/edit-location-form/edit-location-form.tsx +++ b/packages/admin/dashboard/src/routes/locations/location-edit/components/edit-location-form/edit-location-form.tsx @@ -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) => { diff --git a/packages/admin/dashboard/src/routes/locations/location-list/components/location-list-header/index.ts b/packages/admin/dashboard/src/routes/locations/location-list/components/location-list-header/index.ts deleted file mode 100644 index 3f8a119f76..0000000000 --- a/packages/admin/dashboard/src/routes/locations/location-list/components/location-list-header/index.ts +++ /dev/null @@ -1 +0,0 @@ -export * from "./location-list-header" diff --git a/packages/admin/dashboard/src/routes/locations/location-list/components/location-list-header/location-list-header.tsx b/packages/admin/dashboard/src/routes/locations/location-list/components/location-list-header/location-list-header.tsx deleted file mode 100644 index 8747eeb112..0000000000 --- a/packages/admin/dashboard/src/routes/locations/location-list/components/location-list-header/location-list-header.tsx +++ /dev/null @@ -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 ( - -
- {t("stockLocations.domain")} - - {t("stockLocations.list.description")} - -
- -
- ) -} diff --git a/packages/admin/dashboard/src/routes/locations/location-list/index.ts b/packages/admin/dashboard/src/routes/locations/location-list/index.ts index 59a7340e6f..883c2ba632 100644 --- a/packages/admin/dashboard/src/routes/locations/location-list/index.ts +++ b/packages/admin/dashboard/src/routes/locations/location-list/index.ts @@ -1,2 +1 @@ -export { shippingListLoader as loader } from "./loader" export { LocationList as Component } from "./location-list" diff --git a/packages/admin/dashboard/src/routes/locations/location-list/loader.ts b/packages/admin/dashboard/src/routes/locations/location-list/loader.ts deleted file mode 100644 index 19e3cb7fc2..0000000000 --- a/packages/admin/dashboard/src/routes/locations/location-list/loader.ts +++ /dev/null @@ -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( - query.queryKey - ) ?? (await queryClient.fetchQuery(query)) - ) -} diff --git a/packages/admin/dashboard/src/routes/locations/location-list/location-list.tsx b/packages/admin/dashboard/src/routes/locations/location-list/location-list.tsx index f9f62897bb..0baf41def8 100644 --- a/packages/admin/dashboard/src/routes/locations/location-list/location-list.tsx +++ b/packages/admin/dashboard/src/routes/locations/location-list/location-list.tsx @@ -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 - > + 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 > - -
- {stockLocations.map((location) => ( - - ))} -
+ + 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" + /> +
diff --git a/packages/admin/dashboard/src/routes/locations/location-list/use-location-list-table-columns.tsx b/packages/admin/dashboard/src/routes/locations/location-list/use-location-list-table-columns.tsx new file mode 100644 index 0000000000..0af1d55502 --- /dev/null +++ b/packages/admin/dashboard/src/routes/locations/location-list/use-location-list-table-columns.tsx @@ -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() + +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 + } + + return ( + + {name} + + ) + }, + }), + columnHelper.accessor("address", { + header: t("fields.address"), + cell: ({ getValue, row }) => { + const address = getValue() + const location = row.original + + if (!address) { + return + } + + return ( +
+ + {getFormattedAddress({ + address: location.address as HttpTypes.AdminOrderAddress, + }).join(", ")} + +
+ ) + }, + }), + 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 ( + + {t( + fulfillmentSetExists ? "statuses.enabled" : "statuses.disabled" + )} + + ) + }, + }), + 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 ( + + {t( + fulfillmentSetExists ? "statuses.enabled" : "statuses.disabled" + )} + + ) + }, + }), + columnHelper.accessor("sales_channels", { + header: t("stockLocations.salesChannels.label"), + cell: ({ getValue }) => { + const salesChannels = getValue() + + if (!salesChannels?.length) { + return + } + + return ( +
+ s.name)} + /> +
+ ) + }, + }), + columnHelper.action({ + actions: (ctx) => { + const location = ctx.row.original + return [ + [ + { + icon: , + label: t("actions.edit"), + onClick: () => { + navigate(`/settings/locations/${location.id}/edit`) + }, + }, + ], + [ + { + icon: , + label: t("actions.delete"), + onClick: () => handleDelete(location), + }, + ], + ] + }, + }), + ], + [] + ) +} diff --git a/packages/admin/dashboard/src/routes/locations/location-list/use-location-list-table-query.tsx b/packages/admin/dashboard/src/routes/locations/location-list/use-location-list-table-query.tsx new file mode 100644 index 0000000000..6084265102 --- /dev/null +++ b/packages/admin/dashboard/src/routes/locations/location-list/use-location-list-table-query.tsx @@ -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 +} diff --git a/packages/admin/dashboard/src/routes/refund-reasons/refund-reason-list/components/refund-reason-list-table/refund-reason-list-table.tsx b/packages/admin/dashboard/src/routes/refund-reasons/refund-reason-list/components/refund-reason-list-table/refund-reason-list-table.tsx index 3117c8f06c..73a7982cff 100644 --- a/packages/admin/dashboard/src/routes/refund-reasons/refund-reason-list/components/refund-reason-list-table/refund-reason-list-table.tsx +++ b/packages/admin/dashboard/src/routes/refund-reasons/refund-reason-list/components/refund-reason-list-table/refund-reason-list-table.tsx @@ -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} /> diff --git a/packages/modules/stock-location/src/models/stock-location-address.ts b/packages/modules/stock-location/src/models/stock-location-address.ts index e78241d2b7..f0d2dea501 100644 --- a/packages/modules/stock-location/src/models/stock-location-address.ts +++ b/packages/modules/stock-location/src/models/stock-location-address.ts @@ -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", diff --git a/packages/modules/stock-location/src/models/stock-location.ts b/packages/modules/stock-location/src/models/stock-location.ts index feb4b32fd5..7f49801964 100644 --- a/packages/modules/stock-location/src/models/stock-location.ts +++ b/packages/modules/stock-location/src/models/stock-location.ts @@ -9,6 +9,7 @@ const StockLocation = model.define("StockLocation", { .belongsTo(() => StockLocationAddress, { mappedBy: "stock_locations", }) + .searchable() .nullable(), })