Skip to content

Refactored filter value loading#26986

Open
kevinansfield wants to merge 10 commits intomainfrom
codex/filter-search-refactor
Open

Refactored filter value loading#26986
kevinansfield wants to merge 10 commits intomainfrom
codex/filter-search-refactor

Conversation

@kevinansfield
Copy link
Copy Markdown
Member

ref https://linear.app/ghost/issue/BER-3334/

What changed

  • introduced field-owned value sources in shade for dynamic select/multiselect filters
  • removed the legacy filter search bridge from shade
  • added resource-specific filter sources in posts
  • migrated comments, members, label picker, PostAnalytics, and Stats filter flows to the new contract
  • removed obsolete comments and members filter search hooks
  • added focused shade coverage for the value source path

Why

This refactor moves async option lookup behind a simpler, more idiomatic boundary so the filter UI no longer needs to carry search orchestration state or strategy-specific behavior.

ref https://linear.app/ghost/issue/BER-3334/

Moved async filter lookups behind field-owned value sources so filter UIs no longer carry search orchestration and the remaining comments, members, label picker, and stats flows can share a simpler contract.
@coderabbitai
Copy link
Copy Markdown
Contributor

coderabbitai bot commented Mar 26, 2026

Note

Reviews paused

It looks like this branch is under active development. To avoid overwhelming you with review comments due to an influx of new commits, CodeRabbit has automatically paused this review. You can configure this behavior by changing the reviews.auto_review.auto_pause_after_reviewed_commits setting.

Use the following commands to manage reviews:

  • @coderabbitai resume to resume automatic reviews.
  • @coderabbitai review to trigger a single review.

Use the checkboxes below for quick actions:

  • ▶️ Resume reviews
  • 🔍 Trigger review

Walkthrough

This pull request introduces a ValueSource abstraction pattern for dynamic option loading across the application. It adds infinite query hooks and parameterized query functions to the API layer (labels, newsletters, pages, posts, tiers), implements new filter source hooks for members, labels, posts, tiers, and emails, and refactors existing filtering components to use ValueSource instead of managing static options and search state independently. The label picker is enhanced with controlled search capabilities and resolved label tracking. Related utility functions for filtering and merging options are introduced, and deprecated filter option utilities are removed. The shade UI filters component is updated to support the ValueSource interface.

🚥 Pre-merge checks | ✅ 2 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 0.00% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (2 passed)
Check name Status Explanation
Description check ✅ Passed The description is directly related to the changeset, explaining the motivation, scope, and benefits of the refactor. It references specific files and architectural goals.
Title check ✅ Passed The title 'Refactored filter value loading' is concise and directly captures the primary architectural change: refactoring how filter options are loaded and provided to the UI layer.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch codex/filter-search-refactor

Comment @coderabbitai help to get the list of available commands and usage tips.

@github-actions github-actions bot added the migration [pull request] Includes migration for review label Mar 26, 2026
@kevinansfield kevinansfield changed the base branch from filter-search-refactor to main March 26, 2026 14:15
@TryGhost TryGhost deleted a comment from github-actions bot Mar 26, 2026
ref https://linear.app/ghost/issue/BER-3334/

Removed redundant assertions, tightened prop immutability, and simplified value resolution so the PR-specific Sonar findings in the new filter value source path are addressed without changing behavior.
Copy link
Copy Markdown
Contributor

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 1

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
apps/posts/src/views/PostAnalytics/components/stats-filter.tsx (1)

159-174: ⚠️ Potential issue | 🟡 Minor

Unused function: applySelectedPostFilter.

This function is defined but never used in this file. The comment at line 309 confirms there's no 'post' filter in the PostAnalytics context. This appears to be dead code, possibly copied from the stats app version.

🗑️ Proposed removal
-const applySelectedPostFilter = (params: Record<string, string>, selectedValue?: string) => {
-    if (!selectedValue) {
-        return;
-    }
-
-    if (selectedValue.startsWith('/')) {
-        params.pathname = selectedValue;
-        delete params.post_uuid;
-        return;
-    }
-
-    params.post_uuid = selectedValue;
-    delete params.pathname;
-};
-
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@apps/posts/src/views/PostAnalytics/components/stats-filter.tsx` around lines
159 - 174, The file contains an unused dead function applySelectedPostFilter
which is never referenced (and a nearby comment notes there is no 'post' filter
in this context); remove the applySelectedPostFilter function declaration and
any associated types/exports/imports exclusively used by it to eliminate dead
code and simplify the module, leaving other filter-related helpers like
buildFilterParams intact.
🧹 Nitpick comments (12)
apps/posts/src/hooks/filter-sources/use-email-post-value-source.ts (1)

32-39: Minor: buildQuotedListFilter is called twice.

The filter is computed twice - once for the conditional check and once for the value. Consider computing it once and reusing.

♻️ Suggested refactor
+        const hydratedFilter = buildQuotedListFilter('id', selectedValues);
         const hydrated = useBrowsePosts({
             enabled: selectedValues.length > 0,
             searchParams: {
                 limit: '25',
                 fields: 'id,title',
-                ...(buildQuotedListFilter('id', selectedValues) ? {filter: buildQuotedListFilter('id', selectedValues)} : {})
+                ...(hydratedFilter ? {filter: hydratedFilter} : {})
             }
         });
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@apps/posts/src/hooks/filter-sources/use-email-post-value-source.ts` around
lines 32 - 39, The code calls buildQuotedListFilter('id', selectedValues) twice
when constructing the searchParams for useBrowsePosts; compute the filter once
into a local const (e.g. const idFilter = buildQuotedListFilter('id',
selectedValues)) and then use idFilter in the conditional and the object value
so useBrowsePosts only receives the filter once, keeping selectedValues,
buildQuotedListFilter and useBrowsePosts as the referenced symbols to locate the
change.
apps/posts/src/hooks/filter-sources/use-post-resource-value-source.ts (1)

47-62: Minor: buildQuotedListFilter is called twice for each hydrated query.

Same pattern as in use-email-post-value-source.ts - the filter is computed twice per query. Consider computing once and reusing.

♻️ Suggested refactor
+        const hydratedFilter = buildQuotedListFilter('id', selectedValues);
         const hydratedPostBrowse = useBrowsePosts({
             enabled: selectedValues.length > 0,
             searchParams: {
                 limit: '25',
                 fields: 'id,title',
-                ...(buildQuotedListFilter('id', selectedValues) ? {filter: buildQuotedListFilter('id', selectedValues)} : {})
+                ...(hydratedFilter ? {filter: hydratedFilter} : {})
             }
         });
         const hydratedPageBrowse = useBrowsePages({
             enabled: selectedValues.length > 0,
             searchParams: {
                 limit: '25',
                 fields: 'id,title',
-                ...(buildQuotedListFilter('id', selectedValues) ? {filter: buildQuotedListFilter('id', selectedValues)} : {})
+                ...(hydratedFilter ? {filter: hydratedFilter} : {})
             }
         });
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@apps/posts/src/hooks/filter-sources/use-post-resource-value-source.ts` around
lines 47 - 62, Compute the filter once and reuse it instead of calling
buildQuotedListFilter('id', selectedValues) twice for each query: create a local
const (e.g., const idFilter = buildQuotedListFilter('id', selectedValues))
before the useBrowsePosts/useBrowsePages calls, then use idFilter in the
searchParams spread (e.g., ...(idFilter ? { filter: idFilter } : {})) for both
hydratedPostBrowse and hydratedPageBrowse so the filter is evaluated only once
and the code is clearer.
apps/stats/src/views/Stats/components/stats-filter.tsx (1)

607-607: Minor: Function uses declaration syntax inconsistently.

The StatsFilter function at line 369 uses function declaration syntax but ends with a semicolon at line 607 (};), which is typically used for function expressions. This is valid JavaScript but stylistically inconsistent.

♻️ Remove trailing semicolon
-};
+}
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@apps/stats/src/views/Stats/components/stats-filter.tsx` at line 607, The
StatsFilter function is declared using function declaration syntax but ends with
a stray trailing semicolon (`};`); remove the trailing semicolon to make the
declaration style consistent (or alternatively convert the function declaration
to a function expression/arrow assigned to a const named StatsFilter if you
prefer expression style). Locate the StatsFilter function and either delete the
final semicolon after the closing brace or refactor the declaration into a const
StatsFilter = (...) => { ... } form so syntax and style are consistent.
apps/posts/src/hooks/filter-sources/use-newsletter-value-source.ts (1)

6-18: Unstable memoization due to recreated function reference.

The useNewsletterValueSourceOptions function is recreated on every render, causing the outer useMemo at line 15-18 to always recompute since it depends on [useNewsletterValueSourceOptions]. Additionally, the options parameter is captured in the closure but not tracked in any dependency array.

Consider wrapping the inner function with useCallback or restructuring to achieve stable references:

♻️ Proposed fix using useCallback
 export function useNewsletterValueSource(options: FilterOption<string>[] = []): ValueSource<string> {
-    const useNewsletterValueSourceOptions = ({query}: ValueSourceParams<string>): ValueSourceResult<string> => {
+    const useNewsletterValueSourceOptions = useCallback(({query}: ValueSourceParams<string>): ValueSourceResult<string> => {
         const visibleOptions = useMemo(() => filterOptionsByQuery(options, query), [options, query]);
 
         return {
             options: visibleOptions,
             isLoading: false
         };
-    };
+    }, [options]);
 
     return useMemo(() => ({
         id: 'posts.newsletters.local',
         useOptions: useNewsletterValueSourceOptions
     }), [useNewsletterValueSourceOptions]);
 }

Note: You'll need to add useCallback to the imports from react.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@apps/posts/src/hooks/filter-sources/use-newsletter-value-source.ts` around
lines 6 - 18, The inner function useNewsletterValueSourceOptions is recreated
every render, causing the outer useMemo that returns { id:
'posts.newsletters.local', useOptions: useNewsletterValueSourceOptions } to
always recompute; wrap useNewsletterValueSourceOptions in React's useCallback so
it has a stable reference and include all captured values (e.g., options and
filterOptionsByQuery) in its dependency array, then keep the outer useMemo
dependent on that stable callback; also add useCallback to the React imports to
avoid missing import errors.
apps/posts/src/hooks/filter-sources/use-member-value-source.ts (2)

28-30: Simplify conditional filter construction.

The conditional spread ...(buildQuotedListFilter('id', selectedValues) ? {filter: buildQuotedListFilter('id', selectedValues)} : {}) calls buildQuotedListFilter twice. Consider computing once and spreading conditionally:

♻️ Proposed simplification
+        const idFilter = buildQuotedListFilter('id', selectedValues);
         const hydrated = useBrowseMembers({
             enabled: selectedValues.length > 0,
             searchParams: {
                 limit: '100',
-                ...(buildQuotedListFilter('id', selectedValues) ? {filter: buildQuotedListFilter('id', selectedValues)} : {})
+                ...(idFilter && {filter: idFilter})
             }
         });
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@apps/posts/src/hooks/filter-sources/use-member-value-source.ts` around lines
28 - 30, Compute the result of buildQuotedListFilter('id', selectedValues') once
and reuse it instead of calling it twice; e.g., assign its return to a local
variable (e.g., filterValue) inside use-member-value-source.ts and then
conditionally spread {filter: filterValue} only when filterValue is truthy,
updating the object that currently contains limit: '100' and the spread
expression.

47-50: Empty dependency array creates stale closure.

The outer useMemo has an empty dependency array [], but useMemberValueSourceOptions is defined inside the hook and captures no external state. However, this pattern is inconsistent with similar hooks (e.g., useNewsletterValueSource which includes the inner function in deps). The empty array here happens to work because the inner function has no dependencies that change, but it's fragile and inconsistent.

For consistency and clarity, either:

  1. Move useMemberValueSourceOptions outside the hook (if it doesn't need closure variables), or
  2. Use useCallback for the inner function and include it in the dependency array
♻️ Suggested consistent pattern
 export function useMemberValueSource(): ValueSource<string> {
-    const useMemberValueSourceOptions = ({query, selectedValues}: ValueSourceParams<string>): ValueSourceResult<string> => {
+    const useMemberValueSourceOptions = useCallback(({query, selectedValues}: ValueSourceParams<string>): ValueSourceResult<string> => {
         // ... existing implementation
-    };
+    }, []);
 
     return useMemo(() => ({
         id: 'posts.members.remote',
         useOptions: useMemberValueSourceOptions
-    }), []);
+    }), [useMemberValueSourceOptions]);
 }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@apps/posts/src/hooks/filter-sources/use-member-value-source.ts` around lines
47 - 50, The outer useMemo in use-member-value-source currently uses an empty
dependency array while returning an inner function useMemberValueSourceOptions
defined inside the hook, which is inconsistent and fragile; fix by converting
useMemberValueSourceOptions into a stable reference (either move the function
definition for useMemberValueSourceOptions outside the hook if it does not close
over hook-local variables, or wrap it with useCallback inside the hook and then
include that callback in the useMemo dependency array) so that the returned
object from useMemo stays consistent with patterns like
useNewsletterValueSource.
apps/shade/test/unit/components/ui/filters.test.tsx (2)

107-109: Prefer .toBeInTheDocument() for DOM presence assertions.

Using .toBeDefined() on the result of screen.getByText() will never fail since getByText throws if the element isn't found. Consider using toBeInTheDocument() from @testing-library/jest-dom for clearer intent, or use queryByText if you want to test existence.

♻️ Suggested change
         await waitFor(() => {
             expect(screen.getAllByText('Published').length).toBeGreaterThan(1);
-            expect(screen.getByText('Draft')).toBeDefined();
+            expect(screen.getByText('Draft')).toBeInTheDocument();
         });
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@apps/shade/test/unit/components/ui/filters.test.tsx` around lines 107 - 109,
Replace the ambiguous presence assertion using getByText in the test: instead of
expecting screen.getByText('Draft') to be defined, assert presence explicitly by
using jest-dom's matcher (e.g.,
expect(screen.getByText('Draft')).toBeInTheDocument()) or switch to queryByText
and assert it is truthy; update the assertion in the test case in
apps/shade/test/unit/components/ui/filters.test.tsx where
screen.getByText('Draft') is used.

139-145: Consider using fake timers for deterministic delay testing.

The manual setTimeout inside act() makes the test timing-dependent. Using Vitest's fake timers would be more reliable and faster.

♻️ Proposed fix using fake timers
     it('resets the local query when the popover closes', async () => {
+        vi.useFakeTimers();
         const useOptions = vi.fn(({query, selectedValues}: {query: string; selectedValues: string[]}) => ({
             // ... existing mock
         }));
 
         render(<TestFilters valueSource={{id: 'status', useOptions}} />);
 
         openSelectedValuePopover();
 
         const input = await screen.findByPlaceholderText('Search status...');
         fireEvent.change(input, {target: {value: 'dra'}});
 
         await waitFor(() => {
             expect(useOptions).toHaveBeenLastCalledWith({
                 query: 'dra',
                 selectedValues: ['published']
             });
         });
 
         const trigger = getSelectedValueTrigger();
 
         fireEvent.click(trigger);
 
-        await act(async () => {
-            await new Promise<void>((resolve) => {
-                setTimeout(() => {
-                    resolve();
-                }, 250);
-            });
-        });
+        await act(() => {
+            vi.advanceTimersByTime(250);
+        });
 
         fireEvent.click(trigger);
 
         const reopenedInput = await screen.findByPlaceholderText('Search status...');
         expect((reopenedInput as HTMLInputElement).value).toBe('');
     });
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@apps/shade/test/unit/components/ui/filters.test.tsx` around lines 139 - 145,
The test currently waits with a real setTimeout inside act() which is
timing-dependent; replace that block with Vitest fake timers: call
vi.useFakeTimers() before the wait, remove the setTimeout/promise and instead
advance timers with vi.advanceTimersByTime(250) inside act() to flush pending
timers, then restore timers with vi.useRealTimers(); update the test around the
act(...) call (the block containing the setTimeout Promise) and ensure any async
updates triggered by timers are awaited after advancing timers.
apps/posts/src/views/PostAnalytics/components/stats-filter.tsx (2)

97-130: Significant code duplication with apps/stats/src/views/Stats/components/stats-filter.tsx.

The utility functions filterOptionsByQuery, mergeFilterOptions, asFilterValueSource, and buildTinybirdOptions are duplicated verbatim between this file and the stats app's stats-filter.tsx. The useTinybirdFilterValueSource hook is also nearly identical.

Consider extracting these shared utilities to a common location (e.g., apps/posts/src/hooks/filter-sources/utils.ts or a shared package) to reduce maintenance burden and ensure consistent behavior.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@apps/posts/src/views/PostAnalytics/components/stats-filter.tsx` around lines
97 - 130, The four utility functions filterOptionsByQuery, mergeFilterOptions,
asFilterValueSource plus buildTinybirdOptions and the
useTinybirdFilterValueSource hook are duplicated and should be extracted to a
single shared module; create a new utils file (e.g., filter-sources/utils) that
exports filterOptionsByQuery, mergeFilterOptions, asFilterValueSource,
buildTinybirdOptions and useTinybirdFilterValueSource, move the implementations
there, update both posts and stats files to import these symbols, ensure any
related types (FilterOption, ValueSource) are exported or re-exported from the
shared module, and run typechecks/compile to fix any import/type issues.

255-258: Memoization dependencies may cause unnecessary recreations.

The dependency array includes useTinybirdFilterValueSourceOptions, but this function is recreated on every render since it's defined inline. This will cause the useMemo to recompute on every render, defeating its purpose.

This is the same pattern issue noted in other value source hooks in this PR.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@apps/posts/src/views/PostAnalytics/components/stats-filter.tsx` around lines
255 - 258, The useMemo currently depends on a function defined inline
(useTinybirdFilterValueSourceOptions) causing it to recompute every render; make
the dependency stable by either moving the useTinybirdFilterValueSourceOptions
definition out of the component (or into a useCallback) so its reference is
stable, then keep the dependency array as [fieldKey,
useTinybirdFilterValueSourceOptions]; alternatively remove the function from the
dependency array and depend only on [fieldKey] after ensuring the function is
stable—update the useMemo block that returns { id:
`posts.post-analytics.${fieldKey}`, useOptions:
useTinybirdFilterValueSourceOptions } accordingly.
apps/posts/src/views/comments/components/comments-filters.tsx (1)

18-20: Consider extracting shared asFilterValueSource helper.

This type assertion helper is duplicated across multiple files (comments-filters.tsx, stats-filter.tsx in both posts and stats apps). Consider moving it to a shared location (e.g., alongside the value source utilities in apps/posts/src/hooks/filter-sources/utils.ts) or exporting it from @tryghost/shade to eliminate duplication.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@apps/posts/src/views/comments/components/comments-filters.tsx` around lines
18 - 20, The local type-assertion helper asFilterValueSource is duplicated;
extract it into a single shared helper and import it where needed: create/export
a function named asFilterValueSource(ValueSource<string> ->
ValueSource<unknown>) in the shared utilities (for example in the existing
filter-sources utils module used by posts: the module containing other value
source helpers) or add it to the public API of `@tryghost/shade`, then replace the
inline definitions in comments-filters.tsx and stats-filter.tsx by importing
that shared asFilterValueSource so all files use the same implementation and
keep the type assertions identical.
apps/shade/src/components/ui/filters.tsx (1)

1286-1311: Inconsistent controlled mode handling in popover vs inline mode.

In the popover rendering path, selected item deselection (lines 1291-1300) uses values directly and calls onChange, but doesn't check for field.onValueChange like the inline mode does (lines 1141-1153). If controlled mode is used with the popover, this could cause state inconsistencies since effectiveValues considers field.value but the handler writes to values.

Current usage doesn't trigger this (popover mode isn't used in controlled mode), but this could become a bug if usage patterns change.

💡 Consider aligning with inline mode handling
 onSelect={() => {
     if (isMultiSelect) {
-        onChange(values.filter(v => v !== option.value) as T[]);
+        const next = effectiveValues.filter(v => v !== option.value) as T[];
+        if (field.onValueChange) {
+            field.onValueChange(next);
+        } else {
+            onChange(next);
+        }
     } else {
-        onChange([] as T[]);
+        if (field.onValueChange) {
+            field.onValueChange([] as T[]);
+        } else {
+            onChange([] as T[]);
+        }
     }
     if (!isMultiSelect) {
         setOpen(false);
         handleClose();
     }
 }}
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@apps/shade/src/components/ui/filters.tsx` around lines 1286 - 1311, The
popover selection handler in CommandItem uses raw values to compute deselection
and calls onChange without honoring controlled-mode hooks (it uses values
instead of effectiveValues and doesn't call field.onValueChange), which can
desync in controlled scenarios; update the onSelect in the popover path to
mirror the inline-mode logic: derive the new array from effectiveValues (or use
values but then account for field.value), and if field.onValueChange exists call
it with the updated value in addition to onChange, and keep the existing
setOpen(false)/handleClose behavior for single-select; reference
selectedOptions, CommandItem onSelect, isMultiSelect, onChange, values,
effectiveValues, and field.onValueChange to locate where to apply the fix.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@apps/posts/src/hooks/use-label-picker.ts`:
- Around line 37-45: The mapped labels currently set id =
String(option.metadata?.id || option.value) which mixes slugs into IDs and can
break API calls (see editLabel/deleteLabel); update the mapping in the useMemo
that builds labels from labelSourceState.options so that id comes only from
option.metadata.id (e.g., set id to String(option.metadata.id) when present and
to null/undefined otherwise or add a separate flag like hasId), and ensure
downstream logic that calls editLabel/deleteLabel uses only labels with a real
metadata.id or fetches the real id first; reference the labels constant,
useMemo, labelSourceState.options and option.metadata?.id to locate and adjust
the logic.

---

Outside diff comments:
In `@apps/posts/src/views/PostAnalytics/components/stats-filter.tsx`:
- Around line 159-174: The file contains an unused dead function
applySelectedPostFilter which is never referenced (and a nearby comment notes
there is no 'post' filter in this context); remove the applySelectedPostFilter
function declaration and any associated types/exports/imports exclusively used
by it to eliminate dead code and simplify the module, leaving other
filter-related helpers like buildFilterParams intact.

---

Nitpick comments:
In `@apps/posts/src/hooks/filter-sources/use-email-post-value-source.ts`:
- Around line 32-39: The code calls buildQuotedListFilter('id', selectedValues)
twice when constructing the searchParams for useBrowsePosts; compute the filter
once into a local const (e.g. const idFilter = buildQuotedListFilter('id',
selectedValues)) and then use idFilter in the conditional and the object value
so useBrowsePosts only receives the filter once, keeping selectedValues,
buildQuotedListFilter and useBrowsePosts as the referenced symbols to locate the
change.

In `@apps/posts/src/hooks/filter-sources/use-member-value-source.ts`:
- Around line 28-30: Compute the result of buildQuotedListFilter('id',
selectedValues') once and reuse it instead of calling it twice; e.g., assign its
return to a local variable (e.g., filterValue) inside use-member-value-source.ts
and then conditionally spread {filter: filterValue} only when filterValue is
truthy, updating the object that currently contains limit: '100' and the spread
expression.
- Around line 47-50: The outer useMemo in use-member-value-source currently uses
an empty dependency array while returning an inner function
useMemberValueSourceOptions defined inside the hook, which is inconsistent and
fragile; fix by converting useMemberValueSourceOptions into a stable reference
(either move the function definition for useMemberValueSourceOptions outside the
hook if it does not close over hook-local variables, or wrap it with useCallback
inside the hook and then include that callback in the useMemo dependency array)
so that the returned object from useMemo stays consistent with patterns like
useNewsletterValueSource.

In `@apps/posts/src/hooks/filter-sources/use-newsletter-value-source.ts`:
- Around line 6-18: The inner function useNewsletterValueSourceOptions is
recreated every render, causing the outer useMemo that returns { id:
'posts.newsletters.local', useOptions: useNewsletterValueSourceOptions } to
always recompute; wrap useNewsletterValueSourceOptions in React's useCallback so
it has a stable reference and include all captured values (e.g., options and
filterOptionsByQuery) in its dependency array, then keep the outer useMemo
dependent on that stable callback; also add useCallback to the React imports to
avoid missing import errors.

In `@apps/posts/src/hooks/filter-sources/use-post-resource-value-source.ts`:
- Around line 47-62: Compute the filter once and reuse it instead of calling
buildQuotedListFilter('id', selectedValues) twice for each query: create a local
const (e.g., const idFilter = buildQuotedListFilter('id', selectedValues))
before the useBrowsePosts/useBrowsePages calls, then use idFilter in the
searchParams spread (e.g., ...(idFilter ? { filter: idFilter } : {})) for both
hydratedPostBrowse and hydratedPageBrowse so the filter is evaluated only once
and the code is clearer.

In `@apps/posts/src/views/comments/components/comments-filters.tsx`:
- Around line 18-20: The local type-assertion helper asFilterValueSource is
duplicated; extract it into a single shared helper and import it where needed:
create/export a function named asFilterValueSource(ValueSource<string> ->
ValueSource<unknown>) in the shared utilities (for example in the existing
filter-sources utils module used by posts: the module containing other value
source helpers) or add it to the public API of `@tryghost/shade`, then replace the
inline definitions in comments-filters.tsx and stats-filter.tsx by importing
that shared asFilterValueSource so all files use the same implementation and
keep the type assertions identical.

In `@apps/posts/src/views/PostAnalytics/components/stats-filter.tsx`:
- Around line 97-130: The four utility functions filterOptionsByQuery,
mergeFilterOptions, asFilterValueSource plus buildTinybirdOptions and the
useTinybirdFilterValueSource hook are duplicated and should be extracted to a
single shared module; create a new utils file (e.g., filter-sources/utils) that
exports filterOptionsByQuery, mergeFilterOptions, asFilterValueSource,
buildTinybirdOptions and useTinybirdFilterValueSource, move the implementations
there, update both posts and stats files to import these symbols, ensure any
related types (FilterOption, ValueSource) are exported or re-exported from the
shared module, and run typechecks/compile to fix any import/type issues.
- Around line 255-258: The useMemo currently depends on a function defined
inline (useTinybirdFilterValueSourceOptions) causing it to recompute every
render; make the dependency stable by either moving the
useTinybirdFilterValueSourceOptions definition out of the component (or into a
useCallback) so its reference is stable, then keep the dependency array as
[fieldKey, useTinybirdFilterValueSourceOptions]; alternatively remove the
function from the dependency array and depend only on [fieldKey] after ensuring
the function is stable—update the useMemo block that returns { id:
`posts.post-analytics.${fieldKey}`, useOptions:
useTinybirdFilterValueSourceOptions } accordingly.

In `@apps/shade/src/components/ui/filters.tsx`:
- Around line 1286-1311: The popover selection handler in CommandItem uses raw
values to compute deselection and calls onChange without honoring
controlled-mode hooks (it uses values instead of effectiveValues and doesn't
call field.onValueChange), which can desync in controlled scenarios; update the
onSelect in the popover path to mirror the inline-mode logic: derive the new
array from effectiveValues (or use values but then account for field.value), and
if field.onValueChange exists call it with the updated value in addition to
onChange, and keep the existing setOpen(false)/handleClose behavior for
single-select; reference selectedOptions, CommandItem onSelect, isMultiSelect,
onChange, values, effectiveValues, and field.onValueChange to locate where to
apply the fix.

In `@apps/shade/test/unit/components/ui/filters.test.tsx`:
- Around line 107-109: Replace the ambiguous presence assertion using getByText
in the test: instead of expecting screen.getByText('Draft') to be defined,
assert presence explicitly by using jest-dom's matcher (e.g.,
expect(screen.getByText('Draft')).toBeInTheDocument()) or switch to queryByText
and assert it is truthy; update the assertion in the test case in
apps/shade/test/unit/components/ui/filters.test.tsx where
screen.getByText('Draft') is used.
- Around line 139-145: The test currently waits with a real setTimeout inside
act() which is timing-dependent; replace that block with Vitest fake timers:
call vi.useFakeTimers() before the wait, remove the setTimeout/promise and
instead advance timers with vi.advanceTimersByTime(250) inside act() to flush
pending timers, then restore timers with vi.useRealTimers(); update the test
around the act(...) call (the block containing the setTimeout Promise) and
ensure any async updates triggered by timers are awaited after advancing timers.

In `@apps/stats/src/views/Stats/components/stats-filter.tsx`:
- Line 607: The StatsFilter function is declared using function declaration
syntax but ends with a stray trailing semicolon (`};`); remove the trailing
semicolon to make the declaration style consistent (or alternatively convert the
function declaration to a function expression/arrow assigned to a const named
StatsFilter if you prefer expression style). Locate the StatsFilter function and
either delete the final semicolon after the closing brace or refactor the
declaration into a const StatsFilter = (...) => { ... } form so syntax and style
are consistent.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro

Run ID: 9bb08c6e-88cf-4193-8d59-379951499ee9

📥 Commits

Reviewing files that changed from the base of the PR and between 3445eb6 and cddaceb.

📒 Files selected for processing (33)
  • apps/admin-x-framework/src/api/labels.ts
  • apps/admin-x-framework/src/api/newsletters.ts
  • apps/admin-x-framework/src/api/pages.ts
  • apps/admin-x-framework/src/api/posts.ts
  • apps/admin-x-framework/src/api/tiers.ts
  • apps/posts/src/components/label-picker/label-filter-renderer.tsx
  • apps/posts/src/components/label-picker/label-picker.tsx
  • apps/posts/src/hooks/filter-sources/use-email-post-value-source.ts
  • apps/posts/src/hooks/filter-sources/use-label-value-source.ts
  • apps/posts/src/hooks/filter-sources/use-member-value-source.ts
  • apps/posts/src/hooks/filter-sources/use-newsletter-value-source.ts
  • apps/posts/src/hooks/filter-sources/use-post-resource-value-source.ts
  • apps/posts/src/hooks/filter-sources/use-tier-value-source.ts
  • apps/posts/src/hooks/filter-sources/utils.ts
  • apps/posts/src/hooks/use-label-picker.ts
  • apps/posts/src/views/PostAnalytics/components/stats-filter.tsx
  • apps/posts/src/views/comments/comments.tsx
  • apps/posts/src/views/comments/components/comments-filters.tsx
  • apps/posts/src/views/comments/hooks/use-filter-options.ts
  • apps/posts/src/views/comments/hooks/use-known-filter-values.ts
  • apps/posts/src/views/comments/hooks/use-search-members.ts
  • apps/posts/src/views/comments/hooks/use-search-posts.ts
  • apps/posts/src/views/members/components/bulk-action-modals/add-label-modal.tsx
  • apps/posts/src/views/members/components/bulk-action-modals/import-members/components/mapping-step.tsx
  • apps/posts/src/views/members/components/bulk-action-modals/remove-label-modal.tsx
  • apps/posts/src/views/members/components/members-filters.tsx
  • apps/posts/src/views/members/hooks/use-resource-search.ts
  • apps/posts/src/views/members/use-member-filter-fields.test.ts
  • apps/posts/src/views/members/use-member-filter-fields.ts
  • apps/posts/test/unit/hooks/use-filter-options.test.tsx
  • apps/shade/src/components/ui/filters.tsx
  • apps/shade/test/unit/components/ui/filters.test.tsx
  • apps/stats/src/views/Stats/components/stats-filter.tsx
💤 Files with no reviewable changes (7)
  • apps/posts/src/views/comments/hooks/use-search-members.ts
  • apps/posts/src/views/comments/hooks/use-search-posts.ts
  • apps/posts/src/views/comments/comments.tsx
  • apps/posts/test/unit/hooks/use-filter-options.test.tsx
  • apps/posts/src/views/comments/hooks/use-known-filter-values.ts
  • apps/posts/src/views/comments/hooks/use-filter-options.ts
  • apps/posts/src/views/members/hooks/use-resource-search.ts

ref https://linear.app/ghost/issue/BER-3334/

Reduced a small duplicated setup block in the new filter value source tests so the PR addresses low-risk Sonar duplication without dragging the broader stats cleanup into this branch.
ref https://linear.app/ghost/issue/BER-3334/

Stopped deriving label IDs from slugs in the value-source-backed picker so edit and delete actions only operate on real label IDs when the option metadata is present.
ref https://linear.app/ghost/issue/BER-3334/

Removed the stale default React import from the new filters unit test so the shade typecheck passes in CI after the test dedupe change.
ref https://linear.app/ghost/issue/BER-3334/

Dropped the unused newsletter value-source hook now that the members newsletter filters still build explicit slug-based fields and never adopted a searchable value-source path.
ref https://linear.app/ghost/issue/BER-3334/

Pulled the local-to-remote mode decision into a small shared hook so the label value source keeps the hybrid behavior without inlining the switching logic.
Copy link
Copy Markdown
Contributor

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

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

🧹 Nitpick comments (1)
apps/posts/src/hooks/filter-sources/use-hybrid-source-mode.ts (1)

15-20: Mode can become stale after first resolution.

At Line 15, mode !== null prevents recalculation permanently. If pagination/pageLimit changes later (context switch, refetch with different metadata), this can return an outdated mode. Prefer deriving mode from current inputs (or reset when inputs change).

Proposed refactor
-import {useEffect, useState} from 'react';
+import {useMemo} from 'react';
@@
 export function useHybridSourceMode(
     pagination: PaginationLike | undefined,
     pageLimit: number
 ): 'local' | 'remote' | null {
-    const [mode, setMode] = useState<'local' | 'remote' | null>(null);
-
-    useEffect(() => {
-        if (mode !== null || !pagination) {
-            return;
-        }
-
-        setMode(pagination.total <= pageLimit || pagination.next === null ? 'local' : 'remote');
-    }, [mode, pageLimit, pagination]);
-
-    return mode;
+    return useMemo(() => {
+        if (!pagination) {
+            return null;
+        }
+        return pagination.total <= pageLimit || pagination.next === null ? 'local' : 'remote';
+    }, [pagination?.total, pagination?.next, pageLimit]);
 }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@apps/posts/src/hooks/filter-sources/use-hybrid-source-mode.ts` around lines
15 - 20, The current effect in use-hybrid-source-mode prevents recalculation by
returning early when mode !== null, causing mode to become stale if pagination
or pageLimit change; remove that early return and instead compute a derivedMode
from the current pagination and pageLimit (e.g., derivedMode = pagination.total
<= pageLimit || pagination.next === null ? 'local' : 'remote') and call
setMode(derivedMode) whenever pagination or pageLimit change (keep mode,
pageLimit, pagination in the dependency array) or alternatively reset mode to
null when inputs change so the effect can recompute; update the effect that
references mode, setMode, pageLimit, and pagination accordingly.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Nitpick comments:
In `@apps/posts/src/hooks/filter-sources/use-hybrid-source-mode.ts`:
- Around line 15-20: The current effect in use-hybrid-source-mode prevents
recalculation by returning early when mode !== null, causing mode to become
stale if pagination or pageLimit change; remove that early return and instead
compute a derivedMode from the current pagination and pageLimit (e.g.,
derivedMode = pagination.total <= pageLimit || pagination.next === null ?
'local' : 'remote') and call setMode(derivedMode) whenever pagination or
pageLimit change (keep mode, pageLimit, pagination in the dependency array) or
alternatively reset mode to null when inputs change so the effect can recompute;
update the effect that references mode, setMode, pageLimit, and pagination
accordingly.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro

Run ID: 70a356ac-b088-4187-b630-4087d11c50af

📥 Commits

Reviewing files that changed from the base of the PR and between 7bf6dd8 and 857d676.

📒 Files selected for processing (2)
  • apps/posts/src/hooks/filter-sources/use-hybrid-source-mode.ts
  • apps/posts/src/hooks/filter-sources/use-label-value-source.ts
🚧 Files skipped from review as they are similar to previous changes (1)
  • apps/posts/src/hooks/filter-sources/use-label-value-source.ts

ref https://linear.app/ghost/issue/BER-3334/

Removed the remaining value source friction and dead API so filter consumers can use the shared contract directly while members labels reuse the same source path as the rest of the refactor.
@kevinansfield kevinansfield changed the title Refactored filter value loading to use value sources Refactored filter value loading Mar 26, 2026
ref https://linear.app/ghost/issue/BER-3334/

Tightened the filter source hooks by removing repeated selected-value filter construction, covering the hybrid mode decision with tests, and simplifying the tier source shape so its memoization is easier to follow.
ref https://linear.app/ghost/issue/BER-3334/

Made the hook test props explicitly typed so the posts TypeScript build no longer narrows rerender inputs from the initial literal values.
@sonarqubecloud
Copy link
Copy Markdown

Quality Gate Failed Quality Gate failed

Failed conditions
14.9% Duplication on New Code (required ≤ 3%)

See analysis details on SonarQube Cloud

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

Labels

migration [pull request] Includes migration for review

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant