Skip to content

PRD: EPClearRefinements + EPCurrentRefinements (closes part of #304) + Pattern C adoption #315

Description

@field123

Implements items #1 and #2 from #304 (catalog-search component coverage gaps) and adopts the dominant plasmicpkgs cloneElement injection pattern (henceforth "Pattern C") for catalog-search components. Pagination retro is split into a separate follow-up — see Out of Scope.

Problem Statement

Two affected user types:

Storefront shoppers browsing the catalog-search results page can't:

  • Clear all active filters in one click — they must un-toggle each facet pill individually.
  • See at a glance which filters are active above the grid (no chip row like "Brand: Leather × · Price ≥ $25 ×").

Designers in Plasmic Studio building those storefronts can't drop in primitives for either UX, because EPClearRefinements and EPCurrentRefinements don't exist yet (see #304 items #1 and #2).

There is also a related developer-experience problem: the existing catalog-search components (EPSearchPagination, EPRefinementList, etc.) ship editable default chrome but require designers to manually wire onClick interactions in Studio for every action — clear, refine, paginate. Meanwhile, the dominant pattern across plasmicpkgs (used by AddToCartButton, plasmic-cms, antd5, react-aria, dnd-kit, keen-slider, plasmic-tabs — 12+ call sites across 7 packages) is defaultValue + React.cloneElement to inject behavioral props at render time while keeping the default chrome editable. The catalog-search package hasn't adopted this pattern. The two new components are a natural place to introduce it.

Solution

  1. Ship EPClearRefinements (issue feat(ep-commerce): catalog-search component coverage gaps #304 item feat: add basic ep logo #1) and EPCurrentRefinements (issue feat(ep-commerce): catalog-search component coverage gaps #304 item feat: copilot generative ai for editor app #2) as the first catalog-search components using Pattern C.
  2. Extract a shared cloneWithInjectedHandlers helper as a deep module — small, isolated, reusable across the 5+ components that will adopt the pattern.
  3. Pre-wire onClick (and disabled where applicable) into the default slot, so designers drop the component and it works end-to-end without any Studio interaction wiring. Designers customizing the default chrome (or supplying their own onClick) compose cleanly: the designer's handler runs first, then the injected one.
  4. Pagination retro (split EPSearchPagination from PR feat(ep-commerce): enrich EPSearchPagination default slot with Prev/Next chrome #312 into atomic EPSearchPagePrev / EPSearchPageNext / EPSearchPageIndicator sub-components) is a separate follow-up issue / PR — does not block this work.

User Stories

Storefront shoppers

  1. As a shopper who has applied multiple facet filters, I want a single Clear all action so I can reset the search without un-toggling each filter individually.
  2. As a shopper, I want the Clear all control to be visibly disabled when no filters are active, so I'm not misled into thinking it will do something.
  3. As a shopper, I want a chip row above the results showing each active filter (e.g. "Brand: Leather"), so I can see at a glance what's narrowing my view.
  4. As a shopper, I want each chip to dismiss its specific refinement when I click it, so I can drop one filter without affecting the others.
  5. As a shopper, I want the chip row to disappear entirely when no filters are active, so the layout doesn't leave an empty band above the grid.
  6. As a shopper using a price range, I want my range to display readably (e.g. "≥ $25"), not as a raw value.
  7. As a shopper using hierarchical category filters, I want the chip to reflect the full path (e.g. "Bags > Leather"), so I know exactly what's selected.

Designers in Plasmic Studio

  1. As a designer, I want to drop EPClearRefinements onto a search page and see a working Clear all button on the canvas immediately, without going to Interactions to wire onClick.
  2. As a designer, I want to rename the default "Clear all" text to "Reset filters" (or any localized equivalent) without losing the wired behavior.
  3. As a designer, I want to replace the default button with my own custom button or styled element, and have the clear action still trigger when the shopper clicks it.
  4. As a designer building a custom layout (e.g. clear control inside a popover), I want to wire onClick to $ctx.clearRefinementsData.clear myself and have it work even when my structure breaks the auto-injection.
  5. As a designer, I want to drop EPCurrentRefinements and see a styled chip on the canvas immediately, repeating per active refinement, with dismiss-on-click already wired.
  6. As a designer, I want to bind chip text to the per-item context ($ctx.currentRefinementChip.attributeLabel, .label, etc.) so I can format chips like "Brand: Leather".
  7. As a designer, I want to know the refinement type (facet, numeric, hierarchical, etc.) per chip, so I can conditionally style range chips differently from facet chips.
  8. As a designer, I want to limit which attributes appear in clear or chip components via includedAttributes / excludedAttributes props, mirroring react-instantsearch's own controls.

Storefront / composer-app developers

  1. As a developer wiring a non-child element (e.g. a sidebar "Reset" link rendered outside the chip slot) to the clear-all action, I want a clear() ref-action on EPClearRefinements, mirroring the pattern used by EPSearchPagination's prevPage / nextPage from PR feat(ep-commerce): enrich EPSearchPagination default slot with Prev/Next chrome #312.
  2. As a developer, I want a Studio canvas preview of both components rendering with mock data when previewing in the editor, with the option to opt out via a previewState prop, matching the existing auto / withData pattern from EPSearchStats, EPSearchPagination, and EPRefinementList.

Pattern adoption (forward-looking)

  1. As a maintainer extending the catalog-search package with future components (autocomplete, hits-per-page, infinite hits, etc. from feat(ep-commerce): catalog-search component coverage gaps #304), I want a shared cloneWithInjectedHandlers helper so I'm not re-implementing composed-onClick logic per component.
  2. As a maintainer, I want the catalog-search components' Pattern C usage to look identical to commerce-providers/commerce's AddToCartButton, so anyone reading either area sees a consistent precedent.

Implementation Decisions

Pattern C — defaultValue + React.cloneElement

This PRD adopts the pattern already used by:

  • plasmicpkgs/commerce-providers/commerceregisterAddToCartButton.tsx (closest spiritual sibling — single button injecting onClick), registerProductQuantity.tsx (form-field props injection)
  • plasmicpkgs/plasmic-cmscomponents.tsx (4 call sites; injects href, src, value from CMS data into editable defaults)
  • plasmicpkgs/antd5registerBreadcrumb.tsx, registerCollapse.tsx, registerPagination.tsx, form/FormItem.tsx
  • plasmicpkgs/react-ariautils.ts, registerSliderTrack.tsx
  • plasmicpkgs/dnd-kitsortable.tsx (refs, drag transforms, listeners)
  • plasmicpkgs/keen-sliderindex.tsx
  • plasmicpkgs/plasmic-tabstabs.tsx

Why now: the PlasmicElement attrs schema in packages/host/src/element-types.ts only accepts static string attributes (disabled, className, id, aria-*, tabIndex, title, role). It cannot express onClick or any expression-bound prop. Pattern C is the canonical workaround across the ecosystem.

Modules

New: shared cloneWithInjectedHandlers helper (deep module)

A single exported function in a new module under plasmicpkgs/commerce-providers/elastic-path/src/catalog-search/. Signature:

  • Inputs: a ReactNode child, an object of injected props, a list of prop keys to compose (run designer's handler first, then injected) versus override (replace).
  • Output: a cloned React element with composed/overridden props, OR the input child unchanged if it isn't a single valid React element (fail-open).

Deep-module shape (per the Ousterhout sense): one small, stable interface; encapsulates the React.isValidElement guard, the prop composition, and the fail-open semantics; used by 5+ call sites; testable in isolation.

New: EPClearRefinements component

  • Wraps useClearRefinements() from react-instantsearch.
  • Props (designer-facing): children (slot, single button by default), includedAttributes?: string[], excludedAttributes?: string[], className?, previewState?: "auto" | "withData".
  • Default slot: a single <button>Clear all</button> element.
  • Renders a wrapper <div className={className} data-ep-clear-refinements=""> containing the cloned child with onClick (composed with designer's onClick if any) and disabled = !canRefine injected. Multi-element / fragment children render as-is (fail-open).
  • Publishes DataProvider name="clearRefinementsData" with { canRefine, clear }. The clear function on context is the escape hatch for designers whose layout breaks auto-injection.
  • Exposes a clear() ref-action so non-child elements can trigger it (parity with other catalog-search components).
  • Always rendered (never auto-hidden) — the disabled state is the "can't refine right now" signal, preserving layout stability.
  • parentComponentName: "plasmic-commerce-ep-catalog-search-provider" (matches existing primitives).

New: EPCurrentRefinements component

  • Wraps useCurrentRefinements() from react-instantsearch. The hook returns nested data (items per attribute, refinements per value); this component flattens to one chip per (attribute, value) pair.
  • Props: children (slot, single chip-shaped element by default), includedAttributes?, excludedAttributes? (Algolia default ['query'] is preserved when unset), className?, previewState?.
  • Default slot: a single hbox chip (rounded border, padding, gap) containing two text elements ("Brand: Leather" + "×"). All styles use longhand only (no shorthand padding / borderRadius) — defensive against known Plasmic codepath bugs with shorthand styles.
  • Per-iteration DataProvider name="currentRefinementChip" publishes { attribute, attributeLabel, type, value, label, operator?, count?, refine }. refine is zero-arg, pre-bound to remove this specific refinement (the underlying hook's refine takes a refinement object — pre-binding is the designer-friendly affordance). Companion currentRefinementChipIndex ctx for repeater index access.
  • Renders a wrapper <div className={className} data-ep-current-refinements="" role="list"> with the repeated, cloned chip inside <div role="listitem"> per refinement.
  • Each repeated chip is cloned with onClick (composed) injected — top-level clone, click bubbles from inner text/×.
  • Returns null when no refinements are active (the chip row hides entirely — issue spec).
  • No ref-actions. Clearing all is EPClearRefinements's job; per-chip refine() is on per-item ctx.
  • parentComponentName: "plasmic-commerce-ep-catalog-search-provider".

Modified: headless-styling.ts

Adds one zero-specificity :where() rule for [data-ep-current-refinements] setting display: flex; flex-wrap: wrap; gap: 8px;. This makes the default chip row lay out as a wrapped row out of the box; designer-supplied className always wins. No CSS for EPClearRefinements (single button — no layout concern).

Modified: design-time-data.ts

Adds:

  • MOCK_CLEAR_REFINEMENTS_DATA (with both canRefine: true and canRefine: false variants for preview / test).
  • MOCK_CURRENT_REFINEMENT_CHIPS — three mixed sample chips: one facet ("Brand: Leather"), one numeric ("Price ≥ 25"), one hierarchical ("Bags > Leather") — exercises the union of type values designers will encounter.

Existing CatalogSearchData / RefinementItem / etc. types extended to publish the new chip shape (CurrentRefinementChip).

Modified: index.ts and centralized registerAll

Exports EPClearRefinements + registerEPClearRefinements, EPCurrentRefinements + registerEPCurrentRefinements, and the new chip / clear data types. Centralized registration helper updated to register both.

API / data contracts

  • clearRefinementsData = { canRefine: boolean, clear: () => void }
  • currentRefinementChip = { attribute: string, attributeLabel: string, type: 'facet' | 'exclude' | 'disjunctive' | 'hierarchical' | 'numeric' | 'query' | 'tag', value: string | number, label: string, operator?: string, count?: number, refine: () => void }
  • currentRefinementChipIndex = number
  • Both components accept includedAttributes and excludedAttributes as string[] props, passed through to the underlying react-instantsearch hooks unchanged.
  • previewState: "auto" | "withData" matches the established pattern across catalog-search components.

Architectural decisions

  • Flat repeater for chips (one chip per refinement value, attributes flattened) rather than nested per-attribute groups. Issue feat(ep-commerce): catalog-search component coverage gaps #304 spec endorses this. A nested variant (EPCurrentRefinementsByAttribute) can be added later if a real grouping use-case emerges.
  • Pre-bound zero-arg refine on per-item ctx (rather than exposing the raw hook signature refine(refinement)). Friendlier in Studio interactions.
  • Composed onClick (designer's handler runs first, then injected) — matches AddToCartButton semantics.
  • Fail-open on multi-element children — render as-is (don't return null like AddToCartButton); the per-item / data-provider context is the documented escape hatch for designers whose structure defeats auto-injection.
  • Wrap children in a <div> with className and data-ep-* attributes, even though AddToCartButton doesn't (which loses className) — corrects this gap by-design in our adoption.
  • No backporting of these improvements to AddToCartButton (different package, different scope). The buggy behavior there can be addressed separately.

Testing Decisions

A good test asserts external behavior: which DataProvider contexts are published, what data they carry, that clicks trigger the underlying hook actions, that designer-supplied onClick is called before the injected one, and that the fail-open path renders without crashing. A test that snapshots the exact DOM of the default-slot wrapper or that asserts internal helper return shapes is testing implementation details and should be avoided.

Modules tested

  • cloneWithInjectedHandlers helper — isolated unit tests against its single function. Cases: valid single child + onClick injection; valid child with existing onClick → composition order; non-element child (string, fragment, array) → fail-open passthrough; multiple compose keys; override-only keys.
  • EPClearRefinements — integration tests with mocked react-instantsearch.useClearRefinements. Cases: golden path (ctx published, default child cloned, click triggers refine); canRefine: false → injected disabled: true; multi-element children → fail-open render, no crash, ctx still published; designer-supplied onClick on child → composed (designer runs first, then injected); preview-mock path renders without invoking hooks.
  • EPCurrentRefinements — same approach with mocked useCurrentRefinements. Cases: golden path (flatten + repeat + ctx + click → refine for THAT refinement); empty state (no refinements) → returns null; mixed refinement types (facet + numeric + hierarchical) all render with correct type, operator, label; designer-supplied onClick on chip → composed; multi-element repeated child → fail-open; preview-mock path.

Prior art

plasmicpkgs/commerce-providers/elastic-path/src/catalog-search/__tests__/catalog-search-components.test.tsx already covers EPSearchStats, EPRefinementList, EPSearchPagination, etc. with exactly this approach: mock react-instantsearch hooks, use @testing-library/react to assert published context values via probe components, and assert click handlers fire the mocked refine functions. New tests slot into that file alongside the existing ones; no new test infrastructure needed.

Out of Scope

This PRD intentionally excludes:

Further Notes

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions