diff --git a/packages/host/app/components/card-chooser/mini/index.gts b/packages/host/app/components/card-chooser/mini/index.gts index 363bb82179..c4c3faeafb 100644 --- a/packages/host/app/components/card-chooser/mini/index.gts +++ b/packages/host/app/components/card-chooser/mini/index.gts @@ -6,8 +6,10 @@ import type { Filter } from '@cardstack/runtime-common'; import SearchPanel from '@cardstack/host/components/card-search/panel'; import { + isNewCardArgs, removeCardJsonExtension, - type NewCardArgs, + type SearchSelection, + type SelectedSearchItem, } from '@cardstack/host/utils/card-search/types'; interface Signature { @@ -29,8 +31,11 @@ export default class MiniCardChooser extends Component { // SearchContent expects an array (it's the multi-select API plumbing). // Wrap the single-select `@selected` so the existing isCardSelected check // in SearchResultSection picks it up without any other changes. - private get selectedCards(): string[] | undefined { - return this.args.selected ? [this.args.selected] : undefined; + private get selectedCards(): SelectedSearchItem[] | undefined { + // The mini chooser is cards-only, so the selected id is always a card. + return this.args.selected + ? [{ id: this.args.selected, kind: 'card' }] + : undefined; } @action @@ -39,11 +44,11 @@ export default class MiniCardChooser extends Component { } @action - private handleSelect(selection: string | NewCardArgs) { - if (typeof selection !== 'string') { + private handleSelect(selection: SearchSelection) { + if (isNewCardArgs(selection)) { return; } - let normalized = removeCardJsonExtension(selection); + let normalized = removeCardJsonExtension(selection.id); if (normalized) { this.args.onSelect(normalized); } diff --git a/packages/host/app/components/card-chooser/modal.gts b/packages/host/app/components/card-chooser/modal.gts index d802a52572..3de06d45e7 100644 --- a/packages/host/app/components/card-chooser/modal.gts +++ b/packages/host/app/components/card-chooser/modal.gts @@ -17,7 +17,11 @@ import { TrackedArray, TrackedObject } from 'tracked-built-ins'; import { Button } from '@cardstack/boxel-ui/components'; import { eq, not } from '@cardstack/boxel-ui/helpers'; -import type { Loader, CardChooserQuery } from '@cardstack/runtime-common'; +import type { + Loader, + CardChooserQuery, + ChosenItem, +} from '@cardstack/runtime-common'; import { type CodeRef, type CreateNewCard, @@ -31,7 +35,12 @@ import { 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 { + isNewCardArgs, + removeCardJsonExtension, + type SearchSelection, + type SelectedSearchItem, +} from '@cardstack/host/utils/card-search/types'; import { suggestCardChooserTitle, @@ -56,7 +65,7 @@ interface Signature { } type Request = { - deferred: Deferred; + deferred: Deferred; opts?: { offerToCreate?: { ref: CodeRef; @@ -69,34 +78,38 @@ type Request = { type State = { id: number; request: Request; - selectedCards: (string | NewCardArgs)[]; + selectedCards: SearchSelection[]; multiSelect: boolean; + includeFiles?: boolean; searchKey: string; chooseCardTitle: string; dismissModal: boolean; errorMessage?: string; baseFilter?: Filter; availableRealmUrls: string[]; + // The realm scope from the `chooseCard` query. When present, the search is + // pinned to these realms (see `initialSelectedRealmsForPanel` / + // `lockSelectedRealmsForPanel`) instead of fanning out across every + // available realm — which, in mixed mode, would render a tile for every + // file in every realm. + requestedRealms?: string[]; hasPreselectedCard?: boolean; consumingRealm?: URL; preselectConsumingRealm?: boolean; lockConsumingRealm?: boolean; }; -function isNewCardArgs(item: string | NewCardArgs): item is NewCardArgs { - return typeof item !== 'string' && 'realmURL' in item; -} - -function normalizeCardUrl(url: string): string { - return url.replace(/\.json$/, ''); -} - -function selectionEquals( - a: string | NewCardArgs, - b: string | NewCardArgs, -): boolean { - if (typeof a === 'string' && typeof b === 'string') { - return normalizeCardUrl(a) === normalizeCardUrl(b); +function selectionEquals(a: SearchSelection, b: SearchSelection): boolean { + if (!isNewCardArgs(a) && !isNewCardArgs(b)) { + if (a.kind !== b.kind) { + // A card instance and its backing `.json` file share a normalized id but + // are distinct picks; kind keeps them from toggling each other. + return false; + } + // `.json` stripping is a card-id convention; a file id is already canonical. + return a.kind === 'file' + ? a.id === b.id + : removeCardJsonExtension(a.id) === removeCardJsonExtension(b.id); } if (isNewCardArgs(a) && isNewCardArgs(b)) { return a.realmURL === b.realmURL; @@ -117,8 +130,8 @@ export default class CardChooserModal extends Component { @baseFilter={{state.baseFilter}} @initialSelectedRealms={{this.initialSelectedRealmsForPanel}} @initialSelectedTypes={{this.initialSelectedTypesForPanel}} - @lockSelectedRealms={{state.lockConsumingRealm}} - @cardsOnly={{true}} + @lockSelectedRealms={{this.lockSelectedRealmsForPanel}} + @cardsOnly={{not state.includeFiles}} as |Bar Content| > { <:content> { } private get initialSelectedRealmsForPanel(): URL[] | undefined { + // A query-provided realm scope pins the search to exactly those realms. + if (this.state?.requestedRealms?.length) { + return this.state.requestedRealms.map((r) => new URL(r)); + } if (!this.state?.consumingRealm) { return undefined; } @@ -258,6 +275,14 @@ export default class CardChooserModal extends Component { return [this.state.consumingRealm]; } + // Pin the realm filter (no widening) whenever the search is scoped — either + // to a consuming realm or to the query's requested realms. + private get lockSelectedRealmsForPanel(): boolean { + return Boolean( + this.state?.lockConsumingRealm || this.state?.requestedRealms?.length, + ); + } + private get initialSelectedTypesForPanel(): ResolvedCodeRef[] | undefined { let baseFilter = this.state?.baseFilter; if (!baseFilter) { @@ -307,8 +332,9 @@ export default class CardChooserModal extends Component { preselectConsumingRealm?: boolean; lockConsumingRealm?: boolean; preselectedCardUrls?: string[]; + includeFiles?: boolean; }, - ): Promise { + ): Promise { let result = await this._chooseCard.perform( { // default to cardTitle sort so that we can maintain stability in @@ -327,10 +353,15 @@ export default class CardChooserModal extends Component { }, opts, ); + // Mixed mode returns kind-tagged items; cards-only projects to bare ids so + // existing callers keep getting a string / string[]. + if (opts?.includeFiles) { + return opts?.multiSelect ? result : result?.[0]; + } if (opts?.multiSelect) { - return result; + return result?.map((item) => item.id); } - return result?.[0]; + return result?.[0]?.id; } private _chooseCard = task( @@ -348,6 +379,7 @@ export default class CardChooserModal extends Component { preselectConsumingRealm?: boolean; lockConsumingRealm?: boolean; preselectedCardUrls?: string[]; + includeFiles?: boolean; } = {}, ) => { await this.realmServer.ready; @@ -402,6 +434,10 @@ export default class CardChooserModal extends Component { ? [preselectedCardUrl] : [] ).map((url) => (url.endsWith('.json') ? url : `${url}.json`)); + // Preselected items are always card instances. + let preselectedItems: SelectedSearchItem[] = preselectedCardUrls.map( + (id) => ({ id, kind: 'card' }), + ); let cardChooserState = new TrackedObject({ id: this.stateId, @@ -411,12 +447,14 @@ export default class CardChooserModal extends Component { dismissModal: false, baseFilter: query.filter, availableRealmUrls: this.realmServer.availableRealmIdentifiers, - selectedCards: preselectedCardUrls, + requestedRealms: query.realms, + selectedCards: preselectedItems, multiSelect: opts?.multiSelect ?? false, - hasPreselectedCard: preselectedCardUrls.length > 0, + hasPreselectedCard: preselectedItems.length > 0, consumingRealm: opts.consumingRealm, preselectConsumingRealm: opts.preselectConsumingRealm, lockConsumingRealm: opts.lockConsumingRealm, + includeFiles: opts.includeFiles, }); this.stateStack.push(cardChooserState); return await request.deferred.promise; @@ -435,7 +473,7 @@ export default class CardChooserModal extends Component { } } - @action private selectFromSearch(selection: string | NewCardArgs): void { + @action private selectFromSearch(selection: SearchSelection): void { if (!this.state || !selection) { return; } @@ -458,12 +496,13 @@ export default class CardChooserModal extends Component { this.state.hasPreselectedCard = false; } - @action private submitFromSearch(selection: string | NewCardArgs): void { + @action private submitFromSearch(selection: SearchSelection): void { if (!this.state) { return; } - if (this.state.multiSelect && typeof selection === 'string') { - // In multi-select, double-click on existing cards just toggles (don't submit) + if (this.state.multiSelect && !isNewCardArgs(selection)) { + // In multi-select, double-click on an existing item just toggles (don't + // submit); only the "create new" affordance submits immediately. this.selectFromSearch(selection); return; } @@ -471,7 +510,7 @@ export default class CardChooserModal extends Component { this.pickCards(this.state); } - @action private selectAll(cards: string[]): void { + @action private selectAll(cards: SelectedSearchItem[]): void { if (!this.state) { return; } @@ -495,12 +534,10 @@ export default class CardChooserModal extends Component { return; } - let cardIds: string[] = []; + let chosen: ChosenItem[] = []; for (let selectedItem of currentState.selectedCards) { - if (typeof selectedItem === 'string') { - cardIds.push(selectedItem.replace(/\.json$/, '')); - } else { - // NewCardArgs — create the card + if (isNewCardArgs(selectedItem)) { + // NewCardArgs — create the card (always a card instance) let newCardId = await this.createNewTask.perform( selectedItem.ref, selectedItem.relativeTo @@ -509,14 +546,28 @@ export default class CardChooserModal extends Component { new URL(selectedItem.realmURL), ); if (newCardId) { - cardIds.push(newCardId.replace(/\.json$/, '')); + chosen.push({ + id: removeCardJsonExtension(newCardId)!, + kind: 'card', + }); } + } else { + chosen.push({ + // `.json` stripping is a card-id convention; a file id (e.g. a card's + // backing `Foo/1.json`) is already its canonical id — stripping it + // would yield the card-instance id under a `file` kind. + id: + selectedItem.kind === 'file' + ? selectedItem.id + : removeCardJsonExtension(selectedItem.id)!, + kind: selectedItem.kind, + }); } } let request = currentState.request; if (request) { - request.deferred.fulfill(cardIds.length > 0 ? cardIds : undefined); + request.deferred.fulfill(chosen.length > 0 ? chosen : undefined); } // Remove state from stack diff --git a/packages/host/app/components/card-search/panel-content.gts b/packages/host/app/components/card-search/panel-content.gts index 4e8f5c2104..4dc71deee0 100644 --- a/packages/host/app/components/card-search/panel-content.gts +++ b/packages/host/app/components/card-search/panel-content.gts @@ -34,7 +34,10 @@ import { shouldSkipSearchQuery, } from '@cardstack/host/utils/card-search/query-builder'; import { SectionPagination } from '@cardstack/host/utils/card-search/section-pagination'; -import type { NewCardArgs } from '@cardstack/host/utils/card-search/types'; +import type { + SearchSelection, + SelectedSearchItem, +} from '@cardstack/host/utils/card-search/types'; import { isURLSearchKey, resolveSearchKeyAsURL, @@ -117,16 +120,16 @@ interface Signature { // by default; the card choosers set this so file rows never surface. cardsOnly?: boolean; isCompact: boolean; - handleSelect: (selection: string | NewCardArgs) => void; - selectedCards?: (string | NewCardArgs)[]; + handleSelect: (selection: SearchSelection) => void; + selectedCards?: SearchSelection[]; multiSelect?: boolean; - onSelectAll?: (cards: string[]) => void; + onSelectAll?: (cards: SelectedSearchItem[]) => void; onDeselectAll?: () => void; offerToCreate?: { ref: CodeRef; relativeTo: URL | undefined; }; - onSubmit?: (selection: string | NewCardArgs) => void; + onSubmit?: (selection: SearchSelection) => void; showHeader?: boolean; activeSort: SortOption; onSortChange: (sort: SortOption) => void; diff --git a/packages/host/app/components/card-search/result-section.gts b/packages/host/app/components/card-search/result-section.gts index d08e65b037..11e0d40813 100644 --- a/packages/host/app/components/card-search/result-section.gts +++ b/packages/host/app/components/card-search/result-section.gts @@ -26,8 +26,10 @@ import type { } from '@cardstack/host/utils/card-search/sections'; import { + isNewCardArgs, removeCardJsonExtension, type NewCardArgs, + type SearchSelection, } from '@cardstack/host/utils/card-search/types'; import { SECTION_SHOW_MORE_INCREMENT } from './constants'; @@ -69,19 +71,19 @@ interface Signature { section: SearchSheetSection; viewOption?: string; isCompact?: boolean; - handleSelect: (selection: string | NewCardArgs) => void; + handleSelect: (selection: SearchSelection) => void; isFocused?: boolean; isCollapsed?: boolean; onFocusSection?: (sectionId: string | null) => void; getDisplayedCount?: (sectionId: string, totalCount: number) => number; onShowMore?: (sectionId: string, totalCount: number) => void; - selectedCards?: (string | NewCardArgs)[]; + selectedCards?: SearchSelection[]; multiSelect?: boolean; offerToCreate?: { ref: CodeRef; relativeTo: URL | undefined; }; - onSubmit?: (selection: string | NewCardArgs) => void; + onSubmit?: (selection: SearchSelection) => void; // When true, the tiles render the Adorn visual treatment (teal hover // type-label tab + selection chip) rather than the plain grey // hover/selection visuals. @@ -280,9 +282,9 @@ export default class ResultSection extends Component { isCardSelected = (cardUrl: string): boolean => { const selected = this.args.selectedCards; if (!selected) return false; - const normalized = cardUrl.replace(/\.json$/, ''); + const normalized = removeCardJsonExtension(cardUrl); return selected.some( - (s) => typeof s === 'string' && s.replace(/\.json$/, '') === normalized, + (s) => !isNewCardArgs(s) && removeCardJsonExtension(s.id) === normalized, ); }; @@ -296,8 +298,7 @@ export default class ResultSection extends Component { return false; } return selected.some( - (s) => - typeof s !== 'string' && s.realmURL === this.realmSection!.realmUrl, + (s) => isNewCardArgs(s) && s.realmURL === this.realmSection!.realmUrl, ); } diff --git a/packages/host/app/components/card-search/result-tile.gts b/packages/host/app/components/card-search/result-tile.gts index 9cbed37f5e..464acf07aa 100644 --- a/packages/host/app/components/card-search/result-tile.gts +++ b/packages/host/app/components/card-search/result-tile.gts @@ -24,6 +24,8 @@ import { htmlComponent } from '@cardstack/host/lib/html-component'; import { removeCardJsonExtension, type NewCardArgs, + type SearchSelection, + type SearchItemKind, } from '@cardstack/host/utils/card-search/types'; import CardRenderer from '../card-renderer'; @@ -54,8 +56,8 @@ interface Signature { newCard?: NewCardArgs; isSelected: boolean; multiSelect?: boolean; - onSelect: (selection: string | NewCardArgs) => void; - onSubmit?: (selection: string | NewCardArgs) => void; + onSelect: (selection: SearchSelection) => void; + onSubmit?: (selection: SearchSelection) => 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. @@ -149,11 +151,18 @@ export default class SearchResultTile extends Component { return this.args.entry?.id ?? this.args.card?.id; } - private get selectPayload(): string | NewCardArgs { + // Files render natively (no render type); an entry carries its kind directly. + // The URL-paste live card (`@card`) resolves through `getCard`, so it's a + // card. + private get selectKind(): SearchItemKind { + return this.args.entry?.kind ?? 'card'; + } + + private get selectPayload(): SearchSelection { if (this.args.newCard) { return this.args.newCard; } - return this.resolvedItemId as string; + return { id: this.resolvedItemId as string, kind: this.selectKind }; } @action handleClick() { diff --git a/packages/host/app/components/card-search/search-result-header.gts b/packages/host/app/components/card-search/search-result-header.gts index 048c0b6eaa..e8917de245 100644 --- a/packages/host/app/components/card-search/search-result-header.gts +++ b/packages/host/app/components/card-search/search-result-header.gts @@ -11,7 +11,10 @@ import { } from '@cardstack/boxel-ui/components'; import { MenuItem } from '@cardstack/boxel-ui/helpers'; -import type { NewCardArgs } from '@cardstack/host/utils/card-search/types'; +import type { + SearchSelection, + SelectedSearchItem, +} from '@cardstack/host/utils/card-search/types'; import type { SortOption } from './constants'; import type { ViewOption } from './constants'; @@ -27,9 +30,9 @@ interface Signature { onChangeView: (id: string) => void; onChangeSort: (option: SortOption) => void; multiSelect?: boolean; - selectedCards?: (string | NewCardArgs)[]; - allCards?: string[]; - onSelectAll?: (cards: string[]) => void; + selectedCards?: SearchSelection[]; + allCards?: SelectedSearchItem[]; + onSelectAll?: (cards: SelectedSearchItem[]) => void; onDeselectAll?: () => void; hideViewSelector?: boolean; }; diff --git a/packages/host/app/components/card-search/sheet-results.gts b/packages/host/app/components/card-search/sheet-results.gts index fe0c2f01d5..a720500655 100644 --- a/packages/host/app/components/card-search/sheet-results.gts +++ b/packages/host/app/components/card-search/sheet-results.gts @@ -26,7 +26,12 @@ import { type RecentsSection, type SearchSheetSection, } from '@cardstack/host/utils/card-search/sections'; -import type { NewCardArgs } from '@cardstack/host/utils/card-search/types'; +import { removeCardJsonExtension } from '@cardstack/host/utils/card-search/types'; +import type { + SearchSelection, + SelectedSearchItem, + SearchItemKind, +} from '@cardstack/host/utils/card-search/types'; import { SORT_OPTIONS, VIEW_OPTIONS, type SortOption } from './constants'; import ResultSection from './result-section'; @@ -77,11 +82,11 @@ interface Signature { onChangeSort: (option: SortOption) => void; // Selection + submit. - handleSelect: (selection: string | NewCardArgs) => void; - onSubmit?: (selection: string | NewCardArgs) => void; + handleSelect: (selection: SearchSelection) => void; + onSubmit?: (selection: SearchSelection) => void; multiSelect?: boolean; - selectedCards?: (string | NewCardArgs)[]; - onSelectAll?: (cards: string[]) => void; + selectedCards?: SearchSelection[]; + onSelectAll?: (cards: SelectedSearchItem[]) => void; onDeselectAll?: () => void; // Adorn treatment, threaded from the parent's . @@ -181,28 +186,38 @@ export default class SheetResults extends Component { return this.args.variant === 'mini' ? 'mini' : this.args.activeViewId; } - private get allCards(): string[] { - const urls: string[] = []; + private get allCards(): SelectedSearchItem[] { + const byId = new Map(); + const add = (id: string, kind: SearchItemKind) => { + // `.json` stripping is a card-id convention; a file id is already its + // canonical id (stripping it would corrupt a `.json`-backed file row that + // flows into the "Select All" selection). Cross-kind ids can't collide: + // card ids are extensionless, file ids carry their extension. + const normalized = kind === 'file' ? id : removeCardJsonExtension(id)!; + if (!byId.has(normalized)) { + byId.set(normalized, { id: normalized, kind }); + } + }; for (const entry of this.args.mainResults.entries) { if (entry.id) { - urls.push(entry.id.replace(/\.json$/, '')); + add(entry.id, entry.kind); } } if (this.args.liveRecentCards.length > 0) { for (const card of this.args.liveRecentCards) { if (card?.id) { - urls.push(card.id.replace(/\.json$/, '')); + add(card.id, 'card'); } } } else { for (const entry of this.args.recentsResults.entries) { - urls.push(entry.id.replace(/\.json$/, '')); + add(entry.id, entry.kind); } } if (this.args.resolvedCard?.id) { - urls.push(this.args.resolvedCard.id.replace(/\.json$/, '')); + add(this.args.resolvedCard.id, 'card'); } - return [...new Set(urls)]; + return [...byId.values()]; } // The global summary + Sort row. Hidden in the mini chooser's default diff --git a/packages/host/app/components/search-sheet/index.gts b/packages/host/app/components/search-sheet/index.gts index 79b34fe5fb..7c0ee07e89 100644 --- a/packages/host/app/components/search-sheet/index.gts +++ b/packages/host/app/components/search-sheet/index.gts @@ -32,6 +32,10 @@ import { resolveSearchKeyAsURL, } from '@cardstack/host/utils/card-search/url'; +import { + isNewCardArgs, + type SearchSelection, +} from '../../utils/card-search/types'; import SearchPanel from '../card-search/panel'; import type StoreService from '../../services/store'; @@ -147,12 +151,12 @@ export default class SearchSheet extends Component { } } - @action private handleCardSelect(selection: string | { realmURL: string }) { - if (typeof selection !== 'string') { + @action private handleCardSelect(selection: SearchSelection) { + if (isNewCardArgs(selection)) { return; } this.resetState(); - this.args.onCardSelect(selection); + this.args.onCardSelect(selection.id); } @action diff --git a/packages/host/app/resources/renderable-search-entries.ts b/packages/host/app/resources/renderable-search-entries.ts index 4b3e93acc2..109a6390e4 100644 --- a/packages/host/app/resources/renderable-search-entries.ts +++ b/packages/host/app/resources/renderable-search-entries.ts @@ -103,6 +103,12 @@ export class RenderableSearchEntry { return this.raw.realmUrl; } + // Whether this result is a card instance or a file — lets a consumer (e.g. a + // mixed card/file chooser) tag a selection by kind without inspecting the id. + get kind(): 'card' | 'file' { + return this.raw.kind; + } + // The result's realm-local path (e.g. `Person/error`), shown by the host // error tile to identify which result failed. Falls back to the bare id when // the id isn't under the entry's realm. diff --git a/packages/host/app/resources/search-entries.ts b/packages/host/app/resources/search-entries.ts index c8439ba3c5..dc311785e5 100644 --- a/packages/host/app/resources/search-entries.ts +++ b/packages/host/app/resources/search-entries.ts @@ -85,6 +85,10 @@ export interface SearchEntry { // reconstruct the composite validator it sends as `If-None-Match`. indexGeneration?: number; htmlGeneration?: number; + // Which kind of row this is — a card instance or a file. Derived from the + // `item` serialization type, or (for item-less rows) the html render-type + // heuristic (files render natively; card renderings always name a type). + kind: 'card' | 'file'; } interface Args { @@ -717,6 +721,7 @@ export class SearchEntriesResource extends Resource { id: entry.id, realmUrl: realmUrlFor(entry.id), html: renderings, + kind: isFile ? 'file' : 'card', ...(item ? { item } : {}), ...(icon ? { diff --git a/packages/host/app/utils/card-search/query-builder.ts b/packages/host/app/utils/card-search/query-builder.ts index 632fc46544..ff2d77162c 100644 --- a/packages/host/app/utils/card-search/query-builder.ts +++ b/packages/host/app/utils/card-search/query-builder.ts @@ -231,7 +231,8 @@ export function buildSearchQuery( filters = scopeFilters( filters, opts, - Boolean(typeFilter) || hasNarrowingPositiveTypeRef(baseFilter), + hasNarrowingPositiveTypeRef(typeFilter) || + hasNarrowingPositiveTypeRef(baseFilter), ); return { filter: filters.length === 1 ? filters[0] : { every: filters }, @@ -284,7 +285,8 @@ export function buildRecentsQuery( filters = scopeFilters( filters, opts, - Boolean(typeFilter) || hasNarrowingPositiveTypeRef(baseFilter), + hasNarrowingPositiveTypeRef(typeFilter) || + hasNarrowingPositiveTypeRef(baseFilter), ); return { filter: filters.length === 1 ? filters[0] : { every: filters }, diff --git a/packages/host/app/utils/card-search/types.ts b/packages/host/app/utils/card-search/types.ts index f7f330d207..b2743f884e 100644 --- a/packages/host/app/utils/card-search/types.ts +++ b/packages/host/app/utils/card-search/types.ts @@ -6,6 +6,25 @@ export interface NewCardArgs { realmURL: string; } +// Whether a chosen search result is a card instance or a file. +export type SearchItemKind = 'card' | 'file'; + +// A chosen existing search result: its id tagged with its kind. Used as the +// selection payload throughout the card-search components so a mixed card/file +// chooser can report each pick's kind without inspecting the id. +export interface SelectedSearchItem { + id: string; + kind: SearchItemKind; +} + +// The selection payload a result tile emits: an existing card/file, or the +// "create new card" affordance. +export type SearchSelection = SelectedSearchItem | NewCardArgs; + +export function isNewCardArgs(item: SearchSelection): item is NewCardArgs { + return 'realmURL' in item; +} + // Normalizes an id that may carry the card `.json` file convention down to the // canonical extensionless card id. Only `.json` is stripped: a file entry's id // (`….md`, `….gts`, …) IS its canonical id, and stripping its extension would diff --git a/packages/host/tests/integration/components/card-chooser-mixed-mode-test.gts b/packages/host/tests/integration/components/card-chooser-mixed-mode-test.gts new file mode 100644 index 0000000000..1d52439be3 --- /dev/null +++ b/packages/host/tests/integration/components/card-chooser-mixed-mode-test.gts @@ -0,0 +1,201 @@ +// The card chooser's mixed cards + files mode (CS-12205): with +// `includeFiles: true` the modal surfaces file rows alongside card instances +// and resolves each pick as a kind-tagged `{ id, kind }` (a `ChosenItem`) +// rather than a bare card id. Cards-only callers are unaffected — they keep +// getting plain id strings (covered by card-chooser-test). +// +// Runs under the host test-services stack / CI. + +import { click, waitFor } from '@ember/test-helpers'; +import GlimmerComponent from '@glimmer/component'; + +import { getService } from '@universal-ember/test-support'; + +import { module, test } from 'qunit'; + +import { baseRealm, baseRef, chooseCard } from '@cardstack/runtime-common'; + +import OperatorMode from '@cardstack/host/components/operator-mode/container'; + +import { + testRealmURL, + setupLocalIndexing, + setupIntegrationTestRealm, + setupOperatorModeStateCleanup, + realmConfigCardJSON, +} from '../../helpers'; +import { setupMockMatrix } from '../../helpers/mock-matrix'; +import { renderComponent } from '../../helpers/render-component'; +import { setupRenderingTest } from '../../helpers/setup'; + +const noop = () => {}; + +// A plain markdown file indexes as a MarkdownDef (a FileDef, hence a BaseDef), +// so a `{ type: baseRef }` query — the same kind-spanning base filter the +// search sheet uses — returns it alongside card instances. +const README_MD = `# Readme + +Some notes. +`; + +// A standalone `.json` file whose contents are NOT a card resource. It indexes +// as a plain file row (not a card instance), so — unlike a card's dual-indexed +// backing `.json` — it survives the mixed-search dedup and surfaces as a +// `kind: 'file'` row whose id carries the `.json` extension. +const CONFIG_JSON = `{ + "featureFlags": { "beta": true } +} +`; + +const authorInstanceId = `${testRealmURL}Author/1`; +const fileId = `${testRealmURL}notes/readme.md`; +const jsonFileId = `${testRealmURL}settings/config.json`; + +module( + 'Integration | card-chooser (mixed cards + files mode)', + function (hooks) { + setupRenderingTest(hooks); + setupOperatorModeStateCleanup(hooks); + setupLocalIndexing(hooks); + + let mockMatrixUtils = setupMockMatrix(hooks, { + loggedInAs: '@testuser:localhost', + activeRealms: [baseRealm.url, testRealmURL], + autostart: true, + }); + + hooks.beforeEach(async function () { + let loader = getService('loader-service').loader; + let cardApi: typeof import('@cardstack/base/card-api'); + let string: typeof import('@cardstack/base/string'); + let cardsGrid: typeof import('@cardstack/base/cards-grid'); + cardApi = await loader.import('@cardstack/base/card-api'); + string = await loader.import('@cardstack/base/string'); + cardsGrid = await loader.import('@cardstack/base/cards-grid'); + + let { field, contains, CardDef } = cardApi; + let { default: StringField } = string; + let { CardsGrid } = cardsGrid; + + class Author extends CardDef { + static displayName = 'Author'; + @field firstName = contains(StringField); + } + + await setupIntegrationTestRealm({ + mockMatrixUtils, + contents: { + 'author.gts': { Author }, + 'Author/1.json': new Author({ firstName: 'Alice' }), + 'notes/readme.md': README_MD, + 'settings/config.json': CONFIG_JSON, + 'realm.json': realmConfigCardJSON({ + name: 'Local Workspace', + iconURL: 'https://example-icon.test', + }), + 'index.json': new CardsGrid(), + }, + }); + + getService('operator-mode-state-service').restore({ + stacks: [[{ id: `${testRealmURL}index`, format: 'isolated' }]], + }); + await renderComponent( + class TestDriver extends GlimmerComponent { + + }, + ); + await waitFor(`[data-test-stack-card="${testRealmURL}index"]`); + }); + + test('surfaces both a card instance and a file, and resolves kind-tagged picks', async function (assert) { + let pending = chooseCard( + { filter: { type: baseRef }, realms: [testRealmURL] }, + { includeFiles: true, multiSelect: true }, + ); + + await waitFor('[data-test-card-chooser-modal]'); + // Both kinds surface: the card instance and the markdown file appear as + // selectable result tiles in the one mixed list. + await waitFor(`[data-test-item-button="${authorInstanceId}"]`); + await waitFor(`[data-test-item-button="${fileId}"]`); + assert + .dom(`[data-test-item-button="${authorInstanceId}"]`) + .exists('the card instance surfaces as a result tile'); + assert + .dom(`[data-test-item-button="${fileId}"]`) + .exists('the file surfaces as a result tile in the same mixed list'); + + await click(`[data-test-item-button="${authorInstanceId}"]`); + await click(`[data-test-item-button="${fileId}"]`); + await click('[data-test-card-chooser-go-button]'); + + let result = await pending; + assert.ok(result, 'the chooser resolves with the picks'); + let byId = new Map((result ?? []).map((item) => [item.id, item.kind])); + assert.strictEqual( + byId.get(authorInstanceId), + 'card', + 'the card instance pick is tagged kind=card', + ); + assert.strictEqual( + byId.get(fileId), + 'file', + 'the file pick is tagged kind=file', + ); + assert.strictEqual( + result?.length, + 2, + 'exactly the two picks are returned', + ); + }); + + // Regression (CS-12205): a file pick must keep its own id. The `.json` + // extension is a card-id convention — stripping it from a `kind: 'file'` + // row would rewrite `settings/config.json` to `settings/config` (a + // card-instance id shape) under a file kind, so the caller would resolve + // the wrong resource. "Select All" is the sharp case: it routes every row + // through the sheet's dedup + the modal's pick projection, both of which + // previously stripped `.json` for all kinds. + test('Select All preserves a file pick’s `.json` id and kind', async function (assert) { + let pending = chooseCard( + { filter: { type: baseRef }, realms: [testRealmURL] }, + { includeFiles: true, multiSelect: true }, + ); + + await waitFor('[data-test-card-chooser-modal]'); + // The standalone `.json` file surfaces as a file row (its tile's test id + // is `.json`-stripped, but the resolved pick must not be). + await waitFor(`[data-test-item-button="${testRealmURL}settings/config"]`); + await waitFor(`[data-test-item-button="${authorInstanceId}"]`); + + // Select one item so the multi-select menu appears, then Select All to + // pull every row (card + files) into the selection. + await click(`[data-test-item-button="${authorInstanceId}"]`); + await click('[data-test-selection-dropdown-trigger]'); + await waitFor('[data-test-boxel-menu-item-text="Select All"]'); + await click('[data-test-boxel-menu-item-text="Select All"]'); + await click('[data-test-card-chooser-go-button]'); + + let result = await pending; + assert.ok(result, 'the chooser resolves with the Select All picks'); + + let jsonPick = (result ?? []).find( + (item) => item.kind === 'file' && item.id === jsonFileId, + ); + assert.ok( + jsonPick, + 'the standalone `.json` file is returned with its `.json` id intact and kind=file', + ); + + let cardPick = (result ?? []).find( + (item) => item.id === authorInstanceId, + ); + assert.strictEqual( + cardPick?.kind, + 'card', + 'the card instance keeps its extensionless id and kind=card', + ); + }); + }, +); 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 8060e720d6..1063bdf67f 100644 --- a/packages/host/tests/integration/components/card-search-adorn-test.gts +++ b/packages/host/tests/integration/components/card-search-adorn-test.gts @@ -1,11 +1,14 @@ -import { render } from '@ember/test-helpers'; +import { click, render } from '@ember/test-helpers'; import { module, test } from 'qunit'; import { rri, type RenderableSearchEntryLike } from '@cardstack/runtime-common'; import SearchResultTile from '@cardstack/host/components/card-search/result-tile'; -import type { NewCardArgs } from '@cardstack/host/utils/card-search/types'; +import type { + NewCardArgs, + SearchSelection, +} from '@cardstack/host/utils/card-search/types'; import { setupRenderingTest } from '../../helpers/setup'; @@ -26,6 +29,7 @@ function entry( return { id: 'http://test/Foo/1', realmUrl: 'http://test/', + kind: 'card', name: 'Foo/1', isError: false, component: StubComponent, @@ -162,3 +166,66 @@ module('Integration | card-search/result-tile (adorn)', function (hooks) { .exists('the type icon is rendered in the label icon slot'); }); }); + +// The tile is what stamps each pick's kind onto the selection payload: a mixed +// card/file chooser reads `{ id, kind }` straight from the emitted selection +// rather than inferring the kind from the id. The kind rides on the entry +// view-model (`entry.kind`), so a card row emits `card` and a file row `file`. +module( + 'Integration | card-search/result-tile (selection payload kind)', + function (hooks) { + setupRenderingTest(hooks); + + const cardEntry = entry({ id: 'http://test/Book/1', kind: 'card' }); + const fileEntry = entry({ + id: 'http://test/notes/readme.md', + kind: 'file', + }); + + test('a card row emits a kind=card selection payload', async function (assert) { + let selected: SearchSelection | undefined; + const onSelect = (selection: SearchSelection) => (selected = selection); + + await render( + , + ); + + await click('[data-test-item-button="http://test/Book/1"]'); + assert.deepEqual( + selected, + { id: 'http://test/Book/1', kind: 'card' }, + 'clicking a card row emits its id tagged kind=card', + ); + }); + + test('a file row emits a kind=file selection payload', async function (assert) { + let selected: SearchSelection | undefined; + const onSelect = (selection: SearchSelection) => (selected = selection); + + await render( + , + ); + + // A file id keeps its extension (only the card `.json` convention is + // stripped for the data-test hook), so the payload id is the file url. + await click('[data-test-item-button="http://test/notes/readme.md"]'); + assert.deepEqual( + selected, + { id: 'http://test/notes/readme.md', kind: 'file' }, + 'clicking a file row emits its url tagged kind=file', + ); + }); + }, +); diff --git a/packages/host/tests/integration/components/card-search-selection-menu-test.gts b/packages/host/tests/integration/components/card-search-selection-menu-test.gts index 798da7962a..b18322cbf4 100644 --- a/packages/host/tests/integration/components/card-search-selection-menu-test.gts +++ b/packages/host/tests/integration/components/card-search-selection-menu-test.gts @@ -1,4 +1,4 @@ -import { array, get } from '@ember/helper'; +import { get } from '@ember/helper'; import { click, render, waitFor } from '@ember/test-helpers'; import { module, test } from 'qunit'; @@ -8,11 +8,17 @@ import { VIEW_OPTIONS, } from '@cardstack/host/components/card-search/constants'; import SearchResultHeader from '@cardstack/host/components/card-search/search-result-header'; +import type { SelectedSearchItem } from '@cardstack/host/utils/card-search/types'; import { setupRenderingTest } from '../../helpers/setup'; const noop = () => {}; +const card = (id: string): SelectedSearchItem => ({ id, kind: 'card' }); +const selectedTwo: SelectedSearchItem[] = [card('a'), card('b')]; +const allThree: SelectedSearchItem[] = [card('a'), card('b'), card('c')]; +const noSelection: SelectedSearchItem[] = []; + module( 'Integration | card-search/search-result-header (selection menu)', function (hooks) { @@ -30,8 +36,8 @@ module( @onChangeView={{noop}} @onChangeSort={{noop}} @multiSelect={{true}} - @selectedCards={{array 'a' 'b'}} - @allCards={{array 'a' 'b' 'c'}} + @selectedCards={{selectedTwo}} + @allCards={{allThree}} @onSelectAll={{noop}} @onDeselectAll={{noop}} /> @@ -54,9 +60,10 @@ module( }); test('the selection menu opens with an inert "N Selected" header above Select/Deselect All', async function (assert) { - let selectAllArg: string[] | undefined; + let selectAllArg: SelectedSearchItem[] | undefined; let deselectedAll = false; - const onSelectAll = (cards: string[]) => (selectAllArg = cards); + const onSelectAll = (cards: SelectedSearchItem[]) => + (selectAllArg = cards); const onDeselectAll = () => (deselectedAll = true); await render( @@ -70,8 +77,8 @@ module( @onChangeView={{noop}} @onChangeSort={{noop}} @multiSelect={{true}} - @selectedCards={{array 'a' 'b'}} - @allCards={{array 'a' 'b' 'c'}} + @selectedCards={{selectedTwo}} + @allCards={{allThree}} @onSelectAll={{onSelectAll}} @onDeselectAll={{onDeselectAll}} /> @@ -97,7 +104,7 @@ module( await click('[data-test-boxel-menu-item-text="Select All"]'); assert.deepEqual( selectAllArg, - ['a', 'b', 'c'], + [card('a'), card('b'), card('c')], 'Select All forwards every card', ); @@ -119,8 +126,8 @@ module( @onChangeView={{noop}} @onChangeSort={{noop}} @multiSelect={{true}} - @selectedCards={{array}} - @allCards={{array 'a' 'b' 'c'}} + @selectedCards={{noSelection}} + @allCards={{allThree}} @onSelectAll={{noop}} @onDeselectAll={{noop}} /> diff --git a/packages/host/tests/integration/resources/search-entries-test.gts b/packages/host/tests/integration/resources/search-entries-test.gts index d8256e6ac6..0d9113d5b2 100644 --- a/packages/host/tests/integration/resources/search-entries-test.gts +++ b/packages/host/tests/integration/resources/search-entries-test.gts @@ -252,6 +252,11 @@ module('Integration | search-entries resource', function (hooks) { assert.strictEqual(search.errors, undefined); for (let entry of search.entries) { assert.strictEqual(entry.realmUrl, testRealmURL); + assert.strictEqual( + entry.kind, + 'card', + 'a card instance row is tagged kind=card', + ); assert.strictEqual( entry.html.length, 1, @@ -336,6 +341,22 @@ module('Integration | search-entries resource', function (hooks) { knownFileMetaUrls.has(htmlFileUrl), 'the html-backed file row (no render type) is registered', ); + + // Both file rows carry kind=file so a mixed card/file chooser can tag a + // selection by kind without inspecting the id — the item-only row off its + // `file-meta` item type, the html-backed row off its render-type-less + // rendering. + let byId = new Map(search.entries.map((e) => [e.id, e])); + assert.strictEqual( + byId.get(itemFileUrl)?.kind, + 'file', + 'the item-only file row is tagged kind=file', + ); + assert.strictEqual( + byId.get(htmlFileUrl)?.kind, + 'file', + 'the html-backed file row is tagged kind=file', + ); } finally { storeService.searchEntries = originalSearchEntries; clearKnownFileMetaUrls(); diff --git a/packages/host/tests/unit/card-search-query-builder-test.ts b/packages/host/tests/unit/card-search-query-builder-test.ts index b4077f25d0..66cca4bea7 100644 --- a/packages/host/tests/unit/card-search-query-builder-test.ts +++ b/packages/host/tests/unit/card-search-query-builder-test.ts @@ -153,6 +153,21 @@ module('Unit | card-search/query-builder', function () { ); }); + test('a picked ROOT type keeps the card-json dedup', function (assert) { + // The chooser seeds the type picker from its base filter, so a root base + // filter (BaseDef) becomes a *selected* root type. A root selection still + // spans both kinds, so — unlike a narrowing type pick — the card-`.json` + // dedup must remain. Regression: treating any selected type as narrowing + // dropped the dedup here, leaking a card's `.json` file row into the + // mixed chooser (a duplicate of the card's instance row). + let typeKey = `${baseRef.module}/${baseRef.name}`; + let query = buildSearchQuery('', SORT_AZ, { type: baseRef }, [typeKey]); + assert.deepEqual(query, { + filter: { every: [{ type: baseRef }, DEDUP_FILTER] }, + sort: SORT_AZ.sort, + }); + }); + test('search with a selected type produces a type filter alongside the search-term filter', function (assert) { let authorRef = { module: 'http://test-realm/test/author' as RealmResourceIdentifier, diff --git a/packages/runtime-common/index.ts b/packages/runtime-common/index.ts index 5cfb559454..ef833b74b7 100644 --- a/packages/runtime-common/index.ts +++ b/packages/runtime-common/index.ts @@ -987,7 +987,7 @@ export interface RealmCards { // on the server? address in CS-8343 export { v4 as uuidv4 } from '@lukeed/uuid'; // isomorphic UUID's using Math.random import type { LocalPath } from './paths.ts'; -import type { CardTypeFilter, Query, EveryFilter } from './query.ts'; +import type { CardTypeFilter, Query, EveryFilter, AnyFilter } from './query.ts'; import { Loader } from './loader.ts'; export * from './frontmatter-parse.ts'; export * from './paths.ts'; @@ -1214,13 +1214,27 @@ interface CardChooserOpts { */ lockConsumingRealm?: boolean; preselectedCardUrls?: string[]; + /** + * Run the chooser in mixed cards + files mode: file results appear alongside + * card instances, and the return value is tagged with each pick's `kind` + * ({@link ChosenItem}) instead of a bare card id. Defaults to false + * (cards-only, string ids) so existing callers are unaffected. + */ + includeFiles?: boolean; +} + +// A chosen result in mixed (includeFiles) mode: the id plus whether it is a card +// instance or a file, so the caller can route each pick without inspecting the id. +export interface ChosenItem { + id: string; + kind: 'card' | 'file'; } export interface CardChooser { chooseCard( query: CardChooserQuery, opts?: CardChooserOpts & { multiSelect?: boolean }, - ): Promise; + ): Promise; } export interface FileChooser { @@ -1233,6 +1247,24 @@ export interface FileChooser { }): Promise; } +// Mixed mode (includeFiles) — returns kind-tagged ChosenItem(s). +export async function chooseCard( + query: CardChooserQuery, + opts: CardChooserOpts & { + includeFiles: true; + multiSelect: true; + preselectedCardTypeQuery?: Query; + }, +): Promise; +export async function chooseCard( + query: CardChooserQuery, + opts: CardChooserOpts & { + includeFiles: true; + multiSelect?: false; + preselectedCardTypeQuery?: Query; + }, +): Promise; +// Cards-only (default) — returns bare card id string(s). export async function chooseCard( query: CardChooserQuery, opts: CardChooserOpts & { @@ -1253,7 +1285,7 @@ export async function chooseCard( multiSelect?: boolean; preselectedCardTypeQuery?: Query; }, -): Promise { +): Promise { let here = globalThis as any; if (!here._CARDSTACK_CARD_CHOOSER) { throw new Error( @@ -1391,7 +1423,7 @@ export interface Store { } export type CardChooserQuery = Query & { - filter?: CardTypeFilter | EveryFilter; + filter?: CardTypeFilter | EveryFilter | AnyFilter; }; export interface CardCreator { diff --git a/packages/runtime-common/search-results-component.ts b/packages/runtime-common/search-results-component.ts index 3ece884a9a..df8b49352b 100644 --- a/packages/runtime-common/search-results-component.ts +++ b/packages/runtime-common/search-results-component.ts @@ -44,6 +44,9 @@ export interface RenderableSearchEntryLike { id: string; // The URL of the realm hosting this result — used to group results by realm. realmUrl: string; + // Whether this result is a card instance or a file — lets a consumer (e.g. a + // mixed card/file chooser) tag a selection by kind without inspecting the id. + kind: 'card' | 'file'; // The result's realm-local path (e.g. `Person/error`) — a readable label a // consumer shows on an error tile to identify which result failed. Falls back // to the bare id when the id isn't under the entry's realm.