diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 2b929e07..039b919f 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -1,6 +1,7 @@ export * from "./auth" export * from "./availability" export * from "./createBatchStore" +export * from "./line_items" export * from "./orders" export * from "./prices" export * from "./sdk" diff --git a/packages/core/src/line_items/deleteLineItem.spec.ts b/packages/core/src/line_items/deleteLineItem.spec.ts new file mode 100644 index 00000000..aa3b19af --- /dev/null +++ b/packages/core/src/line_items/deleteLineItem.spec.ts @@ -0,0 +1,83 @@ +import { beforeEach, describe, expect, test, vi } from "vitest" +import type { InterceptorManager } from "#sdk" +import { deleteLineItem } from "./deleteLineItem.js" + +const { + mockDelete, + mockAddRequestInterceptor, + mockAddResponseInterceptor, + mockAddRawResponseReader, +} = vi.hoisted(() => { + const mockDelete = vi.fn() + const mockAddRequestInterceptor = vi.fn().mockReturnValue(1) + const mockAddResponseInterceptor = vi.fn().mockReturnValue(1) + const mockAddRawResponseReader = vi.fn() + return { + mockDelete, + mockAddRequestInterceptor, + mockAddResponseInterceptor, + mockAddRawResponseReader, + } +}) + +vi.mock("@commercelayer/sdk/bundle", () => ({ + CommerceLayer: vi.fn().mockReturnValue({ + addRequestInterceptor: mockAddRequestInterceptor, + addResponseInterceptor: mockAddResponseInterceptor, + addRawResponseReader: mockAddRawResponseReader, + line_items: { delete: mockDelete }, + }), +})) + +vi.mock("@commercelayer/js-auth", () => ({ + jwtDecode: vi.fn().mockReturnValue({ payload: { organization: { slug: "my-org" } } }), +})) + +describe("deleteLineItem", () => { + beforeEach(() => { + vi.clearAllMocks() + mockAddRequestInterceptor.mockReturnValue(1) + mockAddResponseInterceptor.mockReturnValue(1) + mockDelete.mockResolvedValue(undefined) + }) + + test("resolves without a return value", async () => { + const result = await deleteLineItem({ accessToken: "fake-token", lineItemId: "li_1" }) + + expect(result).toBeUndefined() + }) + + test("calls line_items.delete with the correct id", async () => { + await deleteLineItem({ accessToken: "fake-token", lineItemId: "li_1" }) + + expect(mockDelete).toHaveBeenCalledWith("li_1") + }) + + test("forwards request interceptors to getSdk", async () => { + const onSuccess = vi.fn() + const interceptors: InterceptorManager = { request: { onSuccess } } + + await deleteLineItem({ accessToken: "fake-token", lineItemId: "li_1", interceptors }) + + expect(mockAddRequestInterceptor).toHaveBeenCalledWith(onSuccess, undefined) + expect(mockAddResponseInterceptor).not.toHaveBeenCalled() + }) + + test("forwards response interceptors to getSdk", async () => { + const onSuccess = vi.fn() + const interceptors: InterceptorManager = { response: { onSuccess } } + + await deleteLineItem({ accessToken: "fake-token", lineItemId: "li_1", interceptors }) + + expect(mockAddResponseInterceptor).toHaveBeenCalledWith(onSuccess, undefined) + expect(mockAddRequestInterceptor).not.toHaveBeenCalled() + }) + + test("does not register any interceptors when none are provided", async () => { + await deleteLineItem({ accessToken: "fake-token", lineItemId: "li_1" }) + + expect(mockAddRequestInterceptor).not.toHaveBeenCalled() + expect(mockAddResponseInterceptor).not.toHaveBeenCalled() + expect(mockAddRawResponseReader).not.toHaveBeenCalled() + }) +}) diff --git a/packages/core/src/line_items/deleteLineItem.ts b/packages/core/src/line_items/deleteLineItem.ts new file mode 100644 index 00000000..87ba8a43 --- /dev/null +++ b/packages/core/src/line_items/deleteLineItem.ts @@ -0,0 +1,22 @@ +import { getSdk } from "#sdk" +import type { RequestConfig } from "#types" + +interface DeleteLineItemParams extends Pick { + lineItemId: string +} + +/** + * Delete a line item by ID. + * + * @param {string} accessToken - The access token to use for authentication. + * @param {string} lineItemId - The ID of the line item to delete. + * @returns {Promise} + */ +export async function deleteLineItem({ + accessToken, + interceptors, + lineItemId, +}: DeleteLineItemParams): Promise { + const sdk = getSdk({ accessToken, interceptors }) + await sdk.line_items.delete(lineItemId) +} diff --git a/packages/core/src/line_items/getLineItems.spec.ts b/packages/core/src/line_items/getLineItems.spec.ts new file mode 100644 index 00000000..9362df81 --- /dev/null +++ b/packages/core/src/line_items/getLineItems.spec.ts @@ -0,0 +1,113 @@ +import { beforeEach, describe, expect, test, vi } from "vitest" +import type { InterceptorManager } from "#sdk" +import { getLineItems } from "./getLineItems.js" + +const { + mockRetrieve, + mockAddRequestInterceptor, + mockAddResponseInterceptor, + mockAddRawResponseReader, +} = vi.hoisted(() => { + const mockRetrieve = vi.fn() + const mockAddRequestInterceptor = vi.fn().mockReturnValue(1) + const mockAddResponseInterceptor = vi.fn().mockReturnValue(1) + const mockAddRawResponseReader = vi.fn() + return { + mockRetrieve, + mockAddRequestInterceptor, + mockAddResponseInterceptor, + mockAddRawResponseReader, + } +}) + +vi.mock("@commercelayer/sdk/bundle", () => ({ + CommerceLayer: vi.fn().mockReturnValue({ + addRequestInterceptor: mockAddRequestInterceptor, + addResponseInterceptor: mockAddResponseInterceptor, + addRawResponseReader: mockAddRawResponseReader, + orders: { retrieve: mockRetrieve }, + }), +})) + +vi.mock("@commercelayer/js-auth", () => ({ + jwtDecode: vi.fn().mockReturnValue({ payload: { organization: { slug: "my-org" } } }), +})) + +const MOCK_LINE_ITEMS = [ + { id: "li_1", item_type: "skus", quantity: 2 }, + { id: "li_2", item_type: "gift_cards", quantity: 1 }, +] + +describe("getLineItems", () => { + beforeEach(() => { + vi.clearAllMocks() + mockAddRequestInterceptor.mockReturnValue(1) + mockAddResponseInterceptor.mockReturnValue(1) + }) + + test("returns line_items from the retrieved order", async () => { + mockRetrieve.mockResolvedValue({ id: "order-1", line_items: MOCK_LINE_ITEMS }) + + const result = await getLineItems({ accessToken: "fake-token", orderId: "order-1" }) + + expect(result).toEqual(MOCK_LINE_ITEMS) + }) + + test("returns an empty array when order has no line_items", async () => { + mockRetrieve.mockResolvedValue({ id: "order-1" }) + + const result = await getLineItems({ accessToken: "fake-token", orderId: "order-1" }) + + expect(result).toEqual([]) + }) + + test("calls orders.retrieve with the correct orderId and includes", async () => { + mockRetrieve.mockResolvedValue({ id: "order-1", line_items: [] }) + + await getLineItems({ accessToken: "fake-token", orderId: "order-1" }) + + expect(mockRetrieve).toHaveBeenCalledWith( + "order-1", + expect.objectContaining({ + include: expect.arrayContaining([ + "line_items", + "line_items.line_item_options.sku_option", + "line_items.item", + ]), + fields: { orders: ["line_items"] }, + }) + ) + }) + + test("forwards request interceptors to getSdk", async () => { + mockRetrieve.mockResolvedValue({ id: "order-1", line_items: [] }) + const onSuccess = vi.fn() + const interceptors: InterceptorManager = { request: { onSuccess } } + + await getLineItems({ accessToken: "fake-token", orderId: "order-1", interceptors }) + + expect(mockAddRequestInterceptor).toHaveBeenCalledWith(onSuccess, undefined) + expect(mockAddResponseInterceptor).not.toHaveBeenCalled() + }) + + test("forwards response interceptors to getSdk", async () => { + mockRetrieve.mockResolvedValue({ id: "order-1", line_items: [] }) + const onSuccess = vi.fn() + const interceptors: InterceptorManager = { response: { onSuccess } } + + await getLineItems({ accessToken: "fake-token", orderId: "order-1", interceptors }) + + expect(mockAddResponseInterceptor).toHaveBeenCalledWith(onSuccess, undefined) + expect(mockAddRequestInterceptor).not.toHaveBeenCalled() + }) + + test("does not register any interceptors when none are provided", async () => { + mockRetrieve.mockResolvedValue({ id: "order-1", line_items: [] }) + + await getLineItems({ accessToken: "fake-token", orderId: "order-1" }) + + expect(mockAddRequestInterceptor).not.toHaveBeenCalled() + expect(mockAddResponseInterceptor).not.toHaveBeenCalled() + expect(mockAddRawResponseReader).not.toHaveBeenCalled() + }) +}) diff --git a/packages/core/src/line_items/getLineItems.ts b/packages/core/src/line_items/getLineItems.ts new file mode 100644 index 00000000..abd03f98 --- /dev/null +++ b/packages/core/src/line_items/getLineItems.ts @@ -0,0 +1,30 @@ +import type { LineItem } from "@commercelayer/sdk" +import { getSdk } from "#sdk" +import type { RequestConfig } from "#types" + +interface GetLineItemsParams extends Pick { + orderId: string +} + +/** + * Retrieve all line items for a given order. + * + * Fetches the order with the necessary includes to fully populate + * line items, their options and the associated item resource. + * + * @param {string} accessToken - The access token to use for authentication. + * @param {string} orderId - The ID of the order whose line items to retrieve. + * @returns {Promise} - The list of line item resources. + */ +export async function getLineItems({ + accessToken, + interceptors, + orderId, +}: GetLineItemsParams): Promise { + const sdk = getSdk({ accessToken, interceptors }) + const order = await sdk.orders.retrieve(orderId, { + include: ["line_items", "line_items.line_item_options.sku_option", "line_items.item"], + fields: { orders: ["line_items"] }, + }) + return order.line_items ?? [] +} diff --git a/packages/core/src/line_items/index.ts b/packages/core/src/line_items/index.ts new file mode 100644 index 00000000..2e17867d --- /dev/null +++ b/packages/core/src/line_items/index.ts @@ -0,0 +1,3 @@ +export { getLineItems } from "./getLineItems" +export { updateLineItem } from "./updateLineItem" +export { deleteLineItem } from "./deleteLineItem" diff --git a/packages/core/src/line_items/updateLineItem.spec.ts b/packages/core/src/line_items/updateLineItem.spec.ts new file mode 100644 index 00000000..b6243c56 --- /dev/null +++ b/packages/core/src/line_items/updateLineItem.spec.ts @@ -0,0 +1,103 @@ +import { beforeEach, describe, expect, test, vi } from "vitest" +import type { InterceptorManager } from "#sdk" +import { updateLineItem } from "./updateLineItem.js" + +const { + mockUpdate, + mockAddRequestInterceptor, + mockAddResponseInterceptor, + mockAddRawResponseReader, +} = vi.hoisted(() => { + const mockUpdate = vi.fn() + const mockAddRequestInterceptor = vi.fn().mockReturnValue(1) + const mockAddResponseInterceptor = vi.fn().mockReturnValue(1) + const mockAddRawResponseReader = vi.fn() + return { + mockUpdate, + mockAddRequestInterceptor, + mockAddResponseInterceptor, + mockAddRawResponseReader, + } +}) + +vi.mock("@commercelayer/sdk/bundle", () => ({ + CommerceLayer: vi.fn().mockReturnValue({ + addRequestInterceptor: mockAddRequestInterceptor, + addResponseInterceptor: mockAddResponseInterceptor, + addRawResponseReader: mockAddRawResponseReader, + line_items: { update: mockUpdate }, + }), +})) + +vi.mock("@commercelayer/js-auth", () => ({ + jwtDecode: vi.fn().mockReturnValue({ payload: { organization: { slug: "my-org" } } }), +})) + +const MOCK_LINE_ITEM = { id: "li_1", item_type: "skus", quantity: 3 } + +describe("updateLineItem", () => { + beforeEach(() => { + vi.clearAllMocks() + mockAddRequestInterceptor.mockReturnValue(1) + mockAddResponseInterceptor.mockReturnValue(1) + mockUpdate.mockResolvedValue(MOCK_LINE_ITEM) + }) + + test("returns the updated line item", async () => { + const result = await updateLineItem({ + accessToken: "fake-token", + lineItemId: "li_1", + quantity: 3, + }) + + expect(result).toEqual(MOCK_LINE_ITEM) + }) + + test("calls line_items.update with correct id and quantity", async () => { + await updateLineItem({ accessToken: "fake-token", lineItemId: "li_1", quantity: 2 }) + + expect(mockUpdate).toHaveBeenCalledWith( + expect.objectContaining({ id: "li_1", quantity: 2 }) + ) + }) + + test("passes hasExternalPrice as _external_price", async () => { + await updateLineItem({ + accessToken: "fake-token", + lineItemId: "li_1", + hasExternalPrice: true, + }) + + expect(mockUpdate).toHaveBeenCalledWith( + expect.objectContaining({ id: "li_1", _external_price: true }) + ) + }) + + test("forwards request interceptors to getSdk", async () => { + const onSuccess = vi.fn() + const interceptors: InterceptorManager = { request: { onSuccess } } + + await updateLineItem({ accessToken: "fake-token", lineItemId: "li_1", interceptors }) + + expect(mockAddRequestInterceptor).toHaveBeenCalledWith(onSuccess, undefined) + expect(mockAddResponseInterceptor).not.toHaveBeenCalled() + }) + + test("forwards response interceptors to getSdk", async () => { + const onSuccess = vi.fn() + const interceptors: InterceptorManager = { response: { onSuccess } } + + await updateLineItem({ accessToken: "fake-token", lineItemId: "li_1", interceptors }) + + expect(mockAddResponseInterceptor).toHaveBeenCalledWith(onSuccess, undefined) + expect(mockAddRequestInterceptor).not.toHaveBeenCalled() + }) + + test("does not register any interceptors when none are provided", async () => { + await updateLineItem({ accessToken: "fake-token", lineItemId: "li_1" }) + + expect(mockAddRequestInterceptor).not.toHaveBeenCalled() + expect(mockAddResponseInterceptor).not.toHaveBeenCalled() + expect(mockAddRawResponseReader).not.toHaveBeenCalled() + }) +}) diff --git a/packages/core/src/line_items/updateLineItem.ts b/packages/core/src/line_items/updateLineItem.ts new file mode 100644 index 00000000..953fd785 --- /dev/null +++ b/packages/core/src/line_items/updateLineItem.ts @@ -0,0 +1,33 @@ +import type { LineItem } from "@commercelayer/sdk" +import { getSdk } from "#sdk" +import type { RequestConfig } from "#types" + +interface UpdateLineItemParams extends Pick { + lineItemId: string + quantity?: number + hasExternalPrice?: boolean +} + +/** + * Update a line item's quantity and/or external price flag. + * + * @param {string} accessToken - The access token to use for authentication. + * @param {string} lineItemId - The ID of the line item to update. + * @param {number} quantity - The new quantity for the line item. + * @param {boolean} hasExternalPrice - Whether to use an external price for the line item. + * @returns {Promise} - The updated line item resource. + */ +export async function updateLineItem({ + accessToken, + interceptors, + lineItemId, + quantity, + hasExternalPrice, +}: UpdateLineItemParams): Promise { + const sdk = getSdk({ accessToken, interceptors }) + return await sdk.line_items.update({ + id: lineItemId, + quantity, + _external_price: hasExternalPrice, + }) +} diff --git a/packages/hooks/src/index.ts b/packages/hooks/src/index.ts index 55376b2e..f73bce3c 100644 --- a/packages/hooks/src/index.ts +++ b/packages/hooks/src/index.ts @@ -1,5 +1,6 @@ export type { InterceptorManager } from "@commercelayer/core" export { useAvailability } from "./availability/useAvailability" +export { useLineItems } from "./line_items/useLineItems" export { useOrder } from "./orders/useOrder" export { usePrices } from "./prices/usePrices" export { useSkuList } from "./sku_lists/useSkuList" diff --git a/packages/hooks/src/line_items/useLineItems.test.ts b/packages/hooks/src/line_items/useLineItems.test.ts new file mode 100644 index 00000000..f5b55c8a --- /dev/null +++ b/packages/hooks/src/line_items/useLineItems.test.ts @@ -0,0 +1,210 @@ +/** + * @vitest-environment jsdom + */ +import { act, renderHook, waitFor } from "@testing-library/react" +import type { ReactNode } from "react" +import { createElement } from "react" +import { SWRConfig } from "swr" +import { beforeEach, describe, expect, it, vi } from "vitest" +import type { InterceptorManager } from "../index" +import { useLineItems } from "./useLineItems" + +const swrWrapper = ({ children }: { children: ReactNode }) => + createElement(SWRConfig, { value: { provider: () => new Map() } }, children) + +const MOCK_LINE_ITEMS = [ + { id: "li_1", item_type: "skus", quantity: 2 }, + { id: "li_2", item_type: "gift_cards", quantity: 1 }, +] +const MOCK_UPDATED_LINE_ITEM = { id: "li_1", item_type: "skus", quantity: 3 } + +const mockGetLineItems = vi.fn().mockResolvedValue(MOCK_LINE_ITEMS) +const mockUpdateLineItem = vi.fn().mockResolvedValue(MOCK_UPDATED_LINE_ITEM) +const mockDeleteLineItem = vi.fn().mockResolvedValue(undefined) + +vi.mock("@commercelayer/core", async (importOriginal) => { + const actual = await importOriginal() + return { + ...actual, + getLineItems: (...args: unknown[]) => mockGetLineItems(...args), + updateLineItem: (...args: unknown[]) => mockUpdateLineItem(...args), + deleteLineItem: (...args: unknown[]) => mockDeleteLineItem(...args), + } +}) + +describe("useLineItems", () => { + const accessToken = "test-token" + const orderId = "order-1" + + beforeEach(() => { + mockGetLineItems.mockClear() + mockUpdateLineItem.mockClear() + mockDeleteLineItem.mockClear() + }) + + it("returns empty array and no loading when orderId is null", () => { + const { result } = renderHook(() => useLineItems({ accessToken, orderId: null }), { + wrapper: swrWrapper, + }) + + expect(result.current.lineItems).toEqual([]) + expect(result.current.isLoading).toBe(false) + expect(result.current.error).toBeNull() + expect(mockGetLineItems).not.toHaveBeenCalled() + }) + + it("returns empty array and no loading when orderId is undefined", () => { + const { result } = renderHook(() => useLineItems({ accessToken }), { + wrapper: swrWrapper, + }) + + expect(result.current.lineItems).toEqual([]) + expect(result.current.isLoading).toBe(false) + expect(mockGetLineItems).not.toHaveBeenCalled() + }) + + it("fetches and returns line items when accessToken and orderId are provided", async () => { + const { result } = renderHook(() => useLineItems({ accessToken, orderId }), { + wrapper: swrWrapper, + }) + + await waitFor(() => { + expect(result.current.lineItems).toEqual(MOCK_LINE_ITEMS) + expect(result.current.isLoading).toBe(false) + expect(result.current.error).toBeNull() + }) + }) + + it("calls getLineItems with correct accessToken and orderId", async () => { + renderHook(() => useLineItems({ accessToken, orderId }), { wrapper: swrWrapper }) + + await waitFor(() => { + expect(mockGetLineItems).toHaveBeenCalledWith( + expect.objectContaining({ accessToken, orderId }) + ) + }) + }) + + it("updateLineItem calls core function with correct args and triggers revalidation", async () => { + const { result } = renderHook(() => useLineItems({ accessToken, orderId }), { + wrapper: swrWrapper, + }) + + await waitFor(() => expect(result.current.lineItems).toEqual(MOCK_LINE_ITEMS)) + + let updated!: Awaited> + await act(async () => { + updated = await result.current.updateLineItem("li_1", 3, false) + }) + + expect(mockUpdateLineItem).toHaveBeenCalledWith( + expect.objectContaining({ accessToken, lineItemId: "li_1", quantity: 3, hasExternalPrice: false }) + ) + expect(updated).toEqual(MOCK_UPDATED_LINE_ITEM) + // SWR revalidates — getLineItems is called again + expect(mockGetLineItems).toHaveBeenCalledTimes(2) + }) + + it("deleteLineItem calls core function and triggers revalidation", async () => { + const { result } = renderHook(() => useLineItems({ accessToken, orderId }), { + wrapper: swrWrapper, + }) + + await waitFor(() => expect(result.current.lineItems).toEqual(MOCK_LINE_ITEMS)) + + await act(async () => { + await result.current.deleteLineItem("li_1") + }) + + expect(mockDeleteLineItem).toHaveBeenCalledWith( + expect.objectContaining({ accessToken, lineItemId: "li_1" }) + ) + expect(mockGetLineItems).toHaveBeenCalledTimes(2) + }) + + it("reload triggers revalidation", async () => { + const { result } = renderHook(() => useLineItems({ accessToken, orderId }), { + wrapper: swrWrapper, + }) + + await waitFor(() => expect(result.current.lineItems).toEqual(MOCK_LINE_ITEMS)) + + await act(async () => { + await result.current.reload() + }) + + expect(mockGetLineItems).toHaveBeenCalledTimes(2) + }) + + it("exposes error message when getLineItems throws", async () => { + mockGetLineItems.mockRejectedValueOnce(new Error("Unauthorized")) + + const { result } = renderHook(() => useLineItems({ accessToken, orderId }), { + wrapper: swrWrapper, + }) + + await waitFor( + () => { + expect(result.current.error).toBe("Unauthorized") + expect(result.current.lineItems).toEqual([]) + }, + { timeout: 5000 } + ) + }) + + it("passes interceptors to getLineItems", async () => { + const interceptors: InterceptorManager = { + request: { onSuccess: vi.fn((req) => req) }, + } + + renderHook(() => useLineItems({ accessToken, orderId, interceptors }), { + wrapper: swrWrapper, + }) + + await waitFor(() => { + expect(mockGetLineItems).toHaveBeenCalledWith(expect.objectContaining({ interceptors })) + }) + }) + + it("passes interceptors to updateLineItem", async () => { + const interceptors: InterceptorManager = { + request: { onSuccess: vi.fn((req) => req) }, + } + + const { result } = renderHook(() => useLineItems({ accessToken, orderId, interceptors }), { + wrapper: swrWrapper, + }) + + await act(async () => { + await result.current.updateLineItem("li_1", 2) + }) + + expect(mockUpdateLineItem).toHaveBeenCalledWith(expect.objectContaining({ interceptors })) + }) + + it("returns empty array and no loading when accessToken is undefined", () => { + const { result } = renderHook(() => useLineItems({ orderId }), { + wrapper: swrWrapper, + }) + + expect(result.current.lineItems).toEqual([]) + expect(result.current.isLoading).toBe(false) + expect(mockGetLineItems).not.toHaveBeenCalled() + }) + + it("updateLineItem throws when accessToken is not provided", async () => { + const { result } = renderHook(() => useLineItems({ orderId }), { + wrapper: swrWrapper, + }) + + await expect(result.current.updateLineItem("li_1", 2)).rejects.toThrow("accessToken is required") + }) + + it("deleteLineItem throws when accessToken is not provided", async () => { + const { result } = renderHook(() => useLineItems({ orderId }), { + wrapper: swrWrapper, + }) + + await expect(result.current.deleteLineItem("li_1")).rejects.toThrow("accessToken is required") + }) +}) diff --git a/packages/hooks/src/line_items/useLineItems.ts b/packages/hooks/src/line_items/useLineItems.ts new file mode 100644 index 00000000..7e5a73e1 --- /dev/null +++ b/packages/hooks/src/line_items/useLineItems.ts @@ -0,0 +1,109 @@ +import { + deleteLineItem as coreDeleteLineItem, + getLineItems, + type InterceptorManager, + updateLineItem as coreUpdateLineItem, +} from "@commercelayer/core" +import type { LineItem } from "@commercelayer/sdk" +import { useCallback } from "react" +import useSWR, { type KeyedMutator } from "swr" + +interface UseLineItemsParams { + accessToken?: string + orderId?: string | null + interceptors?: InterceptorManager +} + +interface UseLineItemsReturn { + lineItems: LineItem[] + isLoading: boolean + isValidating: boolean + error: string | null + updateLineItem: ( + lineItemId: string, + quantity?: number, + hasExternalPrice?: boolean + ) => Promise + deleteLineItem: (lineItemId: string) => Promise + reload: () => Promise + mutate: KeyedMutator +} + +/** + * React hook for fetching and managing line items for a Commerce Layer order. + * + * Uses SWR for data fetching and caching. Provides `updateLineItem` and + * `deleteLineItem` helpers that automatically refresh the cached data. + * + * @param accessToken - Commerce Layer API access token. + * @param orderId - ID of the order whose line items to fetch. Pass `null` or omit to skip fetching. + * @param interceptors - Optional SDK interceptors. + * + * @example + * ```tsx + * const { lineItems, isLoading, updateLineItem, deleteLineItem } = useLineItems({ + * accessToken, + * orderId: 'xYzAbCdE', + * }) + * ``` + */ +export function useLineItems({ + accessToken, + orderId, + interceptors, +}: UseLineItemsParams): UseLineItemsReturn { + const swrKey = + accessToken && orderId ? ["line_items", "get", accessToken, orderId] : null + + const { data, error, isLoading, isValidating, mutate } = useSWR( + swrKey, + async (): Promise => { + return getLineItems({ accessToken: accessToken as string, interceptors, orderId: orderId as string }) + }, + { revalidateOnFocus: false, revalidateOnReconnect: false } + ) + + const updateLineItem = useCallback( + async ( + lineItemId: string, + quantity?: number, + hasExternalPrice?: boolean + ): Promise => { + if (!accessToken) throw new Error("accessToken is required") + const updated = await coreUpdateLineItem({ + accessToken, + interceptors, + lineItemId, + quantity, + hasExternalPrice, + }) + await mutate() + return updated + }, + [accessToken, interceptors, mutate] + ) + + const deleteLineItem = useCallback( + async (lineItemId: string): Promise => { + if (!accessToken) throw new Error("accessToken is required") + await coreDeleteLineItem({ accessToken, interceptors, lineItemId }) + await mutate() + }, + [accessToken, interceptors, mutate] + ) + + const reload = useCallback(async (): Promise => { + return await mutate() + }, [mutate]) + + return { + lineItems: data ?? [], + isLoading, + isValidating, + error: error?.message ?? null, + updateLineItem, + deleteLineItem, + reload, + mutate, + } +} diff --git a/packages/react-components/specs/line_items/LineItems.spec.tsx b/packages/react-components/specs/line_items/LineItems.spec.tsx new file mode 100644 index 00000000..3c9f041a --- /dev/null +++ b/packages/react-components/specs/line_items/LineItems.spec.tsx @@ -0,0 +1,323 @@ +import { render, screen, act } from "@testing-library/react" +import { type ReactNode, useContext } from "react" +import { vi, beforeEach, describe, it, expect } from "vitest" +import { LineItems } from "#components/line_items/LineItems" +import CommerceLayerContext from "#context/CommerceLayerContext" +import LineItemContext from "#context/LineItemContext" +import OrderContext, { defaultOrderContext } from "#context/OrderContext" + +const MOCK_LINE_ITEMS = [ + { id: "li_1", item_type: "skus", quantity: 2, name: "Baby Onesie" }, + { id: "li_2", item_type: "gift_cards", quantity: 1, name: "Gift Card" }, +] + +const mockUpdateLineItem = vi.fn().mockResolvedValue({ id: "li_1" }) +const mockDeleteLineItem = vi.fn().mockResolvedValue(undefined) +const mockReload = vi.fn().mockResolvedValue(undefined) +const mockMutate = vi.fn() + +const mockUseLineItems = vi.fn() + +vi.mock("@commercelayer/hooks", async (importOriginal) => { + const actual = await importOriginal() + return { + ...actual, + useLineItems: (...args: unknown[]) => mockUseLineItems(...args), + } +}) + +function defaultHookReturn(overrides = {}) { + return { + lineItems: MOCK_LINE_ITEMS, + isLoading: false, + isValidating: false, + error: null, + updateLineItem: mockUpdateLineItem, + deleteLineItem: mockDeleteLineItem, + reload: mockReload, + mutate: mockMutate, + ...overrides, + } +} + +function Providers({ + accessToken = "token", + orderId = "order-1", + children, +}: { + accessToken?: string + orderId?: string + children: ReactNode +}) { + return ( + + + {children} + + + ) +} + +describe("LineItems component", () => { + beforeEach(() => { + mockUseLineItems.mockReturnValue(defaultHookReturn()) + mockUpdateLineItem.mockClear() + mockDeleteLineItem.mockClear() + mockReload.mockClear() + }) + + it("renders children when not loading", () => { + render( + + + content + + + ) + + expect(screen.getByTestId("child")).toBeDefined() + }) + + it("renders multiple children", () => { + render( + + + one + two + + + ) + + expect(screen.getByTestId("child-1")).toBeDefined() + expect(screen.getByTestId("child-2")).toBeDefined() + }) + + it("renders the loader while isLoading is true", () => { + mockUseLineItems.mockReturnValue(defaultHookReturn({ isLoading: true })) + + render( + + Loading…}> + content + + + ) + + expect(screen.getByTestId("loader")).toBeDefined() + expect(screen.queryByTestId("child")).toBeNull() + }) + + it("hides the loader and shows children when isLoading becomes false", () => { + mockUseLineItems.mockReturnValue(defaultHookReturn({ isLoading: false })) + + render( + + Loading…}> + content + + + ) + + expect(screen.queryByTestId("loader")).toBeNull() + expect(screen.getByTestId("child")).toBeDefined() + }) + + it("provides lineItems via LineItemContext to children", () => { + let capturedLineItems: unknown = null + + function Consumer() { + const { lineItems } = useContext(LineItemContext) + capturedLineItems = lineItems + return null + } + + render( + + + + + + ) + + expect(capturedLineItems).toEqual(MOCK_LINE_ITEMS) + }) + + it("filters lineItems by types prop before passing to context", () => { + let capturedLineItems: unknown = null + + function Consumer() { + const { lineItems } = useContext(LineItemContext) + capturedLineItems = lineItems + return null + } + + render( + + + + + + ) + + expect(capturedLineItems).toEqual([MOCK_LINE_ITEMS[0]]) + }) + + it("provides all lineItems when types prop is not set", () => { + let capturedLineItems: unknown = null + + function Consumer() { + const { lineItems } = useContext(LineItemContext) + capturedLineItems = lineItems + return null + } + + render( + + + + + + ) + + expect(capturedLineItems).toEqual(MOCK_LINE_ITEMS) + }) + + it("reads accessToken and orderId from context and passes them to useLineItems", () => { + render( + + + + + + ) + + expect(mockUseLineItems).toHaveBeenCalledWith( + expect.objectContaining({ accessToken: "ctx-token", orderId: "ctx-order" }) + ) + }) + + it("calls onUpdate callback after a successful updateLineItem", async () => { + const onUpdate = vi.fn() + let contextUpdateLineItem: ((id: string, qty?: number) => Promise) | undefined + + function Consumer() { + const ctx = useContext(LineItemContext) + contextUpdateLineItem = ctx.updateLineItem + return null + } + + render( + + + + + + ) + + await act(async () => { + await contextUpdateLineItem?.("li_1", 2) + }) + + expect(mockUpdateLineItem).toHaveBeenCalledWith("li_1", 2, undefined) + expect(onUpdate).toHaveBeenCalledWith("li_1") + }) + + it("calls onDelete callback after a successful deleteLineItem", async () => { + const onDelete = vi.fn() + let contextDeleteLineItem: ((id: string) => Promise) | undefined + + function Consumer() { + const ctx = useContext(LineItemContext) + contextDeleteLineItem = ctx.deleteLineItem + return null + } + + render( + + + + + + ) + + await act(async () => { + await contextDeleteLineItem?.("li_1") + }) + + expect(mockDeleteLineItem).toHaveBeenCalledWith("li_1") + expect(onDelete).toHaveBeenCalledWith("li_1") + }) + + it("exposes reload function via context", async () => { + let contextReload: (() => Promise) | undefined + + function Consumer() { + const ctx = useContext(LineItemContext) + contextReload = ctx.reload + return null + } + + render( + + + + + + ) + + expect(contextReload).toBeDefined() + + await act(async () => { + await contextReload?.() + }) + + expect(mockReload).toHaveBeenCalledOnce() + }) + + it("exposes error in context when hook returns an error", () => { + mockUseLineItems.mockReturnValue(defaultHookReturn({ error: "Something went wrong" })) + + let capturedErrors: unknown = null + + function Consumer() { + const { errors } = useContext(LineItemContext) + capturedErrors = errors + return null + } + + render( + + + + + + ) + + expect(capturedErrors).toEqual([ + expect.objectContaining({ + code: "INTERNAL_SERVER_ERROR", + message: "Something went wrong", + }), + ]) + }) + + it("exposes empty errors array when there is no error", () => { + let capturedErrors: unknown = null + + function Consumer() { + const { errors } = useContext(LineItemContext) + capturedErrors = errors + return null + } + + render( + + + + + + ) + + expect(capturedErrors).toEqual([]) + }) +}) diff --git a/packages/react-components/src/components/line_items/LineItems.tsx b/packages/react-components/src/components/line_items/LineItems.tsx new file mode 100644 index 00000000..10be47f0 --- /dev/null +++ b/packages/react-components/src/components/line_items/LineItems.tsx @@ -0,0 +1,82 @@ +import { useLineItems } from "@commercelayer/hooks" +import { type JSX, useContext } from "react" +import CommerceLayerContext from "#context/CommerceLayerContext" +import LineItemContext, { type LineItemContextValue } from "#context/LineItemContext" +import OrderContext from "#context/OrderContext" +import type { BaseError } from "#typings/errors" +import type { DefaultChildrenType } from "#typings/globals" +import type { TLineItem } from "./LineItem" + +interface Props { + children: DefaultChildrenType + /** + * Filter line items by item type. When provided, only matching types + * are put into context (affects LineItem, LineItemsCount, LineItemsEmpty). + */ + types?: TLineItem[] + /** + * Element to display while line items are loading. + */ + loader?: JSX.Element + /** + * Called after a line item has been successfully updated. + */ + onUpdate?: (lineItemId: string) => void + /** + * Called after a line item has been successfully deleted. + */ + onDelete?: (lineItemId: string) => void +} + +export function LineItems({ + children, + types, + loader = <>Loading..., + onUpdate, + onDelete, +}: Props): JSX.Element { + const { accessToken } = useContext(CommerceLayerContext) + const { orderId } = useContext(OrderContext) + + const { + lineItems: allLineItems, + isLoading, + error, + updateLineItem: hookUpdate, + deleteLineItem: hookDelete, + reload: hookReload, + } = useLineItems({ accessToken, orderId }) + + const lineItems = + types != null ? allLineItems.filter((li) => types.includes(li.item_type as TLineItem)) : allLineItems + + const errors: BaseError[] = error + ? [{ code: "INTERNAL_SERVER_ERROR", message: error, resource: "line_items" }] + : [] + + const contextValue: LineItemContextValue = { + lineItems, + loading: isLoading, + errors, + loader, + updateLineItem: async (lineItemId, quantity = 1, hasExternalPrice) => { + await hookUpdate(lineItemId, quantity, hasExternalPrice) + onUpdate?.(lineItemId) + }, + deleteLineItem: async (lineItemId) => { + await hookDelete(lineItemId) + onDelete?.(lineItemId) + }, + reload: async () => { + await hookReload() + }, + } + + return ( + + {isLoading ? loader : children} + + ) +} + +export default LineItems diff --git a/packages/react-components/src/components/line_items/LineItemsContainer.tsx b/packages/react-components/src/components/line_items/LineItemsContainer.tsx index 5454796f..667d1be5 100644 --- a/packages/react-components/src/components/line_items/LineItemsContainer.tsx +++ b/packages/react-components/src/components/line_items/LineItemsContainer.tsx @@ -15,6 +15,10 @@ interface Props { loader?: JSX.Element } +/** + * @deprecated Use `` instead. + * `LineItemsContainer` requires an `OrderContext` parent and will be removed in a future major version. + */ export function LineItemsContainer(props: Props): JSX.Element { const { children, loader = "Loading..." } = props const { order, addResourceToInclude, include, orderId, getOrder, includeLoaded } = diff --git a/packages/react-components/src/components/line_items/LineItemsCount.tsx b/packages/react-components/src/components/line_items/LineItemsCount.tsx index 074b686e..0922fd7d 100644 --- a/packages/react-components/src/components/line_items/LineItemsCount.tsx +++ b/packages/react-components/src/components/line_items/LineItemsCount.tsx @@ -18,7 +18,7 @@ export function LineItemsCount(props: Props): JSX.Element { const { children, typeAccepted, ...p } = props const { lineItems } = useCustomContext({ context: LineItemContext, - contextComponentName: "LineItemsContainer", + contextComponentName: "LineItems", currentComponentName: "LineItemsCount", key: "lineItems", }) diff --git a/packages/react-components/src/context/LineItemContext.ts b/packages/react-components/src/context/LineItemContext.ts index 81509493..0e8c5477 100644 --- a/packages/react-components/src/context/LineItemContext.ts +++ b/packages/react-components/src/context/LineItemContext.ts @@ -4,6 +4,7 @@ import type { LineItem } from "@commercelayer/sdk" export interface LineItemContextValue extends LineItemState { lineItems?: LineItem[] | null + reload?: () => Promise } const initial: LineItemContextValue = {} diff --git a/packages/react-components/src/index.ts b/packages/react-components/src/index.ts index 806e0579..09fc744a 100644 --- a/packages/react-components/src/index.ts +++ b/packages/react-components/src/index.ts @@ -41,6 +41,7 @@ export * from "#components/line_items/LineItemOption" export * from "#components/line_items/LineItemOptions" export * from "#components/line_items/LineItemQuantity" export * from "#components/line_items/LineItemRemoveLink" +export * from "#components/line_items/LineItems" export * from "#components/line_items/LineItemsContainer" export * from "#components/line_items/LineItemsCount" export * from "#components/line_items/LineItemsEmpty"