Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Host command to add a field to a card definition #2106

Merged
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 16 additions & 0 deletions packages/base/command.gts
Original file line number Diff line number Diff line change
Expand Up @@ -123,6 +123,22 @@ export class OpenAiAssistantRoomInput extends CardDef {
@field roomId = contains(StringField);
}

export class AddFieldToCardDefinitionInput extends CardDef {
@field realm = contains(StringField);
@field path = contains(StringField);
@field cardDefinitionToModify = contains(CodeRefField);
@field fieldName = contains(StringField);
@field module = contains(StringField);
@field fieldType = contains(StringField); // 'contains' or 'containsMany'
@field fieldRef = contains(CodeRefField);
@field fieldDefinitionType = contains(StringField); // 'card' or 'field'
@field incomingRelativeTo = contains(StringField); // can be undefined when you know the url is not going to be relative
@field outgoingRelativeTo = contains(StringField); // can be undefined when you know url is not going to be relative
@field outgoingRealmURL = contains(StringField); // should be provided when the other 2 params are provided
@field addFieldAtIndex = contains(NumberField); // if provided, the field will be added at the specified index in the card's possibleFields map
@field computedFieldFunctionSourceCode = contains(StringField); // if provided, the field will be added as a computed field
}

export {
SearchCardsByQueryInput,
SearchCardsByTypeAndTitleInput,
Expand Down
64 changes: 64 additions & 0 deletions packages/host/app/commands/add-field-to-card-definition.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
import { service } from '@ember/service';

import { ModuleSyntax } from '@cardstack/runtime-common/module-syntax';

import { FieldType } from 'https://cardstack.com/base/card-api';
import type * as BaseCommandModule from 'https://cardstack.com/base/command';

import HostBaseCommand from '../lib/host-base-command';

import WriteTextFileCommand from './write-text-file';

import type CardService from '../services/card-service';

export default class AddFieldToCardDefinitionCommand extends HostBaseCommand<
typeof BaseCommandModule.AddFieldToCardDefinitionInput
> {
@service private declare cardService: CardService;

async getInputType() {
let commandModule = await this.loadCommandModule();
const { AddFieldToCardDefinitionInput } = commandModule;
return AddFieldToCardDefinitionInput;
}

protected async run(
input: BaseCommandModule.AddFieldToCardDefinitionInput,
): Promise<undefined> {
let moduleSource = await this.cardService.getSource(
new URL(input.cardDefinitionToModify.module),
);

let moduleSyntax = new ModuleSyntax(
moduleSource,
new URL(input.cardDefinitionToModify.module),
);

moduleSyntax.addField({
cardBeingModified: input.cardDefinitionToModify,
fieldName: input.fieldName,
fieldRef: input.fieldRef,
fieldType: input.fieldType as FieldType,
fieldDefinitionType: input.fieldDefinitionType as 'field' | 'card',
incomingRelativeTo: input.incomingRelativeTo
? new URL(input.incomingRelativeTo)
: undefined,
outgoingRelativeTo: input.outgoingRelativeTo
? new URL(input.outgoingRelativeTo)
: undefined,
outgoingRealmURL: input.outgoingRealmURL
? new URL(input.outgoingRealmURL)
: undefined,
addFieldAtIndex: input.addFieldAtIndex,
computedFieldFunctionSourceCode: input.computedFieldFunctionSourceCode,
});

let writeTextFileCommand = new WriteTextFileCommand(this.commandContext);
await writeTextFileCommand.execute({
content: moduleSyntax.code(),
realm: input.realm,
path: input.cardDefinitionToModify.module + '.gts',
overwrite: true,
});
}
}
9 changes: 9 additions & 0 deletions packages/host/app/services/card-service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -259,6 +259,15 @@ export default class CardService extends Service {
}
}

async getSource(url: URL) {
let response = await this.network.authedFetch(url, {
headers: {
Accept: 'application/vnd.card+source',
},
});
return response.text();
}

async saveSource(url: URL, content: string) {
let response = await this.network.authedFetch(url, {
method: 'POST',
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,155 @@
import { getOwner } from '@ember/owner';
import { RenderingTestContext } from '@ember/test-helpers';

import { module, test } from 'qunit';

import { Loader } from '@cardstack/runtime-common';

import AddFieldToCardDefinitionCommand from '@cardstack/host/commands/add-field-to-card-definition';
import CardService from '@cardstack/host/services/card-service';
import type CommandService from '@cardstack/host/services/command-service';

import RealmService from '@cardstack/host/services/realm';

import {
setupIntegrationTestRealm,
setupLocalIndexing,
lookupLoaderService,
lookupService,
testRealmURL,
testRealmInfo,
} from '../../helpers';
import { setupRenderingTest } from '../../helpers/setup';

let loader: Loader;

class StubRealmService extends RealmService {
get defaultReadableRealm() {
return {
path: testRealmURL,
info: testRealmInfo,
};
}
}

module(
'Integration | commands | add-field-to-card-definition',
function (hooks) {
setupRenderingTest(hooks);
setupLocalIndexing(hooks);

hooks.beforeEach(function (this: RenderingTestContext) {
getOwner(this)!.register('service:realm', StubRealmService);
loader = lookupLoaderService().loader;
});

hooks.beforeEach(async function () {
await setupIntegrationTestRealm({
loader,
contents: {
'person.gts': `
import { contains, field, Component, CardDef } from "https://cardstack.com/base/card-api";
import StringField from "https://cardstack.com/base/string";
export class Person extends CardDef {
static displayName = 'Person';
@field firstName = contains(StringField);
}
`,
},
realmURL: 'http://test-realm/test/',
});
});

test('adds a field to a card definition', async function (assert) {
let commandService = lookupService<CommandService>('command-service');
let cardService = lookupService<CardService>('card-service');
let addFieldToCardDefinitionCommand = new AddFieldToCardDefinitionCommand(
commandService.commandContext,
);

await addFieldToCardDefinitionCommand.execute({
cardDefinitionToModify: {
module: 'http://test-realm/test/person',
name: 'Person',
},
fieldName: 'lastName',
fieldDefinitionType: 'field',
fieldRef: {
module: 'https://cardstack.com/base/string',
name: 'default',
},
fieldType: 'contains',
});
let response = await cardService.getSource(
new URL('person.gts', testRealmURL),
);
assert.strictEqual(
response,
`
import { contains, field, Component, CardDef } from "https://cardstack.com/base/card-api";
import StringField from "https://cardstack.com/base/string";
export class Person extends CardDef {
static displayName = 'Person';
@field firstName = contains(StringField);
@field lastName = contains(StringField);
}
`,
'lastName field was added to the card definition',
);
});

test('can add a computed field', async function (assert) {
let commandService = lookupService<CommandService>('command-service');
let cardService = lookupService<CardService>('card-service');
let addFieldToCardDefinitionCommand = new AddFieldToCardDefinitionCommand(
commandService.commandContext,
);

await addFieldToCardDefinitionCommand.execute({
cardDefinitionToModify: {
module: 'http://test-realm/test/person',
name: 'Person',
},
fieldName: 'rapName',
fieldDefinitionType: 'field',
fieldType: 'contains',
fieldRef: {
module: 'https://cardstack.com/base/string',
name: 'default',
},
incomingRelativeTo: undefined,
outgoingRelativeTo: undefined,
outgoingRealmURL: undefined,
computedFieldFunctionSourceCode: `
function () {
let prefix = this.firstName.length > 5 ? 'Big' : 'Lil';
let nickname = this.firstName.toUpperCase();
return \`\${prefix} \${nickname}\`;
}`,
});

let response = await cardService.getSource(
new URL('person.gts', testRealmURL),
);
assert.strictEqual(
response,
`
import { contains, field, Component, CardDef } from "https://cardstack.com/base/card-api";
import StringField from "https://cardstack.com/base/string";
export class Person extends CardDef {
static displayName = 'Person';
@field firstName = contains(StringField);
@field rapName = contains(StringField, {
computeVia: function () {
let prefix = this.firstName.length > 5 ? 'Big' : 'Lil';
let nickname = this.firstName.toUpperCase();
return \`\${prefix} \${nickname}\`;
}
});
}
`,
'computed field was added to the card definition',
);
});
},
);
49 changes: 49 additions & 0 deletions packages/realm-server/tests/module-syntax-test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -849,6 +849,55 @@ module(basename(__filename), function () {
);
});

test('can add a contains field with a computed value', async function (assert) {
let src = `
import { contains, field, CardDef } from "https://cardstack.com/base/card-api";
import StringField from "https://cardstack.com/base/string";

export class Person extends CardDef {
@field firstName = contains(StringField);
@field lastName = contains(StringField);
}
`;

let mod = new ModuleSyntax(src, new URL(`${testRealm}dir/person`));
mod.addField({
cardBeingModified: { module: `${testRealm}dir/person`, name: 'Person' },
fieldName: 'fullName',
fieldType: 'contains',
fieldDefinitionType: 'field',
fieldRef: {
module: 'https://cardstack.com/base/string',
name: 'default',
},
incomingRelativeTo: undefined,
outgoingRelativeTo: undefined,
outgoingRealmURL: undefined,
computedFieldFunctionSourceCode: `
function() {
return [this.firstName, this.lastName].filter(Boolean).join(' ');
}`,
});

assert.codeEqual(
mod.code(),
`
import { contains, field, CardDef } from "https://cardstack.com/base/card-api";
import StringField from "https://cardstack.com/base/string";

export class Person extends CardDef {
@field firstName = contains(StringField);
@field lastName = contains(StringField);
@field fullName = contains(StringField, {
computeVia: function () {
return [this.firstName, this.lastName].filter(Boolean).join(' ');
},
});
}
`,
);
});

test('can handle field card declaration collisions when adding field', async function (assert) {
let src = `
import { contains, field, CardDef } from "https://cardstack.com/base/card-api";
Expand Down
28 changes: 22 additions & 6 deletions packages/runtime-common/module-syntax.ts
Original file line number Diff line number Diff line change
Expand Up @@ -117,6 +117,7 @@ export class ModuleSyntax {
outgoingRelativeTo,
outgoingRealmURL,
addFieldAtIndex,
computedFieldFunctionSourceCode,
}: {
cardBeingModified: CodeRef;
fieldName: string;
Expand All @@ -127,6 +128,7 @@ export class ModuleSyntax {
outgoingRelativeTo: URL | undefined; // can be undefined when you know url is not going to be relative
outgoingRealmURL: URL | undefined; // should be provided when the other 2 params are provided
addFieldAtIndex?: number; // if provided, the field will be added at the specified index in the card's possibleFields map
computedFieldFunctionSourceCode?: string; // if provided, the field will be added as a computed field
}) {
let card = this.getCard(cardBeingModified);
if (card.possibleFields.has(fieldName)) {
Expand All @@ -147,6 +149,7 @@ export class ModuleSyntax {
outgoingRelativeTo,
outgoingRealmURL,
moduleURL: this.url,
computedFieldFunctionSourceCode,
});

let src = this.code();
Expand Down Expand Up @@ -372,6 +375,7 @@ function makeNewField({
outgoingRelativeTo,
outgoingRealmURL,
moduleURL,
computedFieldFunctionSourceCode,
}: {
target: NodePath<t.Node>;
fieldRef: { name: string; module: string };
Expand All @@ -383,6 +387,7 @@ function makeNewField({
outgoingRelativeTo: URL | undefined;
outgoingRealmURL: URL | undefined;
moduleURL: URL;
computedFieldFunctionSourceCode?: string;
}): string {
let programPath = getProgramPath(target);
//@ts-ignore ImportUtil doesn't seem to believe our Babel.types is a
Expand All @@ -395,6 +400,7 @@ function makeNewField({
`${baseRealm.url}card-api`,
'field',
);

let fieldTypeIdentifier = importUtil.import(
target as NodePath<any>,
`${baseRealm.url}card-api`,
Expand Down Expand Up @@ -434,12 +440,22 @@ function makeNewField({
suggestedCardName(fieldRef, fieldDefinitionType),
);

if (
fieldRef.module.startsWith(baseRealm.url) &&
fieldRef.name === 'default'
) {
// primitive fields
return `@${fieldDecorator.name} ${fieldName} = ${fieldTypeIdentifier.name}(${fieldCardIdentifier.name});`;
if (computedFieldFunctionSourceCode) {
let baseIndent = ' '; // Standard 2-space indent
let functionIndent = baseIndent.repeat(2); // Indent level for function body

let indentedFunctionCode = computedFieldFunctionSourceCode
.trim()
.split('\n')
.map((line, i) => {
if (i === 0) return line; // Keep first line as is
return functionIndent + line; // Add indentation for computed function body
})
.join('\n');

return `@${fieldDecorator.name} ${fieldName} = ${fieldTypeIdentifier.name}(${fieldCardIdentifier.name}, {
computeVia: ${indentedFunctionCode}
});`;
}

return `@${fieldDecorator.name} ${fieldName} = ${fieldTypeIdentifier.name}(${fieldCardIdentifier.name});`;
Expand Down