From e98af4d1266cdc2cc8f1dc35f471f90b6da5efb3 Mon Sep 17 00:00:00 2001
From: Matic Jurglic <matic@jurglic.si>
Date: Thu, 30 Jan 2025 14:46:51 +0100
Subject: [PATCH] Add support for adding computed fields

---
 packages/base/command.gts                     |  1 +
 .../commands/add-field-to-card-definition.ts  |  3 +-
 ...-field-to-card-definition-command-test.gts | 56 +++++++++++++++++--
 .../realm-server/tests/module-syntax-test.ts  | 47 ++++++++++++++++
 packages/runtime-common/module-syntax.ts      | 15 +++++
 5 files changed, 116 insertions(+), 6 deletions(-)

diff --git a/packages/base/command.gts b/packages/base/command.gts
index b9e65e54f8..4714fb3f03 100644
--- a/packages/base/command.gts
+++ b/packages/base/command.gts
@@ -135,6 +135,7 @@ export class AddFieldToCardDefinitionInput extends CardDef {
   @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 sourceCodeForComputedField = contains(StringField); // if provided, the field will be added as a computed field
 }
 
 export {
diff --git a/packages/host/app/commands/add-field-to-card-definition.ts b/packages/host/app/commands/add-field-to-card-definition.ts
index c533eeb1da..857b927f02 100644
--- a/packages/host/app/commands/add-field-to-card-definition.ts
+++ b/packages/host/app/commands/add-field-to-card-definition.ts
@@ -38,7 +38,7 @@ export default class AddFieldToCardDefinitionCommand extends HostBaseCommand<
       cardBeingModified: input.cardDefinitionToModify,
       fieldName: input.fieldName,
       fieldRef: input.fieldRef,
-      fieldType: input.fieldDefinitionType as FieldType,
+      fieldType: input.fieldType as FieldType,
       fieldDefinitionType: input.fieldDefinitionType as 'field' | 'card',
       incomingRelativeTo: input.incomingRelativeTo
         ? new URL(input.incomingRelativeTo)
@@ -50,6 +50,7 @@ export default class AddFieldToCardDefinitionCommand extends HostBaseCommand<
         ? new URL(input.outgoingRealmURL)
         : undefined,
       addFieldAtIndex: input.addFieldAtIndex,
+      sourceCodeForComputedField: input.sourceCodeForComputedField,
     });
 
     let writeTextFileCommand = new WriteTextFileCommand(this.commandContext);
diff --git a/packages/host/tests/integration/commands/add-field-to-card-definition-command-test.gts b/packages/host/tests/integration/commands/add-field-to-card-definition-command-test.gts
index c746f2aa0b..8d04e34c3b 100644
--- a/packages/host/tests/integration/commands/add-field-to-card-definition-command-test.gts
+++ b/packages/host/tests/integration/commands/add-field-to-card-definition-command-test.gts
@@ -49,10 +49,10 @@ module(
         contents: {
           'person.gts': `
           import { contains, field, Component, CardDef } from "https://cardstack.com/base/card-api";
-          import StringCard from "https://cardstack.com/base/string";
+          import StringField from "https://cardstack.com/base/string";
           export class Person extends CardDef {
             static displayName = 'Person';
-            @field firstName = contains(StringCard);
+            @field firstName = contains(StringField);
           }
         `,
         },
@@ -87,15 +87,61 @@ module(
         response,
         `
           import { contains, field, Component, CardDef } from "https://cardstack.com/base/card-api";
-          import StringCard from "https://cardstack.com/base/string";
+          import StringField from "https://cardstack.com/base/string";
           export class Person extends CardDef {
             static displayName = 'Person';
-            @field firstName = contains(StringCard);
-            @field lastName = field(StringCard);
+            @field firstName = contains(StringField);
+            @field lastName = field(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,
+        sourceCodeForComputedField: '`Lil ${this.firstName}`;',
+      });
+
+      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 () {
+                return \`Lil \${this.firstName}\`;
+              },
+            });
+          }
+        `,
+      );
+    });
   },
 );
diff --git a/packages/realm-server/tests/module-syntax-test.ts b/packages/realm-server/tests/module-syntax-test.ts
index 3a7eb5a71d..094ee8731a 100644
--- a/packages/realm-server/tests/module-syntax-test.ts
+++ b/packages/realm-server/tests/module-syntax-test.ts
@@ -853,6 +853,53 @@ 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,
+        sourceCodeForComputedField:
+          "[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";
diff --git a/packages/runtime-common/module-syntax.ts b/packages/runtime-common/module-syntax.ts
index eba50d1bee..efe67c4a25 100644
--- a/packages/runtime-common/module-syntax.ts
+++ b/packages/runtime-common/module-syntax.ts
@@ -117,6 +117,7 @@ export class ModuleSyntax {
     outgoingRelativeTo,
     outgoingRealmURL,
     addFieldAtIndex,
+    sourceCodeForComputedField,
   }: {
     cardBeingModified: CodeRef;
     fieldName: string;
@@ -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
+    sourceCodeForComputedField?: string; // if provided, the field will be added as a computed field
   }) {
     let card = this.getCard(cardBeingModified);
     if (card.possibleFields.has(fieldName)) {
@@ -147,6 +149,7 @@ export class ModuleSyntax {
       outgoingRelativeTo,
       outgoingRealmURL,
       moduleURL: this.url,
+      sourceCodeForComputedField,
     });
 
     let src = this.code();
@@ -372,6 +375,7 @@ function makeNewField({
   outgoingRelativeTo,
   outgoingRealmURL,
   moduleURL,
+  sourceCodeForComputedField,
 }: {
   target: NodePath<t.Node>;
   fieldRef: { name: string; module: string };
@@ -383,6 +387,7 @@ function makeNewField({
   outgoingRelativeTo: URL | undefined;
   outgoingRealmURL: URL | undefined;
   moduleURL: URL;
+  sourceCodeForComputedField?: string;
 }): string {
   let programPath = getProgramPath(target);
   //@ts-ignore ImportUtil doesn't seem to believe our Babel.types is a
@@ -395,6 +400,7 @@ function makeNewField({
     `${baseRealm.url}card-api`,
     'field',
   );
+  debugger;
   let fieldTypeIdentifier = importUtil.import(
     target as NodePath<any>,
     `${baseRealm.url}card-api`,
@@ -434,6 +440,15 @@ function makeNewField({
     suggestedCardName(fieldRef, fieldDefinitionType),
   );
 
+  if (sourceCodeForComputedField) {
+    debugger;
+    return `@${fieldDecorator.name} ${fieldName} = ${fieldTypeIdentifier.name}(${fieldCardIdentifier.name}, {
+              computeVia: function () {
+                return ${sourceCodeForComputedField}
+              },
+            });`;
+  }
+
   return `@${fieldDecorator.name} ${fieldName} = ${fieldTypeIdentifier.name}(${fieldCardIdentifier.name});`;
 }