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:
Oli Juhl
2024-04-02 15:38:33 +02:00
committed by GitHub
parent 3dee91426e
commit 7895ff3849
40 changed files with 1184 additions and 58 deletions

View File

@@ -1,4 +1,4 @@
const { ModuleRegistrationName } = require("@medusajs/modules-sdk")
const { ModuleRegistrationName, Modules } = require("@medusajs/modules-sdk")
const { medusaIntegrationTestRunner } = require("medusa-test-utils")
const { createAdminUser } = require("../../../helpers/create-admin-user")
const { breaking } = require("../../../helpers/breaking")
@@ -24,6 +24,7 @@ medusaIntegrationTestRunner({
let salesChannelService
let productService
let remoteQuery
let remoteLink
beforeAll(() => {
;({
@@ -46,6 +47,7 @@ medusaIntegrationTestRunner({
)
productService = container.resolve(ModuleRegistrationName.PRODUCT)
remoteQuery = container.resolve(ContainerRegistrationKeys.REMOTE_QUERY)
remoteLink = container.resolve(ContainerRegistrationKeys.REMOTE_LINK)
})
describe("GET /admin/sales-channels/:id", () => {
@@ -614,60 +616,126 @@ medusaIntegrationTestRunner({
})
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 product
beforeEach(async () => {
product = await simpleProductFactory(dbConnection, {
id: "product_1",
title: "test title",
})
salesChannel = await simpleSalesChannelFactory(dbConnection, {
name: "test name",
description: "test description",
products: [product],
})
;({ salesChannel, product } = await breaking(
async () => {
const product = await simpleProductFactory(dbConnection, {
id: "product_1",
title: "test title",
})
const salesChannel = await simpleSalesChannelFactory(dbConnection, {
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 () => {
let attachedProduct = await dbConnection.manager.findOne(Product, {
where: { id: product.id },
relations: ["sales_channels"],
})
const attachedProduct = await breaking(
async () => {
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.arrayContaining([
expect.objectContaining({
id: expect.any(String),
name: "test name",
description: "test description",
is_disabled: false,
}),
expect.objectContaining({
id: expect.any(String),
is_disabled: false,
}),
])
expect.arrayContaining(
breaking(
() => [
expect.objectContaining({
id: expect.any(String),
name: "test name",
description: "test description",
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 = {
product_ids: [{ id: product.id }],
product_ids: breaking(
() => [{ id: product.id }],
() => [product.id]
),
}
await api.delete(
`/admin/sales-channels/${salesChannel.id}/products/batch`,
{
...adminReqConfig,
data: payload,
}
)
// Validate idempotency
const response = await api.delete(
`/admin/sales-channels/${salesChannel.id}/products/batch`,
{
...adminReqConfig,
data: payload,
const response = await breaking(
async () => {
return await api.delete(
`/admin/sales-channels/${salesChannel.id}/products/batch`,
{ ...adminReqConfig, data: payload }
)
},
async () => {
return await api.post(
`/admin/sales-channels/${salesChannel.id}/products/batch/remove`,
payload,
adminReqConfig
)
}
)
@@ -681,17 +749,42 @@ medusaIntegrationTestRunner({
})
)
attachedProduct = await dbConnection.manager.findOne(Product, {
where: { id: product.id },
relations: ["sales_channels"],
})
const removedProduct = await breaking(
async () => {
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
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", () => {
// BREAKING CHANGE: Endpoint has changed
// from: /admin/sales-channels/:id/products/batch
// to: /admin/sales-channels/:id/products/batch/add
let { salesChannel, product } = {}
beforeEach(async () => {

View File

@@ -0,0 +1 @@
export * from "./create-sales-channel-form";

View File

@@ -21,11 +21,11 @@ import { useAdminDeleteSalesChannel, useAdminSalesChannels } from "medusa-react"
import { useMemo, useState } from "react"
import { useTranslation } from "react-i18next"
import { Link, useNavigate } from "react-router-dom"
import { ActionMenu } from "../../../../../components/common/action-menu"
import { OrderBy } from "../../../../../components/filtering/order-by"
import { Query } from "../../../../../components/filtering/query"
import { LocalizedTablePagination } from "../../../../../components/localization/localized-table-pagination"
import { useQueryParams } from "../../../../../hooks/use-query-params"
import { ActionMenu } from "../../../../components/common/action-menu"
import { OrderBy } from "../../../../components/filtering/order-by"
import { Query } from "../../../../components/filtering/query"
import { LocalizedTablePagination } from "../../../../components/localization/localized-table-pagination"
import { useQueryParams } from "../../../../hooks/use-query-params"
const PAGE_SIZE = 50

View File

@@ -7,6 +7,7 @@ import { Outlet } from "react-router-dom"
import { Spinner } from "@medusajs/icons"
import { ErrorBoundary } from "../../components/error/error-boundary"
import { useV2Session } from "../../lib/api-v2"
import { SalesChannelDTO } from "@medusajs/types"
import { UserDTO } from "@medusajs/types"
import { SearchProvider } from "../search-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"
),
},
],
},
],
},
],
},
],

View File

@@ -94,6 +94,8 @@ export const AddProductsToSalesChannelForm = ({
const { products, count } = useAdminProducts(
{
expand: "variants,sales_channels",
limit: PAGE_SIZE,
offset: pageIndex * PAGE_SIZE,
...params,
},
{

View File

@@ -1,5 +1,5 @@
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 = () => {
return (

View File

@@ -2,7 +2,7 @@ import { useAdminSalesChannel } from "medusa-react"
import { Outlet, useLoaderData, useParams } from "react-router-dom"
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 { salesChannelLoader } from "./loader"

View File

@@ -4,7 +4,7 @@ import { useTranslation } from "react-i18next"
import { useParams } from "react-router-dom"
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 = () => {
const { id } = useParams()
@@ -23,7 +23,7 @@ export const SalesChannelEdit = () => {
{t("salesChannels.editSalesChannel")}
</Heading>
</RouteDrawer.Header>
{!isLoading && sales_channel && (
{!isLoading && !!sales_channel && (
<EditSalesChannelForm salesChannel={sales_channel} />
)}
</RouteDrawer>

View File

@@ -1 +1 @@
export { SalesChannelList as Component } from "./sales-channel-list"
export { SalesChannelList as Component } from "./sales-channel-list";

View File

@@ -1,5 +1,5 @@
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 = () => {
return (

View File

@@ -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]
)
}

View File

@@ -0,0 +1 @@
export * from "./add-products-to-sales-channel-form"

View File

@@ -0,0 +1 @@
export { SalesChannelAddProducts as Component } from "./sales-channel-add-products"

View File

@@ -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>
)
}

View File

@@ -0,0 +1 @@
export { SalesChannelCreate as Component } from "./sales-channel-create"

View File

@@ -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>
)
}

View File

@@ -0,0 +1 @@
export * from "./sales-channel-product-section"

View File

@@ -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,
},
],
},
]}
/>
)
}

View File

@@ -0,0 +1,2 @@
export { salesChannelLoader as loader } from "./loader"
export { SalesChannelDetail as Component } from "./sales-channel-detail"

View File

@@ -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))
)
}

View File

@@ -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>
)
}

View File

@@ -0,0 +1 @@
export { SalesChannelEdit as Component } from "./sales-channel-edit"

View File

@@ -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>
)
}

View File

@@ -0,0 +1 @@
export { SalesChannelList as Component } from "./sales-channel-list";

View File

@@ -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>
)
}

View File

@@ -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)
}
)

View File

@@ -2,5 +2,6 @@ export * from "./associate-products-with-channels"
export * from "./create-default-sales-channel"
export * from "./create-sales-channels"
export * from "./delete-sales-channels"
export * from "./detach-products-from-sales-channels"
export * from "./update-sales-channels"

View File

@@ -1,5 +1,6 @@
export * from "./add-products-to-sales-channels"
export * from "./create-sales-channels"
export * from "./delete-sales-channels"
export * from "./remove-products-from-sales-channels"
export * from "./update-sales-channels"

View File

@@ -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 })
}
)

View File

@@ -83,6 +83,7 @@ export const defaultAdminProductFields = [
"*options.values",
"*tags",
"*images",
"*sales_channels",
"*variants",
"*variants.prices",
"*variants.options",

View File

@@ -18,6 +18,7 @@ export const GET = async (
const remoteQuery = req.scope.resolve(ContainerRegistrationKeys.REMOTE_QUERY)
const selectFields = remapKeysForProduct(req.remoteQueryConfig.fields ?? [])
const queryObject = remoteQueryObjectFromString({
entryPoint: "product",
variables: {

View File

@@ -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 })
}

View File

@@ -62,4 +62,9 @@ export const adminSalesChannelRoutesMiddlewares: MiddlewareRoute[] = [
matcher: "/admin/sales-channels/:id/products/batch/add",
middlewares: [transformBody(AdminPostSalesChannelsChannelProductsBatchReq)],
},
{
method: ["POST"],
matcher: "/admin/sales-channels/:id/products/batch/remove",
middlewares: [transformBody(AdminPostSalesChannelsChannelProductsBatchReq)],
},
]