Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions catalog-app/listing/listing.gts
Original file line number Diff line number Diff line change
Expand Up @@ -934,6 +934,8 @@ export class Listing extends CardDef {
@field license = linksTo(() => License, { searchable: true });
@field images = linksToMany(ImageDef, { searchable: true });
@field examples = linksToMany(() => CardDef);
// installed alongside examples but never rendered in the listing detail view
@field supportingCards = linksToMany(() => CardDef);
@field skills = linksToMany(() => Skill);

@field cardTitle = contains(StringField, {
Expand Down
9 changes: 6 additions & 3 deletions commands/collect-submission-files.ts
Original file line number Diff line number Diff line change
Expand Up @@ -109,10 +109,13 @@ export default class CollectSubmissionFilesCommand extends Command<
);
}

let examplesToSnapshot = listing.examples;
let examplesToSnapshot = [
...(listing.examples ?? []),
...(listing.supportingCards ?? []),
];
let fileDefUrls = new Set<string>();
if (listing.examples?.length) {
let expanded = await this.expandInstances(listing.examples);
if (examplesToSnapshot.length) {
let expanded = await this.expandInstances(examplesToSnapshot);
examplesToSnapshot = expanded.instances;
fileDefUrls = expanded.fileDefUrls;
}
Expand Down
94 changes: 86 additions & 8 deletions commands/listing-create.ts
Original file line number Diff line number Diff line change
Expand Up @@ -96,6 +96,9 @@ export default class ListingCreateCommand extends Command<
input: BaseCommandModule.ListingCreateInput,
): Promise<BaseCommandModule.ListingCreateResult> {
let { openCardIds, codeRef, targetRealm } = input;
// tolerate a base ListingCreateInput that predates supportingCardIds
let explicitSupportingCardIds = (input as { supportingCardIds?: string[] })
.supportingCardIds;

if (!codeRef) {
throw new Error('codeRef is required');
Expand All @@ -118,12 +121,24 @@ export default class ListingCreateCommand extends Command<
const displayName = (cardDef as any)?.displayName ?? codeRef.name;
const catalogRealm = await this.getCatalogRealm();

const classified = await this.classifyOpenCards(openCardIds, codeRef);
const exampleCardIds = classified.exampleCardIds;
// explicit supporting cards are taken verbatim (no type check); a card
// also picked as an example stays an example
const supportingCardIds = [
...new Set([
...classified.supportingCardIds,
...(explicitSupportingCardIds ?? []),
]),
].filter((id) => !exampleCardIds.includes(id));

let relationships: Record<string, { links: { self: string } }> = {};
if (openCardIds && openCardIds.length > 0) {
openCardIds.forEach((id, index) => {
relationships[`examples.${index}`] = { links: { self: id } };
});
}
exampleCardIds.forEach((id, index) => {
relationships[`examples.${index}`] = { links: { self: id } };
});
supportingCardIds.forEach((id, index) => {
relationships[`supportingCards.${index}`] = { links: { self: id } };
});

const listingDoc: LooseSingleCardDocument = {
data: {
Expand All @@ -144,12 +159,12 @@ export default class ListingCreateCommand extends Command<

const commandModule = await loadCommandModule(this.commandContext);
const listingCard = listing as CardAPI.CardDef;
const firstOpenCardId = openCardIds?.[0];
const firstExampleCardId = exampleCardIds[0];

const examplePromise = this.autoLinkExample(
listingCard,
codeRef,
openCardIds,
exampleCardIds,
);

const backgroundTasks = [
Expand Down Expand Up @@ -190,7 +205,7 @@ export default class ListingCreateCommand extends Command<
promise: this.linkSpecs(
listingCard,
targetRealm,
firstOpenCardId ?? codeRef?.module,
firstExampleCardId ?? codeRef?.module,
codeRef.module,
codeRef,
),
Expand All @@ -216,6 +231,69 @@ export default class ListingCreateCommand extends Command<
return result;
}

// Open cards that are instances of the listed type become examples; other
// open cards (e.g. data a query-based example depends on) become
// supportingCards, which install but don't render in the listing detail.
private async classifyOpenCards(
openCardIds: string[] | undefined,
Comment on lines +234 to +238

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Claude Code 🤖] Same constraint as the install-test note: the listing-create acceptance module is currently module.skip, so classification coverage (non-instance open card lands in supportingCards, load-failure falls back to examples) will be added when that suite is re-enabled. The host-side modal behavior is covered by tests in cardstack/boxel#5536.

codeRef: ResolvedCodeRef,
): Promise<{ exampleCardIds: string[]; supportingCardIds: string[] }> {
const classified = await Promise.all(
(openCardIds ?? []).map(async (id) => {
try {
const instance = await new GetCardCommand(
this.commandContext,
).execute({ cardId: id });
return {
id,
isExample:
isCardInstance(instance) &&
this.isInstanceOfCodeRef(instance as CardAPI.CardDef, codeRef),
};
} catch {
return { id, isExample: true };
}
}),
);
return {
exampleCardIds: classified.filter((c) => c.isExample).map((c) => c.id),
supportingCardIds: classified
.filter((c) => !c.isExample)
.map((c) => c.id),
};
}

private isInstanceOfCodeRef(
instance: CardAPI.CardDef,
codeRef: ResolvedCodeRef,
): boolean {
let targetModule: string;
try {
targetModule = trimExecutableExtension(rri(new URL(codeRef.module).href));
} catch {
return false;
}
let current: CardAPI.BaseDefConstructor | undefined =
instance.constructor as CardAPI.BaseDefConstructor;
while (current) {
const ref = identifyCard(current);
if (ref && !('type' in ref) && ref.name === codeRef.name) {
try {
if (
trimExecutableExtension(rri(new URL(ref.module).href)) ===
targetModule
) {
return true;
}
} catch {
// unresolvable ancestor module; keep walking
}
}
current = getAncestor(current) ?? undefined;
}
return false;
}

private async guessListingType(
codeRef: ResolvedCodeRef,
cardDef: CardAPI.BaseDefConstructor | undefined,
Expand Down
19 changes: 13 additions & 6 deletions commands/listing-install.ts
Original file line number Diff line number Diff line change
Expand Up @@ -98,9 +98,14 @@ export default class ListingInstallCommand extends Command<
// this is intentionally to type because base command cannot interpret Listing type from catalog
const listing = listingInput as Listing;

let examplesToInstall = listing.examples;
if (listing.examples?.length) {
examplesToInstall = await this.expandInstances(listing.examples);
// seed examples first so the primary example stays the first planned instance
let hasPrimaryExamples = (listing.examples?.length ?? 0) > 0;
let examplesToInstall = [
...(listing.examples ?? []),
...(listing.supportingCards ?? []),
];
Comment on lines +102 to +106

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Claude Code 🤖] Agreed on coverage, but the listing install/create/use acceptance modules are currently module.skip pending the skipped-catalog-tests re-enablement effort, so a new test here wouldn't execute. Supporting-card scenarios (copied on install, supporting-only listing leaves exampleCardId/selectedCodeRef unset) are on the list for when those modules are unskipped.

if (examplesToInstall.length) {
examplesToInstall = await this.expandInstances(examplesToInstall);
}

// side-effects
Expand All @@ -125,9 +130,11 @@ export default class ListingInstallCommand extends Command<
resolver,
virtualNetwork,
);
let firstInstance = r.instancesCopy[0];
exampleCardId = join(realmUrl, firstInstance.lid);
selectedCodeRef = firstInstance.targetCodeRef;
if (hasPrimaryExamples) {
let firstInstance = r.instancesCopy[0];
exampleCardId = join(realmUrl, firstInstance.lid);
selectedCodeRef = firstInstance.targetCodeRef;
}
return r;
})
.addIf(listing.skills?.length > 0, (resolver: ListingPathResolver) => {
Expand Down
25 changes: 14 additions & 11 deletions commands/listing-use.ts
Original file line number Diff line number Diff line change
Expand Up @@ -78,17 +78,20 @@ export default class ListingUseCommand extends Command<
});
}

if (listing.examples) {
const sourceCards = (listing.examples as CardAPI.CardDef[]).map(
(example) => example,
);
for (const card of sourceCards) {
await new CopyCardToRealmCommand(this.commandContext).execute({
sourceCard: card,
targetRealm: realmUrl,
localDir,
});
}
const sourceCards = [
...new Map(
[
...((listing.examples ?? []) as CardAPI.CardDef[]),
...((listing.supportingCards ?? []) as CardAPI.CardDef[]),
].map((card) => [card.id ?? card, card]),
).values(),
];
for (const card of sourceCards) {
await new CopyCardToRealmCommand(this.commandContext).execute({
sourceCard: card,
targetRealm: realmUrl,
localDir,
});
}
Comment on lines +81 to 95

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Claude Code 🤖] Fixed in bea1e78 — the copy loop now dedupes by card id across the two fields. Install and submission were already safe because expandInstances tracks visited ids.


if ('skills' in listing && Array.isArray(listing.skills)) {
Expand Down
Loading