diff --git a/README.md b/README.md index 2f45eb8e22..1bddceb8d6 100644 --- a/README.md +++ b/README.md @@ -84,8 +84,8 @@ Instead of running `pnpm start:base`, you can alternatively use `pnpm start:all` | :4205 | `/test` realm for matrix client tests (playwright controlled) | 🚫 | 🚫 | | :4210 | Development Worker Manager (spins up 1 worker by default) | βœ… | 🚫 | | :4211 | Test Worker Manager (spins up 1 worker by default) | βœ… | 🚫 | -| :4212 | Test Worker Manager for matrix client tests (playwright controlled - 1 worker) | βœ… | 🚫 | -| :4213 | Test Worker Manager for matrix client tests - base realm server (playwright controlled - 1 worker) | βœ… | 🚫 | +| :4212 | Worker Manager for matrix client tests (playwright controlled - 1 worker) | βœ… | 🚫 | +| :4213 | Worker Manager for matrix client tests - base realm server (playwright controlled - 1 worker) | βœ… | 🚫 | | :5001 | Mail user interface for viewing emails sent to local SMTP | βœ… | 🚫 | | :5435 | Postgres DB | βœ… | 🚫 | | :8008 | Matrix synapse server | βœ… | 🚫 | @@ -223,7 +223,7 @@ There is a ember-freestyle component explorer available to assist with developme 1. `cd packages/boxel-ui/test-app` 2. `pnpm start` -3. Visit http://localhost:4210/ in your browser +3. Visit http://localhost:4220/ in your browser ## Boxel Motion Demo App @@ -290,7 +290,7 @@ To run the `packages/realm-server/` workspace tests start: ### Boxel UI 1. `cd packages/boxel-ui/test-app` -2. `pnpm test` (or `pnpm start` and visit http://localhost:4210/tests to run tests in the browser) +2. `pnpm test` (or `pnpm start` and visit http://localhost:4220/tests to run tests in the browser) ### Boxel Motion diff --git a/packages/base/card-api.gts b/packages/base/card-api.gts index 9ada4e7726..f3cb050ca4 100644 --- a/packages/base/card-api.gts +++ b/packages/base/card-api.gts @@ -1699,7 +1699,10 @@ export class BaseDef { } return Object.fromEntries( Object.entries( - getFields(value, { includeComputeds: true, usedFieldsOnly: true }), + getFields(value, { + includeComputeds: true, + usedLinksToFieldsOnly: true, + }), ).map(([fieldName, field]) => { let rawValue = peekAtField(value, fieldName); if (field?.fieldType === 'linksToMany') { @@ -1967,7 +1970,7 @@ export function subscribeToChanges( changeSubscribers.add(subscriber); let fields = getFields(fieldOrCard, { - usedFieldsOnly: true, + usedLinksToFieldsOnly: true, includeComputeds: false, }); Object.keys(fields).forEach((fieldName) => { @@ -2010,7 +2013,7 @@ export function unsubscribeFromChanges( changeSubscribers.delete(subscriber); let fields = getFields(fieldOrCard, { - usedFieldsOnly: true, + usedLinksToFieldsOnly: true, includeComputeds: false, }); Object.keys(fields).forEach((fieldName) => { @@ -2308,7 +2311,7 @@ function serializeCardResource( let { includeUnrenderedFields: remove, ...fieldOpts } = opts ?? {}; let { id: removedIdField, ...fields } = getFields(model, { ...fieldOpts, - usedFieldsOnly: !opts?.includeUnrenderedFields, + usedLinksToFieldsOnly: !opts?.includeUnrenderedFields, }); let fieldResources = Object.entries(fields) .filter(([_fieldName, field]) => @@ -2830,7 +2833,7 @@ export async function recompute( Object.keys( getFields(model, { includeComputeds: true, - usedFieldsOnly: !opts?.recomputeAllFields, + usedLinksToFieldsOnly: !opts?.recomputeAllFields, }), ), ); @@ -2936,15 +2939,15 @@ export async function getIfReady( export function getFields( card: typeof BaseDef, - opts?: { usedFieldsOnly?: boolean; includeComputeds?: boolean }, + opts?: { usedLinksToFieldsOnly?: boolean; includeComputeds?: boolean }, ): { [fieldName: string]: Field }; export function getFields( card: T, - opts?: { usedFieldsOnly?: boolean; includeComputeds?: boolean }, + opts?: { usedLinksToFieldsOnly?: boolean; includeComputeds?: boolean }, ): { [P in keyof T]?: Field }; export function getFields( cardInstanceOrClass: BaseDef | typeof BaseDef, - opts?: { usedFieldsOnly?: boolean; includeComputeds?: boolean }, + opts?: { usedLinksToFieldsOnly?: boolean; includeComputeds?: boolean }, ): { [fieldName: string]: Field } { let obj: object | null; let usedFields: string[] = []; @@ -2979,9 +2982,10 @@ export function getFields( !['contains', 'containsMany'].includes(maybeField.fieldType) ) { if ( - opts?.usedFieldsOnly && + opts?.usedLinksToFieldsOnly && !usedFields.includes(maybeFieldName) && - !maybeField.isUsed + !maybeField.isUsed && + !['contains', 'containsMany'].includes(maybeField.fieldType) ) { return []; } diff --git a/packages/base/cards-grid.gts b/packages/base/cards-grid.gts index c3ba4e64b8..aab70f963a 100644 --- a/packages/base/cards-grid.gts +++ b/packages/base/cards-grid.gts @@ -384,13 +384,19 @@ class Isolated extends Component { on: catalogEntryRef, eq: { ref: activeFilterRef }, }, + sort: [ + { + by: 'createdAt', + direction: 'desc', + }, + ], }; } let card = await chooseCard( { filter: { on: catalogEntryRef, - every: [{ eq: { isField: false } }], + every: [{ eq: { specType: 'card' } }], }, }, { preselectedCardTypeQuery }, diff --git a/packages/base/catalog-entry.gts b/packages/base/catalog-entry.gts index 0c67b6bfbf..a31c986ddc 100644 --- a/packages/base/catalog-entry.gts +++ b/packages/base/catalog-entry.gts @@ -3,78 +3,79 @@ import { field, Component, CardDef, - FieldDef, relativeTo, - realmInfo, + linksToMany, + FieldDef, + containsMany, + type CardorFieldTypeIcon, } from './card-api'; import StringField from './string'; import BooleanField from './boolean'; import CodeRef from './code-ref'; +import MarkdownField from './markdown'; +import { restartableTask } from 'ember-concurrency'; +import { LoadingIndicator } from '@cardstack/boxel-ui/components'; +import { loadCard, Loader } from '@cardstack/runtime-common'; +import { eq } from '@cardstack/boxel-ui/helpers'; -import { FieldContainer } from '@cardstack/boxel-ui/components'; import GlimmerComponent from '@glimmer/component'; import BoxModel from '@cardstack/boxel-icons/box-model'; +import BookOpenText from '@cardstack/boxel-icons/book-open-text'; +import LayersSubtract from '@cardstack/boxel-icons/layers-subtract'; +import GitBranch from '@cardstack/boxel-icons/git-branch'; +import { DiagonalArrowLeftUp } from '@cardstack/boxel-ui/icons'; +import { Pill } from '@cardstack/boxel-ui/components'; +import StackIcon from '@cardstack/boxel-icons/stack'; +import AppsIcon from '@cardstack/boxel-icons/apps'; +import LayoutList from '@cardstack/boxel-icons/layout-list'; +import Brain from '@cardstack/boxel-icons/brain'; + +export type BoxelSpecType = 'card' | 'field' | 'app' | 'skill'; + +export class SpecType extends StringField { + static displayName = 'Spec Type'; +} export class CatalogEntry extends CardDef { static displayName = 'Catalog Entry'; static icon = BoxModel; - @field title = contains(StringField); - @field description = contains(StringField); + @field name = contains(StringField); + @field readMe = contains(MarkdownField); + @field ref = contains(CodeRef); + @field specType = contains(SpecType); - // If it's not a field, then it's a card - @field isField = contains(BooleanField); + @field isField = contains(BooleanField, { + computeVia: function (this: CatalogEntry) { + return this.specType === 'field'; + }, + }); + @field isCard = contains(BooleanField, { + computeVia: function (this: CatalogEntry) { + return this.specType === 'card'; + }, + }); @field moduleHref = contains(StringField, { computeVia: function (this: CatalogEntry) { return new URL(this.ref.module, this[relativeTo]).href; }, }); - @field demo = contains(FieldDef); - @field realmName = contains(StringField, { + @field linkedExamples = linksToMany(CardDef); + @field containedExamples = containsMany(FieldDef, { isUsed: true }); + @field title = contains(StringField, { computeVia: function (this: CatalogEntry) { - return this[realmInfo]?.name; + if (this.name) { + return this.name; + } + return this.ref.name === 'default' ? undefined : this.ref.name; }, }); - @field thumbnailURL = contains(StringField, { computeVia: () => null }); // remove this if we want card type entries to have images - - get showDemo() { - return !this.isField; - } - - // An explicit edit template is provided since computed isPrimitive bool - // field (which renders in the embedded format) looks a little wonky - // right now in the edit view. - static edit = class Edit extends Component { - - }; static fitted = class Fitted extends Component { } diff --git a/packages/boxel-ui/addon/src/components/color-palette/usage.gts b/packages/boxel-ui/addon/src/components/color-palette/usage.gts index 90701f7ffd..8a940c1506 100644 --- a/packages/boxel-ui/addon/src/components/color-palette/usage.gts +++ b/packages/boxel-ui/addon/src/components/color-palette/usage.gts @@ -6,9 +6,9 @@ import FreestyleUsage from 'ember-freestyle/components/freestyle/usage'; import ColorPalette from './index.gts'; export default class ColorPaletteUsage extends Component { - @tracked color = '#000000'; + @tracked color: string | null = null; - private handleColorChange = (newColor: string) => { + private handleColorChange = (newColor: string | null) => { this.color = newColor; }; @@ -31,7 +31,6 @@ export default class ColorPaletteUsage extends Component { @description='Currently selected color in hex format.' @value={{this.color}} @onInput={{fn (mut this.color)}} - @defaultValue='#000000' /> void; + onChange: (color: string | null) => void; showHexString?: boolean; }; Element: HTMLDivElement; @@ -21,14 +21,14 @@ export default class ColorPicker extends Component {
{{#if @showHexString}} - {{@color}} + {{@color}} {{/if}}
@@ -44,10 +44,8 @@ export default class ColorPicker extends Component { width: var(--swatch-size); height: var(--swatch-size); padding: 0; - border: none; cursor: pointer; - background: transparent; - border: 1px solid var(--boxel-200); + border: var(--boxel-border); border-radius: 50%; } @@ -65,8 +63,6 @@ export default class ColorPicker extends Component { } .hex-value { - font: var(--boxel-font); - color: var(--boxel-dark); text-transform: uppercase; } diff --git a/packages/boxel-ui/addon/src/components/color-picker/usage.gts b/packages/boxel-ui/addon/src/components/color-picker/usage.gts index 6121ae85d7..927da56241 100644 --- a/packages/boxel-ui/addon/src/components/color-picker/usage.gts +++ b/packages/boxel-ui/addon/src/components/color-picker/usage.gts @@ -6,11 +6,11 @@ import FreestyleUsage from 'ember-freestyle/components/freestyle/usage'; import ColorPicker from './index.gts'; export default class ColorPickerUsage extends Component { - @tracked color = '#ff0000'; + @tracked color: string | null = null; @tracked disabled = false; @tracked showHexString = true; - private onChange = (newColor: string) => { + private onChange = (newColor: string | null) => { this.color = newColor; }; @@ -35,7 +35,6 @@ export default class ColorPickerUsage extends Component { @description='Hex color value.' @value={{this.color}} @onInput={{fn (mut this.color)}} - @defaultValue='#ff0000' /> { padding: 0; background: var(--boxel-icon-button-background, none); border: 1px solid transparent; + color: var(--boxel-icon-button-color, currentColor); z-index: 0; overflow: hidden; } diff --git a/packages/boxel-ui/addon/src/components/input/index.gts b/packages/boxel-ui/addon/src/components/input/index.gts index 94c30ee414..21d004059d 100644 --- a/packages/boxel-ui/addon/src/components/input/index.gts +++ b/packages/boxel-ui/addon/src/components/input/index.gts @@ -7,7 +7,7 @@ import cn from '../../helpers/cn.ts'; import element from '../../helpers/element.ts'; import optional from '../../helpers/optional.ts'; import pick from '../../helpers/pick.ts'; -import { and, eq, not } from '../../helpers/truth-helpers.ts'; +import { and, bool, eq, not } from '../../helpers/truth-helpers.ts'; import FailureBordered from '../../icons/failure-bordered.gts'; import IconSearch from '../../icons/icon-search.gts'; import LoadingIndicator from '../../icons/loading-indicator.gts'; @@ -62,6 +62,7 @@ export interface Signature { id?: string; max?: string | number; onBlur?: (ev: Event) => void; + onChange?: (ev: Event) => void; onFocus?: (ev: Event) => void; onInput?: (val: string) => void; onKeyPress?: (ev: KeyboardEvent) => void; @@ -155,6 +156,7 @@ export default class BoxelInput extends Component { id={{this.id}} type={{this.type}} value={{@value}} + checked={{if (and (eq @type 'checkbox') (bool @value)) @value}} placeholder={{@placeholder}} max={{@max}} required={{@required}} @@ -177,6 +179,7 @@ export default class BoxelInput extends Component { {{on 'blur' (optional @onBlur)}} {{on 'keypress' (optional @onKeyPress)}} {{on 'focus' (optional @onFocus)}} + {{on 'change' (optional @onChange)}} ...attributes /> {{#if this.isSearch}} diff --git a/packages/boxel-ui/addon/src/components/select/trigger.gts b/packages/boxel-ui/addon/src/components/select/trigger.gts index 71682ac81b..9dfff0ec50 100644 --- a/packages/boxel-ui/addon/src/components/select/trigger.gts +++ b/packages/boxel-ui/addon/src/components/select/trigger.gts @@ -2,6 +2,7 @@ import Component from '@glimmer/component'; import type { Select } from 'ember-power-select/components/power-select'; import { cn } from '../../helpers.ts'; +import { not } from '../../helpers/truth-helpers.ts'; import CaretDown from '../../icons/caret-down.gts'; export interface TriggerSignature { @@ -40,9 +41,11 @@ export class BoxelTriggerWrapper extends Component { {{yield @select.selected @select}} {{/if}} + {{#if (not @select.disabled)}} - {{#if (has-block 'icon')}} - {{yield to='icon'}} + {{#if (has-block 'icon')}} + {{yield to='icon'}} + {{/if}} {{/if}}
- {{! template-lint-disable no-inline-styles }}
<@fields.shortName />
@@ -75,8 +79,7 @@ export class BlogCategory extends CardDef { @field longName = contains(StringField); @field shortName = contains(StringField); @field slug = contains(StringField); - @field backgroundColor = contains(ColorField); - @field textColor = contains(ColorField); + @field pillColor = contains(ColorField); @field description = contains(StringField); @field blog = linksTo(BlogAppCard, { isUsed: true }); @@ -91,18 +94,16 @@ export class BlogCategory extends CardDef { border-radius: 50%; display: inline-block; margin-right: var(--boxel-sp-xxs); + background-color: var(--category-swatch); } .category-atom { display: inline-flex; align-items: center; + font-weight: 600; }
- {{! template-lint-disable no-inline-styles }} -
+
<@fields.longName />
@@ -116,14 +117,16 @@ export class BlogCategory extends CardDef { padding: var(--boxel-sp-xs); } .category-name { - padding: var(--boxel-sp-xxs); - color: white; + padding: var(--boxel-sp-xxxs) var(--boxel-sp-xs); border-radius: var(--boxel-border-radius-sm); - font-weight: bold; + font: 600 var(--boxel-font-xs); + letter-spacing: var(--boxel-lsp-sm); display: inline-block; } .category-label { - color: var(--boxel-400); + color: var(--boxel-450); + font: 500 var(--boxel-font-xs); + letter-spacing: var(--boxel-lsp-sm); margin-top: var(--boxel-sp-sm); } .category-full-name { @@ -136,12 +139,13 @@ export class BlogCategory extends CardDef { } .category-description { margin-top: var(--boxel-sp-sm); - color: var(--boxel-400); display: -webkit-box; -webkit-box-orient: vertical; -webkit-line-clamp: 3; overflow: hidden; text-overflow: ellipsis; + font: 400 var(--boxel-font-sm); + letter-spacing: var(--boxel-lsp); } @container fitted-card ((aspect-ratio <= 0.92) and (height <= 182px)) { .category-description { @@ -201,7 +205,6 @@ export class BlogCategory extends CardDef { }
- {{! template-lint-disable no-inline-styles }}
<@fields.shortName />
diff --git a/packages/experiments-realm/blog-post.gts b/packages/experiments-realm/blog-post.gts index d5a424f55e..f9faf24ac9 100644 --- a/packages/experiments-realm/blog-post.gts +++ b/packages/experiments-realm/blog-post.gts @@ -31,10 +31,9 @@ class EmbeddedTemplate extends Component { {{#if @model.categories.length}}
{{#each @model.categories as |category|}} -
{{category.shortName}}
+
+ {{category.shortName}} +
{{/each}}
{{/if}} @@ -111,6 +110,9 @@ class EmbeddedTemplate extends Component { .categories { margin-top: var(--boxel-sp); + display: flex; + flex-wrap: wrap; + gap: var(--boxel-sp-xxxs); } .category { @@ -130,10 +132,9 @@ class FittedTemplate extends Component {
{{#each @model.categories as |category|}} -
{{category.shortName}}
+
+ {{category.shortName}} +
{{/each}}
@@ -217,16 +218,16 @@ class FittedTemplate extends Component { height: 20px; margin-left: 7px; display: none; + overflow: hidden; } .category { - font-size: 0.6rem; - height: 18px; + height: 20px; padding: 3px 4px; border-radius: var(--boxel-border-radius-sm); display: inline-block; - font-family: var(--boxel-font-family); - font-weight: 600; + font: 500 var(--boxel-font-xs); + letter-spacing: var(--boxel-lsp-sm); margin-right: var(--boxel-sp-xxxs); overflow: hidden; text-overflow: ellipsis; @@ -713,10 +714,9 @@ export class BlogPost extends CardDef { {{#if @model.categories.length}}
{{#each @model.categories as |category|}} -
{{category.shortName}}
+
+ {{category.shortName}} +
{{/each}}
{{/if}} @@ -841,13 +841,19 @@ export class BlogPost extends CardDef { } .categories { margin-top: var(--boxel-sp); + display: flex; + flex-wrap: wrap; + gap: var(--boxel-sp-xxs); + } + .featured-image + .categories { + margin-top: var(--boxel-sp-xl); } .category { display: inline-block; padding: 3px var(--boxel-sp-xxxs); border-radius: var(--boxel-border-radius-sm); - font: 500 var(--boxel-font-sm); - letter-spacing: var(--boxel-lsp-xs); + font: 500 var(--boxel-font-xs); + letter-spacing: var(--boxel-lsp-sm); } diff --git a/packages/experiments-realm/color.gts b/packages/experiments-realm/color.gts index bff8ca296c..54c5618d04 100644 --- a/packages/experiments-realm/color.gts +++ b/packages/experiments-realm/color.gts @@ -1,38 +1,22 @@ -import { - Component, - FieldDef, - StringField, - contains, - field, -} from 'https://cardstack.com/base/card-api'; +import { Component, StringField } from 'https://cardstack.com/base/card-api'; import { ColorPalette } from '@cardstack/boxel-ui/components'; import { ColorPicker } from '@cardstack/boxel-ui/components'; class View extends Component { } class EditView extends Component { - setColor = (color: string) => { - this.args.model.hexValue = color; - }; - } -export class ColorField extends FieldDef { +export class ColorField extends StringField { static displayName = 'Color'; - @field hexValue = contains(StringField); - static isolated = View; static embedded = View; static atom = View; static fitted = View; diff --git a/packages/experiments-realm/components/base-task-planner.gts b/packages/experiments-realm/components/base-task-planner.gts index 72de3ce974..f276212800 100644 --- a/packages/experiments-realm/components/base-task-planner.gts +++ b/packages/experiments-realm/components/base-task-planner.gts @@ -1,6 +1,5 @@ import { - Component, - realmURL, + CardContext, CardDef, BaseDef, } from 'https://cardstack.com/base/card-api'; @@ -150,12 +149,21 @@ export interface TaskCollection { columns: TaskColumn[]; } -export class BaseTaskPlannerIsolated< - T extends typeof CardDef = typeof CardDef, -> extends Component { +interface TaskPlannerArgs { + Args: { + config: TaskPlannerConfig; + realmURL: URL | undefined; + parentId: string | undefined; + context: CardContext | undefined; + emptyStateMessage?: string; + viewCard: () => void; + }; + Element: HTMLElement; +} + +export class TaskPlanner extends GlimmerComponent { @tracked loadingColumnKey: string | undefined; @tracked selectedFilter: FilterType | undefined; - config: TaskPlannerConfig; selectedItems = new TrackedMap(); filters: Record< FilterType, @@ -170,17 +178,17 @@ export class BaseTaskPlannerIsolated< } >; cards: { - instances: CardDef[]; + instances: CardDef[]; // possible can be generic type isLoading?: boolean; }; assigneeQuery: { - instances: any[]; + // should be assignee + instances: CardDef[]; isLoading?: boolean; }; - constructor(owner: Owner, args: any, config: TaskPlannerConfig) { + constructor(owner: Owner, args: any) { super(owner, args); - this.config = config; this.selectedItems = new TrackedMap(); // Initialize filters @@ -189,16 +197,16 @@ export class BaseTaskPlannerIsolated< searchKey: 'label', label: 'Status', codeRef: { - module: config.taskSource.module, + module: this.args.config.taskSource.module, name: 'Status', }, - options: () => config.status.values, + options: () => this.args.config.status.values, }, assignee: { searchKey: 'name', label: 'Assignee', codeRef: { - module: config.taskSource.module, + module: this.args.config.taskSource.module, name: 'Assignee', }, options: () => this.assigneeCards, @@ -212,7 +220,7 @@ export class BaseTaskPlannerIsolated< this.assigneeQuery = getCards( { filter: { - type: this.config.filters.assignee.codeRef, + type: this.args.config.filters.assignee.codeRef, }, }, this.realmHrefs, @@ -220,20 +228,16 @@ export class BaseTaskPlannerIsolated< ); } - get parentId(): string | undefined { - return undefined; - } - get emptyStateMessage(): string { - return 'Select a parent to continue'; + return this.args.emptyStateMessage ?? 'Select a parent to continue'; } get getTaskQuery(): Query { - return this.config.taskSource.getQuery(); + return this.args.config.taskSource.getQuery(); } get filterTypes() { - return Object.keys(this.config.filters) as FilterType[]; + return Object.keys(this.args.config.filters) as FilterType[]; } get cardInstances() { @@ -247,16 +251,15 @@ export class BaseTaskPlannerIsolated< return this.assigneeQuery?.instances ?? []; } - get realmURL(): URL { - return this.args.model[realmURL]!; - } - get realmHref() { - return this.realmURL.href; + return this.args.realmURL?.href; } get realmHrefs() { - return [this.realmURL?.href]; + if (!this.args.realmURL) { + return []; + } + return [this.args.realmURL.href]; } @action async onMoveCardMutation( @@ -265,7 +268,7 @@ export class BaseTaskPlannerIsolated< sourceColumnAfterDrag: DndColumn, targetColumnAfterDrag: DndColumn, ) { - await this.config.cardOperations.onMoveCard({ + await this.args.config.cardOperations.onMoveCard({ draggedCard, targetCard, sourceColumn: sourceColumnAfterDrag, @@ -304,7 +307,7 @@ export class BaseTaskPlannerIsolated< if (this.selectedFilter === undefined) { return undefined; } - return this.config.filters[this.selectedFilter]; + return this.args.config.filters[this.selectedFilter]; } get selectedItemsForFilter() { @@ -372,24 +375,17 @@ export class BaseTaskPlannerIsolated< } } - @action viewCard() { - if (!this.args.model.id) { - throw new Error('No card id'); - } - this.args.context?.actions?.viewCard?.(new URL(this.args.model.id), 'edit'); - } - taskCollection = getKanbanResource( this, () => this.cardInstances, - () => this.config.status.values.map((status) => status.label) ?? [], - () => this.config.cardOperations.hasColumnKey, + () => this.args.config.status.values.map((status) => status.label) ?? [], + () => this.args.config.cardOperations.hasColumnKey, ); @action async createNewTask(statusLabel: string) { this.loadingColumnKey = statusLabel; try { - await this.config.cardOperations.onCreateTask(statusLabel); + await this.args.config.cardOperations.onCreateTask(statusLabel); } finally { this.loadingColumnKey = undefined; } @@ -401,13 +397,11 @@ export class BaseTaskPlannerIsolated< } diff --git a/packages/host/app/components/matrix/room.gts b/packages/host/app/components/matrix/room.gts index 0c34cd8f9d..085adcff58 100644 --- a/packages/host/app/components/matrix/room.gts +++ b/packages/host/app/components/matrix/room.gts @@ -1,3 +1,4 @@ +import { registerDestructor } from '@ember/destroyable'; import { fn } from '@ember/helper'; import { on } from '@ember/modifier'; import { action } from '@ember/object'; @@ -235,6 +236,9 @@ export default class Room extends Component { constructor(owner: Owner, args: Signature['Args']) { super(owner, args); this.doMatrixEventFlush.perform(); + registerDestructor(this, () => { + this.scrollState().messageVisibilityObserver.disconnect(); + }); } private scrollState() { diff --git a/packages/host/app/components/operator-mode/code-editor.gts b/packages/host/app/components/operator-mode/code-editor.gts index 28d2d9ac4f..eee7177c8c 100644 --- a/packages/host/app/components/operator-mode/code-editor.gts +++ b/packages/host/app/components/operator-mode/code-editor.gts @@ -324,7 +324,15 @@ export default class CodeEditor extends Component { padding: var(--boxel-sp) 0; } - .monaco-container.readonly { + :global(.monaco-container.readonly) { + background-color: #ebeaed; + } + + :global(.monaco-container.readonly .margin) { + background-color: #ebeaed; + } + + :global(.monaco-container.readonly .monaco-editor-background) { background-color: #ebeaed; } diff --git a/packages/host/app/components/operator-mode/code-submode.gts b/packages/host/app/components/operator-mode/code-submode.gts index 18fd02aecf..af228a12fc 100644 --- a/packages/host/app/components/operator-mode/code-submode.gts +++ b/packages/host/app/components/operator-mode/code-submode.gts @@ -38,6 +38,8 @@ import RecentFiles from '@cardstack/host/components/editor/recent-files'; import CodeSubmodeEditorIndicator from '@cardstack/host/components/operator-mode/code-submode/editor-indicator'; import SyntaxErrorDisplay from '@cardstack/host/components/operator-mode/syntax-error-display'; +import ENV from '@cardstack/host/config/environment'; + import { getCard } from '@cardstack/host/resources/card-resource'; import { isReady, type FileResource } from '@cardstack/host/resources/file'; import { @@ -57,6 +59,8 @@ import type RecentFilesService from '@cardstack/host/services/recent-files-servi import { type CardDef, type Format } from 'https://cardstack.com/base/card-api'; +import { type BoxelSpecType } from 'https://cardstack.com/base/catalog-entry'; + import { htmlComponent } from '../../lib/html-component'; import { CodeModePanelWidths } from '../../utils/local-storage-keys'; import FileTree from '../editor/file-tree'; @@ -66,6 +70,7 @@ import CardErrorDetail from './card-error-detail'; import CardPreviewPanel from './card-preview-panel/index'; import CardURLBar from './card-url-bar'; import CodeEditor from './code-editor'; +import BoxelSpecPreview from './code-submode/boxel-spec-preview'; import InnerContainer from './code-submode/inner-container'; import CodeSubmodeLeftPanelToggle from './code-submode/left-panel-toggle'; import SchemaEditor, { SchemaEditorTitle } from './code-submode/schema-editor'; @@ -75,6 +80,8 @@ import DetailPanel from './detail-panel'; import NewFileButton from './new-file-button'; import SubmodeLayout from './submode-layout'; +const isPlaygroundEnabled = ENV.featureFlags?.ENABLE_PLAYGROUND; + interface Signature { Args: { saveSourceOnClose: (url: URL, content: string) => void; @@ -94,7 +101,11 @@ type PanelHeights = { recentPanel: number; }; -type SelectedAccordionItem = 'schema-editor' | null; +type SelectedAccordionItem = + | 'schema-editor' + | 'boxel-spec-preview' + | 'playground' + | null; const defaultLeftPanelWidth = ((14.0 * parseFloat(getComputedStyle(document.documentElement).fontSize)) / @@ -367,7 +378,6 @@ export default class CodeSubmode extends Component { return `No tools are available for the selected item: ${this.selectedDeclaration?.type} "${this.selectedDeclaration?.localName}". Select a card or field definition in the inspector.`; } } - // If rhs doesn't handle any case but we can't capture the error if (!this.card && !this.selectedCardOrField) { // this will prevent displaying message during a page refresh @@ -486,6 +496,13 @@ export default class CodeSubmode extends Component { return undefined; } + get showBoxelSpecPreview() { + return ( + !this.moduleContentsResource.isLoading && + this.selectedDeclaration?.exportName + ); + } + private get itemToDeleteAsCard() { return this.itemToDelete as CardDef; } @@ -656,6 +673,7 @@ export default class CodeSubmode extends Component { definitionClass?: { displayName: string; ref: ResolvedCodeRef; + specType?: BoxelSpecType; }, sourceInstance?: CardDef, ) => { @@ -772,7 +790,6 @@ export default class CodeSubmode extends Component { {{/let}}
{ this.selectedAccordionItem 'schema-editor' }} + data-test-accordion-item='schema-editor' > <:title> @@ -965,6 +983,50 @@ export default class CodeSubmode extends Component { + {{#if isPlaygroundEnabled}} + + <:title>Playground + <:content> + + + {{/if}} + {{#if this.showBoxelSpecPreview}} + + + <:title> + + + <:content> + + + + + {{/if}} {{else if this.moduleContentsResource.moduleError}} diff --git a/packages/host/app/components/operator-mode/code-submode/boxel-spec-preview.gts b/packages/host/app/components/operator-mode/code-submode/boxel-spec-preview.gts new file mode 100644 index 0000000000..a8bd044567 --- /dev/null +++ b/packages/host/app/components/operator-mode/code-submode/boxel-spec-preview.gts @@ -0,0 +1,491 @@ +import { on } from '@ember/modifier'; +import { action } from '@ember/object'; +import { service } from '@ember/service'; +import GlimmerComponent from '@glimmer/component'; + +import { tracked } from '@glimmer/tracking'; + +import AppsIcon from '@cardstack/boxel-icons/apps'; +import Brain from '@cardstack/boxel-icons/brain'; +import DotIcon from '@cardstack/boxel-icons/dot'; + +import LayoutList from '@cardstack/boxel-icons/layout-list'; +import StackIcon from '@cardstack/boxel-icons/stack'; + +import { + BoxelButton, + Pill, + BoxelSelect, + RealmIcon, +} from '@cardstack/boxel-ui/components'; + +import { LoadingIndicator } from '@cardstack/boxel-ui/components'; + +import { + type ResolvedCodeRef, + catalogEntryRef, + getCards, + type Query, + isCardDef, + isFieldDef, +} from '@cardstack/runtime-common'; + +import { + CardOrFieldDeclaration, + isCardOrFieldDeclaration, + type ModuleDeclaration, +} from '@cardstack/host/resources/module-contents'; +import OperatorModeStateService from '@cardstack/host/services/operator-mode-state-service'; + +import RealmService from '@cardstack/host/services/realm'; + +import type RealmServerService from '@cardstack/host/services/realm-server'; + +import { type CardDef } from 'https://cardstack.com/base/card-api'; + +import { + CatalogEntry, + type BoxelSpecType, +} from 'https://cardstack.com/base/catalog-entry'; + +import { type FileType } from '../create-file-modal'; + +import type { WithBoundArgs } from '@glint/template'; + +interface Signature { + Element: HTMLElement; + Args: { + selectedDeclaration?: ModuleDeclaration; + createFile: ( + fileType: FileType, + definitionClass?: { + displayName: string; + ref: ResolvedCodeRef; + specType?: BoxelSpecType; + }, + sourceInstance?: CardDef, + ) => Promise; + isCreateModalShown: boolean; + }; + Blocks: { + default: [ + WithBoundArgs< + typeof BoxelSpecPreviewTitle, + | 'showCreateBoxelSpecIntent' + | 'boxelSpecInstances' + | 'selectedInstance' + | 'createBoxelSpec' + | 'isCreateModalShown' + >, + WithBoundArgs< + typeof BoxelSpecPreviewContent, + | 'showCreateBoxelSpecIntent' + | 'boxelSpecInstances' + | 'selectedInstance' + | 'selectBoxelSpec' + >, + ]; + }; +} + +interface TitleSignature { + Args: { + boxelSpecInstances: CatalogEntry[]; + selectedInstance: CatalogEntry | null; + showCreateBoxelSpecIntent: boolean; + createBoxelSpec: () => void; + isCreateModalShown: boolean; + }; +} + +class BoxelSpecPreviewTitle extends GlimmerComponent { + get numberOfInstances() { + return this.args.boxelSpecInstances?.length; + } + + get moreThanOneInstance() { + return this.numberOfInstances > 1; + } + + +} + +interface ContentSignature { + Element: HTMLDivElement; + Args: { + boxelSpecInstances: CatalogEntry[]; + selectedInstance: CatalogEntry | null; + selectBoxelSpec: (boxelSpec: CatalogEntry) => void; + showCreateBoxelSpecIntent: boolean; + }; +} + +class BoxelSpecPreviewContent extends GlimmerComponent { + @service private declare realm: RealmService; + + get onlyOneInstance() { + return this.args.boxelSpecInstances.length === 1; + } + + @action realmInfo(card: CatalogEntry) { + return this.realm.info(card.id); + } + + @action getLocalPath(card: CatalogEntry) { + let realmURL = this.realm.realmOfURL(new URL(card.id)); + if (!realmURL) { + throw new Error('bug: no realm URL'); + } + return getRelativePath(realmURL.href, card.id); + } + + +} + +export default class BoxelSpecPreview extends GlimmerComponent { + @service private declare operatorModeStateService: OperatorModeStateService; + @service private declare realm: RealmService; + @service private declare realmServer: RealmServerService; + @tracked selectedInstance?: CatalogEntry = this.boxelSpecInstances[0]; + + get realms() { + return this.realmServer.availableRealmURLs; + } + + private get getSelectedDeclarationAsCodeRef(): ResolvedCodeRef { + if (!this.args.selectedDeclaration?.exportName) { + return { + name: '', + module: '', + }; + } + return { + name: this.args.selectedDeclaration.exportName, + module: `${this.operatorModeStateService.state.codePath!.href.replace( + /\.[^.]+$/, + '', + )}`, + }; + } + + private get boxelSpecQuery(): Query { + return { + filter: { + on: catalogEntryRef, + eq: { + ref: this.getSelectedDeclarationAsCodeRef, //ref is primitive + }, + }, + sort: [ + { + by: 'createdAt', + direction: 'desc', + }, + ], + }; + } + + boxelSpecSearch = getCards(this.boxelSpecQuery, this.realms, { + isLive: true, + }); + + get boxelSpecInstances() { + return this.boxelSpecSearch.instances as CatalogEntry[]; + } + + private get showCreateBoxelSpecIntent() { + return ( + !this.boxelSpecSearch.isLoading && this.boxelSpecInstances.length === 0 + ); + } + + //TODO: Improve identification of isApp and isSkill + // isApp and isSkill are far from perfect functions + //We have good primitives to identify card and field but not for app and skill + //Here we are trying our best based upon schema analyses what is an app and a skill + //We don't try to capture deep ancestry of app and skill + isApp(selectedDeclaration: CardOrFieldDeclaration) { + if (selectedDeclaration.exportName === 'AppCard') { + return true; + } + if ( + selectedDeclaration.super && + selectedDeclaration.super.type === 'external' && + selectedDeclaration.super.name === 'AppCard' + ) { + return true; + } + return false; + } + + isSkill(selectedDeclaration: CardOrFieldDeclaration) { + if (selectedDeclaration.exportName === 'SkillCard') { + return true; + } + if ( + selectedDeclaration.super && + selectedDeclaration.super.type === 'external' && + selectedDeclaration.super.name === 'SkillCard' && + selectedDeclaration.super.module === + 'https://cardstack.com/base/skill-card' + ) { + return true; + } + return false; + } + + guessSpecType(selectedDeclaration: ModuleDeclaration): BoxelSpecType { + if (isCardOrFieldDeclaration(selectedDeclaration)) { + if (isCardDef(selectedDeclaration.cardOrField)) { + if (this.isApp(selectedDeclaration)) { + return 'app'; + } + if (this.isSkill(selectedDeclaration)) { + return 'skill'; + } + return 'card'; + } + if (isFieldDef(selectedDeclaration.cardOrField)) { + return 'field'; + } + } + throw new Error('Unidentified boxel spec'); + } + + @action private createBoxelSpec() { + if (!this.args.selectedDeclaration) { + throw new Error('bug: no selected declaration'); + } + if (!this.getSelectedDeclarationAsCodeRef) { + throw new Error('bug: no code ref'); + } + let specType = this.guessSpecType(this.args.selectedDeclaration); + let displayName = this.getSelectedDeclarationAsCodeRef.name; + this.args.createFile( + { + id: 'boxel-spec-instance', + displayName: 'Boxel Specification', //display name in modal + }, + { + displayName: displayName, + ref: this.getSelectedDeclarationAsCodeRef, + specType, + }, + ); + } + + @action selectBoxelSpec(boxelSpec: CatalogEntry): void { + this.selectedInstance = boxelSpec; + } + + +} + +function getComponent(cardOrField: CatalogEntry) { + return cardOrField.constructor.getComponent(cardOrField); +} + +interface SpecTagSignature { + Element: HTMLDivElement; + Args: { + specType: string; + }; +} + +export class SpecTag extends GlimmerComponent { + get icon() { + return getIcon(this.args.specType); + } + +} + +function getIcon(specType: string) { + switch (specType) { + case 'card': + return StackIcon; + case 'app': + return AppsIcon; + case 'field': + return LayoutList; + case 'skill': + return Brain; + default: + return; + } +} + +function getRelativePath(baseUrl: string, targetUrl: string) { + const basePath = new URL(baseUrl).pathname; + const targetPath = new URL(targetUrl).pathname; + return targetPath.replace(basePath, '') || '/'; +} diff --git a/packages/host/app/components/operator-mode/create-file-modal.gts b/packages/host/app/components/operator-mode/create-file-modal.gts index 2563108a75..b37773afc0 100644 --- a/packages/host/app/components/operator-mode/create-file-modal.gts +++ b/packages/host/app/components/operator-mode/create-file-modal.gts @@ -44,6 +44,8 @@ import type RealmService from '@cardstack/host/services/realm'; import type { CardDef } from 'https://cardstack.com/base/card-api'; import type { CatalogEntry } from 'https://cardstack.com/base/catalog-entry'; +import { type BoxelSpecType } from 'https://cardstack.com/base/catalog-entry'; + import { cleanseString } from '../../lib/utils'; import ModalContainer from '../modal-container'; @@ -59,12 +61,15 @@ export type NewFileType = | 'duplicate-instance' | 'card-instance' | 'card-definition' - | 'field-definition'; + | 'field-definition' + | 'boxel-spec-instance'; + export const newFileTypes: NewFileType[] = [ 'duplicate-instance', + 'card-instance', 'card-definition', 'field-definition', - 'card-instance', + 'boxel-spec-instance', ]; const waiter = buildWaiter('create-file-modal:on-setup-waiter'); @@ -117,11 +122,7 @@ export default class CreateFileModal extends Component { {{#unless (eq this.fileType.id 'duplicate-instance')}} @@ -236,6 +237,17 @@ export default class CreateFileModal extends Component { > Duplicate + {{else if (eq this.fileType.id 'boxel-spec-instance')}} + {{else if (or (eq this.fileType.id 'card-definition') @@ -345,6 +357,7 @@ export default class CreateFileModal extends Component { definitionClass?: { displayName: string; ref: ResolvedCodeRef; + specType?: BoxelSpecType; }; sourceInstance?: CardDef; } @@ -355,6 +368,14 @@ export default class CreateFileModal extends Component { this.args.onCreate(this); } + get refLabel() { + return this.maybeFileType?.id === 'card-instance' + ? 'Adopted From' + : this.maybeFileType?.id === 'boxel-spec-instance' + ? 'Code Ref' + : 'Inherits From'; + } + // public API for callers to use this component async createNewFile( fileType: FileType, @@ -362,6 +383,7 @@ export default class CreateFileModal extends Component { definitionClass?: { displayName: string; ref: ResolvedCodeRef; + specType?: BoxelSpecType; }, sourceInstance?: CardDef, ) { @@ -384,6 +406,7 @@ export default class CreateFileModal extends Component { definitionClass?: { displayName: string; ref: ResolvedCodeRef; + specType?: BoxelSpecType; }, sourceInstance?: CardDef, ) => { @@ -550,7 +573,7 @@ export default class CreateFileModal extends Component { filter: { on: catalogEntryRef, // REMEMBER ME - every: [{ eq: { isField } }], + every: [{ eq: { specType: isField ? 'field' : 'card' } }], }, }); }); @@ -765,6 +788,58 @@ export class ${className} extends ${exportName} { this.saveError = `Error creating card instance: ${e.message}`; } }); + + private createBoxelSpecInstance = restartableTask(async () => { + if (!this.currentRequest) { + throw new Error( + `Cannot createCardInstance when there is no this.currentRequest`, + ); + } + if (!this.definitionClass || !this.selectedRealmURL) { + throw new Error( + `bug: cannot create card instance without adoptsFrom ref and selected realm URL`, + ); + } + + let { ref, specType } = this.definitionClass; + + let relativeTo = new URL(catalogEntryRef.module); + let maybeRef = codeRefWithAbsoluteURL(ref, relativeTo); + if ('name' in maybeRef && 'module' in maybeRef) { + ref = maybeRef; + } + if (!ref) { + throw new Error(`bug: cannot create boxel spec instance without a ref`); + } + + let doc: LooseSingleCardDocument = { + data: { + attributes: { + specType, + ref, + }, + meta: { + adoptsFrom: catalogEntryRef, + realmURL: this.selectedRealmURL.href, + }, + }, + }; + + try { + let card = await this.cardService.createFromSerialized(doc.data, doc); + + if (!card) { + throw new Error( + `Failed to create card from ref "${ref.name}" from "${ref.module}"`, + ); + } + await this.cardService.saveModel(card); + this.currentRequest.newFileDeferred.fulfill(new URL(`${card.id}.json`)); + } catch (e: any) { + console.log('Error saving', e); + this.saveError = `Error creating card instance: ${e.message}`; + } + }); } export function convertToClassName(input: string) { diff --git a/packages/host/app/components/operator-mode/new-file-button.gts b/packages/host/app/components/operator-mode/new-file-button.gts index 9feb1babd2..a72e04fbe5 100644 --- a/packages/host/app/components/operator-mode/new-file-button.gts +++ b/packages/host/app/components/operator-mode/new-file-button.gts @@ -72,7 +72,7 @@ export default class NewFileButton extends Component { private get menuItems() { return flatMap(newFileTypes, (id) => { - if (id === 'duplicate-instance') { + if (id === 'duplicate-instance' || id === 'boxel-spec-instance') { return []; } let displayName = capitalize(startCase(id)); diff --git a/packages/host/app/components/operator-mode/submode-layout.gts b/packages/host/app/components/operator-mode/submode-layout.gts index 80deb178d4..5633665207 100644 --- a/packages/host/app/components/operator-mode/submode-layout.gts +++ b/packages/host/app/components/operator-mode/submode-layout.gts @@ -17,7 +17,7 @@ import { TrackedObject } from 'tracked-built-ins'; import { ResizablePanelGroup } from '@cardstack/boxel-ui/components'; import { Avatar, IconButton } from '@cardstack/boxel-ui/components'; -import { and, cn, not } from '@cardstack/boxel-ui/helpers'; +import { cn, not } from '@cardstack/boxel-ui/helpers'; import { BoxelIcon } from '@cardstack/boxel-ui/icons'; @@ -47,7 +47,6 @@ import type OperatorModeStateService from '../../services/operator-mode-state-se interface Signature { Element: HTMLDivElement; Args: { - hideAiAssistant?: boolean; onSearchSheetOpened?: () => void; onSearchSheetClosed?: () => void; onCardSelectFromSearch: (cardId: string) => void; @@ -309,23 +308,17 @@ export default class SubmodeLayout extends Component { @onCardSelect={{this.handleCardSelectFromSearch}} @onInputInsertion={{this.storeSearchElement}} /> - {{#if (not @hideAiAssistant)}} - - - {{/if}} + + - {{#if - (and - (not @hideAiAssistant) this.operatorModeStateService.aiAssistantOpen - ) - }} + {{#if this.operatorModeStateService.aiAssistantOpen}} { @service private declare matrixService: MatrixService; @tracked private isModalOpen = false; @tracked private endpoint = ''; + @tracked private copyFromSeedRealm = true; @tracked private displayName = ''; @tracked private hasUserEditedEndpoint = false; @tracked private error: string | null = null; @@ -54,6 +55,9 @@ export default class AddWorkspace extends Component { this.endpoint = cleanseString(value); } }; + private toggleCopyFromSeedRealm = () => { + this.copyFromSeedRealm = !this.copyFromSeedRealm; + }; private closeModal = () => { this.isModalOpen = false; }; @@ -65,6 +69,7 @@ export default class AddWorkspace extends Component { name: this.displayName, iconURL: iconURLFor(this.displayName), backgroundURL: getRandomBackgroundURL(), + copyFromSeedRealm: this.copyFromSeedRealm, }); this.closeModal(); } catch (e: any) { @@ -135,6 +140,20 @@ export default class AddWorkspace extends Component { @helperText='The endpoint is the unique identifier for your workspace. Use letters, numbers, and hyphens only.' /> + + + {{/if}} {{/if}} {{#if this.error}} @@ -232,6 +251,28 @@ export default class AddWorkspace extends Component { .spinner { --boxel-loading-indicator-size: 2.5rem; } + .copy-from-seed-label { + display: flex; + gap: calc(var(--boxel-sp-sm) + 1px); + padding-top: var(--boxel-sp-sm); + } + .copy-from-seed-label span { + color: var(--boxel-label-color); + font: var(--boxel-font-sm); + letter-spacing: var(--boxel-lsp-xs); + } + .copy-from-seed-label :deep(.input-container) { + grid-template-columns: 1fr; + width: auto; + height: 100%; + } + .copy-from-seed-checkbox { + --boxel-input-height: 17px; + grid-area: pre-icon; + justify-self: start; + margin: 0; + width: auto; + } } diff --git a/packages/host/app/config/environment.d.ts b/packages/host/app/config/environment.d.ts index 06e667b153..f7c1a156d6 100644 --- a/packages/host/app/config/environment.d.ts +++ b/packages/host/app/config/environment.d.ts @@ -28,5 +28,7 @@ declare const config: { sqlSchema: string; assetsURL: string; stripePaymentLink: string; - featureFlags?: {}; + featureFlags?: { + ENABLE_PLAYGROUND: boolean; + }; }; diff --git a/packages/host/app/lib/current-run.ts b/packages/host/app/lib/current-run.ts index 750099aec0..0500d19f6c 100644 --- a/packages/host/app/lib/current-run.ts +++ b/packages/host/app/lib/current-run.ts @@ -122,50 +122,26 @@ export class CurrentRun { this.#render = render; } - static async fromScratch( - current: CurrentRun, - invalidateEntireRealm?: boolean, - ): Promise { + static async fromScratch(current: CurrentRun): Promise { let start = Date.now(); log.debug(`starting from scratch indexing`); perfLog.debug( `starting from scratch indexing for realm ${current.realmURL.href}`, ); - current.#batch = await current.#indexWriter.createBatch(current.realmURL); let invalidations: URL[] = []; - if (invalidateEntireRealm) { - perfLog.debug( - `flag was set to invalidate entire realm ${current.realmURL.href}, skipping invalidation discovery`, - ); - let mtimesStart = Date.now(); - let filesystemMtimes = await current.#reader.mtimes(); - perfLog.debug( - `time to get file system mtimes ${Date.now() - mtimesStart} ms`, - ); - invalidations = Object.keys(filesystemMtimes) - .filter( - (url) => - // Only allow json and executable files to be invalidated so that we - // don't end up with invalidated files that weren't meant to be indexed - // (images, etc) - url.endsWith('.json') || hasExecutableExtension(url), - ) - .map((url) => new URL(url)); - } else { - let mtimesStart = Date.now(); - let mtimes = await current.batch.getModifiedTimes(); - perfLog.debug( - `completed getting index mtimes in ${Date.now() - mtimesStart} ms`, - ); - let invalidateStart = Date.now(); - invalidations = ( - await current.discoverInvalidations(current.realmURL, mtimes) - ).map((href) => new URL(href)); - perfLog.debug( - `completed invalidations in ${Date.now() - invalidateStart} ms`, - ); - } + let mtimesStart = Date.now(); + let mtimes = await current.batch.getModifiedTimes(); + perfLog.debug( + `completed getting index mtimes in ${Date.now() - mtimesStart} ms`, + ); + let invalidateStart = Date.now(); + invalidations = ( + await current.discoverInvalidations(current.realmURL, mtimes) + ).map((href) => new URL(href)); + perfLog.debug( + `completed invalidations in ${Date.now() - invalidateStart} ms`, + ); await current.whileIndexing(async () => { let visitStart = Date.now(); @@ -529,6 +505,7 @@ export class CurrentRun { identityContext, }, ); + await api.flushLogs(); isolatedHtml = unwrap( sanitizeHTML( await this.#renderCard({ diff --git a/packages/host/app/modifiers/monaco.ts b/packages/host/app/modifiers/monaco.ts index 93197e4140..139436f841 100644 --- a/packages/host/app/modifiers/monaco.ts +++ b/packages/host/app/modifiers/monaco.ts @@ -22,7 +22,6 @@ interface Signature { language?: string; readOnly?: boolean; monacoSDK: typeof MonacoSDK; - darkTheme?: boolean; editorDisplayOptions?: MonacoEditorOptions; }; }; @@ -54,7 +53,6 @@ export default class Monaco extends Modifier { onSetup, readOnly, monacoSDK, - darkTheme, editorDisplayOptions, }: Signature['Args']['Named'], ) { @@ -73,21 +71,17 @@ export default class Monaco extends Modifier { this.model.setValue(content); } } else { - monacoSDK.editor.defineTheme('boxel-theme', { + // The light theme editor is used for the main editor in code mode, + // but we also have a dark themed editor for the preview editor in AI panel. + // The latter is themed using a CSS filter as opposed to defining a new monaco theme + // because monaco does not support multiple themes on the same page (check the comment in + // room-message-command.gts for more details) + monacoSDK.editor.defineTheme('boxel-monaco-light-theme', { base: 'vs', inherit: true, rules: [], colors: { - 'editor.background': readOnly ? '#EBEAED' : '#FFFFFF', - }, - }); - - monacoSDK.editor.defineTheme('boxel-dark-theme', { - base: 'vs-dark', - inherit: true, - rules: [], - colors: { - 'editor.background': '#000000', + 'editor.background': '#FFFFFF', }, }); @@ -100,7 +94,7 @@ export default class Monaco extends Modifier { minimap: { enabled: false, }, - theme: darkTheme ? 'boxel-dark-theme' : 'boxel-theme', + theme: 'boxel-monaco-light-theme', ...editorDisplayOptions, }; diff --git a/packages/host/app/resources/room.ts b/packages/host/app/resources/room.ts index 5eb859b4c0..792b83d3da 100644 --- a/packages/host/app/resources/room.ts +++ b/packages/host/app/resources/room.ts @@ -352,7 +352,7 @@ export class RoomResource extends Resource { (e: any) => e.type === 'm.room.message' && e.content.msgtype === APP_BOXEL_COMMAND_MSGTYPE && - e.content['m.relates_to'].event_id === effectiveEventId, + e.content['m.relates_to']?.event_id === effectiveEventId, )! as CommandEvent | undefined; let message = this._messageCache.get(effectiveEventId); if (!message || !commandEvent) { diff --git a/packages/host/app/services/local-indexer.ts b/packages/host/app/services/local-indexer.ts index edf4238c9c..3c0215a3d0 100644 --- a/packages/host/app/services/local-indexer.ts +++ b/packages/host/app/services/local-indexer.ts @@ -8,10 +8,7 @@ import { type TestRealmAdapter } from '@cardstack/host/tests/helpers/adapter'; // for the test-realm-adapter export default class LocalIndexer extends Service { setup( - _fromScratch: ( - realmURL: URL, - invalidateEntireRealm: boolean, - ) => Promise, + _fromScratch: (realmURL: URL) => Promise, _incremental: ( url: URL, realmURL: URL, diff --git a/packages/host/app/services/matrix-service.ts b/packages/host/app/services/matrix-service.ts index a7c5e8f706..2e896d28e3 100644 --- a/packages/host/app/services/matrix-service.ts +++ b/packages/host/app/services/matrix-service.ts @@ -337,17 +337,20 @@ export default class MatrixService extends Service { name, iconURL, backgroundURL, + copyFromSeedRealm, }: { endpoint: string; name: string; iconURL?: string; backgroundURL?: string; + copyFromSeedRealm?: boolean; }) { let personalRealmURL = await this.realmServer.createRealm({ endpoint, name, iconURL, backgroundURL, + copyFromSeedRealm, }); let { realms = [] } = (await this.client.getAccountDataFromServer<{ realms: string[] }>( diff --git a/packages/host/app/services/realm-server.ts b/packages/host/app/services/realm-server.ts index 222991c019..21fee155bc 100644 --- a/packages/host/app/services/realm-server.ts +++ b/packages/host/app/services/realm-server.ts @@ -115,6 +115,7 @@ export default class RealmServerService extends Service { name: string; iconURL?: string; backgroundURL?: string; + copyFromSeedRealm?: boolean; }) { await this.login(); diff --git a/packages/host/app/services/realm.ts b/packages/host/app/services/realm.ts index 0bc049a637..bb4bc3f860 100644 --- a/packages/host/app/services/realm.ts +++ b/packages/host/app/services/realm.ts @@ -169,6 +169,7 @@ class RealmResource { case 'incremental-index-initiation': this.info.isIndexing = true; break; + case 'copy': case 'incremental': this.info.isIndexing = false; break; @@ -332,15 +333,23 @@ class RealmResource { return; } - // token expiration is unix time (seconds) - let expirationMs = this.claims.exp * 1000; + let refreshMs = 0; - let refreshMs = Math.max( - expirationMs - Date.now() - tokenRefreshPeriodSec * 1000, - 0, - ); + if (!this.claims.sessionRoom) { + // Force JWT renewal to ensure presence of sessionRoom property + console.log(`JWT for realm ${this.url} has no session room, renewing`); + } else { + // token expiration is unix time (seconds) + let expirationMs = this.claims.exp * 1000; + + refreshMs = Math.max( + expirationMs - Date.now() - tokenRefreshPeriodSec * 1000, + 0, + ); + } await rawTimeout(refreshMs); + if (!this.loggingIn) { this.loggingIn = this.loginTask.perform(); await this.loggingIn; diff --git a/packages/host/config/environment.js b/packages/host/config/environment.js index c9a175d8ad..9758199d06 100644 --- a/packages/host/config/environment.js +++ b/packages/host/config/environment.js @@ -41,7 +41,9 @@ module.exports = function (environment) { hostsOwnAssets: true, resolvedBaseRealmURL: process.env.RESOLVED_BASE_REALM_URL || 'http://localhost:4201/base/', - featureFlags: {}, + featureFlags: { + ENABLE_PLAYGROUND: process.env.ENABLE_PLAYGROUND || false, + }, }; if (environment === 'development') { @@ -50,6 +52,9 @@ module.exports = function (environment) { // ENV.APP.LOG_TRANSITIONS = true; // ENV.APP.LOG_TRANSITIONS_INTERNAL = true; // ENV.APP.LOG_VIEW_LOOKUPS = true; + ENV.featureFlags = { + ENABLE_PLAYGROUND: true, + }; } if (environment === 'test') { @@ -69,6 +74,9 @@ module.exports = function (environment) { ENV.loginMessageTimeoutMs = 0; ENV.minSaveTaskDurationMs = 0; ENV.sqlSchema = sqlSchema; + ENV.featureFlags = { + ENABLE_PLAYGROUND: true, + }; } if (environment === 'production') { diff --git a/packages/host/config/schema/1737128666066_schema.sql b/packages/host/config/schema/1737555650787_schema.sql similarity index 100% rename from packages/host/config/schema/1737128666066_schema.sql rename to packages/host/config/schema/1737555650787_schema.sql diff --git a/packages/host/tests/acceptance/basic-test.gts b/packages/host/tests/acceptance/basic-test.gts index 204ffc94a4..23126b1980 100644 --- a/packages/host/tests/acceptance/basic-test.gts +++ b/packages/host/tests/acceptance/basic-test.gts @@ -81,7 +81,7 @@ module('Acceptance | basic tests', function (hooks) { 'index.gts': { Index }, 'person.gts': { Person }, 'person-entry.json': new CatalogEntry({ - title: 'Person', + name: 'Person', description: 'Catalog entry', isField: false, ref: { diff --git a/packages/host/tests/acceptance/code-submode-test.ts b/packages/host/tests/acceptance/code-submode-test.ts index 7a85d9bc1b..40fc81ca57 100644 --- a/packages/host/tests/acceptance/code-submode-test.ts +++ b/packages/host/tests/acceptance/code-submode-test.ts @@ -562,7 +562,7 @@ module('Acceptance | code submode tests', function (_hooks) { attributes: { title: 'Person', description: 'Catalog entry', - isField: false, + specType: 'card', ref: { module: `./person`, name: 'Person', @@ -576,6 +576,42 @@ module('Acceptance | code submode tests', function (_hooks) { }, }, }, + 'pet-entry.json': { + data: { + type: 'card', + attributes: { + specType: 'card', + ref: { + module: `./pet`, + name: 'Pet', + }, + }, + meta: { + adoptsFrom: { + module: `${baseRealm.url}catalog-entry`, + name: 'CatalogEntry', + }, + }, + }, + }, + 'pet-entry-2.json': { + data: { + type: 'card', + attributes: { + specType: 'card', + ref: { + module: `./pet`, + name: 'Pet', + }, + }, + meta: { + adoptsFrom: { + module: `${baseRealm.url}catalog-entry`, + name: 'CatalogEntry', + }, + }, + }, + }, 'index.json': { data: { type: 'card', diff --git a/packages/host/tests/acceptance/code-submode/boxel-spec-test.gts b/packages/host/tests/acceptance/code-submode/boxel-spec-test.gts new file mode 100644 index 0000000000..290d730d71 --- /dev/null +++ b/packages/host/tests/acceptance/code-submode/boxel-spec-test.gts @@ -0,0 +1,341 @@ +import { click, waitFor } from '@ember/test-helpers'; + +import { module, test } from 'qunit'; + +import { baseRealm } from '@cardstack/runtime-common'; + +import { + setupLocalIndexing, + testRealmURL, + setupAcceptanceTestRealm, + setupServerSentEvents, + visitOperatorMode, + setupUserSubscription, + percySnapshot, +} from '../../helpers'; +import { setupMockMatrix } from '../../helpers/mock-matrix'; +import { setupApplicationTest } from '../../helpers/setup'; +import '@cardstack/runtime-common/helpers/code-equality-assertion'; + +const personCardSource = ` + import { contains, containsMany, field, linksTo, linksToMany, CardDef, Component } from "https://cardstack.com/base/card-api"; + import StringCard from "https://cardstack.com/base/string"; + + export class Person extends CardDef { + static displayName = 'Person'; + @field firstName = contains(StringCard); + @field lastName = contains(StringCard); + @field title = contains(StringCard, { + computeVia: function (this: Person) { + return [this.firstName, this.lastName].filter(Boolean).join(' '); + }, + }); + static isolated = class Isolated extends Component { + + }; + } +`; + +const petCardSource = ` + import { contains, field, Component, CardDef } from "https://cardstack.com/base/card-api"; + import StringCard from "https://cardstack.com/base/string"; + + export class Pet extends CardDef { + static displayName = 'Pet'; + @field name = contains(StringCard); + @field title = contains(StringCard, { + computeVia: function (this: Pet) { + return this.name; + }, + }); + static embedded = class Embedded extends Component { + + } + static isolated = class Isolated extends Component { + + } + } +`; + +const employeeCardSource = ` + import { contains, field, Component, CardDef } from "https://cardstack.com/base/card-api"; + import StringCard from "https://cardstack.com/base/string"; + + export default class Employee extends CardDef { + static displayName = 'Employee'; + @field name = contains(StringCard); + @field title = contains(StringCard, { + computeVia: function (this: Pet) { + return this.name; + }, + }); + } +`; + +const newSkillCardSource = ` + import { contains, field, Component, CardDef } from "https://cardstack.com/base/card-api"; + import { SkillCard } from 'https://cardstack.com/base/skill-card'; + + export class NewSkill extends CardDef { + static displayName = 'NewSkill'; + } +`; + +let matrixRoomId: string; +module('boxel spec preview', function (hooks) { + setupApplicationTest(hooks); + setupLocalIndexing(hooks); + setupServerSentEvents(hooks); + let { setActiveRealms, createAndJoinRoom } = setupMockMatrix(hooks, { + loggedInAs: '@testuser:staging', + activeRealms: [testRealmURL], + }); + + hooks.beforeEach(async function () { + matrixRoomId = createAndJoinRoom('@testuser:staging', 'room-test'); + setupUserSubscription(matrixRoomId); + + // this seeds the loader used during index which obtains url mappings + // from the global loader + await setupAcceptanceTestRealm({ + contents: { + 'person.gts': personCardSource, + 'pet.gts': petCardSource, + 'employee.gts': employeeCardSource, + 'new-skill.gts': newSkillCardSource, + 'person-entry.json': { + data: { + type: 'card', + attributes: { + title: 'Person', + description: 'Catalog entry', + specType: 'card', + ref: { + module: `./person`, + name: 'Person', + }, + }, + meta: { + adoptsFrom: { + module: `${baseRealm.url}catalog-entry`, + name: 'CatalogEntry', + }, + }, + }, + }, + 'employee-entry.json': { + data: { + type: 'card', + attributes: { + specType: 'card', + ref: { + module: `./employee`, + name: 'default', + }, + }, + meta: { + adoptsFrom: { + module: `${baseRealm.url}catalog-entry`, + name: 'CatalogEntry', + }, + }, + }, + }, + 'pet-entry.json': { + data: { + type: 'card', + attributes: { + specType: 'card', + ref: { + module: `./pet`, + name: 'Pet', + }, + }, + meta: { + adoptsFrom: { + module: `${baseRealm.url}catalog-entry`, + name: 'CatalogEntry', + }, + }, + }, + }, + 'pet-entry-2.json': { + data: { + type: 'card', + attributes: { + specType: 'card', + ref: { + module: `./pet`, + name: 'Pet', + }, + }, + meta: { + adoptsFrom: { + module: `${baseRealm.url}catalog-entry`, + name: 'CatalogEntry', + }, + }, + }, + }, + 'Person/fadhlan.json': { + data: { + attributes: { + firstName: 'Fadhlan', + address: [ + { + city: 'Bandung', + country: 'Indonesia', + shippingInfo: { + preferredCarrier: 'DHL', + remarks: `Don't let bob deliver the package--he's always bringing it to the wrong address`, + }, + }, + ], + }, + relationships: { + pet: { + links: { + self: `${testRealmURL}Pet/mango`, + }, + }, + }, + meta: { + adoptsFrom: { + module: `${testRealmURL}person`, + name: 'Person', + }, + }, + }, + }, + 'Person/1.json': { + data: { + type: 'card', + attributes: { + firstName: 'Hassan', + lastName: 'Abdel-Rahman', + }, + meta: { + adoptsFrom: { + module: '../person', + name: 'Person', + }, + }, + }, + }, + 'Pet/mango.json': { + data: { + attributes: { + name: 'Mango', + }, + meta: { + adoptsFrom: { + module: `${testRealmURL}pet`, + name: 'Pet', + }, + }, + }, + }, + '.realm.json': { + name: 'Test Workspace B', + backgroundURL: + 'https://i.postimg.cc/VNvHH93M/pawel-czerwinski-Ly-ZLa-A5jti-Y-unsplash.jpg', + iconURL: 'https://i.postimg.cc/L8yXRvws/icon.png', + }, + }, + }); + setActiveRealms([testRealmURL]); + }); + test('view when there is a single boxel spec instance', async function (assert) { + await visitOperatorMode({ + submode: 'code', + codePath: `${testRealmURL}person.gts`, + }); + await waitFor('[data-test-accordion-item="boxel-spec-preview"]'); + assert.dom('[data-test-accordion-item="boxel-spec-preview"]').exists(); + assert.dom('[data-test-has-boxel-spec]').containsText('card'); + await click('[data-test-accordion-item="boxel-spec-preview"] button'); + await waitFor('[data-test-boxel-spec-selector]'); + assert.dom('[data-test-boxel-spec-selector]').exists(); + await percySnapshot(assert); + assert.dom('[data-test-title]').containsText('Person'); + assert.dom('[data-test-description]').containsText('Catalog entry'); + assert.dom('[data-test-module-href]').containsText(`${testRealmURL}person`); + assert.dom('[data-test-exported-name]').containsText('Person'); + assert.dom('[data-test-exported-type]').containsText('card'); + }); + test('view when there are multiple boxel spec instances', async function (assert) { + await visitOperatorMode({ + submode: 'code', + codePath: `${testRealmURL}pet.gts`, + }); + await waitFor('[data-test-accordion-item="boxel-spec-preview"]'); + assert.dom('[data-test-accordion-item="boxel-spec-preview"]').exists(); + assert.dom('[data-test-has-boxel-spec]').containsText('2 instances'); + await click('[data-test-accordion-item="boxel-spec-preview"] button'); + await waitFor('[data-test-boxel-spec-selector]'); + assert.dom('[data-test-boxel-spec-selector]').exists(); + assert.dom('[data-test-caret-down]').exists(); + }); + test('view when there are no boxel spec instances', async function (assert) { + await visitOperatorMode({ + submode: 'code', + codePath: `${testRealmURL}new-skill.gts`, + }); + await waitFor('[data-test-accordion-item="boxel-spec-preview"]'); + assert.dom('[data-test-accordion-item="boxel-spec-preview"]').exists(); + assert.dom('[data-test-create-boxel-spec-button]').exists(); + assert.dom('[data-test-create-boxel-spec-intent-message]').exists(); + }); + test('have ability to create new boxel spec instances', async function (assert) { + await visitOperatorMode({ + submode: 'code', + codePath: `${testRealmURL}new-skill.gts`, + }); + assert.dom('[data-test-create-boxel-spec-button]').exists(); + await click('[data-test-create-boxel-spec-button]'); + assert.dom('[data-test-create-file-modal]').exists(); + await waitFor('[data-test-create-boxel-spec-instance]'); + assert.dom('[data-test-selected-type="NewSkill"]').exists(); + await click('[data-test-create-boxel-spec-instance]'); + await waitFor('[data-test-field="specType"]'); + assert.dom('[data-test-field="specType"] input').hasValue('card'); + }); + test('title does not default to "default"', async function (assert) { + await visitOperatorMode({ + submode: 'code', + codePath: `${testRealmURL}employee.gts`, + }); + await waitFor('[data-test-accordion-item="boxel-spec-preview"]'); + assert.dom('[data-test-accordion-item="boxel-spec-preview"]').exists(); + await click('[data-test-accordion-item="boxel-spec-preview"] button'); + assert.dom('[data-test-title]').doesNotContainText('default'); + assert.dom('[data-test-exported-name]').containsText('default'); + }); +}); diff --git a/packages/host/tests/acceptance/code-submode/create-file-test.gts b/packages/host/tests/acceptance/code-submode/create-file-test.gts index 9302905cc3..73976bfa3c 100644 --- a/packages/host/tests/acceptance/code-submode/create-file-test.gts +++ b/packages/host/tests/acceptance/code-submode/create-file-test.gts @@ -89,14 +89,11 @@ const files: Record = { attributes: { title: 'Error', description: 'Catalog entry for Error', - isField: false, + specType: 'card', ref: { module: '../error', name: 'default', }, - demo: { - title: 'Error title', - }, }, meta: { adoptsFrom: { @@ -112,7 +109,7 @@ const files: Record = { attributes: { title: 'Pet', description: 'Catalog entry for Pet', - isField: false, + specType: 'card', ref: { module: `../pet`, name: 'default' }, }, meta: { @@ -129,7 +126,7 @@ const files: Record = { attributes: { title: 'Person', description: 'Catalog entry for Person', - isField: false, + specType: 'card', ref: { module: `../person`, name: 'Person' }, }, meta: { diff --git a/packages/host/tests/acceptance/code-submode/editor-test.ts b/packages/host/tests/acceptance/code-submode/editor-test.ts index 52214afc0e..b7bb400e1c 100644 --- a/packages/host/tests/acceptance/code-submode/editor-test.ts +++ b/packages/host/tests/acceptance/code-submode/editor-test.ts @@ -822,7 +822,7 @@ module('Acceptance | code submode | editor tests', function (hooks) { assert.dom('[data-test-realm-indicator-not-writable]').exists(); assert.strictEqual( window - .getComputedStyle(find('.monaco-editor')!) + .getComputedStyle(find('.monaco-editor-background')!) .getPropertyValue('background-color')!, 'rgb(235, 234, 237)', // equivalent to #ebeaed 'monaco editor is greyed out when read-only', diff --git a/packages/host/tests/acceptance/code-submode/file-tree-test.ts b/packages/host/tests/acceptance/code-submode/file-tree-test.ts index f28f3433af..7905bee9b2 100644 --- a/packages/host/tests/acceptance/code-submode/file-tree-test.ts +++ b/packages/host/tests/acceptance/code-submode/file-tree-test.ts @@ -224,7 +224,7 @@ module('Acceptance | code submode | file-tree tests', function (hooks) { attributes: { title: 'Person', description: 'Catalog entry', - isField: false, + specType: 'card', ref: { module: `./person`, name: 'Person', diff --git a/packages/host/tests/acceptance/code-submode/inspector-test.ts b/packages/host/tests/acceptance/code-submode/inspector-test.ts index 727509339d..5993ee3ef9 100644 --- a/packages/host/tests/acceptance/code-submode/inspector-test.ts +++ b/packages/host/tests/acceptance/code-submode/inspector-test.ts @@ -443,7 +443,7 @@ module('Acceptance | code submode | inspector tests', function (hooks) { attributes: { title: 'Person', description: 'Catalog entry', - isField: false, + specType: 'card', ref: { module: `./person`, name: 'Person', diff --git a/packages/host/tests/acceptance/code-submode/playground-test.gts b/packages/host/tests/acceptance/code-submode/playground-test.gts new file mode 100644 index 0000000000..5f062694ad --- /dev/null +++ b/packages/host/tests/acceptance/code-submode/playground-test.gts @@ -0,0 +1,107 @@ +import { waitFor } from '@ember/test-helpers'; + +import { module, test } from 'qunit'; + +import { + setupAcceptanceTestRealm, + setupLocalIndexing, + setupServerSentEvents, + testRealmURL, + visitOperatorMode, +} from '../../helpers'; +import { setupMockMatrix } from '../../helpers/mock-matrix'; +import { setupApplicationTest } from '../../helpers/setup'; + +module('Integration | code-submode | playground panel', function (hooks) { + setupApplicationTest(hooks); + setupLocalIndexing(hooks); + setupServerSentEvents(hooks); + setupMockMatrix(hooks, { + loggedInAs: '@testuser:staging', + activeRealms: [testRealmURL], + }); + + const authorCard = ` + import { contains, field, CardDef } from "https://cardstack.com/base/card-api"; + import MarkdownField from 'https://cardstack.com/base/markdown'; + import StringField from "https://cardstack.com/base/string"; + export class Author extends CardDef { + static displayName = 'Author'; + @field firstName = contains(StringField); + @field lastName = contains(StringField); + @field bio = contains(MarkdownField); + @field title = contains(StringField, { + computeVia: function (this: Person) { + return [this.firstName, this.lastName].filter(Boolean).join(' '); + }, + }); + } +`; + const blogPostCard = ` + import { contains, field, linksTo, CardDef } from "https://cardstack.com/base/card-api"; + import DatetimeField from 'https://cardstack.com/base/datetime'; + import MarkdownField from 'https://cardstack.com/base/markdown'; + import StringField from "https://cardstack.com/base/string"; + import { Author } from './author'; + export class BlogPost extends CardDef { + static displayName = 'Blog Post'; + @field publishDate = contains(DatetimeField); + @field author = linksTo(Author); + @field body = contains(MarkdownField); + } +`; + + hooks.beforeEach(async function () { + await setupAcceptanceTestRealm({ + contents: { + 'author.gts': authorCard, + 'blog-post.gts': blogPostCard, + 'Author/jane-doe.json': { + data: { + attributes: { + firstName: 'Jane', + lastName: 'Doe', + }, + meta: { + adoptsFrom: { + module: `${testRealmURL}author`, + name: 'Author', + }, + }, + }, + }, + 'BlogPost/remote-work.json': { + data: { + attributes: { + title: 'The Ultimate Guide to Remote Work', + description: + 'In today’s digital age, remote work has transformed from a luxury to a necessity. This comprehensive guide will help you navigate the world of remote work, offering tips, tools, and best practices for success.', + }, + relationships: { + author: { + links: { + self: `${testRealmURL}Author/jane-doe`, + }, + }, + }, + meta: { + adoptsFrom: { + module: `${testRealmURL}blog-post`, + name: 'BlogPost', + }, + }, + }, + }, + }, + }); + }); + + test('can render playground panel', async function (assert) { + await visitOperatorMode({ + submode: 'code', + codePath: `${testRealmURL}blog-post.gts`, + }); + await waitFor('[data-test-accordion-item="playground"]'); + assert.dom('[data-test-accordion-item="playground"]').exists(); + }); +}); diff --git a/packages/host/tests/acceptance/code-submode/recent-files-test.ts b/packages/host/tests/acceptance/code-submode/recent-files-test.ts index a6e55096b7..4ab079d2b1 100644 --- a/packages/host/tests/acceptance/code-submode/recent-files-test.ts +++ b/packages/host/tests/acceptance/code-submode/recent-files-test.ts @@ -210,7 +210,7 @@ module('Acceptance | code submode | recent files tests', function (hooks) { attributes: { title: 'Person', description: 'Catalog entry', - isField: false, + specType: 'card', ref: { module: `./person`, name: 'Person', diff --git a/packages/host/tests/acceptance/code-submode/schema-editor-test.ts b/packages/host/tests/acceptance/code-submode/schema-editor-test.ts index 153d9dc75f..2eaf567244 100644 --- a/packages/host/tests/acceptance/code-submode/schema-editor-test.ts +++ b/packages/host/tests/acceptance/code-submode/schema-editor-test.ts @@ -255,7 +255,7 @@ module('Acceptance | code submode | schema editor tests', function (hooks) { attributes: { title: 'Person', description: 'Catalog entry', - isField: false, + specType: 'card', ref: { module: `./person`, name: 'Person', diff --git a/packages/host/tests/acceptance/interact-submode-test.gts b/packages/host/tests/acceptance/interact-submode-test.gts index 9dc4e9bed3..a5e1547e43 100644 --- a/packages/host/tests/acceptance/interact-submode-test.gts +++ b/packages/host/tests/acceptance/interact-submode-test.gts @@ -256,7 +256,7 @@ module('Acceptance | interact submode tests', function (hooks) { [`${fileName}.json`]: new CatalogEntry({ title, description: `Catalog entry for ${title}`, - isField: false, + specType: 'card', ref, }), }); @@ -280,18 +280,18 @@ module('Acceptance | interact submode tests', function (hooks) { 'shipping-info.gts': { ShippingInfo }, 'README.txt': `Hello World`, 'person-entry.json': new CatalogEntry({ - title: 'Person Card', + name: 'Person Card', description: 'Catalog entry for Person Card', - isField: false, + specType: 'card', ref: { module: `${testRealmURL}person`, name: 'Person', }, }), 'pet-entry.json': new CatalogEntry({ - title: 'Pet Card', + name: 'Pet Card', description: 'Catalog entry for Pet Card', - isField: false, + specType: 'card', ref: { module: `${testRealmURL}pet`, name: 'Pet', @@ -299,9 +299,9 @@ module('Acceptance | interact submode tests', function (hooks) { }), ...catalogEntries, 'puppy-entry.json': new CatalogEntry({ - title: 'Puppy Card', + name: 'Puppy Card', description: 'Catalog entry for Puppy Card', - isField: false, + specType: 'card', ref: { module: `${testRealmURL}pet`, name: 'Puppy', @@ -454,7 +454,7 @@ module('Acceptance | interact submode tests', function (hooks) { assert .dom(`[data-test-stack-card="${testRealmURL}person-entry"]`) - .containsText('Test Workspace B'); + .containsText('http://test-realm/test/person'); // Close the card, find it in recent cards, and reopen it await click( diff --git a/packages/host/tests/acceptance/operator-mode-acceptance-test.gts b/packages/host/tests/acceptance/operator-mode-acceptance-test.gts index 54875f7fca..4d8f899d19 100644 --- a/packages/host/tests/acceptance/operator-mode-acceptance-test.gts +++ b/packages/host/tests/acceptance/operator-mode-acceptance-test.gts @@ -301,7 +301,7 @@ module('Acceptance | operator mode tests', function (hooks) { attributes: { title: 'Person Card', description: 'Catalog entry for Person Card', - isField: false, + specType: 'card', ref: { module: `${testRealmURL}person`, name: 'Person', @@ -1186,5 +1186,26 @@ module('Acceptance | operator mode tests', function (hooks) { .hasNoClass('out-of-credit'); assert.dom('[data-test-buy-more-credits]').hasNoClass('out-of-credit'); }); + + test(`ai panel continues being open when switching to code submode`, async function (assert) { + await visitOperatorMode({ + stacks: [ + [ + { + id: `${testRealmURL}Person/fadhlan`, + format: 'isolated', + }, + ], + ], + }); + + await click('[data-test-open-ai-assistant]'); + assert.dom('[data-test-ai-assistant-panel]').exists(); + await click('[data-test-submode-switcher] button'); + await click('[data-test-boxel-menu-item-text="Code"]'); + assert.dom('[data-test-ai-assistant-panel]').exists(); + await click('[data-test-open-ai-assistant]'); + assert.dom('[data-test-ai-assistant-panel]').doesNotExist(); + }); }); }); diff --git a/packages/host/tests/acceptance/permissioned-realm-test.gts b/packages/host/tests/acceptance/permissioned-realm-test.gts index e4cfd82ccc..41a58dde50 100644 --- a/packages/host/tests/acceptance/permissioned-realm-test.gts +++ b/packages/host/tests/acceptance/permissioned-realm-test.gts @@ -78,9 +78,9 @@ module('Acceptance | permissioned realm tests', function (hooks) { 'index.gts': { Index }, 'person.gts': { Person }, 'person-entry.json': new CatalogEntry({ - title: 'Person', + name: 'Person', description: 'Catalog entry', - isField: false, + specType: 'card', ref: { module: `./person`, name: 'Person', diff --git a/packages/host/tests/helpers/mock-matrix/_client.ts b/packages/host/tests/helpers/mock-matrix/_client.ts index 68b6b304a5..0651209010 100644 --- a/packages/host/tests/helpers/mock-matrix/_client.ts +++ b/packages/host/tests/helpers/mock-matrix/_client.ts @@ -90,6 +90,7 @@ export class MockClient implements ExtendedClient { exp: expires, user: this.loggedInAs, realm: realmURL.href, + sessionRoom: `test-session-room-for-${this.loggedInAs}`, // adding a nonce to the test token so that we can tell the difference // between different tokens created in the same second nonce: nonce++, diff --git a/packages/host/tests/integration/components/card-basics-test.gts b/packages/host/tests/integration/components/card-basics-test.gts index 778a578ee9..559bd6ac9a 100644 --- a/packages/host/tests/integration/components/card-basics-test.gts +++ b/packages/host/tests/integration/components/card-basics-test.gts @@ -1421,9 +1421,7 @@ module('Integration | card-basics', function (hooks) { person.firstName = ''; person.lastName = ''; await renderCard(loader, person, 'atom'); - assert - .dom('[data-test-compound-field-component]') - .hasText('Untitled Person'); + assert.dom('[data-test-compound-field-component]').hasNoText(); }); test('render user-provided atom view template', async function (assert) { @@ -1501,6 +1499,47 @@ module('Integration | card-basics', function (hooks) { ); }); + test('can render empty linksTo and linksToMany fields in default atom format', async function (assert) { + class Pet extends CardDef { + @field firstName = contains(StringField); + @field title = contains(StringField, { + computeVia: function (this: Pet) { + return this.firstName; + }, + }); + } + class Person extends CardDef { + @field firstName = contains(StringField); + @field pet = linksTo(Pet); + @field pets = linksToMany(Pet); + static isolated = class Isolated extends Component { + + }; + } + let hassan = new Person({ firstName: 'Hassan' }); + await renderCard(loader, hassan, 'isolated'); + + assert.dom('[data-test-person]').hasText('Hassan'); + assert.dom('[data-test-pet]').hasText('Pet:'); + assert.dom('[data-test-pet] span').hasClass('empty-field'); + assert.dom('[data-test-pets]').hasText('Pets:'); + assert + .dom( + '[data-test-pets] > [data-test-plural-view="linksToMany"][data-test-plural-view-format="atom"]', + ) + .hasClass('empty'); + }); + test('render a containsMany composite field', async function (this: RenderingTestContext, assert) { class Person extends FieldDef { @field firstName = contains(StringField); diff --git a/packages/host/tests/integration/components/card-catalog-test.gts b/packages/host/tests/integration/components/card-catalog-test.gts index a839ae6095..bce0dd71b7 100644 --- a/packages/host/tests/integration/components/card-catalog-test.gts +++ b/packages/host/tests/integration/components/card-catalog-test.gts @@ -116,63 +116,63 @@ module('Integration | card-catalog', function (hooks) { '.realm.json': `{ "name": "${realmName}", "iconURL": "https://example-icon.test" }`, 'index.json': new CardsGrid(), 'CatalogEntry/publishing-packet.json': new CatalogEntry({ - title: 'Publishing Packet', + name: 'Publishing Packet', description: 'Catalog entry for PublishingPacket', - isField: false, + specType: 'card', ref: { module: `../publishing-packet`, name: 'PublishingPacket', }, }), 'CatalogEntry/author.json': new CatalogEntry({ - title: 'Author', + name: 'Author', description: 'Catalog entry for Author', - isField: false, + specType: 'card', ref: { module: `${testRealmURL}author`, name: 'Author', }, }), 'CatalogEntry/person.json': new CatalogEntry({ - title: 'Person', + name: 'Person', description: 'Catalog entry for Person', - isField: false, + specType: 'card', ref: { module: `${testRealmURL}person`, name: 'Person', }, }), 'CatalogEntry/pet.json': new CatalogEntry({ - title: 'Pet', + name: 'Pet', description: 'Catalog entry for Pet', - isField: false, + specType: 'card', ref: { module: `${testRealmURL}pet`, name: 'Pet', }, }), 'CatalogEntry/tree.json': new CatalogEntry({ - title: 'Tree', + name: 'Tree', description: 'Catalog entry for Tree', - isField: false, + specType: 'card', ref: { module: `${testRealmURL}tree`, name: 'Tree', }, }), 'CatalogEntry/blog-post.json': new CatalogEntry({ - title: 'BlogPost', + name: 'BlogPost', description: 'Catalog entry for BlogPost', - isField: false, + specType: 'card', ref: { module: `${testRealmURL}blog-post`, name: 'BlogPost', }, }), 'CatalogEntry/address.json': new CatalogEntry({ - title: 'Address', + name: 'Address', description: 'Catalog entry for Address field', - isField: true, + specType: 'field', ref: { module: `${testRealmURL}address`, name: 'Address', diff --git a/packages/host/tests/integration/components/operator-mode-test.gts b/packages/host/tests/integration/components/operator-mode-test.gts index 241b38bcca..cf1f6190b2 100644 --- a/packages/host/tests/integration/components/operator-mode-test.gts +++ b/packages/host/tests/integration/components/operator-mode-test.gts @@ -471,31 +471,31 @@ module('Integration | operator-mode', function (hooks) { } as LooseSingleCardDocument, 'grid.json': new CardsGrid(), 'CatalogEntry/publishing-packet.json': new CatalogEntry({ - title: 'Publishing Packet', + name: 'Publishing Packet', description: 'Catalog entry for PublishingPacket', - isField: false, + specType: 'card', ref: { module: `${testRealmURL}publishing-packet`, name: 'PublishingPacket', }, }), 'CatalogEntry/pet-room.json': new CatalogEntry({ - title: 'General Pet Room', + name: 'General Pet Room', description: 'Catalog entry for Pet Room Card', - isField: false, + specType: 'card', ref: { module: `${testRealmURL}pet-room`, name: 'PetRoom', }, }), 'CatalogEntry/pet-card.json': new CatalogEntry({ - title: 'Pet', + name: 'Pet', description: 'Catalog entry for Pet', + specType: 'card', ref: { module: `${testRealmURL}pet`, name: 'Pet', }, - isField: false, }), 'Author/1.json': author1, 'Author/2.json': new Author({ firstName: 'R2-D2' }), diff --git a/packages/host/tests/integration/components/prerendered-card-search-test.gts b/packages/host/tests/integration/components/prerendered-card-search-test.gts index d5a89589bc..f2ee6a55f1 100644 --- a/packages/host/tests/integration/components/prerendered-card-search-test.gts +++ b/packages/host/tests/integration/components/prerendered-card-search-test.gts @@ -259,7 +259,7 @@ module(`Integration | prerendered-card-search`, function (hooks) { attributes: { title: 'Post', description: 'A card that represents a blog post', - isField: false, + specType: 'card', ref: { module: `${testRealmURL}post`, name: 'Post', @@ -279,7 +279,7 @@ module(`Integration | prerendered-card-search`, function (hooks) { attributes: { title: 'Article', description: 'A card that represents an online article ', - isField: false, + specType: 'card', ref: { module: `${testRealmURL}article`, name: 'Article', diff --git a/packages/host/tests/integration/realm-indexing-and-querying-test.gts b/packages/host/tests/integration/realm-indexing-and-querying-test.gts index 7cba0ce45f..a76b5efe9d 100644 --- a/packages/host/tests/integration/realm-indexing-and-querying-test.gts +++ b/packages/host/tests/integration/realm-indexing-and-querying-test.gts @@ -617,7 +617,7 @@ module(`Integration | realm indexing and querying`, function (hooks) { attributes: { title: 'Person Card', description: 'Catalog entry for Person card', - isField: false, + specType: 'card', ref: { module: './person', name: 'Person', @@ -648,13 +648,24 @@ module(`Integration | realm indexing and querying`, function (hooks) { title: 'Person Card', description: 'Catalog entry for Person card', moduleHref: `${testRealmURL}person`, - realmName: 'Unnamed Workspace', + name: null, + readMe: null, + specType: 'card', + isCard: true, isField: false, + thumbnailURL: null, ref: { module: `./person`, name: 'Person', }, - demo: {}, + containedExamples: [], + }, + relationships: { + linkedExamples: { + links: { + self: null, + }, + }, }, meta: { adoptsFrom: { @@ -676,14 +687,16 @@ module(`Integration | realm indexing and querying`, function (hooks) { ); assert.deepEqual(instance?.searchDoc, { _cardType: 'Catalog Entry', - demo: {}, description: 'Catalog entry for Person card', id: `${testRealmURL}person-catalog-entry`, - isField: false, + specType: 'card', moduleHref: `${testRealmURL}person`, - realmName: 'Unnamed Workspace', ref: `${testRealmURL}person/Person`, title: 'Person Card', + linkedExamples: null, + containedExamples: null, + isCard: true, + isField: false, }); } else { assert.ok( @@ -1589,7 +1602,6 @@ module(`Integration | realm indexing and querying`, function (hooks) { }, ); if (vendor?.type === 'doc') { - console.log(vendor.doc); assert.deepEqual(vendor.doc, { data: { id: `${testRealmURL}Vendor/vendor1`, @@ -1780,7 +1792,7 @@ module(`Integration | realm indexing and querying`, function (hooks) { } }); - test(`search doc includes 'contains' and used 'linksTo' fields`, async function (assert) { + test(`search doc includes 'contains' and used 'linksTo' fields, including contained computed fields`, async function (assert) { let { realm } = await setupIntegrationTestRealm({ loader, contents: { @@ -1832,9 +1844,11 @@ module(`Integration | realm indexing and querying`, function (hooks) { email: 'hassan@cardstack.com', posts: 100, title: 'Hassan Abdel-Rahman', + description: 'Person', + fullName: 'Hassan Abdel-Rahman', _cardType: 'Person', }, - `search doc does not include fullName field`, + `search doc includes fullName field`, ); }); @@ -1906,7 +1920,9 @@ module(`Integration | realm indexing and querying`, function (hooks) { { _cardType: 'Post', author: { + description: 'Person', fullName: ' ', + title: ' ', }, id: `${testRealmURL}Post/1`, title: '50 Ways to Leave Your Laptop', @@ -1991,29 +2007,13 @@ module(`Integration | realm indexing and querying`, function (hooks) { attributes: { title: 'Booking', description: 'Catalog entry for Booking', - isField: false, + specType: 'card', ref: { module: 'http://localhost:4202/test/booking', name: 'Booking', }, - demo: { - title: null, - venue: null, - startTime: null, - endTime: null, - hosts: [], - sponsors: [], - }, }, meta: { - fields: { - demo: { - adoptsFrom: { - module: '../booking', - name: 'Booking', - }, - }, - }, adoptsFrom: { module: 'https://cardstack.com/base/catalog-entry', name: 'CatalogEntry', @@ -2030,18 +2030,15 @@ module(`Integration | realm indexing and querying`, function (hooks) { assert.deepEqual(entry?.searchDoc, { _cardType: 'Catalog Entry', id: `${testRealmURL}CatalogEntry/booking`, - demo: { - hosts: null, - sponsors: null, - title: null, - venue: null, - }, description: 'Catalog entry for Booking', - isField: false, + specType: 'card', moduleHref: 'http://localhost:4202/test/booking', - realmName: 'Unnamed Workspace', + linkedExamples: null, + containedExamples: null, ref: 'http://localhost:4202/test/booking/Booking', title: 'Booking', + isCard: true, + isField: false, }); // we should be able to perform a structured clone of the search doc (this // emulates the limitations of the postMessage used to communicate between @@ -2347,30 +2344,14 @@ module(`Integration | realm indexing and querying`, function (hooks) { attributes: { title: 'PetPerson', description: 'Catalog entry for PetPerson', - isField: false, + specType: 'card', ref: { module: `${testModuleRealm}pet-person`, name: 'PetPerson', }, - demo: { firstName: 'Hassan' }, - }, - relationships: { - 'demo.pets.0': { - links: { self: `${testRealmURL}Pet/mango` }, - }, - 'demo.pets.1': { - links: { self: `${testRealmURL}Pet/vanGogh` }, - }, }, + relationships: {}, meta: { - fields: { - demo: { - adoptsFrom: { - module: `${testModuleRealm}pet-person`, - name: 'PetPersonField', - }, - }, - }, adoptsFrom: { module: 'https://cardstack.com/base/catalog-entry', name: 'CatalogEntry', @@ -2406,24 +2387,24 @@ module(`Integration | realm indexing and querying`, function (hooks) { attributes: { title: 'PetPerson', description: 'Catalog entry for PetPerson', + readMe: null, + thumbnailURL: null, ref: { module: `${testModuleRealm}pet-person`, name: 'PetPerson', }, - demo: { firstName: 'Hassan' }, - isField: false, + specType: 'card', moduleHref: `${testModuleRealm}pet-person`, - realmName: 'Unnamed Workspace', + name: null, + containedExamples: [], + isCard: true, + isField: false, }, relationships: { - 'demo.friend': { links: { self: null } }, - 'demo.pets.0': { - links: { self: `${testRealmURL}Pet/mango` }, - data: { id: `${testRealmURL}Pet/mango`, type: 'card' }, - }, - 'demo.pets.1': { - links: { self: `${testRealmURL}Pet/vanGogh` }, - data: { id: `${testRealmURL}Pet/vanGogh`, type: 'card' }, + linkedExamples: { + links: { + self: null, + }, }, }, meta: { @@ -2431,14 +2412,6 @@ module(`Integration | realm indexing and querying`, function (hooks) { module: 'https://cardstack.com/base/catalog-entry', name: 'CatalogEntry', }, - fields: { - demo: { - adoptsFrom: { - module: `${testModuleRealm}pet-person`, - name: 'PetPersonField', - }, - }, - }, lastModified: adapter.lastModifiedMap.get( `${testRealmURL}pet-person-catalog-entry.json`, ), @@ -2449,55 +2422,6 @@ module(`Integration | realm indexing and querying`, function (hooks) { ), }, }); - - assert.deepEqual(catalogEntry.doc.included, [ - { - id: `${testRealmURL}Pet/mango`, - type: 'card', - links: { self: `${testRealmURL}Pet/mango` }, - attributes: { - description: null, - firstName: 'Mango', - title: 'Mango', - thumbnailURL: null, - }, - relationships: { owner: { links: { self: null } } }, - meta: { - adoptsFrom: { module: `${testModuleRealm}pet`, name: 'Pet' }, - lastModified: adapter.lastModifiedMap.get( - `${testRealmURL}Pet/mango.json`, - ), - realmInfo: testRealmInfo, - realmURL: 'http://test-realm/test/', - resourceCreatedAt: adapter.resourceCreatedAtMap.get( - `${testRealmURL}pet-person-catalog-entry.json`, - ), - }, - }, - { - id: `${testRealmURL}Pet/vanGogh`, - type: 'card', - links: { self: `${testRealmURL}Pet/vanGogh` }, - attributes: { - description: null, - firstName: 'Van Gogh', - title: 'Van Gogh', - thumbnailURL: null, - }, - relationships: { owner: { links: { self: null } } }, - meta: { - adoptsFrom: { module: `${testModuleRealm}pet`, name: 'Pet' }, - lastModified: adapter.lastModifiedMap.get( - `${testRealmURL}Pet/vanGogh.json`, - ), - realmInfo: testRealmInfo, - realmURL: 'http://test-realm/test/', - resourceCreatedAt: adapter.resourceCreatedAtMap.get( - `${testRealmURL}pet-person-catalog-entry.json`, - ), - }, - }, - ]); } else { assert.ok( false, @@ -2515,32 +2439,13 @@ module(`Integration | realm indexing and querying`, function (hooks) { id: `${testRealmURL}pet-person-catalog-entry`, title: 'PetPerson', description: 'Catalog entry for PetPerson', + linkedExamples: null, + containedExamples: null, + moduleHref: `${testModuleRealm}pet-person`, ref: `${testModuleRealm}pet-person/PetPerson`, - demo: { - firstName: 'Hassan', - pets: [ - { - id: `${testRealmURL}Pet/mango`, - description: null, - firstName: 'Mango', - owner: null, - title: 'Mango', - thumbnailURL: null, - }, - { - id: `${testRealmURL}Pet/vanGogh`, - description: null, - firstName: 'Van Gogh', - owner: null, - title: 'Van Gogh', - thumbnailURL: null, - }, - ], - friend: null, - }, + specType: 'card', + isCard: true, isField: false, - moduleHref: `${testModuleRealm}pet-person`, - realmName: 'Unnamed Workspace', }); } else { assert.ok( @@ -3402,6 +3307,7 @@ module(`Integration | realm indexing and querying`, function (hooks) { { id: vanGoghID, firstName: 'Van Gogh', + title: 'Van Gogh', friends: [ { id: hassanID, @@ -3909,7 +3815,7 @@ posts/ignore-me.json attributes: { title: 'Post', description: 'A card that represents a blog post', - isField: false, + specType: 'card', ref: { module: `${testModuleRealm}post`, name: 'Post', @@ -3929,7 +3835,7 @@ posts/ignore-me.json attributes: { title: 'Article', description: 'A card that represents an online article ', - isField: false, + specType: 'card', ref: { module: `${testModuleRealm}article`, name: 'Article', diff --git a/packages/host/tests/integration/realm-test.ts b/packages/host/tests/integration/realm-test.ts index 8dd14d2355..38ec23a90e 100644 --- a/packages/host/tests/integration/realm-test.ts +++ b/packages/host/tests/integration/realm-test.ts @@ -223,12 +223,14 @@ module('Integration | realm', function (hooks) { type: 'card', id: `${testRealmURL}dir/owner`, attributes: { + description: 'Person', email: null, posts: null, thumbnailURL: null, firstName: 'Hassan', lastName: 'Abdel-Rahman', title: 'Hassan Abdel-Rahman', + fullName: 'Hassan Abdel-Rahman', }, meta: { adoptsFrom: { @@ -339,11 +341,13 @@ module('Integration | realm', function (hooks) { type: 'card', id: `http://localhost:4202/test/hassan`, attributes: { + description: 'Person', email: null, posts: null, thumbnailURL: null, firstName: 'Hassan', lastName: 'Abdel-Rahman', + fullName: 'Hassan Abdel-Rahman', title: 'Hassan Abdel-Rahman', }, meta: { @@ -592,12 +596,14 @@ module('Integration | realm', function (hooks) { type: 'card', id: `${testRealmURL}dir/owner`, attributes: { + description: 'Person', email: null, posts: null, thumbnailURL: null, firstName: 'Hassan', lastName: 'Abdel-Rahman', title: 'Hassan Abdel-Rahman', + fullName: 'Hassan Abdel-Rahman', }, meta: { adoptsFrom: { @@ -951,8 +957,10 @@ module('Integration | realm', function (hooks) { endTime: '2023-02-19T02:00:00.000Z', hosts: [ { + description: 'Person', firstName: 'Hassan', lastName: null, + fullName: 'Hassan ', title: 'Hassan ', email: null, posts: null, @@ -1157,11 +1165,13 @@ module('Integration | realm', function (hooks) { id: `${testRealmURL}dir/friend`, links: { self: `${testRealmURL}dir/friend` }, attributes: { + description: 'Person', email: null, posts: null, thumbnailURL: null, firstName: 'Hassan', lastName: 'Abdel-Rahman', + fullName: 'Hassan Abdel-Rahman', title: 'Hassan Abdel-Rahman', }, meta: { @@ -2011,7 +2021,9 @@ module('Integration | realm', function (hooks) { attributes: { firstName: 'Mariko', lastName: 'Abdel-Rahman', + fullName: 'Mariko Abdel-Rahman', title: 'Mariko Abdel-Rahman', + description: 'Person', email: null, posts: null, thumbnailURL: null, @@ -2980,7 +2992,9 @@ module('Integration | realm', function (hooks) { attributes: { firstName: 'Mariko', lastName: 'Abdel-Rahman', + fullName: 'Mariko Abdel-Rahman', title: 'Mariko Abdel-Rahman', + description: 'Person', email: null, posts: null, thumbnailURL: null, @@ -3047,12 +3061,14 @@ module('Integration | realm', function (hooks) { type: 'card', id: `http://localhost:4202/test/hassan`, attributes: { + description: 'Person', email: null, posts: null, thumbnailURL: null, firstName: 'Hassan', lastName: 'Abdel-Rahman', title: 'Hassan Abdel-Rahman', + fullName: 'Hassan Abdel-Rahman', }, meta: { adoptsFrom: { diff --git a/packages/host/tests/integration/resources/search-test.ts b/packages/host/tests/integration/resources/search-test.ts index f3dcc12505..3b72445acd 100644 --- a/packages/host/tests/integration/resources/search-test.ts +++ b/packages/host/tests/integration/resources/search-test.ts @@ -229,7 +229,7 @@ module(`Integration | search resource`, function (hooks) { attributes: { title: 'Post', description: 'A card that represents a blog post', - isField: false, + specType: 'card', ref: { module: `${testRealmURL}post`, name: 'Post', @@ -249,7 +249,7 @@ module(`Integration | search resource`, function (hooks) { attributes: { title: 'Article', description: 'A card that represents an online article ', - isField: false, + specType: 'card', ref: { module: `${testRealmURL}article`, name: 'Article', diff --git a/packages/host/tests/unit/index-writer-test.ts b/packages/host/tests/unit/index-writer-test.ts index e893a8b3ff..8c304cabbf 100644 --- a/packages/host/tests/unit/index-writer-test.ts +++ b/packages/host/tests/unit/index-writer-test.ts @@ -56,6 +56,22 @@ module('Unit | index-writer', function (hooks) { }); }); + test('can copy index entries', async function (assert) { + await runSharedTest(indexWriterTests, assert, { + indexWriter, + indexQueryEngine, + adapter, + }); + }); + + test('throws when copy source realm is not present on the realm server', async function (assert) { + await runSharedTest(indexWriterTests, assert, { + indexWriter, + indexQueryEngine, + adapter, + }); + }); + test('error entry includes last known good state when available', async function (assert) { await runSharedTest(indexWriterTests, assert, { indexWriter, diff --git a/packages/matrix/helpers/index.ts b/packages/matrix/helpers/index.ts index 7032e77e9f..893488b930 100644 --- a/packages/matrix/helpers/index.ts +++ b/packages/matrix/helpers/index.ts @@ -89,10 +89,14 @@ export async function createRealm( page: Page, endpoint: string, name = endpoint, + copyFromSeed = true, ) { await page.locator('[data-test-add-workspace]').click(); await page.locator('[data-test-display-name-field]').fill(name); await page.locator('[data-test-endpoint-field]').fill(endpoint); + if (!copyFromSeed) { + await page.locator('[data-test-copy-from-seed-field]').click(); + } await page.locator('[data-test-create-workspace-submit]').click(); await expect(page.locator(`[data-test-workspace="${name}"]`)).toBeVisible(); await expect(page.locator('[data-test-create-workspace-modal]')).toHaveCount( diff --git a/packages/matrix/helpers/isolated-realm-server.ts b/packages/matrix/helpers/isolated-realm-server.ts index 27466c8788..dad478f275 100644 --- a/packages/matrix/helpers/isolated-realm-server.ts +++ b/packages/matrix/helpers/isolated-realm-server.ts @@ -30,6 +30,7 @@ export async function startServer() { process.env.REALM_SECRET_SEED = "shhh! it's a secret"; process.env.MATRIX_URL = 'http://localhost:8008'; process.env.REALM_SERVER_MATRIX_USERNAME = 'realm_server'; + process.env.NODE_ENV = 'test'; let workerManager = spawn( 'ts-node', @@ -42,6 +43,8 @@ export async function startServer() { `--fromUrl='http://localhost:4205/test/'`, `--toUrl='http://localhost:4205/test/'`, + `--fromUrl='http://localhost:4205/seed/'`, + `--toUrl='http://localhost:4205/seed/'`, `--fromUrl='https://cardstack.com/base/'`, `--toUrl='http://localhost:4201/base/'`, ], @@ -70,6 +73,7 @@ export async function startServer() { `--matrixURL='http://localhost:8008'`, `--realmsRootPath='${dir.name}'`, `--seedPath='${seedPath}'`, + `--seedRealmURL='http://localhost:4205/seed/'`, `--workerManagerPort=4212`, `--migrateDB`, `--useRegistrationSecretFunction`, @@ -78,6 +82,12 @@ export async function startServer() { `--username='test_realm'`, `--fromUrl='http://localhost:4205/test/'`, `--toUrl='http://localhost:4205/test/'`, + + `--path='${seedPath}'`, + `--username='seed_realm'`, + `--fromUrl='http://localhost:4205/seed/'`, + `--toUrl='http://localhost:4205/seed/'`, + `--fromUrl='https://cardstack.com/base/'`, `--toUrl='http://localhost:4201/base/'`, ], diff --git a/packages/matrix/tests/create-realm.spec.ts b/packages/matrix/tests/create-realm.spec.ts index 28c8960e0e..9bb05bc135 100644 --- a/packages/matrix/tests/create-realm.spec.ts +++ b/packages/matrix/tests/create-realm.spec.ts @@ -58,6 +58,45 @@ test.describe('Create Realm via Dashboard', () => { await expect( page.locator(`[data-test-stack-card="${newRealmURL}index"]`), ).toBeVisible(); + + const filterListElements = page.locator('[data-test-boxel-filter-list-button]'); + const count = await filterListElements.count(); + expect(count).toBeGreaterThan(1); + + await page.locator(`[data-test-workspace-chooser-toggle]`).click(); + await expect( + page.locator( + `[data-test-workspace="1New Workspace"] [data-test-realm-icon-url]`, + ), + 'the "N" icon URL is shown', + ).toHaveAttribute( + 'style', + 'background-image: url("https://boxel-images.boxel.ai/icons/Letter-n.png");', + ); + }); + + test('it can create a new realm without copying from seed realm', async ({ + page, + }) => { + let serverIndexUrl = new URL(appURL).origin; + await clearLocalStorage(page, serverIndexUrl); + + await setupUserSubscribed('@user1:localhost', realmServer); + + await login(page, 'user1', 'pass', { + url: serverIndexUrl, + skipOpeningAssistant: true, + }); + + await createRealm(page, 'new-workspace', '1New Workspace', false); + await page.locator('[data-test-workspace="1New Workspace"]').click(); + let newRealmURL = new URL('user1/new-workspace/', serverIndexUrl).href; + await expect( + page.locator(`[data-test-stack-card="${newRealmURL}index"]`), + ).toBeVisible(); + await expect( + page.locator(`[data-test-boxel-filter-list-button]`), + ).toHaveCount(1); await page.locator(`[data-test-workspace-chooser-toggle]`).click(); await expect( diff --git a/packages/postgres/migrations/1737555650787_add-matrix-seed-realm-permissions.js b/packages/postgres/migrations/1737555650787_add-matrix-seed-realm-permissions.js new file mode 100644 index 0000000000..89deb49942 --- /dev/null +++ b/packages/postgres/migrations/1737555650787_add-matrix-seed-realm-permissions.js @@ -0,0 +1,22 @@ +exports.up = (pgm) => { + pgm.sql( + `INSERT INTO realm_user_permissions (realm_url, username, read, write, realm_owner) + VALUES + ('http://localhost:4205/seed/', '@seed_realm:localhost', true, true, true), + ('http://localhost:4205/seed/', '*', true, false, false), + ('http://localhost:4205/seed/', 'users', true, true, false) + ON CONFLICT ON CONSTRAINT realm_user_permissions_pkey + DO UPDATE SET + realm_url = EXCLUDED.realm_url, + username = EXCLUDED.username, + read = EXCLUDED.read, + write = EXCLUDED.write, + realm_owner = EXCLUDED.realm_owner`, + ); +}; + +exports.down = (pgm) => { + pgm.sql( + `DELETE FROM realm_user_permissions WHERE realm_url = 'http://localhost:4205/seed/'`, + ); +}; diff --git a/packages/postgres/pg-queue.ts b/packages/postgres/pg-queue.ts index b8ed2232be..681aaa612f 100644 --- a/packages/postgres/pg-queue.ts +++ b/packages/postgres/pg-queue.ts @@ -93,7 +93,7 @@ class WorkLoop { return; } let timerPromise = new Promise((resolve) => { - this.timeout = setTimeout(resolve, this.pollInterval); + this.timeout = setTimeout(resolve, this.pollInterval).unref(); }); log.debug(`[workloop %s] entering promise race`, this.label); await Promise.race([this.waker.promise, timerPromise]); @@ -354,7 +354,7 @@ export class PgQueueRunner implements QueueRunner { new Promise<'timeout'>((r) => setTimeout(() => { r('timeout'); - }, this.#maxTimeoutSec * 1000), + }, this.#maxTimeoutSec * 1000).unref(), ), ]); if (result === 'timeout') { diff --git a/packages/realm-server/handlers/handle-create-realm.ts b/packages/realm-server/handlers/handle-create-realm.ts index 306eef6f9a..7b8d0bd98e 100644 --- a/packages/realm-server/handlers/handle-create-realm.ts +++ b/packages/realm-server/handlers/handle-create-realm.ts @@ -24,6 +24,7 @@ interface RealmCreationJSON { name: string; backgroundURL?: string; iconURL?: string; + copyFromSeedRealm?: boolean; }; }; } diff --git a/packages/realm-server/main.ts b/packages/realm-server/main.ts index f253e7cacb..31f3fb8d92 100644 --- a/packages/realm-server/main.ts +++ b/packages/realm-server/main.ts @@ -11,7 +11,6 @@ import { NodeAdapter } from './node-realm'; import yargs from 'yargs'; import { RealmServer } from './server'; import { resolve } from 'path'; -import { createConnection, type Socket } from 'net'; import { makeFastBootIndexRunner } from './fastboot'; import { shimExternals } from './lib/externals'; import * as Sentry from '@sentry/node'; @@ -20,6 +19,9 @@ import { MatrixClient } from '@cardstack/runtime-common/matrix-client'; import 'decorator-transforms/globals'; let log = logger('main'); +if (process.env.NODE_ENV === 'test') { + (globalThis as any).__environment = 'test'; +} const REALM_SECRET_SEED = process.env.REALM_SECRET_SEED; if (!REALM_SECRET_SEED) { @@ -66,6 +68,7 @@ let { username: usernames, useRegistrationSecretFunction, seedPath, + seedRealmURL, migrateDB, workerManagerPort, } = yargs(process.argv.slice(2)) @@ -110,6 +113,10 @@ let { 'the path of the seed realm which is used to seed new realms', type: 'string', }, + seedRealmURL: { + description: 'The URL of the seed realm', + type: 'string', + }, matrixURL: { description: 'The matrix homeserver for the realm', demandOption: true, @@ -253,6 +260,7 @@ let autoMigrate = migrateDB || undefined; getIndexHTML, serverURL: new URL(serverURL), seedPath, + seedRealmURL: seedRealmURL ? new URL(seedRealmURL) : undefined, matrixRegistrationSecret: MATRIX_REGISTRATION_SHARED_SECRET, getRegistrationSecret: useRegistrationSecretFunction ? getRegistrationSecret @@ -331,49 +339,20 @@ let autoMigrate = migrateDB || undefined; process.exit(-3); }); -let workerReadyDeferred: Deferred | undefined; async function waitForWorkerManager(port: number) { - const workerManager = await new Promise((r) => { - let socket = createConnection({ port }, () => { - log.info(`Connected to worker manager on port ${port}`); - r(socket); - }); - }); - - workerManager.on('data', (data) => { - let res = data.toString(); - if (!workerReadyDeferred) { - throw new Error( - `received unsolicited message from worker manager on port ${port}`, - ); + let isReady = false; + let timeout = Date.now() + 30_000; + do { + let response = await fetch(`http://localhost:${port}/`); + if (response.ok) { + let json = await response.json(); + isReady = json.ready; } - switch (res) { - case 'ready': - case 'not-ready': - workerReadyDeferred.fulfill(res === 'ready' ? true : false); - break; - default: - workerReadyDeferred.reject( - `unexpected response from worker manager: ${res}`, - ); - } - }); - - try { - let isReady = false; - let timeout = Date.now() + 30_000; - do { - workerReadyDeferred = new Deferred(); - workerManager.write('ready?'); - isReady = await workerReadyDeferred.promise; - } while (!isReady && Date.now() < timeout); - if (!isReady) { - throw new Error( - `timed out trying to connect to worker manager on port ${port}`, - ); - } - } finally { - workerManager.end(); + } while (!isReady && Date.now() < timeout); + if (!isReady) { + throw new Error( + `timed out trying to waiting for worker manager to be ready on port ${port}`, + ); } log.info('workers are ready'); } diff --git a/packages/realm-server/routes.ts b/packages/realm-server/routes.ts index 75bda4ed9c..e5d82e198e 100644 --- a/packages/realm-server/routes.ts +++ b/packages/realm-server/routes.ts @@ -23,12 +23,14 @@ export type CreateRoutesArgs = { name, backgroundURL, iconURL, + copyFromSeedRealm, }: { ownerUserId: string; endpoint: string; name: string; backgroundURL?: string; iconURL?: string; + copyFromSeedRealm?: boolean; }) => Promise; serveIndex: (ctxt: Koa.Context, next: Koa.Next) => Promise; serveFromRealm: (ctxt: Koa.Context, next: Koa.Next) => Promise; diff --git a/packages/realm-server/scripts/remove-test-dbs.sh b/packages/realm-server/scripts/remove-test-dbs.sh index 77a21f9553..43028801ab 100755 --- a/packages/realm-server/scripts/remove-test-dbs.sh +++ b/packages/realm-server/scripts/remove-test-dbs.sh @@ -5,6 +5,10 @@ isolated_realm_processes=$(ps -ef | grep ts-node | grep '\-\-port=4205' | awk '{ for pid in $isolated_realm_processes; do kill -9 $pid done +isolated_realm_processes=$(ps -ef | grep ts-node | grep '\-\-port=4212' | awk '{print $2}') +for pid in $isolated_realm_processes; do + kill -9 $pid +done databases=$(docker exec boxel-pg psql -U postgres -w -lqt | cut -d \| -f 1 | grep -E 'test_db_' | tr -d ' ') echo "cleaning up old test databases..." diff --git a/packages/realm-server/scripts/start-development.sh b/packages/realm-server/scripts/start-development.sh index f9704ea706..cf250991bd 100755 --- a/packages/realm-server/scripts/start-development.sh +++ b/packages/realm-server/scripts/start-development.sh @@ -23,6 +23,7 @@ NODE_ENV=development \ --matrixURL='http://localhost:8008' \ --realmsRootPath='./realms/localhost_4201' \ --seedPath='../seed-realm' \ + --seedRealmURL='http://localhost:4201/seed/' \ --migrateDB \ $1 \ \ diff --git a/packages/realm-server/scripts/start-production.sh b/packages/realm-server/scripts/start-production.sh index 307e2b6e7e..00bf7b8767 100755 --- a/packages/realm-server/scripts/start-production.sh +++ b/packages/realm-server/scripts/start-production.sh @@ -13,6 +13,7 @@ NODE_NO_WARNINGS=1 \ --realmsRootPath='/persistent/realms' \ --serverURL='https://app.boxel.ai' \ --seedPath='/persistent/seed' \ + --seedRealmURL='https://app.boxel.ai/seed/' \ \ --path='/persistent/base' \ --username='base_realm' \ diff --git a/packages/realm-server/scripts/start-staging.sh b/packages/realm-server/scripts/start-staging.sh index 3edd6386ed..68f992dd9e 100755 --- a/packages/realm-server/scripts/start-staging.sh +++ b/packages/realm-server/scripts/start-staging.sh @@ -13,6 +13,7 @@ NODE_NO_WARNINGS=1 \ --realmsRootPath='/persistent/realms' \ --serverURL='https://realms-staging.stack.cards' \ --seedPath='/persistent/seed' \ + --seedRealmURL='https://realms-staging.stack.cards/seed/' \ \ --path='/persistent/base' \ --username='base_realm' \ diff --git a/packages/realm-server/server.ts b/packages/realm-server/server.ts index fcf495910f..7000f4c729 100644 --- a/packages/realm-server/server.ts +++ b/packages/realm-server/server.ts @@ -67,6 +67,7 @@ export class RealmServer { private getIndexHTML: () => Promise; private serverURL: URL; private seedPath: string | undefined; + private seedRealmURL: URL | undefined; private matrixRegistrationSecret: string | undefined; private promiseForIndexHTML: Promise | undefined; private getRegistrationSecret: @@ -87,6 +88,7 @@ export class RealmServer { matrixRegistrationSecret, getRegistrationSecret, seedPath, + seedRealmURL, }: { serverURL: URL; realms: Realm[]; @@ -99,6 +101,7 @@ export class RealmServer { assetsURL: URL; getIndexHTML: () => Promise; seedPath?: string; + seedRealmURL?: URL; matrixRegistrationSecret?: string; getRegistrationSecret?: () => Promise; }) { @@ -116,6 +119,7 @@ export class RealmServer { this.secretSeed = secretSeed; this.realmsRootPath = realmsRootPath; this.seedPath = seedPath; + this.seedRealmURL = seedRealmURL; this.dbAdapter = dbAdapter; this.queue = queue; this.assetsURL = assetsURL; @@ -270,12 +274,14 @@ export class RealmServer { name, backgroundURL, iconURL, + copyFromSeedRealm = true, }: { ownerUserId: string; // note matrix userIDs look like "@mango:boxel.ai" endpoint: string; name: string; backgroundURL?: string; iconURL?: string; + copyFromSeedRealm?: boolean; }): Promise => { if ( this.realms.find( @@ -335,16 +341,29 @@ export class RealmServer { ...(iconURL ? { iconURL } : {}), ...(backgroundURL ? { backgroundURL } : {}), }); - if (this.seedPath) { + if (this.seedPath && copyFromSeedRealm) { let ignoreList = IGNORE_SEED_FILES.map((file) => join(this.seedPath!.replace(/\/$/, ''), file), ); + copySync(this.seedPath, realmPath, { filter: (src, _dest) => { return !ignoreList.includes(src); }, }); this.log.debug(`seed files for new realm ${url} copied to ${realmPath}`); + } else { + writeJSONSync(join(realmPath, 'index.json'), { + data: { + type: 'card', + meta: { + adoptsFrom: { + module: 'https://cardstack.com/base/cards-grid', + name: 'CardsGrid', + }, + }, + }, + }); } let realm = new Realm( @@ -360,7 +379,13 @@ export class RealmServer { username, }, }, - { invalidateEntireRealm: true, userInitiatedRealmCreation: true }, + { + ...(this.seedRealmURL && copyFromSeedRealm + ? { + copiedFromRealm: this.seedRealmURL, + } + : {}), + }, ); this.realms.push(realm); this.virtualNetwork.mount(realm.handle); diff --git a/packages/realm-server/tests/helpers/index.ts b/packages/realm-server/tests/helpers/index.ts index e33256ad8d..b6b3f6e5ba 100644 --- a/packages/realm-server/tests/helpers/index.ts +++ b/packages/realm-server/tests/helpers/index.ts @@ -381,13 +381,31 @@ export async function runTestRealmServer({ dbAdapter, }); virtualNetwork.mount(testRealm.handle); + let realms = [testRealm]; + let seedRealmURL: URL | undefined; + let seedRealm: Realm | undefined; + if (realmURL.pathname && realmURL.pathname !== '/') { + seedRealmURL = new URL('/seed/', realmURL); + seedRealm = await createRealm({ + dir: testRealmDir, + fileSystem, + realmURL: seedRealmURL.href, + permissions, + virtualNetwork, + matrixConfig, + publisher, + dbAdapter, + }); + virtualNetwork.mount(seedRealm.handle); + realms.push(seedRealm); + } let matrixClient = new MatrixClient({ matrixURL: realmServerTestMatrix.url, username: realmServerTestMatrix.username, seed: secretSeed, }); let testRealmServer = new RealmServer({ - realms: [testRealm], + realms, virtualNetwork, matrixClient, secretSeed, @@ -397,6 +415,7 @@ export async function runTestRealmServer({ queue: publisher, getIndexHTML, seedPath, + seedRealmURL, serverURL: new URL(realmURL.origin), assetsURL: new URL(`http://example.com/notional-assets-host/`), }); @@ -405,6 +424,7 @@ export async function runTestRealmServer({ return { testRealmDir, testRealm, + seedRealm, testRealmServer, testRealmHttpServer, }; diff --git a/packages/realm-server/tests/index-writer-test.ts b/packages/realm-server/tests/index-writer-test.ts index 0bd220a08a..1e7a69c8f2 100644 --- a/packages/realm-server/tests/index-writer-test.ts +++ b/packages/realm-server/tests/index-writer-test.ts @@ -55,6 +55,22 @@ module(basename(__filename), function () { }); }); + test('can copy index entries', async function (assert) { + await runSharedTest(indexWriterTests, assert, { + indexWriter, + indexQueryEngine, + adapter, + }); + }); + + test('throws when copy source realm is not present on the realm server', async function (assert) { + await runSharedTest(indexWriterTests, assert, { + indexWriter, + indexQueryEngine, + adapter, + }); + }); + test('error entry includes last known good state when available', async function (assert) { await runSharedTest(indexWriterTests, assert, { indexWriter, diff --git a/packages/realm-server/tests/index.ts b/packages/realm-server/tests/index.ts index 0823b47bde..4be1bdb2d8 100644 --- a/packages/realm-server/tests/index.ts +++ b/packages/realm-server/tests/index.ts @@ -13,29 +13,3 @@ import './realm-endpoints-test'; import './server-endpoints-test'; import './virtual-network-test'; import './billing-test'; - -// There is some timer that is preventing the node process from ending promptly. -// This forces the test to end with the correct response code. Note that a -// message "Error: Process exited before tests finished running" will be -// displayed because of this approach. -import QUnit from 'qunit'; -(QUnit as any).on( - 'runEnd', - ({ - testCounts, - }: { - testCounts: { - passed: number; - failed: number; - total: number; - skipped: number; - todo: number; - }; - }) => { - if (testCounts.failed > 0) { - process.exit(1); - } else { - process.exit(0); - } - }, -); diff --git a/packages/realm-server/tests/realm-endpoints-test.ts b/packages/realm-server/tests/realm-endpoints-test.ts index f8958ac515..9222595d34 100644 --- a/packages/realm-server/tests/realm-endpoints-test.ts +++ b/packages/realm-server/tests/realm-endpoints-test.ts @@ -3040,6 +3040,7 @@ module(basename(__filename), function () { let publisher: QueuePublisher; let runner: QueueRunner; let testRealmDir: string; + let seedRealm: Realm | undefined; hooks.beforeEach(async function () { shimExternals(virtualNetwork); @@ -3057,17 +3058,20 @@ module(basename(__filename), function () { if (testRealm2) { virtualNetwork.unmount(testRealm2.handle); } - ({ testRealm: testRealm2, testRealmHttpServer: testRealmHttpServer2 } = - await runTestRealmServer({ - virtualNetwork, - testRealmDir, - realmsRootPath: join(dir.name, 'realm_server_2'), - realmURL: testRealm2URL, - dbAdapter, - publisher, - runner, - matrixURL, - })); + ({ + seedRealm, + testRealm: testRealm2, + testRealmHttpServer: testRealmHttpServer2, + } = await runTestRealmServer({ + virtualNetwork, + testRealmDir, + realmsRootPath: join(dir.name, 'realm_server_2'), + realmURL: testRealm2URL, + dbAdapter, + publisher, + runner, + matrixURL, + })); } setupDB(hooks, { @@ -3081,6 +3085,9 @@ module(basename(__filename), function () { await startRealmServer(dbAdapter, publisher, runner); }, afterEach: async () => { + if (seedRealm) { + virtualNetwork.unmount(seedRealm.handle); + } await closeServer(testRealmHttpServer2); }, }); @@ -3399,6 +3406,7 @@ module(basename(__filename), function () { }); }); }); + module('Realm server with realm mounted at the origin', function (hooks) { let testRealmServer: Server; diff --git a/packages/realm-server/tests/server-endpoints-test.ts b/packages/realm-server/tests/server-endpoints-test.ts index 70a7b019ad..5fb777c84c 100644 --- a/packages/realm-server/tests/server-endpoints-test.ts +++ b/packages/realm-server/tests/server-endpoints-test.ts @@ -149,6 +149,7 @@ module(basename(__filename), function () { let runner: QueueRunner; let request2: SuperTest; let testRealmDir: string; + let seedRealm: Realm | undefined; hooks.beforeEach(async function () { shimExternals(virtualNetwork); @@ -167,6 +168,7 @@ module(basename(__filename), function () { virtualNetwork.unmount(testRealm2.handle); } ({ + seedRealm, testRealm: testRealm2, testRealmServer: testRealmServer2, testRealmHttpServer: testRealmHttpServer2, @@ -194,6 +196,9 @@ module(basename(__filename), function () { await startRealmServer(dbAdapter, publisher, runner); }, afterEach: async () => { + if (seedRealm) { + virtualNetwork.unmount(seedRealm.handle); + } await closeServer(testRealmHttpServer2); }, }); @@ -306,6 +311,28 @@ module(basename(__filename), function () { let realm = testRealmServer2.testingOnlyRealms.find( (r) => r.url === json.data.id, )!; + { + // owner can get a seeded instance + let response = await request2 + .get(`/${owner}/${endpoint}/jade`) + .set('Accept', 'application/vnd.card+json') + .set( + 'Authorization', + `Bearer ${createJWT(realm, ownerUserId, [ + 'read', + 'write', + 'realm-owner', + ])}`, + ); + + assert.strictEqual(response.status, 200, 'HTTP 200 status'); + let doc = response.body as SingleCardDocument; + assert.strictEqual( + doc.data.attributes?.title, + 'Jade', + 'instance data is correct', + ); + } { // owner can create an instance let response = await request2 @@ -392,6 +419,100 @@ module(basename(__filename), function () { } }); + test('POST /_create-realm without copying seed realm', async function (assert) { + // we randomize the realm and owner names so that we can isolate matrix + // test state--there is no "delete user" matrix API + let endpoint = `test-realm-${uuidv4()}`; + let owner = 'mango'; + let ownerUserId = '@mango:boxel.ai'; + let response = await request2 + .post('/_create-realm') + .set('Accept', 'application/vnd.api+json') + .set('Content-Type', 'application/json') + .set( + 'Authorization', + `Bearer ${createRealmServerJWT( + { user: ownerUserId, sessionRoom: 'session-room-test' }, + secretSeed, + )}`, + ) + .send( + JSON.stringify({ + data: { + type: 'realm', + attributes: { + ...testRealmInfo, + endpoint, + backgroundURL: 'http://example.com/background.jpg', + iconURL: 'http://example.com/icon.jpg', + copyFromSeedRealm: false, + }, + }, + }), + ); + + assert.strictEqual(response.status, 201, 'HTTP 201 status'); + let json = response.body; + assert.deepEqual( + json, + { + data: { + type: 'realm', + id: `${testRealm2URL.origin}/${owner}/${endpoint}/`, + attributes: { + ...testRealmInfo, + endpoint, + backgroundURL: 'http://example.com/background.jpg', + iconURL: 'http://example.com/icon.jpg', + copyFromSeedRealm: false, + }, + }, + }, + 'realm creation JSON is correct', + ); + + let realmPath = join(dir.name, 'realm_server_2', owner, endpoint); + let realmJSON = readJSONSync(join(realmPath, '.realm.json')); + assert.deepEqual( + realmJSON, + { + name: 'Test Realm', + backgroundURL: 'http://example.com/background.jpg', + iconURL: 'http://example.com/icon.jpg', + }, + '.realm.json is correct', + ); + assert.ok( + existsSync(join(realmPath, 'index.json')), + 'seed file index.json exists', + ); + assert.notOk( + existsSync( + join( + realmPath, + 'HelloWorld/47c0fc54-5099-4e9c-ad0d-8a58572d05c0.json', + ), + ), + 'seed file HelloWorld/47c0fc54-5099-4e9c-ad0d-8a58572d05c0.json exists', + ); + assert.notOk( + existsSync(join(realmPath, 'package.json')), + 'ignored seed file package.json does not exist', + ); + assert.notOk( + existsSync(join(realmPath, 'node_modules')), + 'ignored seed file node_modules/ does not exist', + ); + assert.notOk( + existsSync(join(realmPath, '.gitignore')), + 'ignored seed file .gitignore does not exist', + ); + assert.notOk( + existsSync(join(realmPath, 'tsconfig.json')), + 'ignored seed file tsconfig.json does not exist', + ); + }); + test('dynamically created realms are not publicly readable or writable', async function (assert) { let endpoint = `test-realm-${uuidv4()}`; let owner = 'mango'; @@ -940,6 +1061,13 @@ module(basename(__filename), function () { id: `${testRealm2URL}`, attributes: testRealmInfo, }, + // the seed realm is automatically added to the realm server running + // on port 4445 as a public realm + { + type: 'catalog-realm', + id: `${new URL('/seed/', testRealm2URL)}`, + attributes: testRealmInfo, + }, ], }); }); diff --git a/packages/realm-server/worker-manager.ts b/packages/realm-server/worker-manager.ts index af2c3a1c7b..540d9497e6 100644 --- a/packages/realm-server/worker-manager.ts +++ b/packages/realm-server/worker-manager.ts @@ -7,12 +7,22 @@ import { } from '@cardstack/runtime-common'; import yargs from 'yargs'; import * as Sentry from '@sentry/node'; -import { createServer } from 'net'; import flattenDeep from 'lodash/flattenDeep'; import { spawn } from 'child_process'; import pluralize from 'pluralize'; +import Koa from 'koa'; +import Router from '@koa/router'; +import { ecsMetadata, fullRequestURL, livenessCheck } from './middleware'; +import { Server } from 'http'; -let log = logger('worker'); +/* About the Worker Manager + * + * This process runs on each queue worker container and is responsible starting and monitoring the worker processes. It does this via IPC (inter-process communication). + * In test and development environments, the worker manager is also responsible for providing a readiness check HTTP endpoint so that tests can wait until the worker + * manager is ready before proceeding. + */ + +let log = logger('worker-manager'); const REALM_SECRET_SEED = process.env.REALM_SECRET_SEED; if (!REALM_SECRET_SEED) { @@ -34,7 +44,8 @@ let { .usage('Start worker manager') .options({ port: { - description: 'TCP port for worker to communicate readiness (for tests)', + description: + 'HTTP port for worker manager to communicate readiness and status', type: 'number', }, highPriorityCount: { @@ -75,63 +86,88 @@ let isExiting = false; process.on('SIGINT', () => (isExiting = true)); process.on('SIGTERM', () => (isExiting = true)); -if (port != null) { - // in tests we start a simple TCP server to communicate to the realm when - // the worker is ready to start processing jobs - let server = createServer((socket) => { - log.info(`realm connected to worker manager`); - socket.on('data', (data) => { - if (data.toString() === 'ready?') { - socket.write(isReady ? 'ready' : 'not-ready'); - } - }); - socket.on('close', (hadError) => { - log.info(`realm has disconnected${hadError ? ' due to an error' : ''}.`); - }); - socket.on('error', (err: any) => { - console.error(`realm disconnected from worker manager: ${err.message}`); - }); - }); - server.unref(); +let webServerInstance: Server | undefined; - server.listen(port, () => { - log.info(`worker manager listening for realm on port ${port}`); +if (port) { + let webServer = new Koa(); + let router = new Router(); + router.head('/', livenessCheck); + router.get('/', async (ctxt: Koa.Context, _next: Koa.Next) => { + let result = { + ready: isReady, + } as Record; + if (isReady) { + result = { + ...result, + highPriorityWorkers: highPriorityCount, + allPriorityWorkers: allPriorityCount, + }; + } + ctxt.set('Content-Type', 'application/json'); + ctxt.body = JSON.stringify(result); + ctxt.status = isReady ? 200 : 503; }); - const shutdown = () => { - log.info(`Shutting down server for worker manager...`); - server.close((err) => { - if (err) { - log.error(`Error while closing the server for worker manager:`, err); - process.exit(1); - } - log.info(`Server closed for worker manager.`); - process.exit(0); - }); - }; + webServer + .use(router.routes()) + .use((ctxt: Koa.Context, next: Koa.Next) => { + log.info( + `<-- ${ctxt.method} ${ctxt.req.headers.accept} ${ + fullRequestURL(ctxt).href + }`, + ); + + ctxt.res.on('finish', () => { + log.info( + `--> ${ctxt.method} ${ctxt.req.headers.accept} ${ + fullRequestURL(ctxt).href + }: ${ctxt.status}`, + ); + log.debug(JSON.stringify(ctxt.req.headers)); + }); + return next(); + }) + .use(ecsMetadata); - process.on('SIGINT', shutdown); - process.on('SIGTERM', shutdown); - process.on('uncaughtException', (err) => { - log.error(`Uncaught exception in worker manager:`, err); - shutdown(); + webServer.on('error', (err: any) => { + log.error(`worker manager HTTP server error: ${err.message}`); }); - process.on('message', (message) => { - if (message === 'stop') { - console.log(`stopping realm server on port ${port}...`); - server.close(() => { - console.log(`worker manager on port ${port} has stopped`); - if (process.send) { - process.send('stopped'); - } - }); - } else if (message === 'kill') { - console.log(`Ending worker manager process for ${port}...`); - process.exit(0); + webServerInstance = webServer.listen(port); + log.info(`worker manager HTTP listening on port ${port}`); +} + +const shutdown = (onShutdown?: () => void) => { + log.info(`Shutting down server for worker manager...`); + webServerInstance?.closeAllConnections(); + webServerInstance?.close((err?: Error) => { + if (err) { + log.error(`Error while closing the server for worker manager HTTP:`, err); + process.exit(1); } + log.info(`worker manager HTTP on port ${port} has stopped.`); + onShutdown?.(); + process.exit(0); }); -} +}; + +process.on('SIGINT', () => shutdown()); +process.on('SIGTERM', () => shutdown()); +process.on('uncaughtException', (err) => { + log.error(`Uncaught exception in worker manager:`, err); + shutdown(); +}); + +process.on('message', (message) => { + if (message === 'stop') { + shutdown(() => { + process.send?.('stopped'); + }); + } else if (message === 'kill') { + log.info(`Ending worker manager process for ${port}...`); + process.exit(0); + } +}); (async () => { log.info( @@ -217,7 +253,7 @@ async function startWorker(priority: number, urlMappings: URL[][]) { } }); }), - new Promise((r) => setTimeout(() => r(true), 30_000)), + new Promise((r) => setTimeout(() => r(true), 30_000).unref()), ]); if (timeout) { console.error( diff --git a/packages/runtime-common/expression.ts b/packages/runtime-common/expression.ts index 3ceaf3dd91..0264a820ca 100644 --- a/packages/runtime-common/expression.ts +++ b/packages/runtime-common/expression.ts @@ -320,6 +320,29 @@ export function upsert( ] as Expression; } +export function upsertMultipleRows( + table: string, + constraint: string, + nameExpressions: string[][], + valueExpressions: Expression[][], +) { + let names = flattenDeep(nameExpressions); + return [ + 'INSERT INTO', + table, + ...addExplicitParens(separatedByCommas(nameExpressions)), + 'VALUES', + ...separatedByCommas( + valueExpressions.map((expression) => + addExplicitParens(separatedByCommas(expression)), + ), + ), + 'ON CONFLICT ON CONSTRAINT', + constraint, + 'DO UPDATE SET', + ...separatedByCommas(names.map((name) => [`${name}=EXCLUDED.${name}`])), + ] as Expression; +} export function insert( table: string, nameExpressions: string[][], @@ -385,7 +408,9 @@ export function expressionToSql( return element[dbAdapterKind] ?? ''; } else if (isParam(element)) { let value = element[dbAdapterKind] ?? element.param ?? null; - values.push(value); + values.push( + value && typeof value === 'object' ? JSON.stringify(value) : value, + ); return `$${values.length}`; } else if (typeof element === 'string') { return element; diff --git a/packages/runtime-common/helpers/ai.ts b/packages/runtime-common/helpers/ai.ts index d60f9eb900..23dec28bde 100644 --- a/packages/runtime-common/helpers/ai.ts +++ b/packages/runtime-common/helpers/ai.ts @@ -203,7 +203,7 @@ function generateJsonSchemaForContainsFields( }; const { id: _removedIdField, ...fields } = cardApi.getFields(def, { - usedFieldsOnly: false, + usedLinksToFieldsOnly: false, }); for (let [fieldName, field] of Object.entries(fields)) { @@ -301,7 +301,7 @@ function generateRelationshipFieldsInfo( fieldName?: string, ) { const { id: _removedIdField, ...fields } = cardApi.getFields(def, { - usedFieldsOnly: false, + usedLinksToFieldsOnly: false, }); for (let [fName, fValue] of Object.entries(fields)) { let flatFieldName = fieldName ? `${fieldName}.${fName}` : fName; diff --git a/packages/runtime-common/helpers/indexer.ts b/packages/runtime-common/helpers/indexer.ts index ee2d7fdd4b..3d5d418579 100644 --- a/packages/runtime-common/helpers/indexer.ts +++ b/packages/runtime-common/helpers/indexer.ts @@ -81,7 +81,7 @@ export async function serializeCard(card: CardDef): Promise { // we can relax the resource here since we will be asserting an ID when we // setup the index type RelaxedBoxelIndexTable = Omit & { - pristine_doc: LooseCardResource; + pristine_doc: LooseCardResource | null; }; export type TestIndexRow = diff --git a/packages/runtime-common/index-writer.ts b/packages/runtime-common/index-writer.ts index b8cd1b356b..f0bd73832d 100644 --- a/packages/runtime-common/index-writer.ts +++ b/packages/runtime-common/index-writer.ts @@ -3,6 +3,7 @@ import flatten from 'lodash/flatten'; import flattenDeep from 'lodash/flattenDeep'; import { type CardResource, + type RealmInfo, hasExecutableExtension, trimExecutableExtension, RealmPaths, @@ -21,6 +22,7 @@ import { query, upsert, dbExpression, + upsertMultipleRows, } from './expression'; import { type SerializedError } from './error'; import { type DBAdapter } from './db'; @@ -99,6 +101,7 @@ export class Batch { #invalidations = new Set(); #dbAdapter: DBAdapter; #perfLog = logger('index-perf'); + #log = logger('index-writer'); private declare realmVersion: number; constructor( @@ -141,6 +144,100 @@ export class Batch { return result; } + async copyFrom(sourceRealmURL: URL, destRealmInfo: RealmInfo): Promise { + let columns: string[][] | undefined; + let sources = (await this.#query([ + `SELECT * FROM boxel_index WHERE`, + // intentionally copying over error docs--perhaps these can be resolved in + // the new realm? + ...every([ + any([['is_deleted = false'], ['is_deleted IS NULL']]), + [`realm_url =`, param(sourceRealmURL.href)], + ]), + ] as Expression)) as unknown as BoxelIndexTable[]; + let now = String(Date.now()); + let values = sources.map((entry) => { + let destURL = this.copiedRealmURL( + sourceRealmURL, + new URL(entry.url), + ).href; + this.#invalidations.add(destURL); + if (entry.type === 'instance' && entry.source) { + let json: { data: CardResource } | undefined; + try { + json = JSON.parse(entry.source); + } catch (e: any) { + this.#log.info( + `Cannot parse instance source for ${entry.url}: ${e.message}`, + ); + } + if (json) { + json.data.id = destURL.replace(/\.json$/, ''); + entry.source = JSON.stringify(json); + } + } + + entry.url = destURL; + entry.realm_url = this.realmURL.href; + entry.realm_version = this.realmVersion; + entry.file_alias = this.copiedRealmURL( + sourceRealmURL, + new URL(entry.file_alias), + ).href; + entry.types = entry.types + ? entry.types.map( + (type) => this.copiedRealmURL(sourceRealmURL, new URL(type)).href, + ) + : entry.types; + entry.deps = entry.deps + ? entry.deps.map( + (dep) => this.copiedRealmURL(sourceRealmURL, new URL(dep)).href, + ) + : entry.deps; + entry.pristine_doc = entry.pristine_doc + ? { + ...entry.pristine_doc, + id: this.copiedRealmURL( + sourceRealmURL, + new URL(entry.pristine_doc.id), + ).href, + } + : entry.pristine_doc; + if (entry.type === 'instance' && entry.pristine_doc) { + entry.pristine_doc.meta = { + ...entry.pristine_doc.meta, + realmURL: this.realmURL.href, + realmInfo: destRealmInfo, + }; + } + entry.fitted_html = entry.fitted_html + ? this.objectWithCopiedRealmKeys(sourceRealmURL, entry.fitted_html) + : entry.fitted_html; + entry.embedded_html = entry.embedded_html + ? this.objectWithCopiedRealmKeys(sourceRealmURL, entry.embedded_html) + : entry.embedded_html; + entry.indexed_at = now; + + let { valueExpressions, nameExpressions } = asExpressions(entry); + columns = nameExpressions; + return valueExpressions; + }); + if (!columns) { + throw new Error( + `nothing to copy from ${sourceRealmURL.href} - this realm is not present on the realm server`, + ); + } + + await this.#query([ + ...upsertMultipleRows( + 'boxel_index_working', + 'boxel_index_working_pkey', + columns, + values, + ), + ]); + } + async updateEntry(url: URL, entry: IndexEntry): Promise { if (!new RealmPaths(this.realmURL).inRealm(url)) { // TODO this is a workaround for CS-6886. after we have solved that issue we can @@ -448,18 +545,15 @@ export class Batch { ].map((v) => [param(v)]), ); - let names = flattenDeep(columns); let insertStart = Date.now(); await this.#query([ - 'INSERT INTO boxel_index_working', - ...addExplicitParens(separatedByCommas(columns)), - 'VALUES', - ...separatedByCommas( - rows.map((value) => addExplicitParens(separatedByCommas(value))), + ...upsertMultipleRows( + 'boxel_index_working', + 'boxel_index_working_pkey', + columns, + rows, ), - 'ON CONFLICT ON CONSTRAINT boxel_index_working_pkey DO UPDATE SET', - ...separatedByCommas(names.map((name) => [`${name}=EXCLUDED.${name}`])), - ] as Expression); + ]); this.#perfLog.debug( `inserted invalidated rows for ${url.href} in ${ Date.now() - insertStart @@ -561,4 +655,25 @@ export class Batch { ]; return [...new Set(results)]; } + + private copiedRealmURL(fromRealm: URL, file: URL): URL { + let source = new RealmPaths(fromRealm); + let dest = new RealmPaths(this.realmURL); + if (!source.inRealm(file)) { + return file; + } + let local = source.local(file); + return dest.fileURL(local); + } + + private objectWithCopiedRealmKeys( + fromRealm: URL, + obj: Record, + ): Record { + let result: Record = {}; + for (let [key, value] of Object.entries(obj)) { + result[this.copiedRealmURL(fromRealm, new URL(key)).href] = value; + } + return result; + } } diff --git a/packages/runtime-common/realm-index-updater.ts b/packages/runtime-common/realm-index-updater.ts index eb2a8dcbed..41a4540b4b 100644 --- a/packages/runtime-common/realm-index-updater.ts +++ b/packages/runtime-common/realm-index-updater.ts @@ -13,6 +13,8 @@ import { type FromScratchResult, type IncrementalArgs, type IncrementalResult, + type CopyArgs, + type CopyResult, } from '.'; import { Realm } from './realm'; import { RealmPaths } from './paths'; @@ -20,11 +22,6 @@ import { Loader } from './loader'; import ignore, { type Ignore } from 'ignore'; import { getMatrixUsername } from './matrix-client'; -interface FullIndexOpts { - invalidateEntireRealm?: boolean; - userInitiatedRequest?: boolean; -} - export class RealmIndexUpdater { #realm: Realm; #loader: Loader; @@ -88,8 +85,8 @@ export class RealmIndexUpdater { return await this.#indexWriter.isNewIndex(this.realmURL); } - async run(opts?: FullIndexOpts) { - await this.fullIndex(opts); + async run() { + await this.fullIndex(); } indexing() { @@ -99,21 +96,18 @@ export class RealmIndexUpdater { // TODO consider triggering SSE events for invalidations now that we can // calculate fine grained invalidations for from-scratch indexing by passing // in an onInvalidation callback - async fullIndex(opts?: FullIndexOpts) { + async fullIndex() { this.#indexingDeferred = new Deferred(); try { let args: FromScratchArgs = { realmURL: this.#realm.url, realmUsername: await this.getRealmUsername(), - invalidateEntireRealm: Boolean(opts?.invalidateEntireRealm), }; let job = await this.#queue.publish({ jobType: `from-scratch-index`, concurrencyGroup: `indexing:${this.#realm.url}`, timeout: 4 * 60, - priority: opts?.userInitiatedRequest - ? userInitiatedPriority - : systemInitiatedPriority, + priority: systemInitiatedPriority, args, }); let { ignoreData, stats } = await job.done; @@ -172,6 +166,39 @@ export class RealmIndexUpdater { } } + async copy( + sourceRealmURL: URL, + onInvalidation?: (invalidatedURLs: URL[]) => void, + ): Promise { + this.#indexingDeferred = new Deferred(); + try { + let args: CopyArgs = { + realmURL: this.#realm.url, + realmUsername: await this.getRealmUsername(), + sourceRealmURL: sourceRealmURL.href, + }; + let job = await this.#queue.publish({ + jobType: 'copy-index', + concurrencyGroup: `indexing:${this.#realm.url}`, + timeout: 4 * 60, + priority: userInitiatedPriority, + args, + }); + let { invalidations } = await job.done; + this.#loader = Loader.cloneLoader(this.#realm.loaderTemplate); + if (onInvalidation) { + onInvalidation( + invalidations.map((href) => new URL(href.replace(/\.json$/, ''))), + ); + } + } catch (e: any) { + this.#indexingDeferred.reject(e); + throw e; + } finally { + this.#indexingDeferred.fulfill(); + } + } + public isIgnored(url: URL): boolean { // TODO this may be called before search index is ready in which case we // should provide a default ignore list. But really we should decouple the @@ -206,6 +233,8 @@ export class RealmIndexUpdater { // hard coded test URLs if ((globalThis as any).__environment === 'test') { switch (this.realmURL.href) { + case 'http://localhost:4205/seed/': + return 'seed_realm'; case 'http://127.0.0.1:4441/': return 'base_realm'; case 'http://127.0.0.1:4444/': diff --git a/packages/runtime-common/realm.ts b/packages/runtime-common/realm.ts index c95aea977a..096bc7c49c 100644 --- a/packages/runtime-common/realm.ts +++ b/packages/runtime-common/realm.ts @@ -166,8 +166,7 @@ export interface RealmAdapter { interface Options { disableModuleCaching?: true; - invalidateEntireRealm?: true; - userInitiatedRealmCreation?: true; + copiedFromRealm?: URL; } interface UpdateItem { @@ -206,7 +205,8 @@ interface FileRemovedEventData { export type IndexEventData = | IncrementalIndexInitiation | IncrementalIndexEventData - | FullIndexEventData; + | FullIndexEventData + | CopiedIndexEventData; interface IndexEvent { type: 'index'; @@ -224,6 +224,11 @@ interface FullIndexEventData { type: 'full'; realmURL: string; } +interface CopiedIndexEventData { + type: 'copy'; + sourceRealmURL: string; + destRealmURL: string; +} interface IncrementalIndexInitiation { type: 'incremental-index-initiation'; @@ -257,8 +262,7 @@ export class Realm { #recentWrites: Map = new Map(); #realmSecretSeed: string; #disableModuleCaching = false; - #invalidateEntireRealm = false; - #userInitiatedRealmCreation = false; + #copiedFromRealm: URL | undefined; #publicEndpoints: RouteTable = new Map([ [ @@ -312,11 +316,7 @@ export class Realm { seed: secretSeed, }); this.#disableModuleCaching = Boolean(opts?.disableModuleCaching); - this.#invalidateEntireRealm = Boolean(opts?.invalidateEntireRealm); - this.#userInitiatedRealmCreation = Boolean( - opts?.userInitiatedRealmCreation, - ); - + this.#copiedFromRealm = opts?.copiedFromRealm; let fetch = fetcher(virtualNetwork.fetch, [ async (req, next) => { return (await maybeHandleScopedCSSRequest(req)) || next(req); @@ -621,19 +621,28 @@ export class Realm { async #startup() { await Promise.resolve(); let startTime = Date.now(); - let isNewIndex = await this.#realmIndexUpdater.isNewIndex(); - let promise = this.#realmIndexUpdater.run({ - invalidateEntireRealm: this.#invalidateEntireRealm, - userInitiatedRequest: this.#userInitiatedRealmCreation, - }); - if (isNewIndex) { - // we only await the full indexing at boot if this is a brand new index - await promise; + if (this.#copiedFromRealm) { + await this.#realmIndexUpdater.copy(this.#copiedFromRealm); + this.sendServerEvent({ + type: 'index', + data: { + type: 'copy', + sourceRealmURL: this.#copiedFromRealm.href, + destRealmURL: this.url, + }, + }); + } else { + let isNewIndex = await this.#realmIndexUpdater.isNewIndex(); + let promise = this.#realmIndexUpdater.run(); + if (isNewIndex) { + // we only await the full indexing at boot if this is a brand new index + await promise; + } + this.sendServerEvent({ + type: 'index', + data: { type: 'full', realmURL: this.url }, + }); } - this.sendServerEvent({ - type: 'index', - data: { type: 'full', realmURL: this.url }, - }); this.#perfLog.debug( `realm server ${this.url} startup in ${Date.now() - startTime} ms`, ); @@ -719,14 +728,15 @@ export class Realm { if (!request.headers.get('X-Boxel-Building-Index')) { let timeout = await Promise.race([ this.#startedUp.promise, - new Promise((resolve) => - setTimeout(() => { - resolve( - new Error( - `Timeout waiting for realm ${this.url} to become ready`, - ), - ); - }, 60 * 1000), + new Promise( + (resolve) => + setTimeout(() => { + resolve( + new Error( + `Timeout waiting for realm ${this.url} to become ready`, + ), + ); + }, 60 * 1000).unref?.(), ), ]); if (timeout) { diff --git a/packages/runtime-common/tests/index-writer-test.ts b/packages/runtime-common/tests/index-writer-test.ts index d49d1b0060..2a1b6585d9 100644 --- a/packages/runtime-common/tests/index-writer-test.ts +++ b/packages/runtime-common/tests/index-writer-test.ts @@ -7,8 +7,10 @@ import { type IndexedInstance, type BoxelIndexTable, type CardResource, + type RealmInfo, } from '../index'; import { cardSrc, compiledCard } from '../etc/test-fixtures'; +import merge from 'lodash/merge'; import { type SharedTests } from '../helpers'; import { setupIndex } from '../helpers/indexer'; import { testRealmURL } from '../helpers/const'; @@ -18,6 +20,13 @@ import { IndexQueryEngine } from '../index-query-engine'; import { coerceTypes } from '../index-structure'; const testRealmURL2 = `http://test-realm/test2/`; +const testRealmInfo: RealmInfo = { + name: 'Test Realm', + backgroundURL: null, + iconURL: null, + showAsCatalog: null, + visibility: 'public', +}; const tests = Object.freeze({ 'can perform invalidations for a instance entry': async ( @@ -420,6 +429,216 @@ const tests = Object.freeze({ ); }, + 'can copy index entries': async (assert, { indexWriter, adapter }) => { + let types = [{ module: `./person`, name: 'Person' }, baseCardRef].map((i) => + internalKeyFor(i, new URL(testRealmURL)), + ); + let destTypes = [{ module: `./person`, name: 'Person' }, baseCardRef].map( + (i) => internalKeyFor(i, new URL(testRealmURL2)), + ); + let modified = Date.now(); + let resource: CardResource = { + id: `${testRealmURL}1`, + type: 'card', + attributes: { + name: 'Mango', + }, + meta: { + adoptsFrom: { + module: `./person`, + name: 'Person', + }, + }, + }; + let source = JSON.stringify({ data: resource }); + await setupIndex( + adapter, + [ + { realm_url: testRealmURL, current_version: 1 }, + { realm_url: testRealmURL2, current_version: 1 }, + ], + [ + { + url: `${testRealmURL}1.json`, + realm_version: 1, + realm_url: testRealmURL, + type: 'instance', + pristine_doc: resource, + source, + transpiled_code: null, + search_doc: { name: 'Mango' }, + display_names: [`Person`], + deps: [`${testRealmURL}person`], + types, + last_modified: String(modified), + resource_created_at: String(modified), + embedded_html: Object.fromEntries( + types.map((type) => [ + type, + `
Embedded HTML for ${type + .split('/') + .pop()!}
`, + ]), + ), + fitted_html: Object.fromEntries( + types.map((type) => [ + type, + `
Fitted HTML for ${type + .split('/') + .pop()!}
`, + ]), + ), + isolated_html: `
Isolated HTML
`, + atom_html: `Atom HTML`, + icon_html: 'test icon', + }, + { + url: `${testRealmURL}person.gts`, + realm_version: 1, + realm_url: testRealmURL, + type: 'module', + source: `// person.gts source`, + transpiled_code: `// person.gts transpiled code`, + pristine_doc: null, + search_doc: null, + display_names: null, + deps: [`https://cardstack.com/base/card-api.gts`], + types: null, + last_modified: String(modified), + resource_created_at: String(modified), + embedded_html: null, + fitted_html: null, + isolated_html: null, + atom_html: null, + icon_html: null, + }, + ], + ); + let batch = await indexWriter.createBatch(new URL(testRealmURL2)); + await batch.copyFrom(new URL(testRealmURL), testRealmInfo); + await batch.done(); + + let results = (await adapter.execute( + 'SELECT * FROM boxel_index WHERE realm_url = $1 ORDER BY url COLLATE "POSIX"', + { coerceTypes, bind: [testRealmURL2] }, + )) as unknown as BoxelIndexTable[]; + assert.strictEqual( + results.length, + 2, + 'correct number of items were copied', + ); + + let [copiedIndex, copiedModule] = results; + assert.ok(copiedIndex.indexed_at, 'indexed_at was set'); + assert.ok(copiedModule.indexed_at, 'indexed_at was set'); + + delete (copiedIndex as Partial).indexed_at; + delete (copiedModule as Partial).indexed_at; + + assert.deepEqual( + copiedIndex as Omit, + { + url: `${testRealmURL2}1.json`, + file_alias: `${testRealmURL2}1`, + realm_version: 2, + realm_url: testRealmURL2, + type: 'instance', + pristine_doc: { + ...resource, + id: `${testRealmURL2}1`, + meta: { + ...resource.meta, + realmURL: testRealmURL2, + realmInfo: testRealmInfo, + }, + }, + source: JSON.stringify( + merge(JSON.parse(source), { data: { id: `${testRealmURL2}1` } }), + ), + error_doc: null, + transpiled_code: null, + search_doc: { name: 'Mango' }, + display_names: [`Person`], + deps: [`${testRealmURL2}person`], + types: destTypes, + last_modified: String(modified), + resource_created_at: String(modified), + embedded_html: Object.fromEntries( + destTypes.map((type) => [ + type, + `
Embedded HTML for ${type + .split('/') + .pop()!}
`, + ]), + ), + fitted_html: Object.fromEntries( + destTypes.map((type) => [ + type, + `
Fitted HTML for ${type + .split('/') + .pop()!}
`, + ]), + ), + isolated_html: `
Isolated HTML
`, + atom_html: `Atom HTML`, + icon_html: 'test icon', + is_deleted: null, + }, + 'the copied instance is correct', + ); + assert.deepEqual( + copiedModule as Omit, + { + url: `${testRealmURL2}person.gts`, + file_alias: `${testRealmURL2}person`, + realm_version: 2, + realm_url: testRealmURL2, + type: 'module', + source: `// person.gts source`, + transpiled_code: `// person.gts transpiled code`, + error_doc: null, + pristine_doc: null, + search_doc: null, + display_names: null, + deps: [`https://cardstack.com/base/card-api.gts`], + types: null, + last_modified: String(modified), + resource_created_at: String(modified), + embedded_html: null, + fitted_html: null, + isolated_html: null, + atom_html: null, + icon_html: null, + is_deleted: null, + }, + 'the copied module is correct', + ); + }, + + 'throws when copy source realm is not present on the realm server': async ( + assert, + { indexWriter, adapter }, + ) => { + assert.expect(1); + + await setupIndex( + adapter, + [{ realm_url: testRealmURL2, current_version: 1 }], + [], + ); + let batch = await indexWriter.createBatch(new URL(testRealmURL2)); + try { + await batch.copyFrom(new URL(testRealmURL), testRealmInfo); + throw new Error('Expected error to be thrown'); + } catch (e: any) { + assert.strictEqual( + e.message, + `nothing to copy from ${testRealmURL} - this realm is not present on the realm server`, + 'the correct exception was thrown', + ); + } + }, + 'error entry includes last known good state when available': async ( assert, { indexWriter, adapter }, diff --git a/packages/runtime-common/worker.ts b/packages/runtime-common/worker.ts index cd52c0aafd..fd1bd7ae94 100644 --- a/packages/runtime-common/worker.ts +++ b/packages/runtime-common/worker.ts @@ -17,6 +17,7 @@ import { type TextFileRef, type VirtualNetwork, type ResponseWithNodeStream, + type RealmInfo, } from '.'; import { MatrixClient } from './matrix-client'; @@ -40,10 +41,7 @@ export interface Reader { } export type RunnerRegistration = ( - fromScratch: ( - realmURL: URL, - invalidateEntireRealm: boolean, - ) => Promise, + fromScratch: (realmURL: URL) => Promise, incremental: ( url: URL, realmURL: URL, @@ -76,15 +74,22 @@ export interface IncrementalResult { stats: Stats; } -export interface FromScratchArgs extends WorkerArgs { - invalidateEntireRealm: boolean; -} +export type FromScratchArgs = WorkerArgs; export interface FromScratchResult extends JSONTypes.Object { ignoreData: Record; stats: Stats; } +export interface CopyArgs extends WorkerArgs { + sourceRealmURL: string; +} + +export interface CopyResult { + totalNonErrorIndexEntries: number; + invalidations: string[]; +} + export type IndexRunner = (optsId: number) => Promise; // This class is used to support concurrent index runs against the same fastboot @@ -128,11 +133,7 @@ export class Worker { #matrixClientCache: Map = new Map(); #secretSeed: string; #fromScratch: - | (( - realmURL: URL, - invalidateEntireRealm: boolean, - boom?: true, - ) => Promise) + | ((realmURL: URL, boom?: true) => Promise) | undefined; #incremental: | (( @@ -173,17 +174,13 @@ export class Worker { await Promise.all([ this.#queue.register(`from-scratch-index`, this.fromScratch), this.#queue.register(`incremental-index`, this.incremental), + this.#queue.register(`copy-index`, this.copy), ]); await this.#queue.start(); } - private async prepareAndRunJob( - args: WorkerArgs, - run: () => Promise, - ): Promise { - let deferred = new Deferred(); + private async makeAuthedFetch(args: WorkerArgs) { let matrixClient: MatrixClient; - if (this.#matrixClientCache.has(args.realmUsername)) { matrixClient = this.#matrixClientCache.get(args.realmUsername)!; @@ -215,6 +212,15 @@ export class Worker { }, authorizationMiddleware(new RealmAuthDataSource(matrixClient, getFetch)), ]); + return _fetch; + } + + private async prepareAndRunJob( + args: WorkerArgs, + run: () => Promise, + ): Promise { + let deferred = new Deferred(); + let _fetch = await this.makeAuthedFetch(args); let optsId = this.runnerOptsMgr.setOptions({ _fetch, reader: getReader(_fetch, new URL(args.realmURL)), @@ -252,6 +258,33 @@ export class Worker { return result; } + private copy = async (args: CopyArgs) => { + this.#log.debug(`starting copy indexing for job: ${JSON.stringify(args)}`); + let authedFetch = await this.makeAuthedFetch(args); + let realmInfoResponse = await authedFetch(`${args.realmURL}_info`, { + headers: { Accept: SupportedMimeType.RealmInfo }, + }); + let realmInfo: RealmInfo = (await realmInfoResponse.json())?.data + ?.attributes; + + let batch = await this.#indexWriter.createBatch(new URL(args.realmURL)); + await batch.copyFrom(new URL(args.sourceRealmURL), realmInfo); + let result = await batch.done(); + let invalidations = batch.invalidations; + this.#log.debug( + `completed copy indexing for realm ${args.realmURL}:\n${JSON.stringify( + result, + null, + 2, + )}`, + ); + let { totalIndexEntries: totalNonErrorIndexEntries } = result; + return { + invalidations, + totalNonErrorIndexEntries, + }; + }; + private fromScratch = async (args: FromScratchArgs) => { this.#log.debug( `starting from-scratch indexing for job: ${JSON.stringify(args)}`, @@ -262,7 +295,6 @@ export class Worker { } let { ignoreData, stats } = await this.#fromScratch( new URL(args.realmURL), - args.invalidateEntireRealm, ); this.#log.debug( `completed from-scratch indexing for realm ${ diff --git a/packages/seed-realm/BlogCategory/5c529dbb-bc16-41ee-9c31-a2b28e07b79d.json b/packages/seed-realm/BlogCategory/5c529dbb-bc16-41ee-9c31-a2b28e07b79d.json index 7a8084c971..7cc55d53ba 100644 --- a/packages/seed-realm/BlogCategory/5c529dbb-bc16-41ee-9c31-a2b28e07b79d.json +++ b/packages/seed-realm/BlogCategory/5c529dbb-bc16-41ee-9c31-a2b28e07b79d.json @@ -5,12 +5,7 @@ "longName": "Movie Review", "shortName": "Movies", "slug": "movies", - "backgroundColor": { - "hexValue": "#FBEB06" - }, - "textColor": { - "hexValue": "#000000" - }, + "pillColor": "#FBEB06", "description": null, "title": "Movie Review", "thumbnailURL": null diff --git a/packages/seed-realm/BlogCategory/a4ee8a18-182b-47ff-b557-ef2e529c97ec.json b/packages/seed-realm/BlogCategory/a4ee8a18-182b-47ff-b557-ef2e529c97ec.json index 9fb81de3f5..1d170ed2d3 100644 --- a/packages/seed-realm/BlogCategory/a4ee8a18-182b-47ff-b557-ef2e529c97ec.json +++ b/packages/seed-realm/BlogCategory/a4ee8a18-182b-47ff-b557-ef2e529c97ec.json @@ -5,12 +5,7 @@ "longName": "TV Series", "shortName": "TV", "slug": "tv-series", - "backgroundColor": { - "hexValue": "#9D00FF" - }, - "textColor": { - "hexValue": "#ffffff" - }, + "pillColor": "#9D00FF", "description": null, "title": "TV Series", "thumbnailURL": null diff --git a/packages/seed-realm/BlogCategory/city-design.json b/packages/seed-realm/BlogCategory/city-design.json index b883ae19a0..dc9c2a5fee 100644 --- a/packages/seed-realm/BlogCategory/city-design.json +++ b/packages/seed-realm/BlogCategory/city-design.json @@ -5,12 +5,7 @@ "longName": "City Design", "shortName": "Design", "slug": "city-design", - "backgroundColor": { - "hexValue": "#1EDF67" - }, - "textColor": { - "hexValue": "#ffffff" - }, + "pillColor": "#1EDF67", "description": "Showcasing architecture and urban planning brilliance.", "title": "City Design", "thumbnailURL": null diff --git a/packages/seed-realm/BlogCategory/cultural-scenes.json b/packages/seed-realm/BlogCategory/cultural-scenes.json index f771b9b1a7..0491e3b410 100644 --- a/packages/seed-realm/BlogCategory/cultural-scenes.json +++ b/packages/seed-realm/BlogCategory/cultural-scenes.json @@ -5,12 +5,7 @@ "longName": "Cultural Scenes", "shortName": "Culture", "slug": "cultural-scenes", - "backgroundColor": { - "hexValue": "#FA7F01" - }, - "textColor": { - "hexValue": "#ffffff" - }, + "pillColor": "#FA7F01", "description": "Capturing the vibrant art, food, and music of cities.", "title": "Cultural Scenes", "thumbnailURL": null diff --git a/packages/seed-realm/BlogCategory/future-tech.json b/packages/seed-realm/BlogCategory/future-tech.json index 0a4dadb2ba..f872fc7a41 100644 --- a/packages/seed-realm/BlogCategory/future-tech.json +++ b/packages/seed-realm/BlogCategory/future-tech.json @@ -5,12 +5,7 @@ "longName": "Future Tech", "shortName": "Tech", "slug": "future-tech", - "backgroundColor": { - "hexValue": "#000000" - }, - "textColor": { - "hexValue": "#ffffff" - }, + "pillColor": "#000000", "description": "Highlighting technology shaping tomorrow’s cities.", "title": "Future Tech", "thumbnailURL": null diff --git a/packages/seed-realm/BlogCategory/street-life.json b/packages/seed-realm/BlogCategory/street-life.json index 7f36e6dbec..63b9f3e443 100644 --- a/packages/seed-realm/BlogCategory/street-life.json +++ b/packages/seed-realm/BlogCategory/street-life.json @@ -5,12 +5,7 @@ "longName": "Street Life", "shortName": "Streets", "slug": "street-life", - "backgroundColor": { - "hexValue": "#39B1FF" - }, - "textColor": { - "hexValue": "#ffffff" - }, + "pillColor": "#39B1FF", "description": "Discovering the stories of streets and public spaces.", "title": "Street Life", "thumbnailURL": null diff --git a/packages/seed-realm/BlogCategory/urban-work.json b/packages/seed-realm/BlogCategory/urban-work.json index 73b9a62501..324437fbcf 100644 --- a/packages/seed-realm/BlogCategory/urban-work.json +++ b/packages/seed-realm/BlogCategory/urban-work.json @@ -5,12 +5,7 @@ "longName": "Urban Work", "shortName": "Work", "slug": "urban-work", - "backgroundColor": { - "hexValue": "#A6F4CA" - }, - "textColor": { - "hexValue": "#000000" - }, + "pillColor": "#A6F4CA", "description": "Exploring work trends in the evolving city landscape.", "title": "Work", "thumbnailURL": null diff --git a/packages/seed-realm/CatalogEntry/blog.json b/packages/seed-realm/CatalogEntry/blog.json index 74bfeaac3e..797241f604 100644 --- a/packages/seed-realm/CatalogEntry/blog.json +++ b/packages/seed-realm/CatalogEntry/blog.json @@ -4,7 +4,7 @@ "attributes": { "title": "Blog App", "description": "Catalog entry for Blog App card", - "isField": false, + "specType": "card", "ref": { "module": "../blog-app", "name": "BlogApp" diff --git a/packages/seed-realm/CatalogEntry/hello-world.json b/packages/seed-realm/CatalogEntry/hello-world.json index dc95c9c26f..1726de079a 100644 --- a/packages/seed-realm/CatalogEntry/hello-world.json +++ b/packages/seed-realm/CatalogEntry/hello-world.json @@ -4,7 +4,7 @@ "attributes": { "title": "Hello World", "description": "Catalog entry for Hello World card", - "isField": false, + "specType": "card", "ref": { "module": "../hello-world", "name": "HelloWorld" diff --git a/packages/seed-realm/CatalogEntry/mortgage-calculator.json b/packages/seed-realm/CatalogEntry/mortgage-calculator.json index 37742a1b12..59b7f8ca9e 100644 --- a/packages/seed-realm/CatalogEntry/mortgage-calculator.json +++ b/packages/seed-realm/CatalogEntry/mortgage-calculator.json @@ -4,7 +4,7 @@ "attributes": { "title": "Mortgage Calculator", "description": "Catalog entry for Mortgage Calculator card", - "isField": false, + "specType": "card", "ref": { "module": "../mortgage-calculator", "name": "MortgageCalculator" diff --git a/packages/seed-realm/CatalogEntry/product-list.json b/packages/seed-realm/CatalogEntry/product-list.json index a638a145e3..c9d3c549af 100644 --- a/packages/seed-realm/CatalogEntry/product-list.json +++ b/packages/seed-realm/CatalogEntry/product-list.json @@ -4,7 +4,7 @@ "attributes": { "title": "Product List", "description": "Catalog entry for Product List card", - "isField": false, + "specType": "card", "ref": { "module": "../product-list", "name": "ProductList" diff --git a/packages/seed-realm/CatalogEntry/product-with-video.json b/packages/seed-realm/CatalogEntry/product-with-video.json index f0e8b75637..76b5106f2a 100644 --- a/packages/seed-realm/CatalogEntry/product-with-video.json +++ b/packages/seed-realm/CatalogEntry/product-with-video.json @@ -4,7 +4,7 @@ "attributes": { "title": "Product with Video", "description": "Catalog entry for Product with Video card", - "isField": false, + "specType": "card", "ref": { "module": "../product-with-video", "name": "ProductWithVideo" diff --git a/packages/seed-realm/CatalogEntry/product.json b/packages/seed-realm/CatalogEntry/product.json index e248963649..e92951fe2c 100644 --- a/packages/seed-realm/CatalogEntry/product.json +++ b/packages/seed-realm/CatalogEntry/product.json @@ -4,7 +4,7 @@ "attributes": { "title": "Product", "description": "Catalog entry for Product card", - "isField": false, + "specType": "card", "ref": { "module": "../product", "name": "Product" diff --git a/packages/seed-realm/CatalogEntry/review-blog.json b/packages/seed-realm/CatalogEntry/review-blog.json index 0fa3f44b1d..363a264589 100644 --- a/packages/seed-realm/CatalogEntry/review-blog.json +++ b/packages/seed-realm/CatalogEntry/review-blog.json @@ -4,7 +4,7 @@ "attributes": { "title": "Review Blog", "description": "Catalog entry for Review Blog card", - "isField": false, + "specType": "card", "ref": { "module": "../review-blog", "name": "ReviewBlog" diff --git a/packages/seed-realm/CatalogEntry/review.json b/packages/seed-realm/CatalogEntry/review.json index e42b7ef8cb..e82cfcb94e 100644 --- a/packages/seed-realm/CatalogEntry/review.json +++ b/packages/seed-realm/CatalogEntry/review.json @@ -4,7 +4,7 @@ "attributes": { "title": "Review", "description": "Catalog entry for Review card", - "isField": false, + "specType": "card", "ref": { "module": "../review", "name": "Review" diff --git a/packages/seed-realm/CatalogEntry/seller.json b/packages/seed-realm/CatalogEntry/seller.json index 30fb06df7f..6891025d3a 100644 --- a/packages/seed-realm/CatalogEntry/seller.json +++ b/packages/seed-realm/CatalogEntry/seller.json @@ -4,11 +4,11 @@ "attributes": { "title": "Seller", "description": "Catalog entry for Seller card", - "isField": false, "ref": { "module": "../seller", "name": "Seller" - } + }, + "specType": "card" }, "meta": { "adoptsFrom": { diff --git a/packages/seed-realm/CatalogEntry/skill.json b/packages/seed-realm/CatalogEntry/skill.json index 8da82b5309..82ad2dfff9 100644 --- a/packages/seed-realm/CatalogEntry/skill.json +++ b/packages/seed-realm/CatalogEntry/skill.json @@ -4,11 +4,11 @@ "attributes": { "title": "Skill Card", "description": "Catalog entry for Skill card", - "isField": false, "ref": { "module": "../skill-card", "name": "SkillCard" - } + }, + "specType": "card" }, "meta": { "adoptsFrom": { diff --git a/packages/seed-realm/CatalogEntry/sprint-planner.json b/packages/seed-realm/CatalogEntry/sprint-planner.json index 79253670da..68c725f912 100644 --- a/packages/seed-realm/CatalogEntry/sprint-planner.json +++ b/packages/seed-realm/CatalogEntry/sprint-planner.json @@ -4,11 +4,11 @@ "attributes": { "title": "Sprint Planner", "description": "Catalog entry for Sprint Planner App card", - "isField": false, "ref": { "module": "../sprint-planner", "name": "SprintPlanner" - } + }, + "specType": "card" }, "meta": { "adoptsFrom": { diff --git a/packages/seed-realm/CatalogEntry/sprint-task.json b/packages/seed-realm/CatalogEntry/sprint-task.json index 1725e33d70..649e6292e1 100644 --- a/packages/seed-realm/CatalogEntry/sprint-task.json +++ b/packages/seed-realm/CatalogEntry/sprint-task.json @@ -8,7 +8,7 @@ "name": "SprintTask", "module": "../sprint-task" }, - "isField": false + "specType": "card" }, "meta": { "adoptsFrom": { diff --git a/packages/seed-realm/CatalogEntry/tag.json b/packages/seed-realm/CatalogEntry/tag.json index db4d9831a1..f047e87bf5 100644 --- a/packages/seed-realm/CatalogEntry/tag.json +++ b/packages/seed-realm/CatalogEntry/tag.json @@ -8,7 +8,7 @@ "name": "Tag", "module": "../tag" }, - "isField": false + "specType": "card" }, "meta": { "adoptsFrom": { diff --git a/packages/seed-realm/CatalogEntry/task.json b/packages/seed-realm/CatalogEntry/task.json index be66653e3c..6cf4ce5137 100644 --- a/packages/seed-realm/CatalogEntry/task.json +++ b/packages/seed-realm/CatalogEntry/task.json @@ -8,7 +8,7 @@ "name": "Task", "module": "../task" }, - "isField": false + "specType": "card" }, "meta": { "adoptsFrom": { diff --git a/packages/seed-realm/CatalogEntry/team-member.json b/packages/seed-realm/CatalogEntry/team-member.json index 0f04ce5802..1413b89a67 100644 --- a/packages/seed-realm/CatalogEntry/team-member.json +++ b/packages/seed-realm/CatalogEntry/team-member.json @@ -8,7 +8,7 @@ "name": "TeamMember", "module": "../sprint-task" }, - "isField": false + "specType": "card" }, "meta": { "adoptsFrom": { diff --git a/packages/seed-realm/CatalogEntry/team.json b/packages/seed-realm/CatalogEntry/team.json index d259682406..f6798ed682 100644 --- a/packages/seed-realm/CatalogEntry/team.json +++ b/packages/seed-realm/CatalogEntry/team.json @@ -8,7 +8,7 @@ "name": "Team", "module": "../sprint-task" }, - "isField": false + "specType": "card" }, "meta": { "adoptsFrom": { diff --git a/packages/seed-realm/CatalogEntry/todo.json b/packages/seed-realm/CatalogEntry/todo.json index a8e6108852..68241de8f0 100644 --- a/packages/seed-realm/CatalogEntry/todo.json +++ b/packages/seed-realm/CatalogEntry/todo.json @@ -8,7 +8,7 @@ "name": "Todo", "module": "../todo" }, - "isField": false + "specType": "card" }, "meta": { "adoptsFrom": { diff --git a/packages/seed-realm/CatalogEntry/video-product.json b/packages/seed-realm/CatalogEntry/video-product.json index 24d10394df..80f74bd06f 100644 --- a/packages/seed-realm/CatalogEntry/video-product.json +++ b/packages/seed-realm/CatalogEntry/video-product.json @@ -4,7 +4,7 @@ "attributes": { "title": "Video Product", "description": "Catalog entry for Video Product card", - "isField": false, + "specType": "card", "ref": { "module": "../video-product", "name": "VideoProduct" diff --git a/packages/seed-realm/blog-category.gts b/packages/seed-realm/blog-category.gts index ba8bb0a2aa..e4c3854dce 100644 --- a/packages/seed-realm/blog-category.gts +++ b/packages/seed-realm/blog-category.gts @@ -9,19 +9,21 @@ import StringField from 'https://cardstack.com/base/string'; import { BlogApp as BlogAppCard } from './blog-app'; import { htmlSafe } from '@ember/template'; import { ColorField } from './fields/color'; - -function htmlSafeColor(color?: string) { - return htmlSafe(`background-color: ${color || ''}`); -} +import { cssVar, getContrastColor } from '@cardstack/boxel-ui/helpers'; export const categoryStyle = (category: Partial) => { if (!category) { return; } + const pillColor = category.pillColor ?? '#e8e8e8'; // var(--boxel-200) + const borderColor = category.pillColor ?? '#d3d3d3'; // var(--boxel-border-color) return htmlSafe(` - background-color: ${category.backgroundColor?.hexValue || '#000000'}; - color: ${category.textColor?.hexValue || '#ffffff'}; - `); + background-color: ${pillColor}; + color: ${getContrastColor(pillColor, undefined, undefined, { + isSmallText: true, + })}; + border: 1px solid ${borderColor} + `); }; let BlogCategoryTemplate = class Embedded extends Component< @@ -34,15 +36,17 @@ let BlogCategoryTemplate = class Embedded extends Component< padding: var(--boxel-sp); } .category-name { - padding: var(--boxel-sp-xxs); - color: white; + padding: var(--boxel-sp-xxxs) var(--boxel-sp-xs); border-radius: var(--boxel-border-radius-sm); - font-weight: bold; + font: 600 var(--boxel-font-xs); + letter-spacing: var(--boxel-lsp-sm); display: inline-block; } .category-label { - color: var(--boxel-400); - margin-top: var(--boxel-sp-sm); + color: var(--boxel-450); + font: 500 var(--boxel-font-xs); + letter-spacing: var(--boxel-lsp-sm); + margin-top: var(--boxel-sp); } .category-full-name { font-size: var(--boxel-font-size); @@ -50,11 +54,11 @@ let BlogCategoryTemplate = class Embedded extends Component< } .category-description { margin-top: var(--boxel-sp-sm); - color: var(--boxel-400); + font: 400 var(--boxel-font-xs); + letter-spacing: var(--boxel-lsp-sm); }
- {{! template-lint-disable no-inline-styles }}
<@fields.shortName />
@@ -75,8 +79,7 @@ export class BlogCategory extends CardDef { @field longName = contains(StringField); @field shortName = contains(StringField); @field slug = contains(StringField); - @field backgroundColor = contains(ColorField); - @field textColor = contains(ColorField); + @field pillColor = contains(ColorField); @field description = contains(StringField); @field blog = linksTo(BlogAppCard, { isUsed: true }); @@ -91,18 +94,16 @@ export class BlogCategory extends CardDef { border-radius: 50%; display: inline-block; margin-right: var(--boxel-sp-xxs); + background-color: var(--category-swatch); } .category-atom { display: inline-flex; align-items: center; + font-weight: 600; }
- {{! template-lint-disable no-inline-styles }} -
+
<@fields.longName />
@@ -116,14 +117,16 @@ export class BlogCategory extends CardDef { padding: var(--boxel-sp-xs); } .category-name { - padding: var(--boxel-sp-xxs); - color: white; + padding: var(--boxel-sp-xxxs) var(--boxel-sp-xs); border-radius: var(--boxel-border-radius-sm); - font-weight: bold; + font: 600 var(--boxel-font-xs); + letter-spacing: var(--boxel-lsp-sm); display: inline-block; } .category-label { - color: var(--boxel-400); + color: var(--boxel-450); + font: 500 var(--boxel-font-xs); + letter-spacing: var(--boxel-lsp-sm); margin-top: var(--boxel-sp-sm); } .category-full-name { @@ -136,12 +139,13 @@ export class BlogCategory extends CardDef { } .category-description { margin-top: var(--boxel-sp-sm); - color: var(--boxel-400); display: -webkit-box; -webkit-box-orient: vertical; -webkit-line-clamp: 3; overflow: hidden; text-overflow: ellipsis; + font: 400 var(--boxel-font-sm); + letter-spacing: var(--boxel-lsp); } @container fitted-card ((aspect-ratio <= 0.92) and (height <= 182px)) { .category-description { @@ -201,7 +205,6 @@ export class BlogCategory extends CardDef { }
- {{! template-lint-disable no-inline-styles }}
<@fields.shortName />
diff --git a/packages/seed-realm/blog-post.gts b/packages/seed-realm/blog-post.gts index d5a424f55e..f9faf24ac9 100644 --- a/packages/seed-realm/blog-post.gts +++ b/packages/seed-realm/blog-post.gts @@ -31,10 +31,9 @@ class EmbeddedTemplate extends Component { {{#if @model.categories.length}}
{{#each @model.categories as |category|}} -
{{category.shortName}}
+
+ {{category.shortName}} +
{{/each}}
{{/if}} @@ -111,6 +110,9 @@ class EmbeddedTemplate extends Component { .categories { margin-top: var(--boxel-sp); + display: flex; + flex-wrap: wrap; + gap: var(--boxel-sp-xxxs); } .category { @@ -130,10 +132,9 @@ class FittedTemplate extends Component {
{{#each @model.categories as |category|}} -
{{category.shortName}}
+
+ {{category.shortName}} +
{{/each}}
@@ -217,16 +218,16 @@ class FittedTemplate extends Component { height: 20px; margin-left: 7px; display: none; + overflow: hidden; } .category { - font-size: 0.6rem; - height: 18px; + height: 20px; padding: 3px 4px; border-radius: var(--boxel-border-radius-sm); display: inline-block; - font-family: var(--boxel-font-family); - font-weight: 600; + font: 500 var(--boxel-font-xs); + letter-spacing: var(--boxel-lsp-sm); margin-right: var(--boxel-sp-xxxs); overflow: hidden; text-overflow: ellipsis; @@ -713,10 +714,9 @@ export class BlogPost extends CardDef { {{#if @model.categories.length}}
{{#each @model.categories as |category|}} -
{{category.shortName}}
+
+ {{category.shortName}} +
{{/each}}
{{/if}} @@ -841,13 +841,19 @@ export class BlogPost extends CardDef { } .categories { margin-top: var(--boxel-sp); + display: flex; + flex-wrap: wrap; + gap: var(--boxel-sp-xxs); + } + .featured-image + .categories { + margin-top: var(--boxel-sp-xl); } .category { display: inline-block; padding: 3px var(--boxel-sp-xxxs); border-radius: var(--boxel-border-radius-sm); - font: 500 var(--boxel-font-sm); - letter-spacing: var(--boxel-lsp-xs); + font: 500 var(--boxel-font-xs); + letter-spacing: var(--boxel-lsp-sm); } diff --git a/packages/seed-realm/fields/color.gts b/packages/seed-realm/fields/color.gts index a1d5221192..54c5618d04 100644 --- a/packages/seed-realm/fields/color.gts +++ b/packages/seed-realm/fields/color.gts @@ -1,37 +1,22 @@ -import { - Component, - FieldDef, - StringField, - contains, - field, -} from 'https://cardstack.com/base/card-api'; -import { ColorPalette, ColorPicker } from '@cardstack/boxel-ui/components'; +import { Component, StringField } from 'https://cardstack.com/base/card-api'; +import { ColorPalette } from '@cardstack/boxel-ui/components'; +import { ColorPicker } from '@cardstack/boxel-ui/components'; class View extends Component { } class EditView extends Component { - setColor = (color: string) => { - this.args.model.hexValue = color; - }; - } -export class ColorField extends FieldDef { +export class ColorField extends StringField { static displayName = 'Color'; - @field hexValue = contains(StringField); - static isolated = View; static embedded = View; static atom = View; static fitted = View; diff --git a/packages/seed-realm/monetary-amount.gts b/packages/seed-realm/monetary-amount.gts index c403b70f27..b42face527 100644 --- a/packages/seed-realm/monetary-amount.gts +++ b/packages/seed-realm/monetary-amount.gts @@ -13,9 +13,6 @@ import { getCards } from '@cardstack/runtime-common'; import { guidFor } from '@ember/object/internals'; import GlimmerComponent from '@glimmer/component'; -// TODO: should this be configurable? -const CURRENCIES_REALM_URL = 'http://localhost:4201/experiments/'; - interface MonetaryAmountAtomSignature { Element: HTMLSpanElement; Args: { @@ -44,21 +41,21 @@ class Edit extends Component { { filter: { type: { - module: `${CURRENCIES_REALM_URL}asset`, + module: new URL('./asset', import.meta.url).href, name: 'Currency', }, }, sort: [ { on: { - module: `${CURRENCIES_REALM_URL}asset`, + module: new URL('./asset', import.meta.url).href, name: 'Currency', }, by: 'name', }, ], }, - [CURRENCIES_REALM_URL], + [new URL('./', import.meta.url).href], ); @action