diff --git a/packages/boxel-ui/addon/src/components/picker/index.gts b/packages/boxel-ui/addon/src/components/picker/index.gts index 635e80d9526..e976b2134d9 100644 --- a/packages/boxel-ui/addon/src/components/picker/index.gts +++ b/packages/boxel-ui/addon/src/components/picker/index.gts @@ -2,10 +2,13 @@ import type Owner from '@ember/owner'; import { scheduleOnce } from '@ember/runloop'; import Component from '@glimmer/component'; import { tracked } from '@glimmer/tracking'; +import type { ComponentLike } from '@glint/template'; +import { modifier } from 'ember-modifier'; import type { Select } from 'ember-power-select/components/power-select'; import { includes } from 'lodash'; import type { Icon } from '../../icons/types.ts'; +import LoadingIndicator from '../loading-indicator/index.gts'; import { BoxelMultiSelectBasic } from '../multi-select/index.gts'; import PickerBeforeOptionsWithSearch from './before-options-with-search.gts'; import PickerOptionRow from './option-row.gts'; @@ -21,19 +24,21 @@ export type PickerOption = { export interface PickerSignature { Args: { - // State + afterOptionsComponent?: ComponentLike; + disableClientSideSearch?: boolean; disabled?: boolean; - // Display + extra?: Record; + hasMore?: boolean; + isLoading?: boolean; + isLoadingMore?: boolean; label: string; matchTriggerWidth?: boolean; maxSelectedDisplay?: number; - onChange: (selected: PickerOption[]) => void; - // Data + onLoadMore?: () => void; + onSearchTermChange?: (term: string) => void; options: PickerOption[]; - placeholder?: string; - renderInPlace?: boolean; searchPlaceholder?: string; selected: PickerOption[]; @@ -44,6 +49,122 @@ export interface PickerSignature { Element: HTMLElement; } +let loadMoreSentinel = modifier( + ( + element: Element, + [onLoadMore, isLoadingMore]: [ + (() => void) | undefined, + boolean | undefined, + ], + { enabled }: { enabled?: boolean }, + ) => { + if (!enabled || !onLoadMore) { + return; + } + + let optionsList = element + .closest('.ember-basic-dropdown-content') + ?.querySelector('.ember-power-select-options'); + if (!optionsList) { + return; + } + + let handleScroll = () => { + if (isLoadingMore) { + return; + } + let { scrollTop, scrollHeight, clientHeight } = optionsList as Element; + if (scrollTop + clientHeight >= scrollHeight - 50) { + onLoadMore(); + } + }; + + optionsList.addEventListener('scroll', handleScroll); + + // Check immediately: if the list is short enough to fit without + // scrolling, we're already at the "bottom" and should load more. + requestAnimationFrame(() => handleScroll()); + + return () => optionsList!.removeEventListener('scroll', handleScroll); + }, +); + +interface PickerAfterOptionsSignature { + Args: { + extra?: Record; + select: Record; + }; +} + +class PickerAfterOptions extends Component { + get isLoading(): boolean { + return !!this.args.extra?.['isLoading']; + } + + get hasMore(): boolean { + return !!this.args.extra?.['hasMore']; + } + + get isLoadingMore(): boolean { + return !!this.args.extra?.['isLoadingMore']; + } + + get onLoadMore(): (() => void) | undefined { + return this.args.extra?.['onLoadMore'] as (() => void) | undefined; + } + + +} + export default class Picker extends Component { @tracked searchTerm = ''; @tracked private pinnedOption: PickerOption | null = null; @@ -86,7 +207,7 @@ export default class Picker extends Component { // - Then list already-selected options (so they stay visible even if they don't match the term) // - Then list unselected options that match the search term, in their original order get filteredOptions(): PickerOption[] { - if (!this.searchTerm) { + if (!this.searchTerm || this.args.disableClientSideSearch) { return this.args.options; } @@ -134,6 +255,7 @@ export default class Picker extends Component { }); const selectAll = options.filter((o) => o.type === 'select-all'); + return [...selectAll, ...selected, ...unselected]; } @@ -174,6 +296,7 @@ export default class Picker extends Component { onSearchTermChange = (term: string) => { this.searchTerm = term; + this.args.onSearchTermChange?.(term); }; onOptionHover = (option: PickerOption | null) => { @@ -198,14 +321,41 @@ export default class Picker extends Component { get extra() { return { + ...this.args.extra, label: this.args.label, searchTerm: this.searchTerm, searchPlaceholder: this.args.searchPlaceholder, onSearchTermChange: this.onSearchTermChange, maxSelectedDisplay: this.args.maxSelectedDisplay, + isLoading: this.args.isLoading, + isLoadingMore: this.args.isLoadingMore, + hasMore: this.args.hasMore, + onLoadMore: this.args.onLoadMore, }; } + get dropdownClass(): string { + let cls = 'boxel-picker__dropdown'; + if (this.args.isLoading) { + cls += ' boxel-picker__dropdown--loading'; + } + return cls; + } + + get afterOptionsComponent(): ComponentLike | undefined { + if (this.args.afterOptionsComponent) { + return this.args.afterOptionsComponent; + } + if ( + this.args.hasMore !== undefined || + this.args.onLoadMore !== undefined || + this.args.isLoading + ) { + return PickerAfterOptions; + } + return undefined; + } + onChange = (selected: PickerOption[]) => { const selectAllOptions = selected.filter((option) => { return option.type === 'select-all'; @@ -276,7 +426,8 @@ export default class Picker extends Component { @extra={{this.extra}} @triggerComponent={{component this.triggerComponent}} @beforeOptionsComponent={{component PickerBeforeOptionsWithSearch}} - @dropdownClass='boxel-picker__dropdown' + @afterOptionsComponent={{this.afterOptionsComponent}} + @dropdownClass={{this.dropdownClass}} ...attributes as |option| > @@ -301,6 +452,19 @@ export default class Picker extends Component { width: 100%; } + .boxel-picker__dropdown--loading .picker-before-options { + position: relative; + z-index: 2; + } + + .boxel-picker__dropdown--loading + .ember-power-select-option:not(:first-child) { + display: none; + } + .boxel-picker__dropdown--loading .picker-divider:not(:last-child) { + display: none; + } + .boxel-picker__dropdown .ember-power-select-option { padding: 0 var(--boxel-sp-2xs); display: flex; diff --git a/packages/host/app/components/card-search/panel.gts b/packages/host/app/components/card-search/panel.gts index 6dfbae1c6d0..c28187d2b7f 100644 --- a/packages/host/app/components/card-search/panel.gts +++ b/packages/host/app/components/card-search/panel.gts @@ -5,6 +5,7 @@ import { service } from '@ember/service'; import Component from '@glimmer/component'; import { cached, tracked } from '@glimmer/tracking'; +import { restartableTask, timeout } from 'ember-concurrency'; import { consume } from 'ember-provide-consume-context'; import { resource, use } from 'ember-resources'; @@ -18,13 +19,10 @@ import { type getCardCollection, CardContextName, GetCardCollectionContextName, + internalKeyFor, + isResolvedCodeRef, } from '@cardstack/runtime-common'; -import { - cardTypeDisplayName, - cardTypeIcon, -} from '@cardstack/runtime-common/helpers/card-type-display-name'; - import { getPrerenderedSearch } from '@cardstack/host/resources/prerendered-search'; import type RealmServerService from '@cardstack/host/services/realm-server'; import type RecentCards from '@cardstack/host/services/recent-cards-service'; @@ -38,13 +36,20 @@ import { buildSearchQuery, filterCardsByTypeRefs, getFilterTypeRefs, - getSearchTerm, shouldSkipSearchQuery, } from './utils'; import type { WithBoundArgs } from '@glint/template'; const OWNER_DESTROYED_ERROR = 'OWNER_DESTROYED_ERROR'; +const TYPE_PAGE_SIZE = 25; + +type TypeSummaryItem = { + id: string; + type: string; + attributes: { displayName: string; total: number; iconHTML: string }; + meta?: { realmURL: string }; +}; interface Signature { Args: { @@ -61,12 +66,19 @@ interface Signature { | 'selectedTypes' | 'onTypeChange' | 'typeOptions' + | 'onTypeSearchChange' + | 'onLoadMoreTypes' + | 'hasMoreTypes' + | 'isLoadingTypes' + | 'isLoadingMoreTypes' + | 'typesTotalCount' >, WithBoundArgs< typeof SearchContent, | 'searchKey' | 'selectedRealmURLs' | 'selectedCardTypes' + | 'typeCodeRefs' | 'baseFilter' | 'searchResource' | 'activeSort' @@ -90,6 +102,14 @@ export default class SearchPanel extends Component { @tracked private selectedRealms: PickerOption[] = []; @tracked private activeSort: SortOption = SORT_OPTIONS[0]; + // Type summaries state + @tracked private _typeSearchKey = ''; + @tracked private _typePageNumber = 0; + @tracked private _typeSummariesData: TypeSummaryItem[] = []; + @tracked private _isLoadingTypes = false; + @tracked private _isLoadingMoreTypes = false; + @tracked private _hasMoreTypes = false; + @cached private get recentCardCollection(): ReturnType { return this.getCardCollection( @@ -134,18 +154,6 @@ export default class SearchPanel extends Component { return filterCardsByTypeRefs(realmFiltered, typeRefs); } - private get searchTermFilteredRecentCards(): CardDef[] { - const cards = this.baseFilteredRecentCards; - const term = getSearchTerm(this.args.searchKey); - if (!term) { - return cards; - } - const lowerTerm = term.toLowerCase(); - return cards.filter((c) => - (c.cardTitle ?? '').toLowerCase().includes(lowerTerm), - ); - } - private get cardComponentModifier() { if (isDestroying(this) || isDestroyed(this)) { return undefined; @@ -163,7 +171,99 @@ export default class SearchPanel extends Component { } } + // Stores code_ref mappings for each display name (multiple code_refs + // can map to the same display name, e.g. different modules with the same card type name). + private _typeCodeRefs = new Map(); + + private fetchTypeSummaries = restartableTask( + async ( + realmURLs: string[], + searchKey: string, + pageNumber: number, + append: boolean, + ) => { + // Debounce search requests (not load-more or initial load) + if (pageNumber === 0 && searchKey) { + await timeout(300); + } + + if (isDestroyed(this) || isDestroying(this)) { + return; + } + + if (append) { + this._isLoadingMoreTypes = true; + } else { + this._isLoadingTypes = true; + } + + try { + let result = await this.realmServer.fetchCardTypeSummaries(realmURLs, { + searchKey: searchKey || undefined, + page: { number: pageNumber, size: TYPE_PAGE_SIZE }, + }); + + if (isDestroyed(this) || isDestroying(this)) { + return; + } + + if (append) { + this._typeSummariesData = [ + ...this._typeSummariesData, + ...result.data, + ]; + } else { + this._typeSummariesData = result.data; + } + + this._hasMoreTypes = + this._typeSummariesData.length < result.meta.page.total; + this._isLoadingTypes = false; + this._isLoadingMoreTypes = false; + } catch (e) { + console.error('Failed to fetch card type summaries', e); + if (!isDestroyed(this) && !isDestroying(this)) { + if (!append) { + this._typeSummariesData = []; + } + this._hasMoreTypes = false; + this._isLoadingTypes = false; + this._isLoadingMoreTypes = false; + } + } + }, + ); + + // Resource that watches selectedRealmURLs and typeSearchKey to trigger fetches + @use private _typeFetchTrigger = resource(() => { + let realmURLs = this.selectedRealmURLs; + let searchKey = this._typeSearchKey; + + // Reset page and fetch + this._typePageNumber = 0; + this.fetchTypeSummaries.perform(realmURLs, searchKey, 0, false); + + return { realmURLs, searchKey }; + }); + + private get baseFilterCodeRefs(): Set | undefined { + const typeRefs = getFilterTypeRefs(this.args.baseFilter, ''); + if (!typeRefs || typeRefs.length === 0) { + return undefined; + } + const refs = new Set(); + for (const { ref, negated } of typeRefs) { + if (!negated && isResolvedCodeRef(ref)) { + refs.add(internalKeyFor(ref, undefined)); + } + } + return refs.size > 0 ? refs : undefined; + } + @use private typeFilter = resource(() => { + // Access _typeFetchTrigger to ensure we re-run when fetch completes + this._typeFetchTrigger; + let value: { selected: PickerOption[]; options: PickerOption[] } = new TrackedObject({ selected: [], @@ -171,37 +271,37 @@ export default class SearchPanel extends Component { }); const seen = new Map(); + const codeRefsByDisplayName = new Map(); + const allowedCodeRefs = this.baseFilterCodeRefs; - // Derive types from search results (these have icons) - for (const card of this.searchResource.instances) { - const name = card.cardType; + for (const item of this._typeSummariesData) { + const name = item.attributes.displayName; + const codeRef = item.id; if (!name) { continue; } + + // When baseFilter constrains to specific types, only show matching types + if (allowedCodeRefs && !allowedCodeRefs.has(codeRef)) { + continue; + } + + if (!codeRefsByDisplayName.has(name)) { + codeRefsByDisplayName.set(name, []); + } + codeRefsByDisplayName.get(name)!.push(codeRef); + if (!seen.has(name)) { seen.set(name, { id: name, label: name, - icon: card.iconHtml ?? undefined, + icon: item.attributes.iconHTML ?? undefined, type: 'option', }); } } - // Also derive types from recent cards so the picker has options - // even when searchKey is blank and no search query runs. - for (const card of this.searchTermFilteredRecentCards) { - const name = cardTypeDisplayName(card); - if (!name || seen.has(name)) { - continue; - } - seen.set(name, { - id: name, - label: name, - icon: cardTypeIcon(card), - type: 'option', - }); - } + this._typeCodeRefs = codeRefsByDisplayName; value.options = [ ...[...seen.values()].sort((a, b) => a.label.localeCompare(b.label)), @@ -216,17 +316,9 @@ export default class SearchPanel extends Component { if (hadSelectAll) { value.selected = []; - } else if ( - this.searchResource.instances.length === 0 && - this.args.searchKey?.trim() && - !this.searchResource.hasSearchRun - ) { - // Active search but results haven't arrived yet — keep previous - // selections to avoid jarring UI changes while loading. - // `hasSearchRun` is false while the search task is in-flight and - // becomes true once it completes (success or error), so a completed - // search with zero results falls through to the else branch below - // which correctly reverts to "Any Type". + } else if (this._isLoadingTypes || this._isLoadingMoreTypes) { + // Type summaries still loading — keep previous selections + // to avoid jarring UI changes. value.selected = prev; } else { // Keep previous selections that still exist in the new options, @@ -273,6 +365,30 @@ export default class SearchPanel extends Component { this.activeSort = option; } + @action + private onTypeSearchChange(term: string) { + this._typeSearchKey = term; + this._typePageNumber = 0; + } + + @action + private onLoadMoreTypes() { + if ( + this._isLoadingTypes || + this._isLoadingMoreTypes || + !this._hasMoreTypes + ) { + return; + } + this._typePageNumber = this._typePageNumber + 1; + this.fetchTypeSummaries.perform( + this.selectedRealmURLs, + this._typeSearchKey, + this._typePageNumber, + true, + ); + } +