Skip to content
Open
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
11 changes: 6 additions & 5 deletions packages/base/card-api.gts
Original file line number Diff line number Diff line change
Expand Up @@ -1318,9 +1318,8 @@ class LinksTo<CardT extends LinkableDefConstructor> implements Field<CardT> {
format: Format | undefined,
defaultFormat: Format,
isComputed: boolean,
isFileDef: boolean,
) {
return (format ?? defaultFormat) === 'edit' && !isComputed && !isFileDef;
return (format ?? defaultFormat) === 'edit' && !isComputed;
}
function getChildFormat(
format: Format | undefined,
Expand Down Expand Up @@ -1354,9 +1353,7 @@ class LinksTo<CardT extends LinkableDefConstructor> implements Field<CardT> {
<CardCrudFunctionsConsumer as |cardCrudFunctions|>
<DefaultFormatsConsumer as |defaultFormats|>
{{#if
(shouldRenderEditor
@format defaultFormats.cardDef isComputed isFileDef
)
(shouldRenderEditor @format defaultFormats.cardDef isComputed)
}}
<LinksToEditor
@model={{(getInnerModel)}}
Expand Down Expand Up @@ -2859,6 +2856,10 @@ function lazilyLoadLink(
if (isCardError(error) && error.deps?.length) {
payloadError.deps = [...new Set(error.deps)];
}
if (isMissingFile) {
let missingDep = isFileLink ? reference : referenceForMissingFile;
payloadError.deps = [...new Set([...(payloadError.deps ?? []), missingDep])];
}
let payload = JSON.stringify({
type: 'error',
error: payloadError,
Expand Down
13 changes: 11 additions & 2 deletions packages/base/links-to-editor.gts
Original file line number Diff line number Diff line change
Expand Up @@ -10,16 +10,17 @@ import {
getBoxComponent,
} from './field-component';
import {
type CardDef,
type BaseDef,
type Box,
type Field,
type CardContext,
type LinkableDefConstructor,
CreateCardFn,
isFileDefConstructor,
} from './card-api';
import {
chooseCard,
chooseFile,
baseCardRef,
identifyCard,
CardContextName,
Expand All @@ -37,7 +38,7 @@ import { hash } from '@ember/helper';
interface Signature {
Element: HTMLElement;
Args: {
model: Box<CardDef | null>;
model: Box<BaseDef | null>;
field: Field<LinkableDefConstructor>;
typeConstraint?: ResolvedCodeRef;
createCard?: CreateCardFn;
Expand Down Expand Up @@ -166,6 +167,14 @@ export class LinksToEditor extends GlimmerComponent<Signature> {
}

private chooseCard = restartableTask(async () => {
if (isFileDefConstructor(this.args.field.card as typeof BaseDef)) {
let file = await chooseFile();
if (file) {
this.args.model.value = file;
}
return;
}

let type = identifyCard(this.args.field.card) ?? baseCardRef;
if (this.args.typeConstraint) {
type = await getNarrowestType(this.args.typeConstraint, type, myLoader());
Expand Down
9 changes: 2 additions & 7 deletions packages/base/links-to-many-component.gts
Original file line number Diff line number Diff line change
Expand Up @@ -482,9 +482,8 @@ function shouldRenderEditor(
format: Format | undefined,
defaultFormat: Format,
isComputed: boolean,
isFileDef: boolean,
) {
return (format ?? defaultFormat) === 'edit' && !isComputed && !isFileDef;
return (format ?? defaultFormat) === 'edit' && !isComputed;
}
const componentCache = initSharedState(
'linksToManyComponentCache',
Expand Down Expand Up @@ -519,11 +518,7 @@ export function getLinksToManyComponent({
let linksToManyComponent = class LinksToManyComponent extends GlimmerComponent<BoxComponentSignature> {
<template>
<DefaultFormatsConsumer as |defaultFormats|>
{{#if
(shouldRenderEditor
@format defaultFormats.cardDef isComputed isFileDef
)
}}
{{#if (shouldRenderEditor @format defaultFormats.cardDef isComputed)}}
<LinksToManyEditor
@model={{model}}
@arrayField={{arrayField}}
Expand Down
18 changes: 10 additions & 8 deletions packages/host/app/components/operator-mode/choose-file-modal.gts
Original file line number Diff line number Diff line change
Expand Up @@ -20,14 +20,14 @@ import {
Deferred,
RealmPaths,
type LocalPath,
isCardErrorJSONAPI,
} from '@cardstack/runtime-common';

import ModalContainer from '@cardstack/host/components/modal-container';

import type MatrixService from '@cardstack/host/services/matrix-service';
import type OperatorModeStateService from '@cardstack/host/services/operator-mode-state-service';

import type RealmService from '@cardstack/host/services/realm';
import type StoreService from '@cardstack/host/services/store';

import type { FileDef } from 'https://cardstack.com/base/file-api';

Expand All @@ -44,7 +44,7 @@ export default class ChooseFileModal extends Component<Signature> {

@service private declare operatorModeStateService: OperatorModeStateService;
@service private declare realm: RealmService;
@service private declare matrixService: MatrixService;
@service private declare store: StoreService;

constructor(owner: Owner, args: Signature['Args']) {
super(owner, args);
Expand Down Expand Up @@ -72,13 +72,15 @@ export default class ChooseFileModal extends Component<Signature> {
}

@action
private pick(path: LocalPath | undefined) {
private async pick(path: LocalPath | undefined) {
if (this.deferred && this.selectedRealm && path) {
let fileURL = new RealmPaths(this.selectedRealm.url).fileURL(path);
let file = this.matrixService.fileAPI.createFileDef({
sourceUrl: fileURL.toString(),
name: fileURL.toString().split('/').pop()!,
});
let file = await this.store.getFileMeta<FileDef>(fileURL.href);
if (isCardErrorJSONAPI(file)) {
throw new Error(
`choose-file-modal: failed to load file meta for ${fileURL.href}`,
);
}
this.deferred.fulfill(file);
Comment on lines +80 to 84
Copy link

Copilot AI Jan 21, 2026

Choose a reason for hiding this comment

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

The error thrown here will result in an uncaught promise rejection if the user clicks the Add button while a file is selected but the file metadata fails to load. Consider handling this error more gracefully, such as displaying an error message to the user rather than throwing an uncaught exception.

Suggested change
throw new Error(
`choose-file-modal: failed to load file meta for ${fileURL.href}`,
);
}
this.deferred.fulfill(file);
console.error(
`choose-file-modal: failed to load file meta for ${fileURL.href}`,
);
// Treat this as if no file was chosen so callers receive `undefined`.
this.deferred.fulfill(undefined as unknown as FileDef);
} else {
this.deferred.fulfill(file);
}

Copilot uses AI. Check for mistakes.
}

Expand Down
26 changes: 26 additions & 0 deletions packages/host/app/services/store.ts
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,7 @@ import {

import type { CardDef, BaseDef } from 'https://cardstack.com/base/card-api';
import type * as CardAPI from 'https://cardstack.com/base/card-api';
import type { FileDef } from 'https://cardstack.com/base/file-api';

import type { RealmEventContent } from 'https://cardstack.com/base/matrix-event';

Expand Down Expand Up @@ -377,6 +378,31 @@ export default class StoreService extends Service implements StoreInterface {
return await this.getInstance<T>({ idOrDoc: id });
}

async getFileMeta<T extends FileDef>(
url: string,
): Promise<T | CardErrorJSONAPI> {
return await this.withTestWaiters(async () => {
try {
let fileMetaDoc = await this.store.loadFileMetaDocument(url);
if (isCardError(fileMetaDoc)) {
throw fileMetaDoc;
}
let api = await this.cardService.getAPI();
let fileInstance = await api.createFromSerialized(
fileMetaDoc.data,
fileMetaDoc,
fileMetaDoc.data.id ? new URL(fileMetaDoc.data.id) : new URL(url),
{ store: this.store },
);
this.setIdentityContext(fileInstance as unknown as CardDef);
return fileInstance as unknown as T;
} catch (error: any) {
let errorResponse = processCardError(url, error);
return errorResponse.errors[0];
}
});
}

// Bypass cached state and fetch from source of truth
async getWithoutCache<T extends CardDef>(
id: string,
Expand Down
52 changes: 51 additions & 1 deletion packages/host/tests/acceptance/interact-submode-test.gts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import {
typeIn,
triggerKeyEvent,
settled,
waitFor,
} from '@ember/test-helpers';

import { triggerEvent } from '@ember/test-helpers';
Expand Down Expand Up @@ -511,7 +512,7 @@ module('Acceptance | interact submode tests', function (hooks) {
});

test('clicking a linked file opens it as a new isolated stack item', async function (assert) {
let fileId = `${testRealmURL}FileLinkCard/notes.txt`;
let fileId = `${testRealmURL}FileLinkCard/notes.md`;
await visitOperatorMode({
stacks: [
[
Expand Down Expand Up @@ -549,6 +550,55 @@ module('Acceptance | interact submode tests', function (hooks) {
});
});

test('can link a file via the chooser and index the update', async function (assert) {
let cardId = `${testRealmURL}FileLinkCard/empty`;
await visitOperatorMode({
stacks: [
[
{
id: cardId,
format: 'edit',
},
],
],
});

let messageService = getService('message-service');
let receivedEventDeferred = new Deferred<IncrementalIndexEventContent>();
messageService.listenerCallbacks.get(testRealmURL)!.push((ev) => {
if (
ev.eventName === 'index' &&
ev.indexType === 'incremental-index-initiation'
) {
return; // ignore the index initiation event
}
if (ev.eventName === 'index' && ev.indexType === 'incremental') {
receivedEventDeferred.fulfill(ev as IncrementalIndexEventContent);
}
});

await click(
`[data-test-links-to-editor="attachment"] [data-test-add-new="attachment"]`,
);
await waitFor('[data-test-file="README.md"]');
await click('[data-test-file="README.md"]');
await click('[data-test-choose-file-modal-add-button]');

await click('[data-test-edit-button]');

assert
.dom(
`[data-test-stack-card="${cardId}"] [data-test-file-link-attachment]`,
)
.includesText('Hello World', 'linked file is rendered');

let indexEvent = await receivedEventDeferred.promise;
assert.ok(
indexEvent.invalidations.includes(cardId),
'indexing invalidates the edited card',
);
});

test('can save mutated card without having opened in stack', async function (assert) {
await visitOperatorMode({
stacks: [
Expand Down
38 changes: 30 additions & 8 deletions packages/host/tests/helpers/interact-submode-setup.gts
Original file line number Diff line number Diff line change
Expand Up @@ -56,12 +56,12 @@ export function setupInteractSubmodeTests(
let string: typeof import('https://cardstack.com/base/string');
let spec: typeof import('https://cardstack.com/base/spec');
let cardsGrid: typeof import('https://cardstack.com/base/cards-grid');
let fileApi: typeof import('https://cardstack.com/base/file-api');
let markdownFileDef: typeof import('https://cardstack.com/base/markdown-file-def');
cardApi = await loader.import(`${baseRealm.url}card-api`);
string = await loader.import(`${baseRealm.url}string`);
spec = await loader.import(`${baseRealm.url}spec`);
cardsGrid = await loader.import(`${baseRealm.url}cards-grid`);
fileApi = await loader.import(`${baseRealm.url}file-api`);
markdownFileDef = await loader.import(`${baseRealm.url}markdown-file-def`);

let {
field,
Expand All @@ -76,7 +76,7 @@ export function setupInteractSubmodeTests(
let { default: StringField } = string;
let { Spec } = spec;
let { CardsGrid } = cardsGrid;
let { FileDef } = fileApi;
let { MarkdownDef } = markdownFileDef;

class Pet extends CardDef {
static displayName = 'Pet';
Expand Down Expand Up @@ -245,7 +245,7 @@ export function setupInteractSubmodeTests(
class FileLinkCard extends CardDef {
static displayName = 'File Link Card';
@field title = contains(StringField);
@field attachment = linksTo(FileDef);
@field attachment = linksTo(MarkdownDef);

static isolated = class Isolated extends Component<typeof this> {
<template>
Expand Down Expand Up @@ -313,8 +313,8 @@ export function setupInteractSubmodeTests(
'personnel.gts': { Personnel },
'pet.gts': { Pet, Puppy },
'shipping-info.gts': { ShippingInfo },
'README.txt': `Hello World`,
'FileLinkCard/notes.txt': 'Hello from a file link',
'README.md': `# Hello World`,
'FileLinkCard/notes.md': '# Hello from a file link',
'person-entry.json': new Spec({
cardTitle: 'Person Card',
cardDescription: 'Spec for Person Card',
Expand Down Expand Up @@ -392,11 +392,11 @@ export function setupInteractSubmodeTests(
relationships: {
attachment: {
links: {
self: './notes.txt',
self: './notes.md',
},
data: {
type: 'file-meta',
id: './notes.txt',
id: `${testRealmURL}FileLinkCard/notes.md`,
},
},
},
Expand All @@ -408,6 +408,28 @@ export function setupInteractSubmodeTests(
},
},
},
'FileLinkCard/empty.json': {
data: {
type: 'card',
attributes: {
title: 'Empty linked file',
},
relationships: {
attachment: {
links: {
self: null,
},
data: null,
},
},
meta: {
adoptsFrom: {
module: '../file-link-card',
name: 'FileLinkCard',
},
},
},
},
'Puppy/marco.json': new Puppy({ name: 'Marco', age: '5 months' }),
'grid.json': new CardsGrid(),
'index.json': new CardsGrid(),
Expand Down
11 changes: 4 additions & 7 deletions packages/host/tests/integration/components/card-basics-test.gts
Original file line number Diff line number Diff line change
Expand Up @@ -385,7 +385,7 @@ module('Integration | card-basics', function (hooks) {
assert.true(instanceOf(new ExteriorField(), FieldDef));
});

test('linksTo FileDef renders without editor controls in edit format', async function (assert) {
test('linksTo FileDef renders editor controls in edit format', async function (assert) {
class ImageDef extends FileDef {
static fitted = class Fitted extends Component<typeof this> {
<template>
Expand Down Expand Up @@ -420,13 +420,13 @@ module('Integration | card-basics', function (hooks) {

assert
.dom('[data-test-links-to-editor="hero"]')
.doesNotExist('FileDef links should not show linksTo editor UI');
.exists('FileDef links shows linksTo editor UI');
assert
.dom('[data-test-image-def]')
.hasText('hero.png', 'FileDef uses delegated fitted view');
});

test('linksToMany FileDef renders without editor controls in edit format', async function (assert) {
test('linksToMany FileDef renders editor controls in edit format', async function (assert) {
class ImageDef extends FileDef {
static fitted = class Fitted extends Component<typeof this> {
<template>
Expand Down Expand Up @@ -468,10 +468,7 @@ module('Integration | card-basics', function (hooks) {

assert
.dom('[data-test-links-to-many="attachments"]')
.doesNotExist('FileDef links should not show linksToMany editor UI');
assert
.dom('[data-test-plural-view-field="attachments"]')
.exists('FileDef links render via the plural view');
.exists('FileDef links shows linksToMany editor UI');
assert
.dom('[data-test-image-def]')
.exists(
Expand Down
Loading
Loading