diff --git a/packages/boxel-ui/addon/raw-icons/icon-plus-thin.svg b/packages/boxel-ui/addon/raw-icons/icon-plus-thin.svg new file mode 100644 index 0000000000..6927a0a7c5 --- /dev/null +++ b/packages/boxel-ui/addon/raw-icons/icon-plus-thin.svg @@ -0,0 +1 @@ +<svg xmlns="http://www.w3.org/2000/svg" viewBox="3.5 3.5 17 17"><path d="M19,11H13V5a1,1,0,0,0-2,0v6H5a1,1,0,0,0,0,2h6v6a1,1,0,0,0,2,0V13h6a1,1,0,0,0,0-2Z" fill="var(--icon-color, currentColor)"/></svg> \ No newline at end of file diff --git a/packages/boxel-ui/addon/src/icons.gts b/packages/boxel-ui/addon/src/icons.gts index 42be4880ca..5477b50954 100644 --- a/packages/boxel-ui/addon/src/icons.gts +++ b/packages/boxel-ui/addon/src/icons.gts @@ -40,6 +40,7 @@ import IconPencilCrossedOut from './icons/icon-pencil-crossed-out.gts'; import IconPencilNotCrossedOut from './icons/icon-pencil-not-crossed-out.gts'; import IconPlus from './icons/icon-plus.gts'; import IconPlusCircle from './icons/icon-plus-circle.gts'; +import IconPlusThin from './icons/icon-plus-thin.gts'; import IconSearch from './icons/icon-search.gts'; import IconSearchThick from './icons/icon-search-thick.gts'; import IconTrash from './icons/icon-trash.gts'; @@ -100,6 +101,7 @@ export const ALL_ICON_COMPONENTS = [ IconPencilNotCrossedOut, IconPlus, IconPlusCircle, + IconPlusThin, IconSearch, IconSearchThick, IconTrash, @@ -161,6 +163,7 @@ export { IconPencilNotCrossedOut, IconPlus, IconPlusCircle, + IconPlusThin, IconSearch, IconSearchThick, IconTrash, diff --git a/packages/boxel-ui/addon/src/icons/icon-plus-thin.gts b/packages/boxel-ui/addon/src/icons/icon-plus-thin.gts new file mode 100644 index 0000000000..e11235e221 --- /dev/null +++ b/packages/boxel-ui/addon/src/icons/icon-plus-thin.gts @@ -0,0 +1,19 @@ +// This file is auto-generated by 'pnpm rebuild:icons' +import type { TemplateOnlyComponent } from '@ember/component/template-only'; + +import type { Signature } from './types.ts'; + +const IconComponent: TemplateOnlyComponent<Signature> = <template> + <svg + xmlns='http://www.w3.org/2000/svg' + viewBox='3.5 3.5 17 17' + ...attributes + ><path + fill='var(--icon-color, currentColor)' + d='M19 11h-6V5a1 1 0 0 0-2 0v6H5a1 1 0 0 0 0 2h6v6a1 1 0 0 0 2 0v-6h6a1 1 0 0 0 0-2Z' + /></svg> +</template>; + +// @ts-expect-error this is the only way to set a name on a Template Only Component currently +IconComponent.name = 'IconPlusThin'; +export default IconComponent; diff --git a/packages/host/app/components/operator-mode/code-submode/playground-panel.gts b/packages/host/app/components/operator-mode/code-submode/playground-panel.gts index f9424437d2..be5cdf6687 100644 --- a/packages/host/app/components/operator-mode/code-submode/playground-panel.gts +++ b/packages/host/app/components/operator-mode/code-submode/playground-panel.gts @@ -4,9 +4,10 @@ import { action } from '@ember/object'; import type Owner from '@ember/owner'; import { service } from '@ember/service'; import Component from '@glimmer/component'; +import { tracked } from '@glimmer/tracking'; import Folder from '@cardstack/boxel-icons/folder'; -import { restartableTask, task } from 'ember-concurrency'; +import { task } from 'ember-concurrency'; import perform from 'ember-concurrency/helpers/perform'; import window from 'ember-window-mock'; import { TrackedObject } from 'tracked-built-ins'; @@ -18,13 +19,19 @@ import { CardHeader, } from '@cardstack/boxel-ui/components'; import { eq, or, MenuItem } from '@cardstack/boxel-ui/helpers'; -import { Eye, IconCode, IconLink } from '@cardstack/boxel-ui/icons'; +import { + Eye, + IconCode, + IconLink, + IconPlusThin, +} from '@cardstack/boxel-ui/icons'; import { cardTypeDisplayName, cardTypeIcon, chooseCard, type Query, + type LooseSingleCardDocument, type ResolvedCodeRef, } from '@cardstack/runtime-common'; @@ -33,6 +40,7 @@ import { getCard } from '@cardstack/host/resources/card-resource'; import { getCodeRef, type CardType } from '@cardstack/host/resources/card-type'; import { ModuleContentsResource } from '@cardstack/host/resources/module-contents'; +import type CardService from '@cardstack/host/services/card-service'; import type OperatorModeStateService from '@cardstack/host/services/operator-mode-state-service'; import type RealmService from '@cardstack/host/services/realm'; import type RealmServerService from '@cardstack/host/services/realm-server'; @@ -87,7 +95,7 @@ const SelectedItem: TemplateOnlyComponent<{ Args: { title?: string } }> = </style> </template>; -const BeforeOptions: TemplateOnlyComponent<{ Args: {} }> = <template> +const BeforeOptions: TemplateOnlyComponent = <template> <div class='before-options'> <span class='title'> Recent @@ -109,6 +117,8 @@ const BeforeOptions: TemplateOnlyComponent<{ Args: {} }> = <template> interface AfterOptionsSignature { Args: { chooseCard: () => void; + createNew: () => void; + createNewIsRunning?: boolean; }; } const AfterOptions: TemplateOnlyComponent<AfterOptionsSignature> = <template> @@ -116,6 +126,14 @@ const AfterOptions: TemplateOnlyComponent<AfterOptionsSignature> = <template> <span class='title'> Action </span> + <button class='action' {{on 'click' @createNew}} data-test-create-instance> + {{#if @createNewIsRunning}} + <LoadingIndicator class='action-running' /> + {{else}} + <IconPlusThin width='16px' height='16px' /> + {{/if}} + New card instance + </button> <button class='action' {{on 'click' @chooseCard}} @@ -132,7 +150,6 @@ const AfterOptions: TemplateOnlyComponent<AfterOptionsSignature> = <template> border-top: var(--boxel-border); background-color: var(--boxel-light); padding: var(--boxel-sp-xs); - margin-top: var(--boxel-sp-xxs); gap: var(--boxel-sp-xxs); } .title { @@ -152,6 +169,9 @@ const AfterOptions: TemplateOnlyComponent<AfterOptionsSignature> = <template> .action:hover { background-color: var(--boxel-100); } + .action-running { + --boxel-loading-indicator-size: 16px; + } </style> </template>; @@ -193,6 +213,8 @@ class PlaygroundPanelContent extends Component<PlaygroundContentSignature> { @afterOptionsComponent={{component AfterOptions chooseCard=(perform this.chooseCard) + createNew=(perform this.createNew) + createNewIsRunning=this.createNew.isRunning }} data-test-instance-chooser as |card| @@ -284,9 +306,6 @@ class PlaygroundPanelContent extends Component<PlaygroundContentSignature> { height: var(--boxel-form-control-height); box-shadow: 0 5px 10px 0 rgba(0 0 0 / 40%); } - :deep(.instances-dropdown-content > .ember-power-select-options) { - max-height: 20rem; - } :deep( .boxel-select__dropdown .ember-power-select-option[aria-current='true'] ), @@ -355,10 +374,12 @@ class PlaygroundPanelContent extends Component<PlaygroundContentSignature> { </style> </template> + @service private declare cardService: CardService; @service private declare operatorModeStateService: OperatorModeStateService; @service private declare realm: RealmService; @service private declare realmServer: RealmServerService; @service declare recentFilesService: RecentFilesService; + @tracked newCardJSON: LooseSingleCardDocument | undefined; private playgroundSelections: Record< string, // moduleId { cardId: string; format: Format } @@ -411,7 +432,8 @@ class PlaygroundPanelContent extends Component<PlaygroundContentSignature> { private cardResource = getCard( this, - () => this.playgroundSelections[this.args.moduleId]?.cardId, + () => + this.newCardJSON ?? this.playgroundSelections[this.args.moduleId]?.cardId, { isAutoSave: () => true }, ); @@ -458,6 +480,7 @@ class PlaygroundPanelContent extends Component<PlaygroundContentSignature> { } private persistSelections = (cardId: string, format = this.format) => { + this.newCardJSON = undefined; this.playgroundSelections[this.args.moduleId] = { cardId, format }; window.localStorage.setItem( PlaygroundSelections, @@ -477,7 +500,7 @@ class PlaygroundPanelContent extends Component<PlaygroundContentSignature> { this.persistSelections(this.card.id, format); } - private chooseCard = restartableTask(async () => { + private chooseCard = task(async () => { let chosenCard: CardDef | undefined = await chooseCard({ filter: { type: this.args.codeRef }, }); @@ -488,6 +511,23 @@ class PlaygroundPanelContent extends Component<PlaygroundContentSignature> { } }); + // TODO: convert this to @action once we no longer need to await below + private createNew = task(async () => { + this.newCardJSON = { + data: { + meta: { + adoptsFrom: this.args.codeRef, + realmURL: this.operatorModeStateService.realmURL.href, + }, + }, + }; + await this.cardResource.loaded; // TODO: remove await when card-resource is refactored + if (this.card) { + this.recentFilesService.addRecentFileUrl(`${this.card.id}.json`); + this.persistSelections(this.card.id, 'edit'); // open new instance in playground in edit format + } + }); + private get canEdit() { return ( this.format !== 'edit' && diff --git a/packages/host/tests/acceptance/code-submode/playground-test.gts b/packages/host/tests/acceptance/code-submode/playground-test.gts index 91af62a3f4..7b311a8e9b 100644 --- a/packages/host/tests/acceptance/code-submode/playground-test.gts +++ b/packages/host/tests/acceptance/code-submode/playground-test.gts @@ -587,6 +587,51 @@ export class BlogPost extends CardDef { ]); }); + test<TestContextWithSSE>('can create new instance', async function (assert) { + window.localStorage.removeItem('recent-files'); + await visitOperatorMode({ + submode: 'code', + codePath: `${testRealmURL}blog-post.gts`, + }); + let recentFiles = JSON.parse(window.localStorage.getItem('recent-files')!); + assert.deepEqual(recentFiles[0], [testRealmURL, 'blog-post.gts']); + await click('[data-boxel-selector-item-text="BlogPost"]'); + await click('[data-test-accordion-item="playground"] button'); + assert + .dom('[data-test-instance-chooser] [data-test-selected-item]') + .doesNotExist(); + + await click('[data-test-instance-chooser]'); + await this.expectEvents({ + assert, + realm, + expectedNumberOfEvents: 2, + callback: async () => { + await click('[data-test-create-instance]'); + }, + }); + recentFiles = JSON.parse(window.localStorage.getItem('recent-files')!); + assert.strictEqual(recentFiles.length, 2, 'recent file count is correct'); + let newCardId = `${recentFiles[0][0]}${recentFiles[0][1]}`.replace( + '.json', + '', + ); + assert + .dom('[data-test-instance-chooser] [data-test-selected-item]') + .hasText('Untitled Blog Post', 'created instance is selected'); + assert + .dom( + `[data-test-playground-panel] [data-test-card="${newCardId}"][data-test-card-format="edit"]`, + ) + .exists('new card is rendered in edit format'); + + await click('[data-test-instance-chooser]'); + assert + .dom('[data-option-index]') + .exists({ count: 1 }, 'dropdown instance count is correct'); + assert.dom('[data-option-index]').containsText('Blog Post'); + }); + test<TestContextWithSSE>('playground preview for card with contained fields can live update when module changes', async function (assert) { // change: added "Hello" before rendering title on the template const authorCard = `import { contains, field, CardDef, Component } from "https://cardstack.com/base/card-api";