diff --git a/plasmicpkgs/commerce-providers/elastic-path/src/cart-provider/seed-cart-fallback.ts b/plasmicpkgs/commerce-providers/elastic-path/src/cart-provider/seed-cart-fallback.ts index fcc1940a0..158ad0e37 100644 --- a/plasmicpkgs/commerce-providers/elastic-path/src/cart-provider/seed-cart-fallback.ts +++ b/plasmicpkgs/commerce-providers/elastic-path/src/cart-provider/seed-cart-fallback.ts @@ -1,4 +1,3 @@ -import { unstable_serialize } from "swr"; import type { Cart } from "../types/cart"; import { epGetCart } from "../ep-server-functions"; import { epCartCacheKey } from "./cache-keys"; @@ -20,6 +19,7 @@ export async function seedCartFallback(): Promise> { } catch { cart = null; } + const { unstable_serialize } = require("swr") as typeof import("swr"); const key = unstable_serialize(epCartCacheKey()); return { [key]: cart }; } diff --git a/plasmicpkgs/commerce-providers/elastic-path/src/catalog-search/EPClearRefinements.tsx b/plasmicpkgs/commerce-providers/elastic-path/src/catalog-search/EPClearRefinements.tsx new file mode 100644 index 000000000..fd817bc47 --- /dev/null +++ b/plasmicpkgs/commerce-providers/elastic-path/src/catalog-search/EPClearRefinements.tsx @@ -0,0 +1,204 @@ +/** + * EPClearRefinements — single-click clear-all-filters control. + * + * Wraps `useClearRefinements()` from react-instantsearch. Pre-wires onClick + * and `disabled` into a single child element via Pattern C (cloneElement), + * so designers drop the component and it works without extra Studio + * interaction wiring. Multi-element children fall through unchanged — the + * `clearRefinementsData.clear` context value is the documented escape + * hatch when auto-injection can't apply. + */ + +import { DataProvider, usePlasmicCanvasContext } from "@plasmicapp/host"; +import registerComponent, { + CodeComponentMeta, +} from "@plasmicapp/host/registerComponent"; +import React, { useCallback, useImperativeHandle, useMemo } from "react"; +import { Registerable } from "../registerable"; +import { + MOCK_CLEAR_REFINEMENTS_DATA, + ClearRefinementsData, +} from "./design-time-data"; + +type PreviewState = "auto" | "withData"; + +interface EPClearRefinementsProps { + children?: React.ReactNode; + includedAttributes?: string[]; + excludedAttributes?: string[]; + className?: string; + previewState?: PreviewState; +} + +interface EPClearRefinementsActions { + clear(): void; +} + +export const epClearRefinementsMeta: CodeComponentMeta = { + name: "plasmic-commerce-ep-clear-refinements", + displayName: "EP Clear Refinements", + description: + "Drops a single button that clears all active filters in one click. Click + disabled state are wired automatically. Must be inside EP Catalog Search Provider.", + props: { + children: { + type: "slot", + defaultValue: [ + { + type: "button", + value: "Clear all", + }, + ], + }, + includedAttributes: { + type: "array", + displayName: "Included Attributes", + description: + "Restrict which attributes the clear action affects. Mutually exclusive with Excluded Attributes.", + }, + excludedAttributes: { + type: "array", + displayName: "Excluded Attributes", + description: + "Attributes to leave alone when clearing. Mutually exclusive with Included Attributes.", + }, + previewState: { + type: "choice", + options: ["auto", "withData"], + defaultValue: "auto", + displayName: "Preview State", + advanced: true, + }, + }, + importPath: "@elasticpath/plasmic-ep-commerce-elastic-path", + importName: "EPClearRefinements", + parentComponentName: "plasmic-commerce-ep-catalog-search-provider", + providesData: true, + refActions: { + clear: { + description: "Trigger the clear-all action programmatically", + argTypes: [], + }, + }, +}; + +export const EPClearRefinements = React.forwardRef< + EPClearRefinementsActions, + EPClearRefinementsProps +>(function EPClearRefinements(props, ref) { + const { + children, + includedAttributes, + excludedAttributes, + className, + previewState = "auto", + } = props; + + const inEditor = !!usePlasmicCanvasContext(); + const useMock = + previewState === "withData" || (previewState === "auto" && inEditor); + + if (useMock) { + return ( + + {children} + + ); + } + + return ( + + {children} + + ); +}); + +const MockClearRefinements = React.forwardRef< + EPClearRefinementsActions, + { children?: React.ReactNode; className?: string } +>(function MockClearRefinements({ children, className }, ref) { + useImperativeHandle(ref, () => ({ + clear: () => {}, + })); + + return ( + +
{}} + > + {children} +
+
+ ); +}); + +const EPClearRefinementsInner = React.forwardRef< + EPClearRefinementsActions, + { + children?: React.ReactNode; + includedAttributes?: string[]; + excludedAttributes?: string[]; + className?: string; + } +>(function EPClearRefinementsInner( + { children, includedAttributes, excludedAttributes, className }, + ref +) { + const { useClearRefinements } = require("react-instantsearch"); + const { refine, canRefine } = useClearRefinements({ + includedAttributes, + excludedAttributes, + }); + + const handleClear = useCallback(() => { + refine(); + }, [refine]); + + useImperativeHandle(ref, () => ({ + clear: handleClear, + })); + + const data: ClearRefinementsData = useMemo( + () => ({ canRefine, clear: handleClear }), + [canRefine, handleClear] + ); + + const handleWrapperClick = useCallback(() => { + if (canRefine) refine(); + }, [refine, canRefine]); + + if (!canRefine) return null; + + return ( + +
+ {children} +
+
+ ); +}); + +export function registerEPClearRefinements( + loader?: Registerable, + customMeta?: CodeComponentMeta +) { + const doRegisterComponent: typeof registerComponent = (...args) => + loader ? loader.registerComponent(...args) : registerComponent(...args); + doRegisterComponent( + EPClearRefinements, + customMeta ?? epClearRefinementsMeta + ); +} diff --git a/plasmicpkgs/commerce-providers/elastic-path/src/catalog-search/EPCurrentRefinements.tsx b/plasmicpkgs/commerce-providers/elastic-path/src/catalog-search/EPCurrentRefinements.tsx new file mode 100644 index 000000000..7a3f3569e --- /dev/null +++ b/plasmicpkgs/commerce-providers/elastic-path/src/catalog-search/EPCurrentRefinements.tsx @@ -0,0 +1,254 @@ +/** + * EPCurrentRefinements — chip row showing each active refinement. + * + * Wraps `useCurrentRefinements()` from react-instantsearch. The hook returns + * data nested per-attribute; this component flattens into one chip per + * (attribute, value) pair. Each repeated child gets `onClick` injected via + * Pattern C (cloneElement) so dismissing a chip works without Studio + * interaction wiring. The per-iteration `currentRefinementChip` ctx value + * carries a zero-arg `refine` for designers whose layout breaks + * auto-injection. + * + * Returns null when there are no refinements — no empty band above the grid. + */ + +import { + DataProvider, + repeatedElement, + usePlasmicCanvasContext, +} from "@plasmicapp/host"; +import registerComponent, { + CodeComponentMeta, +} from "@plasmicapp/host/registerComponent"; +import React, { useMemo } from "react"; +import { Registerable } from "../registerable"; +import { + MOCK_CURRENT_REFINEMENT_CHIPS, + CurrentRefinementChip, + CurrentRefinementType, +} from "./design-time-data"; + +type PreviewState = "auto" | "withData"; + +interface EPCurrentRefinementsProps { + children?: React.ReactNode; + includedAttributes?: string[]; + excludedAttributes?: string[]; + className?: string; + previewState?: PreviewState; +} + +export const epCurrentRefinementsMeta: CodeComponentMeta = { + name: "plasmic-commerce-ep-current-refinements", + displayName: "EP Current Refinements", + description: + "Chip row showing each active filter. Repeats children per refinement; click dismisses that refinement automatically. Hides itself when no filters are active. Must be inside EP Catalog Search Provider.", + props: { + children: { + type: "slot", + defaultValue: [ + { + type: "hbox", + styles: { + alignItems: "center", + gap: "4px", + paddingTop: "4px", + paddingRight: "8px", + paddingBottom: "4px", + paddingLeft: "8px", + borderTopLeftRadius: "999px", + borderTopRightRadius: "999px", + borderBottomLeftRadius: "999px", + borderBottomRightRadius: "999px", + borderTopWidth: "1px", + borderRightWidth: "1px", + borderBottomWidth: "1px", + borderLeftWidth: "1px", + borderTopStyle: "solid", + borderRightStyle: "solid", + borderBottomStyle: "solid", + borderLeftStyle: "solid", + borderTopColor: "#d1d5db", + borderRightColor: "#d1d5db", + borderBottomColor: "#d1d5db", + borderLeftColor: "#d1d5db", + }, + children: [ + { type: "text", value: "Brand: Leather" }, + { type: "text", value: "×" }, + ], + }, + ], + }, + includedAttributes: { + type: "array", + displayName: "Included Attributes", + description: + "Restrict which attributes appear as chips. Mutually exclusive with Excluded Attributes.", + }, + excludedAttributes: { + type: "array", + displayName: "Excluded Attributes", + description: + "Attributes to omit from the chip row. Defaults to ['query'] in react-instantsearch when unset.", + }, + previewState: { + type: "choice", + options: ["auto", "withData"], + defaultValue: "auto", + displayName: "Preview State", + advanced: true, + }, + }, + importPath: "@elasticpath/plasmic-ep-commerce-elastic-path", + importName: "EPCurrentRefinements", + parentComponentName: "plasmic-commerce-ep-catalog-search-provider", + providesData: true, +}; + +interface RawRefinement { + attribute: string; + type: CurrentRefinementType; + value: string | number; + label: string; + operator?: string; + count?: number; +} + +interface RawCurrentRefinementsItem { + attribute: string; + label: string; + refinements: RawRefinement[]; + refine: (refinement: RawRefinement) => void; +} + +export function EPCurrentRefinements(props: EPCurrentRefinementsProps) { + const { + children, + includedAttributes, + excludedAttributes, + className, + previewState = "auto", + } = props; + + const inEditor = !!usePlasmicCanvasContext(); + const useMock = + previewState === "withData" || (previewState === "auto" && inEditor); + + if (useMock) { + return ( + + {children} + + ); + } + + return ( + + {children} + + ); +} + +function MockCurrentRefinements({ + children, + className, +}: { + children?: React.ReactNode; + className?: string; +}) { + return ( +
+ {MOCK_CURRENT_REFINEMENT_CHIPS.map((chip, i) => ( +
+ + + {repeatedElement(i, children)} + + +
+ ))} +
+ ); +} + +function EPCurrentRefinementsInner({ + children, + includedAttributes, + excludedAttributes, + className, +}: { + children?: React.ReactNode; + includedAttributes?: string[]; + excludedAttributes?: string[]; + className?: string; +}) { + const { useCurrentRefinements } = require("react-instantsearch"); + const { items } = useCurrentRefinements({ + includedAttributes, + excludedAttributes, + }) as { items: RawCurrentRefinementsItem[] }; + + const chips: CurrentRefinementChip[] = useMemo(() => { + const flattened: CurrentRefinementChip[] = []; + for (const item of items || []) { + for (const refinement of item.refinements || []) { + flattened.push({ + attribute: refinement.attribute, + attributeLabel: item.label, + type: refinement.type, + value: refinement.value, + label: refinement.label, + operator: refinement.operator, + count: refinement.count, + refine: () => item.refine(refinement), + }); + } + } + return flattened; + }, [items]); + + if (chips.length === 0) return null; + + return ( +
+ {chips.map((chip, i) => ( +
+ + + {repeatedElement(i, children)} + + +
+ ))} +
+ ); +} + +export function registerEPCurrentRefinements( + loader?: Registerable, + customMeta?: CodeComponentMeta +) { + const doRegisterComponent: typeof registerComponent = (...args) => + loader ? loader.registerComponent(...args) : registerComponent(...args); + doRegisterComponent( + EPCurrentRefinements, + customMeta ?? epCurrentRefinementsMeta + ); +} diff --git a/plasmicpkgs/commerce-providers/elastic-path/src/catalog-search/__tests__/catalog-search-components.test.tsx b/plasmicpkgs/commerce-providers/elastic-path/src/catalog-search/__tests__/catalog-search-components.test.tsx index 7dd29d028..459539dff 100644 --- a/plasmicpkgs/commerce-providers/elastic-path/src/catalog-search/__tests__/catalog-search-components.test.tsx +++ b/plasmicpkgs/commerce-providers/elastic-path/src/catalog-search/__tests__/catalog-search-components.test.tsx @@ -81,6 +81,20 @@ const mockUseSortBy = jest.fn().mockReturnValue({ refine: jest.fn(), }); +const mockUseClearRefinements = jest.fn().mockReturnValue({ + refine: jest.fn(), + canRefine: true, + hasRefinements: true, + createURL: jest.fn(), +}); + +const mockUseCurrentRefinements = jest.fn().mockReturnValue({ + items: [], + canRefine: false, + refine: jest.fn(), + createURL: jest.fn(), +}); + /* ---------- jest.mock calls ---------- */ jest.mock("@plasmicapp/host", () => ({ DataProvider: ({ @@ -138,6 +152,8 @@ jest.mock("react-instantsearch", () => ({ usePagination: (...a: unknown[]) => mockUsePagination(...a), useStats: (...a: unknown[]) => mockUseStats(...a), useSortBy: (...a: unknown[]) => mockUseSortBy(...a), + useClearRefinements: (...a: unknown[]) => mockUseClearRefinements(...a), + useCurrentRefinements: (...a: unknown[]) => mockUseCurrentRefinements(...a), InstantSearch: ({ children }: { children: React.ReactNode }) => (
{children}
), @@ -210,6 +226,18 @@ const { EPSearchStats, epSearchStatsMeta, registerEPSearchStats } = const { EPSearchSortBy, epSearchSortByMeta, registerEPSearchSortBy } = require("../EPSearchSortBy") as typeof import("../EPSearchSortBy"); +const { + EPClearRefinements, + epClearRefinementsMeta, + registerEPClearRefinements, +} = require("../EPClearRefinements") as typeof import("../EPClearRefinements"); + +const { + EPCurrentRefinements, + epCurrentRefinementsMeta, + registerEPCurrentRefinements, +} = require("../EPCurrentRefinements") as typeof import("../EPCurrentRefinements"); + /* ---------- helpers ---------- */ const mockClient = { baseUrl: "https://api.test.com" }; const mockProvider = { locale: "en-US", client: mockClient }; @@ -1016,6 +1044,378 @@ describe("EPSearchSortBy", () => { }); }); +/* ================================================================ + * EPClearRefinements tests + * ================================================================ */ +describe("EPClearRefinements", () => { + beforeEach(() => { + jest.clearAllMocks(); + mockUsePlasmicCanvasContext.mockReturnValue(null); + }); + + it("publishes clearRefinementsData and triggers refine() on default child click", () => { + const refine = jest.fn(); + mockUseClearRefinements.mockReturnValue({ + refine, + canRefine: true, + hasRefinements: true, + createURL: jest.fn(), + }); + + const { container, getByText } = render( + + + + ); + + const provider = container.querySelector( + '[data-testid="data-provider-clearRefinementsData"]' + ); + expect(provider).not.toBeNull(); + const data = JSON.parse( + provider!.getAttribute("data-provider-data") || "{}" + ); + expect(data.canRefine).toBe(true); + + require("react-dom/test-utils").act(() => { + getByText("Clear all").click(); + }); + expect(refine).toHaveBeenCalledTimes(1); + }); + + it("renders null when no refinements are active (canRefine=false)", () => { + mockUseClearRefinements.mockReturnValue({ + refine: jest.fn(), + canRefine: false, + hasRefinements: false, + createURL: jest.fn(), + }); + + const { container } = render( + + + + ); + + expect(container.querySelector("[data-ep-clear-refinements]")).toBeNull(); + }); + + it("composes designer-supplied onClick before the injected one (via bubble)", () => { + const calls: string[] = []; + const refine = jest.fn(() => calls.push("refine")); + mockUseClearRefinements.mockReturnValue({ + refine, + canRefine: true, + hasRefinements: true, + createURL: jest.fn(), + }); + + const designerClick = () => calls.push("designer"); + + const { getByText } = render( + + + + ); + + require("react-dom/test-utils").act(() => { + getByText("Reset").click(); + }); + expect(calls).toEqual(["designer", "refine"]); + }); + + it("fail-open: multi-element children render unchanged but ctx is still published", () => { + mockUseClearRefinements.mockReturnValue({ + refine: jest.fn(), + canRefine: true, + hasRefinements: true, + createURL: jest.fn(), + }); + + const { container } = render( + + a + b + + ); + + const provider = container.querySelector( + '[data-testid="data-provider-clearRefinementsData"]' + ); + expect(provider).not.toBeNull(); + expect(container.textContent).toContain("a"); + expect(container.textContent).toContain("b"); + }); + + it("renders mock data in editor without invoking the hook", () => { + mockUsePlasmicCanvasContext.mockReturnValue({}); + const refine = jest.fn(); + mockUseClearRefinements.mockReturnValue({ + refine, + canRefine: true, + hasRefinements: true, + createURL: jest.fn(), + }); + + const { container } = render( + + + + ); + + const provider = container.querySelector( + '[data-testid="data-provider-clearRefinementsData"]' + ); + expect(provider).not.toBeNull(); + expect(mockUseClearRefinements).not.toHaveBeenCalled(); + }); + + it("clear() ref-action triggers refine for non-child elements", () => { + const refine = jest.fn(); + mockUseClearRefinements.mockReturnValue({ + refine, + canRefine: true, + hasRefinements: true, + createURL: jest.fn(), + }); + + const ref = React.createRef<{ clear: () => void }>(); + render( + + + + ); + + require("react-dom/test-utils").act(() => { + ref.current!.clear(); + }); + expect(refine).toHaveBeenCalledTimes(1); + }); +}); + +/* ================================================================ + * EPCurrentRefinements tests + * ================================================================ */ +describe("EPCurrentRefinements", () => { + beforeEach(() => { + jest.clearAllMocks(); + mockUsePlasmicCanvasContext.mockReturnValue(null); + }); + + it("returns null when no refinements are active", () => { + mockUseCurrentRefinements.mockReturnValue({ + items: [], + canRefine: false, + refine: jest.fn(), + createURL: jest.fn(), + }); + + const { container } = render( + +
chip
+
+ ); + + expect( + container.querySelector("[data-ep-current-refinements]") + ).toBeNull(); + }); + + it("flattens items into one chip per refinement and publishes per-iteration ctx", () => { + const refine = jest.fn(); + mockUseCurrentRefinements.mockReturnValue({ + items: [ + { + attribute: "brand", + label: "Brand", + refinements: [ + { attribute: "brand", type: "facet", value: "leather", label: "Leather" }, + { attribute: "brand", type: "facet", value: "canvas", label: "Canvas" }, + ], + refine, + }, + { + attribute: "price.USD.float_price", + label: "Price", + refinements: [ + { + attribute: "price.USD.float_price", + type: "numeric", + value: 25, + label: "25", + operator: ">=", + }, + ], + refine, + }, + ], + canRefine: true, + refine, + createURL: jest.fn(), + }); + + const { container } = render( + +
chip
+
+ ); + + const items = container.querySelectorAll('[role="listitem"]'); + expect(items).toHaveLength(3); + + const chipProviders = container.querySelectorAll( + '[data-testid="data-provider-currentRefinementChip"]' + ); + expect(chipProviders).toHaveLength(3); + const first = JSON.parse( + chipProviders[0].getAttribute("data-provider-data") || "{}" + ); + expect(first.attribute).toBe("brand"); + expect(first.attributeLabel).toBe("Brand"); + expect(first.type).toBe("facet"); + expect(first.value).toBe("leather"); + expect(first.label).toBe("Leather"); + + const numeric = JSON.parse( + chipProviders[2].getAttribute("data-provider-data") || "{}" + ); + expect(numeric.type).toBe("numeric"); + expect(numeric.operator).toBe(">="); + expect(numeric.value).toBe(25); + }); + + it("clicking a chip triggers refine bound to that specific refinement", () => { + const refineBrand = jest.fn(); + const refinePrice = jest.fn(); + mockUseCurrentRefinements.mockReturnValue({ + items: [ + { + attribute: "brand", + label: "Brand", + refinements: [ + { attribute: "brand", type: "facet", value: "leather", label: "Leather" }, + ], + refine: refineBrand, + }, + { + attribute: "price.USD.float_price", + label: "Price", + refinements: [ + { + attribute: "price.USD.float_price", + type: "numeric", + value: 25, + label: "25", + operator: ">=", + }, + ], + refine: refinePrice, + }, + ], + canRefine: true, + refine: jest.fn(), + createURL: jest.fn(), + }); + + const { container } = render( + + + + ); + + const buttons = container.querySelectorAll("button"); + expect(buttons).toHaveLength(2); + + require("react-dom/test-utils").act(() => { + (buttons[1] as HTMLButtonElement).click(); + }); + + expect(refineBrand).not.toHaveBeenCalled(); + expect(refinePrice).toHaveBeenCalledTimes(1); + const arg = refinePrice.mock.calls[0][0]; + expect(arg.attribute).toBe("price.USD.float_price"); + expect(arg.value).toBe(25); + }); + + it("composes designer-supplied onClick before injected refine", () => { + const calls: string[] = []; + const refine = jest.fn(() => calls.push("refine")); + mockUseCurrentRefinements.mockReturnValue({ + items: [ + { + attribute: "brand", + label: "Brand", + refinements: [ + { attribute: "brand", type: "facet", value: "leather", label: "Leather" }, + ], + refine, + }, + ], + canRefine: true, + refine, + createURL: jest.fn(), + }); + + const designerClick = () => calls.push("designer"); + + const { container } = render( + + + + ); + + require("react-dom/test-utils").act(() => { + (container.querySelector("button") as HTMLButtonElement).click(); + }); + expect(calls).toEqual(["designer", "refine"]); + }); + + it("renders mock chips in editor without invoking the hook", () => { + mockUsePlasmicCanvasContext.mockReturnValue({}); + const { container } = render( + +
chip
+
+ ); + + const items = container.querySelectorAll('[role="listitem"]'); + expect(items.length).toBeGreaterThan(0); + expect(mockUseCurrentRefinements).not.toHaveBeenCalled(); + }); + + it("fail-open: multi-element repeated child renders without crashing, ctx still published", () => { + mockUseCurrentRefinements.mockReturnValue({ + items: [ + { + attribute: "brand", + label: "Brand", + refinements: [ + { attribute: "brand", type: "facet", value: "leather", label: "Leather" }, + ], + refine: jest.fn(), + }, + ], + canRefine: true, + refine: jest.fn(), + createURL: jest.fn(), + }); + + const { container } = render( + + a + b + + ); + + const chipProviders = container.querySelectorAll( + '[data-testid="data-provider-currentRefinementChip"]' + ); + expect(chipProviders).toHaveLength(1); + expect(container.textContent).toContain("a"); + expect(container.textContent).toContain("b"); + }); +}); + /* ================================================================ * Component registration tests * ================================================================ */ @@ -1137,6 +1537,29 @@ describe("component registration", () => { expect(meta).toBeDefined(); expect(meta.defaultValue).toBe("search"); }); + + it("EPClearRefinements meta exposes name, parent, providesData, refActions", () => { + expect(typeof registerEPClearRefinements).toBe("function"); + expect(epClearRefinementsMeta.name).toBe( + "plasmic-commerce-ep-clear-refinements" + ); + expect(epClearRefinementsMeta.parentComponentName).toBe( + "plasmic-commerce-ep-catalog-search-provider" + ); + expect(epClearRefinementsMeta.providesData).toBe(true); + expect(epClearRefinementsMeta.refActions!.clear).toBeDefined(); + }); + + it("EPCurrentRefinements meta exposes name, parent, providesData", () => { + expect(typeof registerEPCurrentRefinements).toBe("function"); + expect(epCurrentRefinementsMeta.name).toBe( + "plasmic-commerce-ep-current-refinements" + ); + expect(epCurrentRefinementsMeta.parentComponentName).toBe( + "plasmic-commerce-ep-catalog-search-provider" + ); + expect(epCurrentRefinementsMeta.providesData).toBe(true); + }); }); /* ================================================================ @@ -1312,3 +1735,60 @@ describeHeadlessStylingContract({ ), }); + +describeHeadlessStylingContract({ + componentName: "EPClearRefinements", + leafSelector: "[data-ep-clear-refinements]", + setEditorMode, + renderInEditor: ({ className }) => ( + + + + ), + renderAtRuntime: ({ className }) => { + mockUseClearRefinements.mockReturnValue({ + refine: jest.fn(), + canRefine: true, + hasRefinements: true, + createURL: jest.fn(), + }); + return ( + + + + ); + }, +}); + +describeHeadlessStylingContract({ + componentName: "EPCurrentRefinements", + leafSelector: "[data-ep-current-refinements]", + setEditorMode, + renderInEditor: ({ className }) => ( + +
chip
+
+ ), + renderAtRuntime: ({ className }) => { + mockUseCurrentRefinements.mockReturnValue({ + items: [ + { + attribute: "brand", + label: "Brand", + refinements: [ + { attribute: "brand", type: "facet", value: "leather", label: "Leather" }, + ], + refine: jest.fn(), + }, + ], + canRefine: true, + refine: jest.fn(), + createURL: jest.fn(), + }); + return ( + +
chip
+
+ ); + }, +}); diff --git a/plasmicpkgs/commerce-providers/elastic-path/src/catalog-search/__tests__/cloneWithInjectedHandlers.test.tsx b/plasmicpkgs/commerce-providers/elastic-path/src/catalog-search/__tests__/cloneWithInjectedHandlers.test.tsx new file mode 100644 index 000000000..eb175a8c6 --- /dev/null +++ b/plasmicpkgs/commerce-providers/elastic-path/src/catalog-search/__tests__/cloneWithInjectedHandlers.test.tsx @@ -0,0 +1,71 @@ +/** + * @jest-environment jsdom + */ +import React from "react"; +import { render, fireEvent } from "@testing-library/react"; + +import { cloneWithInjectedHandlers } from "../cloneWithInjectedHandlers"; + +describe("cloneWithInjectedHandlers", () => { + it("injects onClick onto a valid single child element with no existing onClick", () => { + const onClick = jest.fn(); + const cloned = cloneWithInjectedHandlers( + , + { injected: { onClick }, compose: ["onClick"] } + ); + const { getByText } = render(<>{cloned}); + fireEvent.click(getByText("label")); + expect(onClick).toHaveBeenCalledTimes(1); + }); + + it("composes existing onClick before injected onClick", () => { + const calls: string[] = []; + const designer = () => calls.push("designer"); + const injected = () => calls.push("injected"); + const cloned = cloneWithInjectedHandlers( + , + { injected: { onClick: injected }, compose: ["onClick"] } + ); + const { getByText } = render(<>{cloned}); + fireEvent.click(getByText("x")); + expect(calls).toEqual(["designer", "injected"]); + }); + + it("returns the child unchanged for non-element values (string, fragment, array)", () => { + const onClick = jest.fn(); + const opts = { injected: { onClick }, compose: ["onClick"] }; + expect(cloneWithInjectedHandlers("plain text", opts)).toBe("plain text"); + expect(cloneWithInjectedHandlers(null, opts)).toBeNull(); + const arr = [a, b]; + // arrays are not valid single elements — should be returned untouched + expect(cloneWithInjectedHandlers(arr, opts)).toBe(arr); + }); + + it("supports multiple compose keys (runs each designer handler before injected)", () => { + const calls: string[] = []; + const dClick = () => calls.push("d-click"); + const dMouse = () => calls.push("d-mouse"); + const iClick = () => calls.push("i-click"); + const iMouse = () => calls.push("i-mouse"); + const cloned = cloneWithInjectedHandlers( + , + { + injected: { onClick: iClick, onMouseEnter: iMouse }, + compose: ["onClick", "onMouseEnter"], + } + ); + const { getByText } = render(<>{cloned}); + fireEvent.mouseEnter(getByText("z")); + fireEvent.click(getByText("z")); + expect(calls).toEqual(["d-mouse", "i-mouse", "d-click", "i-click"]); + }); + + it("override-only keys replace the existing value (no composition)", () => { + const cloned = cloneWithInjectedHandlers( + , + { injected: { disabled: true } } + ); + const { getByText } = render(<>{cloned}); + expect((getByText("q") as HTMLButtonElement).disabled).toBe(true); + }); +}); diff --git a/plasmicpkgs/commerce-providers/elastic-path/src/catalog-search/cloneWithInjectedHandlers.ts b/plasmicpkgs/commerce-providers/elastic-path/src/catalog-search/cloneWithInjectedHandlers.ts new file mode 100644 index 000000000..b35f10edd --- /dev/null +++ b/plasmicpkgs/commerce-providers/elastic-path/src/catalog-search/cloneWithInjectedHandlers.ts @@ -0,0 +1,53 @@ +/** + * cloneWithInjectedHandlers — Pattern C helper for catalog-search components. + * + * Clones a single React element, injecting behavioral props (e.g. onClick, + * disabled). Compose-keys merge functions: designer's handler runs first, + * then the injected one. Override keys replace whatever was there. + * + * Non-element children (strings, arrays, fragments, null) are returned + * unchanged — fail-open. Designers whose layout breaks auto-injection use + * the per-component context (e.g. $ctx.clearRefinementsData.clear) instead. + */ + +import React from "react"; + +export interface CloneWithInjectedHandlersOptions { + injected: Record; + compose?: string[]; +} + +export function cloneWithInjectedHandlers( + child: React.ReactNode, + options: CloneWithInjectedHandlersOptions +): React.ReactNode { + if (!React.isValidElement(child)) return child; + + const { injected, compose = [] } = options; + const composeSet = new Set(compose); + const existing = (child.props ?? {}) as Record; + + const merged: Record = {}; + for (const key of Object.keys(injected)) { + const injectedVal = injected[key]; + if ( + composeSet.has(key) && + typeof existing[key] === "function" && + typeof injectedVal === "function" + ) { + const designerFn = existing[key] as (...args: unknown[]) => unknown; + const injectedFn = injectedVal as (...args: unknown[]) => unknown; + merged[key] = (...args: unknown[]) => { + designerFn(...args); + injectedFn(...args); + }; + } else { + merged[key] = injectedVal; + } + } + + return React.cloneElement( + child, + merged as Partial & React.Attributes + ); +} diff --git a/plasmicpkgs/commerce-providers/elastic-path/src/catalog-search/design-time-data.ts b/plasmicpkgs/commerce-providers/elastic-path/src/catalog-search/design-time-data.ts index 547b428d0..bc7c034c3 100644 --- a/plasmicpkgs/commerce-providers/elastic-path/src/catalog-search/design-time-data.ts +++ b/plasmicpkgs/commerce-providers/elastic-path/src/catalog-search/design-time-data.ts @@ -324,6 +324,77 @@ export const MOCK_SEARCH_STATS_DATA: SearchStatsData = { summary: '48 results for "leather" in 12ms', }; +// --------------------------------------------------------------------------- +// ClearRefinementsData — shape exposed by EPClearRefinements +// --------------------------------------------------------------------------- + +export interface ClearRefinementsData { + canRefine: boolean; + clear: () => void; +} + +export const MOCK_CLEAR_REFINEMENTS_DATA: ClearRefinementsData = { + canRefine: true, + clear: () => {}, +}; + +export const MOCK_CLEAR_REFINEMENTS_DATA_EMPTY: ClearRefinementsData = { + canRefine: false, + clear: () => {}, +}; + +// --------------------------------------------------------------------------- +// CurrentRefinementChip — shape exposed per-iteration by EPCurrentRefinements +// --------------------------------------------------------------------------- + +export type CurrentRefinementType = + | "facet" + | "exclude" + | "disjunctive" + | "hierarchical" + | "numeric" + | "query" + | "tag"; + +export interface CurrentRefinementChip { + attribute: string; + attributeLabel: string; + type: CurrentRefinementType; + value: string | number; + label: string; + operator?: string; + count?: number; + refine: () => void; +} + +export const MOCK_CURRENT_REFINEMENT_CHIPS: CurrentRefinementChip[] = [ + { + attribute: "brand", + attributeLabel: "Brand", + type: "facet", + value: "leather", + label: "Leather", + refine: () => {}, + }, + { + attribute: "price.USD.float_price", + attributeLabel: "Price", + type: "numeric", + value: 25, + label: "25", + operator: ">=", + refine: () => {}, + }, + { + attribute: "categories", + attributeLabel: "Categories", + type: "hierarchical", + value: "bags > leather", + label: "Bags > Leather", + refine: () => {}, + }, +]; + // --------------------------------------------------------------------------- // SortByData — shape exposed by EPSearchSortBy // --------------------------------------------------------------------------- diff --git a/plasmicpkgs/commerce-providers/elastic-path/src/catalog-search/headless-styling.ts b/plasmicpkgs/commerce-providers/elastic-path/src/catalog-search/headless-styling.ts index 3b0bae398..eeaf496a8 100644 --- a/plasmicpkgs/commerce-providers/elastic-path/src/catalog-search/headless-styling.ts +++ b/plasmicpkgs/commerce-providers/elastic-path/src/catalog-search/headless-styling.ts @@ -25,6 +25,11 @@ const STYLE_BLOCK = ` width: 100%; align-self: stretch; } +:where([data-ep-current-refinements]) { + display: flex; + flex-wrap: wrap; + gap: 8px; +} `; let injected = false; diff --git a/plasmicpkgs/commerce-providers/elastic-path/src/catalog-search/index.ts b/plasmicpkgs/commerce-providers/elastic-path/src/catalog-search/index.ts index 39e14ad19..6e9807726 100644 --- a/plasmicpkgs/commerce-providers/elastic-path/src/catalog-search/index.ts +++ b/plasmicpkgs/commerce-providers/elastic-path/src/catalog-search/index.ts @@ -19,6 +19,14 @@ export { } from "./EPSearchPagination"; export { EPSearchStats, registerEPSearchStats } from "./EPSearchStats"; export { EPSearchSortBy, registerEPSearchSortBy } from "./EPSearchSortBy"; +export { + EPClearRefinements, + registerEPClearRefinements, +} from "./EPClearRefinements"; +export { + EPCurrentRefinements, + registerEPCurrentRefinements, +} from "./EPCurrentRefinements"; export type { CatalogSearchData, @@ -28,4 +36,7 @@ export type { SearchPaginationData, SearchStatsData, SortByData, + ClearRefinementsData, + CurrentRefinementChip, + CurrentRefinementType, } from "./design-time-data"; diff --git a/plasmicpkgs/commerce-providers/elastic-path/src/index.tsx b/plasmicpkgs/commerce-providers/elastic-path/src/index.tsx index dcf599c16..d9feb9565 100644 --- a/plasmicpkgs/commerce-providers/elastic-path/src/index.tsx +++ b/plasmicpkgs/commerce-providers/elastic-path/src/index.tsx @@ -51,6 +51,8 @@ import { registerEPRangeFilter } from "./catalog-search/EPRangeFilter"; import { registerEPSearchPagination } from "./catalog-search/EPSearchPagination"; import { registerEPSearchStats } from "./catalog-search/EPSearchStats"; import { registerEPSearchSortBy } from "./catalog-search/EPSearchSortBy"; +import { registerEPClearRefinements } from "./catalog-search/EPClearRefinements"; +import { registerEPCurrentRefinements } from "./catalog-search/EPCurrentRefinements"; import { registerEPCatalogSearchProvider } from "./catalog-search/EPCatalogSearchProvider"; import { Registerable } from "./registerable"; @@ -155,6 +157,8 @@ export function registerAll(loader?: Registerable) { registerEPHierarchicalMenu(loader); registerEPRangeFilter(loader); registerEPSearchPagination(loader); + registerEPClearRefinements(loader); + registerEPCurrentRefinements(loader); registerEPCatalogSearchProvider(loader); // Legacy monolithic bundle configurator