From 7895ff3849aed20e405c8b87ecbb21c42e0ab12f Mon Sep 17 00:00:00 2001 From: Oli Juhl <59018053+olivermrbl@users.noreply.github.com> Date: Tue, 2 Apr 2024 15:38:33 +0200 Subject: [PATCH] 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` --- .../api/__tests__/admin/sales-channels.js | 185 ++++++--- .../create-sales-channel-form.tsx | 0 .../create-sales-channel-form/index.ts | 1 + .../sales-channel-general-section/index.ts | 0 .../sales-channel-general-section.tsx | 0 .../edit-sales-channel-form.tsx | 0 .../edit-sales-channel-form/index.ts | 0 .../sales-channel-list/components}/index.ts | 0 .../components}/sales-channel-list-table.tsx | 10 +- .../src/providers/router-provider/v2.tsx | 49 +++ .../add-products-to-sales-channel-form.tsx | 2 + .../create-sales-channel-form/index.ts | 1 - .../sales-channel-create.tsx | 2 +- .../sales-channel-detail.tsx | 2 +- .../sales-channel-edit/sales-channel-edit.tsx | 4 +- .../sales-channel-list/index.ts | 2 +- .../sales-channel-list/sales-channel-list.tsx | 2 +- .../add-products-to-sales-channel-form.tsx | 360 +++++++++++++++++ .../components/index.ts | 1 + .../sales-channel-add-products/index.ts | 1 + .../sales-channel-add-products.tsx | 21 + .../sales-channel-create/index.ts | 1 + .../sales-channel-create.tsx | 10 + .../sales-channel-product-section/index.ts | 1 + .../sales-channel-product-section.tsx | 363 ++++++++++++++++++ .../sales-channel-detail/index.ts | 2 + .../sales-channel-detail/loader.ts | 21 + .../sales-channel-detail.tsx | 31 ++ .../sales-channel-edit/index.ts | 1 + .../sales-channel-edit/sales-channel-edit.tsx | 31 ++ .../sales-channel-list/index.ts | 1 + .../sales-channel-list/sales-channel-list.tsx | 11 + .../detach-products-from-sales-channels.ts | 47 +++ .../src/sales-channel/steps/index.ts | 1 + .../src/sales-channel/workflows/index.ts | 1 + .../remove-products-from-sales-channels.ts | 19 + .../src/api-v2/admin/products/query-config.ts | 1 + .../medusa/src/api-v2/admin/products/route.ts | 1 + .../[id]/products/batch/remove/route.ts | 51 +++ .../admin/sales-channels/middlewares.ts | 5 + 40 files changed, 1184 insertions(+), 58 deletions(-) rename packages/admin-next/dashboard/src/{routes => modules}/sales-channels/sales-channel-create/components/create-sales-channel-form/create-sales-channel-form.tsx (100%) create mode 100644 packages/admin-next/dashboard/src/modules/sales-channels/sales-channel-create/components/create-sales-channel-form/index.ts rename packages/admin-next/dashboard/src/{routes => modules}/sales-channels/sales-channel-detail/components/sales-channel-general-section/index.ts (100%) rename packages/admin-next/dashboard/src/{routes => modules}/sales-channels/sales-channel-detail/components/sales-channel-general-section/sales-channel-general-section.tsx (100%) rename packages/admin-next/dashboard/src/{routes => modules}/sales-channels/sales-channel-edit/components/edit-sales-channel-form/edit-sales-channel-form.tsx (100%) rename packages/admin-next/dashboard/src/{routes => modules}/sales-channels/sales-channel-edit/components/edit-sales-channel-form/index.ts (100%) rename packages/admin-next/dashboard/src/{routes/sales-channels/sales-channel-list/components/sales-channel-list-table => modules/sales-channels/sales-channel-list/components}/index.ts (100%) rename packages/admin-next/dashboard/src/{routes/sales-channels/sales-channel-list/components/sales-channel-list-table => modules/sales-channels/sales-channel-list/components}/sales-channel-list-table.tsx (94%) delete mode 100644 packages/admin-next/dashboard/src/routes/sales-channels/sales-channel-create/components/create-sales-channel-form/index.ts create mode 100644 packages/admin-next/dashboard/src/v2-routes/sales-channels/sales-channel-add-products/components/add-products-to-sales-channel-form.tsx create mode 100644 packages/admin-next/dashboard/src/v2-routes/sales-channels/sales-channel-add-products/components/index.ts create mode 100644 packages/admin-next/dashboard/src/v2-routes/sales-channels/sales-channel-add-products/index.ts create mode 100644 packages/admin-next/dashboard/src/v2-routes/sales-channels/sales-channel-add-products/sales-channel-add-products.tsx create mode 100644 packages/admin-next/dashboard/src/v2-routes/sales-channels/sales-channel-create/index.ts create mode 100644 packages/admin-next/dashboard/src/v2-routes/sales-channels/sales-channel-create/sales-channel-create.tsx create mode 100644 packages/admin-next/dashboard/src/v2-routes/sales-channels/sales-channel-detail/components/sales-channel-product-section/index.ts create mode 100644 packages/admin-next/dashboard/src/v2-routes/sales-channels/sales-channel-detail/components/sales-channel-product-section/sales-channel-product-section.tsx create mode 100644 packages/admin-next/dashboard/src/v2-routes/sales-channels/sales-channel-detail/index.ts create mode 100644 packages/admin-next/dashboard/src/v2-routes/sales-channels/sales-channel-detail/loader.ts create mode 100644 packages/admin-next/dashboard/src/v2-routes/sales-channels/sales-channel-detail/sales-channel-detail.tsx create mode 100644 packages/admin-next/dashboard/src/v2-routes/sales-channels/sales-channel-edit/index.ts create mode 100644 packages/admin-next/dashboard/src/v2-routes/sales-channels/sales-channel-edit/sales-channel-edit.tsx create mode 100644 packages/admin-next/dashboard/src/v2-routes/sales-channels/sales-channel-list/index.ts create mode 100644 packages/admin-next/dashboard/src/v2-routes/sales-channels/sales-channel-list/sales-channel-list.tsx create mode 100644 packages/core-flows/src/sales-channel/steps/detach-products-from-sales-channels.ts create mode 100644 packages/core-flows/src/sales-channel/workflows/remove-products-from-sales-channels.ts create mode 100644 packages/medusa/src/api-v2/admin/sales-channels/[id]/products/batch/remove/route.ts diff --git a/integration-tests/api/__tests__/admin/sales-channels.js b/integration-tests/api/__tests__/admin/sales-channels.js index a849db72cb..64cfc745bd 100644 --- a/integration-tests/api/__tests__/admin/sales-channels.js +++ b/integration-tests/api/__tests__/admin/sales-channels.js @@ -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 () => { diff --git a/packages/admin-next/dashboard/src/routes/sales-channels/sales-channel-create/components/create-sales-channel-form/create-sales-channel-form.tsx b/packages/admin-next/dashboard/src/modules/sales-channels/sales-channel-create/components/create-sales-channel-form/create-sales-channel-form.tsx similarity index 100% rename from packages/admin-next/dashboard/src/routes/sales-channels/sales-channel-create/components/create-sales-channel-form/create-sales-channel-form.tsx rename to packages/admin-next/dashboard/src/modules/sales-channels/sales-channel-create/components/create-sales-channel-form/create-sales-channel-form.tsx diff --git a/packages/admin-next/dashboard/src/modules/sales-channels/sales-channel-create/components/create-sales-channel-form/index.ts b/packages/admin-next/dashboard/src/modules/sales-channels/sales-channel-create/components/create-sales-channel-form/index.ts new file mode 100644 index 0000000000..4ebac80385 --- /dev/null +++ b/packages/admin-next/dashboard/src/modules/sales-channels/sales-channel-create/components/create-sales-channel-form/index.ts @@ -0,0 +1 @@ +export * from "./create-sales-channel-form"; diff --git a/packages/admin-next/dashboard/src/routes/sales-channels/sales-channel-detail/components/sales-channel-general-section/index.ts b/packages/admin-next/dashboard/src/modules/sales-channels/sales-channel-detail/components/sales-channel-general-section/index.ts similarity index 100% rename from packages/admin-next/dashboard/src/routes/sales-channels/sales-channel-detail/components/sales-channel-general-section/index.ts rename to packages/admin-next/dashboard/src/modules/sales-channels/sales-channel-detail/components/sales-channel-general-section/index.ts diff --git a/packages/admin-next/dashboard/src/routes/sales-channels/sales-channel-detail/components/sales-channel-general-section/sales-channel-general-section.tsx b/packages/admin-next/dashboard/src/modules/sales-channels/sales-channel-detail/components/sales-channel-general-section/sales-channel-general-section.tsx similarity index 100% rename from packages/admin-next/dashboard/src/routes/sales-channels/sales-channel-detail/components/sales-channel-general-section/sales-channel-general-section.tsx rename to packages/admin-next/dashboard/src/modules/sales-channels/sales-channel-detail/components/sales-channel-general-section/sales-channel-general-section.tsx diff --git a/packages/admin-next/dashboard/src/routes/sales-channels/sales-channel-edit/components/edit-sales-channel-form/edit-sales-channel-form.tsx b/packages/admin-next/dashboard/src/modules/sales-channels/sales-channel-edit/components/edit-sales-channel-form/edit-sales-channel-form.tsx similarity index 100% rename from packages/admin-next/dashboard/src/routes/sales-channels/sales-channel-edit/components/edit-sales-channel-form/edit-sales-channel-form.tsx rename to packages/admin-next/dashboard/src/modules/sales-channels/sales-channel-edit/components/edit-sales-channel-form/edit-sales-channel-form.tsx diff --git a/packages/admin-next/dashboard/src/routes/sales-channels/sales-channel-edit/components/edit-sales-channel-form/index.ts b/packages/admin-next/dashboard/src/modules/sales-channels/sales-channel-edit/components/edit-sales-channel-form/index.ts similarity index 100% rename from packages/admin-next/dashboard/src/routes/sales-channels/sales-channel-edit/components/edit-sales-channel-form/index.ts rename to packages/admin-next/dashboard/src/modules/sales-channels/sales-channel-edit/components/edit-sales-channel-form/index.ts diff --git a/packages/admin-next/dashboard/src/routes/sales-channels/sales-channel-list/components/sales-channel-list-table/index.ts b/packages/admin-next/dashboard/src/modules/sales-channels/sales-channel-list/components/index.ts similarity index 100% rename from packages/admin-next/dashboard/src/routes/sales-channels/sales-channel-list/components/sales-channel-list-table/index.ts rename to packages/admin-next/dashboard/src/modules/sales-channels/sales-channel-list/components/index.ts diff --git a/packages/admin-next/dashboard/src/routes/sales-channels/sales-channel-list/components/sales-channel-list-table/sales-channel-list-table.tsx b/packages/admin-next/dashboard/src/modules/sales-channels/sales-channel-list/components/sales-channel-list-table.tsx similarity index 94% rename from packages/admin-next/dashboard/src/routes/sales-channels/sales-channel-list/components/sales-channel-list-table/sales-channel-list-table.tsx rename to packages/admin-next/dashboard/src/modules/sales-channels/sales-channel-list/components/sales-channel-list-table.tsx index 4a1a09f3fc..d3ca4618ae 100644 --- a/packages/admin-next/dashboard/src/routes/sales-channels/sales-channel-list/components/sales-channel-list-table/sales-channel-list-table.tsx +++ b/packages/admin-next/dashboard/src/modules/sales-channels/sales-channel-list/components/sales-channel-list-table.tsx @@ -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 diff --git a/packages/admin-next/dashboard/src/providers/router-provider/v2.tsx b/packages/admin-next/dashboard/src/providers/router-provider/v2.tsx index 8eb2231dc9..93c5685e97 100644 --- a/packages/admin-next/dashboard/src/providers/router-provider/v2.tsx +++ b/packages/admin-next/dashboard/src/providers/router-provider/v2.tsx @@ -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: , + 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" + ), + }, + ], + }, + ], + }, ], }, ], diff --git a/packages/admin-next/dashboard/src/routes/sales-channels/sales-channel-add-products/components/add-products-to-sales-channel-form.tsx b/packages/admin-next/dashboard/src/routes/sales-channels/sales-channel-add-products/components/add-products-to-sales-channel-form.tsx index 532a5ee494..d254d42424 100644 --- a/packages/admin-next/dashboard/src/routes/sales-channels/sales-channel-add-products/components/add-products-to-sales-channel-form.tsx +++ b/packages/admin-next/dashboard/src/routes/sales-channels/sales-channel-add-products/components/add-products-to-sales-channel-form.tsx @@ -94,6 +94,8 @@ export const AddProductsToSalesChannelForm = ({ const { products, count } = useAdminProducts( { expand: "variants,sales_channels", + limit: PAGE_SIZE, + offset: pageIndex * PAGE_SIZE, ...params, }, { diff --git a/packages/admin-next/dashboard/src/routes/sales-channels/sales-channel-create/components/create-sales-channel-form/index.ts b/packages/admin-next/dashboard/src/routes/sales-channels/sales-channel-create/components/create-sales-channel-form/index.ts deleted file mode 100644 index d35c2ee15b..0000000000 --- a/packages/admin-next/dashboard/src/routes/sales-channels/sales-channel-create/components/create-sales-channel-form/index.ts +++ /dev/null @@ -1 +0,0 @@ -export * from "./create-sales-channel-form" diff --git a/packages/admin-next/dashboard/src/routes/sales-channels/sales-channel-create/sales-channel-create.tsx b/packages/admin-next/dashboard/src/routes/sales-channels/sales-channel-create/sales-channel-create.tsx index d4abcb0126..e1ee387408 100644 --- a/packages/admin-next/dashboard/src/routes/sales-channels/sales-channel-create/sales-channel-create.tsx +++ b/packages/admin-next/dashboard/src/routes/sales-channels/sales-channel-create/sales-channel-create.tsx @@ -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 ( diff --git a/packages/admin-next/dashboard/src/routes/sales-channels/sales-channel-detail/sales-channel-detail.tsx b/packages/admin-next/dashboard/src/routes/sales-channels/sales-channel-detail/sales-channel-detail.tsx index dd74f83136..09110f86c3 100644 --- a/packages/admin-next/dashboard/src/routes/sales-channels/sales-channel-detail/sales-channel-detail.tsx +++ b/packages/admin-next/dashboard/src/routes/sales-channels/sales-channel-detail/sales-channel-detail.tsx @@ -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" diff --git a/packages/admin-next/dashboard/src/routes/sales-channels/sales-channel-edit/sales-channel-edit.tsx b/packages/admin-next/dashboard/src/routes/sales-channels/sales-channel-edit/sales-channel-edit.tsx index 8a0d2be965..bff248ecef 100644 --- a/packages/admin-next/dashboard/src/routes/sales-channels/sales-channel-edit/sales-channel-edit.tsx +++ b/packages/admin-next/dashboard/src/routes/sales-channels/sales-channel-edit/sales-channel-edit.tsx @@ -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")} - {!isLoading && sales_channel && ( + {!isLoading && !!sales_channel && ( )} diff --git a/packages/admin-next/dashboard/src/routes/sales-channels/sales-channel-list/index.ts b/packages/admin-next/dashboard/src/routes/sales-channels/sales-channel-list/index.ts index 7c4e547be6..3900dde000 100644 --- a/packages/admin-next/dashboard/src/routes/sales-channels/sales-channel-list/index.ts +++ b/packages/admin-next/dashboard/src/routes/sales-channels/sales-channel-list/index.ts @@ -1 +1 @@ -export { SalesChannelList as Component } from "./sales-channel-list" +export { SalesChannelList as Component } from "./sales-channel-list"; diff --git a/packages/admin-next/dashboard/src/routes/sales-channels/sales-channel-list/sales-channel-list.tsx b/packages/admin-next/dashboard/src/routes/sales-channels/sales-channel-list/sales-channel-list.tsx index 9ae07a1ac2..9088b57b3c 100644 --- a/packages/admin-next/dashboard/src/routes/sales-channels/sales-channel-list/sales-channel-list.tsx +++ b/packages/admin-next/dashboard/src/routes/sales-channels/sales-channel-list/sales-channel-list.tsx @@ -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 ( diff --git a/packages/admin-next/dashboard/src/v2-routes/sales-channels/sales-channel-add-products/components/add-products-to-sales-channel-form.tsx b/packages/admin-next/dashboard/src/v2-routes/sales-channels/sales-channel-add-products/components/add-products-to-sales-channel-form.tsx new file mode 100644 index 0000000000..b3c24b2b6a --- /dev/null +++ b/packages/admin-next/dashboard/src/v2-routes/sales-channels/sales-channel-add-products/components/add-products-to-sales-channel-form.tsx @@ -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>({ + 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({ + pageIndex: 0, + pageSize: PAGE_SIZE, + }) + + const pagination = useMemo( + () => ({ + pageIndex, + pageSize, + }), + [pageIndex, pageSize] + ) + + const [rowSelection, setRowSelection] = useState({}) + + 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 ( + +
+ +
+ {form.formState.errors.product_ids && ( + + {form.formState.errors.product_ids.message} + + )} + + + + +
+
+ +
+
+
+ + +
+
+
+ + + {table.getHeaderGroups().map((headerGroup) => { + return ( + + {headerGroup.headers.map((header) => { + return ( + + {flexRender( + header.column.columnDef.header, + header.getContext() + )} + + ) + })} + + ) + })} + + + {table.getRowModel().rows.map((row) => ( + sc.id) + .includes(salesChannel.id), + } + )} + > + {row.getVisibleCells().map((cell) => ( + + {flexRender( + cell.column.columnDef.cell, + cell.getContext() + )} + + ))} + + ))} + +
+
+
+ +
+
+
+
+ ) +} + +const columnHelper = createColumnHelper() + +const useColumns = () => { + const { t } = useTranslation() + + return useMemo( + () => [ + columnHelper.display({ + id: "select", + header: ({ table }) => { + return ( + + 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 = ( + row.toggleSelected(!!value)} + onClick={(e) => { + e.stopPropagation() + }} + /> + ) + + if (isAdded) { + return ( + + {Component} + + ) + } + + return Component + }, + }), + columnHelper.accessor("title", { + header: t("fields.title"), + cell: ({ row }) => { + const product = row.original + + return + }, + }), + columnHelper.accessor("collection", { + header: t("fields.collection"), + cell: ({ getValue }) => { + const collection = getValue() + + return + }, + }), + columnHelper.accessor("sales_channels", { + header: t("fields.availability"), + cell: ({ getValue }) => { + const salesChannels = getValue() + + return + }, + }), + columnHelper.accessor("variants", { + header: t("fields.inventory"), + cell: (cell) => { + const variants = cell.getValue() + + return + }, + }), + columnHelper.accessor("status", { + header: t("fields.status"), + cell: ({ getValue }) => { + const status = getValue() + + return + }, + }), + ], + [t] + ) +} diff --git a/packages/admin-next/dashboard/src/v2-routes/sales-channels/sales-channel-add-products/components/index.ts b/packages/admin-next/dashboard/src/v2-routes/sales-channels/sales-channel-add-products/components/index.ts new file mode 100644 index 0000000000..87d988e4af --- /dev/null +++ b/packages/admin-next/dashboard/src/v2-routes/sales-channels/sales-channel-add-products/components/index.ts @@ -0,0 +1 @@ +export * from "./add-products-to-sales-channel-form" diff --git a/packages/admin-next/dashboard/src/v2-routes/sales-channels/sales-channel-add-products/index.ts b/packages/admin-next/dashboard/src/v2-routes/sales-channels/sales-channel-add-products/index.ts new file mode 100644 index 0000000000..3d06c4174c --- /dev/null +++ b/packages/admin-next/dashboard/src/v2-routes/sales-channels/sales-channel-add-products/index.ts @@ -0,0 +1 @@ +export { SalesChannelAddProducts as Component } from "./sales-channel-add-products" diff --git a/packages/admin-next/dashboard/src/v2-routes/sales-channels/sales-channel-add-products/sales-channel-add-products.tsx b/packages/admin-next/dashboard/src/v2-routes/sales-channels/sales-channel-add-products/sales-channel-add-products.tsx new file mode 100644 index 0000000000..1b28c6d5d3 --- /dev/null +++ b/packages/admin-next/dashboard/src/v2-routes/sales-channels/sales-channel-add-products/sales-channel-add-products.tsx @@ -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 ( + + {!isLoading && sales_channel && ( + + )} + + ) +} diff --git a/packages/admin-next/dashboard/src/v2-routes/sales-channels/sales-channel-create/index.ts b/packages/admin-next/dashboard/src/v2-routes/sales-channels/sales-channel-create/index.ts new file mode 100644 index 0000000000..2d294e8ae7 --- /dev/null +++ b/packages/admin-next/dashboard/src/v2-routes/sales-channels/sales-channel-create/index.ts @@ -0,0 +1 @@ +export { SalesChannelCreate as Component } from "./sales-channel-create" diff --git a/packages/admin-next/dashboard/src/v2-routes/sales-channels/sales-channel-create/sales-channel-create.tsx b/packages/admin-next/dashboard/src/v2-routes/sales-channels/sales-channel-create/sales-channel-create.tsx new file mode 100644 index 0000000000..e1ee387408 --- /dev/null +++ b/packages/admin-next/dashboard/src/v2-routes/sales-channels/sales-channel-create/sales-channel-create.tsx @@ -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 ( + + + + ) +} diff --git a/packages/admin-next/dashboard/src/v2-routes/sales-channels/sales-channel-detail/components/sales-channel-product-section/index.ts b/packages/admin-next/dashboard/src/v2-routes/sales-channels/sales-channel-detail/components/sales-channel-product-section/index.ts new file mode 100644 index 0000000000..72a7d4ad25 --- /dev/null +++ b/packages/admin-next/dashboard/src/v2-routes/sales-channels/sales-channel-detail/components/sales-channel-product-section/index.ts @@ -0,0 +1 @@ +export * from "./sales-channel-product-section" diff --git a/packages/admin-next/dashboard/src/v2-routes/sales-channels/sales-channel-detail/components/sales-channel-product-section/sales-channel-product-section.tsx b/packages/admin-next/dashboard/src/v2-routes/sales-channels/sales-channel-detail/components/sales-channel-product-section/sales-channel-product-section.tsx new file mode 100644 index 0000000000..03304b3e1e --- /dev/null +++ b/packages/admin-next/dashboard/src/v2-routes/sales-channels/sales-channel-detail/components/sales-channel-product-section/sales-channel-product-section.tsx @@ -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({ + pageIndex: 0, + pageSize: PAGE_SIZE, + }) + + const pagination = useMemo( + () => ({ + pageIndex, + pageSize, + }), + [pageIndex, pageSize] + ) + + const [rowSelection, setRowSelection] = useState({}) + + 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 ( + +
+ {t("products.domain")} + + + +
+
+ +
+ + +
+
+
+ + + {table.getHeaderGroups().map((headerGroup) => { + return ( + + {headerGroup.headers.map((header) => { + return ( + + {flexRender( + header.column.columnDef.header, + header.getContext() + )} + + ) + })} + + ) + })} + + + {table.getRowModel().rows.map((row) => ( + + {row.getVisibleCells().map((cell) => ( + + {flexRender(cell.column.columnDef.cell, cell.getContext())} + + ))} + + ))} + +
+ + + + + {t("general.countSelected", { + count: Object.keys(rowSelection).length, + })} + + + + + +
+
+ ) +} + +const listColumnHelper = createColumnHelper() + +const useListColumns = (id: string) => { + const { t } = useTranslation() + + return useMemo( + () => [ + listColumnHelper.display({ + id: "select", + header: ({ table }) => { + return ( + + table.toggleAllPageRowsSelected(!!value) + } + /> + ) + }, + cell: ({ row }) => { + return ( + row.toggleSelected(!!value)} + onClick={(e) => { + e.stopPropagation() + }} + /> + ) + }, + }), + listColumnHelper.accessor("title", { + header: t("fields.title"), + cell: ({ row }) => { + const product = row.original + + return + }, + }), + listColumnHelper.accessor("variants", { + header: t("fields.variants"), + cell: (cell) => { + const variants = cell.getValue() + + return + }, + }), + listColumnHelper.accessor("status", { + header: t("fields.status"), + cell: ({ getValue }) => { + const status = getValue() + + return + }, + }), + listColumnHelper.display({ + id: "actions", + cell: ({ row }) => { + return ( + + ) + }, + }), + ], + [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 ( + , + label: t("actions.edit"), + to: `/products/${productId}`, + }, + ], + }, + { + actions: [ + { + icon: , + label: t("actions.remove"), + onClick: onRemove, + }, + ], + }, + ]} + /> + ) +} diff --git a/packages/admin-next/dashboard/src/v2-routes/sales-channels/sales-channel-detail/index.ts b/packages/admin-next/dashboard/src/v2-routes/sales-channels/sales-channel-detail/index.ts new file mode 100644 index 0000000000..8d5a59045f --- /dev/null +++ b/packages/admin-next/dashboard/src/v2-routes/sales-channels/sales-channel-detail/index.ts @@ -0,0 +1,2 @@ +export { salesChannelLoader as loader } from "./loader" +export { SalesChannelDetail as Component } from "./sales-channel-detail" diff --git a/packages/admin-next/dashboard/src/v2-routes/sales-channels/sales-channel-detail/loader.ts b/packages/admin-next/dashboard/src/v2-routes/sales-channels/sales-channel-detail/loader.ts new file mode 100644 index 0000000000..5208c2e065 --- /dev/null +++ b/packages/admin-next/dashboard/src/v2-routes/sales-channels/sales-channel-detail/loader.ts @@ -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>(query.queryKey) ?? + (await queryClient.fetchQuery(query)) + ) +} diff --git a/packages/admin-next/dashboard/src/v2-routes/sales-channels/sales-channel-detail/sales-channel-detail.tsx b/packages/admin-next/dashboard/src/v2-routes/sales-channels/sales-channel-detail/sales-channel-detail.tsx new file mode 100644 index 0000000000..9ba2161176 --- /dev/null +++ b/packages/admin-next/dashboard/src/v2-routes/sales-channels/sales-channel-detail/sales-channel-detail.tsx @@ -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 + > + + const { id } = useParams() + const { sales_channel, isLoading } = useAdminSalesChannel(id!, { + initialData, + }) + + if (isLoading || !sales_channel) { + return
Loading...
+ } + + return ( +
+ + + + +
+ ) +} diff --git a/packages/admin-next/dashboard/src/v2-routes/sales-channels/sales-channel-edit/index.ts b/packages/admin-next/dashboard/src/v2-routes/sales-channels/sales-channel-edit/index.ts new file mode 100644 index 0000000000..04317d1479 --- /dev/null +++ b/packages/admin-next/dashboard/src/v2-routes/sales-channels/sales-channel-edit/index.ts @@ -0,0 +1 @@ +export { SalesChannelEdit as Component } from "./sales-channel-edit" diff --git a/packages/admin-next/dashboard/src/v2-routes/sales-channels/sales-channel-edit/sales-channel-edit.tsx b/packages/admin-next/dashboard/src/v2-routes/sales-channels/sales-channel-edit/sales-channel-edit.tsx new file mode 100644 index 0000000000..bff248ecef --- /dev/null +++ b/packages/admin-next/dashboard/src/v2-routes/sales-channels/sales-channel-edit/sales-channel-edit.tsx @@ -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 ( + + + + {t("salesChannels.editSalesChannel")} + + + {!isLoading && !!sales_channel && ( + + )} + + ) +} diff --git a/packages/admin-next/dashboard/src/v2-routes/sales-channels/sales-channel-list/index.ts b/packages/admin-next/dashboard/src/v2-routes/sales-channels/sales-channel-list/index.ts new file mode 100644 index 0000000000..3900dde000 --- /dev/null +++ b/packages/admin-next/dashboard/src/v2-routes/sales-channels/sales-channel-list/index.ts @@ -0,0 +1 @@ +export { SalesChannelList as Component } from "./sales-channel-list"; diff --git a/packages/admin-next/dashboard/src/v2-routes/sales-channels/sales-channel-list/sales-channel-list.tsx b/packages/admin-next/dashboard/src/v2-routes/sales-channels/sales-channel-list/sales-channel-list.tsx new file mode 100644 index 0000000000..9088b57b3c --- /dev/null +++ b/packages/admin-next/dashboard/src/v2-routes/sales-channels/sales-channel-list/sales-channel-list.tsx @@ -0,0 +1,11 @@ +import { Outlet } from "react-router-dom" +import { SalesChannelListTable } from "../../../modules/sales-channels/sales-channel-list/components" + +export const SalesChannelList = () => { + return ( +
+ + +
+ ) +} diff --git a/packages/core-flows/src/sales-channel/steps/detach-products-from-sales-channels.ts b/packages/core-flows/src/sales-channel/steps/detach-products-from-sales-channels.ts new file mode 100644 index 0000000000..74f8af1a7a --- /dev/null +++ b/packages/core-flows/src/sales-channel/steps/detach-products-from-sales-channels.ts @@ -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) + } +) diff --git a/packages/core-flows/src/sales-channel/steps/index.ts b/packages/core-flows/src/sales-channel/steps/index.ts index 662d94bce6..8380f657ce 100644 --- a/packages/core-flows/src/sales-channel/steps/index.ts +++ b/packages/core-flows/src/sales-channel/steps/index.ts @@ -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" diff --git a/packages/core-flows/src/sales-channel/workflows/index.ts b/packages/core-flows/src/sales-channel/workflows/index.ts index e00575b44e..a12ea37c1a 100644 --- a/packages/core-flows/src/sales-channel/workflows/index.ts +++ b/packages/core-flows/src/sales-channel/workflows/index.ts @@ -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" diff --git a/packages/core-flows/src/sales-channel/workflows/remove-products-from-sales-channels.ts b/packages/core-flows/src/sales-channel/workflows/remove-products-from-sales-channels.ts new file mode 100644 index 0000000000..325c5842a8 --- /dev/null +++ b/packages/core-flows/src/sales-channel/workflows/remove-products-from-sales-channels.ts @@ -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): WorkflowData => { + return detachProductsFromSalesChannelsStep({ links: input.data }) + } +) diff --git a/packages/medusa/src/api-v2/admin/products/query-config.ts b/packages/medusa/src/api-v2/admin/products/query-config.ts index 85d680e48a..d5c7e26930 100644 --- a/packages/medusa/src/api-v2/admin/products/query-config.ts +++ b/packages/medusa/src/api-v2/admin/products/query-config.ts @@ -83,6 +83,7 @@ export const defaultAdminProductFields = [ "*options.values", "*tags", "*images", + "*sales_channels", "*variants", "*variants.prices", "*variants.options", diff --git a/packages/medusa/src/api-v2/admin/products/route.ts b/packages/medusa/src/api-v2/admin/products/route.ts index 3a2be9ecb6..fe876946fd 100644 --- a/packages/medusa/src/api-v2/admin/products/route.ts +++ b/packages/medusa/src/api-v2/admin/products/route.ts @@ -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: { diff --git a/packages/medusa/src/api-v2/admin/sales-channels/[id]/products/batch/remove/route.ts b/packages/medusa/src/api-v2/admin/sales-channels/[id]/products/batch/remove/route.ts new file mode 100644 index 0000000000..f1ea1e4e44 --- /dev/null +++ b/packages/medusa/src/api-v2/admin/sales-channels/[id]/products/batch/remove/route.ts @@ -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 }) +} diff --git a/packages/medusa/src/api-v2/admin/sales-channels/middlewares.ts b/packages/medusa/src/api-v2/admin/sales-channels/middlewares.ts index 13d31fe29f..ece6497b2d 100644 --- a/packages/medusa/src/api-v2/admin/sales-channels/middlewares.ts +++ b/packages/medusa/src/api-v2/admin/sales-channels/middlewares.ts @@ -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)], + }, ]