diff --git a/packages/boxel-ui/addon/src/components/picker/index.gts b/packages/boxel-ui/addon/src/components/picker/index.gts index 65109387fe2..d58773fd2e3 100644 --- a/packages/boxel-ui/addon/src/components/picker/index.gts +++ b/packages/boxel-ui/addon/src/components/picker/index.gts @@ -5,7 +5,6 @@ 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'; @@ -192,7 +191,9 @@ export default class Picker extends Component { } get isSelected() { - return (option: PickerOption) => includes(this.args.selected, option); + return (option: PickerOption) => { + return this.args.selected.some((o) => o.id === option.id); + }; } isLastOption = (option: PickerOption): boolean => { @@ -251,10 +252,12 @@ export default class Picker extends Component { } onToggleItem = (item: PickerOption) => { - const isCurrentlySelected = this.args.selected.includes(item); + const isCurrentlySelected = this.args.selected.some( + (o) => o.id === item.id, + ); let newSelected: PickerOption[]; if (isCurrentlySelected) { - newSelected = this.args.selected.filter((o) => o !== item); + newSelected = this.args.selected.filter((o) => o.id !== item.id); } else { newSelected = [...this.args.selected, item]; } @@ -263,7 +266,9 @@ export default class Picker extends Component { onChange = (selected: PickerOption[]) => { // Ignore clicks on disabled options - const lastAdded = selected.find((opt) => !this.args.selected.includes(opt)); + const lastAdded = selected.find( + (opt) => !this.args.selected.some((o) => o.id === opt.id), + ); if (lastAdded?.disabled) { return; } diff --git a/packages/boxel-ui/test-app/tests/integration/components/picker-test.gts b/packages/boxel-ui/test-app/tests/integration/components/picker-test.gts index ddfa2726bf9..a7f91a4055d 100644 --- a/packages/boxel-ui/test-app/tests/integration/components/picker-test.gts +++ b/packages/boxel-ui/test-app/tests/integration/components/picker-test.gts @@ -89,6 +89,29 @@ module('Integration | Component | picker', function (hooks) { // Check that selected items are displayed (they should be in pills) await click('[data-test-boxel-picker-trigger]'); assert.dom('[data-test-boxel-picker-selected-item]').exists({ count: 2 }); + + // Selected options should have checked checkboxes in the dropdown + assert + .dom( + '[data-test-boxel-picker-option-row="1"] .picker-option-row__checkbox--selected', + ) + .exists('Option 1 checkbox is checked'); + assert + .dom( + '[data-test-boxel-picker-option-row="2"] .picker-option-row__checkbox--selected', + ) + .exists('Option 2 checkbox is checked'); + // Unselected options should have unchecked checkboxes + assert + .dom( + '[data-test-boxel-picker-option-row="3"] .picker-option-row__checkbox--selected', + ) + .doesNotExist('Option 3 checkbox is unchecked'); + assert + .dom( + '[data-test-boxel-picker-option-row="4"] .picker-option-row__checkbox--selected', + ) + .doesNotExist('Option 4 checkbox is unchecked'); }); test('picker opens dropdown when clicked', async function (assert) { @@ -207,7 +230,14 @@ module('Integration | Component | picker', function (hooks) { '1', 'Should have selected first option', ); + // Checkbox should be checked for selected option + assert + .dom( + '[data-test-boxel-picker-option-row="1"] .picker-option-row__checkbox--selected', + ) + .exists('Option 1 checkbox is checked after selecting'); + // Click again to deselect — should fall back to select-all await click( firstOption.closest('.ember-power-select-option') as HTMLElement, ); @@ -216,6 +246,12 @@ module('Integration | Component | picker', function (hooks) { 1, 'Select-all option cannot be deselected', ); + // Checkbox should be unchecked after deselecting + assert + .dom( + '[data-test-boxel-picker-option-row="1"] .picker-option-row__checkbox--selected', + ) + .doesNotExist('Option 1 checkbox is unchecked after deselecting'); }); test('picker shows search input when searchEnabled is true', async function (assert) { @@ -321,6 +357,17 @@ module('Integration | Component | picker', function (hooks) { ['1'], 'select-all is removed once another option is selected', ); + // Option 1 checkbox should be checked, select-all unchecked + assert + .dom( + '[data-test-boxel-picker-option-row="1"] .picker-option-row__checkbox--selected', + ) + .exists('Option 1 checkbox is checked'); + assert + .dom( + '[data-test-boxel-picker-option-row="select-all"] .picker-option-row__checkbox--selected', + ) + .doesNotExist('Select-all checkbox is unchecked'); }); test('picker selects select-all when it is chosen after other options', async function (assert) { @@ -356,6 +403,17 @@ module('Integration | Component | picker', function (hooks) { ['select-all'], 'select-all replaces existing selections when selected', ); + // Select-all checkbox should be checked, Option 1 unchecked + assert + .dom( + '[data-test-boxel-picker-option-row="select-all"] .picker-option-row__checkbox--selected', + ) + .exists('Select-all checkbox is checked'); + assert + .dom( + '[data-test-boxel-picker-option-row="1"] .picker-option-row__checkbox--selected', + ) + .doesNotExist('Option 1 checkbox is unchecked after select-all'); }); test('picker hides remove button for select-all pill', async function (assert) { diff --git a/packages/host/app/components/card-catalog/modal.gts b/packages/host/app/components/card-catalog/modal.gts index 092289a7e60..3c9adedf93c 100644 --- a/packages/host/app/components/card-catalog/modal.gts +++ b/packages/host/app/components/card-catalog/modal.gts @@ -22,12 +22,17 @@ import { type CodeRef, type CreateNewCard, type Filter, + type ResolvedCodeRef, baseRealm, Deferred, + isResolvedCodeRef, } from '@cardstack/runtime-common'; import type { Query } from '@cardstack/runtime-common/query'; +import { getFilterTypeRefs } from '@cardstack/host/utils/card-search/type-filter'; +import type { NewCardArgs } from '@cardstack/host/utils/card-search/types'; + import type { CardDef } from 'https://cardstack.com/base/card-api'; import { @@ -46,7 +51,6 @@ import type OperatorModeStateService from '../../services/operator-mode-state-se import type RealmService from '../../services/realm'; import type RealmServerService from '../../services/realm-server'; import type StoreService from '../../services/store'; -import type { NewCardArgs } from '../card-search/utils'; interface Signature { Args: {}; @@ -111,9 +115,8 @@ export default class CardCatalogModal extends Component { { <:header> { return this.stateStack[this.stateStack.length - 1]; } + private get initialSelectedRealmsForPanel(): URL[] | undefined { + if (!this.state?.preselectConsumingRealm || !this.state?.consumingRealm) { + return undefined; + } + return [this.state.consumingRealm]; + } + + private get initialSelectedTypesForPanel(): ResolvedCodeRef[] | undefined { + let baseFilter = this.state?.baseFilter; + if (!baseFilter) { + return undefined; + } + let typeRefs = getFilterTypeRefs(baseFilter); + if (!typeRefs || typeRefs.length === 0) { + return undefined; + } + let refs = typeRefs + .filter((r) => !r.negated && isResolvedCodeRef(r.ref)) + .map((r) => r.ref as ResolvedCodeRef); + return refs.length > 0 ? refs : undefined; + } + private get offerToCreateArg() { if (!this.state) { return undefined; diff --git a/packages/host/app/components/card-search/constants.ts b/packages/host/app/components/card-search/constants.ts index 97340d2960e..96e7dfe5ce9 100644 --- a/packages/host/app/components/card-search/constants.ts +++ b/packages/host/app/components/card-search/constants.ts @@ -36,6 +36,13 @@ export const SECTION_SHOW_MORE_INCREMENT = 5; /** * Host-local SORT_OPTIONS compatible with realm server query expectations. * Aligned with SORT_OPTIONS in packages/base/components/cards-grid-layout.gts. + * + * Sort is dual-mode: + * - Server-side: the `sort` field is embedded in the search Query sent to + * the realm server, affecting prerendered search results (realm sections). + * - Client-side: `sortAndFilterRecentCards()` in utils/card-search/recent-cards.ts + * applies the same sort locally to recent cards, matching by `displayName`. + * - URL card lookup: no sort (single card). */ export const SORT_OPTIONS: SortOption[] = [ { diff --git a/packages/host/app/components/card-search/item-button.gts b/packages/host/app/components/card-search/item-button.gts index 30732b96e51..74c2a2782f1 100644 --- a/packages/host/app/components/card-search/item-button.gts +++ b/packages/host/app/components/card-search/item-button.gts @@ -11,13 +11,15 @@ import { isCardInstance } from '@cardstack/runtime-common'; import type RealmService from '@cardstack/host/services/realm'; +import { + removeFileExtension, + type NewCardArgs, +} from '@cardstack/host/utils/card-search/types'; + import type { CardDef } from 'https://cardstack.com/base/card-api'; import CardRenderer from '../card-renderer'; -import { removeFileExtension } from './utils'; - -import type { NewCardArgs } from './utils'; import type { ComponentLike } from '@glint/template'; type ItemType = ComponentLike<{ Element: Element }> | CardDef | NewCardArgs; diff --git a/packages/host/app/components/card-search/panel.gts b/packages/host/app/components/card-search/panel.gts index c8b56b183da..3fe85f3a106 100644 --- a/packages/host/app/components/card-search/panel.gts +++ b/packages/host/app/components/card-search/panel.gts @@ -1,469 +1,115 @@ -import { isDestroyed, isDestroying } from '@ember/destroyable'; import { action } from '@ember/object'; import { getOwner } from '@ember/owner'; import { service } from '@ember/service'; import Component from '@glimmer/component'; -import { cached, tracked } from '@glimmer/tracking'; +import { tracked } from '@glimmer/tracking'; -import { restartableTask, timeout } from 'ember-concurrency'; -import { consume } from 'ember-provide-consume-context'; +import type { Filter, ResolvedCodeRef } from '@cardstack/runtime-common'; -import { resource, use } from 'ember-resources'; - -import { TrackedObject } from 'tracked-built-ins'; - -import type { PickerOption } from '@cardstack/boxel-ui/components'; - -import { - type Filter, - type ResolvedCodeRef, - type getCardCollection, - baseCardRef, - baseFieldRef, - baseRef, - CardContextName, - GetCardCollectionContextName, - internalKeyFor, - isResolvedCodeRef, -} from '@cardstack/runtime-common'; - -import { getPrerenderedSearch } from '@cardstack/host/resources/prerendered-search'; -import type RealmService from '@cardstack/host/services/realm'; +import type { RealmFilter } from '@cardstack/host/components/realm-picker'; +import type { TypeFilter } from '@cardstack/host/components/type-picker'; +import { getTypeSummaries } from '@cardstack/host/resources/type-summaries'; import type RealmServerService from '@cardstack/host/services/realm-server'; -import type RecentCards from '@cardstack/host/services/recent-cards-service'; - -import type { CardContext, CardDef } from 'https://cardstack.com/base/card-api'; import { SORT_OPTIONS, type SortOption } from './constants'; import SearchBar from './search-bar'; import SearchContent from './search-content'; -import { - buildSearchQuery, - filterCardsByTypeRefs, - getFilterTypeRefs, - shouldSkipSearchQuery, -} from './utils'; import type { WithBoundArgs } from '@glint/template'; -const OWNER_DESTROYED_ERROR = 'OWNER_DESTROYED_ERROR'; - -type TypeSummaryItem = { - id: string; - type: string; - attributes: { displayName: string; total: number; iconHTML: string }; - meta?: { realmURL: string }; -}; - interface Signature { Args: { searchKey: string; baseFilter?: Filter; - initialSelectedType?: ResolvedCodeRef; - availableRealmUrls?: string[]; - consumingRealm?: URL; - preselectConsumingRealm?: boolean; - onFilterChange?: () => void; + initialSelectedTypes?: ResolvedCodeRef[]; + initialSelectedRealms?: URL[]; + onRealmChange?: (selectedRealms: URL[]) => void; + onTypeChange?: (selectedTypes: ResolvedCodeRef[]) => void; }; Blocks: { default: [ - WithBoundArgs< - typeof SearchBar, - | 'selectedRealms' - | 'onRealmChange' - | 'selectedTypes' - | 'onTypeChange' - | 'typeOptions' - | 'onTypeSearchChange' - | 'onLoadMoreTypes' - | 'hasMoreTypes' - | 'isLoadingTypes' - | 'isLoadingMoreTypes' - | 'typesTotalCount' - | 'disableSelectAll' - >, + WithBoundArgs, WithBoundArgs< typeof SearchContent, | 'searchKey' - | 'selectedRealmURLs' - | 'selectedCardTypes' + | 'realmFilter' + | 'typeFilter' | 'baseFilter' - | 'skipTypeFiltering' - | 'searchResource' | 'activeSort' | 'onSortChange' - | 'filteredRecentCards' | 'initialFocusedSection' >, - string, ]; }; } export default class SearchPanel extends Component { - @service declare private realm: RealmService; @service declare private realmServer: RealmServerService; - @service declare private recentCardsService: RecentCards; - @consume(CardContextName) declare private cardContext: - | CardContext - | undefined; - @consume(GetCardCollectionContextName) - declare private getCardCollection: getCardCollection; - @tracked private selectedRealms: PickerOption[] = this.initialSelectedRealms; - - private get shouldPreselectConsumingRealm(): boolean { - return Boolean(this.args.preselectConsumingRealm); - } + @tracked private selectedRealms: URL[] = + this.args.initialSelectedRealms ?? []; + @tracked private activeSort: SortOption = SORT_OPTIONS[0]; - private get initialSelectedRealms(): PickerOption[] { - let consumingRealm = this.args.consumingRealm; - if (!this.shouldPreselectConsumingRealm || !consumingRealm) { - return []; - } - let realmURL = consumingRealm.href; - let info = this.realm.info(realmURL); - let label = info?.name ?? realmURL; - let icon = info?.iconURL ?? undefined; - return [{ id: realmURL, icon, label, type: 'option' }]; - } + private typeSummaries = getTypeSummaries(this, getOwner(this)!, () => ({ + realmURLs: this.selectedRealmURLs, + baseFilter: this.args.baseFilter, + initialSelectedTypes: this.args.initialSelectedTypes, + })); private get initialFocusedSectionId(): string | null { - let consumingRealm = this.args.consumingRealm; - if (!this.shouldPreselectConsumingRealm || !consumingRealm) { + let realmURLs = this.args.initialSelectedRealms; + if (!realmURLs || realmURLs.length === 0) { return null; } - return `realm:${consumingRealm.href}`; + return `realm:${realmURLs[0].href}`; } - @tracked private activeSort: SortOption = SORT_OPTIONS[0]; - - // Type summaries state - @tracked private _typeSearchKey = ''; - @tracked private _typeSummariesData: TypeSummaryItem[] = []; - @tracked private _isLoadingTypes = false; - @tracked private _isLoadingMoreTypes = false; - @tracked private _hasMoreTypes = false; - @tracked private _typesTotalCount = 0; - - @cached - private get recentCardCollection(): ReturnType { - return this.getCardCollection( - this, - () => this.recentCardsService.recentCardIds, - ); - } - - // Non-tracked: persists across resource re-runs without creating - // tracking dependencies. Updated by onTypeChange and the resource itself. - private _previousSelectedTypes: PickerOption[] = []; - private get selectedRealmURLs(): string[] { - const hasSelectAll = this.selectedRealms.some( - (opt) => opt.type === 'select-all', - ); - if (hasSelectAll || this.selectedRealms.length === 0) { - return ( - this.args.availableRealmUrls ?? this.realmServer.availableRealmURLs - ); + if (this.selectedRealms.length === 0) { + return this.realmServer.availableRealmURLs; } - return this.selectedRealms.map((opt) => opt.id).filter(Boolean); + return this.selectedRealms.map((url) => url.href); } - private get joinedSelectedRealmURLs(): string { - return this.selectedRealmURLs.join(','); - } - - private get baseFilteredRecentCards(): CardDef[] { - const cards = - (this.recentCardCollection?.cards?.filter(Boolean) as - | CardDef[] - | undefined) ?? []; - const realmURLs = this.selectedRealmURLs; - const realmFiltered = cards.filter( - (c) => c.id && realmURLs.some((url) => c.id.startsWith(url)), - ); - const typeRefs = getFilterTypeRefs(this.args.baseFilter); - return filterCardsByTypeRefs(realmFiltered, typeRefs); - } - - private get cardComponentModifier() { - if (isDestroying(this) || isDestroyed(this)) { - return undefined; - } - try { - return this.cardContext?.cardComponentModifier; - } catch (error) { - if ( - error instanceof Error && - error.message.includes(OWNER_DESTROYED_ERROR) - ) { - return undefined; - } - throw error; - } - } - - @tracked private _currentPage = 0; - static PAGE_SIZE = 25; - - private fetchTypeSummariesTask = restartableTask( - async (realmURLs: string[], searchKey: string) => { - if (searchKey) { - await timeout(300); // debounce search - } else { - await Promise.resolve(); // yield to avoid autotracking assertion - } - if (isDestroyed(this) || isDestroying(this)) return; - this._isLoadingTypes = true; - this._currentPage = 0; - - try { - let result = await this.realmServer.fetchCardTypeSummaries(realmURLs, { - searchKey: searchKey || undefined, - page: { number: 0, size: SearchPanel.PAGE_SIZE }, - }); - if (isDestroyed(this) || isDestroying(this)) return; - - this._typeSummariesData = result.data; - this._typesTotalCount = result.meta.page.total; - this._hasMoreTypes = result.data.length < result.meta.page.total; - - // If there are selected types (or an initialSelectedType) not yet in - // the fetched results, keep fetching more pages until they're found. - const selectedIds = new Set( - this._previousSelectedTypes - .filter((opt) => opt.type !== 'select-all') - .map((opt) => opt.id), - ); - const initialType = this.args.initialSelectedType; - if (initialType) { - selectedIds.add(internalKeyFor(initialType, undefined)); - } - - if (selectedIds.size > 0 && this._hasMoreTypes) { - while (this._hasMoreTypes) { - const fetchedIds = new Set( - this._typeSummariesData.map((d) => d.id), - ); - if ([...selectedIds].every((id) => fetchedIds.has(id))) break; - - const nextPage = this._currentPage + 1; - let moreResult = await this.realmServer.fetchCardTypeSummaries( - realmURLs, - { - searchKey: searchKey || undefined, - page: { number: nextPage, size: SearchPanel.PAGE_SIZE }, - }, - ); - if (isDestroyed(this) || isDestroying(this)) return; - - this._currentPage = nextPage; - this._typeSummariesData = [ - ...this._typeSummariesData, - ...moreResult.data, - ]; - this._hasMoreTypes = - this._typeSummariesData.length < moreResult.meta.page.total; - } - } + // -- Filter objects -- - this._isLoadingTypes = false; - } catch (e) { - console.error('Failed to fetch card type summaries', e); - if (!isDestroyed(this) && !isDestroying(this)) { - this._typeSummariesData = []; - this._typesTotalCount = 0; - this._hasMoreTypes = false; - this._isLoadingTypes = false; - } - } - }, - ); - - // Resource that watches selectedRealmURLs and typeSearchKey to trigger fetches - @use private _typeFetchTrigger = resource(() => { - let realmURLs = this.selectedRealmURLs; - let searchKey = this._typeSearchKey; - - this.fetchTypeSummariesTask.perform(realmURLs, searchKey); - - 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)); - } - } - if (refs.size === 0) return undefined; - - // CardDef/FieldDef are root types — all card types inherit from them, - // so filtering by them would incorrectly show zero results. Skip. - const baseKeys = new Set([ - internalKeyFor(baseCardRef, undefined), - internalKeyFor(baseFieldRef, undefined), - internalKeyFor(baseRef, undefined), - ]); - if ([...refs].every((r) => baseKeys.has(r))) { - return undefined; - } - - return refs; - } - - private get hasNonRootBaseFilter(): boolean { - return this.baseFilterCodeRefs !== undefined; + private get realmFilter(): RealmFilter { + return { + selected: this.selectedRealms, + onChange: this.onRealmChange, + selectedURLs: this.selectedRealmURLs, + }; } - @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: [], - options: [], - }); - - const allowedCodeRefs = this.baseFilterCodeRefs; - const optionsById = new Map(); - - const rootTypeKeys = new Set([ - internalKeyFor(baseCardRef, undefined), - internalKeyFor(baseFieldRef, undefined), - internalKeyFor(baseRef, undefined), - ]); - - for (const item of this._typeSummariesData) { - const name = item.attributes.displayName; - const codeRef = item.id; - if (!name) { - continue; - } - - // Never show root types — they are internal meta types - if (rootTypeKeys.has(codeRef)) { - continue; - } - - // When baseFilter constrains to specific types, only show matching types - if (allowedCodeRefs && !allowedCodeRefs.has(codeRef)) { - continue; - } - - optionsById.set(codeRef, { - id: codeRef, - label: name, - tooltip: codeRef, - icon: item.attributes.iconHTML ?? undefined, - type: 'option', - }); - } - - value.options = [ - ...[...optionsById.values()].sort((a, b) => - a.label.localeCompare(b.label), - ), - ]; - - // Recalculate selected based on previous user selection. - // An empty array lets the Picker's ensureDefaultSelection() handle - // selecting the built-in select-all option automatically. - const prev = this._previousSelectedTypes; - const initialType = this.args.initialSelectedType; - const hadSelectAll = - prev.length === 0 || prev.some((opt) => opt.type === 'select-all'); - if (initialType && prev.length === 0) { - // First launch with initialSelectedType (e.g., from "Find Instances"). - // Only applied when prev is empty (first computation). After that, - // _previousSelectedTypes is always non-empty, preserving user's choice. - const typeKey = internalKeyFor(initialType, undefined); - const matchingOption = optionsById.get(typeKey); - if (matchingOption) { - value.selected = [matchingOption]; - } else { - // Type summaries not yet loaded; create a synthetic option so the - // search query is type-constrained immediately. - value.selected = [ - { - id: typeKey, - label: initialType.name, - type: 'option', - } as PickerOption, - ]; - } - } else if (hadSelectAll) { - // If baseFilter constrains to specific types and they exist in options, - // auto-select them instead of defaulting to "Any Type" - const baseTypeRefs = getFilterTypeRefs(this.args.baseFilter); - const baseRefs = - baseTypeRefs - ?.filter((r) => !r.negated && isResolvedCodeRef(r.ref)) - .map((r) => internalKeyFor(r.ref, undefined)) ?? []; - if (baseRefs.length > 0) { - const autoSelected = baseRefs - .filter((ref) => optionsById.has(ref)) - .map((ref) => optionsById.get(ref)!); - value.selected = autoSelected.length > 0 ? autoSelected : []; - } else { - value.selected = []; - } - } 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, - // mapping to new object references so Picker's isSelected (which - // uses reference equality via lodash includes) works correctly. - const kept = prev - .filter((opt) => opt.type !== 'select-all' && optionsById.has(opt.id)) - .map((opt) => optionsById.get(opt.id)!); - value.selected = kept.length > 0 ? kept : []; - } - - this._previousSelectedTypes = value.selected; - return value; - }); - - private searchResource = getPrerenderedSearch(this, getOwner(this)!, () => { - // Consume typeFilter.selected outside the ternary so the tracking - // dependency is always established, even when the query is skipped. - const selectedTypeIds = this.typeFilter.selected.map((opt) => opt.id); + private get typeFilter(): TypeFilter { + let ts = this.typeSummaries; return { - query: shouldSkipSearchQuery(this.args.searchKey, this.args.baseFilter) - ? undefined - : buildSearchQuery( - this.args.searchKey, - this.activeSort, - this.args.baseFilter, - selectedTypeIds, - ), - format: 'fitted' as const, - realms: this.selectedRealmURLs, - isLive: true, - cardComponentModifier: this.cardComponentModifier, + options: ts.options, + selected: ts.selected, + onChange: this.onTypeChange, + onSearchChange: this.onTypeSearchChange, + onLoadMore: this.onLoadMoreTypes, + hasMore: ts.hasMore, + isLoading: ts.isLoading, + isLoadingMore: ts.isLoadingMore, + totalCount: ts.totalCount, + disableSelectAll: ts.hasNonRootBaseFilter, + skipTypeFiltering: ts.hasNonRootBaseFilter, }; - }); + } + + // -- Actions -- @action - private onRealmChange(selected: PickerOption[]) { + private onRealmChange(selected: URL[]) { this.selectedRealms = selected; - this.args.onFilterChange?.(); + this.args.onRealmChange?.(selected); } @action - private onTypeChange(selected: PickerOption[]) { - this._previousSelectedTypes = selected; - this.typeFilter.selected = selected; - this.args.onFilterChange?.(); + private onTypeChange(selected: ResolvedCodeRef[]) { + this.typeSummaries.updateSelected(selected); + this.args.onTypeChange?.(selected); } @action @@ -473,81 +119,32 @@ export default class SearchPanel extends Component { @action private onTypeSearchChange(term: string) { - this._typeSearchKey = term; - this.fetchTypeSummariesTask.perform(this.selectedRealmURLs, term); + this.typeSummaries.onSearchChange(term); } - private loadMoreTypesTask = restartableTask(async () => { - if (isDestroyed(this) || isDestroying(this)) return; - this._isLoadingMoreTypes = true; - const nextPage = this._currentPage + 1; - - try { - let result = await this.realmServer.fetchCardTypeSummaries( - this.selectedRealmURLs, - { - searchKey: this._typeSearchKey || undefined, - page: { number: nextPage, size: SearchPanel.PAGE_SIZE }, - }, - ); - if (isDestroyed(this) || isDestroying(this)) return; - - this._currentPage = nextPage; - this._typeSummariesData = [...this._typeSummariesData, ...result.data]; - const totalFetched = this._typeSummariesData.length; - this._hasMoreTypes = totalFetched < result.meta.page.total; - this._isLoadingMoreTypes = false; - } catch (e) { - console.error('Failed to load more card type summaries', e); - if (!isDestroyed(this) && !isDestroying(this)) { - this._isLoadingMoreTypes = false; - } - } - }); - @action private onLoadMoreTypes() { - if ( - this._isLoadingTypes || - this._isLoadingMoreTypes || - !this._hasMoreTypes - ) { - return; - } - this.loadMoreTypesTask.perform(); + this.typeSummaries.onLoadMore(); } } diff --git a/packages/host/app/components/card-search/search-bar.gts b/packages/host/app/components/card-search/search-bar.gts index 6bedd97ac82..1233409175b 100644 --- a/packages/host/app/components/card-search/search-bar.gts +++ b/packages/host/app/components/card-search/search-bar.gts @@ -8,12 +8,15 @@ import { BoxelInput, type BoxelInputBottomTreatments, } from '@cardstack/boxel-ui/components'; -import type { PickerOption } from '@cardstack/boxel-ui/components'; import { IconSearch } from '@cardstack/boxel-ui/icons'; import { autoFocus } from '@cardstack/boxel-ui/modifiers'; -import RealmPicker from '@cardstack/host/components/realm-picker'; -import TypePicker from '@cardstack/host/components/type-picker'; +import RealmPicker, { + type RealmFilter, +} from '@cardstack/host/components/realm-picker'; +import TypePicker, { + type TypeFilter, +} from '@cardstack/host/components/type-picker'; let elementCallback = modifier( (element, [callback]: [((element: HTMLElement) => void) | undefined]) => { @@ -27,6 +30,8 @@ interface Signature { Element: HTMLElement; Args: { value: string; + realmFilter: RealmFilter; + typeFilter: TypeFilter; placeholder?: string; autocomplete?: string; onInput?: (value: string) => void; @@ -34,18 +39,6 @@ interface Signature { onBlur?: (ev: Event) => void; onKeyDown?: (ev: Event) => void; onInputInsertion?: (element: HTMLElement) => void; - selectedRealms: PickerOption[]; - onRealmChange: (selected: PickerOption[]) => void; - typeOptions: PickerOption[]; - selectedTypes: PickerOption[]; - onTypeChange: (selected: PickerOption[]) => void; - onTypeSearchChange?: (term: string) => void; - onLoadMoreTypes?: () => void; - hasMoreTypes?: boolean; - isLoadingTypes?: boolean; - isLoadingMoreTypes?: boolean; - typesTotalCount?: number; - disableSelectAll?: boolean; pickerDestination?: string; bottomTreatment?: BoxelInputBottomTreatments; state?: 'none' | 'valid' | 'invalid' | 'loading' | 'initial'; @@ -75,23 +68,13 @@ export default class SearchBar extends Component {
diff --git a/packages/host/app/components/card-search/search-content.gts b/packages/host/app/components/card-search/search-content.gts index b521952d6c3..863fab6dc97 100644 --- a/packages/host/app/components/card-search/search-content.gts +++ b/packages/host/app/components/card-search/search-content.gts @@ -1,4 +1,6 @@ +import { isDestroyed, isDestroying } from '@ember/destroyable'; import { action } from '@ember/object'; +import { getOwner } from '@ember/owner'; import { service } from '@ember/service'; import Component from '@glimmer/component'; import { cached, tracked } from '@glimmer/tracking'; @@ -8,42 +10,54 @@ import { consume } from 'ember-provide-consume-context'; import pluralize from 'pluralize'; -import type { PickerOption } from '@cardstack/boxel-ui/components'; - import { eq } from '@cardstack/boxel-ui/helpers'; import { type CodeRef, type Filter, type getCard, + type getCardCollection, + CardContextName, GetCardContextName, + GetCardCollectionContextName, + internalKeyFor, } from '@cardstack/runtime-common'; -import { cardTypeDisplayName } from '@cardstack/runtime-common/helpers/card-type-display-name'; - -import { urlForRealmLookup } from '@cardstack/host/lib/utils'; -import type { PrerenderedSearchResource } from '@cardstack/host/resources/prerendered-search'; -import type LoaderService from '@cardstack/host/services/loader-service'; +import type { RealmFilter } from '@cardstack/host/components/realm-picker'; +import type { TypeFilter } from '@cardstack/host/components/type-picker'; +import { getPrerenderedSearch } from '@cardstack/host/resources/prerendered-search'; import type RealmService from '@cardstack/host/services/realm'; import type RealmServerService from '@cardstack/host/services/realm-server'; -import type StoreService from '@cardstack/host/services/store'; - -import type { CardDef } from 'https://cardstack.com/base/card-api'; +import type RecentCards from '@cardstack/host/services/recent-cards-service'; import { - SECTION_DISPLAY_LIMIT_FOCUSED, - SECTION_DISPLAY_LIMIT_UNFOCUSED, - SECTION_SHOW_MORE_INCREMENT, - SORT_OPTIONS, - VIEW_OPTIONS, - type SortOption, -} from './constants'; + buildSearchQuery, + shouldSkipSearchQuery, +} from '@cardstack/host/utils/card-search/query-builder'; +import { + filterRecentCards, + sortAndFilterRecentCards, +} from '@cardstack/host/utils/card-search/recent-cards'; +import { SectionPagination } from '@cardstack/host/utils/card-search/section-pagination'; +import { + assembleSections, + buildQuerySections, + buildRecentsSection, + buildUrlSection, + type SearchSheetSection, +} from '@cardstack/host/utils/card-search/sections'; +import type { NewCardArgs } from '@cardstack/host/utils/card-search/types'; +import { + isURLSearchKey, + resolveSearchKeyAsURL, +} from '@cardstack/host/utils/card-search/url'; + +import type { CardContext, CardDef } from 'https://cardstack.com/base/card-api'; + +import { SORT_OPTIONS, VIEW_OPTIONS, type SortOption } from './constants'; import SearchResultHeader from './search-result-header'; import SearchResultSection from './search-result-section'; -import type { PrerenderedCard } from '../prerendered-card-search'; - -import type { NewCardArgs } from './utils'; import type { NamedArgs } from 'ember-modifier'; interface ScrollToFocusedSectionSignature { @@ -101,59 +115,25 @@ class ScrollToFocusedSection extends Modifier { } } -export interface RealmSectionInfo { - name: string; - iconURL: string | null; - publishable: boolean | null; -} - -export interface RealmSection { - sid: string; - type: 'realm'; - realmUrl: string; - realmInfo: RealmSectionInfo; - cards: PrerenderedCard[]; - totalCount: number; -} - -export interface RecentsSection { - sid: string; - type: 'recents'; - cards: CardDef[]; - totalCount: number; -} - -export interface UrlSection { - sid: string; - type: 'url'; - card: CardDef; - realmInfo: RealmSectionInfo; -} - -export type SearchSheetSection = RealmSection | RecentsSection | UrlSection; - interface Signature { Element: HTMLElement; Args: { searchKey: string; - selectedRealmURLs: string[]; + realmFilter: RealmFilter; + typeFilter: TypeFilter; + baseFilter?: Filter; isCompact: boolean; handleSelect: (selection: string | NewCardArgs) => void; selectedCards?: (string | NewCardArgs)[]; multiSelect?: boolean; onSelectAll?: (cards: string[]) => void; onDeselectAll?: () => void; - baseFilter?: Filter; - skipTypeFiltering?: boolean; offerToCreate?: { ref: CodeRef; relativeTo: URL | undefined; }; onSubmit?: (selection: string | NewCardArgs) => void; showHeader?: boolean; - selectedCardTypes?: PickerOption[]; - filteredRecentCards?: CardDef[]; - searchResource: PrerenderedSearchResource; activeSort: SortOption; onSortChange: (sort: SortOption) => void; initialFocusedSection?: string | null; @@ -161,25 +141,109 @@ interface Signature { Blocks: {}; } +const OWNER_DESTROYED_ERROR = 'OWNER_DESTROYED_ERROR'; + export default class SearchContent extends Component { - @service declare loaderService: LoaderService; @service declare realm: RealmService; @service declare realmServer: RealmServerService; - @service declare store: StoreService; + @service('recent-cards-service') + declare private recentCardsService: RecentCards; @tracked activeViewId = 'grid'; - /** Section id when focused: 'realm:' or 'recents'. Null = no focus */ - @tracked focusedSection: string | null = - this.args.initialFocusedSection ?? null; - @tracked displayedCountBySection: Record = {}; + private pagination = new SectionPagination(this.args.initialFocusedSection); @consume(GetCardContextName) declare private getCard: getCard; + @consume(CardContextName) declare private cardContext: + | CardContext + | undefined; + @consume(GetCardCollectionContextName) + declare private getCardCollection: getCardCollection; + + private get searchKeyIsURL() { + return isURLSearchKey(this.args.searchKey); + } + + private get searchKeyAsURL() { + return resolveSearchKeyAsURL( + this.args.searchKey, + this.realmServer.availableRealmURLs, + ); + } @cached private get cardResource(): ReturnType { return this.getCard(this, () => this.searchKeyAsURL); } + private get resolvedCard(): CardDef | undefined { + return this.cardResource?.card; + } + + private get isCardResourceLoaded(): boolean { + return this.cardResource?.isLoaded ?? false; + } + + // -- Card component modifier -- + + private get cardComponentModifier() { + if (isDestroying(this) || isDestroyed(this)) { + return undefined; + } + try { + return this.cardContext?.cardComponentModifier; + } catch (error) { + if ( + error instanceof Error && + error.message.includes(OWNER_DESTROYED_ERROR) + ) { + return undefined; + } + throw error; + } + } + + private searchResource = getPrerenderedSearch(this, getOwner(this)!, () => { + // Consume selectedTypeIds outside the ternary so the tracking + // dependency is always established, even when the query is skipped. + const selectedTypeIds = this.args.typeFilter.selected.map((ref) => + internalKeyFor(ref, undefined), + ); + return { + query: shouldSkipSearchQuery(this.args.searchKey, this.args.baseFilter) + ? undefined + : buildSearchQuery( + this.args.searchKey, + this.args.activeSort, + this.args.baseFilter, + selectedTypeIds, + ), + format: 'fitted' as const, + realms: this.args.realmFilter.selectedURLs, + isLive: true, + cardComponentModifier: this.cardComponentModifier, + }; + }); + + @cached + private get recentCardCollection(): ReturnType { + return this.getCardCollection( + this, + () => this.recentCardsService.recentCardIds, + ); + } + + private get filteredRecentCards(): CardDef[] { + const cards = + (this.recentCardCollection?.cards?.filter(Boolean) as + | CardDef[] + | undefined) ?? []; + return filterRecentCards( + cards, + this.args.realmFilter.selectedURLs, + this.args.baseFilter, + ); + } + private get shouldSkipQuery() { // In baseFilter mode (modal), only skip when search key is a URL if (this.args.baseFilter) { @@ -197,15 +261,6 @@ export default class SearchContent extends Component { return (this.args.searchKey?.trim() ?? '') === ''; } - private get searchKeyIsURL() { - try { - new URL(this.args.searchKey); - return true; - } catch (_e) { - return false; - } - } - private get searchTerm(): string | undefined { if (this.isSearchKeyEmpty || this.searchKeyIsURL) { return undefined; @@ -213,30 +268,10 @@ export default class SearchContent extends Component { return this.args.searchKey?.trim(); } - private get searchKeyAsURL() { - if (!this.searchKeyIsURL) { - return undefined; - } - let cardURL = this.args.searchKey; - - let maybeIndexCardURL = this.realmServer.availableRealmURLs.find( - (u) => u === cardURL + '/', - ); - return maybeIndexCardURL ?? cardURL; - } - - private get resolvedCard() { - return this.cardResource?.card; - } - - private get isCardResourceLoaded() { - return this.cardResource?.isLoaded ?? false; - } - private get realms() { const urls = - this.args.selectedRealmURLs.length > 0 - ? this.args.selectedRealmURLs + this.args.realmFilter.selectedURLs.length > 0 + ? this.args.realmFilter.selectedURLs : this.realmServer.availableRealmURLs; return urls ?? []; } @@ -246,7 +281,7 @@ export default class SearchContent extends Component { return ''; } - if (this.args.searchResource.isLoading) { + if (this.searchResource.isLoading) { return 'Searching…'; } @@ -259,7 +294,7 @@ export default class SearchContent extends Component { } // Query search results - const total = this.args.searchResource.meta.page?.total ?? 0; + const total = this.searchResource.meta.page?.total ?? 0; const realms = this.realms; // Default: all results across all realms @@ -267,81 +302,15 @@ export default class SearchContent extends Component { } private get sortedRecentCards(): CardDef[] { - let cards = [...(this.args.filteredRecentCards ?? [])]; - - // Apply type picker filter (from TypePicker selection). - // Skip when baseFilter has a non-root type — the server already - // constrains results via the adoption chain, and recent cards are - // pre-filtered by filterCardsByTypeRefs which handles subtypes. - if (!this.args.skipTypeFiltering) { - const pickerSelectedTypeNames = new Set( - (this.args.selectedCardTypes ?? []) - .filter((opt) => opt.type !== 'select-all') - .map((opt) => opt.label), - ); - if (pickerSelectedTypeNames.size > 0) { - cards = cards.filter((card) => - pickerSelectedTypeNames.has(cardTypeDisplayName(card)), - ); - } - } - - if (this.args.isCompact) { - return cards; - } - let filtered = cards; - const term = this.searchTerm; - if (term) { - const lowerTerm = term.toLowerCase(); - filtered = cards.filter((c) => - (c.cardTitle ?? '').toLowerCase().includes(lowerTerm), - ); - } - const sortOption = this.args.activeSort; - const displayName = sortOption.displayName; - return [...filtered].sort((a, b) => { - if (displayName === 'A-Z') { - return (a.cardTitle ?? '').localeCompare(b.cardTitle ?? ''); - } - if (displayName === 'Last Updated') { - const aVal = - 'lastModified' in a - ? ((a as Record).lastModified as number) - : 0; - const bVal = - 'lastModified' in b - ? ((b as Record).lastModified as number) - : 0; - return bVal - aVal; - } - if (displayName === 'Date Created') { - const aVal = - 'createdAt' in a - ? ((a as Record).createdAt as number) - : 0; - const bVal = - 'createdAt' in b - ? ((b as Record).createdAt as number) - : 0; - return bVal - aVal; - } - return 0; + return sortAndFilterRecentCards(this.filteredRecentCards, { + selectedTypes: this.args.typeFilter.selected, + skipTypeFiltering: this.args.typeFilter.skipTypeFiltering, + searchTerm: this.searchTerm, + activeSort: this.args.activeSort, + isCompact: this.args.isCompact, }); } - private get recentCardsSection(): RecentsSection | undefined { - const cards = this.sortedRecentCards; - if (cards.length === 0) { - return undefined; - } - return { - sid: 'recents', - type: 'recents', - cards, - totalCount: cards.length, - }; - } - @action onChangeView(id: string) { this.activeViewId = id; @@ -354,183 +323,60 @@ export default class SearchContent extends Component { @action onFocusSection(sectionId: string | null) { - this.focusedSection = sectionId; - if (sectionId) { - const current = this.displayedCountBySection[sectionId] ?? 0; - const limit = SECTION_DISPLAY_LIMIT_FOCUSED; - if (current < limit) { - this.displayedCountBySection = { - ...this.displayedCountBySection, - [sectionId]: limit, - }; - } - } + this.pagination.focus(sectionId); } getDisplayedCount = (sectionId: string, totalCount: number): number => { - const isFocused = this.focusedSection === sectionId; - const initialLimit = isFocused - ? SECTION_DISPLAY_LIMIT_FOCUSED - : SECTION_DISPLAY_LIMIT_UNFOCUSED; - const current = this.displayedCountBySection[sectionId] ?? initialLimit; - return Math.min(current, totalCount); + return this.pagination.getDisplayedCount(sectionId, totalCount); }; @action onShowMore(sectionId: string, totalCount: number) { - const current = this.getDisplayedCount(sectionId, totalCount); - const next = Math.min(current + SECTION_SHOW_MORE_INCREMENT, totalCount); - this.displayedCountBySection = { - ...this.displayedCountBySection, - [sectionId]: next, - }; + this.pagination.showMore(sectionId, totalCount); } - private realmNameFromUrl(realmUrl: string): string { - try { - const pathname = new URL(realmUrl).pathname; - const segments = pathname.split('/').filter(Boolean); - return segments[segments.length - 1] ?? 'Workspace'; - } catch { - return 'Workspace'; - } + private get recentCardsSection() { + return buildRecentsSection(this.sortedRecentCards); } - private get cardByUrlSection(): SearchSheetSection | undefined { - if (!this.searchKeyIsURL || !this.resolvedCard) { - return undefined; - } - const card = this.resolvedCard; - const urlForRealm = urlForRealmLookup(card); - const realmUrl = this.realmUrlForCard(urlForRealm); - const realmInfo = realmUrl ? this.realm.info(realmUrl) : null; - return { - sid: `url:${card.id}`, - type: 'url', - card, - realmInfo: { - name: realmInfo?.name ?? this.realmNameFromUrl(realmUrl), - iconURL: realmInfo?.iconURL ?? null, - publishable: realmInfo?.publishable ?? null, - }, - } as UrlSection; + private get cardByUrlSection() { + return buildUrlSection( + this.resolvedCard, + this.searchKeyIsURL, + this.realms, + this.realm, + ); } - private get cardsByQuerySection(): SearchSheetSection[] | null { - if (this.searchKeyIsURL) { - return null; - } - - // In search-sheet mode (no baseFilter), skip when search key is empty - if (!this.args.baseFilter && this.isSearchKeyEmpty) { - return null; - } - - const cards = this.args.searchResource.instances; - const byRealm = new Map(); - - for (const card of cards) { - const list: PrerenderedCard[] = byRealm.get(card.realmUrl) ?? []; - list.push(card); - byRealm.set(card.realmUrl, list); - } - - const sections: RealmSection[] = []; - for (const [realmUrl, realmCards] of byRealm) { - const realmInfo = this.realm.info(realmUrl); - sections.push({ - sid: `realm:${realmUrl}`, - type: 'realm', - realmUrl, - realmInfo: { - name: realmInfo?.name ?? this.realmNameFromUrl(realmUrl), - iconURL: realmInfo?.iconURL ?? null, - publishable: realmInfo?.publishable ?? null, - }, - cards: realmCards, - totalCount: realmCards.length, - }); - } - - // When offerToCreate is provided, include empty sections for all - // available/selected realms that have no results, so users can - // create new cards in those realms. - if (this.args.offerToCreate) { - for (const realmUrl of this.realms) { - if (!byRealm.has(realmUrl)) { - const realmInfo = this.realm.info(realmUrl); - sections.push({ - sid: `realm:${realmUrl}`, - type: 'realm', - realmUrl, - realmInfo: { - name: realmInfo?.name ?? this.realmNameFromUrl(realmUrl), - iconURL: realmInfo?.iconURL ?? null, - publishable: realmInfo?.publishable ?? null, - }, - cards: [], - totalCount: 0, - }); - } - } - } - - return sections; + private get cardsByQuerySection() { + return buildQuerySections(this.searchResource.instances, { + isURL: this.searchKeyIsURL, + isSearchKeyEmpty: this.isSearchKeyEmpty, + hasBaseFilter: !!this.args.baseFilter, + realmURLs: this.realms, + offerToCreate: this.args.offerToCreate, + realm: this.realm, + }); } private get sections(): SearchSheetSection[] { - const sections: SearchSheetSection[] = []; - - // Add recents section if present - if (this.recentCardsSection) { - sections.push(this.recentCardsSection); - } - - // Add URL section if present - if (this.cardByUrlSection) { - sections.push(this.cardByUrlSection); - } - - // Add query sections if present - if (this.cardsByQuerySection) { - sections.push(...this.cardsByQuerySection); - } - - // Move focused section to front so it appears at the top - if (this.focusedSection) { - const idx = sections.findIndex((s) => s.sid === this.focusedSection); - if (idx > 0) { - const [focused] = sections.splice(idx, 1); - sections.unshift(focused); - } - } - - return sections; - } - - private realmUrlForCard(cardIdOrUrl: string): string { - for (const realm of this.realms) { - if (cardIdOrUrl.startsWith(realm)) { - return realm; - } - } - try { - const url = new URL(cardIdOrUrl); - return `${url.origin}${url.pathname.split('/').slice(0, -1)?.join('/') ?? ''}/`; - } catch { - return ''; - } + return assembleSections( + this.recentCardsSection, + this.cardByUrlSection, + this.cardsByQuerySection, + this.pagination.focusedSection, + ); } @action isSectionCollapsed(sectionId: string): boolean { - return !!this.focusedSection && this.focusedSection !== sectionId; + return this.pagination.isCollapsed(sectionId); } private get allCards(): string[] { const urls: string[] = []; // Cards from search results (realm sections) - respects type filter - for (const card of this.args.searchResource.instances) { + for (const card of this.searchResource.instances) { if (card.url) { urls.push(card.url.replace(/\.json$/, '')); } @@ -551,7 +397,7 @@ export default class SearchContent extends Component { private get hasNoResults(): boolean { return ( this.sections.length === 0 && - !this.args.searchResource.isLoading && + !this.searchResource.isLoading && !this.shouldSkipQuery ); } @@ -559,7 +405,7 @@ export default class SearchContent extends Component {