Skip to content

Add new interface for storing f filter state#5996

Draft
witoszekdev wants to merge 1 commit intomainfrom
eng-695-add-filter-value-controller
Draft

Add new interface for storing f filter state#5996
witoszekdev wants to merge 1 commit intomainfrom
eng-695-add-filter-value-controller

Conversation

@witoszekdev
Copy link
Member

@witoszekdev witoszekdev commented Oct 21, 2025

Scope of the change

This PR introduces new TypeScript interfaces to separate two tasks of current FilterValueProvider:

  1. holding filter state from user selection, so that it can be used for fetching data
  2. persisting filter state so it can be re-created (e.g. after page refresh from URL)

Holding canonical state of filters will be separate of persisting the state. It's done by introducing new persistence strategies: in URL (list pages) and in-memory (for modals). Implementation will be done in next PR (note for URL we will just wrap our existing logic, to avoid breaking changes)

Added

PersistenceStrategy

Base interface for pluggable persistence implementations with persist() and clear() methods.

FilterValueController

Main controller interface matching the existing FilterValueProvider API to ensure backward compatibility.

Why

Our current implementation treats URLs as source of truth for filter state. We don't use URL state for modals, where we want to introduce filters in. This also has drawbacks: because we treat it as source of truth we need to always rehydrate state by fetching options (e.g. reference attributes values) from API, even though we have already fetched this data in the filter UI itself.

How ConditionalFilter work right now

┌─────────────────────────────────────────────────────────────────────────┐
│                        USER INTERACTS WITH FILTERS                      │
└─────────────────────────────────────────────────────────────────────────┘
                                    ↓
┌─────────────────────────────────────────────────────────────────────────┐
│                      ConditionalFilter Component                        │
│  ┌────────────────────────────────────────────────────────────────┐    │
│  │  FilterRow: "Category" = "Electronics"                         │    │
│  │  FilterRow: "Price" between "$100 - $500"                      │    │
│  │  FilterRow: "Vendor" in ["Acme Corp", "TechCo"]               │    │
│  └────────────────────────────────────────────────────────────────┘    │
└─────────────────────────────────────────────────────────────────────────┘
                                    ↓
┌─────────────────────────────────────────────────────────────────────────┐
│                   ConditionalFilterContext                              │
│                                                                         │
│  Provides 5 pieces of state:                                           │
│  1. apiProvider        - Fetches metadata (categories, vendors, etc.)  │
│  2. valueProvider      - Manages filter state & persistence            │
│  3. leftOperandsProvider - Available filter types                      │
│  4. containerState     - Local CRUD operations on filters              │
│  5. filterWindow       - UI state (open/closed)                        │
└─────────────────────────────────────────────────────────────────────────┘
                                    ↓
┌─────────────────────────────────────────────────────────────────────────┐
│                        useUrlValueProvider                              │
│                     (FilterValueProvider interface)                     │
│                                                                         │
│  State:                                                                 │
│  • value: FilterContainer  ← Canonical filter state                    │
│  • loading: boolean        ← Data fetching state                       │
│  • count: number          ← Number of active filters                   │
│                                                                         │
│  Methods:                                                               │
│  • persist(FilterContainer) → Updates URL + internal state             │
│  • clear()                  → Clears URL + resets state                │
│  • isPersisted(element)     → Checks if filter exists                  │
│  • getTokenByName(name)     → Returns URL token for lookups            │
└─────────────────────────────────────────────────────────────────────────┘
                                    ↓
                        ┌───────────┴───────────┐
                        ↓                       ↓
┌──────────────────────────────┐  ┌───────────────────────────────┐
│   URL State (Browser)        │  │  Internal State (React)       │
│                              │  │                               │
│  /products?                  │  │  value: [                     │
│    s0.category=electronics   │  │    FilterElement {            │
│    &n1.price=100,500         │  │      value: "category",       │
│    &r2.vendor=acme,techco    │  │      condition: "electronics" │
│                              │  │    },                         │
│  ↑                           │  │    FilterElement {            │
│  router.history.replace()    │  │      value: "price",          │
│                              │  │      condition: [100, 500]    │
│                              │  │    }                          │
│                              │  │  ]                            │
└──────────────────────────────┘  └───────────────────────────────┘
            ↓
┌─────────────────────────────────────────────────────────────────────────┐
│                       URL Parsing on Page Load                          │
│                                                                         │
│  1. Read URL params: ?s0.category=electronics&n1.price=100,500         │
│  2. Parse into TokenArray (array of UrlToken objects)                  │
│  3. Fetch metadata for tokens (category names, vendor details, etc.)   │
│  4. Convert tokens → FilterContainer (FilterElement objects)           │
│  5. Render ConditionalFilter UI with hydrated state                    │
└─────────────────────────────────────────────────────────────────────────┘
            ↓
┌─────────────────────────────────────────────────────────────────────────┐
│                    FiltersQueryBuilder                                  │
│                                                                         │
│  Converts FilterContainer → GraphQL query variables:                   │
│                                                                         │
│  {                                                                      │
│    where: {                                                             │
│      categories: ["electronics"],                                      │
│      price: { gte: 100, lte: 500 },                                    │
│      attributes: [                                                      │
│        {                                                                │
│          slug: "vendor",                                                │
│          values: {                                                      │
│            reference: {                                                 │
│              referencedIds: { containsAny: ["acme", "techco"] }         │
│            }                                                             │
│          }                                                              │
│        }                                                                │
│      ]                                                                  │
│    }                                                                    │
│  }                                                                      │
└─────────────────────────────────────────────────────────────────────────┘
            ↓
┌─────────────────────────────────────────────────────────────────────────┐
│                       Apollo GraphQL Query                              │
│                                                                         │
│  query SearchProducts($where: ProductWhereInput) {                     │
│    products(where: $where) { ... }                                     │
│  }                                                                      │
└─────────────────────────────────────────────────────────────────────────┘
            ↓
┌─────────────────────────────────────────────────────────────────────────┐
│                      Filtered Product List                              │
└─────────────────────────────────────────────────────────────────────────┘

New approach

┌─────────────────────────────────────────────────────────────────────────┐
│                     useFilterValueController                            │
│                        (SHARED CORE LOGIC)                              │
│                                                                         │
│  Responsibilities:                                                      │
│  1. Maintain canonical FilterContainer state                           │
│  2. Mirror state as UrlToken[] for getTokenByName compatibility        │
│  3. Expose FilterValueProvider interface                               │
│  4. Delegate persistence to pluggable strategy                         │
│  5. Manage lifecycle subscriptions safely (Strict Mode ready)          │
│                                                                         │
│  Configuration:                                                         │
│  • persistenceStrategy: PersistenceStrategy  ← PLUGGABLE!              │
│  • initialValue: FilterContainer                                       │
│  • initialLoading: boolean                                             │
│  • onChange: (FilterContainer) => void                                 │
└─────────────────────────────────────────────────────────────────────────┘
                                    ↓
                        ┌───────────┴───────────┐
                        ↓                       ↓
┌──────────────────────────────┐  ┌───────────────────────────────┐
│  UrlPersistenceStrategy      │  │  InMemoryPersistenceStrategy  │
│  (For List Pages)            │  │  (For Modals)                 │
│                              │  │                               │
│  persist(value, tokens):     │  │  persist(value, tokens):      │
│    • Convert to URL tokens   │  │    • Store in React state     │
│    • Build query string      │  │    • No URL changes!          │
│    • Call router.replace()   │  │    • Call onChange callback   │
│                              │  │                               │
│  clear():                    │  │  clear():                     │
│    • Navigate to clean URL   │  │    • Reset local state        │
│    • Clear query params      │  │    • Modal remains open       │
└──────────────────────────────┘  └───────────────────────────────┘

List page example

┌─────────────────────────────────────────────────────────────────────────┐
│                        PRODUCT LIST PAGE                                │
└─────────────────────────────────────────────────────────────────────────┘
                                    ↓
┌─────────────────────────────────────────────────────────────────────────┐
│  const locationSearch = useLocation().search;                           │
│  const router = useRouter();                                            │
│                                                                         │
│  // Create URL persistence strategy                                    │
│  const urlStrategy = new UrlPersistenceStrategy({                      │
│    history: router.history,                                            │
│    location: router.location,                                          │
│    preservedParams: { activeTab, query, before, after }                │
│  });                                                                    │
│                                                                         │
│  // Use controller with URL strategy                                   │
│  const controller = useFilterValueController({                         │
│    persistenceStrategy: urlStrategy,                                   │
│    initialValue: parseUrlToFilters(locationSearch),                    │
│    initialLoading: false                                               │
│  });                                                                    │
│                                                                         │
│  // Pass to ConditionalFilter (same as before!)                        │
│  return (                                                               │
│    <ConditionalFilterProvider valueProvider={controller}>              │
│      <ProductList />                                                   │
│    </ConditionalFilterProvider>                                        │
│  );                                                                     │
└─────────────────────────────────────────────────────────────────────────┘
                                    ↓
                   ┌────────────────┴────────────────┐
                   ↓                                 ↓
┌──────────────────────────────┐  ┌─────────────────────────────────┐
│  User adds filter            │  │  Controller state updates       │
│  "Category = Electronics"    │  │  value: [FilterElement {...}]   │
└──────────────────────────────┘  └─────────────────────────────────┘
                   ↓                                 ↓
┌──────────────────────────────┐  ┌─────────────────────────────────┐
│  controller.persist() called │  │  urlStrategy.persist(value,     │
│                              │  │                    tokens)      │
└──────────────────────────────┘  └─────────────────────────────────┘
                   ↓                                 ↓
┌──────────────────────────────────────────────────────────────────┐
│  router.history.replace({                                        │
│    pathname: '/products',                                        │
│    search: '?s0.category=electronics'                            │
│  })                                                               │
└──────────────────────────────────────────────────────────────────┘
                                    ↓
┌─────────────────────────────────────────────────────────────────────────┐
│  URL Updated: /products?s0.category=electronics                         │
│  Browser history entry created                                          │
│  Shareable URL ✓                                                        │
│  Back/Forward navigation works ✓                                        │
└─────────────────────────────────────────────────────────────────────────┘

Modal example

┌─────────────────────────────────────────────────────────────────────────┐
│                    ASSIGN PRODUCT DIALOG (MODAL)                        │
└─────────────────────────────────────────────────────────────────────────┘
                                    ↓
┌─────────────────────────────────────────────────────────────────────────┐
│  // Modal opens with clean state                                        │
│  const [open, setOpen] = useState(false);                               │
│                                                                         │
│  // Create in-memory persistence strategy                              │
│  const memoryStrategy = useMemo(() => new InMemoryPersistenceStrategy(),[]); │
│                                                                         │
│  // Use controller with in-memory strategy                             │
│  const controller = useFilterValueController({                         │
│    persistenceStrategy: memoryStrategy,                                │
│    initialLoading: false                                               │
│  });                                                                    │
│                                                                         │
│  // Cleanup on modal close                                             │
│  useEffect(() => {                                                      │
│    if (!open) {                                                         │
│      controller.clear();  // Reset filters when modal closes           │
│    }                                                                    │
│  }, [open]);                                                            │
│                                                                         │
│  return (                                                               │
│    <Dialog open={open}>                                                │
│      <ConditionalFilterProvider valueProvider={controller}>            │
│        <ProductList />                                                 │
│      </ConditionalFilterProvider>                                      │
│    </Dialog>                                                            │
│  );                                                                     │
└─────────────────────────────────────────────────────────────────────────┘
                                    ↓
                   ┌────────────────┴────────────────┐
                   ↓                                 ↓
┌──────────────────────────────┐  ┌─────────────────────────────────┐
│  User adds filter            │  │  Controller state updates       │
│  "Vendor = Acme Corp"        │  │  value: [FilterElement {...}]   │
└──────────────────────────────┘  └─────────────────────────────────┘
                   ↓                                 ↓
┌──────────────────────────────┐  ┌─────────────────────────────────┐
│  controller.persist() called │  │  memoryStrategy.persist(value,  │
│                              │  │                      tokens)    │
└──────────────────────────────┘  └─────────────────────────────────┘
                   ↓                                 ↓
┌──────────────────────────────────────────────────────────────────┐
│  // Store in internal Map                                        │
│  this.state.set('filters', value);                               │
│                                                                  │
│  // Call onChange callback                                       │
│  if (this.config.onStateChange) {                                │
│    this.config.onStateChange(value);                             │
│  }                                                                │
│                                                                  │
│  // NO URL CHANGES! ✓                                            │
└──────────────────────────────────────────────────────────────────┘
                                    ↓
┌─────────────────────────────────────────────────────────────────────────┐
│  URL Unchanged: /collections/123 (whatever page opened the modal)       │
│  Filter state only in React memory                                      │
│  Not shareable (modal-specific) ✓                                       │
│  Cleared on modal close ✓                                               │
└─────────────────────────────────────────────────────────────────────────┘

Copilot AI review requested due to automatic review settings October 21, 2025 16:43
@changeset-bot
Copy link

changeset-bot bot commented Oct 21, 2025

⚠️ No Changeset found

Latest commit: f0a424e

Merging this PR will not cause a version bump for any packages. If these changes should not result in a new version, you're good to go. If these changes should result in a version bump, you need to add a changeset.

This PR includes no changesets

When changesets are added to this PR, you'll see the packages that this PR includes changesets for and the associated semver types

Click here to learn what changesets are, and how to add one.

Click here if you're a maintainer who wants to add a changeset to this PR

@vercel
Copy link

vercel bot commented Oct 21, 2025

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Preview Comments Updated (UTC)
saleor-dashboard-storybook Building Building Preview Comment Oct 21, 2025 4:43pm

💡 Enable Vercel Agent with $100 free credit for automated AI reviews

Copy link
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull Request Overview

This PR introduces TypeScript interfaces for a pluggable filter state persistence architecture that will support both URL-based and in-memory storage strategies.

Key Changes:

  • Defines PersistenceStrategy interface with persist() and clear() methods
  • Introduces FilterValueController interface matching the existing FilterValueProvider API for backward compatibility
  • Adds skeleton implementations for UrlPersistenceStrategy and InMemoryPersistenceStrategy (marked with TODOs)

Reviewed Changes

Copilot reviewed 3 out of 3 changed files in this pull request and generated 2 comments.

File Description
FilterValueController.ts Defines core interfaces (PersistenceStrategy, FilterValueController, FilterValueControllerConfig) establishing the contract for filter state management
UrlPersistenceStrategy.ts Adds URL-based persistence strategy skeleton with configuration for router history and query parameter preservation
InMemoryPersistenceStrategy.ts Adds in-memory persistence strategy skeleton with configuration for initial state and change callbacks

Tip: Customize your code reviews with copilot-instructions.md. Create the file or learn how to get started.

Comment on lines +11 to +12
* @see {@link ./strategies/UrlPersistenceStrategy.ts}
* @see {@link ./strategies/InMemoryPersistenceStrategy.ts}
Copy link

Copilot AI Oct 21, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The @see links reference './strategies/' but the actual files are in './persistenceStrategies/'. Update the paths to '@see {@link ./persistenceStrategies/UrlPersistenceStrategy.ts}' and '@see {@link ./persistenceStrategies/InMemoryPersistenceStrategy.ts}'.

Suggested change
* @see {@link ./strategies/UrlPersistenceStrategy.ts}
* @see {@link ./strategies/InMemoryPersistenceStrategy.ts}
* @see {@link ./persistenceStrategies/UrlPersistenceStrategy.ts}
* @see {@link ./persistenceStrategies/InMemoryPersistenceStrategy.ts}

Copilot uses AI. Check for mistakes.
* Callback when state changes in memory
*/
onStateChange?: (state: FilterContainer) => void;
}
Copy link

Copilot AI Oct 21, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[nitpick] Missing blank line between the closing brace of InMemoryPersistenceStrategyConfig and the JSDoc comment. Add a blank line for consistency with TypeScript formatting conventions.

Suggested change
}
}

Copilot uses AI. Check for mistakes.
@codecov
Copy link

codecov bot commented Oct 21, 2025

Codecov Report

❌ Patch coverage is 0% with 10 lines in your changes missing coverage. Please review.
✅ Project coverage is 39.48%. Comparing base (61cbce3) to head (f0a424e).
⚠️ Report is 1 commits behind head on main.

Files with missing lines Patch % Lines
...rsistenceStrategies/InMemoryPersistenceStrategy.ts 0.00% 5 Missing ⚠️
...er/persistenceStrategies/UrlPersistenceStrategy.ts 0.00% 5 Missing ⚠️
Additional details and impacted files
@@            Coverage Diff             @@
##             main    #5996      +/-   ##
==========================================
- Coverage   39.49%   39.48%   -0.01%     
==========================================
  Files        2429     2431       +2     
  Lines       39518    39528      +10     
  Branches     8711     8711              
==========================================
  Hits        15609    15609              
- Misses      23883    23893      +10     
  Partials       26       26              

☔ View full report in Codecov by Sentry.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.
  • 📦 JS Bundle Analysis: Save yourself from yourself by tracking and limiting bundle sizes in JS merges.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants