From 6ffb23e42e9bb451261dc363d6aad18cc40e6601 Mon Sep 17 00:00:00 2001 From: Hassan Abdel-Rahman Date: Fri, 19 Jun 2026 12:30:49 -0400 Subject: [PATCH] Fold the search sheet / card chooser SearchContent tree into Render the search sheet and card chooser through the v2 component family and delete the deprecated SearchContent / SearchResultSection / ItemButton tree. A thin panel-content wrapper owns the nested queries; a sheet-results presenter derives the realm / recents / URL-paste sections, multiselect, the Adorn treatment, pagination, and the result count in getters over the yielded entries. Behavior and the data-test contract are preserved. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../components/card-search/item-button.gts | 509 ------------------ .../card-search/live-recents-provider.gts | 60 +++ .../{search-content.gts => panel-content.gts} | 419 ++++---------- .../host/app/components/card-search/panel.gts | 6 +- ...-result-section.gts => result-section.gts} | 52 +- .../components/card-search/result-tile.gts | 382 +++++++++++++ .../components/card-search/sheet-results.gts | 292 ++++++++++ .../components/card-search-adorn-test.gts | 101 ++-- 8 files changed, 916 insertions(+), 905 deletions(-) delete mode 100644 packages/host/app/components/card-search/item-button.gts create mode 100644 packages/host/app/components/card-search/live-recents-provider.gts rename packages/host/app/components/card-search/{search-content.gts => panel-content.gts} (50%) rename packages/host/app/components/card-search/{search-result-section.gts => result-section.gts} (92%) create mode 100644 packages/host/app/components/card-search/result-tile.gts create mode 100644 packages/host/app/components/card-search/sheet-results.gts diff --git a/packages/host/app/components/card-search/item-button.gts b/packages/host/app/components/card-search/item-button.gts deleted file mode 100644 index b84e17c8e8d..00000000000 --- a/packages/host/app/components/card-search/item-button.gts +++ /dev/null @@ -1,509 +0,0 @@ -import { on } from '@ember/modifier'; -import { action } from '@ember/object'; -import { scheduleOnce } from '@ember/runloop'; -import { service } from '@ember/service'; -import Component from '@glimmer/component'; -import { tracked } from '@glimmer/tracking'; - -import { modifier } from 'ember-modifier'; - -import { Button } from '@cardstack/boxel-ui/components'; -import { and, cn, not } from '@cardstack/boxel-ui/helpers'; -import { IconPlus } from '@cardstack/boxel-ui/icons'; - -import { - cardTypeDisplayName, - cardTypeIcon, - isCardInstance, - rri, - type ResolvedCodeRef, -} from '@cardstack/runtime-common'; - -import AdornLabel from '@cardstack/host/components/adorn/adorn-label'; -import AdornSelectChip from '@cardstack/host/components/adorn/adorn-select-chip'; -import { htmlComponent } from '@cardstack/host/lib/html-component'; -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 type { ComponentLike, ModifierLike } from '@glint/template'; - -interface AdornCardMeta { - name: string | undefined; - iconHtml: string | undefined; -} - -// CardRenderer / the prerendered wrapper stamp `data-card-type-display-name` -// and `data-card-type-icon-html` on each rendered card, so look them up -// inside the button DOM once it has rendered. These are the same attributes -// OperatorModeOverlays reads to label and icon the hover tab. -const captureAdornCardMeta = modifier( - ( - element: HTMLElement, - [setMeta, enabled]: [(meta: AdornCardMeta) => void, boolean | undefined], - ) => { - if (!enabled) return; - let destroyed = false; - let read = () => { - if (destroyed) return; - let inner = element.querySelector('[data-card-type-display-name]'); - setMeta({ - name: inner?.getAttribute('data-card-type-display-name') ?? undefined, - iconHtml: inner?.getAttribute('data-card-type-icon-html') ?? undefined, - }); - }; - // Defer the initial read out of the current render. This modifier - // installs during render commit, and setMeta writes tracked state - // the label getters already consumed this render — writing it - // synchronously here trips a backtracking assertion. MutationObserver - // callbacks are always async, so subsequent reads are already safe. - scheduleOnce('afterRender', null, read); - let observer = new MutationObserver(read); - observer.observe(element, { - childList: true, - subtree: true, - attributes: true, - attributeFilter: [ - 'data-card-type-display-name', - 'data-card-type-icon-html', - ], - }); - return () => { - destroyed = true; - observer.disconnect(); - }; - }, -); - -// Fallback used when no AdornContext positioner was threaded in (e.g. -// ItemButton rendered in isolation by a component test). Real callers -// inside an AdornContext thread its pre-wired `positionLabel`. -const noopPositionLabel = modifier<{ - Element: HTMLElement; - Args: { Positional: [cardEl: HTMLElement | undefined] }; -}>(() => {}); - -type ItemType = ComponentLike<{ Element: Element }> | CardDef | NewCardArgs; - -interface Signature { - Element: HTMLElement; - Args: { - item: ItemType; - itemId?: string; - isSelected: boolean; - multiSelect?: boolean; - onSelect: (selection: string | NewCardArgs) => void; - onSubmit?: (selection: string | NewCardArgs) => void; - // The ancestor type to render a live/fallback card as — the search's - // resolved render type, so a live row renders identically to its - // prerendered-HTML siblings. Omitted by callers that haven't adopted the - // unified render type yet, which then fall back to the default fitted card - // template. - renderType?: ResolvedCodeRef; - // When true, render the Adorn visual treatment: a teal hover type-label - // tab, teal hover/selection outline, and a teal selection chip in place - // of the legacy grey selection circle. - adorn?: boolean; - // The outline class yielded by the enclosing (the - // caller threads it down from `as |adorn|`). Applied to the button - // so AdornContext's stroke rules match it, instead of hard-coding - // the primitive's internal class name here. - adornStrokeClass?: string; - // The label positioner yielded by the enclosing - // (positionAdornLabel with the boundary resolver pre-wired). The - // caller threads it down; we attach it to the type-label tab and - // pass the card element to anchor against. - adornPositionLabel?: ModifierLike<{ - Element: HTMLElement; - Args: { Positional: [cardEl: HTMLElement | undefined] }; - }>; - }; -} - -// The default render type for a live search result — the CardDef fitted -// template — used when the caller doesn't thread a resolved render type. -let defaultResultsCardRef: ResolvedCodeRef = { - name: 'CardDef', - module: rri('https://cardstack.com/base/card-api'), -}; - -function isNewCardArgs(item: ItemType): item is NewCardArgs { - return typeof item === 'object' && 'realmURL' in item; -} - -/** - * @deprecated Leaf of the legacy `SearchContent` → `SearchResultSection` → - * `ItemButton` search-results tree (renders a single result, prerendered or - * live). Favor the v2 `` component, whose entries render - * prerendered-vs-live transparently. Removed once every consumer is on v2. - */ -export default class ItemButton extends Component { - @service declare realm: RealmService; - - @tracked private prerenderedTypeName: string | undefined; - @tracked private prerenderedTypeIconHtml: string | undefined; - - // The item-button element, captured so the shared - // positionAdornLabel modifier can anchor the type-label tab to the - // card's footprint (the same way the stack-item overlay anchors to - // the rendered card). Tracked so the label positioner re-runs once - // the element is available, regardless of modifier install order. - @tracked private cardEl: HTMLElement | undefined; - - private registerCardEl = modifier((element: HTMLElement) => { - // Assigned unconditionally: this modifier takes no tracked args, so - // it installs once and never re-runs. A guard that read `this.cardEl` - // here would instead trip a backtracking assertion, since the - // modifier install reads tracked state the label positioner consumes - // in the same render. - this.cardEl = element; - }); - - // AdornContext's pre-wired label positioner, or a no-op when this - // button is rendered outside an AdornContext. - private get positionLabel(): ModifierLike<{ - Element: HTMLElement; - Args: { Positional: [cardEl: HTMLElement | undefined] }; - }> { - return this.args.adornPositionLabel ?? noopPositionLabel; - } - - @action private setAdornCardMeta(meta: AdornCardMeta) { - // Only write when a value actually changed. The MutationObserver - // that feeds this fires on any mutation inside the button subtree — - // including our own label/icon render — so re-assigning identical - // values would dirty tracked state and re-render in a loop. - if (meta.name !== this.prerenderedTypeName) { - this.prerenderedTypeName = meta.name; - } - if (meta.iconHtml !== this.prerenderedTypeIconHtml) { - this.prerenderedTypeIconHtml = meta.iconHtml; - } - } - - private get adornTypeName(): string | undefined { - if (!this.args.adorn) return undefined; - if (this.isNewCard) return undefined; // hover bar isn't relevant for "Create New" - if (this.cardItem) { - return cardTypeDisplayName(this.cardItem); - } - return this.prerenderedTypeName; - } - - // Type-name precedence mirrored for the icon: card instances supply - // it in-memory; prerendered items carry it as icon HTML stamped on - // the rendered wrapper (the realm server resolves the proper - // subclass icon there). - private get adornTypeIcon(): unknown { - if (!this.args.adorn || this.isNewCard) return undefined; - if (this.cardItem) { - return cardTypeIcon(this.cardItem); - } - if (this.prerenderedTypeIconHtml) { - return htmlComponent(this.prerenderedTypeIconHtml); - } - return undefined; - } - - private get isNewCard(): boolean { - return isNewCardArgs(this.args.item); - } - - private get newCardItem(): NewCardArgs | undefined { - return isNewCardArgs(this.args.item) ? this.args.item : undefined; - } - - private get isCard(): boolean { - return isCardInstance(this.args.item); - } - - private get cardItem(): CardDef | undefined { - return isCardInstance(this.args.item) - ? (this.args.item as CardDef) - : undefined; - } - - // The type to render a live card as: the search's resolved render type when - // threaded, else the default fitted card template. - private get resolvedRenderType(): ResolvedCodeRef { - return this.args.renderType ?? defaultResultsCardRef; - } - - private get isComponent(): boolean { - return !this.isNewCard && !this.isCard; - } - - private get componentItem(): ComponentLike<{ Element: Element }> | undefined { - return this.isComponent - ? (this.args.item as ComponentLike<{ Element: Element }>) - : undefined; - } - - private get cardRefName(): string { - const newCard = this.newCardItem; - if (!newCard) { - return 'Card'; - } - return (newCard.ref as { module: string; name: string }).name ?? 'Card'; - } - - private get selectPayload(): string | NewCardArgs { - if (this.isNewCard) { - return this.args.item as NewCardArgs; - } - return this.args.itemId ?? (this.cardItem?.id as string); - } - - private get resolvedItemId(): string | undefined { - return this.args.itemId ?? this.cardItem?.id; - } - - @action handleClick() { - if (this.isNewCard) { - // "Create New" always submits immediately, even in multi-select mode - this.args.onSelect(this.selectPayload); - this.args.onSubmit?.(this.selectPayload); - return; - } - this.args.onSelect(this.selectPayload); - } - - @action handleDblClick() { - if (this.args.multiSelect && !this.isNewCard) { - // In multi-select, double-click just toggles for existing cards - this.args.onSelect(this.selectPayload); - return; - } - this.args.onSelect(this.selectPayload); - this.args.onSubmit?.(this.selectPayload); - } - - @action handleKeydown(event: Event) { - if ((event as KeyboardEvent).key === 'Enter') { - if (this.args.multiSelect && !this.isNewCard) { - // In multi-select, Enter just toggles for existing cards - this.args.onSelect(this.selectPayload); - return; - } - this.args.onSelect(this.selectPayload); - this.args.onSubmit?.(this.selectPayload); - } - } - - -} diff --git a/packages/host/app/components/card-search/live-recents-provider.gts b/packages/host/app/components/card-search/live-recents-provider.gts new file mode 100644 index 00000000000..105fa3736af --- /dev/null +++ b/packages/host/app/components/card-search/live-recents-provider.gts @@ -0,0 +1,60 @@ +import Component from '@glimmer/component'; +import { cached } from '@glimmer/tracking'; + +import { consume } from 'ember-provide-consume-context'; + +import { + GetCardCollectionContextName, + type getCardCollection, + type SearchResultsYield, +} from '@cardstack/runtime-common'; + +import type { CardDef } from 'https://cardstack.com/base/card-api'; + +interface Signature { + Args: { + // The recent card ids to resolve live, when the fallback is needed. + cardIds: string[]; + // The recents `` results — the fallback engages only when its + // prerendered search threw (e.g. a multi-realm setup where federated search + // can't be authorized). An empty result is legitimate (filter/realm excluded + // all recents) and must NOT engage the fallback, or it would resurrect cards + // the user filtered out. + recentsResults: SearchResultsYield; + }; + Blocks: { + default: [CardDef[]]; + }; +} + +// Resolves the recent cards as live instances, but only when the prerendered +// recents search failed — the lazy live fallback for the recents row. The +// `getCardCollection` resource is created once (a `@cached` getter, so the +// consumed context is injected before it runs) and varied through its reactive +// thunk: while the fallback is off the thunk yields an empty id list, so no card +// modules load on the happy path; when the recents search errors it resolves the +// ids. +export default class LiveRecentsProvider extends Component { + @consume(GetCardCollectionContextName) + declare private getCardCollection: getCardCollection; + + private get enabled(): boolean { + return ( + this.args.cardIds.length > 0 && + (this.args.recentsResults.errors?.length ?? 0) > 0 + ); + } + + @cached + private get collection(): ReturnType { + return this.getCardCollection(this, () => + this.enabled ? this.args.cardIds : [], + ); + } + + private get liveCards(): CardDef[] { + return (this.collection.cards?.filter(Boolean) as CardDef[]) ?? []; + } + + +} diff --git a/packages/host/app/components/card-search/search-content.gts b/packages/host/app/components/card-search/panel-content.gts similarity index 50% rename from packages/host/app/components/card-search/search-content.gts rename to packages/host/app/components/card-search/panel-content.gts index 8ed5b8a93d0..a3c9fc905df 100644 --- a/packages/host/app/components/card-search/search-content.gts +++ b/packages/host/app/components/card-search/panel-content.gts @@ -6,19 +6,14 @@ import { cached, tracked } from '@glimmer/tracking'; import Modifier from 'ember-modifier'; import { consume } from 'ember-provide-consume-context'; -import pluralize from 'pluralize'; - -import { cn, eq } from '@cardstack/boxel-ui/helpers'; +import { cn } from '@cardstack/boxel-ui/helpers'; import { type CodeRef, type Filter, type getCard, - type getCardCollection, - type RenderableSearchEntryLike, type SearchEntryWireQuery, GetCardContextName, - GetCardCollectionContextName, internalKeyFor, searchEntryWireQueryFromQuery, } from '@cardstack/runtime-common'; @@ -26,9 +21,7 @@ import { import AdornContext from '@cardstack/host/components/adorn/adorn-context'; import type { RealmFilter } from '@cardstack/host/components/realm-picker'; import type { TypeFilter } from '@cardstack/host/components/type-picker'; -import { getRenderableSearchEntries } from '@cardstack/host/resources/renderable-search-entries'; import type NetworkService from '@cardstack/host/services/network'; -import type RealmService from '@cardstack/host/services/realm'; import type RealmServerService from '@cardstack/host/services/realm-server'; import type RecentCards from '@cardstack/host/services/recent-cards-service'; @@ -38,14 +31,6 @@ import { shouldSkipSearchQuery, } from '@cardstack/host/utils/card-search/query-builder'; import { SectionPagination } from '@cardstack/host/utils/card-search/section-pagination'; -import { - assembleSections, - buildLiveRecentsSection, - buildQuerySections, - buildRecentsSection, - buildUrlSection, - type SearchSheetSection, -} from '@cardstack/host/utils/card-search/sections'; import type { NewCardArgs } from '@cardstack/host/utils/card-search/types'; import { isURLSearchKey, @@ -54,14 +39,10 @@ import { import type { CardDef } from 'https://cardstack.com/base/card-api'; -import { - SECTION_DISPLAY_LIMIT_FOCUSED, - SORT_OPTIONS, - VIEW_OPTIONS, - type SortOption, -} from './constants'; -import SearchResultHeader from './search-result-header'; -import SearchResultSection from './search-result-section'; +import { SECTION_DISPLAY_LIMIT_FOCUSED, type SortOption } from './constants'; +import LiveRecentsProvider from './live-recents-provider'; +import SearchResults from './search-results'; +import SheetResults from './sheet-results'; import type { NamedArgs } from 'ember-modifier'; @@ -144,38 +125,35 @@ interface Signature { activeSort: SortOption; onSortChange: (sort: SortOption) => void; initialFocusedSection?: string | null; - // When true, search-result cards render the Adorn visual treatment + // When true, search-result tiles render the Adorn visual treatment // (teal hover type-label tab + teal selection chip). adorn?: boolean; }; Blocks: {}; } -/** - * @deprecated Root of the legacy search-results tree (`SearchContent` → - * `SearchResultSection` → `ItemButton`). Favor the v2 `` - * component, which renders the heterogeneous `search-entry` stream (prerendered - * HTML inert or live card) for a `search-entry`-rooted query. This sheet now - * sources its data from the v2 `getSearchEntriesResource` (via - * `getRenderableSearchEntries`); the bespoke section / multiselect / adorn - * rendering is why it isn't yet folded into ``. Removed once - * every consumer is on v2. - */ -export default class SearchContent extends Component { +// The results pane of the search sheet / card chooser. Renders through the v2 +// `` component family: one instance for the realm search, a +// nested one for recents (with the live-recents fallback layered in), then hands +// their yielded `search-entry` streams to ``, which lays them out +// into realm / recents / URL-paste sections (with the header, multiselect, the +// Adorn treatment, pagination, and the result count). Resources are +// construct-once: the two `` own their live-search resources +// (varied only through their `@query` thunk), the URL-paste `getCard` is a +// `@cached` getter, and the live-recents fallback's `getCardCollection` lives in +// `LiveRecentsProvider` — none built in a getter-per-render. +export default class PanelContent extends Component { @service declare network: NetworkService; - @service declare realm: RealmService; @service declare realmServer: RealmServerService; @service('recent-cards-service') declare private recentCardsService: RecentCards; @tracked activeViewId = 'grid'; - private pagination = new SectionPagination(this.args.initialFocusedSection); + pagination = new SectionPagination(this.args.initialFocusedSection); @consume(GetCardContextName) declare private getCard: getCard; - @consume(GetCardCollectionContextName) - declare private getCardCollection: getCardCollection; - private get searchKeyIsURL() { + get searchKeyIsURL() { return isURLSearchKey(this.args.searchKey); } @@ -186,24 +164,27 @@ export default class SearchContent extends Component { ); } + // Construct-once (`@cached`, so the consumed `getCard` provider is injected + // before it runs); the resolved URL varies through the reactive thunk. @cached private get cardResource(): ReturnType { return this.getCard(this, () => this.searchKeyAsURL); } - private get resolvedCard(): CardDef | undefined { + get resolvedCard(): CardDef | undefined { return this.cardResource?.card; } - private get isCardResourceLoaded(): boolean { + get isCardResourceLoaded(): boolean { return this.cardResource?.isLoaded ?? false; } - // The v2 `search-entry` query for the main realm search, adapted from the - // legacy `Query` builder. Fitted is the default rendering, so no `htmlQuery` - // override is needed; realms ride alongside. Undefined leaves the search idle - // (the skip cases: empty search key or a URL paste, handled separately). - private get mainSearchQuery(): SearchEntryWireQuery | undefined { + // The v2 `search-entry` query for the main realm search, built from the + // shared `Query` builder via `searchEntryWireQueryFromQuery`. Fitted is the + // default rendering, so no `htmlQuery` override is needed; realms ride + // alongside. Undefined leaves the search idle (the skip cases: empty search + // key or a URL paste, handled separately). + get mainSearchQuery(): SearchEntryWireQuery | undefined { if (shouldSkipSearchQuery(this.args.searchKey, this.args.baseFilter)) { return undefined; } @@ -230,16 +211,7 @@ export default class SearchContent extends Component { }; } - // Created once: the resource owns its realm subscriptions and re-runs through - // the reactive query thunk. Rows render inert (`mode='none'`); the - // operator-mode overlay attaches via each HydratableCard's own card context. - private searchEntries = getRenderableSearchEntries( - this, - () => this.mainSearchQuery, - () => 'none', - ); - - private get recentCardUrls(): string[] { + get recentCardUrls(): string[] { return this.recentCardsService.recentCardIds.map((id) => id.endsWith('.json') ? id : `${id}.json`, ); @@ -247,18 +219,18 @@ export default class SearchContent extends Component { // The recent card ids stripped of any `.json` extension, used to order the // recents results most-recent-first against the bare `entry.id`. - private get recentCardBareIds(): string[] { + get recentCardBareIds(): string[] { return this.recentCardsService.recentCardIds.map((id) => id.replace(/\.json$/, ''), ); } // Only query realms that actually host one of the recent cards. The main - // searchResource hits every available realm (good for free-text search), - // but for Recents we already know the exact URL of each card, so searching - // realms that can't possibly contain them is pointless — and in tests - // that mix origins (e.g. baseRealm + testRealm + testModuleRealm at a - // different server), assertOwnRealmServer throws on the mixed set. + // search hits every available realm (good for free-text search), but for + // Recents we already know the exact URL of each card, so searching realms + // that can't possibly contain them is pointless — and in tests that mix + // origins (e.g. baseRealm + testRealm + testModuleRealm at a different + // server), assertOwnRealmServer throws on the mixed set. private get recentsSearchRealms(): string[] { const realms = new Set(); for (const url of this.recentCardUrls) { @@ -276,7 +248,7 @@ export default class SearchContent extends Component { // reuses the realm-section query so sort, type filter, and search-term filter // all apply server-side alongside the cardUrls constraint. Undefined (no // recents) leaves the resource idle. - private get recentsSearchQuery(): SearchEntryWireQuery | undefined { + get recentsSearchQuery(): SearchEntryWireQuery | undefined { if (this.recentCardUrls.length === 0) { return undefined; } @@ -290,8 +262,8 @@ export default class SearchContent extends Component { // No selected realm hosts a recent card. The v2 search treats an empty // `realms` array as "search every realm", which — with the `cardUrls` // constraint still matching them — would resurface recents from realms the - // user filtered out. Suppress the recents query instead, matching the - // legacy behavior. + // user filtered out. Suppress the recents query instead, so a filtered-out + // realm's recents never reappear. if (this.recentsSearchRealms.length === 0) { return undefined; } @@ -312,13 +284,7 @@ export default class SearchContent extends Component { }; } - private recentsEntries = getRenderableSearchEntries( - this, - () => this.recentsSearchQuery, - () => 'none', - ); - - private get shouldSkipQuery() { + get shouldSkipQuery() { // In baseFilter mode (modal), only skip when search key is a URL if (this.args.baseFilter) { return this.searchKeyIsURL; @@ -327,11 +293,11 @@ export default class SearchContent extends Component { return this.isSearchKeyEmpty || this.searchKeyIsURL; } - private get showHeader() { + get showHeader() { return this.args.showHeader !== false; } - private get isSearchKeyEmpty() { + get isSearchKeyEmpty() { return (this.args.searchKey?.trim() ?? '') === ''; } @@ -342,7 +308,7 @@ export default class SearchContent extends Component { return this.args.searchKey?.trim(); } - private get realms() { + get realms() { const urls = this.args.realmFilter.selectedURLs.length > 0 ? this.args.realmFilter.selectedURLs @@ -350,29 +316,8 @@ export default class SearchContent extends Component { return urls ?? []; } - private get summaryText(): string { - if (this.args.isCompact) { - return ''; - } - - if (this.searchEntries.isLoading) { - return 'Searching…'; - } - - // URL search result - if (this.searchKeyIsURL) { - if (!this.isCardResourceLoaded) { - return 'Searching…'; - } - return this.resolvedCard ? '1 result from 1 realm' : '0 results'; - } - - // Query search results - const total = this.searchEntries.meta.page?.total ?? 0; - const realms = this.realms; - - // Default: all results across all realms - return `${pluralize('result', total, true)} across ${pluralize('realm', realms.length, true)}`; + get recentCardIds(): string[] { + return this.recentCardsService.recentCardIds; } @action @@ -385,137 +330,6 @@ export default class SearchContent extends Component { this.args.onSortChange(option); } - @action - onFocusSection(sectionId: string | null) { - this.pagination.focus(sectionId); - } - - getDisplayedCount = (sectionId: string, totalCount: number): number => { - return this.pagination.getDisplayedCount(sectionId, totalCount); - }; - - @action - onShowMore(sectionId: string, totalCount: number) { - this.pagination.showMore(sectionId, totalCount); - } - - @cached - private get recentCardCollection(): ReturnType | null { - // Only instantiate the collection when we actually need the fallback, - // so the happy path (prerendered succeeds) never loads card modules. - if (!this.needsLiveRecentsFallback) { - return null; - } - return this.getCardCollection( - this, - () => this.recentCardsService.recentCardIds, - ); - } - - private get needsLiveRecentsFallback(): boolean { - // Use the live CardDef path only when the prerendered fetch threw — - // e.g. a multi-realm test setup where federated search can't be - // authorized. An empty prerendered result is legitimate (filter/realm - // excluded all recents) and must NOT trigger the fallback, or we'd - // resurrect cards the user filtered out. - return ( - this.recentCardUrls.length > 0 && - (this.recentsEntries.errors?.length ?? 0) > 0 - ); - } - - private get liveRecentCards(): CardDef[] { - const collection = this.recentCardCollection; - if (!collection) return []; - return (collection.cards?.filter(Boolean) as CardDef[] | undefined) ?? []; - } - - private get recentCardsSection() { - const instances = this.recentsEntries.entries; - - if (this.needsLiveRecentsFallback) { - return buildLiveRecentsSection(this.liveRecentCards); - } - - if (this.args.isCompact) { - // Preserve most-recent-first order from RecentCardsService rather - // than the arbitrary order the server returns for an unsorted query. - // `entry.id` is the bare card URL, so we order by the bare recent ids. - let byId = new Map(); - for (let entry of instances) { - byId.set(entry.id, entry); - } - let ordered = this.recentCardBareIds - .map((id) => byId.get(id)) - .filter((e): e is RenderableSearchEntryLike => e !== undefined); - return buildRecentsSection(ordered); - } - // Full mode: server already applied sort/filter/search, use the - // response order directly. - return buildRecentsSection([...instances]); - } - - private get cardByUrlSection() { - return buildUrlSection( - this.resolvedCard, - this.searchKeyIsURL, - this.realms, - this.realm, - ); - } - - private get cardsByQuerySection() { - return buildQuerySections(this.searchEntries.entries, { - isURL: this.searchKeyIsURL, - isSearchKeyEmpty: this.isSearchKeyEmpty, - hasBaseFilter: !!this.args.baseFilter, - realmURLs: this.realms, - offerToCreate: this.args.offerToCreate, - realm: this.realm, - }); - } - - private get sections(): SearchSheetSection[] { - return assembleSections( - this.recentCardsSection, - this.cardByUrlSection, - this.cardsByQuerySection, - this.pagination.focusedSection, - ); - } - - @action - isSectionCollapsed(sectionId: string): boolean { - return this.pagination.isCollapsed(sectionId); - } - - private get allCards(): string[] { - const urls: string[] = []; - // Cards from search results (realm sections) - respects type filter - for (const entry of this.searchEntries.entries) { - if (entry.id) { - urls.push(entry.id.replace(/\.json$/, '')); - } - } - // Cards from recents - for (const entry of this.recentsEntries.entries) { - urls.push(entry.id.replace(/\.json$/, '')); - } - // Card from URL section - if (this.resolvedCard?.id) { - urls.push(this.resolvedCard.id.replace(/\.json$/, '')); - } - return [...new Set(urls)]; - } - - private get hasNoResults(): boolean { - return ( - this.sections.length === 0 && - !this.searchEntries.isLoading && - !this.shouldSkipQuery - ); - } - } diff --git a/packages/host/app/components/card-search/panel.gts b/packages/host/app/components/card-search/panel.gts index 3bf18e7959a..8bad0e27a4b 100644 --- a/packages/host/app/components/card-search/panel.gts +++ b/packages/host/app/components/card-search/panel.gts @@ -12,8 +12,8 @@ import { getTypeSummaries } from '@cardstack/host/resources/type-summaries'; import type RealmServerService from '@cardstack/host/services/realm-server'; import { SORT_OPTIONS, type SortOption } from './constants'; +import PanelContent from './panel-content'; import SearchBar from './search-bar'; -import SearchContent from './search-content'; import type { WithBoundArgs } from '@glint/template'; @@ -35,7 +35,7 @@ interface Signature { default: [ WithBoundArgs, WithBoundArgs< - typeof SearchContent, + typeof PanelContent, | 'searchKey' | 'realmFilter' | 'typeFilter' @@ -145,7 +145,7 @@ export default class SearchPanel extends Component { typeFilter=this.typeFilter ) (component - SearchContent + PanelContent searchKey=@searchKey realmFilter=this.realmFilter typeFilter=this.typeFilter diff --git a/packages/host/app/components/card-search/search-result-section.gts b/packages/host/app/components/card-search/result-section.gts similarity index 92% rename from packages/host/app/components/card-search/search-result-section.gts rename to packages/host/app/components/card-search/result-section.gts index e52b69536e1..7382c567f37 100644 --- a/packages/host/app/components/card-search/search-result-section.gts +++ b/packages/host/app/components/card-search/result-section.gts @@ -31,7 +31,7 @@ import { } from '@cardstack/host/utils/card-search/types'; import { SECTION_SHOW_MORE_INCREMENT } from './constants'; -import ItemButton from './item-button'; +import ResultTile from './result-tile'; import SearchSheetSectionHeader from './section-header'; import type { ModifierLike } from '@glint/template'; @@ -58,7 +58,7 @@ const warnPlaceholderModifier = modifier( if (placeholderWarnedFor.has(realmUrl)) return; placeholderWarnedFor.add(realmUrl); console.warn( - `search-result-section: rendering with placeholder realm name for ${realmUrl} — realm.info() returned "${UNKNOWN_REALM_NAME}" because fetchInfo has not resolved yet. If a test failed selecting on data-test-realm here, this is the race.`, + `result-section: rendering with placeholder realm name for ${realmUrl} — realm.info() returned "${UNKNOWN_REALM_NAME}" because fetchInfo has not resolved yet. If a test failed selecting on data-test-realm here, this is the race.`, ); }, ); @@ -82,15 +82,15 @@ interface Signature { relativeTo: URL | undefined; }; onSubmit?: (selection: string | NewCardArgs) => void; - // When true, ItemButton renders the Adorn visual treatment (teal hover - // type-label tab + teal selection chip) rather than the legacy grey + // When true, the tiles render the Adorn visual treatment (teal hover + // type-label tab + selection chip) rather than the plain grey // hover/selection visuals. adorn?: boolean; - // The outline class yielded by the enclosing , - // threaded down to each ItemButton. + // The outline class yielded by the enclosing , threaded + // down to each tile. adornStrokeClass?: string; - // The pre-wired label positioner yielded by the enclosing - // , threaded down to each ItemButton. + // The pre-wired label positioner yielded by the enclosing , + // threaded down to each tile. adornPositionLabel?: ModifierLike<{ Element: HTMLElement; Args: { Positional: [cardEl: HTMLElement | undefined] }; @@ -99,12 +99,12 @@ interface Signature { Blocks: {}; } -/** - * @deprecated Mid-tier of the legacy `SearchContent` → `SearchResultSection` → - * `ItemButton` search-results tree. Favor the v2 `` component. - * Removed once every consumer is on v2. - */ -export default class SearchResultSection extends Component { +// One section of the search-results pane — a realm group, the URL-paste row, or +// the recents row. Lays its rows out into a grid of `ResultTile`s; each tile +// renders through the unified search-entry rendering surface (a search-entry's +// `entry.component`, or a live `CardDef` for the URL paste / live-recents +// fallback). +export default class ResultSection extends Component { @service declare realm: RealmService; recentsIcon = HistoryIcon; @@ -340,8 +340,8 @@ export default class SearchResultSection extends Component { data-test-search-cards-result > {{#if (this.showCreateForRealm this.realmSection.realmUrl)}} - { {{/if}} {{#each this.displayedRealmCards as |card i|}} {{#unless card.isError}} - { @viewFormat={{this.viewFormat}} @size={{this.cardSize}} > - { > <:default> - { > <:default> - (() => {}); + +interface Signature { + Element: HTMLElement; + Args: { + // A search result, rendered through its `entry.component` (the unified + // `HydratableCard` — inert prerendered HTML or a live card). The realm and + // recents sections pass this. + entry?: RenderableSearchEntryLike; + // A live card resolved by URL paste (`getCard`), rendered with a + // `CardRenderer` so it matches the prerendered tiles around it. + card?: CardDef; + // The "Create New " affordance for a realm the user can write to. + newCard?: NewCardArgs; + isSelected: boolean; + multiSelect?: boolean; + onSelect: (selection: string | NewCardArgs) => void; + onSubmit?: (selection: string | NewCardArgs) => void; + // When true, render the Adorn visual treatment: a teal hover type-label + // tab, teal hover/selection outline, and a teal selection chip in place of + // the plain grey selection circle. + adorn?: boolean; + // The outline class yielded by the enclosing (the caller + // threads it down from `as |adorn|`). Applied to the button so + // AdornContext's stroke rules match it. + adornStrokeClass?: string; + // The label positioner yielded by the enclosing + // (positionAdornLabel with the boundary resolver pre-wired). Attached to + // the type-label tab, anchored against the tile element. + adornPositionLabel?: ModifierLike<{ + Element: HTMLElement; + Args: { Positional: [cardEl: HTMLElement | undefined] }; + }>; + }; +} + +// The render type for the URL-paste live card — the CardDef fitted template, +// matching the prerendered tiles' default native rendering. +const defaultResultsCardRef: ResolvedCodeRef = { + name: 'CardDef', + module: rri('https://cardstack.com/base/card-api'), +}; + +export default class SearchResultTile extends Component { + // The tile element, captured so the shared positionAdornLabel modifier can + // anchor the type-label tab to the card's footprint (the same way the + // stack-item overlay anchors to the rendered card). Tracked so the label + // positioner re-runs once the element is available, regardless of modifier + // install order. + @tracked private cardEl: HTMLElement | undefined; + + private registerCardEl = modifier((element: HTMLElement) => { + // Assigned unconditionally: this modifier takes no tracked args, so it + // installs once and never re-runs. A guard reading `this.cardEl` here would + // trip a backtracking assertion, since the install reads tracked state the + // label positioner consumes in the same render. + this.cardEl = element; + }); + + private get positionLabel(): ModifierLike<{ + Element: HTMLElement; + Args: { Positional: [cardEl: HTMLElement | undefined] }; + }> { + return this.args.adornPositionLabel ?? noopPositionLabel; + } + + private get isNewCard(): boolean { + return this.args.newCard != null; + } + + // The type name shown in the Adorn type-label tab. Search-entry rows carry it + // on their deduped `icon` resource (no live instance needed); the URL-paste + // live card supplies it in-memory. The "Create New" row has no label. + private get adornTypeName(): string | undefined { + if (!this.args.adorn || this.isNewCard) return undefined; + if (this.args.entry) { + return this.args.entry.displayName; + } + if (this.args.card) { + return cardTypeDisplayName(this.args.card); + } + return undefined; + } + + // Type-name precedence mirrored for the icon: search-entry rows carry icon + // HTML on the `icon` resource; the live card supplies a component in-memory. + private get adornTypeIcon(): unknown { + if (!this.args.adorn || this.isNewCard) return undefined; + if (this.args.entry?.iconHtml) { + return htmlComponent(this.args.entry.iconHtml); + } + if (this.args.card) { + return cardTypeIcon(this.args.card); + } + return undefined; + } + + private get cardRefName(): string { + let ref = this.args.newCard?.ref as { name?: string } | undefined; + return ref?.name ?? 'Card'; + } + + private get resolvedItemId(): string | undefined { + return this.args.entry?.id ?? this.args.card?.id; + } + + private get selectPayload(): string | NewCardArgs { + if (this.args.newCard) { + return this.args.newCard; + } + return this.resolvedItemId as string; + } + + @action handleClick() { + if (this.isNewCard) { + // "Create New" always submits immediately, even in multi-select mode. + this.args.onSelect(this.selectPayload); + this.args.onSubmit?.(this.selectPayload); + return; + } + this.args.onSelect(this.selectPayload); + } + + @action handleDblClick() { + if (this.args.multiSelect && !this.isNewCard) { + // In multi-select, double-click just toggles for existing cards. + this.args.onSelect(this.selectPayload); + return; + } + this.args.onSelect(this.selectPayload); + this.args.onSubmit?.(this.selectPayload); + } + + @action handleKeydown(event: Event) { + if ((event as KeyboardEvent).key === 'Enter') { + if (this.args.multiSelect && !this.isNewCard) { + this.args.onSelect(this.selectPayload); + return; + } + this.args.onSelect(this.selectPayload); + this.args.onSubmit?.(this.selectPayload); + } + } + + +} diff --git a/packages/host/app/components/card-search/sheet-results.gts b/packages/host/app/components/card-search/sheet-results.gts new file mode 100644 index 00000000000..d05c5731586 --- /dev/null +++ b/packages/host/app/components/card-search/sheet-results.gts @@ -0,0 +1,292 @@ +import { action } from '@ember/object'; +import { service } from '@ember/service'; +import Component from '@glimmer/component'; + +import pluralize from 'pluralize'; + +import { eq } from '@cardstack/boxel-ui/helpers'; + +import type { + CodeRef, + Filter, + RenderableSearchEntryLike, + SearchResultsYield, +} from '@cardstack/runtime-common'; + +import type RealmService from '@cardstack/host/services/realm'; + +import type { SectionPagination } from '@cardstack/host/utils/card-search/section-pagination'; +import { + assembleSections, + buildLiveRecentsSection, + buildQuerySections, + buildRecentsSection, + buildUrlSection, + type RecentsSection, + type SearchSheetSection, +} from '@cardstack/host/utils/card-search/sections'; +import type { NewCardArgs } from '@cardstack/host/utils/card-search/types'; + +import type { CardDef } from 'https://cardstack.com/base/card-api'; + +import { SORT_OPTIONS, VIEW_OPTIONS, type SortOption } from './constants'; +import ResultSection from './result-section'; +import SearchResultHeader from './search-result-header'; + +import type { ModifierLike } from '@glint/template'; + +interface Signature { + Element: HTMLElement; + Args: { + // The yielded results from the realm-search and recents ``, + // plus the live-recents fallback cards (non-empty only when the recents + // search threw). The section / count / multiselect derivations read these + // through getters, so no ``-yielded param is fed into an + // in-template helper call. + mainResults: SearchResultsYield; + recentsResults: SearchResultsYield; + liveRecentCards: CardDef[]; + + isCompact: boolean; + showHeader: boolean; + + // Search-key / URL-paste state, resolved by the parent. + searchKey: string; + searchKeyIsURL: boolean; + isSearchKeyEmpty: boolean; + shouldSkipQuery: boolean; + resolvedCard: CardDef | undefined; + isCardResourceLoaded: boolean; + realms: string[]; + baseFilter?: Filter; + offerToCreate?: { ref: CodeRef; relativeTo: URL | undefined }; + // The recent card ids stripped of any `.json`, for most-recent-first + // ordering of the compact recents row against the bare `entry.id`. + recentCardBareIds: string[]; + // Shared pagination/focus state (Show More / Show Only) owned by the parent. + pagination: SectionPagination; + + // Header controls. + activeViewId: string; + activeSort: SortOption; + onChangeView: (id: string) => void; + onChangeSort: (option: SortOption) => void; + + // Selection + submit. + handleSelect: (selection: string | NewCardArgs) => void; + onSubmit?: (selection: string | NewCardArgs) => void; + multiSelect?: boolean; + selectedCards?: (string | NewCardArgs)[]; + onSelectAll?: (cards: string[]) => void; + onDeselectAll?: () => void; + + // Adorn treatment, threaded from the parent's . + adorn?: boolean; + adornStrokeClass?: string; + adornPositionLabel?: ModifierLike<{ + Element: HTMLElement; + Args: { Positional: [cardEl: HTMLElement | undefined] }; + }>; + }; + Blocks: {}; +} + +// Lays the heterogeneous `search-entry` stream from `` out into +// the search sheet's realm / recents / URL-paste sections, with the header, +// multiselect, the Adorn treatment, pagination, and the result count expressed +// here at the call site over the yielded entries. Every derivation is a getter +// reading the yielded results passed in as args, so the view stays reactive +// without a parallel search resource. +export default class SheetResults extends Component { + @service declare private realm: RealmService; + + VIEW_OPTIONS = VIEW_OPTIONS; + SORT_OPTIONS = SORT_OPTIONS; + + // The recents row, from the live fallback when the prerendered recents search + // threw, else the prerendered entries. Compact mode reorders to + // most-recent-first; full mode keeps the server's sort/filter order. + private get recentsSection(): RecentsSection | undefined { + if (this.args.liveRecentCards.length > 0) { + return buildLiveRecentsSection(this.args.liveRecentCards); + } + const entries = this.args.recentsResults.entries; + if (this.args.isCompact) { + let byId = new Map(); + for (let entry of entries) { + byId.set(entry.id, entry); + } + let ordered = this.args.recentCardBareIds + .map((id) => byId.get(id)) + .filter((e): e is RenderableSearchEntryLike => e !== undefined); + return buildRecentsSection(ordered); + } + return buildRecentsSection([...entries]); + } + + private get sections(): SearchSheetSection[] { + return assembleSections( + this.recentsSection, + buildUrlSection( + this.args.resolvedCard, + this.args.searchKeyIsURL, + this.args.realms, + this.realm, + ), + buildQuerySections(this.args.mainResults.entries, { + isURL: this.args.searchKeyIsURL, + isSearchKeyEmpty: this.args.isSearchKeyEmpty, + hasBaseFilter: !!this.args.baseFilter, + realmURLs: this.args.realms, + offerToCreate: this.args.offerToCreate, + realm: this.realm, + }), + this.args.pagination.focusedSection, + ); + } + + private get summaryText(): string { + if (this.args.isCompact) { + return ''; + } + if (this.args.mainResults.isLoading) { + return 'Searching…'; + } + if (this.args.searchKeyIsURL) { + if (!this.args.isCardResourceLoaded) { + return 'Searching…'; + } + return this.args.resolvedCard ? '1 result from 1 realm' : '0 results'; + } + const total = this.args.mainResults.meta.page?.total ?? 0; + return `${pluralize('result', total, true)} across ${pluralize('realm', this.args.realms.length, true)}`; + } + + private get allCards(): string[] { + const urls: string[] = []; + for (const entry of this.args.mainResults.entries) { + if (entry.id) { + urls.push(entry.id.replace(/\.json$/, '')); + } + } + if (this.args.liveRecentCards.length > 0) { + for (const card of this.args.liveRecentCards) { + if (card?.id) { + urls.push(card.id.replace(/\.json$/, '')); + } + } + } else { + for (const entry of this.args.recentsResults.entries) { + urls.push(entry.id.replace(/\.json$/, '')); + } + } + if (this.args.resolvedCard?.id) { + urls.push(this.args.resolvedCard.id.replace(/\.json$/, '')); + } + return [...new Set(urls)]; + } + + private get hasNoResults(): boolean { + return ( + this.sections.length === 0 && + !this.args.mainResults.isLoading && + !this.args.shouldSkipQuery + ); + } + + getDisplayedCount = (sectionId: string, totalCount: number): number => { + return this.args.pagination.getDisplayedCount(sectionId, totalCount); + }; + + @action onFocusSection(sectionId: string | null) { + this.args.pagination.focus(sectionId); + } + + @action onShowMore(sectionId: string, totalCount: number) { + this.args.pagination.showMore(sectionId, totalCount); + } + + @action isSectionCollapsed(sectionId: string): boolean { + return this.args.pagination.isCollapsed(sectionId); + } + + +} diff --git a/packages/host/tests/integration/components/card-search-adorn-test.gts b/packages/host/tests/integration/components/card-search-adorn-test.gts index a3921809843..4dd097a50e9 100644 --- a/packages/host/tests/integration/components/card-search-adorn-test.gts +++ b/packages/host/tests/integration/components/card-search-adorn-test.gts @@ -1,36 +1,44 @@ -import { render, settled, waitFor } from '@ember/test-helpers'; +import { render } from '@ember/test-helpers'; import { module, test } from 'qunit'; -import { rri } from '@cardstack/runtime-common'; +import { rri, type RenderableSearchEntryLike } from '@cardstack/runtime-common'; -import ItemButton from '@cardstack/host/components/card-search/item-button'; +import SearchResultTile from '@cardstack/host/components/card-search/result-tile'; import type { NewCardArgs } from '@cardstack/host/utils/card-search/types'; import { setupRenderingTest } from '../../helpers/setup'; import type { ComponentLike } from '@glint/template'; -// Stand-in for a prerendered card component. ItemButton extracts the -// type-label name from the rendered card DOM, where CardRenderer normally -// stamps it. -const PrerenderedStub: ComponentLike<{ Element: Element }> =