diff --git a/apps/posts/src/components/label-picker/label-picker.tsx b/apps/posts/src/components/label-picker/label-picker.tsx index 6f0cd27988e..12af1d326db 100644 --- a/apps/posts/src/components/label-picker/label-picker.tsx +++ b/apps/posts/src/components/label-picker/label-picker.tsx @@ -6,7 +6,8 @@ import { CommandEmpty, CommandGroup, CommandItem, - CommandList + CommandList, + useFilterOptionsInfiniteScroll } from '@tryghost/shade/components'; import {EditRow} from './edit-row'; import {Label} from '@tryghost/admin-x-framework/api/labels'; @@ -73,6 +74,7 @@ interface LabelListItemsProps { labels: Label[]; selectedSlugs: string[]; search: string; + optionSource: ComboboxOptionSource; onToggle: (slug: string) => void; onEdit?: (id: string, name: string) => Promise; onDelete?: (id: string) => Promise; @@ -87,6 +89,7 @@ const LabelListItems: React.FC = ({ labels, selectedSlugs, search, + optionSource, onToggle, onEdit, onDelete, @@ -103,6 +106,12 @@ const LabelListItems: React.FC = ({ : labels; const showCreate = !!onCreate && search.trim() && canCreateFromSearch?.(search); const showEdit = !!onEdit; + const loadMoreSentinelRef = useFilterOptionsInfiniteScroll({ + optionSource, + optionsCount: visibleLabels.length, + resetKey: search + }); + const handleCreate = async () => { if (onCreate) { const newLabel = await onCreate(search.trim()); @@ -128,7 +137,7 @@ const LabelListItems: React.FC = ({ return ( <> - {!showCreate && visibleLabels.length === 0 && ( + {!showCreate && visibleLabels.length === 0 && !optionSource.hasMore && ( No labels found )} {visibleLabels.length > 0 && ( @@ -167,6 +176,18 @@ const LabelListItems: React.FC = ({ )} + {optionSource.hasMore && ( +
+ +
+ )} ); }; @@ -338,6 +359,7 @@ const ComboboxPicker: React.FC = ({ isCreating={isCreating} isDuplicateName={isDuplicateName} labels={labels} + optionSource={optionSource} search={search} selectedSlugs={selectedSlugs} onCreate={onCreate} diff --git a/apps/posts/src/hooks/filter-sources/create-combined-value-source.ts b/apps/posts/src/hooks/filter-sources/create-combined-value-source.ts index d6a645358f0..b3c2ecb301b 100644 --- a/apps/posts/src/hooks/filter-sources/create-combined-value-source.ts +++ b/apps/posts/src/hooks/filter-sources/create-combined-value-source.ts @@ -26,6 +26,7 @@ export function createCombinedValueSource( loadMore: () => { if (firstState.hasMore) { firstState.loadMore(); + return; } if (secondState.hasMore) { diff --git a/apps/posts/test/unit/hooks/create-combined-value-source.test.tsx b/apps/posts/test/unit/hooks/create-combined-value-source.test.tsx new file mode 100644 index 00000000000..6644f60c77c --- /dev/null +++ b/apps/posts/test/unit/hooks/create-combined-value-source.test.tsx @@ -0,0 +1,115 @@ +import {FilterOption, ValueSourceState} from '@tryghost/shade/patterns'; +import {createCombinedValueSource} from '@src/hooks/filter-sources/create-combined-value-source'; +import {describe, expect, it, vi} from 'vitest'; +import {renderHook} from '@testing-library/react'; + +const firstOptions: FilterOption[] = [ + {value: 'post-1', label: 'Post 1'}, + {value: 'post-2', label: 'Post 2'} +]; + +const secondOptions: FilterOption[] = [ + {value: 'page-1', label: 'Page 1'}, + {value: 'page-2', label: 'Page 2'} +]; + +function buildState(options: FilterOption[], overrides: Partial> = {}): ValueSourceState { + return { + options, + isInitialLoad: false, + isSearching: false, + isLoadingMore: false, + hasMore: false, + loadMore: () => {}, + ...overrides + }; +} + +describe('createCombinedValueSource', () => { + it('orders first source options before second source options', () => { + const useCombinedSource = createCombinedValueSource( + () => ({ + id: 'posts', + useOptions: () => buildState(firstOptions) + }), + () => ({ + id: 'pages', + useOptions: () => buildState(secondOptions) + }) + ); + + const {result} = renderHook(() => { + const source = useCombinedSource(); + return source.useOptions({query: '', selectedValues: []}); + }); + + expect(result.current.options).toEqual([ + {value: 'post-1', label: 'Post 1'}, + {value: 'post-2', label: 'Post 2'}, + {value: 'page-1', label: 'Page 1'}, + {value: 'page-2', label: 'Page 2'} + ]); + }); + + it('loads more from the first source while it still has more pages', () => { + const loadMoreFirst = vi.fn(); + const loadMoreSecond = vi.fn(); + const useCombinedSource = createCombinedValueSource( + () => ({ + id: 'posts', + useOptions: () => buildState(firstOptions, { + hasMore: true, + loadMore: loadMoreFirst + }) + }), + () => ({ + id: 'pages', + useOptions: () => buildState(secondOptions, { + hasMore: true, + loadMore: loadMoreSecond + }) + }) + ); + + const {result} = renderHook(() => { + const source = useCombinedSource(); + return source.useOptions({query: '', selectedValues: []}); + }); + + result.current.loadMore(); + + expect(loadMoreFirst).toHaveBeenCalledTimes(1); + expect(loadMoreSecond).not.toHaveBeenCalled(); + }); + + it('loads more from the second source once the first source is exhausted', () => { + const loadMoreFirst = vi.fn(); + const loadMoreSecond = vi.fn(); + const useCombinedSource = createCombinedValueSource( + () => ({ + id: 'posts', + useOptions: () => buildState(firstOptions, { + hasMore: false, + loadMore: loadMoreFirst + }) + }), + () => ({ + id: 'pages', + useOptions: () => buildState(secondOptions, { + hasMore: true, + loadMore: loadMoreSecond + }) + }) + ); + + const {result} = renderHook(() => { + const source = useCombinedSource(); + return source.useOptions({query: '', selectedValues: []}); + }); + + result.current.loadMore(); + + expect(loadMoreFirst).not.toHaveBeenCalled(); + expect(loadMoreSecond).toHaveBeenCalledTimes(1); + }); +}); diff --git a/apps/shade/src/components.ts b/apps/shade/src/components.ts index 3f73e4eb64a..7ffd17068ce 100644 --- a/apps/shade/src/components.ts +++ b/apps/shade/src/components.ts @@ -45,6 +45,7 @@ export * from './components/ui/tabs'; export * from './components/ui/textarea'; export * from './components/ui/toggle-group'; export * from './components/ui/tooltip'; +export * from './components/ui/use-filter-options-infinite-scroll'; export type {DropdownMenuCheckboxItemProps as DropdownMenuCheckboxItemProps} from '@radix-ui/react-dropdown-menu'; diff --git a/apps/shade/src/components/ui/filters.tsx b/apps/shade/src/components/ui/filters.tsx index 3c83d33620a..f5faa219c8a 100644 --- a/apps/shade/src/components/ui/filters.tsx +++ b/apps/shade/src/components/ui/filters.tsx @@ -20,6 +20,7 @@ import { import {Popover, PopoverContent, PopoverTrigger} from '@/components/ui/popover'; import {Switch} from '@/components/ui/switch'; import {Tooltip, TooltipContent, TooltipTrigger} from '@/components/ui/tooltip'; +import {type FilterOptionsInfiniteScrollSource, useFilterOptionsInfiniteScroll} from '@/components/ui/use-filter-options-infinite-scroll'; import {cva, type VariantProps} from 'class-variance-authority'; import {AlertCircle, Check, Loader2, Plus, X} from 'lucide-react'; import {cn} from '@/lib/utils'; @@ -1094,10 +1095,8 @@ interface SelectOptionsListProps { contextLabel?: string; selectedOptions: FilterOption[]; unselectedOptions: FilterOption[]; - isInitialLoad: boolean; - isLoadingMore: boolean; - hasMore: boolean; - onLoadMore: () => void; + optionSource: FilterOptionsInfiniteScrollSource; + searchInput: string; onSelectSelected: (option: FilterOption) => void; onSelectUnselected: (option: FilterOption) => void; } @@ -1106,18 +1105,22 @@ function SelectOptionsList({ contextLabel, selectedOptions, unselectedOptions, - isInitialLoad, - isLoadingMore, - hasMore, - onLoadMore, + optionSource, + searchInput, onSelectSelected, onSelectUnselected }: Readonly>) { const context = useFilterContext(); + const renderedOptionsCount = selectedOptions.length + unselectedOptions.length; + const loadMoreSentinelRef = useFilterOptionsInfiniteScroll({ + optionSource, + optionsCount: renderedOptionsCount, + resetKey: searchInput + }); return ( - {isInitialLoad ? ( + {optionSource.isInitialLoad ? (
{context.i18n.loading} @@ -1167,18 +1170,18 @@ function SelectOptionsList({ )} - {hasMore && ( + {optionSource.hasMore && ( <> {(selectedOptions.length > 0 || unselectedOptions.length > 0) && } -
+
@@ -1206,6 +1209,13 @@ function ResolvedSelectOptionsPopover({ // Track selected options separately so they persist during async search const [cachedSelectedOptions, setCachedSelectedOptions] = useState[]>([]); const context = useFilterContext(); + const optionSource: FilterOptionsInfiniteScrollSource = { + hasMore, + isInitialLoad, + isLoadingMore, + isSearching, + loadMore: onLoadMore + }; const isMultiSelect = field.type === 'multiselect' || values.length > 1; const effectiveValues = useMemo(() => field.value ?? values, [field.value, values]); @@ -1295,12 +1305,10 @@ function ResolvedSelectOptionsPopover({ /> { if (isMultiSelect) { const next = effectiveValues.filter(v => v !== option.value) as T[]; @@ -1401,12 +1409,10 @@ function ResolvedSelectOptionsPopover({ onSearchChange={handleSearchChange} /> { if (isMultiSelect) { onChange(values.filter(v => v !== option.value) as T[]); diff --git a/apps/shade/src/components/ui/multi-select-combobox.tsx b/apps/shade/src/components/ui/multi-select-combobox.tsx index fc5e9734388..cdde0ebe48b 100644 --- a/apps/shade/src/components/ui/multi-select-combobox.tsx +++ b/apps/shade/src/components/ui/multi-select-combobox.tsx @@ -11,6 +11,7 @@ import { CommandList, CommandSeparator } from '@/components/ui/command'; +import {useFilterOptionsInfiniteScroll} from '@/components/ui/use-filter-options-infinite-scroll'; import {Check, Loader2} from 'lucide-react'; import {cn} from '@/lib/utils'; import type {FilterOption, ValueSource} from '@/components/ui/filters'; @@ -236,6 +237,13 @@ export function MultiSelectCombobox({ () => resolvedOptions.filter(opt => !values.includes(opt.value)), [resolvedOptions, values] ); + const renderedOptionsCount = visibleSelectedOptions.length + unselectedOptions.length; + const loadMoreSentinelRef = useFilterOptionsInfiniteScroll({ + optionSource: source, + optionsCount: renderedOptionsCount, + resetKey: searchInput + }); + // --- Handlers --- const handleDeselect = useCallback((option: FilterOption) => { @@ -352,7 +360,7 @@ export function MultiSelectCombobox({ {source.hasMore && ( <> {(visibleSelectedOptions.length > 0 || unselectedOptions.length > 0) && } -
+