feat: add sales channel management (#6761)
Add V2 sales channel management to admin
`@medusajs/medusa`
- Add `POST /admin/sales-channels/:id/products/batch/remove`
- Refactor cross-module filter middleware to comply with the latest convention
`@medusajs/admin-next`
- Add all sales channel routes
- Moves the following sales channel UI to shared components in `modules/sales-channel`:
- sales-channel-list
- sales-channel-edit
- sales-channel-details
- sales-channel-general-section
- sales-channel-create
The sales-channel-product-section is not shared because the API in V2 will change.
The sales-channel-add-products component is not shared because the API in V2 will change.
`@medusajs/core-flows`
- Add `detachProductsFromSalesChannelsStep`
- Add `removeProductsFromSalesChannelsWorkflow`
This commit is contained in:
@@ -1,4 +1,4 @@
|
|||||||
const { ModuleRegistrationName } = require("@medusajs/modules-sdk")
|
const { ModuleRegistrationName, Modules } = require("@medusajs/modules-sdk")
|
||||||
const { medusaIntegrationTestRunner } = require("medusa-test-utils")
|
const { medusaIntegrationTestRunner } = require("medusa-test-utils")
|
||||||
const { createAdminUser } = require("../../../helpers/create-admin-user")
|
const { createAdminUser } = require("../../../helpers/create-admin-user")
|
||||||
const { breaking } = require("../../../helpers/breaking")
|
const { breaking } = require("../../../helpers/breaking")
|
||||||
@@ -24,6 +24,7 @@ medusaIntegrationTestRunner({
|
|||||||
let salesChannelService
|
let salesChannelService
|
||||||
let productService
|
let productService
|
||||||
let remoteQuery
|
let remoteQuery
|
||||||
|
let remoteLink
|
||||||
|
|
||||||
beforeAll(() => {
|
beforeAll(() => {
|
||||||
;({
|
;({
|
||||||
@@ -46,6 +47,7 @@ medusaIntegrationTestRunner({
|
|||||||
)
|
)
|
||||||
productService = container.resolve(ModuleRegistrationName.PRODUCT)
|
productService = container.resolve(ModuleRegistrationName.PRODUCT)
|
||||||
remoteQuery = container.resolve(ContainerRegistrationKeys.REMOTE_QUERY)
|
remoteQuery = container.resolve(ContainerRegistrationKeys.REMOTE_QUERY)
|
||||||
|
remoteLink = container.resolve(ContainerRegistrationKeys.REMOTE_LINK)
|
||||||
})
|
})
|
||||||
|
|
||||||
describe("GET /admin/sales-channels/:id", () => {
|
describe("GET /admin/sales-channels/:id", () => {
|
||||||
@@ -614,60 +616,126 @@ medusaIntegrationTestRunner({
|
|||||||
})
|
})
|
||||||
|
|
||||||
describe("DELETE /admin/sales-channels/:id/products/batch", () => {
|
describe("DELETE /admin/sales-channels/:id/products/batch", () => {
|
||||||
|
// BREAKING CHANGE: Endpoint has changed
|
||||||
|
// from: DELETE /admin/sales-channels/:id/products/batch
|
||||||
|
// to: POST /admin/sales-channels/:id/products/batch/remove
|
||||||
|
|
||||||
let salesChannel
|
let salesChannel
|
||||||
let product
|
let product
|
||||||
|
|
||||||
beforeEach(async () => {
|
beforeEach(async () => {
|
||||||
product = await simpleProductFactory(dbConnection, {
|
;({ salesChannel, product } = await breaking(
|
||||||
id: "product_1",
|
async () => {
|
||||||
title: "test title",
|
const product = await simpleProductFactory(dbConnection, {
|
||||||
})
|
id: "product_1",
|
||||||
salesChannel = await simpleSalesChannelFactory(dbConnection, {
|
title: "test title",
|
||||||
name: "test name",
|
})
|
||||||
description: "test description",
|
const salesChannel = await simpleSalesChannelFactory(dbConnection, {
|
||||||
products: [product],
|
name: "test name",
|
||||||
})
|
description: "test description",
|
||||||
|
products: [product],
|
||||||
|
})
|
||||||
|
|
||||||
|
return { salesChannel, product }
|
||||||
|
},
|
||||||
|
async () => {
|
||||||
|
const salesChannel = await salesChannelService.create({
|
||||||
|
name: "test name",
|
||||||
|
description: "test description",
|
||||||
|
})
|
||||||
|
const product = await productService.create({
|
||||||
|
title: "test title",
|
||||||
|
})
|
||||||
|
|
||||||
|
await remoteLink.create({
|
||||||
|
[Modules.PRODUCT]: {
|
||||||
|
product_id: product.id,
|
||||||
|
},
|
||||||
|
[Modules.SALES_CHANNEL]: {
|
||||||
|
sales_channel_id: salesChannel.id,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
return { salesChannel, product }
|
||||||
|
}
|
||||||
|
))
|
||||||
})
|
})
|
||||||
|
|
||||||
it("should remove products from a sales channel", async () => {
|
it("should remove products from a sales channel", async () => {
|
||||||
let attachedProduct = await dbConnection.manager.findOne(Product, {
|
const attachedProduct = await breaking(
|
||||||
where: { id: product.id },
|
async () => {
|
||||||
relations: ["sales_channels"],
|
return await dbConnection.manager.findOne(Product, {
|
||||||
})
|
where: { id: product.id },
|
||||||
|
relations: ["sales_channels"],
|
||||||
|
})
|
||||||
|
},
|
||||||
|
async () => {
|
||||||
|
const [product] = await remoteQuery({
|
||||||
|
products: {
|
||||||
|
fields: ["id"],
|
||||||
|
sales_channels: {
|
||||||
|
fields: ["id", "name", "description", "is_disabled"],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
expect(attachedProduct.sales_channels.length).toBe(2)
|
return product
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
expect(attachedProduct.sales_channels.length).toBe(
|
||||||
|
breaking(
|
||||||
|
() => 2,
|
||||||
|
() => 1 // Comment: The product factory from v1 adds products to the default channel
|
||||||
|
)
|
||||||
|
)
|
||||||
expect(attachedProduct.sales_channels).toEqual(
|
expect(attachedProduct.sales_channels).toEqual(
|
||||||
expect.arrayContaining([
|
expect.arrayContaining(
|
||||||
expect.objectContaining({
|
breaking(
|
||||||
id: expect.any(String),
|
() => [
|
||||||
name: "test name",
|
expect.objectContaining({
|
||||||
description: "test description",
|
id: expect.any(String),
|
||||||
is_disabled: false,
|
name: "test name",
|
||||||
}),
|
description: "test description",
|
||||||
expect.objectContaining({
|
is_disabled: false,
|
||||||
id: expect.any(String),
|
}),
|
||||||
is_disabled: false,
|
expect.objectContaining({
|
||||||
}),
|
id: expect.any(String),
|
||||||
])
|
is_disabled: false,
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
() => [
|
||||||
|
expect.objectContaining({
|
||||||
|
id: expect.any(String),
|
||||||
|
name: "test name",
|
||||||
|
description: "test description",
|
||||||
|
is_disabled: false,
|
||||||
|
}),
|
||||||
|
]
|
||||||
|
)
|
||||||
|
)
|
||||||
)
|
)
|
||||||
|
|
||||||
const payload = {
|
const payload = {
|
||||||
product_ids: [{ id: product.id }],
|
product_ids: breaking(
|
||||||
|
() => [{ id: product.id }],
|
||||||
|
() => [product.id]
|
||||||
|
),
|
||||||
}
|
}
|
||||||
|
|
||||||
await api.delete(
|
const response = await breaking(
|
||||||
`/admin/sales-channels/${salesChannel.id}/products/batch`,
|
async () => {
|
||||||
{
|
return await api.delete(
|
||||||
...adminReqConfig,
|
`/admin/sales-channels/${salesChannel.id}/products/batch`,
|
||||||
data: payload,
|
{ ...adminReqConfig, data: payload }
|
||||||
}
|
)
|
||||||
)
|
},
|
||||||
// Validate idempotency
|
async () => {
|
||||||
const response = await api.delete(
|
return await api.post(
|
||||||
`/admin/sales-channels/${salesChannel.id}/products/batch`,
|
`/admin/sales-channels/${salesChannel.id}/products/batch/remove`,
|
||||||
{
|
payload,
|
||||||
...adminReqConfig,
|
adminReqConfig
|
||||||
data: payload,
|
)
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -681,17 +749,42 @@ medusaIntegrationTestRunner({
|
|||||||
})
|
})
|
||||||
)
|
)
|
||||||
|
|
||||||
attachedProduct = await dbConnection.manager.findOne(Product, {
|
const removedProduct = await breaking(
|
||||||
where: { id: product.id },
|
async () => {
|
||||||
relations: ["sales_channels"],
|
return await dbConnection.manager.findOne(Product, {
|
||||||
})
|
where: { id: product.id },
|
||||||
|
relations: ["sales_channels"],
|
||||||
|
})
|
||||||
|
},
|
||||||
|
async () => {
|
||||||
|
const [product] = await remoteQuery({
|
||||||
|
products: {
|
||||||
|
fields: ["id"],
|
||||||
|
sales_channels: {
|
||||||
|
fields: ["id", "name", "description", "is_disabled"],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
return product
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
// default sales channel
|
// default sales channel
|
||||||
expect(attachedProduct.sales_channels.length).toBe(1)
|
expect(removedProduct.sales_channels.length).toBe(
|
||||||
|
breaking(
|
||||||
|
() => 1,
|
||||||
|
() => 0 // Comment: The product factory from v1 adds products to the default channel
|
||||||
|
)
|
||||||
|
)
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
describe("POST /admin/sales-channels/:id/products/batch", () => {
|
describe("POST /admin/sales-channels/:id/products/batch", () => {
|
||||||
|
// BREAKING CHANGE: Endpoint has changed
|
||||||
|
// from: /admin/sales-channels/:id/products/batch
|
||||||
|
// to: /admin/sales-channels/:id/products/batch/add
|
||||||
|
|
||||||
let { salesChannel, product } = {}
|
let { salesChannel, product } = {}
|
||||||
|
|
||||||
beforeEach(async () => {
|
beforeEach(async () => {
|
||||||
|
|||||||
+1
@@ -0,0 +1 @@
|
|||||||
|
export * from "./create-sales-channel-form";
|
||||||
+5
-5
@@ -21,11 +21,11 @@ import { useAdminDeleteSalesChannel, useAdminSalesChannels } from "medusa-react"
|
|||||||
import { useMemo, useState } from "react"
|
import { useMemo, useState } from "react"
|
||||||
import { useTranslation } from "react-i18next"
|
import { useTranslation } from "react-i18next"
|
||||||
import { Link, useNavigate } from "react-router-dom"
|
import { Link, useNavigate } from "react-router-dom"
|
||||||
import { ActionMenu } from "../../../../../components/common/action-menu"
|
import { ActionMenu } from "../../../../components/common/action-menu"
|
||||||
import { OrderBy } from "../../../../../components/filtering/order-by"
|
import { OrderBy } from "../../../../components/filtering/order-by"
|
||||||
import { Query } from "../../../../../components/filtering/query"
|
import { Query } from "../../../../components/filtering/query"
|
||||||
import { LocalizedTablePagination } from "../../../../../components/localization/localized-table-pagination"
|
import { LocalizedTablePagination } from "../../../../components/localization/localized-table-pagination"
|
||||||
import { useQueryParams } from "../../../../../hooks/use-query-params"
|
import { useQueryParams } from "../../../../hooks/use-query-params"
|
||||||
|
|
||||||
const PAGE_SIZE = 50
|
const PAGE_SIZE = 50
|
||||||
|
|
||||||
@@ -7,6 +7,7 @@ import { Outlet } from "react-router-dom"
|
|||||||
import { Spinner } from "@medusajs/icons"
|
import { Spinner } from "@medusajs/icons"
|
||||||
import { ErrorBoundary } from "../../components/error/error-boundary"
|
import { ErrorBoundary } from "../../components/error/error-boundary"
|
||||||
import { useV2Session } from "../../lib/api-v2"
|
import { useV2Session } from "../../lib/api-v2"
|
||||||
|
import { SalesChannelDTO } from "@medusajs/types"
|
||||||
import { UserDTO } from "@medusajs/types"
|
import { UserDTO } from "@medusajs/types"
|
||||||
import { SearchProvider } from "../search-provider"
|
import { SearchProvider } from "../search-provider"
|
||||||
import { SidebarProvider } from "../sidebar-provider"
|
import { SidebarProvider } from "../sidebar-provider"
|
||||||
@@ -172,6 +173,54 @@ export const v2Routes: RouteObject[] = [
|
|||||||
},
|
},
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
path: "sales-channels",
|
||||||
|
element: <Outlet />,
|
||||||
|
handle: {
|
||||||
|
crumb: () => "Sales Channels",
|
||||||
|
},
|
||||||
|
children: [
|
||||||
|
{
|
||||||
|
path: "",
|
||||||
|
lazy: () =>
|
||||||
|
import("../../v2-routes/sales-channels/sales-channel-list"),
|
||||||
|
children: [
|
||||||
|
{
|
||||||
|
path: "create",
|
||||||
|
lazy: () =>
|
||||||
|
import(
|
||||||
|
"../../v2-routes/sales-channels/sales-channel-create"
|
||||||
|
),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: ":id",
|
||||||
|
lazy: () =>
|
||||||
|
import("../../v2-routes/sales-channels/sales-channel-detail"),
|
||||||
|
handle: {
|
||||||
|
crumb: (data: { sales_channel: SalesChannelDTO }) =>
|
||||||
|
data.sales_channel.name,
|
||||||
|
},
|
||||||
|
children: [
|
||||||
|
{
|
||||||
|
path: "edit",
|
||||||
|
lazy: () =>
|
||||||
|
import(
|
||||||
|
"../../v2-routes/sales-channels/sales-channel-edit"
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: "add-products",
|
||||||
|
lazy: () =>
|
||||||
|
import(
|
||||||
|
"../../v2-routes/sales-channels/sales-channel-add-products"
|
||||||
|
),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
|
|||||||
+2
@@ -94,6 +94,8 @@ export const AddProductsToSalesChannelForm = ({
|
|||||||
const { products, count } = useAdminProducts(
|
const { products, count } = useAdminProducts(
|
||||||
{
|
{
|
||||||
expand: "variants,sales_channels",
|
expand: "variants,sales_channels",
|
||||||
|
limit: PAGE_SIZE,
|
||||||
|
offset: pageIndex * PAGE_SIZE,
|
||||||
...params,
|
...params,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|||||||
-1
@@ -1 +0,0 @@
|
|||||||
export * from "./create-sales-channel-form"
|
|
||||||
+1
-1
@@ -1,5 +1,5 @@
|
|||||||
import { RouteFocusModal } from "../../../components/route-modal"
|
import { RouteFocusModal } from "../../../components/route-modal"
|
||||||
import { CreateSalesChannelForm } from "./components/create-sales-channel-form"
|
import { CreateSalesChannelForm } from "../../../modules/sales-channels/sales-channel-create/components/create-sales-channel-form"
|
||||||
|
|
||||||
export const SalesChannelCreate = () => {
|
export const SalesChannelCreate = () => {
|
||||||
return (
|
return (
|
||||||
|
|||||||
+1
-1
@@ -2,7 +2,7 @@ import { useAdminSalesChannel } from "medusa-react"
|
|||||||
import { Outlet, useLoaderData, useParams } from "react-router-dom"
|
import { Outlet, useLoaderData, useParams } from "react-router-dom"
|
||||||
|
|
||||||
import { JsonViewSection } from "../../../components/common/json-view-section"
|
import { JsonViewSection } from "../../../components/common/json-view-section"
|
||||||
import { SalesChannelGeneralSection } from "./components/sales-channel-general-section"
|
import { SalesChannelGeneralSection } from "../../../modules/sales-channels/sales-channel-detail/components/sales-channel-general-section"
|
||||||
import { SalesChannelProductSection } from "./components/sales-channel-product-section"
|
import { SalesChannelProductSection } from "./components/sales-channel-product-section"
|
||||||
import { salesChannelLoader } from "./loader"
|
import { salesChannelLoader } from "./loader"
|
||||||
|
|
||||||
|
|||||||
+2
-2
@@ -4,7 +4,7 @@ import { useTranslation } from "react-i18next"
|
|||||||
import { useParams } from "react-router-dom"
|
import { useParams } from "react-router-dom"
|
||||||
|
|
||||||
import { RouteDrawer } from "../../../components/route-modal"
|
import { RouteDrawer } from "../../../components/route-modal"
|
||||||
import { EditSalesChannelForm } from "./components/edit-sales-channel-form"
|
import { EditSalesChannelForm } from "../../../modules/sales-channels/sales-channel-edit/components/edit-sales-channel-form"
|
||||||
|
|
||||||
export const SalesChannelEdit = () => {
|
export const SalesChannelEdit = () => {
|
||||||
const { id } = useParams()
|
const { id } = useParams()
|
||||||
@@ -23,7 +23,7 @@ export const SalesChannelEdit = () => {
|
|||||||
{t("salesChannels.editSalesChannel")}
|
{t("salesChannels.editSalesChannel")}
|
||||||
</Heading>
|
</Heading>
|
||||||
</RouteDrawer.Header>
|
</RouteDrawer.Header>
|
||||||
{!isLoading && sales_channel && (
|
{!isLoading && !!sales_channel && (
|
||||||
<EditSalesChannelForm salesChannel={sales_channel} />
|
<EditSalesChannelForm salesChannel={sales_channel} />
|
||||||
)}
|
)}
|
||||||
</RouteDrawer>
|
</RouteDrawer>
|
||||||
|
|||||||
@@ -1 +1 @@
|
|||||||
export { SalesChannelList as Component } from "./sales-channel-list"
|
export { SalesChannelList as Component } from "./sales-channel-list";
|
||||||
|
|||||||
+1
-1
@@ -1,5 +1,5 @@
|
|||||||
import { Outlet } from "react-router-dom"
|
import { Outlet } from "react-router-dom"
|
||||||
import { SalesChannelListTable } from "./components/sales-channel-list-table"
|
import { SalesChannelListTable } from "../../../modules/sales-channels/sales-channel-list/components"
|
||||||
|
|
||||||
export const SalesChannelList = () => {
|
export const SalesChannelList = () => {
|
||||||
return (
|
return (
|
||||||
|
|||||||
+360
@@ -0,0 +1,360 @@
|
|||||||
|
import { zodResolver } from "@hookform/resolvers/zod"
|
||||||
|
import { Product, SalesChannel } from "@medusajs/medusa"
|
||||||
|
import { Button, Checkbox, Hint, Table, Tooltip, clx } from "@medusajs/ui"
|
||||||
|
import {
|
||||||
|
PaginationState,
|
||||||
|
RowSelectionState,
|
||||||
|
createColumnHelper,
|
||||||
|
flexRender,
|
||||||
|
getCoreRowModel,
|
||||||
|
useReactTable,
|
||||||
|
} from "@tanstack/react-table"
|
||||||
|
import {
|
||||||
|
adminProductKeys,
|
||||||
|
adminSalesChannelsKeys,
|
||||||
|
useAdminCustomPost,
|
||||||
|
useAdminProducts,
|
||||||
|
} from "medusa-react"
|
||||||
|
import { useEffect, useMemo, useState } from "react"
|
||||||
|
import { useForm } from "react-hook-form"
|
||||||
|
import { useTranslation } from "react-i18next"
|
||||||
|
import * as zod from "zod"
|
||||||
|
import {
|
||||||
|
ProductAvailabilityCell,
|
||||||
|
ProductCollectionCell,
|
||||||
|
ProductStatusCell,
|
||||||
|
ProductTitleCell,
|
||||||
|
ProductVariantCell,
|
||||||
|
} from "../../../../components/common/product-table-cells"
|
||||||
|
import { OrderBy } from "../../../../components/filtering/order-by"
|
||||||
|
import { Query } from "../../../../components/filtering/query"
|
||||||
|
import { LocalizedTablePagination } from "../../../../components/localization/localized-table-pagination"
|
||||||
|
import {
|
||||||
|
RouteFocusModal,
|
||||||
|
useRouteModal,
|
||||||
|
} from "../../../../components/route-modal"
|
||||||
|
import { useQueryParams } from "../../../../hooks/use-query-params"
|
||||||
|
import { queryClient } from "../../../../lib/medusa"
|
||||||
|
|
||||||
|
type AddProductsToSalesChannelFormProps = {
|
||||||
|
salesChannel: SalesChannel
|
||||||
|
}
|
||||||
|
|
||||||
|
const AddProductsToSalesChannelSchema = zod.object({
|
||||||
|
product_ids: zod.array(zod.string()).min(1),
|
||||||
|
})
|
||||||
|
|
||||||
|
const PAGE_SIZE = 50
|
||||||
|
|
||||||
|
export const AddProductsToSalesChannelForm = ({
|
||||||
|
salesChannel,
|
||||||
|
}: AddProductsToSalesChannelFormProps) => {
|
||||||
|
const { t } = useTranslation()
|
||||||
|
const { handleSuccess } = useRouteModal()
|
||||||
|
|
||||||
|
const form = useForm<zod.infer<typeof AddProductsToSalesChannelSchema>>({
|
||||||
|
defaultValues: {
|
||||||
|
product_ids: [],
|
||||||
|
},
|
||||||
|
resolver: zodResolver(AddProductsToSalesChannelSchema),
|
||||||
|
})
|
||||||
|
|
||||||
|
const { setValue } = form
|
||||||
|
|
||||||
|
const { mutateAsync, isLoading: isMutating } = useAdminCustomPost(
|
||||||
|
`/admin/sales-channels/${salesChannel.id}/products/batch/add`,
|
||||||
|
[
|
||||||
|
adminSalesChannelsKeys.lists(),
|
||||||
|
adminSalesChannelsKeys.detail(salesChannel.id),
|
||||||
|
]
|
||||||
|
)
|
||||||
|
|
||||||
|
const [{ pageIndex, pageSize }, setPagination] = useState<PaginationState>({
|
||||||
|
pageIndex: 0,
|
||||||
|
pageSize: PAGE_SIZE,
|
||||||
|
})
|
||||||
|
|
||||||
|
const pagination = useMemo(
|
||||||
|
() => ({
|
||||||
|
pageIndex,
|
||||||
|
pageSize,
|
||||||
|
}),
|
||||||
|
[pageIndex, pageSize]
|
||||||
|
)
|
||||||
|
|
||||||
|
const [rowSelection, setRowSelection] = useState<RowSelectionState>({})
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setValue(
|
||||||
|
"product_ids",
|
||||||
|
Object.keys(rowSelection).filter((k) => rowSelection[k]),
|
||||||
|
{
|
||||||
|
shouldDirty: true,
|
||||||
|
shouldTouch: true,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}, [rowSelection, setValue])
|
||||||
|
|
||||||
|
const params = useQueryParams(["q", "order"])
|
||||||
|
|
||||||
|
const { products, count } = useAdminProducts(
|
||||||
|
{
|
||||||
|
expand: "variants,sales_channels",
|
||||||
|
limit: PAGE_SIZE,
|
||||||
|
offset: pageIndex * PAGE_SIZE,
|
||||||
|
...params,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
keepPreviousData: true,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
const columns = useColumns()
|
||||||
|
|
||||||
|
const table = useReactTable({
|
||||||
|
data: (products ?? []) as Product[],
|
||||||
|
columns,
|
||||||
|
pageCount: Math.ceil((count ?? 0) / PAGE_SIZE),
|
||||||
|
state: {
|
||||||
|
pagination,
|
||||||
|
rowSelection,
|
||||||
|
},
|
||||||
|
onPaginationChange: setPagination,
|
||||||
|
onRowSelectionChange: setRowSelection,
|
||||||
|
getCoreRowModel: getCoreRowModel(),
|
||||||
|
manualPagination: true,
|
||||||
|
getRowId: (row) => row.id,
|
||||||
|
enableRowSelection(row) {
|
||||||
|
return !row.original.sales_channels
|
||||||
|
?.map((sc) => sc.id)
|
||||||
|
.includes(salesChannel.id)
|
||||||
|
},
|
||||||
|
meta: {
|
||||||
|
salesChannelId: salesChannel.id,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
const handleSubmit = form.handleSubmit(async (values) => {
|
||||||
|
await mutateAsync(
|
||||||
|
{
|
||||||
|
product_ids: values.product_ids,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
onSuccess: () => {
|
||||||
|
/**
|
||||||
|
* Invalidate the products list query to refetch products and
|
||||||
|
* determine if they are added to the sales channel or not.
|
||||||
|
*/
|
||||||
|
queryClient.invalidateQueries(adminProductKeys.lists())
|
||||||
|
handleSuccess()
|
||||||
|
},
|
||||||
|
}
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
return (
|
||||||
|
<RouteFocusModal.Form form={form}>
|
||||||
|
<form
|
||||||
|
onSubmit={handleSubmit}
|
||||||
|
className="flex h-full flex-col overflow-hidden"
|
||||||
|
>
|
||||||
|
<RouteFocusModal.Header>
|
||||||
|
<div className="flex items-center justify-end gap-x-2">
|
||||||
|
{form.formState.errors.product_ids && (
|
||||||
|
<Hint variant="error">
|
||||||
|
{form.formState.errors.product_ids.message}
|
||||||
|
</Hint>
|
||||||
|
)}
|
||||||
|
<RouteFocusModal.Close asChild>
|
||||||
|
<Button size="small" variant="secondary">
|
||||||
|
{t("actions.cancel")}
|
||||||
|
</Button>
|
||||||
|
</RouteFocusModal.Close>
|
||||||
|
<Button size="small" type="submit" isLoading={isMutating}>
|
||||||
|
{t("actions.save")}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</RouteFocusModal.Header>
|
||||||
|
<RouteFocusModal.Body className="flex h-full w-full flex-col items-center divide-y overflow-y-auto">
|
||||||
|
<div className="flex w-full items-center justify-between px-6 py-4">
|
||||||
|
<div></div>
|
||||||
|
<div className="flex items-center gap-x-2">
|
||||||
|
<Query />
|
||||||
|
<OrderBy keys={["title"]} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="w-full flex-1 overflow-y-auto">
|
||||||
|
<Table>
|
||||||
|
<Table.Header className="border-t-0">
|
||||||
|
{table.getHeaderGroups().map((headerGroup) => {
|
||||||
|
return (
|
||||||
|
<Table.Row
|
||||||
|
key={headerGroup.id}
|
||||||
|
className="[&_th:first-of-type]:w-[1%] [&_th:first-of-type]:whitespace-nowrap"
|
||||||
|
>
|
||||||
|
{headerGroup.headers.map((header) => {
|
||||||
|
return (
|
||||||
|
<Table.HeaderCell key={header.id}>
|
||||||
|
{flexRender(
|
||||||
|
header.column.columnDef.header,
|
||||||
|
header.getContext()
|
||||||
|
)}
|
||||||
|
</Table.HeaderCell>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</Table.Row>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</Table.Header>
|
||||||
|
<Table.Body className="border-b-0">
|
||||||
|
{table.getRowModel().rows.map((row) => (
|
||||||
|
<Table.Row
|
||||||
|
key={row.id}
|
||||||
|
className={clx(
|
||||||
|
"transition-fg",
|
||||||
|
{
|
||||||
|
"bg-ui-bg-highlight hover:bg-ui-bg-highlight-hover":
|
||||||
|
row.getIsSelected(),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"bg-ui-bg-disabled hover:bg-ui-bg-disabled":
|
||||||
|
row.original.sales_channels
|
||||||
|
?.map((sc) => sc.id)
|
||||||
|
.includes(salesChannel.id),
|
||||||
|
}
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{row.getVisibleCells().map((cell) => (
|
||||||
|
<Table.Cell key={cell.id}>
|
||||||
|
{flexRender(
|
||||||
|
cell.column.columnDef.cell,
|
||||||
|
cell.getContext()
|
||||||
|
)}
|
||||||
|
</Table.Cell>
|
||||||
|
))}
|
||||||
|
</Table.Row>
|
||||||
|
))}
|
||||||
|
</Table.Body>
|
||||||
|
</Table>
|
||||||
|
</div>
|
||||||
|
<div className="w-full border-t">
|
||||||
|
<LocalizedTablePagination
|
||||||
|
canNextPage={table.getCanNextPage()}
|
||||||
|
canPreviousPage={table.getCanPreviousPage()}
|
||||||
|
nextPage={table.nextPage}
|
||||||
|
previousPage={table.previousPage}
|
||||||
|
count={count ?? 0}
|
||||||
|
pageIndex={pageIndex}
|
||||||
|
pageCount={table.getPageCount()}
|
||||||
|
pageSize={PAGE_SIZE}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</RouteFocusModal.Body>
|
||||||
|
</form>
|
||||||
|
</RouteFocusModal.Form>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const columnHelper = createColumnHelper<Product>()
|
||||||
|
|
||||||
|
const useColumns = () => {
|
||||||
|
const { t } = useTranslation()
|
||||||
|
|
||||||
|
return useMemo(
|
||||||
|
() => [
|
||||||
|
columnHelper.display({
|
||||||
|
id: "select",
|
||||||
|
header: ({ table }) => {
|
||||||
|
return (
|
||||||
|
<Checkbox
|
||||||
|
checked={
|
||||||
|
table.getIsSomePageRowsSelected()
|
||||||
|
? "indeterminate"
|
||||||
|
: table.getIsAllPageRowsSelected()
|
||||||
|
}
|
||||||
|
onCheckedChange={(value) =>
|
||||||
|
table.toggleAllPageRowsSelected(!!value)
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
},
|
||||||
|
cell: ({ row, table }) => {
|
||||||
|
const { salesChannelId } = table.options.meta as {
|
||||||
|
salesChannelId: string
|
||||||
|
}
|
||||||
|
|
||||||
|
const isAdded = row.original.sales_channels
|
||||||
|
?.map((sc) => sc.id)
|
||||||
|
.includes(salesChannelId)
|
||||||
|
|
||||||
|
const isSelected = row.getIsSelected() || isAdded
|
||||||
|
|
||||||
|
const Component = (
|
||||||
|
<Checkbox
|
||||||
|
checked={isSelected}
|
||||||
|
disabled={isAdded}
|
||||||
|
onCheckedChange={(value) => row.toggleSelected(!!value)}
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation()
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
|
||||||
|
if (isAdded) {
|
||||||
|
return (
|
||||||
|
<Tooltip
|
||||||
|
content={t("salesChannels.productAlreadyAdded")}
|
||||||
|
side="right"
|
||||||
|
>
|
||||||
|
{Component}
|
||||||
|
</Tooltip>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return Component
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
columnHelper.accessor("title", {
|
||||||
|
header: t("fields.title"),
|
||||||
|
cell: ({ row }) => {
|
||||||
|
const product = row.original
|
||||||
|
|
||||||
|
return <ProductTitleCell product={product} />
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
columnHelper.accessor("collection", {
|
||||||
|
header: t("fields.collection"),
|
||||||
|
cell: ({ getValue }) => {
|
||||||
|
const collection = getValue()
|
||||||
|
|
||||||
|
return <ProductCollectionCell collection={collection} />
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
columnHelper.accessor("sales_channels", {
|
||||||
|
header: t("fields.availability"),
|
||||||
|
cell: ({ getValue }) => {
|
||||||
|
const salesChannels = getValue()
|
||||||
|
|
||||||
|
return <ProductAvailabilityCell salesChannels={salesChannels} />
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
columnHelper.accessor("variants", {
|
||||||
|
header: t("fields.inventory"),
|
||||||
|
cell: (cell) => {
|
||||||
|
const variants = cell.getValue()
|
||||||
|
|
||||||
|
return <ProductVariantCell variants={variants} />
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
columnHelper.accessor("status", {
|
||||||
|
header: t("fields.status"),
|
||||||
|
cell: ({ getValue }) => {
|
||||||
|
const status = getValue()
|
||||||
|
|
||||||
|
return <ProductStatusCell status={status} />
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
[t]
|
||||||
|
)
|
||||||
|
}
|
||||||
+1
@@ -0,0 +1 @@
|
|||||||
|
export * from "./add-products-to-sales-channel-form"
|
||||||
+1
@@ -0,0 +1 @@
|
|||||||
|
export { SalesChannelAddProducts as Component } from "./sales-channel-add-products"
|
||||||
+21
@@ -0,0 +1,21 @@
|
|||||||
|
import { useAdminSalesChannel } from "medusa-react"
|
||||||
|
import { useParams } from "react-router-dom"
|
||||||
|
import { RouteFocusModal } from "../../../components/route-modal"
|
||||||
|
import { AddProductsToSalesChannelForm } from "./components"
|
||||||
|
|
||||||
|
export const SalesChannelAddProducts = () => {
|
||||||
|
const { id } = useParams()
|
||||||
|
const { sales_channel, isLoading, isError, error } = useAdminSalesChannel(id!)
|
||||||
|
|
||||||
|
if (isError) {
|
||||||
|
throw error
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<RouteFocusModal>
|
||||||
|
{!isLoading && sales_channel && (
|
||||||
|
<AddProductsToSalesChannelForm salesChannel={sales_channel} />
|
||||||
|
)}
|
||||||
|
</RouteFocusModal>
|
||||||
|
)
|
||||||
|
}
|
||||||
+1
@@ -0,0 +1 @@
|
|||||||
|
export { SalesChannelCreate as Component } from "./sales-channel-create"
|
||||||
+10
@@ -0,0 +1,10 @@
|
|||||||
|
import { RouteFocusModal } from "../../../components/route-modal"
|
||||||
|
import { CreateSalesChannelForm } from "../../../modules/sales-channels/sales-channel-create/components/create-sales-channel-form"
|
||||||
|
|
||||||
|
export const SalesChannelCreate = () => {
|
||||||
|
return (
|
||||||
|
<RouteFocusModal>
|
||||||
|
<CreateSalesChannelForm />
|
||||||
|
</RouteFocusModal>
|
||||||
|
)
|
||||||
|
}
|
||||||
+1
@@ -0,0 +1 @@
|
|||||||
|
export * from "./sales-channel-product-section"
|
||||||
+363
@@ -0,0 +1,363 @@
|
|||||||
|
import { PencilSquare, Trash } from "@medusajs/icons"
|
||||||
|
import { Product, SalesChannel } from "@medusajs/medusa"
|
||||||
|
import {
|
||||||
|
Button,
|
||||||
|
Checkbox,
|
||||||
|
CommandBar,
|
||||||
|
Container,
|
||||||
|
Heading,
|
||||||
|
Table,
|
||||||
|
clx,
|
||||||
|
usePrompt,
|
||||||
|
} from "@medusajs/ui"
|
||||||
|
import {
|
||||||
|
PaginationState,
|
||||||
|
RowSelectionState,
|
||||||
|
createColumnHelper,
|
||||||
|
flexRender,
|
||||||
|
getCoreRowModel,
|
||||||
|
useReactTable,
|
||||||
|
} from "@tanstack/react-table"
|
||||||
|
import {
|
||||||
|
adminProductKeys,
|
||||||
|
adminSalesChannelsKeys,
|
||||||
|
useAdminCustomPost,
|
||||||
|
useAdminDeleteProductsFromSalesChannel,
|
||||||
|
useAdminProducts,
|
||||||
|
} from "medusa-react"
|
||||||
|
import { useMemo, useState } from "react"
|
||||||
|
import { useTranslation } from "react-i18next"
|
||||||
|
import { Link } from "react-router-dom"
|
||||||
|
|
||||||
|
import {
|
||||||
|
ProductStatusCell,
|
||||||
|
ProductTitleCell,
|
||||||
|
ProductVariantCell,
|
||||||
|
} from "../../../../../components/common/product-table-cells"
|
||||||
|
import { LocalizedTablePagination } from "../../../../../components/localization/localized-table-pagination"
|
||||||
|
|
||||||
|
import { ActionMenu } from "../../../../../components/common/action-menu"
|
||||||
|
import { FilterGroup } from "../../../../../components/filtering/filter-group"
|
||||||
|
import { OrderBy } from "../../../../../components/filtering/order-by"
|
||||||
|
import { Query } from "../../../../../components/filtering/query"
|
||||||
|
import { useQueryParams } from "../../../../../hooks/use-query-params"
|
||||||
|
import { queryClient } from "../../../../../lib/medusa"
|
||||||
|
|
||||||
|
const PAGE_SIZE = 10
|
||||||
|
|
||||||
|
type SalesChannelProductSection = {
|
||||||
|
salesChannel: SalesChannel
|
||||||
|
}
|
||||||
|
|
||||||
|
export const SalesChannelProductSection = ({
|
||||||
|
salesChannel,
|
||||||
|
}: SalesChannelProductSection) => {
|
||||||
|
const [{ pageIndex, pageSize }, setPagination] = useState<PaginationState>({
|
||||||
|
pageIndex: 0,
|
||||||
|
pageSize: PAGE_SIZE,
|
||||||
|
})
|
||||||
|
|
||||||
|
const pagination = useMemo(
|
||||||
|
() => ({
|
||||||
|
pageIndex,
|
||||||
|
pageSize,
|
||||||
|
}),
|
||||||
|
[pageIndex, pageSize]
|
||||||
|
)
|
||||||
|
|
||||||
|
const [rowSelection, setRowSelection] = useState<RowSelectionState>({})
|
||||||
|
|
||||||
|
const params = useQueryParams(["q", "order"])
|
||||||
|
const { products, count, isLoading, isError, error } = useAdminProducts(
|
||||||
|
{
|
||||||
|
sales_channel_id: [salesChannel.id],
|
||||||
|
limit: PAGE_SIZE,
|
||||||
|
offset: pageIndex * PAGE_SIZE,
|
||||||
|
...params,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
keepPreviousData: true,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
const columns = useListColumns(salesChannel.id)
|
||||||
|
|
||||||
|
const table = useReactTable({
|
||||||
|
data: (products ?? []) as Product[],
|
||||||
|
columns,
|
||||||
|
pageCount: Math.ceil((count ?? 0) / PAGE_SIZE),
|
||||||
|
state: {
|
||||||
|
pagination,
|
||||||
|
rowSelection,
|
||||||
|
},
|
||||||
|
getRowId: (row) => row.id,
|
||||||
|
onPaginationChange: setPagination,
|
||||||
|
onRowSelectionChange: setRowSelection,
|
||||||
|
getCoreRowModel: getCoreRowModel(),
|
||||||
|
manualPagination: true,
|
||||||
|
})
|
||||||
|
|
||||||
|
const { mutateAsync } = useAdminCustomPost(
|
||||||
|
`/admin/sales-channels/${salesChannel.id}/products/batch/remove`,
|
||||||
|
[
|
||||||
|
adminSalesChannelsKeys.lists(),
|
||||||
|
adminSalesChannelsKeys.detail(salesChannel.id),
|
||||||
|
]
|
||||||
|
)
|
||||||
|
|
||||||
|
const prompt = usePrompt()
|
||||||
|
|
||||||
|
const { t } = useTranslation()
|
||||||
|
|
||||||
|
const onRemove = async () => {
|
||||||
|
const ids = Object.keys(rowSelection)
|
||||||
|
|
||||||
|
const result = await prompt({
|
||||||
|
title: t("general.areYouSure"),
|
||||||
|
description: t("salesChannels.removeProductsWarning", {
|
||||||
|
count: ids.length,
|
||||||
|
sales_channel: salesChannel.name,
|
||||||
|
}),
|
||||||
|
confirmText: t("actions.delete"),
|
||||||
|
cancelText: t("actions.cancel"),
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!result) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
await mutateAsync(
|
||||||
|
{
|
||||||
|
product_ids: ids,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
onSuccess: () => {
|
||||||
|
setRowSelection({})
|
||||||
|
queryClient.invalidateQueries(adminProductKeys.lists())
|
||||||
|
},
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isError) {
|
||||||
|
throw error
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Container className="divide-y p-0">
|
||||||
|
<div className="flex items-center justify-between px-6 py-4">
|
||||||
|
<Heading level="h2">{t("products.domain")}</Heading>
|
||||||
|
<Link to={`/settings/sales-channels/${salesChannel.id}/add-products`}>
|
||||||
|
<Button size="small" variant="secondary">
|
||||||
|
{t("general.add")}
|
||||||
|
</Button>
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center justify-between px-6 py-4">
|
||||||
|
<FilterGroup
|
||||||
|
filters={{
|
||||||
|
collection: "Collection",
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<div className="flex items-center gap-x-2">
|
||||||
|
<Query />
|
||||||
|
<OrderBy keys={["title", "status", "created_at", "updated_at"]} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<Table>
|
||||||
|
<Table.Header className="border-t-0">
|
||||||
|
{table.getHeaderGroups().map((headerGroup) => {
|
||||||
|
return (
|
||||||
|
<Table.Row
|
||||||
|
key={headerGroup.id}
|
||||||
|
className="[&_th:first-of-type]:w-[1%] [&_th:first-of-type]:whitespace-nowrap [&_th:last-of-type]:w-[1%] [&_th:last-of-type]:whitespace-nowrap [&_th]:w-1/3"
|
||||||
|
>
|
||||||
|
{headerGroup.headers.map((header) => {
|
||||||
|
return (
|
||||||
|
<Table.HeaderCell key={header.id}>
|
||||||
|
{flexRender(
|
||||||
|
header.column.columnDef.header,
|
||||||
|
header.getContext()
|
||||||
|
)}
|
||||||
|
</Table.HeaderCell>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</Table.Row>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</Table.Header>
|
||||||
|
<Table.Body className="border-b-0">
|
||||||
|
{table.getRowModel().rows.map((row) => (
|
||||||
|
<Table.Row
|
||||||
|
key={row.id}
|
||||||
|
className={clx(
|
||||||
|
"transition-fg [&_td:last-of-type]:w-[1%] [&_td:last-of-type]:whitespace-nowrap",
|
||||||
|
{
|
||||||
|
"bg-ui-bg-highlight hover:bg-ui-bg-highlight-hover":
|
||||||
|
row.getIsSelected(),
|
||||||
|
}
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{row.getVisibleCells().map((cell) => (
|
||||||
|
<Table.Cell key={cell.id}>
|
||||||
|
{flexRender(cell.column.columnDef.cell, cell.getContext())}
|
||||||
|
</Table.Cell>
|
||||||
|
))}
|
||||||
|
</Table.Row>
|
||||||
|
))}
|
||||||
|
</Table.Body>
|
||||||
|
</Table>
|
||||||
|
<LocalizedTablePagination
|
||||||
|
canNextPage={table.getCanNextPage()}
|
||||||
|
canPreviousPage={table.getCanPreviousPage()}
|
||||||
|
nextPage={table.nextPage}
|
||||||
|
previousPage={table.previousPage}
|
||||||
|
count={count ?? 0}
|
||||||
|
pageIndex={pageIndex}
|
||||||
|
pageCount={table.getPageCount()}
|
||||||
|
pageSize={PAGE_SIZE}
|
||||||
|
/>
|
||||||
|
<CommandBar open={!!Object.keys(rowSelection).length}>
|
||||||
|
<CommandBar.Bar>
|
||||||
|
<CommandBar.Value>
|
||||||
|
{t("general.countSelected", {
|
||||||
|
count: Object.keys(rowSelection).length,
|
||||||
|
})}
|
||||||
|
</CommandBar.Value>
|
||||||
|
<CommandBar.Seperator />
|
||||||
|
<CommandBar.Command
|
||||||
|
action={onRemove}
|
||||||
|
shortcut="r"
|
||||||
|
label={t("actions.remove")}
|
||||||
|
/>
|
||||||
|
</CommandBar.Bar>
|
||||||
|
</CommandBar>
|
||||||
|
</div>
|
||||||
|
</Container>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const listColumnHelper = createColumnHelper<Product>()
|
||||||
|
|
||||||
|
const useListColumns = (id: string) => {
|
||||||
|
const { t } = useTranslation()
|
||||||
|
|
||||||
|
return useMemo(
|
||||||
|
() => [
|
||||||
|
listColumnHelper.display({
|
||||||
|
id: "select",
|
||||||
|
header: ({ table }) => {
|
||||||
|
return (
|
||||||
|
<Checkbox
|
||||||
|
checked={
|
||||||
|
table.getIsSomePageRowsSelected()
|
||||||
|
? "indeterminate"
|
||||||
|
: table.getIsAllPageRowsSelected()
|
||||||
|
}
|
||||||
|
onCheckedChange={(value) =>
|
||||||
|
table.toggleAllPageRowsSelected(!!value)
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
},
|
||||||
|
cell: ({ row }) => {
|
||||||
|
return (
|
||||||
|
<Checkbox
|
||||||
|
checked={row.getIsSelected()}
|
||||||
|
onCheckedChange={(value) => row.toggleSelected(!!value)}
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation()
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
listColumnHelper.accessor("title", {
|
||||||
|
header: t("fields.title"),
|
||||||
|
cell: ({ row }) => {
|
||||||
|
const product = row.original
|
||||||
|
|
||||||
|
return <ProductTitleCell product={product} />
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
listColumnHelper.accessor("variants", {
|
||||||
|
header: t("fields.variants"),
|
||||||
|
cell: (cell) => {
|
||||||
|
const variants = cell.getValue()
|
||||||
|
|
||||||
|
return <ProductVariantCell variants={variants} />
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
listColumnHelper.accessor("status", {
|
||||||
|
header: t("fields.status"),
|
||||||
|
cell: ({ getValue }) => {
|
||||||
|
const status = getValue()
|
||||||
|
|
||||||
|
return <ProductStatusCell status={status} />
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
listColumnHelper.display({
|
||||||
|
id: "actions",
|
||||||
|
cell: ({ row }) => {
|
||||||
|
return (
|
||||||
|
<ProductListCellActions
|
||||||
|
productId={row.original.id}
|
||||||
|
salesChannelId={id}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
[t]
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const ProductListCellActions = ({
|
||||||
|
salesChannelId,
|
||||||
|
productId,
|
||||||
|
}: {
|
||||||
|
productId: string
|
||||||
|
salesChannelId: string
|
||||||
|
}) => {
|
||||||
|
const { t } = useTranslation()
|
||||||
|
|
||||||
|
const { mutateAsync } = useAdminCustomPost(
|
||||||
|
`/admin/sales-channels/${salesChannelId}/products/batch/remove`,
|
||||||
|
[
|
||||||
|
...adminSalesChannelsKeys.lists(),
|
||||||
|
...adminSalesChannelsKeys.detail(salesChannelId),
|
||||||
|
]
|
||||||
|
)
|
||||||
|
|
||||||
|
const onRemove = async () => {
|
||||||
|
await mutateAsync({
|
||||||
|
product_ids: [productId],
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ActionMenu
|
||||||
|
groups={[
|
||||||
|
{
|
||||||
|
actions: [
|
||||||
|
{
|
||||||
|
icon: <PencilSquare />,
|
||||||
|
label: t("actions.edit"),
|
||||||
|
to: `/products/${productId}`,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
actions: [
|
||||||
|
{
|
||||||
|
icon: <Trash />,
|
||||||
|
label: t("actions.remove"),
|
||||||
|
onClick: onRemove,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
+2
@@ -0,0 +1,2 @@
|
|||||||
|
export { salesChannelLoader as loader } from "./loader"
|
||||||
|
export { SalesChannelDetail as Component } from "./sales-channel-detail"
|
||||||
+21
@@ -0,0 +1,21 @@
|
|||||||
|
import { AdminSalesChannelsRes } from "@medusajs/medusa"
|
||||||
|
import { Response } from "@medusajs/medusa-js"
|
||||||
|
import { adminProductKeys } from "medusa-react"
|
||||||
|
import { LoaderFunctionArgs } from "react-router-dom"
|
||||||
|
|
||||||
|
import { medusa, queryClient } from "../../../lib/medusa"
|
||||||
|
|
||||||
|
const salesChannelDetailQuery = (id: string) => ({
|
||||||
|
queryKey: adminProductKeys.detail(id),
|
||||||
|
queryFn: async () => medusa.admin.salesChannels.retrieve(id),
|
||||||
|
})
|
||||||
|
|
||||||
|
export const salesChannelLoader = async ({ params }: LoaderFunctionArgs) => {
|
||||||
|
const id = params.id
|
||||||
|
const query = salesChannelDetailQuery(id!)
|
||||||
|
|
||||||
|
return (
|
||||||
|
queryClient.getQueryData<Response<AdminSalesChannelsRes>>(query.queryKey) ??
|
||||||
|
(await queryClient.fetchQuery(query))
|
||||||
|
)
|
||||||
|
}
|
||||||
+31
@@ -0,0 +1,31 @@
|
|||||||
|
import { useAdminSalesChannel } from "medusa-react"
|
||||||
|
import { Outlet, useLoaderData, useParams } from "react-router-dom"
|
||||||
|
|
||||||
|
import { JsonViewSection } from "../../../components/common/json-view-section"
|
||||||
|
import { SalesChannelGeneralSection } from "../../../modules/sales-channels/sales-channel-detail/components/sales-channel-general-section"
|
||||||
|
import { SalesChannelProductSection } from "./components/sales-channel-product-section"
|
||||||
|
import { salesChannelLoader } from "./loader"
|
||||||
|
|
||||||
|
export const SalesChannelDetail = () => {
|
||||||
|
const initialData = useLoaderData() as Awaited<
|
||||||
|
ReturnType<typeof salesChannelLoader>
|
||||||
|
>
|
||||||
|
|
||||||
|
const { id } = useParams()
|
||||||
|
const { sales_channel, isLoading } = useAdminSalesChannel(id!, {
|
||||||
|
initialData,
|
||||||
|
})
|
||||||
|
|
||||||
|
if (isLoading || !sales_channel) {
|
||||||
|
return <div>Loading...</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col gap-y-2">
|
||||||
|
<SalesChannelGeneralSection salesChannel={sales_channel} />
|
||||||
|
<SalesChannelProductSection salesChannel={sales_channel} />
|
||||||
|
<JsonViewSection data={sales_channel} />
|
||||||
|
<Outlet />
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
export { SalesChannelEdit as Component } from "./sales-channel-edit"
|
||||||
+31
@@ -0,0 +1,31 @@
|
|||||||
|
import { Heading } from "@medusajs/ui"
|
||||||
|
import { useAdminSalesChannel } from "medusa-react"
|
||||||
|
import { useTranslation } from "react-i18next"
|
||||||
|
import { useParams } from "react-router-dom"
|
||||||
|
|
||||||
|
import { RouteDrawer } from "../../../components/route-modal"
|
||||||
|
import { EditSalesChannelForm } from "../../../modules/sales-channels/sales-channel-edit/components/edit-sales-channel-form"
|
||||||
|
|
||||||
|
export const SalesChannelEdit = () => {
|
||||||
|
const { id } = useParams()
|
||||||
|
const { t } = useTranslation()
|
||||||
|
|
||||||
|
const { sales_channel, isLoading, isError, error } = useAdminSalesChannel(id!)
|
||||||
|
|
||||||
|
if (isError) {
|
||||||
|
throw error
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<RouteDrawer>
|
||||||
|
<RouteDrawer.Header>
|
||||||
|
<Heading className="capitalize">
|
||||||
|
{t("salesChannels.editSalesChannel")}
|
||||||
|
</Heading>
|
||||||
|
</RouteDrawer.Header>
|
||||||
|
{!isLoading && !!sales_channel && (
|
||||||
|
<EditSalesChannelForm salesChannel={sales_channel} />
|
||||||
|
)}
|
||||||
|
</RouteDrawer>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
export { SalesChannelList as Component } from "./sales-channel-list";
|
||||||
+11
@@ -0,0 +1,11 @@
|
|||||||
|
import { Outlet } from "react-router-dom"
|
||||||
|
import { SalesChannelListTable } from "../../../modules/sales-channels/sales-channel-list/components"
|
||||||
|
|
||||||
|
export const SalesChannelList = () => {
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col gap-y-2">
|
||||||
|
<SalesChannelListTable />
|
||||||
|
<Outlet />
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,47 @@
|
|||||||
|
import { Modules } from "@medusajs/modules-sdk"
|
||||||
|
import { ContainerRegistrationKeys } from "@medusajs/utils"
|
||||||
|
import { StepResponse, createStep } from "@medusajs/workflows-sdk"
|
||||||
|
|
||||||
|
interface StepInput {
|
||||||
|
links: {
|
||||||
|
sales_channel_id: string
|
||||||
|
product_ids: string[]
|
||||||
|
}[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export const detachProductsFromSalesChannelsStepId =
|
||||||
|
"detach-products-from-sales-channels-step"
|
||||||
|
export const detachProductsFromSalesChannelsStep = createStep(
|
||||||
|
detachProductsFromSalesChannelsStepId,
|
||||||
|
async (input: StepInput, { container }) => {
|
||||||
|
const remoteLink = container.resolve(ContainerRegistrationKeys.REMOTE_LINK)
|
||||||
|
|
||||||
|
const links = input.links
|
||||||
|
.map((link) => {
|
||||||
|
return link.product_ids.map((id) => {
|
||||||
|
return {
|
||||||
|
[Modules.PRODUCT]: {
|
||||||
|
product_id: id,
|
||||||
|
},
|
||||||
|
[Modules.SALES_CHANNEL]: {
|
||||||
|
sales_channel_id: link.sales_channel_id,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
})
|
||||||
|
})
|
||||||
|
.flat()
|
||||||
|
|
||||||
|
await remoteLink.dismiss(links)
|
||||||
|
|
||||||
|
return new StepResponse(void 0, links)
|
||||||
|
},
|
||||||
|
async (links, { container }) => {
|
||||||
|
if (!links?.length) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const remoteLink = container.resolve(ContainerRegistrationKeys.REMOTE_LINK)
|
||||||
|
|
||||||
|
await remoteLink.create(links)
|
||||||
|
}
|
||||||
|
)
|
||||||
@@ -2,5 +2,6 @@ export * from "./associate-products-with-channels"
|
|||||||
export * from "./create-default-sales-channel"
|
export * from "./create-default-sales-channel"
|
||||||
export * from "./create-sales-channels"
|
export * from "./create-sales-channels"
|
||||||
export * from "./delete-sales-channels"
|
export * from "./delete-sales-channels"
|
||||||
|
export * from "./detach-products-from-sales-channels"
|
||||||
export * from "./update-sales-channels"
|
export * from "./update-sales-channels"
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
export * from "./add-products-to-sales-channels"
|
export * from "./add-products-to-sales-channels"
|
||||||
export * from "./create-sales-channels"
|
export * from "./create-sales-channels"
|
||||||
export * from "./delete-sales-channels"
|
export * from "./delete-sales-channels"
|
||||||
|
export * from "./remove-products-from-sales-channels"
|
||||||
export * from "./update-sales-channels"
|
export * from "./update-sales-channels"
|
||||||
|
|
||||||
|
|||||||
+19
@@ -0,0 +1,19 @@
|
|||||||
|
import { SalesChannelDTO } from "@medusajs/types"
|
||||||
|
import { WorkflowData, createWorkflow } from "@medusajs/workflows-sdk"
|
||||||
|
import { detachProductsFromSalesChannelsStep } from "../steps/detach-products-from-sales-channels"
|
||||||
|
|
||||||
|
type WorkflowInput = {
|
||||||
|
data: {
|
||||||
|
sales_channel_id: string
|
||||||
|
product_ids: string[]
|
||||||
|
}[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export const removeProductsFromSalesChannelsWorkflowId =
|
||||||
|
"remove-products-from-sales-channels"
|
||||||
|
export const removeProductsFromSalesChannelsWorkflow = createWorkflow(
|
||||||
|
removeProductsFromSalesChannelsWorkflowId,
|
||||||
|
(input: WorkflowData<WorkflowInput>): WorkflowData<SalesChannelDTO[]> => {
|
||||||
|
return detachProductsFromSalesChannelsStep({ links: input.data })
|
||||||
|
}
|
||||||
|
)
|
||||||
@@ -83,6 +83,7 @@ export const defaultAdminProductFields = [
|
|||||||
"*options.values",
|
"*options.values",
|
||||||
"*tags",
|
"*tags",
|
||||||
"*images",
|
"*images",
|
||||||
|
"*sales_channels",
|
||||||
"*variants",
|
"*variants",
|
||||||
"*variants.prices",
|
"*variants.prices",
|
||||||
"*variants.options",
|
"*variants.options",
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ export const GET = async (
|
|||||||
const remoteQuery = req.scope.resolve(ContainerRegistrationKeys.REMOTE_QUERY)
|
const remoteQuery = req.scope.resolve(ContainerRegistrationKeys.REMOTE_QUERY)
|
||||||
|
|
||||||
const selectFields = remapKeysForProduct(req.remoteQueryConfig.fields ?? [])
|
const selectFields = remapKeysForProduct(req.remoteQueryConfig.fields ?? [])
|
||||||
|
|
||||||
const queryObject = remoteQueryObjectFromString({
|
const queryObject = remoteQueryObjectFromString({
|
||||||
entryPoint: "product",
|
entryPoint: "product",
|
||||||
variables: {
|
variables: {
|
||||||
|
|||||||
@@ -0,0 +1,51 @@
|
|||||||
|
import { removeProductsFromSalesChannelsWorkflow } from "@medusajs/core-flows"
|
||||||
|
import {
|
||||||
|
ContainerRegistrationKeys,
|
||||||
|
remoteQueryObjectFromString,
|
||||||
|
} from "@medusajs/utils"
|
||||||
|
import {
|
||||||
|
AuthenticatedMedusaRequest,
|
||||||
|
MedusaResponse,
|
||||||
|
} from "../../../../../../../types/routing"
|
||||||
|
import { defaultAdminSalesChannelFields } from "../../../../query-config"
|
||||||
|
import { AdminPostSalesChannelsChannelProductsBatchReq } from "../../../../validators"
|
||||||
|
|
||||||
|
export const POST = async (
|
||||||
|
req: AuthenticatedMedusaRequest,
|
||||||
|
res: MedusaResponse
|
||||||
|
) => {
|
||||||
|
const body =
|
||||||
|
req.validatedBody as AdminPostSalesChannelsChannelProductsBatchReq
|
||||||
|
|
||||||
|
const workflowInput = {
|
||||||
|
data: [
|
||||||
|
{
|
||||||
|
sales_channel_id: req.params.id,
|
||||||
|
product_ids: body.product_ids,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}
|
||||||
|
|
||||||
|
const { errors } = await removeProductsFromSalesChannelsWorkflow(
|
||||||
|
req.scope
|
||||||
|
).run({
|
||||||
|
input: workflowInput,
|
||||||
|
throwOnError: false,
|
||||||
|
})
|
||||||
|
|
||||||
|
if (Array.isArray(errors) && errors[0]) {
|
||||||
|
throw errors[0].error
|
||||||
|
}
|
||||||
|
|
||||||
|
const remoteQuery = req.scope.resolve(ContainerRegistrationKeys.REMOTE_QUERY)
|
||||||
|
|
||||||
|
const queryObject = remoteQueryObjectFromString({
|
||||||
|
entryPoint: "sales_channels",
|
||||||
|
variables: { id: req.params.id },
|
||||||
|
fields: defaultAdminSalesChannelFields,
|
||||||
|
})
|
||||||
|
|
||||||
|
const [sales_channel] = await remoteQuery(queryObject)
|
||||||
|
|
||||||
|
res.status(200).json({ sales_channel })
|
||||||
|
}
|
||||||
@@ -62,4 +62,9 @@ export const adminSalesChannelRoutesMiddlewares: MiddlewareRoute[] = [
|
|||||||
matcher: "/admin/sales-channels/:id/products/batch/add",
|
matcher: "/admin/sales-channels/:id/products/batch/add",
|
||||||
middlewares: [transformBody(AdminPostSalesChannelsChannelProductsBatchReq)],
|
middlewares: [transformBody(AdminPostSalesChannelsChannelProductsBatchReq)],
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
method: ["POST"],
|
||||||
|
matcher: "/admin/sales-channels/:id/products/batch/remove",
|
||||||
|
middlewares: [transformBody(AdminPostSalesChannelsChannelProductsBatchReq)],
|
||||||
|
},
|
||||||
]
|
]
|
||||||
|
|||||||
Reference in New Issue
Block a user