Skip to content

Commit

Permalink
Merge pull request #28 from cardstack/contains-many
Browse files Browse the repository at this point in the history
containsMany support
  • Loading branch information
habdelra committed May 5, 2022
2 parents 022e4ee + 302d240 commit c723304
Show file tree
Hide file tree
Showing 4 changed files with 300 additions and 40 deletions.
119 changes: 90 additions & 29 deletions host/app/lib/card-api.gts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ export const serialize = Symbol('cardstack-serialize');
export const deserialize = Symbol('cardstack-deserialize');

const isField = Symbol('cardstack-field');
const isContainsMany = Symbol('cardstack-contains-many');
const isComputed = Symbol('cardstack-field');

type CardInstanceType<T extends Constructable> = T extends { [primitive]: infer P } ? P : InstanceType<T>;
Expand All @@ -33,6 +34,7 @@ export type Format = 'isolated' | 'embedded' | 'edit';

interface Options {
computeVia?: string | (() => unknown);
containsMany?: true;
}

const deserializedData = new WeakMap<Card, Map<string, any>>();
Expand Down Expand Up @@ -67,8 +69,25 @@ export class Card {
}

constructor(data?: Record<string, any>) {
if (data) {
Object.assign(this, data);
if (data !== undefined) {
for (let [fieldName, value] of Object.entries(data)) {
if (isFieldContainsMany(this.constructor, fieldName)) {
if (value && !Array.isArray(value)) {
throw new Error(`Expected array for field value ${fieldName} for card ${this.constructor.name}`);
}
let field = getField(this.constructor, fieldName);
if (!field) {
continue;
}
if (primitive in field) {
(this as any)[fieldName] = value || [];
} else {
(this as any)[fieldName] = ((value || []) as any[]).map(item => new field!(item));
}
} else {
(this as any)[fieldName] = value;
}
}
}

registerDestructor(this, Card.didRecompute.bind(this));
Expand Down Expand Up @@ -99,34 +118,53 @@ export function serializedGet<CardT extends Constructable>(model: InstanceType<C
value = deserialized.get(fieldName);
if (primitive in (field as any)) {
if (typeof (field as any)[serialize] === 'function') {
value = (field as any)[serialize](value);
if (isFieldContainsMany(model.constructor, fieldName)) {
value = (value as any[]).map(item => (field as any)[serialize](item));
} else {
value = (field as any)[serialize](value);
}
}
} else if (value != null) {
let instance = {} as Record<string, any>;
for (let interiorFieldName of Object.keys(getFields(value))) {
instance[interiorFieldName] = serializedGet(value, interiorFieldName);
if (isFieldContainsMany(model.constructor, fieldName)) {
value = (Object.values(value) as Card[]).map(m => serializeModel(m));
} else {
value = serializeModel(value);
}
value = instance;
}
serialized.set(fieldName, value);
return value;
}

function serializeModel(model: Card) {
return Object.fromEntries(
Object.keys(getFields(model)).map(fieldName => [fieldName, serializedGet(model, fieldName)])
);
}

export function serializedSet<CardT extends Constructable>(model: InstanceType<CardT>, fieldName: string, value: any ) {
let { serialized, deserialized } = getDataBuckets(model);
let field = getField(model.constructor, fieldName);
if (!field) {
throw new Error(`Field ${fieldName} does not exist on ${model.constructor.name}`);
throw new Error(`Field ${fieldName} does not exist in card ${model.constructor.name}`);
}
let isContainsMany = isFieldContainsMany(model.constructor, fieldName);
if (isContainsMany && !Array.isArray(value)) {
throw new Error(`Expected array for field value ${fieldName} for card ${model.constructor.name}`);
}

if (primitive in field) {
serialized.set(fieldName, value);
serialized.set(fieldName, isContainsMany ? value || [] : value);
} else {
let instance = new field();
for (let [ interiorFieldName, interiorValue ] of Object.entries(value)) {
serializedSet(instance, interiorFieldName, interiorValue);
if (isContainsMany) {
value = ((value || []) as any[]).map(item => (field! as typeof Card).fromSerialized(item));
serialized.set(fieldName, value);
} else {
let instance = new field();
for (let [ interiorFieldName, interiorValue ] of Object.entries(value)) {
serializedSet(instance, interiorFieldName, interiorValue);
}
serialized.set(fieldName, instance);
}
serialized.set(fieldName, instance);
}
deserialized.delete(fieldName);
}
Expand All @@ -146,8 +184,12 @@ export function serializeCard<CardT extends Constructable>(model: InstanceType<C
return resource;
}

export function containsMany<CardT extends Constructable>(card: CardT | (() => CardT), options?: Options): CardInstanceType<CardT>[] {
return contains(card, { ...options, containsMany: true }) as CardInstanceType<CardT>[];
}

export function contains<CardT extends Constructable>(card: CardT | (() => CardT), options?: Options): CardInstanceType<CardT> {
let { computeVia } = options ?? {};
let { computeVia, containsMany } = options ?? {};
let computedGet = function (fieldName: string) {
return function(this: InstanceType<CardT>) {
let { deserialized } = getDataBuckets(this);
Expand Down Expand Up @@ -178,12 +220,17 @@ export function contains<CardT extends Constructable>(card: CardT | (() => CardT
value = serialized.get(fieldName);
let field = getField(this.constructor, fieldName);
if (typeof (field as any)[deserialize] === 'function') {
value = (field as any)[deserialize](value);
if (isFieldContainsMany(this.constructor, fieldName)) {
value = (value as any[]).map(item => (field as any)[deserialize](item));
} else {
value = (field as any)[deserialize](value);
}
}
deserialized.set(fieldName, value);
return value;
};
(get as any)[isField] = card;
(get as any)[isContainsMany] = Boolean(containsMany);
(get as any)[isComputed] = Boolean(computeVia);
return {
enumerable: true,
Expand Down Expand Up @@ -232,6 +279,7 @@ export function contains<CardT extends Constructable>(card: CardT | (() => CardT
return value;
};
(get as any)[isField] = card;
(get as any)[isContainsMany] = Boolean(containsMany);
(get as any)[isComputed] = Boolean(computeVia);
return {
enumerable: true,
Expand Down Expand Up @@ -395,32 +443,35 @@ async function loadField<T extends Card, K extends keyof T>(model: T, fieldName:
}

function getField<CardT extends Constructable>(card: CardT, fieldName: string): Constructable | undefined {
let obj = card.prototype;
while (obj) {
let desc = Reflect.getOwnPropertyDescriptor(obj, fieldName);
let fieldCard = (desc?.get as any)?.[isField] as CardT | (() => CardT);
if (fieldCard) {
return "baseCard" in fieldCard ? fieldCard : (fieldCard as () => CardT)();
}
obj = Reflect.getPrototypeOf(obj);
let result = getFieldDescriptorAttr(card, fieldName, isField) as CardT | (() => CardT) | undefined;
if (result) {
return "baseCard" in result ? result : (result as () => CardT)();
}
return undefined
return undefined;
}

function isFieldComputed<CardT extends Constructable>(card: CardT, fieldName: string): boolean {
function isFieldContainsMany<CardT extends Constructable>(card: CardT, fieldName: string): boolean | undefined {
return getFieldDescriptorAttr(card, fieldName, isContainsMany) as boolean | undefined;
}

function isFieldComputed<CardT extends Constructable>(card: CardT, fieldName: string): boolean | undefined {
return getFieldDescriptorAttr(card, fieldName, isComputed) as boolean | undefined;
}

function getFieldDescriptorAttr<CardT extends Constructable>(card: CardT, fieldName: string, attr: string | symbol): unknown | undefined {
let obj = card.prototype;
while (obj) {
let desc = Reflect.getOwnPropertyDescriptor(obj, fieldName);
let result = (desc?.get as any)?.[isComputed];
let result = (desc?.get as any)?.[attr];
if (result !== undefined) {
return result;
}
obj = Reflect.getPrototypeOf(obj);
}
return false
return undefined;
}

function getFields<T extends Card>(card: T, onlyComputeds?: boolean): { [P in keyof T]?: Constructable } {
function getFields<T extends Card>(card: T, onlyComputeds = false): { [P in keyof T]?: Constructable } {
let obj = Reflect.getPrototypeOf(card);
let fields: { [P in keyof T]?: Constructable } = {};
while (obj?.constructor.name && obj.constructor.name !== 'Object') {
Expand Down Expand Up @@ -453,9 +504,19 @@ function fieldsComponentsFor<T extends Card>(target: object, model: T, defaultFo
// field doesn't exist, fall back to normal property access behavior
return Reflect.get(target, property, received);
}
// found field: get the corresponding component
let innerModel = (model as any)[property];
defaultFormat = isFieldComputed(model.constructor, property) ? 'embedded' : defaultFormat;

if (isFieldContainsMany(model.constructor, property)) {
let components = (Object.values(innerModel) as T[]).map(m => getComponent(field!, defaultFormat, m, set?.setters[property])) as any[];
return class ContainsMany extends GlimmerComponent {
<template>
{{#each components as |Item|}}
<Item/>
{{/each}}
</template>
};
}
return getComponent(field, defaultFormat, innerModel, set?.setters[property]);
},
getPrototypeOf() {
Expand Down
89 changes: 83 additions & 6 deletions host/tests/integration/components/card-basics-test.gts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import { module, test } from 'qunit';
import { setupRenderingTest } from 'ember-qunit';
import { fillIn } from '@ember/test-helpers';
import { renderCard } from '../../helpers/render-component';
import { contains, field, Component, primitive, Card } from 'runtime-spike/lib/card-api';
import { contains, containsMany, field, Component, primitive, Card } from 'runtime-spike/lib/card-api';
import StringCard from 'runtime-spike/lib/string';
import IntegerCard from 'runtime-spike/lib/integer';
import { cleanWhiteSpace } from '../../helpers';
Expand All @@ -15,42 +15,62 @@ module('Integration | card-basics', function (hooks) {
@field firstName = contains(StringCard);
@field title = contains(StringCard);
@field number = contains(IntegerCard);
@field languagesSpoken = containsMany(StringCard);

static isolated = class Isolated extends Component<typeof this> {
<template>{{@model.firstName}} {{@model.title}} {{@model.number}}</template>
<template>
{{@model.firstName}}
{{@model.title}}
{{@model.number}}
{{#each @model.languagesSpoken as |language|}}
{{language}}
{{/each}}
</template>
}
}
let card = new Person();
card.firstName = 'arthur';
card.number = 42;
card.languagesSpoken = ['english', 'japanese'];
let readName: string = card.firstName;
assert.strictEqual(readName, 'arthur');
let readNumber: number = card.number;
assert.strictEqual(readNumber, 42);
let readLanguages: string[] = card.languagesSpoken;
assert.deepEqual(readLanguages, ['english', 'japanese']);
});

test('access @model for primitive and composite fields', async function (assert) {

class Person extends Card {
@field firstName = contains(StringCard);
@field subscribers = contains(IntegerCard);
@field languagesSpoken = containsMany(StringCard);
}

class Post extends Card {
@field title = contains(StringCard);
@field author = contains(Person);
@field languagesSpoken = containsMany(StringCard);
static isolated = class Isolated extends Component<typeof this> {
<template>{{@model.title}} by {{@model.author.firstName}}, {{@model.author.subscribers}} subscribers</template>
<template>
{{@model.title}} by {{@model.author.firstName}}
speaks {{#each @model.author.languagesSpoken as |language|}} {{language}} {{/each}}
{{@model.author.subscribers}} subscribers
</template>
}
}

let helloWorld = new Post({
title: 'First Post',
author: { firstName: 'Arthur', subscribers: 5 },
author: {
firstName: 'Arthur',
subscribers: 5,
languagesSpoken: ['english', 'japanese']
},
});

await renderCard(helloWorld, 'isolated');
assert.strictEqual(this.element.textContent!.trim(), 'First Post by Arthur, 5 subscribers');
assert.strictEqual(cleanWhiteSpace(this.element.textContent!), 'First Post by Arthur speaks english japanese 5 subscribers');
});

test('render primitive field', async function (assert) {
Expand Down Expand Up @@ -180,6 +200,63 @@ module('Integration | card-basics', function (hooks) {
assert.dom('[data-test="title"]').containsText('First Post');
});

test('render a containsMany primitive field', async function (assert) {
class Person extends Card {
@field firstName = contains(StringCard);
@field languagesSpoken = containsMany(StringCard);
static isolated = class Isolated extends Component<typeof this> {
<template><@fields.firstName/> speaks <@fields.languagesSpoken/></template>
}
}

let mango = new Person({
firstName: 'Mango',
languagesSpoken: ['english', 'japanese']
});

await renderCard(mango, 'isolated');
assert.strictEqual(cleanWhiteSpace(this.element.textContent!), 'Mango speaks english japanese');
});

test('render a containsMany composite field', async function (assert) {
class Person extends Card {
@field firstName = contains(StringCard);
static embedded = class Embedded extends Component<typeof this> {
<template><@fields.firstName/></template>
}
}

class Family extends Card {
@field people = containsMany(Person);
static isolated = class Isolated extends Component<typeof this> {
<template><@fields.people/></template>
}
}
let abdelRahmans = new Family({
people: [
{ firstName: 'Mango'},
{ firstName: 'Van Gogh'},
{ firstName: 'Hassan'},
{ firstName: 'Mariko'},
{ firstName: 'Yume'},
{ firstName: 'Sakura'},
]
});

await renderCard(abdelRahmans, 'isolated');
assert.strictEqual(cleanWhiteSpace(this.element.textContent!), 'Mango Van Gogh Hassan Mariko Yume Sakura');
});

test('throws if contains many value is set with a non-array', async function(assert) {
class Person extends Card {
@field firstName = contains(StringCard);
@field languagesSpoken = containsMany(StringCard);
}

assert.throws(() => new Person({ languagesSpoken: 'english' }), /Expected array for field value languagesSpoken for card Person/);
assert.throws(() => Person.fromSerialized({ languagesSpoken: 'english' }), /Expected array for field value languagesSpoken for card Person/);
});

test('render default edit template', async function (assert) {
class TestString extends Card {
static [primitive]: string;
Expand Down
Loading

0 comments on commit c723304

Please sign in to comment.