From 2f42ed35d69688e5ecd29c56bb3e3a5d17c3205f Mon Sep 17 00:00:00 2001 From: Riqwan Thamir Date: Mon, 13 Mar 2023 18:30:21 +0100 Subject: [PATCH] feat(medusa, admin-ui): increase tree depth + scope categories on store + allow categories relation in products API (#3450) What: - increase tree depth in react nestable - scope categories on store queries - allow categories relation in products API RESOLVES CORE-1238 RESOLVES CORE-1237 RESOLVES CORE-1236 --- .changeset/khaki-plants-bake.md | 6 + .../api/__tests__/admin/product.js | 280 +------------- .../admin/products/ff-product-categories.js | 352 ++++++++++++++++++ .../api/__tests__/store/products.js | 109 ------ .../store/products/ff-product-categories.ts | 230 ++++++++++++ .../components/product-categories-list.tsx | 4 + .../admin/products/__tests__/get-product.js | 1 - .../src/api/routes/admin/products/index.ts | 5 +- packages/medusa/src/api/routes/store/index.js | 4 +- .../__tests__/get-product-category.ts | 6 +- .../get-product-category.ts | 6 +- .../routes/store/product-categories/index.ts | 2 +- .../list-product-categories.ts | 6 +- .../store/products/__tests__/list-products.js | 17 +- .../src/api/routes/store/products/index.ts | 7 +- .../routes/store/products/list-products.ts | 7 + .../src/repositories/product-category.ts | 6 +- packages/medusa/src/repositories/product.ts | 61 +-- .../services/__tests__/product-category.ts | 2 +- .../medusa/src/services/product-category.ts | 15 +- packages/medusa/src/utils/index.ts | 1 + .../src/utils/product-category/index.ts | 25 ++ 22 files changed, 720 insertions(+), 432 deletions(-) create mode 100644 .changeset/khaki-plants-bake.md create mode 100644 integration-tests/api/__tests__/admin/products/ff-product-categories.js create mode 100644 integration-tests/api/__tests__/store/products/ff-product-categories.ts create mode 100644 packages/medusa/src/utils/product-category/index.ts diff --git a/.changeset/khaki-plants-bake.md b/.changeset/khaki-plants-bake.md new file mode 100644 index 0000000000..dd6b0a47bd --- /dev/null +++ b/.changeset/khaki-plants-bake.md @@ -0,0 +1,6 @@ +--- +"@medusajs/admin-ui": patch +"@medusajs/medusa": patch +--- + +feat(medusa, admin-ui): increase tree depth + scope categories on store + allow categories relation in products API diff --git a/integration-tests/api/__tests__/admin/product.js b/integration-tests/api/__tests__/admin/product.js index 26c9e5105c..11b975b3fd 100644 --- a/integration-tests/api/__tests__/admin/product.js +++ b/integration-tests/api/__tests__/admin/product.js @@ -6,7 +6,6 @@ const { initDb, useDb } = require("../../../helpers/use-db") const adminSeeder = require("../../helpers/admin-seeder") const productSeeder = require("../../helpers/product-seeder") -const { ProductCategory } = require("@medusajs/medusa") const { ProductVariant, @@ -19,7 +18,6 @@ const priceListSeeder = require("../../helpers/price-list-seeder") const { simpleProductFactory, simpleDiscountFactory, - simpleProductCategoryFactory, simpleSalesChannelFactory, simpleRegionFactory, } = require("../../factories") @@ -46,6 +44,7 @@ describe("/admin/products", () => { dbConnection = await initDb({ cwd }) medusaProcess = await setupServer({ cwd, + env: { MEDUSA_FF_PRODUCT_CATEGORIES: true } }) }) @@ -454,127 +453,6 @@ describe("/admin/products", () => { } }) - describe("Product Category filtering", () => { - let categoryWithProduct - let categoryWithoutProduct - let nestedCategoryWithProduct - let nested2CategoryWithProduct - const nestedCategoryWithProductId = "nested-category-with-product-id" - const nested2CategoryWithProductId = "nested2-category-with-product-id" - const categoryWithProductId = "category-with-product-id" - const categoryWithoutProductId = "category-without-product-id" - - beforeEach(async () => { - const manager = dbConnection.manager - categoryWithProduct = await simpleProductCategoryFactory(dbConnection, { - id: categoryWithProductId, - name: "category with Product", - products: [{ id: testProductId }], - }) - - nestedCategoryWithProduct = await simpleProductCategoryFactory( - dbConnection, - { - id: nestedCategoryWithProductId, - name: "nested category with Product1", - parent_category: categoryWithProduct, - products: [{ id: testProduct1Id }], - } - ) - - nested2CategoryWithProduct = await simpleProductCategoryFactory( - dbConnection, - { - id: nested2CategoryWithProductId, - name: "nested2 category with Product1", - parent_category: nestedCategoryWithProduct, - products: [{ id: testProductFilteringId1 }], - } - ) - - categoryWithoutProduct = await simpleProductCategoryFactory( - dbConnection, - { - id: categoryWithoutProductId, - name: "category without product", - } - ) - }) - - it("returns a list of products in product category without category children", async () => { - const api = useApi() - const params = `category_id[]=${categoryWithProductId}` - const response = await api.get( - `/admin/products?${params}`, - adminHeaders - ) - - expect(response.status).toEqual(200) - expect(response.data.products).toHaveLength(1) - expect(response.data.products).toEqual([ - expect.objectContaining({ - id: testProductId, - }), - ]) - }) - - it("returns a list of products in product category without category children explicitly set to false", async () => { - const api = useApi() - const params = `category_id[]=${categoryWithProductId}&include_category_children=false` - const response = await api.get( - `/admin/products?${params}`, - adminHeaders - ) - - expect(response.status).toEqual(200) - expect(response.data.products).toHaveLength(1) - expect(response.data.products).toEqual([ - expect.objectContaining({ - id: testProductId, - }), - ]) - }) - - it("returns a list of products in product category with category children", async () => { - const api = useApi() - - const params = `category_id[]=${categoryWithProductId}&include_category_children=true` - const response = await api.get( - `/admin/products?${params}`, - adminHeaders - ) - - expect(response.status).toEqual(200) - expect(response.data.products).toHaveLength(3) - expect(response.data.products).toEqual( - expect.arrayContaining([ - expect.objectContaining({ - id: testProduct1Id, - }), - expect.objectContaining({ - id: testProductId, - }), - expect.objectContaining({ - id: testProductFilteringId1, - }), - ]) - ) - }) - - it("returns no products when product category with category children does not have products", async () => { - const api = useApi() - - const params = `category_id[]=${categoryWithoutProductId}&include_category_children=true` - const response = await api.get( - `/admin/products?${params}`, - adminHeaders - ) - - expect(response.status).toEqual(200) - expect(response.data.products).toHaveLength(0) - }) - }) - it("returns a list of products with tags", async () => { const api = useApi() @@ -1573,7 +1451,6 @@ describe("/admin/products", () => { ], type: null, collection: null, - categories: [], }) ) }) @@ -1604,152 +1481,6 @@ describe("/admin/products", () => { }) ) }) - - describe("Categories", () => { - let categoryWithProduct - let categoryWithoutProduct - const categoryWithProductId = "category-with-product-id" - const categoryWithoutProductId = "category-without-product-id" - - beforeEach(async () => { - const manager = dbConnection.manager - categoryWithProduct = await manager.create(ProductCategory, { - id: categoryWithProductId, - name: "category with Product", - products: [{ id: testProductId }], - }) - await manager.save(categoryWithProduct) - - categoryWithoutProduct = await manager.create(ProductCategory, { - id: categoryWithoutProductId, - name: "category without product", - }) - await manager.save(categoryWithoutProduct) - }) - - it("creates a product with categories associated to it", async () => { - const api = useApi() - - const payload = { - title: "Test", - description: "test-product-description", - categories: [ - { id: categoryWithProductId }, - { id: categoryWithoutProductId }, - ], - } - - const response = await api - .post("/admin/products", payload, adminHeaders) - .catch((e) => e) - - expect(response.status).toEqual(200) - expect(response.data.product).toEqual( - expect.objectContaining({ - categories: [ - expect.objectContaining({ - id: categoryWithProductId, - }), - expect.objectContaining({ - id: categoryWithoutProductId, - }), - ], - }) - ) - }) - - it("throws error when creating a product with invalid category ID", async () => { - const api = useApi() - const categoryNotFoundId = "category-doesnt-exist" - - const payload = { - title: "Test", - description: "test-product-description", - categories: [{ id: categoryNotFoundId }], - } - - const error = await api - .post("/admin/products", payload, adminHeaders) - .catch((e) => e) - - expect(error.response.status).toEqual(404) - expect(error.response.data.type).toEqual("not_found") - expect(error.response.data.message).toEqual( - `Product_category with product_category_id ${categoryNotFoundId} does not exist.` - ) - }) - - it("updates a product's categories", async () => { - const api = useApi() - - const payload = { - categories: [{ id: categoryWithoutProductId }], - } - - const response = await api.post( - `/admin/products/${testProductId}`, - payload, - adminHeaders - ) - - expect(response.status).toEqual(200) - expect(response.data.product).toEqual( - expect.objectContaining({ - id: testProductId, - handle: "test-product", - categories: [ - expect.objectContaining({ - id: categoryWithoutProductId, - }), - ], - }) - ) - }) - - it("remove all categories of a product", async () => { - const api = useApi() - const category = await simpleProductCategoryFactory(dbConnection, { - id: "existing-category", - name: "existing category", - products: [{ id: "test-product" }], - }) - - const payload = { - categories: [], - } - - const response = await api.post( - "/admin/products/test-product", - payload, - adminHeaders - ) - - expect(response.status).toEqual(200) - expect(response.data.product).toEqual( - expect.objectContaining({ - id: "test-product", - categories: [], - }) - ) - }) - - it("throws error if product categories input is incorreect", async () => { - const api = useApi() - const payload = { - categories: [{ incorrect: "test-category-d2B" }], - } - - const error = await api - .post("/admin/products/test-product", payload, adminHeaders) - .catch((e) => e) - - expect(error.response.status).toEqual(400) - expect(error.response.data.type).toEqual("invalid_data") - expect(error.response.data.message).toEqual( - "property incorrect should not exist, id must be a string" - ) - }) - }) }) describe("DELETE /admin/products/:id/options/:option_id", () => { @@ -2032,6 +1763,7 @@ describe("/admin/products", () => { it("successfully updates a variant's prices by replacing a price", async () => { const api = useApi() + const variantId = "test-variant" const data = { prices: [ { @@ -2043,7 +1775,7 @@ describe("/admin/products", () => { const response = await api .post( - "/admin/products/test-product/variants/test-variant", + `/admin/products/test-product/variants/${variantId}`, data, adminHeaders ) @@ -2052,9 +1784,9 @@ describe("/admin/products", () => { }) expect(response.status).toEqual(200) - - expect(response.data.product.variants[0].prices.length).toEqual(1) - expect(response.data.product.variants[0].prices).toEqual( + const variant = response.data.product.variants.find(v => v.id === variantId) + expect(variant.prices.length).toEqual(1) + expect(variant.prices).toEqual( expect.arrayContaining([ expect.objectContaining({ amount: 4500, diff --git a/integration-tests/api/__tests__/admin/products/ff-product-categories.js b/integration-tests/api/__tests__/admin/products/ff-product-categories.js new file mode 100644 index 0000000000..93305985da --- /dev/null +++ b/integration-tests/api/__tests__/admin/products/ff-product-categories.js @@ -0,0 +1,352 @@ +const path = require("path") +const { ProductCategory } = require("@medusajs/medusa") +const { DiscountRuleType, AllocationType } = require("@medusajs/medusa/dist") +const { IdMap } = require("medusa-test-utils") +const { + ProductVariant, + ProductOptionValue, + MoneyAmount, + DiscountConditionType, + DiscountConditionOperator, +} = require("@medusajs/medusa") + +const setupServer = require("../../../../helpers/setup-server") +const { useApi } = require("../../../../helpers/use-api") +const { initDb, useDb } = require("../../../../helpers/use-db") +const adminSeeder = require("../../../helpers/admin-seeder") +const productSeeder = require("../../../helpers/product-seeder") +const priceListSeeder = require("../../../helpers/price-list-seeder") +const { + simpleProductFactory, + simpleDiscountFactory, + simpleProductCategoryFactory, + simpleSalesChannelFactory, + simpleRegionFactory, +} = require("../../../factories") + +const testProductId = "test-product" +const testProduct1Id = "test-product1" +const testProductFilteringId1 = "test-product_filtering_1" +const adminHeaders = { + headers: { + Authorization: "Bearer test_token", + }, +} + +describe("/admin/products [MEDUSA_FF_PRODUCT_CATEGORIES=true]", () => { + let medusaProcess + let dbConnection + let categoryWithProduct + let categoryWithoutProduct + let nestedCategoryWithProduct + let nested2CategoryWithProduct + const nestedCategoryWithProductId = "nested-category-with-product-id" + const nested2CategoryWithProductId = "nested2-category-with-product-id" + const categoryWithProductId = "category-with-product-id" + const categoryWithoutProductId = "category-without-product-id" + + beforeAll(async () => { + const cwd = path.resolve(path.join(__dirname, "..", "..", "..")) + dbConnection = await initDb({ cwd }) + medusaProcess = await setupServer({ + cwd, + env: { MEDUSA_FF_PRODUCT_CATEGORIES: true }, + }) + }) + + afterAll(async () => { + const db = useDb() + await db.shutdown() + + medusaProcess.kill() + }) + + describe("GET /admin/products", () => { + beforeEach(async () => { + await productSeeder(dbConnection) + await adminSeeder(dbConnection) + + await simpleSalesChannelFactory(dbConnection, { + name: "Default channel", + id: "default-channel", + is_default: true, + }) + + const manager = dbConnection.manager + categoryWithProduct = await simpleProductCategoryFactory(dbConnection, { + id: categoryWithProductId, + name: "category with Product", + products: [{ id: testProductId }], + is_active: false, + is_internal: false, + }) + + nestedCategoryWithProduct = await simpleProductCategoryFactory( + dbConnection, + { + id: nestedCategoryWithProductId, + name: "nested category with Product1", + parent_category: categoryWithProduct, + products: [{ id: testProduct1Id }], + is_active: true, + is_internal: true, + } + ) + + nested2CategoryWithProduct = await simpleProductCategoryFactory( + dbConnection, + { + id: nested2CategoryWithProductId, + name: "nested2 category with Product1", + parent_category: nestedCategoryWithProduct, + products: [{ id: testProductFilteringId1 }], + is_active: false, + is_internal: true, + } + ) + + categoryWithoutProduct = await simpleProductCategoryFactory( + dbConnection, + { + id: categoryWithoutProductId, + name: "category without product", + is_active: true, + is_internal: false, + } + ) + }) + + afterEach(async () => { + const db = useDb() + await db.teardown() + }) + + it("returns a list of products in product category without category children", async () => { + const api = useApi() + const params = `category_id[]=${categoryWithProductId}` + const response = await api.get( + `/admin/products?${params}`, + adminHeaders + ) + + expect(response.status).toEqual(200) + expect(response.data.products).toHaveLength(1) + expect(response.data.products).toEqual([ + expect.objectContaining({ + id: testProductId, + }), + ]) + }) + + it("returns a list of products in product category without category children explicitly set to false", async () => { + const api = useApi() + const params = `category_id[]=${categoryWithProductId}&include_category_children=false` + const response = await api.get( + `/admin/products?${params}`, + adminHeaders + ) + + expect(response.status).toEqual(200) + expect(response.data.products).toHaveLength(1) + expect(response.data.products).toEqual([ + expect.objectContaining({ + id: testProductId, + }), + ]) + }) + + it("returns a list of products in product category with category children", async () => { + const api = useApi() + + const params = `category_id[]=${categoryWithProductId}&include_category_children=true` + const response = await api.get( + `/admin/products?${params}`, + adminHeaders + ) + + expect(response.status).toEqual(200) + expect(response.data.products).toHaveLength(3) + expect(response.data.products).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + id: testProduct1Id, + }), + expect.objectContaining({ + id: testProductId, + }), + expect.objectContaining({ + id: testProductFilteringId1, + }), + ]) + ) + }) + + it("returns no products when product category with category children does not have products", async () => { + const api = useApi() + + const params = `category_id[]=${categoryWithoutProductId}&include_category_children=true` + const response = await api.get( + `/admin/products?${params}`, + adminHeaders + ) + + expect(response.status).toEqual(200) + expect(response.data.products).toHaveLength(0) + }) + }) + + describe("POST /admin/products", () => { + beforeEach(async () => { + await productSeeder(dbConnection) + await adminSeeder(dbConnection) + + await simpleSalesChannelFactory(dbConnection, { + name: "Default channel", + id: "default-channel", + is_default: true, + }) + + const manager = dbConnection.manager + categoryWithProduct = await manager.create(ProductCategory, { + id: categoryWithProductId, + name: "category with Product", + products: [{ id: testProductId }], + }) + await manager.save(categoryWithProduct) + + categoryWithoutProduct = await manager.create(ProductCategory, { + id: categoryWithoutProductId, + name: "category without product", + }) + await manager.save(categoryWithoutProduct) + }) + + afterEach(async () => { + const db = useDb() + await db.teardown() + }) + + it("creates a product with categories associated to it", async () => { + const api = useApi() + + const payload = { + title: "Test", + description: "test-product-description", + categories: [ + { id: categoryWithProductId }, + { id: categoryWithoutProductId }, + ], + } + + const response = await api + .post("/admin/products", payload, adminHeaders) + .catch((e) => e) + + expect(response.status).toEqual(200) + expect(response.data.product).toEqual( + expect.objectContaining({ + categories: [ + expect.objectContaining({ + id: categoryWithProductId, + }), + expect.objectContaining({ + id: categoryWithoutProductId, + }), + ], + }) + ) + }) + + it("throws error when creating a product with invalid category ID", async () => { + const api = useApi() + const categoryNotFoundId = "category-doesnt-exist" + + const payload = { + title: "Test", + description: "test-product-description", + categories: [{ id: categoryNotFoundId }], + } + + const error = await api + .post("/admin/products", payload, adminHeaders) + .catch((e) => e) + + expect(error.response.status).toEqual(404) + expect(error.response.data.type).toEqual("not_found") + expect(error.response.data.message).toEqual( + `Product_category with product_category_id ${categoryNotFoundId} does not exist.` + ) + }) + + it("updates a product's categories", async () => { + const api = useApi() + + const payload = { + categories: [{ id: categoryWithoutProductId }], + } + + const response = await api.post( + `/admin/products/${testProductId}`, + payload, + adminHeaders + ) + + expect(response.status).toEqual(200) + expect(response.data.product).toEqual( + expect.objectContaining({ + id: testProductId, + handle: "test-product", + categories: [ + expect.objectContaining({ + id: categoryWithoutProductId, + }), + ], + }) + ) + }) + + it("remove all categories of a product", async () => { + const api = useApi() + const category = await simpleProductCategoryFactory(dbConnection, { + id: "existing-category", + name: "existing category", + products: [{ id: "test-product" }], + }) + + const payload = { + categories: [], + } + + const response = await api.post( + "/admin/products/test-product", + payload, + adminHeaders + ) + + expect(response.status).toEqual(200) + expect(response.data.product).toEqual( + expect.objectContaining({ + id: "test-product", + categories: [], + }) + ) + }) + + it("throws error if product categories input is incorreect", async () => { + const api = useApi() + const payload = { + categories: [{ incorrect: "test-category-d2B" }], + } + + const error = await api + .post("/admin/products/test-product", payload, adminHeaders) + .catch((e) => e) + + expect(error.response.status).toEqual(400) + expect(error.response.data.type).toEqual("invalid_data") + expect(error.response.data.message).toEqual( + "property incorrect should not exist, id must be a string" + ) + }) + }) +}) diff --git a/integration-tests/api/__tests__/store/products.js b/integration-tests/api/__tests__/store/products.js index 06176ec172..acf82e462f 100644 --- a/integration-tests/api/__tests__/store/products.js +++ b/integration-tests/api/__tests__/store/products.js @@ -473,115 +473,6 @@ describe("/store/products", () => { ) } }) - - describe("Product Category filtering", () => { - let categoryWithProduct - let categoryWithoutProduct - let nestedCategoryWithProduct - let nested2CategoryWithProduct - const nestedCategoryWithProductId = "nested-category-with-product-id" - const nested2CategoryWithProductId = "nested2-category-with-product-id" - const categoryWithProductId = "category-with-product-id" - const categoryWithoutProductId = "category-without-product-id" - - beforeEach(async () => { - const manager = dbConnection.manager - categoryWithProduct = await simpleProductCategoryFactory(dbConnection, { - id: categoryWithProductId, - name: "category with Product", - products: [{ id: testProductId }], - }) - - nestedCategoryWithProduct = await simpleProductCategoryFactory( - dbConnection, - { - id: nestedCategoryWithProductId, - name: "nested category with Product1", - parent_category: categoryWithProduct, - products: [{ id: testProductId1 }], - } - ) - - nested2CategoryWithProduct = await simpleProductCategoryFactory( - dbConnection, - { - id: nested2CategoryWithProductId, - name: "nested2 category with Product1", - parent_category: nestedCategoryWithProduct, - products: [{ id: testProductFilteringId1 }], - } - ) - - categoryWithoutProduct = await simpleProductCategoryFactory( - dbConnection, - { - id: categoryWithoutProductId, - name: "category without product", - } - ) - }) - - it("returns a list of products in product category without category children", async () => { - const api = useApi() - const params = `category_id[]=${categoryWithProductId}` - const response = await api.get(`/store/products?${params}`) - - expect(response.status).toEqual(200) - expect(response.data.products).toHaveLength(1) - expect(response.data.products).toEqual([ - expect.objectContaining({ - id: testProductId, - }), - ]) - }) - - it("returns a list of products in product category without category children explicitly set to false", async () => { - const api = useApi() - const params = `category_id[]=${categoryWithProductId}&include_category_children=false` - const response = await api.get(`/store/products?${params}`) - - expect(response.status).toEqual(200) - expect(response.data.products).toHaveLength(1) - expect(response.data.products).toEqual([ - expect.objectContaining({ - id: testProductId, - }), - ]) - }) - - it("returns a list of products in product category with category children", async () => { - const api = useApi() - - const params = `category_id[]=${categoryWithProductId}&include_category_children=true` - const response = await api.get(`/store/products?${params}`) - - expect(response.status).toEqual(200) - expect(response.data.products).toHaveLength(3) - expect(response.data.products).toEqual( - expect.arrayContaining([ - expect.objectContaining({ - id: testProductId1, - }), - expect.objectContaining({ - id: testProductId, - }), - expect.objectContaining({ - id: testProductFilteringId1, - }), - ]) - ) - }) - - it("returns no products when product category with category children does not have products", async () => { - const api = useApi() - - const params = `category_id[]=${categoryWithoutProductId}&include_category_children=true` - const response = await api.get(`/store/products?${params}`) - - expect(response.status).toEqual(200) - expect(response.data.products).toHaveLength(0) - }) - }) }) describe("list params", () => { diff --git a/integration-tests/api/__tests__/store/products/ff-product-categories.ts b/integration-tests/api/__tests__/store/products/ff-product-categories.ts new file mode 100644 index 0000000000..8ec31a6a2c --- /dev/null +++ b/integration-tests/api/__tests__/store/products/ff-product-categories.ts @@ -0,0 +1,230 @@ +const path = require("path") +const setupServer = require("../../../../helpers/setup-server") +const { useApi } = require("../../../../helpers/use-api") +const { initDb, useDb } = require("../../../../helpers/use-db") + +const { + simpleProductCategoryFactory, +} = require("../../../factories") + +const productSeeder = require("../../../helpers/store-product-seeder") +const adminSeeder = require("../../../helpers/admin-seeder") + +jest.setTimeout(30000) + +describe("/store/products", () => { + let medusaProcess + let dbConnection + + const testProductId = "test-product" + const testProductId1 = "test-product1" + const testProductFilteringId1 = "test-product_filtering_1" + const testProductFilteringId2 = "test-product_filtering_2" + + beforeAll(async () => { + const cwd = path.resolve(path.join(__dirname, "..", "..", "..")) + dbConnection = await initDb({ cwd }) + medusaProcess = await setupServer({ + cwd, + env: { MEDUSA_FF_PRODUCT_CATEGORIES: true } + }) + }) + + afterAll(async () => { + const db = useDb() + await db.shutdown() + medusaProcess.kill() + }) + + describe("GET /store/products [MEDUSA_FF_PRODUCT_CATEGORIES=true]", () => { + let categoryWithProduct + let categoryWithoutProduct + let inactiveCategoryWithProduct + let internalCategoryWithProduct + let nestedCategoryWithProduct + let nested2CategoryWithProduct + const nestedCategoryWithProductId = "nested-category-with-product-id" + const nested2CategoryWithProductId = "nested2-category-with-product-id" + const categoryWithProductId = "category-with-product-id" + const categoryWithoutProductId = "category-without-product-id" + const inactiveCategoryWithProductId = "inactive-category-with-product-id" + const internalCategoryWithProductId = "inactive-category-with-product-id" + + beforeEach(async () => { + const manager = dbConnection.manager + + await productSeeder(dbConnection) + await adminSeeder(dbConnection) + + categoryWithProduct = await simpleProductCategoryFactory(dbConnection, { + id: categoryWithProductId, + name: "category with Product", + products: [{ id: testProductId }], + is_active: true, + is_internal: false, + }) + + nestedCategoryWithProduct = await simpleProductCategoryFactory( + dbConnection, + { + id: nestedCategoryWithProductId, + name: "nested category with Product1", + parent_category: categoryWithProduct, + products: [{ id: testProductId1 }], + is_active: true, + is_internal: false, + } + ) + + inactiveCategoryWithProduct = await simpleProductCategoryFactory(dbConnection, { + id: inactiveCategoryWithProductId, + name: "inactive category with Product", + products: [{ id: testProductFilteringId2 }], + parent_category: nestedCategoryWithProduct, + is_active: false, + is_internal: false, + rank: 0, + }) + + internalCategoryWithProduct = await simpleProductCategoryFactory(dbConnection, { + id: inactiveCategoryWithProductId, + name: "inactive category with Product", + products: [{ id: testProductFilteringId2 }], + parent_category: nestedCategoryWithProduct, + is_active: true, + is_internal: true, + rank: 1, + }) + + nested2CategoryWithProduct = await simpleProductCategoryFactory( + dbConnection, + { + id: nested2CategoryWithProductId, + name: "nested2 category with Product1", + parent_category: nestedCategoryWithProduct, + products: [{ id: testProductFilteringId1 }], + is_active: true, + is_internal: false, + rank: 2, + } + ) + + categoryWithoutProduct = await simpleProductCategoryFactory( + dbConnection, + { + id: categoryWithoutProductId, + name: "category without product", + is_active: true, + is_internal: false, + } + ) + }) + + afterEach(async () => { + const db = useDb() + await db.teardown() + }) + + it("returns a list of products in product category without category children", async () => { + const api = useApi() + const params = `category_id[]=${categoryWithProductId}` + const response = await api.get(`/store/products?${params}`) + + expect(response.status).toEqual(200) + expect(response.data.products).toHaveLength(1) + expect(response.data.products).toEqual([ + expect.objectContaining({ + id: testProductId, + }), + ]) + }) + + it("returns a list of products in product category without category children explicitly set to false", async () => { + const api = useApi() + const params = `category_id[]=${categoryWithProductId}&include_category_children=false` + const response = await api.get(`/store/products?${params}`) + + expect(response.status).toEqual(200) + expect(response.data.products).toHaveLength(1) + expect(response.data.products).toEqual([ + expect.objectContaining({ + id: testProductId, + }), + ]) + }) + + it("returns a list of products in product category with category children", async () => { + const api = useApi() + + const params = `category_id[]=${categoryWithProductId}&include_category_children=true` + const response = await api.get(`/store/products?${params}`) + + expect(response.status).toEqual(200) + expect(response.data.products).toHaveLength(3) + expect(response.data.products).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + id: testProductId1, + }), + expect.objectContaining({ + id: testProductId, + }), + expect.objectContaining({ + id: testProductFilteringId1, + }), + ]) + ) + }) + + it("returns no products when product category with category children does not have products", async () => { + const api = useApi() + + const params = `category_id[]=${categoryWithoutProductId}&include_category_children=true` + const response = await api.get(`/store/products?${params}`) + + expect(response.status).toEqual(200) + expect(response.data.products).toHaveLength(0) + }) + + it("returns only active and public products with include_category_children", async () => { + const api = useApi() + + const params = `category_id[]=${nestedCategoryWithProductId}&include_category_children=true` + const response = await api.get(`/store/products?${params}`) + + expect(response.status).toEqual(200) + expect(response.data.products).toHaveLength(2) + + expect(response.data.products).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + id: testProductFilteringId1, + }), + expect.objectContaining({ + id: testProductId1, + }), + ]) + ) + }) + + it("does not query products with category that are inactive", async () => { + const api = useApi() + + const params = `category_id[]=${inactiveCategoryWithProductId}` + const response = await api.get(`/store/products?${params}`) + + expect(response.status).toEqual(200) + expect(response.data.products).toHaveLength(0) + }) + + it("does not query products with category that are internal", async () => { + const api = useApi() + + const params = `category_id[]=${internalCategoryWithProductId}` + const response = await api.get(`/store/products?${params}`) + + expect(response.status).toEqual(200) + expect(response.data.products).toHaveLength(0) + }) + }) +}) diff --git a/packages/admin-ui/ui/src/domain/product-categories/components/product-categories-list.tsx b/packages/admin-ui/ui/src/domain/product-categories/components/product-categories-list.tsx index 18faabc6d5..84c4367f7e 100644 --- a/packages/admin-ui/ui/src/domain/product-categories/components/product-categories-list.tsx +++ b/packages/admin-ui/ui/src/domain/product-categories/components/product-categories-list.tsx @@ -78,6 +78,10 @@ function ProductCategoriesList(props: ProductCategoriesListProps) { items={categories} onChange={onItemDrop} childrenProp="category_children" + // Adding an unreasonably high number here to prevent us from + // setting a hard limit on category depth. This should be decided upon + // by consumers of medusa after considering the pros and cons to the approach + maxDepth={99} renderItem={({ item, depth, handler, collapseIcon }) => ( { "tags", "type", "collection", - "categories", "sales_channels", ], } diff --git a/packages/medusa/src/api/routes/admin/products/index.ts b/packages/medusa/src/api/routes/admin/products/index.ts index 28279807d1..072ea12016 100644 --- a/packages/medusa/src/api/routes/admin/products/index.ts +++ b/packages/medusa/src/api/routes/admin/products/index.ts @@ -17,6 +17,10 @@ export default (app, featureFlagRouter: FlagRouter) => { defaultAdminProductRelations.push("sales_channels") } + if (featureFlagRouter.isFeatureEnabled("product_categories")) { + defaultAdminProductRelations.push("categories") + } + route.post( "/", validateSalesChannelsExist((req) => req.body?.sales_channels), @@ -100,7 +104,6 @@ export const defaultAdminProductRelations = [ "tags", "type", "collection", - "categories", ] export const defaultAdminProductFields: (keyof Product)[] = [ diff --git a/packages/medusa/src/api/routes/store/index.js b/packages/medusa/src/api/routes/store/index.js index b0a990b26b..19f60502ec 100644 --- a/packages/medusa/src/api/routes/store/index.js +++ b/packages/medusa/src/api/routes/store/index.js @@ -26,7 +26,9 @@ const route = Router() export default (app, container, config) => { app.use("/store", route) + const featureFlagRouter = container.resolve("featureFlagRouter") const storeCors = config.store_cors || "" + route.use( cors({ origin: parseCorsOrigins(storeCors), @@ -39,7 +41,7 @@ export default (app, container, config) => { authRoutes(route) collectionRoutes(route) customerRoutes(route, container) - productRoutes(route) + productRoutes(route, featureFlagRouter) productTagsRoutes(route) productTypesRoutes(route) orderRoutes(route) diff --git a/packages/medusa/src/api/routes/store/product-categories/__tests__/get-product-category.ts b/packages/medusa/src/api/routes/store/product-categories/__tests__/get-product-category.ts index 6f67a5959f..6994f90d02 100644 --- a/packages/medusa/src/api/routes/store/product-categories/__tests__/get-product-category.ts +++ b/packages/medusa/src/api/routes/store/product-categories/__tests__/get-product-category.ts @@ -2,7 +2,7 @@ import { IdMap } from "medusa-test-utils" import { request } from "../../../../../helpers/test-request" import { defaultStoreProductCategoryRelations, - defaultStoreScope, + defaultStoreCategoryScope, defaultStoreProductCategoryFields } from ".." import { @@ -31,7 +31,7 @@ describe("GET /store/product-categories/:id", () => { relations: defaultStoreProductCategoryRelations, select: defaultStoreProductCategoryFields, }, - defaultStoreScope + defaultStoreCategoryScope ) }) @@ -59,7 +59,7 @@ describe("GET /store/product-categories/:id", () => { relations: defaultStoreProductCategoryRelations, select: defaultStoreProductCategoryFields, }, - defaultStoreScope + defaultStoreCategoryScope ) }) diff --git a/packages/medusa/src/api/routes/store/product-categories/get-product-category.ts b/packages/medusa/src/api/routes/store/product-categories/get-product-category.ts index ff978bda7f..69b706664b 100644 --- a/packages/medusa/src/api/routes/store/product-categories/get-product-category.ts +++ b/packages/medusa/src/api/routes/store/product-categories/get-product-category.ts @@ -3,7 +3,7 @@ import { Request, Response } from "express" import ProductCategoryService from "../../../../services/product-category" import { FindParams } from "../../../../types/common" import { transformTreeNodesWithConfig } from "../../../../utils/transformers/tree" -import { defaultStoreScope } from "." +import { defaultStoreCategoryScope } from "." /** * @oas [get] /store/product-categories/{id} @@ -70,7 +70,7 @@ export default async (req: Request, res: Response) => { const productCategory = await productCategoryService.retrieve( id, retrieveConfig, - defaultStoreScope + defaultStoreCategoryScope ) res.status(200).json({ @@ -80,7 +80,7 @@ export default async (req: Request, res: Response) => { product_category: transformTreeNodesWithConfig( productCategory, retrieveConfig, - defaultStoreScope + defaultStoreCategoryScope ), }) } diff --git a/packages/medusa/src/api/routes/store/product-categories/index.ts b/packages/medusa/src/api/routes/store/product-categories/index.ts index d91a1a4d16..0551c464a0 100644 --- a/packages/medusa/src/api/routes/store/product-categories/index.ts +++ b/packages/medusa/src/api/routes/store/product-categories/index.ts @@ -46,7 +46,7 @@ export const defaultStoreProductCategoryRelations = [ "category_children", ] -export const defaultStoreScope = { +export const defaultStoreCategoryScope = { is_internal: false, is_active: true, } diff --git a/packages/medusa/src/api/routes/store/product-categories/list-product-categories.ts b/packages/medusa/src/api/routes/store/product-categories/list-product-categories.ts index 490a8eed53..7459647d2e 100644 --- a/packages/medusa/src/api/routes/store/product-categories/list-product-categories.ts +++ b/packages/medusa/src/api/routes/store/product-categories/list-product-categories.ts @@ -5,7 +5,7 @@ import { Transform } from "class-transformer" import { ProductCategoryService } from "../../../../services" import { extendedFindParamsMixin } from "../../../../types/common" import { optionalBooleanMapper } from "../../../../utils/validators/is-boolean" -import { defaultStoreScope } from "." +import { defaultStoreCategoryScope } from "." /** * @oas [get] /store/product-categories @@ -68,14 +68,14 @@ export default async (req: Request, res: Response) => { ) const selectors = Object.assign( - { ...defaultStoreScope }, + { ...defaultStoreCategoryScope }, req.filterableFields ) const [data, count] = await productCategoryService.listAndCount( selectors, req.listConfig, - defaultStoreScope + defaultStoreCategoryScope ) const { limit, offset } = req.validatedQuery diff --git a/packages/medusa/src/api/routes/store/products/__tests__/list-products.js b/packages/medusa/src/api/routes/store/products/__tests__/list-products.js index 88f050719f..fea1591151 100644 --- a/packages/medusa/src/api/routes/store/products/__tests__/list-products.js +++ b/packages/medusa/src/api/routes/store/products/__tests__/list-products.js @@ -18,7 +18,13 @@ describe("GET /store/products", () => { it("calls get product from productSerice", () => { expect(ProductServiceMock.listAndCount).toHaveBeenCalledTimes(1) expect(ProductServiceMock.listAndCount).toHaveBeenCalledWith( - { status: ["published"] }, + { + status: ["published"], + categories: { + is_active: true, + is_internal: false, + } + }, { relations: defaultStoreProductsRelations, select: defaultStoreProductsFields, @@ -49,7 +55,14 @@ describe("GET /store/products", () => { it("calls list from productSerice", () => { expect(ProductServiceMock.listAndCount).toHaveBeenCalledTimes(1) expect(ProductServiceMock.listAndCount).toHaveBeenCalledWith( - { is_giftcard: true, status: ["published"] }, + { + is_giftcard: true, + status: ["published"], + categories: { + is_active: true, + is_internal: false, + } + }, { relations: defaultStoreProductsRelations, select: defaultStoreProductsFields, diff --git a/packages/medusa/src/api/routes/store/products/index.ts b/packages/medusa/src/api/routes/store/products/index.ts index afcdf91530..0f7974f058 100644 --- a/packages/medusa/src/api/routes/store/products/index.ts +++ b/packages/medusa/src/api/routes/store/products/index.ts @@ -9,10 +9,15 @@ import { validateProductSalesChannelAssociation } from "../../../middlewares/pub import { validateSalesChannelParam } from "../../../middlewares/publishable-api-key/validate-sales-channel-param" import { StoreGetProductsParams } from "./list-products" import { StoreGetProductsProductParams } from "./get-product" +import { FlagRouter } from "../../../../utils/flag-router" const route = Router() -export default (app) => { +export default (app, featureFlagRouter: FlagRouter) => { + if (featureFlagRouter.isFeatureEnabled("product_categories")) { + allowedStoreProductsRelations.push("categories") + } + app.use("/products", extendRequestParams, validateSalesChannelParam, route) route.use("/:id", validateProductSalesChannelAssociation) diff --git a/packages/medusa/src/api/routes/store/products/list-products.ts b/packages/medusa/src/api/routes/store/products/list-products.ts index 0bf67d0603..2303d612a1 100644 --- a/packages/medusa/src/api/routes/store/products/list-products.ts +++ b/packages/medusa/src/api/routes/store/products/list-products.ts @@ -21,6 +21,7 @@ import { optionalBooleanMapper } from "../../../../utils/validators/is-boolean" import { IsType } from "../../../../utils/validators/is-type" import { cleanResponseData } from "../../../../utils/clean-response-data" import { Cart, Product } from "../../../../models" +import { defaultStoreCategoryScope } from "../product-categories" /** * @oas [get] /store/products @@ -199,6 +200,12 @@ export default async (req, res) => { // get only published products for store endpoint filterableFields["status"] = ["published"] + // store APIs only receive active and public categories to query from + filterableFields["categories"] = { + ...(filterableFields.categories || {}), + // Store APIs are only allowed to query active and public categories + ...defaultStoreCategoryScope + } if (req.publishableApiKeyScopes?.sales_channel_ids.length) { filterableFields.sales_channel_id = diff --git a/packages/medusa/src/repositories/product-category.ts b/packages/medusa/src/repositories/product-category.ts index 8cd988e55f..a866ce4282 100644 --- a/packages/medusa/src/repositories/product-category.ts +++ b/packages/medusa/src/repositories/product-category.ts @@ -16,7 +16,8 @@ export const ProductCategoryRepository = dataSource .getTreeRepository(ProductCategory) .extend({ async findOneWithDescendants( - query: FindOneOptions + query: FindOneOptions, + treeScope: QuerySelector = {} ): Promise { const productCategory = await this.findOne(query) @@ -26,7 +27,8 @@ export const ProductCategoryRepository = dataSource return sortChildren( // Returns the productCategory with all of its descendants until the last child node - await this.findDescendantsTree(productCategory) + await this.findDescendantsTree(productCategory), + treeScope ) }, diff --git a/packages/medusa/src/repositories/product.ts b/packages/medusa/src/repositories/product.ts index f5b1613c14..508ab5923b 100644 --- a/packages/medusa/src/repositories/product.ts +++ b/packages/medusa/src/repositories/product.ts @@ -9,7 +9,11 @@ import { Product, ProductCategory, ProductVariant } from "../models" import { ExtendedFindConfig } from "../types/common" import { dataSource } from "../loaders/database" import { ProductFilterOptions } from "../types/product" -import { buildLegacyFieldsListFrom, isObject } from "../utils" +import { + buildLegacyFieldsListFrom, + isObject, + fetchCategoryDescendantsIds, +} from "../utils" export const ProductRepository = dataSource.getRepository(Product).extend({ async bulkAddToCollection( @@ -106,6 +110,8 @@ export const ProductRepository = dataSource.getRepository(Product).extend({ > const categoryId = options_.where.category_id as FindOperator const discountConditionId = options_.where.discount_condition_id + const categoriesQuery = (options_.where.categories || + {}) as FindOptionsWhere const includeCategoryChildren = options_.where.include_category_children ?? false @@ -115,6 +121,7 @@ export const ProductRepository = dataSource.getRepository(Product).extend({ delete options_.where.category_id delete options_.where.discount_condition_id delete options_.where.include_category_children + delete options_.where.categories // TODO: move back to the service layer if (q) { @@ -198,7 +205,7 @@ export const ProductRepository = dataSource.getRepository(Product).extend({ } if (salesChannelId) { - const joinMethod = options_.relations.sales_channel_id + const joinMethod = options_.relations.sales_channels ? queryBuilder.innerJoinAndSelect.bind(queryBuilder) : queryBuilder.innerJoin.bind(queryBuilder) @@ -215,7 +222,7 @@ export const ProductRepository = dataSource.getRepository(Product).extend({ } if (categoryId) { - const joinMethod = options_.relations.category_id + const joinMethod = options_.relations.categories ? queryBuilder.innerJoinAndSelect.bind(queryBuilder) : queryBuilder.innerJoin.bind(queryBuilder) @@ -224,40 +231,48 @@ export const ProductRepository = dataSource.getRepository(Product).extend({ if (includeCategoryChildren) { const categoryRepository = this.manager.getTreeRepository(ProductCategory) + const categories = await categoryRepository.find({ - where: { id: In(categoryIds) }, + where: { + id: In(categoryIds), + ...categoriesQuery, + }, }) - categoryIds = [] for (const category of categories) { const categoryChildren = await categoryRepository.findDescendantsTree( category ) - const getAllIdsRecursively = (productCategory: ProductCategory) => { - let result = [productCategory.id] - - ;(productCategory.category_children || []).forEach((child) => { - result = result.concat(getAllIdsRecursively(child)) - }) - - return result - } - categoryIds = categoryIds.concat( - getAllIdsRecursively(categoryChildren) + fetchCategoryDescendantsIds(categoryChildren, categoriesQuery) ) } } - joinMethod( - `${productAlias}.categories`, - "categories", - "categories.id IN (:...categoryIds)", - { - categoryIds, + if (categoryIds.length) { + const categoryAlias = "categories" + const joinScope = { + ...categoriesQuery, + id: categoryIds, } - ) + const joinWhere = Object.entries(joinScope) + .map((entry) => { + if (Array.isArray(entry[1])) { + return `${categoryAlias}.${entry[0]} IN (:...${entry[0]})` + } else { + return `${categoryAlias}.${entry[0]} = :${entry[0]}` + } + }) + .join(" AND ") + + joinMethod( + `${productAlias}.${categoryAlias}`, + categoryAlias, + joinWhere, + joinScope + ) + } } if (discountConditionId) { diff --git a/packages/medusa/src/services/__tests__/product-category.ts b/packages/medusa/src/services/__tests__/product-category.ts index 64e8a006dc..7b99c28fe3 100644 --- a/packages/medusa/src/services/__tests__/product-category.ts +++ b/packages/medusa/src/services/__tests__/product-category.ts @@ -32,7 +32,7 @@ describe("ProductCategoryService", () => { expect(productCategoryRepository.findOneWithDescendants).toHaveBeenCalledTimes(1) expect(productCategoryRepository.findOneWithDescendants).toHaveBeenCalledWith({ where: { id: validID }, - }) + }, {}) }) it("fails on not-found product category id", async () => { diff --git a/packages/medusa/src/services/product-category.ts b/packages/medusa/src/services/product-category.ts index 090af6b1e3..297fcbb3f3 100644 --- a/packages/medusa/src/services/product-category.ts +++ b/packages/medusa/src/services/product-category.ts @@ -101,7 +101,8 @@ class ProductCategoryService extends TransactionBaseService { async retrieve( productCategoryId: string, config: FindConfig = {}, - selector: Selector = {} + selector: Selector = {}, + treeSelector: QuerySelector = {} ): Promise { if (!isDefined(productCategoryId)) { throw new MedusaError( @@ -116,7 +117,10 @@ class ProductCategoryService extends TransactionBaseService { this.productCategoryRepo_ ) - const productCategory = await productCategoryRepo.findOneWithDescendants(query) + const productCategory = await productCategoryRepo.findOneWithDescendants( + query, + treeSelector + ) if (!productCategory) { throw new MedusaError( @@ -306,8 +310,7 @@ class ProductCategoryService extends TransactionBaseService { const targetRank = input.rank const shouldChangeParent = targetParentId !== undefined && targetParentId !== originalParentId - const shouldChangeRank = - shouldChangeParent || originalRank !== targetRank + const shouldChangeRank = shouldChangeParent || originalRank !== targetRank return { targetCategoryId: productCategory.id, @@ -436,9 +439,7 @@ class ProductCategoryService extends TransactionBaseService { continue } - sibling.rank = shouldIncrementRank - ? sibling.rank + 1 - : sibling.rank - 1 + sibling.rank = shouldIncrementRank ? ++sibling.rank : --sibling.rank await repository.save(sibling) } diff --git a/packages/medusa/src/utils/index.ts b/packages/medusa/src/utils/index.ts index 3786e860e8..555b6acbd2 100644 --- a/packages/medusa/src/utils/index.ts +++ b/packages/medusa/src/utils/index.ts @@ -10,3 +10,4 @@ export * from "./calculate-price-tax-amount" export * from "./csv-cell-content-formatter" export * from "./exception-formatter" export * from "./db-aware-column" +export * from "./product-category" diff --git a/packages/medusa/src/utils/product-category/index.ts b/packages/medusa/src/utils/product-category/index.ts new file mode 100644 index 0000000000..01ec732be2 --- /dev/null +++ b/packages/medusa/src/utils/product-category/index.ts @@ -0,0 +1,25 @@ +import { FindOptionsWhere } from "typeorm" +import { ProductCategory } from "../../models" +import { isDefined } from "medusa-core-utils" + +export const categoryMatchesScope = ( + category: ProductCategory, + query: FindOptionsWhere +): boolean => { + return Object.keys(query ?? {}).every(key => category[key] === query[key]) +} + +export const fetchCategoryDescendantsIds = ( + productCategory: ProductCategory, + query: FindOptionsWhere +) => { + let result = [productCategory.id] + + ;(productCategory.category_children || []).forEach((child) => { + if (categoryMatchesScope(child, query)) { + result = result.concat(fetchCategoryDescendantsIds(child, query)) + } + }) + + return result +}