diff --git a/packages/base/card-api.gts b/packages/base/card-api.gts index fad0e9cebe..7a2411712c 100644 --- a/packages/base/card-api.gts +++ b/packages/base/card-api.gts @@ -2173,6 +2173,7 @@ export type ViewCardFn = ( cardOrURL: CardDef | URL, format?: Format, opts?: { + type?: 'card' | 'file'; openCardInRightMostStack?: boolean; stackIndex?: number; fieldType?: 'linksTo' | 'contains' | 'containsMany' | 'linksToMany'; diff --git a/packages/base/links-to-editor.gts b/packages/base/links-to-editor.gts index dba3f3f783..a982e3ad03 100644 --- a/packages/base/links-to-editor.gts +++ b/packages/base/links-to-editor.gts @@ -87,7 +87,10 @@ export class LinksToEditor extends GlimmerComponent { /> {{/if}} diff --git a/packages/host/app/components/operator-mode/interact-submode.gts b/packages/host/app/components/operator-mode/interact-submode.gts index 536ab66e57..69476bc750 100644 --- a/packages/host/app/components/operator-mode/interact-submode.gts +++ b/packages/host/app/components/operator-mode/interact-submode.gts @@ -53,7 +53,11 @@ import { import CopyCardToStackCommand from '@cardstack/host/commands/copy-card-to-stack'; -import { StackItem } from '@cardstack/host/lib/stack-item'; +import { + detectStackItemTypeForTarget, + StackItem, + type StackItemType, +} from '@cardstack/host/lib/stack-item'; import { stackBackgroundsResource } from '@cardstack/host/resources/stack-backgrounds'; @@ -186,6 +190,7 @@ export default class InteractSubmode extends Component { request: new Deferred(), closeAfterSaving: opts?.closeAfterCreating, stackIndex, + type: 'card', }); this.addToStack(newItem); return localId; @@ -196,6 +201,7 @@ export default class InteractSubmode extends Component { cardOrURL: CardDef | URL | string, format: Format | Event = 'isolated', opts?: { + type?: StackItemType; openCardInRightMostStack?: boolean; stackIndex?: number; fieldType?: 'linksTo' | 'linksToMany' | 'contains' | 'containsMany'; @@ -212,6 +218,9 @@ export default class InteractSubmode extends Component { : cardOrURL instanceof URL ? cardOrURL.href : cardOrURL.id; + if (!cardId) { + return; + } if (opts?.openCardInRightMostStack) { stackIndex = this.stacks.length; } else if (typeof opts?.stackIndex === 'number') { @@ -229,10 +238,12 @@ export default class InteractSubmode extends Component { } stackIndex = opts.stackIndex; } + let stackItemType = opts?.type ?? this.getStackItemType(cardOrURL, cardId); let newItem = new StackItem({ id: cardId, format, stackIndex, + type: stackItemType, relationshipContext: opts?.fieldName ? { fieldName: opts.fieldName, @@ -251,6 +262,13 @@ export default class InteractSubmode extends Component { this.operatorModeStateService.editCardOnStack(stackIndex, card); }; + private getStackItemType( + cardOrURL: CardDef | URL | string, + cardId: string, + ): StackItemType { + return detectStackItemTypeForTarget(cardOrURL, cardId, this.store); + } + private saveCard = (id: string): void => { this.store.save(id); }; @@ -500,6 +518,7 @@ export default class InteractSubmode extends Component { id: url.href, format: 'isolated', stackIndex: 0, + type: this.getStackItemType(url, url.href), }); // it's important that we await the stack item readiness _before_ // we mutate the stack, otherwise there are very odd visual artifacts @@ -542,6 +561,7 @@ export default class InteractSubmode extends Component { id: url.href, format: 'isolated', stackIndex, + type: this.getStackItemType(url, url.href), }); // await stackItem.ready(); this.operatorModeStateService.clearStackAndAdd( diff --git a/packages/host/app/components/operator-mode/operator-mode-overlays.gts b/packages/host/app/components/operator-mode/operator-mode-overlays.gts index fd0b9d60a9..bd2f10a1b5 100644 --- a/packages/host/app/components/operator-mode/operator-mode-overlays.gts +++ b/packages/host/app/components/operator-mode/operator-mode-overlays.gts @@ -1,6 +1,7 @@ import { array, fn } from '@ember/helper'; import { on } from '@ember/modifier'; import { action } from '@ember/object'; +import { service } from '@ember/service'; import { consume } from 'ember-provide-consume-context'; import { velcro } from 'ember-velcro'; @@ -32,6 +33,7 @@ import { } from '@cardstack/boxel-ui/icons'; import type { CommandContext } from '@cardstack/runtime-common'; + import { CardCrudFunctionsContextName, CommandContextName, @@ -44,6 +46,8 @@ import type { Format, } from 'https://cardstack.com/base/card-api'; +import { detectStackItemTypeForTarget } from '../../lib/stack-item'; + import { removeFileExtension } from '../card-search/utils'; import Overlays from './overlays'; @@ -51,9 +55,11 @@ import Overlays from './overlays'; import type { StackItemRenderedCardForOverlayActions } from './stack-item'; import type { CardDefOrId } from './stack-item'; +import type StoreService from '../../services/store'; export default class OperatorModeOverlays extends Overlays { overlayClassName = 'actions-overlay'; + @service declare private store: StoreService; @consume(CardCrudFunctionsContextName) declare private cardCrudFunctions: CardCrudFunctions; @@ -345,6 +351,9 @@ export default class OperatorModeOverlays extends Overlays { case 'select': return !this.isField(renderedCard) && !!this.args.toggleSelect; case 'edit': + if (this.isFileMetaTarget(renderedCard)) { + return false; + } return this.realm.canWrite(this.getCardId(renderedCard.cardDefOrId)); case 'more-options': return true; @@ -353,6 +362,36 @@ export default class OperatorModeOverlays extends Overlays { } } + private isFileMetaTarget( + renderedCard: StackItemRenderedCardForOverlayActions, + ): boolean { + return this.getTypeForCardTarget(renderedCard.cardDefOrId) === 'file'; + } + + private getTypeForCardTarget(cardDefOrId: CardDefOrId): 'card' | 'file' { + return detectStackItemTypeForTarget( + cardDefOrId, + this.getCardId(cardDefOrId), + this.store, + ); + } + + protected override buildViewCardOpts( + cardDefOrId: CardDefOrId, + fieldType?: 'linksTo' | 'contains' | 'containsMany' | 'linksToMany', + fieldName?: string, + ): { + type?: 'card' | 'file'; + fieldType?: 'linksTo' | 'contains' | 'containsMany' | 'linksToMany'; + fieldName?: string; + } { + return { + type: this.getTypeForCardTarget(cardDefOrId), + fieldType, + fieldName, + }; + } + @action private registerDropdownAPI( renderedCard: StackItemRenderedCardForOverlayActions, @@ -398,6 +437,9 @@ export default class OperatorModeOverlays extends Overlays { protected override getFormatForCard( renderedCard: StackItemRenderedCardForOverlayActions, ): Format { + if (this.isFileMetaTarget(renderedCard)) { + return 'isolated'; + } return renderedCard.stackItem.format as Format; } diff --git a/packages/host/app/components/operator-mode/overlays.gts b/packages/host/app/components/operator-mode/overlays.gts index b0871721e1..e13996b1df 100644 --- a/packages/host/app/components/operator-mode/overlays.gts +++ b/packages/host/app/components/operator-mode/overlays.gts @@ -230,14 +230,32 @@ export default class Overlays extends Component { let canWrite = this.realm.canWrite(cardId); format = canWrite ? format : 'isolated'; if (this.args.viewCard) { - await this.args.viewCard(new URL(cardId), format, { - fieldType, - fieldName, - }); + let target = + typeof cardDefOrId === 'string' ? new URL(cardId) : cardDefOrId; + await this.args.viewCard( + target, + format, + this.buildViewCardOpts(cardDefOrId, fieldType, fieldName), + ); } }, ); + protected buildViewCardOpts( + _cardDefOrId: CardDefOrId, + fieldType?: 'linksTo' | 'contains' | 'containsMany' | 'linksToMany', + fieldName?: string, + ): { + type?: 'card' | 'file'; + fieldType?: 'linksTo' | 'contains' | 'containsMany' | 'linksToMany'; + fieldName?: string; + } { + return { + fieldType, + fieldName, + }; + } + protected zIndexStyle(element: HTMLElement, overlayZIndexStyle?: SafeString) { if (overlayZIndexStyle) { return overlayZIndexStyle; diff --git a/packages/host/app/components/operator-mode/preview-panel/index.gts b/packages/host/app/components/operator-mode/preview-panel/index.gts index 2f2ad9a7d6..3e7bd3026f 100644 --- a/packages/host/app/components/operator-mode/preview-panel/index.gts +++ b/packages/host/app/components/operator-mode/preview-panel/index.gts @@ -24,6 +24,7 @@ import { getMenuItems, identifyCard, isCardInstance, + isFileDefInstance, isResolvedCodeRef, } from '@cardstack/runtime-common'; @@ -94,7 +95,11 @@ export default class PreviewPanel extends Component { private openInInteractMode = () => { if (this.cardId) { - this.operatorModeStateService.openCardInInteractMode(this.cardId); + this.operatorModeStateService.openCardInInteractMode( + this.cardId, + 'isolated', + isFileDefInstance(this.args.card) ? 'file' : 'card', + ); } }; diff --git a/packages/host/app/components/operator-mode/stack-item.gts b/packages/host/app/components/operator-mode/stack-item.gts index e6b783c786..da2abcc918 100644 --- a/packages/host/app/components/operator-mode/stack-item.gts +++ b/packages/host/app/components/operator-mode/stack-item.gts @@ -43,6 +43,7 @@ import { type getCard, type getCards, type getCardCollection, + isFileDefInstance, cardTypeDisplayName, PermissionsContextName, RealmURLContextName, @@ -57,7 +58,10 @@ import { getMenuItems, } from '@cardstack/runtime-common'; -import type { StackItem } from '@cardstack/host/lib/stack-item'; +import { + stackItemTypeToStoreReadType, + type StackItem, +} from '@cardstack/host/lib/stack-item'; import { urlForRealmLookup } from '@cardstack/host/lib/utils'; import type { @@ -178,7 +182,9 @@ export default class OperatorModeStackItem extends Component { } private makeCardResource = () => { - this.cardResource = this.getCard(this, () => this.args.item.id); + this.cardResource = this.getCard(this, () => this.args.item.id, { + type: stackItemTypeToStoreReadType(this.args.item.type), + }); }; private get url() { @@ -654,12 +660,26 @@ export default class OperatorModeStackItem extends Component { this.card[realmURL] && !this.isBuried && !this.isEditing && + !this.isFileCard && this.realm.canWrite(this.card[realmURL].href) ); } private get isEditing() { - return !this.isBuried && this.args.item.format === 'edit'; + return ( + !this.isBuried && !this.isFileCard && this.args.item.format === 'edit' + ); + } + + private get isFileCard() { + return ( + this.args.item.type === 'file' || + (this.card ? isFileDefInstance(this.card) : false) + ); + } + + private get cardFormat() { + return this.isFileCard ? 'isolated' : this.args.item.format; } private get showError() { @@ -831,7 +851,7 @@ export default class OperatorModeStackItem extends Component { ; stackIndex: number; id: string; + type?: StackItemType; closeAfterSaving?: boolean; relationshipContext?: { fieldName?: string; @@ -14,11 +17,45 @@ interface Args { }; } +export type StackItemType = 'card' | 'file'; + +function inferStackItemType(type?: StackItemType): StackItemType { + return type === 'file' ? 'file' : 'card'; +} + +export function stackItemTypeToStoreReadType( + type: StackItemType, +): StoreReadType { + return type === 'file' ? 'file-meta' : 'card'; +} + +export function detectStackItemTypeForTarget( + cardOrURL: unknown, + cardId: string | undefined, + store: Pick, +): StackItemType { + if ( + cardOrURL && + typeof cardOrURL === 'object' && + !(cardOrURL instanceof URL) + ) { + return isFileDefInstance(cardOrURL) ? 'file' : 'card'; + } + if (!cardId) { + return 'card'; + } + let fileMetaInstanceOrError = + store.peek(cardId, { type: 'file-meta' }) ?? + store.peekError(cardId, { type: 'file-meta' }); + return fileMetaInstanceOrError ? 'file' : 'card'; +} + export class StackItem { format: Format; request?: Deferred; stackIndex: number; closeAfterSaving?: boolean; + type: StackItemType; #id: string; relationshipContext?: | { @@ -33,6 +70,7 @@ export class StackItem { request, stackIndex, id, + type, closeAfterSaving, relationshipContext, } = args; @@ -41,6 +79,7 @@ export class StackItem { this.format = format; this.request = request; this.stackIndex = stackIndex; + this.type = inferStackItemType(type); this.closeAfterSaving = closeAfterSaving; this.relationshipContext = relationshipContext; } @@ -57,12 +96,14 @@ export class StackItem { closeAfterSaving, stackIndex, relationshipContext, + type, } = this; return new StackItem({ format, request, closeAfterSaving, id, + type, stackIndex, relationshipContext, ...args, diff --git a/packages/host/app/resources/card-resource.ts b/packages/host/app/resources/card-resource.ts index 814673c404..189304b042 100644 --- a/packages/host/app/resources/card-resource.ts +++ b/packages/host/app/resources/card-resource.ts @@ -9,6 +9,8 @@ import { Resource } from 'ember-modify-based-class-resource'; import { isCardInstance, isFileDefInstance } from '@cardstack/runtime-common'; +import type { StoreReadType } from '@cardstack/runtime-common'; + import type { BaseDef } from 'https://cardstack.com/base/card-api'; import type StoreService from '../services/store'; @@ -16,22 +18,25 @@ import type StoreService from '../services/store'; interface Args { named: { id: string | undefined; + type?: StoreReadType; }; } export class CardResource extends Resource { #id: string | undefined; + #type: StoreReadType | undefined; #hasRegisteredDestructor = false; #hasReference = false; @service declare private store: StoreService; modify(_positional: never[], named: Args['named']) { - let { id } = named; - if (id !== this.#id) { + let { id, type } = named; + if (id !== this.#id || type !== this.#type) { this.dropReferenceIfHeld(); this.#id = id; + this.#type = type; if (this.#id) { - this.store.addReference(this.#id); + this.store.addReference(this.#id, { type: this.#type }); this.#hasReference = true; } } @@ -50,6 +55,28 @@ export class CardResource extends Resource { } } + private get readType(): StoreReadType { + return this.#type ?? 'card'; + } + + private peekForType(type: StoreReadType): unknown { + if (!this.#id) { + return undefined; + } + return type === 'file-meta' + ? (this.store.peek(this.#id, { type: 'file-meta' }) as unknown) + : (this.store.peek(this.#id) as unknown); + } + + private peekErrorForType(type: StoreReadType) { + if (!this.#id) { + return undefined; + } + return type === 'file-meta' + ? this.store.peekError(this.#id, { type: 'file-meta' }) + : this.store.peekError(this.#id); + } + // Note that this will return a stale instance when the server state for this // id becomes an error. use this.cardError to see the live server state for // this instance. @@ -57,7 +84,7 @@ export class CardResource extends Resource { if (!this.#id) { return undefined; } - let maybeCard = this.store.peek(this.#id) as unknown; + let maybeCard = this.peekForType(this.readType); return isCardInstance(maybeCard) || isFileDefInstance(maybeCard) ? (maybeCard as BaseDef) : undefined; @@ -67,8 +94,7 @@ export class CardResource extends Resource { if (!this.#id) { return undefined; } - let maybeError = this.store.peekError(this.#id); - return maybeError && !isCardInstance(maybeError) ? maybeError : undefined; + return this.peekErrorForType(this.readType); } get id() { @@ -79,7 +105,8 @@ export class CardResource extends Resource { if (!this.#id) { return false; } - return Boolean(this.store.peek(this.#id)); + let maybeInstanceOrError = this.peekForType(this.readType); + return Boolean(maybeInstanceOrError); } get autoSaveState() { @@ -100,10 +127,15 @@ export class CardResource extends Resource { // ``` // If you need to use `getCard()` in something that is not a Component, then // let's talk. -export function getCard(parent: object, id: () => string | undefined) { +export function getCard( + parent: object, + id: () => string | undefined, + opts?: { type?: StoreReadType }, +) { return CardResource.from(parent, () => ({ named: { id: id(), + type: opts?.type, }, })); } diff --git a/packages/host/app/services/operator-mode-state-service.ts b/packages/host/app/services/operator-mode-state-service.ts index d92c8a2c82..a93a24df25 100644 --- a/packages/host/app/services/operator-mode-state-service.ts +++ b/packages/host/app/services/operator-mode-state-service.ts @@ -27,7 +27,7 @@ import { import type { Submode } from '@cardstack/host/components/submode-switcher'; import { Submodes } from '@cardstack/host/components/submode-switcher'; -import { StackItem } from '@cardstack/host/lib/stack-item'; +import { StackItem, type StackItemType } from '@cardstack/host/lib/stack-item'; import { file, @@ -92,6 +92,7 @@ export interface OperatorModeState { interface CardItem { id: string; format: 'isolated' | 'edit' | 'head'; + type?: StackItemType; } export type FileView = 'inspector' | 'browser'; @@ -413,6 +414,9 @@ export default class OperatorModeStateService extends Service { editCardOnStack(stackIndex: number, card: CardDef): void { let item = this.findCardInStack(card, stackIndex); + if (item.type === 'file') { + return; + } this.replaceItemInStack( item, item.clone({ @@ -875,11 +879,14 @@ export default class OperatorModeStateService extends Service { throw new Error(`Unknown format for card on stack ${item.format}`); } if (item.id) { - let instance = this.store.peek(item.id); + let instance = + this.store.peek(item.id) ?? + this.store.peek(item.id, { type: 'file-meta' }); if (!isLocalId(item.id) || instance?.id) { serializedStack.push({ id: instance?.id ?? item.id, format: item.format, + type: item.type === 'card' ? undefined : item.type, }); } } @@ -903,12 +910,14 @@ export default class OperatorModeStateService extends Service { fieldName?: string; fieldType?: 'linksTo' | 'linksToMany'; }, + type?: StackItemType, ) { let stackItem = new StackItem({ id, stackIndex, format, relationshipContext, + type, }); return stackItem; } @@ -961,6 +970,7 @@ export default class OperatorModeStateService extends Service { id: item.id, format, stackIndex, + type: item.type, }), ); } @@ -1129,7 +1139,11 @@ export default class OperatorModeStateService extends Service { })); }); - openCardInInteractMode(id: string, format: Format = 'isolated') { + openCardInInteractMode( + id: string, + format: Format = 'isolated', + type: StackItemType = 'card', + ) { this.clearStacks(); // Determine realm URL. If id is a localId, look up the instance in the store to read its realm. let realmHref: string | undefined; @@ -1152,11 +1166,13 @@ export default class OperatorModeStateService extends Service { id: `${realmHref}index`, stackIndex: 0, format: 'isolated', + type: 'card', }); let newItem = new StackItem({ id, // keep provided id (may be localId) so later replacement on save works stackIndex: 0, format, + type, }); this.addItemToStack(indexItem); this.addItemToStack(newItem); @@ -1183,6 +1199,7 @@ export default class OperatorModeStateService extends Service { id, format: 'isolated', stackIndex: 0, + type: 'card', }); this.clearStacks(); this.addItemToStack(stackItem); diff --git a/packages/host/app/services/store.ts b/packages/host/app/services/store.ts index 161bae71bd..3e33884dbe 100644 --- a/packages/host/app/services/store.ts +++ b/packages/host/app/services/store.ts @@ -232,10 +232,11 @@ export default class StoreService extends Service implements StoreInterface { } } - addReference(id: string | undefined) { + addReference(id: string | undefined, opts?: { type?: StoreReadType }) { if (!id) { return; } + let readType: StoreReadType = opts?.type ?? 'card'; // synchronously update the reference count so we don't run into race // conditions requiring a mutex let currentReferenceCount = this.referenceCount.get(id) ?? 0; @@ -259,7 +260,7 @@ export default class StoreService extends Service implements StoreInterface { this.subscribeToRealm(new URL(id)); // intentionally not awaiting this. we keep track of the promise in // this.newReferencePromises - this.wireUpNewReference(id); + this.wireUpNewReference(id, readType); } } @@ -859,12 +860,26 @@ export default class StoreService extends Service implements StoreInterface { deferred.fulfill(); } - private async wireUpNewReference(url: string) { + private async wireUpNewReference( + url: string, + readType: StoreReadType = 'card', + ) { let deferred = new Deferred(); await this.withTestWaiters(async () => { this.newReferencePromises.push(deferred.promise); try { await this.ready; + if (readType === 'file-meta') { + let instanceOrError = await this.getFileMetaInstance({ + idOrDoc: url, + }); + this.setIdentityContext( + instanceOrError as FileDef | CardErrorJSONAPI, + 'file-meta', + ); + deferred.fulfill(); + return; + } // Check file-meta map as well as card map — file-meta instances // are loaded into their own map by store.get(id, { type: 'file-meta' }) let fileMetaInstance = @@ -926,33 +941,35 @@ export default class StoreService extends Service implements StoreInterface { private unsubscribeFromInstance(id: string) { let instance = this.store.getCard(id); - if (instance) { - if (this.cardApiCache && instance) { - this.cardApiCache?.unsubscribeFromChanges( - instance, - this.onInstanceUpdated, - ); + if (instance && this.cardApiCache) { + this.cardApiCache.unsubscribeFromChanges( + instance, + this.onInstanceUpdated, + ); + } - // if there are no more subscribers to this realm then unsubscribe from realm - let realm = instance[this.cardApiCache.realmURL]; - if (!realm) { - return; - } + // if there are no more subscribers to this realm then unsubscribe from realm + let realmHref = !isLocalId(id) + ? [...this.subscriptions.keys()].find((realmURL) => + id.startsWith(realmURL), + ) + : undefined; + if (!realmHref) { + return; + } - let subscription = this.subscriptions.get(realm.href); - if ( - subscription && - ![...this.referenceCount.entries()].find( - ([id, count]) => - id.startsWith('http') && - count > 0 && - this.realm.realmOfURL(new URL(id))?.href === realm!.href, - ) - ) { - subscription.unsubscribe(); - this.subscriptions.delete(realm.href); - } - } + let subscription = this.subscriptions.get(realmHref); + if ( + subscription && + ![...this.referenceCount.entries()].find( + ([referenceId, count]) => + !isLocalId(referenceId) && + count > 0 && + referenceId.startsWith(realmHref), + ) + ) { + subscription.unsubscribe(); + this.subscriptions.delete(realmHref); } } diff --git a/packages/host/tests/acceptance/file-chooser-test.gts b/packages/host/tests/acceptance/file-chooser-test.gts index b4422e0e5b..1922b28dcf 100644 --- a/packages/host/tests/acceptance/file-chooser-test.gts +++ b/packages/host/tests/acceptance/file-chooser-test.gts @@ -47,6 +47,7 @@ module('Acceptance | file chooser tests', function (hooks) { { id: fileId, format: 'isolated', + type: 'file', }, ], ], diff --git a/packages/host/tests/integration/components/operator-mode-ui-test.gts b/packages/host/tests/integration/components/operator-mode-ui-test.gts index 732f4abe4f..b4f923548a 100644 --- a/packages/host/tests/integration/components/operator-mode-ui-test.gts +++ b/packages/host/tests/integration/components/operator-mode-ui-test.gts @@ -86,6 +86,91 @@ module('Integration | operator-mode | ui', function (hooks) { .includesText('Author'); }); + test(`click on "links to" the embedded file will open it on the stack`, async function (assert) { + let linkedFileId = `${testRealmURL}FileLinkCard/notes.txt`; + + await ctx.testRealm.write( + 'file-link-card.gts', + ` + import { CardDef, Component, field, contains, linksTo, StringField } from 'https://cardstack.com/base/card-api'; + import { FileDef } from 'https://cardstack.com/base/file-api'; + + export class FileLinkCard extends CardDef { + static displayName = 'File Link Card'; + @field title = contains(StringField); + @field attachment = linksTo(FileDef); + + static isolated = class Isolated extends Component { + + }; + } + `, + ); + + await ctx.testRealm.write( + 'FileLinkCard/notes.txt', + 'Hello from a file link', + ); + await ctx.testRealm.write( + 'FileLinkCard/with-file.json', + JSON.stringify({ + data: { + type: 'card', + attributes: { + title: 'Linked file example', + }, + relationships: { + attachment: { + links: { + self: './notes.txt', + }, + data: { + type: 'file-meta', + id: './notes.txt', + }, + }, + }, + meta: { + adoptsFrom: { + module: '../file-link-card', + name: 'FileLinkCard', + }, + }, + }, + }), + ); + + ctx.setCardInOperatorModeState(`${testRealmURL}FileLinkCard/with-file`); + await renderComponent( + class TestDriver extends GlimmerComponent { + + }, + ); + + await waitFor('[data-test-file-link-attachment] [data-test-card]'); + await click('[data-test-file-link-attachment] [data-test-card]'); + await waitFor('[data-test-stack-card-index="1"]'); + assert.dom('[data-test-stack-card-index]').exists({ count: 2 }); + assert + .dom(`[data-test-stack-card="${linkedFileId}"]`) + .exists('linked file opens as a second stack card'); + assert.strictEqual( + ctx.operatorModeStateService.state?.stacks?.[0]?.[1]?.id, + linkedFileId, + 'operator mode state targets the linked file', + ); + assert.strictEqual( + ctx.operatorModeStateService.state?.stacks?.[0]?.[1]?.type, + 'file', + 'stack item type is file', + ); + }); + test(`toggles mode switcher`, async function (assert) { ctx.setCardInOperatorModeState(`${testRealmURL}BlogPost/1`); await renderComponent( diff --git a/packages/host/tests/integration/store-test.gts b/packages/host/tests/integration/store-test.gts index 222cec5131..6db00fe5d4 100644 --- a/packages/host/tests/integration/store-test.gts +++ b/packages/host/tests/integration/store-test.gts @@ -416,6 +416,32 @@ module('Integration | Store', function (hooks) { ); }); + test('realm subscription is removed when file-meta reference count drops to zero', async function (assert) { + await testRealm.write('hero.png', 'mock hero image'); + let fileUrl = `${testRealmURL}hero.png`; + let subscriptions = (storeService as any).subscriptions as Map< + string, + { unsubscribe: () => void } + >; + + assert.false( + subscriptions.has(testRealmURL), + 'realm is not subscribed before adding file-meta reference', + ); + + storeService.addReference(fileUrl, { type: 'file-meta' }); + assert.true( + subscriptions.has(testRealmURL), + 'realm subscription is created for file-meta reference', + ); + + storeService.dropReference(fileUrl); + assert.false( + subscriptions.has(testRealmURL), + 'realm subscription is removed when file-meta reference reaches zero', + ); + }); + test('add stores FileDef dependencies', async function (assert) { class FileCard extends CardDef { @field attachment = linksTo(FileDef); @@ -1843,6 +1869,78 @@ module('Integration | Store', function (hooks) { ); }); + test('reference count is balanced when used with CardResource for file-meta that is destroyed', async function (assert) { + class Driver { + @tracked showComponent = false; + @tracked id: string | undefined; + } + + let driver = new Driver(); + let firstFile = `${testRealmURL}notes.txt`; + let secondFile = `${testRealmURL}README.txt`; + await testRealm.write('notes.txt', 'notes'); + await testRealm.write('README.txt', 'readme'); + + class ResourceConsumer extends GlimmerComponent { + resource = getCard(this, () => driver.id, { type: 'file-meta' }); + + } + + await renderComponent( + class TestDriver extends GlimmerComponent { + + }, + ); + + driver.showComponent = true; + driver.id = firstFile; + await waitFor(`[data-test-rendered-file="${firstFile}"]`); + assert.strictEqual( + storeService.getReferenceCount(firstFile), + 1, + `reference count for ${firstFile} is 1`, + ); + assert.strictEqual( + storeService.getReferenceCount(secondFile), + 0, + `reference count for ${secondFile} is 0`, + ); + + driver.id = secondFile; + await waitFor(`[data-test-rendered-file="${secondFile}"]`); + assert.strictEqual( + storeService.getReferenceCount(firstFile), + 0, + `reference count for ${firstFile} is 0`, + ); + assert.strictEqual( + storeService.getReferenceCount(secondFile), + 1, + `reference count for ${secondFile} is 1`, + ); + + driver.showComponent = false; + await waitFor(`[data-test-rendered-file]`, { count: 0 }); + assert.strictEqual( + storeService.getReferenceCount(firstFile), + 0, + `reference count for ${firstFile} is 0`, + ); + assert.strictEqual( + storeService.getReferenceCount(secondFile), + 0, + `reference count for ${secondFile} is 0`, + ); + }); + test('reference count is balanced during auto saving', async function (assert) { let hassan = `${testRealmURL}Person/hassan`; diff --git a/packages/runtime-common/index.ts b/packages/runtime-common/index.ts index ccf9faa6ee..386fc1b41f 100644 --- a/packages/runtime-common/index.ts +++ b/packages/runtime-common/index.ts @@ -437,9 +437,10 @@ export type AutoSaveState = { lastSaveError: CardErrorJSONAPI | Error | undefined; lastSavedErrorMsg: string | undefined; }; -export type getCard = ( +export type getCard = ( parent: object, id: () => string | undefined, + opts?: { type?: StoreReadType }, ) => // This is a duck type of the CardResource { id: string | undefined;