From c061ae77a73dd12855caa6eecae012a882826623 Mon Sep 17 00:00:00 2001 From: Fadhlan Ridhwanallah Date: Thu, 12 Mar 2026 15:25:01 +0700 Subject: [PATCH 1/7] Fetch type picker options from _federated-types endpoint Instead of deriving type picker options from search results and recent cards, fetch all available types from the _federated-types endpoint so the picker is populated with all types across selected realms regardless of search state. Types with the same display_name but different code_refs appear as one picker option, but all associated code_refs are used when filtering search results. Co-Authored-By: Claude Opus 4.6 --- .../host/app/components/card-search/panel.gts | 99 ++++++++++--------- .../components/card-search/search-content.gts | 32 ++++-- packages/host/app/services/realm-server.ts | 47 +++++++++ .../tests/helpers/realm-server-mock/routes.ts | 55 ++++++++++- 4 files changed, 173 insertions(+), 60 deletions(-) diff --git a/packages/host/app/components/card-search/panel.gts b/packages/host/app/components/card-search/panel.gts index 066400fabbe..a8e66974f78 100644 --- a/packages/host/app/components/card-search/panel.gts +++ b/packages/host/app/components/card-search/panel.gts @@ -20,11 +20,6 @@ import { GetCardCollectionContextName, } from '@cardstack/runtime-common'; -import { - cardTypeDisplayName, - cardTypeIcon, -} from '@cardstack/runtime-common/helpers/card-type-display-name'; - import consumeContext from '@cardstack/host/helpers/consume-context'; import { getPrerenderedSearch } from '@cardstack/host/resources/prerendered-search'; @@ -40,7 +35,6 @@ import { buildSearchQuery, filterCardsByTypeRefs, getFilterTypeRefs, - getSearchTerm, shouldSkipSearchQuery, } from './utils'; @@ -69,6 +63,7 @@ interface Signature { | 'searchKey' | 'selectedRealmURLs' | 'selectedCardTypes' + | 'typeCodeRefs' | 'baseFilter' | 'searchResource' | 'activeSort' @@ -138,18 +133,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; @@ -167,6 +150,41 @@ 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(); + + @use private cardTypeSummaries = resource(() => { + // Track selectedRealmURLs so we re-fetch when realms change + let realmURLs = this.selectedRealmURLs; + let state: { + data: { + id: string; + type: string; + attributes: { displayName: string; total: number; iconHTML: string }; + }[]; + isLoading: boolean; + } = new TrackedObject({ data: [], isLoading: true }); + + (async () => { + try { + let result = await this.realmServer.fetchCardTypeSummaries(realmURLs); + if (!isDestroyed(this) && !isDestroying(this)) { + state.data = result.data; + state.isLoading = false; + } + } catch (e) { + console.error('Failed to fetch card type summaries', e); + if (!isDestroyed(this) && !isDestroying(this)) { + state.data = []; + state.isLoading = false; + } + } + })(); + + return state; + }); + @use private typeFilter = resource(() => { let value: { selected: PickerOption[]; options: PickerOption[] } = new TrackedObject({ @@ -175,37 +193,31 @@ export default class SearchPanel extends Component { }); const seen = new Map(); + const codeRefsByDisplayName = new Map(); - // Derive types from search results (these have icons) - for (const card of this.searchResource.instances) { - const name = card.cardType; + for (const item of this.cardTypeSummaries.data) { + const name = item.attributes.displayName; + const codeRef = item.id; if (!name) { 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)), @@ -220,17 +232,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.cardTypeSummaries.isLoading) { + // 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, @@ -293,6 +297,7 @@ export default class SearchPanel extends Component { searchKey=@searchKey selectedRealmURLs=this.selectedRealmURLs selectedCardTypes=this.typeFilter.selected + typeCodeRefs=this._typeCodeRefs baseFilter=@baseFilter searchResource=this.searchResource activeSort=this.activeSort diff --git a/packages/host/app/components/card-search/search-content.gts b/packages/host/app/components/card-search/search-content.gts index 9420c77465d..7f056c7fad5 100644 --- a/packages/host/app/components/card-search/search-content.gts +++ b/packages/host/app/components/card-search/search-content.gts @@ -15,6 +15,7 @@ import { type Filter, type getCard, GetCardContextName, + internalKeyFor, } from '@cardstack/runtime-common'; import { cardTypeDisplayName } from '@cardstack/runtime-common/helpers/card-type-display-name'; @@ -96,6 +97,7 @@ interface Signature { onSubmit?: (selection: string | NewCardArgs) => void; showHeader?: boolean; selectedCardTypes?: PickerOption[]; + typeCodeRefs?: Map; filteredRecentCards?: CardDef[]; searchResource: PrerenderedSearchResource; activeSort: SortOption; @@ -368,18 +370,28 @@ export default class SearchContent extends Component { } private get filteredSearchResults(): PrerenderedCard[] { - const selectedTypeNames = new Set( - (this.args.selectedCardTypes ?? []) - .filter((opt) => opt.type !== 'select-all') - .map((opt) => opt.id), - ); + const selectedCodeRefs = new Set(); + for (const opt of this.args.selectedCardTypes ?? []) { + if (opt.type === 'select-all') { + continue; + } + for (const ref of this.args.typeCodeRefs?.get(opt.id) ?? []) { + selectedCodeRefs.add(ref); + } + } const allCards = this.args.searchResource.instances; - return selectedTypeNames.size > 0 - ? allCards.filter( - (card) => card.cardType && selectedTypeNames.has(card.cardType), - ) - : allCards; + if (selectedCodeRefs.size === 0) { + return allCards; + } + return allCards.filter((card) => { + if (!card.usedRenderType) { + return false; + } + return selectedCodeRefs.has( + internalKeyFor(card.usedRenderType, undefined), + ); + }); } private get cardsByQuerySection(): SearchSheetSection[] | null { diff --git a/packages/host/app/services/realm-server.ts b/packages/host/app/services/realm-server.ts index 9d62147bca0..955b359c46f 100644 --- a/packages/host/app/services/realm-server.ts +++ b/packages/host/app/services/realm-server.ts @@ -445,6 +445,53 @@ export default class RealmServerService extends Service { return { data: json.data ?? [], publicReadableRealms }; } + async fetchCardTypeSummaries(realmUrls: string[]): Promise<{ + data: { + id: string; + type: 'card-type-summary'; + attributes: { displayName: string; total: number; iconHTML: string }; + }[]; + }> { + if (realmUrls.length === 0) { + return { data: [] }; + } + + let uniqueRealmUrls = Array.from(new Set(realmUrls)); + let realmServerURLs = this.getRealmServersForRealms(uniqueRealmUrls); + // TODO remove this assertion after multi-realm server/federated identity is supported + this.assertOwnRealmServer(realmServerURLs); + let [realmServerURL] = realmServerURLs; + + await this.login(); + + let typesURL = new URL('_federated-types', realmServerURL); + + let response = await this.authedFetch(typesURL.href, { + method: 'QUERY', + headers: { + Accept: SupportedMimeType.CardTypeSummary, + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ realms: uniqueRealmUrls }), + }); + + if (!response.ok) { + let responseText = await response.text(); + throw new Error( + `Failed to fetch federated card type summaries: ${response.status} - ${responseText}`, + ); + } + + let json = (await response.json()) as { + data: { + id: string; + type: 'card-type-summary'; + attributes: { displayName: string; total: number; iconHTML: string }; + }[]; + }; + return { data: json.data ?? [] }; + } + async handleEvent(event: Partial) { let claims = await this.getClaims(); if (event.room_id !== claims.sessionRoom || !event.content) { diff --git a/packages/host/tests/helpers/realm-server-mock/routes.ts b/packages/host/tests/helpers/realm-server-mock/routes.ts index c4e1249f359..191d9865a62 100644 --- a/packages/host/tests/helpers/realm-server-mock/routes.ts +++ b/packages/host/tests/helpers/realm-server-mock/routes.ts @@ -14,10 +14,12 @@ import { type Query, } from '@cardstack/runtime-common'; -import type { - LinkableCollectionDocument, - PrerenderedCardCollectionDocument, +import { + makeCardTypeSummaryDoc, + type LinkableCollectionDocument, + type PrerenderedCardCollectionDocument, } from '@cardstack/runtime-common/document-types'; +import type { CardTypeSummary } from '@cardstack/runtime-common/index-structure'; import ENV from '@cardstack/host/config/environment'; @@ -64,6 +66,7 @@ export function getRealmServerRoute( export function registerDefaultRoutes() { registerSearchRoutes(); registerInfoRoutes(); + registerTypesRoutes(); registerCatalogRoutes(); registerAuthRoutes(); } @@ -204,6 +207,52 @@ function registerInfoRoutes() { }); } +function registerTypesRoutes() { + registerRealmServerRoute({ + path: '/_federated-types', + handler: async (req) => { + let payload; + try { + payload = await parseSearchRequestPayload(req.clone()); + } catch (e) { + if (e instanceof SearchRequestError) { + return buildSearchErrorResponse(e.message); + } + throw e; + } + + let realmList: string[]; + try { + realmList = parseRealmsFromPayload(payload); + } catch (e) { + if (e instanceof SearchRequestError) { + return buildSearchErrorResponse(e.message); + } + throw e; + } + + let registry = getTestRealmRegistry(); + let allSummaries: CardTypeSummary[] = []; + + for (let realmURL of realmList) { + let registryEntry = registry.get(ensureTrailingSlash(realmURL)); + if (registryEntry?.realm) { + let summaries = + await registryEntry.realm.realmIndexQueryEngine.fetchCardTypeSummary(); + allSummaries.push(...summaries); + } + } + + let doc = makeCardTypeSummaryDoc(allSummaries); + + return new Response(JSON.stringify(doc), { + status: 200, + headers: { 'content-type': SupportedMimeType.CardTypeSummary }, + }); + }, + }); +} + function registerCatalogRoutes() { registerRealmServerRoute({ path: '/_catalog-realms', From 0771194ece6080c15042c33b23f2a082c2828728 Mon Sep 17 00:00:00 2001 From: Fadhlan Ridhwanallah Date: Thu, 12 Mar 2026 20:54:56 +0700 Subject: [PATCH 2/7] Fix host tests --- .../host/app/components/card-search/panel.gts | 24 ++++- packages/host/app/services/realm-server.ts | 24 +++-- .../tests/helpers/realm-server-mock/routes.ts | 15 +-- .../components/operator-mode-ui-test.gts | 100 ++++-------------- 4 files changed, 66 insertions(+), 97 deletions(-) diff --git a/packages/host/app/components/card-search/panel.gts b/packages/host/app/components/card-search/panel.gts index e265018655a..e5d634e18ce 100644 --- a/packages/host/app/components/card-search/panel.gts +++ b/packages/host/app/components/card-search/panel.gts @@ -18,10 +18,10 @@ import { type getCardCollection, CardContextName, GetCardCollectionContextName, + internalKeyFor, + isResolvedCodeRef, } from '@cardstack/runtime-common'; -import consumeContext from '@cardstack/host/helpers/consume-context'; - 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'; @@ -183,6 +183,20 @@ export default class SearchPanel extends Component { return state; }); + 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(() => { let value: { selected: PickerOption[]; options: PickerOption[] } = new TrackedObject({ @@ -192,6 +206,7 @@ export default class SearchPanel extends Component { const seen = new Map(); const codeRefsByDisplayName = new Map(); + const allowedCodeRefs = this.baseFilterCodeRefs; for (const item of this.cardTypeSummaries.data) { const name = item.attributes.displayName; @@ -200,6 +215,11 @@ export default class SearchPanel extends Component { continue; } + // When baseFilter constrains to specific types, only show matching types + if (allowedCodeRefs && !allowedCodeRefs.has(codeRef)) { + continue; + } + if (!codeRefsByDisplayName.has(name)) { codeRefsByDisplayName.set(name, []); } diff --git a/packages/host/app/services/realm-server.ts b/packages/host/app/services/realm-server.ts index 298b7da8076..97512eab012 100644 --- a/packages/host/app/services/realm-server.ts +++ b/packages/host/app/services/realm-server.ts @@ -514,13 +514,25 @@ export default class RealmServerService extends Service { } let json = (await response.json()) as { - data: { - id: string; - type: 'card-type-summary'; - attributes: { displayName: string; total: number; iconHTML: string }; - }[]; + data: Record< + string, + { + data: { + id: string; + type: 'card-type-summary'; + attributes: { + displayName: string; + total: number; + iconHTML: string; + }; + }[]; + } + >; }; - return { data: json.data ?? [] }; + let flatData = Object.values(json.data ?? {}).flatMap( + (realm) => realm.data, + ); + return { data: flatData }; } async handleEvent(event: Partial) { diff --git a/packages/host/tests/helpers/realm-server-mock/routes.ts b/packages/host/tests/helpers/realm-server-mock/routes.ts index 191d9865a62..692ec957274 100644 --- a/packages/host/tests/helpers/realm-server-mock/routes.ts +++ b/packages/host/tests/helpers/realm-server-mock/routes.ts @@ -19,7 +19,6 @@ import { type LinkableCollectionDocument, type PrerenderedCardCollectionDocument, } from '@cardstack/runtime-common/document-types'; -import type { CardTypeSummary } from '@cardstack/runtime-common/index-structure'; import ENV from '@cardstack/host/config/environment'; @@ -232,20 +231,22 @@ function registerTypesRoutes() { } let registry = getTestRealmRegistry(); - let allSummaries: CardTypeSummary[] = []; + let result: Record< + string, + ReturnType + > = {}; for (let realmURL of realmList) { - let registryEntry = registry.get(ensureTrailingSlash(realmURL)); + let normalizedURL = ensureTrailingSlash(realmURL); + let registryEntry = registry.get(normalizedURL); if (registryEntry?.realm) { let summaries = await registryEntry.realm.realmIndexQueryEngine.fetchCardTypeSummary(); - allSummaries.push(...summaries); + result[normalizedURL] = makeCardTypeSummaryDoc(summaries); } } - let doc = makeCardTypeSummaryDoc(allSummaries); - - return new Response(JSON.stringify(doc), { + return new Response(JSON.stringify({ data: result }), { status: 200, headers: { 'content-type': SupportedMimeType.CardTypeSummary }, }); diff --git a/packages/host/tests/integration/components/operator-mode-ui-test.gts b/packages/host/tests/integration/components/operator-mode-ui-test.gts index 5fac3b6d9bb..69bd6925b76 100644 --- a/packages/host/tests/integration/components/operator-mode-ui-test.gts +++ b/packages/host/tests/integration/components/operator-mode-ui-test.gts @@ -907,7 +907,7 @@ module('Integration | operator-mode | ui', function (hooks) { ); }); - test('type options derived from recent cards when no search term, sorted alphabetically', async function (assert) { + test('type options derived from realm types when no search term, sorted alphabetically', async function (assert) { let recentCardsService = getService('recent-cards-service'); recentCardsService.add(`${testRealmURL}Pet/mango`); recentCardsService.add(`${testRealmURL}Person/fadhlan`); @@ -939,20 +939,20 @@ module('Integration | operator-mode | ui', function (hooks) { assert .dom('[data-test-boxel-picker-option-row="select-all"]') .containsText( - 'Any Type (3)', - 'select-all shows count of 3 types (Blog Post, Person, Pet)', + 'Any Type (13)', + 'select-all shows count of all realm types', ); - // Type options should include types from recent cards + // Type options should include types from the realm assert .dom('[data-test-boxel-picker-option-row="Blog Post"]') - .exists('Blog Post type option present from recent cards'); + .exists('Blog Post type option present from realm types'); assert .dom('[data-test-boxel-picker-option-row="Person"]') - .exists('Person type option present from recent cards'); + .exists('Person type option present from realm types'); assert .dom('[data-test-boxel-picker-option-row="Pet"]') - .exists('Pet type option present from recent cards'); + .exists('Pet type option present from realm types'); // Verify alphabetical order const optionRows = [ @@ -971,8 +971,8 @@ module('Integration | operator-mode | ui', function (hooks) { ); }); - test('empty state shows no type options besides Any Type', async function (assert) { - // Do NOT add any recent cards + test('type options show all realm types even without recent cards', async function (assert) { + // Do NOT add any recent cards — types still come from the realm ctx.setCardInOperatorModeState(`${testRealmURL}grid`); await renderComponent( class TestDriver extends GlimmerComponent { @@ -998,8 +998,8 @@ module('Integration | operator-mode | ui', function (hooks) { assert .dom('[data-test-boxel-picker-option-row="select-all"]') .containsText( - 'Any Type (0)', - 'select-all shows count of 0 when no types available', + 'Any Type (13)', + 'select-all shows count of all realm types', ); const nonSelectAllOptions = document.querySelectorAll( @@ -1007,8 +1007,8 @@ module('Integration | operator-mode | ui', function (hooks) { ); assert.strictEqual( nonSelectAllOptions.length, - 0, - 'no type options besides Any Type when no cards are available', + 13, + 'all realm types are shown even without recent cards', ); }); @@ -1121,71 +1121,6 @@ module('Integration | operator-mode | ui', function (hooks) { .exists('BlogPost/1 is visible again after reverting to Any Type'); }); - test('type options update when search term changes and deduplicate', async function (assert) { - let recentCardsService = getService('recent-cards-service'); - recentCardsService.add(`${testRealmURL}Pet/mango`); - recentCardsService.add(`${testRealmURL}Person/fadhlan`); - - ctx.setCardInOperatorModeState(`${testRealmURL}grid`); - await renderComponent( - class TestDriver extends GlimmerComponent { - - }, - ); - await waitFor(`[data-test-stack-card="${testRealmURL}grid"]`); - await click(`[data-test-boxel-filter-list-button="All Cards"]`); - await waitFor(`[data-test-cards-grid-item]`); - - // Search for 'Mango' — should match Pet/mango - await click(`[data-test-open-search-field]`); - await typeIn('[data-test-search-field]', 'Mango'); - await click('[data-test-search-sheet] .search-sheet-content'); - await waitFor('[data-test-search-label]', { timeout: 8000 }); - await settled(); - - // Open type picker - await click('[data-test-type-picker] [data-test-boxel-picker-trigger]'); - await waitFor('[data-test-boxel-picker-option-row]'); - - // 'Pet' should appear (from search results and/or recent cards, deduplicated) - assert - .dom('[data-test-boxel-picker-option-row="Pet"]') - .exists('Pet type option present'); - // select-all should show count matching number of type options - assert - .dom('[data-test-boxel-picker-option-row="select-all"]') - .containsText('Any Type (', 'select-all shows type count'); - // 'Person' should not appear since no Person cards match 'Mango' - assert - .dom('[data-test-boxel-picker-option-row="Person"]') - .doesNotExist('Person type option not present'); - - // Verify no duplicate Pet options — count all 'Pet' option rows - const petOptions = document.querySelectorAll( - '[data-test-boxel-picker-option-row="Pet"]', - ); - assert.strictEqual(petOptions.length, 1, 'Pet type is deduplicated'); - - // Close the picker dropdown by clicking the trigger again - await click('[data-test-type-picker] [data-test-boxel-picker-trigger]'); - - // Change search to 'Fadhlan' — should match Person/fadhlan - await fillIn('[data-test-search-field]', ''); - await typeIn('[data-test-search-field]', 'Fadhlan'); - await click('[data-test-search-sheet] .search-sheet-content'); - await waitFor('[data-test-search-label]', { timeout: 8000 }); - await settled(); - - // Open type picker again - await click('[data-test-type-picker] [data-test-boxel-picker-trigger]'); - await waitFor('[data-test-boxel-picker-option-row]'); - - // 'Person' should be present (from search results) - assert - .dom('[data-test-boxel-picker-option-row="Person"]') - .exists('Person type option present after search change'); - }); - test('type selection persists when search term changes if type still available', async function (assert) { ctx.setCardInOperatorModeState(`${testRealmURL}grid`); await renderComponent( @@ -1233,11 +1168,12 @@ module('Integration | operator-mode | ui', function (hooks) { await waitFor('[data-test-search-label]', { timeout: 8000 }); await settled(); - // Pet selection should be gone — reverts to "Any Type" + // Pet selection should persist — type options come from the realm, + // not search results, so they don't change with search term assert - .dom('[data-test-type-picker] [data-test-boxel-picker-remove-button]') - .doesNotExist( - 'type selection clears when selected type no longer in results', + .dom('[data-test-type-picker] [data-test-boxel-picker-selected-item]') + .exists( + 'type selection persists since type options are realm-level, not search-dependent', ); }); From 72f70c7d8720983b3f2b7581b1328af30580c1e0 Mon Sep 17 00:00:00 2001 From: Fadhlan Ridhwanallah Date: Fri, 13 Mar 2026 14:34:17 +0700 Subject: [PATCH 3/7] Implement pagination --- .../addon/src/components/picker/index.gts | 16 ++- .../host/app/components/card-search/panel.gts | 132 +++++++++++++++--- .../app/components/card-search/search-bar.gts | 10 ++ .../host/app/components/type-picker/index.gts | 128 ++++++++++++++++- packages/host/app/services/realm-server.ts | 52 ++++--- .../tests/helpers/realm-server-mock/routes.ts | 56 ++++++-- 6 files changed, 332 insertions(+), 62 deletions(-) diff --git a/packages/boxel-ui/addon/src/components/picker/index.gts b/packages/boxel-ui/addon/src/components/picker/index.gts index 635e80d9526..511022d0026 100644 --- a/packages/boxel-ui/addon/src/components/picker/index.gts +++ b/packages/boxel-ui/addon/src/components/picker/index.gts @@ -2,6 +2,7 @@ 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 type { Select } from 'ember-power-select/components/power-select'; import { includes } from 'lodash'; @@ -21,19 +22,17 @@ export type PickerOption = { export interface PickerSignature { Args: { - // State + afterOptionsComponent?: ComponentLike; + disableClientSideSearch?: boolean; disabled?: boolean; - // Display + extra?: Record; label: string; matchTriggerWidth?: boolean; maxSelectedDisplay?: number; - onChange: (selected: PickerOption[]) => void; - // Data + onSearchTermChange?: (term: string) => void; options: PickerOption[]; - placeholder?: string; - renderInPlace?: boolean; searchPlaceholder?: string; selected: PickerOption[]; @@ -86,7 +85,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; } @@ -174,6 +173,7 @@ export default class Picker extends Component { onSearchTermChange = (term: string) => { this.searchTerm = term; + this.args.onSearchTermChange?.(term); }; onOptionHover = (option: PickerOption | null) => { @@ -198,6 +198,7 @@ export default class Picker extends Component { get extra() { return { + ...this.args.extra, label: this.args.label, searchTerm: this.searchTerm, searchPlaceholder: this.args.searchPlaceholder, @@ -276,6 +277,7 @@ export default class Picker extends Component { @extra={{this.extra}} @triggerComponent={{component this.triggerComponent}} @beforeOptionsComponent={{component PickerBeforeOptionsWithSearch}} + @afterOptionsComponent={{@afterOptionsComponent}} @dropdownClass='boxel-picker__dropdown' ...attributes as |option| diff --git a/packages/host/app/components/card-search/panel.gts b/packages/host/app/components/card-search/panel.gts index e5d634e18ce..7f603f41d42 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'; @@ -41,6 +42,14 @@ import { 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: { @@ -57,6 +66,11 @@ interface Signature { | 'selectedTypes' | 'onTypeChange' | 'typeOptions' + | 'onTypeSearchChange' + | 'onLoadMoreTypes' + | 'hasMoreTypes' + | 'isLoadingMoreTypes' + | 'typesTotalCount' >, WithBoundArgs< typeof SearchContent, @@ -87,6 +101,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 _typeSummariesTotal = 0; + @tracked private _typeSummariesLoading = false; + @tracked private _hasMoreTypes = false; + @cached private get recentCardCollection(): ReturnType { return this.getCardCollection( @@ -152,35 +174,71 @@ export default class SearchPanel extends Component { // can map to the same display name, e.g. different modules with the same card type name). private _typeCodeRefs = new Map(); - @use private cardTypeSummaries = resource(() => { - // Track selectedRealmURLs so we re-fetch when realms change - let realmURLs = this.selectedRealmURLs; - let state: { - data: { - id: string; - type: string; - attributes: { displayName: string; total: number; iconHTML: string }; - }[]; - isLoading: boolean; - } = new TrackedObject({ data: [], isLoading: true }); - - (async () => { + 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; + } + + this._typeSummariesLoading = true; + try { - let result = await this.realmServer.fetchCardTypeSummaries(realmURLs); - if (!isDestroyed(this) && !isDestroying(this)) { - state.data = result.data; - state.isLoading = false; + 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._typeSummariesTotal = result.meta.page.total; + this._hasMoreTypes = + this._typeSummariesData.length < result.meta.page.total; + this._typeSummariesLoading = false; } catch (e) { console.error('Failed to fetch card type summaries', e); if (!isDestroyed(this) && !isDestroying(this)) { - state.data = []; - state.isLoading = false; + if (!append) { + this._typeSummariesData = []; + this._typeSummariesTotal = 0; + } + this._hasMoreTypes = false; + this._typeSummariesLoading = 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 state; + return { realmURLs, searchKey }; }); private get baseFilterCodeRefs(): Set | undefined { @@ -198,6 +256,9 @@ export default class SearchPanel extends Component { } @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: [], @@ -208,7 +269,7 @@ export default class SearchPanel extends Component { const codeRefsByDisplayName = new Map(); const allowedCodeRefs = this.baseFilterCodeRefs; - for (const item of this.cardTypeSummaries.data) { + for (const item of this._typeSummariesData) { const name = item.attributes.displayName; const codeRef = item.id; if (!name) { @@ -250,7 +311,7 @@ export default class SearchPanel extends Component { if (hadSelectAll) { value.selected = []; - } else if (this.cardTypeSummaries.isLoading) { + } else if (this._typeSummariesLoading) { // Type summaries still loading — keep previous selections // to avoid jarring UI changes. value.selected = prev; @@ -299,6 +360,26 @@ 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._typeSummariesLoading || !this._hasMoreTypes) { + return; + } + this._typePageNumber = this._typePageNumber + 1; + this.fetchTypeSummaries.perform( + this.selectedRealmURLs, + this._typeSearchKey, + this._typePageNumber, + true, + ); + } + diff --git a/packages/host/tests/integration/components/operator-mode-ui-test.gts b/packages/host/tests/integration/components/operator-mode-ui-test.gts index 69bd6925b76..c709e0dd107 100644 --- a/packages/host/tests/integration/components/operator-mode-ui-test.gts +++ b/packages/host/tests/integration/components/operator-mode-ui-test.gts @@ -931,6 +931,8 @@ module('Integration | operator-mode | ui', function (hooks) { // Open type picker dropdown await click('[data-test-type-picker] [data-test-boxel-picker-trigger]'); await waitFor('[data-test-boxel-picker-option-row]'); + // Wait for all pages to load via infinite scroll + await waitFor('[data-test-boxel-picker-option-row="Pet"]'); // "Any Type" (select-all) should be present with count assert @@ -939,7 +941,7 @@ module('Integration | operator-mode | ui', function (hooks) { assert .dom('[data-test-boxel-picker-option-row="select-all"]') .containsText( - 'Any Type (13)', + 'Any Type (14)', 'select-all shows count of all realm types', ); @@ -991,6 +993,8 @@ module('Integration | operator-mode | ui', function (hooks) { // Open type picker dropdown await click('[data-test-type-picker] [data-test-boxel-picker-trigger]'); await waitFor('[data-test-boxel-picker-option-row]'); + // Wait for all pages to load via infinite scroll + await waitFor('[data-test-boxel-picker-option-row="Pet"]'); assert .dom('[data-test-boxel-picker-option-row="select-all"]') @@ -998,7 +1002,7 @@ module('Integration | operator-mode | ui', function (hooks) { assert .dom('[data-test-boxel-picker-option-row="select-all"]') .containsText( - 'Any Type (13)', + 'Any Type (14)', 'select-all shows count of all realm types', ); @@ -1036,7 +1040,7 @@ module('Integration | operator-mode | ui', function (hooks) { // Open type picker and select 'Pet' await click('[data-test-type-picker] [data-test-boxel-picker-trigger]'); - await waitFor('[data-test-boxel-picker-option-row]'); + await waitFor('[data-test-boxel-picker-option-row="Pet"]'); await click('[data-test-boxel-picker-option-row="Pet"]'); // Verify selected chip shows 'Pet' @@ -1082,7 +1086,7 @@ module('Integration | operator-mode | ui', function (hooks) { // Open type picker and select 'Pet', then 'Person' await click('[data-test-type-picker] [data-test-boxel-picker-trigger]'); - await waitFor('[data-test-boxel-picker-option-row]'); + await waitFor('[data-test-boxel-picker-option-row="Pet"]'); await click('[data-test-boxel-picker-option-row="Pet"]'); await waitFor('[data-test-boxel-picker-option-row]'); await click('[data-test-boxel-picker-option-row="Person"]'); @@ -1141,7 +1145,7 @@ module('Integration | operator-mode | ui', function (hooks) { // Select 'Pet' type await click('[data-test-type-picker] [data-test-boxel-picker-trigger]'); - await waitFor('[data-test-boxel-picker-option-row]'); + await waitFor('[data-test-boxel-picker-option-row="Pet"]'); await click('[data-test-boxel-picker-option-row="Pet"]'); // Verify 'Pet' is selected @@ -1205,7 +1209,7 @@ module('Integration | operator-mode | ui', function (hooks) { // Select 'Pet' type await click('[data-test-type-picker] [data-test-boxel-picker-trigger]'); - await waitFor('[data-test-boxel-picker-option-row]'); + await waitFor('[data-test-boxel-picker-option-row="Pet"]'); await click('[data-test-boxel-picker-option-row="Pet"]'); // Only Pet cards should be visible in search results From 4522ed0705e619ae8aa370d3c07d4f7488d47465 Mon Sep 17 00:00:00 2001 From: Fadhlan Ridhwanallah Date: Mon, 16 Mar 2026 12:56:49 +0700 Subject: [PATCH 5/7] Fix loading style --- .../addon/src/components/picker/index.gts | 20 ++++++++++++++++--- 1 file changed, 17 insertions(+), 3 deletions(-) diff --git a/packages/boxel-ui/addon/src/components/picker/index.gts b/packages/boxel-ui/addon/src/components/picker/index.gts index 8502db25826..1166271577e 100644 --- a/packages/boxel-ui/addon/src/components/picker/index.gts +++ b/packages/boxel-ui/addon/src/components/picker/index.gts @@ -346,7 +346,11 @@ export default class Picker extends Component { if (this.args.afterOptionsComponent) { return this.args.afterOptionsComponent; } - if (this.args.hasMore !== undefined || this.args.onLoadMore !== undefined) { + if ( + this.args.hasMore !== undefined || + this.args.onLoadMore !== undefined || + this.args.isLoading + ) { return PickerAfterOptions; } return undefined; @@ -398,9 +402,11 @@ export default class Picker extends Component { displayDivider = (option: PickerOption) => { return ( - (this.isLastSelected(option) && this.hasUnselected) || + (this.isLastSelected(option) && + this.hasUnselected && + this.args.isLoading) || (option.type === 'select-all' && - this.selectedInSortedOptions.length === 0) + (this.selectedInSortedOptions.length === 0 || this.args.isLoading)) ); }; @@ -453,6 +459,14 @@ export default class Picker extends Component { 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; From aa82e40695f262fd535076dce243c1fa4d6adbe0 Mon Sep 17 00:00:00 2001 From: Fadhlan Ridhwanallah Date: Mon, 16 Mar 2026 13:12:22 +0700 Subject: [PATCH 6/7] Fix count --- packages/host/app/components/card-search/panel.gts | 5 +---- .../tests/integration/components/operator-mode-ui-test.gts | 4 ++-- 2 files changed, 3 insertions(+), 6 deletions(-) diff --git a/packages/host/app/components/card-search/panel.gts b/packages/host/app/components/card-search/panel.gts index fdde7c0b161..c28187d2b7f 100644 --- a/packages/host/app/components/card-search/panel.gts +++ b/packages/host/app/components/card-search/panel.gts @@ -106,7 +106,6 @@ export default class SearchPanel extends Component { @tracked private _typeSearchKey = ''; @tracked private _typePageNumber = 0; @tracked private _typeSummariesData: TypeSummaryItem[] = []; - @tracked private _typeSummariesTotal = 0; @tracked private _isLoadingTypes = false; @tracked private _isLoadingMoreTypes = false; @tracked private _hasMoreTypes = false; @@ -217,7 +216,6 @@ export default class SearchPanel extends Component { this._typeSummariesData = result.data; } - this._typeSummariesTotal = result.meta.page.total; this._hasMoreTypes = this._typeSummariesData.length < result.meta.page.total; this._isLoadingTypes = false; @@ -227,7 +225,6 @@ export default class SearchPanel extends Component { if (!isDestroyed(this) && !isDestroying(this)) { if (!append) { this._typeSummariesData = []; - this._typeSummariesTotal = 0; } this._hasMoreTypes = false; this._isLoadingTypes = false; @@ -406,7 +403,7 @@ export default class SearchPanel extends Component { hasMoreTypes=this._hasMoreTypes isLoadingTypes=this._isLoadingTypes isLoadingMoreTypes=this._isLoadingMoreTypes - typesTotalCount=this._typeSummariesTotal + typesTotalCount=this.typeFilter.options.length ) (component SearchContent diff --git a/packages/host/tests/integration/components/operator-mode-ui-test.gts b/packages/host/tests/integration/components/operator-mode-ui-test.gts index c709e0dd107..20ceb235c09 100644 --- a/packages/host/tests/integration/components/operator-mode-ui-test.gts +++ b/packages/host/tests/integration/components/operator-mode-ui-test.gts @@ -941,7 +941,7 @@ module('Integration | operator-mode | ui', function (hooks) { assert .dom('[data-test-boxel-picker-option-row="select-all"]') .containsText( - 'Any Type (14)', + 'Any Type (13)', 'select-all shows count of all realm types', ); @@ -1002,7 +1002,7 @@ module('Integration | operator-mode | ui', function (hooks) { assert .dom('[data-test-boxel-picker-option-row="select-all"]') .containsText( - 'Any Type (14)', + 'Any Type (13)', 'select-all shows count of all realm types', ); From b645670c0056c500ec93e04afb7f2438383e976c Mon Sep 17 00:00:00 2001 From: Fadhlan Ridhwanallah Date: Mon, 16 Mar 2026 13:27:09 +0700 Subject: [PATCH 7/7] Fix boxel ui test --- packages/boxel-ui/addon/src/components/picker/index.gts | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/packages/boxel-ui/addon/src/components/picker/index.gts b/packages/boxel-ui/addon/src/components/picker/index.gts index 1166271577e..e976b2134d9 100644 --- a/packages/boxel-ui/addon/src/components/picker/index.gts +++ b/packages/boxel-ui/addon/src/components/picker/index.gts @@ -402,11 +402,9 @@ export default class Picker extends Component { displayDivider = (option: PickerOption) => { return ( - (this.isLastSelected(option) && - this.hasUnselected && - this.args.isLoading) || + (this.isLastSelected(option) && this.hasUnselected) || (option.type === 'select-all' && - (this.selectedInSortedOptions.length === 0 || this.args.isLoading)) + this.selectedInSortedOptions.length === 0) ); };