Skip to content

Commit 07b9eb7

Browse files
committed
Basic ability to choose a file for a linksTo(FileDef) field
1 parent fb9f69a commit 07b9eb7

11 files changed

Lines changed: 207 additions & 40 deletions

File tree

packages/base/card-api.gts

Lines changed: 2 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1318,9 +1318,8 @@ class LinksTo<CardT extends LinkableDefConstructor> implements Field<CardT> {
13181318
format: Format | undefined,
13191319
defaultFormat: Format,
13201320
isComputed: boolean,
1321-
isFileDef: boolean,
13221321
) {
1323-
return (format ?? defaultFormat) === 'edit' && !isComputed && !isFileDef;
1322+
return (format ?? defaultFormat) === 'edit' && !isComputed;
13241323
}
13251324
function getChildFormat(
13261325
format: Format | undefined,
@@ -1354,9 +1353,7 @@ class LinksTo<CardT extends LinkableDefConstructor> implements Field<CardT> {
13541353
<CardCrudFunctionsConsumer as |cardCrudFunctions|>
13551354
<DefaultFormatsConsumer as |defaultFormats|>
13561355
{{#if
1357-
(shouldRenderEditor
1358-
@format defaultFormats.cardDef isComputed isFileDef
1359-
)
1356+
(shouldRenderEditor @format defaultFormats.cardDef isComputed)
13601357
}}
13611358
<LinksToEditor
13621359
@model={{(getInnerModel)}}

packages/base/links-to-editor.gts

Lines changed: 11 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -10,16 +10,17 @@ import {
1010
getBoxComponent,
1111
} from './field-component';
1212
import {
13-
type CardDef,
1413
type BaseDef,
1514
type Box,
1615
type Field,
1716
type CardContext,
1817
type LinkableDefConstructor,
1918
CreateCardFn,
19+
isFileDefConstructor,
2020
} from './card-api';
2121
import {
2222
chooseCard,
23+
chooseFile,
2324
baseCardRef,
2425
identifyCard,
2526
CardContextName,
@@ -37,7 +38,7 @@ import { hash } from '@ember/helper';
3738
interface Signature {
3839
Element: HTMLElement;
3940
Args: {
40-
model: Box<CardDef | null>;
41+
model: Box<BaseDef | null>;
4142
field: Field<LinkableDefConstructor>;
4243
typeConstraint?: ResolvedCodeRef;
4344
createCard?: CreateCardFn;
@@ -166,6 +167,14 @@ export class LinksToEditor extends GlimmerComponent<Signature> {
166167
}
167168

168169
private chooseCard = restartableTask(async () => {
170+
if (isFileDefConstructor(this.args.field.card as typeof BaseDef)) {
171+
let file = await chooseFile();
172+
if (file) {
173+
this.args.model.value = file;
174+
}
175+
return;
176+
}
177+
169178
let type = identifyCard(this.args.field.card) ?? baseCardRef;
170179
if (this.args.typeConstraint) {
171180
type = await getNarrowestType(this.args.typeConstraint, type, myLoader());

packages/host/app/components/operator-mode/choose-file-modal.gts

Lines changed: 10 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -20,14 +20,14 @@ import {
2020
Deferred,
2121
RealmPaths,
2222
type LocalPath,
23+
isCardErrorJSONAPI,
2324
} from '@cardstack/runtime-common';
2425

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

27-
import type MatrixService from '@cardstack/host/services/matrix-service';
2828
import type OperatorModeStateService from '@cardstack/host/services/operator-mode-state-service';
29-
3029
import type RealmService from '@cardstack/host/services/realm';
30+
import type StoreService from '@cardstack/host/services/store';
3131

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

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

4545
@service private declare operatorModeStateService: OperatorModeStateService;
4646
@service private declare realm: RealmService;
47-
@service private declare matrixService: MatrixService;
47+
@service private declare store: StoreService;
4848

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

7474
@action
75-
private pick(path: LocalPath | undefined) {
75+
private async pick(path: LocalPath | undefined) {
7676
if (this.deferred && this.selectedRealm && path) {
7777
let fileURL = new RealmPaths(this.selectedRealm.url).fileURL(path);
78-
let file = this.matrixService.fileAPI.createFileDef({
79-
sourceUrl: fileURL.toString(),
80-
name: fileURL.toString().split('/').pop()!,
81-
});
78+
let file = await this.store.getFileMeta<FileDef>(fileURL.href);
79+
if (isCardErrorJSONAPI(file)) {
80+
throw new Error(
81+
`choose-file-modal: failed to load file meta for ${fileURL.href}`,
82+
);
83+
}
8284
this.deferred.fulfill(file);
8385
}
8486

packages/host/app/services/store.ts

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -52,6 +52,7 @@ import {
5252

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

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

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

381+
async getFileMeta<T extends FileDef>(
382+
url: string,
383+
): Promise<T | CardErrorJSONAPI> {
384+
return await this.withTestWaiters(async () => {
385+
try {
386+
let fileMetaDoc = await this.store.loadFileMetaDocument(url);
387+
if (isCardError(fileMetaDoc)) {
388+
throw fileMetaDoc;
389+
}
390+
let api = await this.cardService.getAPI();
391+
let fileInstance = await api.createFromSerialized(
392+
fileMetaDoc.data,
393+
fileMetaDoc,
394+
fileMetaDoc.data.id ? new URL(fileMetaDoc.data.id) : new URL(url),
395+
{ store: this.store },
396+
);
397+
this.setIdentityContext(fileInstance as unknown as CardDef);
398+
return fileInstance as unknown as T;
399+
} catch (error: any) {
400+
let errorResponse = processCardError(url, error);
401+
return errorResponse.errors[0];
402+
}
403+
});
404+
}
405+
380406
// Bypass cached state and fetch from source of truth
381407
async getWithoutCache<T extends CardDef>(
382408
id: string,

packages/host/tests/acceptance/interact-submode-test.gts

Lines changed: 51 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import {
66
typeIn,
77
triggerKeyEvent,
88
settled,
9+
waitFor,
910
} from '@ember/test-helpers';
1011

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

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

553+
test('can link a file via the chooser and index the update', async function (assert) {
554+
let cardId = `${testRealmURL}FileLinkCard/empty`;
555+
await visitOperatorMode({
556+
stacks: [
557+
[
558+
{
559+
id: cardId,
560+
format: 'edit',
561+
},
562+
],
563+
],
564+
});
565+
566+
let messageService = getService('message-service');
567+
let receivedEventDeferred = new Deferred<IncrementalIndexEventContent>();
568+
messageService.listenerCallbacks.get(testRealmURL)!.push((ev) => {
569+
if (
570+
ev.eventName === 'index' &&
571+
ev.indexType === 'incremental-index-initiation'
572+
) {
573+
return; // ignore the index initiation event
574+
}
575+
if (ev.eventName === 'index' && ev.indexType === 'incremental') {
576+
receivedEventDeferred.fulfill(ev as IncrementalIndexEventContent);
577+
}
578+
});
579+
580+
await click(
581+
`[data-test-links-to-editor="attachment"] [data-test-add-new="attachment"]`,
582+
);
583+
await waitFor('[data-test-file="README.md"]');
584+
await click('[data-test-file="README.md"]');
585+
await click('[data-test-choose-file-modal-add-button]');
586+
587+
await click('[data-test-edit-button]');
588+
589+
assert
590+
.dom(
591+
`[data-test-stack-card="${cardId}"] [data-test-file-link-attachment]`,
592+
)
593+
.includesText('Hello World', 'linked file is rendered');
594+
595+
let indexEvent = await receivedEventDeferred.promise;
596+
assert.ok(
597+
indexEvent.invalidations.includes(cardId),
598+
'indexing invalidates the edited card',
599+
);
600+
});
601+
552602
test('can save mutated card without having opened in stack', async function (assert) {
553603
await visitOperatorMode({
554604
stacks: [

packages/host/tests/helpers/interact-submode-setup.gts

Lines changed: 30 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -56,12 +56,12 @@ export function setupInteractSubmodeTests(
5656
let string: typeof import('https://cardstack.com/base/string');
5757
let spec: typeof import('https://cardstack.com/base/spec');
5858
let cardsGrid: typeof import('https://cardstack.com/base/cards-grid');
59-
let fileApi: typeof import('https://cardstack.com/base/file-api');
59+
let markdownFileDef: typeof import('https://cardstack.com/base/markdown-file-def');
6060
cardApi = await loader.import(`${baseRealm.url}card-api`);
6161
string = await loader.import(`${baseRealm.url}string`);
6262
spec = await loader.import(`${baseRealm.url}spec`);
6363
cardsGrid = await loader.import(`${baseRealm.url}cards-grid`);
64-
fileApi = await loader.import(`${baseRealm.url}file-api`);
64+
markdownFileDef = await loader.import(`${baseRealm.url}markdown-file-def`);
6565

6666
let {
6767
field,
@@ -76,7 +76,7 @@ export function setupInteractSubmodeTests(
7676
let { default: StringField } = string;
7777
let { Spec } = spec;
7878
let { CardsGrid } = cardsGrid;
79-
let { FileDef } = fileApi;
79+
let { MarkdownDef } = markdownFileDef;
8080

8181
class Pet extends CardDef {
8282
static displayName = 'Pet';
@@ -245,7 +245,7 @@ export function setupInteractSubmodeTests(
245245
class FileLinkCard extends CardDef {
246246
static displayName = 'File Link Card';
247247
@field title = contains(StringField);
248-
@field attachment = linksTo(FileDef);
248+
@field attachment = linksTo(MarkdownDef);
249249

250250
static isolated = class Isolated extends Component<typeof this> {
251251
<template>
@@ -313,8 +313,8 @@ export function setupInteractSubmodeTests(
313313
'personnel.gts': { Personnel },
314314
'pet.gts': { Pet, Puppy },
315315
'shipping-info.gts': { ShippingInfo },
316-
'README.txt': `Hello World`,
317-
'FileLinkCard/notes.txt': 'Hello from a file link',
316+
'README.md': `# Hello World`,
317+
'FileLinkCard/notes.md': '# Hello from a file link',
318318
'person-entry.json': new Spec({
319319
cardTitle: 'Person Card',
320320
cardDescription: 'Spec for Person Card',
@@ -392,11 +392,11 @@ export function setupInteractSubmodeTests(
392392
relationships: {
393393
attachment: {
394394
links: {
395-
self: './notes.txt',
395+
self: './notes.md',
396396
},
397397
data: {
398398
type: 'file-meta',
399-
id: './notes.txt',
399+
id: './notes.md',
400400
},
401401
},
402402
},
@@ -408,6 +408,28 @@ export function setupInteractSubmodeTests(
408408
},
409409
},
410410
},
411+
'FileLinkCard/empty.json': {
412+
data: {
413+
type: 'card',
414+
attributes: {
415+
title: 'Empty linked file',
416+
},
417+
relationships: {
418+
attachment: {
419+
links: {
420+
self: null,
421+
},
422+
data: null,
423+
},
424+
},
425+
meta: {
426+
adoptsFrom: {
427+
module: '../file-link-card',
428+
name: 'FileLinkCard',
429+
},
430+
},
431+
},
432+
},
411433
'Puppy/marco.json': new Puppy({ name: 'Marco', age: '5 months' }),
412434
'grid.json': new CardsGrid(),
413435
'index.json': new CardsGrid(),

packages/host/tests/integration/realm-test.gts

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2958,6 +2958,48 @@ module('Integration | realm', function (hooks) {
29582958
assert.strictEqual(response.status, 404, 'file no longer exists');
29592959
});
29602960

2961+
test('realm can delete a file asset via card source', async function (assert) {
2962+
let { realm } = await setupIntegrationTestRealm({
2963+
mockMatrixUtils,
2964+
contents: {
2965+
'notes.md': '# Notes\n\nhello\n',
2966+
},
2967+
});
2968+
2969+
let response = await handle(
2970+
realm,
2971+
new Request(`${testRealmURL}notes.md`, {
2972+
headers: {
2973+
Accept: 'application/vnd.card+source',
2974+
},
2975+
}),
2976+
);
2977+
assert.strictEqual(response.status, 200, 'file exists');
2978+
2979+
response = await handle(
2980+
realm,
2981+
new Request(`${testRealmURL}notes.md`, {
2982+
method: 'DELETE',
2983+
headers: {
2984+
Accept: 'application/vnd.card+source',
2985+
},
2986+
}),
2987+
);
2988+
await realm.flushUpdateEvents();
2989+
2990+
assert.strictEqual(response.status, 204, 'file is deleted');
2991+
2992+
response = await handle(
2993+
realm,
2994+
new Request(`${testRealmURL}notes.md`, {
2995+
headers: {
2996+
Accept: 'application/vnd.card+source',
2997+
},
2998+
}),
2999+
);
3000+
assert.strictEqual(response.status, 404, 'file no longer exists');
3001+
});
3002+
29613003
test('realm can serve compiled js file when requested without file extension ', async function (assert) {
29623004
let { realm } = await setupIntegrationTestRealm({
29633005
mockMatrixUtils,

0 commit comments

Comments
 (0)