Skip to content

Commit

Permalink
Re-ordering linksToMany doesn't preserve templates (and UI component …
Browse files Browse the repository at this point in the history
…state) (#2241)

* add test for re-rodering linksToMany items

* Fix reordering issue where finding element is based upon value that is already set. The getter was not tracking correct value

* fix lint

fix more lint

* add box unit test

* make re-order example for 3 items

* fix lint

* improve test naming

* fix more english

---------

Co-authored-by: Buck Doyle <[email protected]>
  • Loading branch information
tintinthong and backspace authored Mar 10, 2025
1 parent 160dab0 commit 8f1e981
Show file tree
Hide file tree
Showing 3 changed files with 216 additions and 4 deletions.
12 changes: 8 additions & 4 deletions packages/base/card-api.gts
Original file line number Diff line number Diff line change
Expand Up @@ -3100,6 +3100,7 @@ export class Box<T> {
}

private prevChildren: Box<ElementType<T>>[] = [];
private prevValues: ElementType<T>[] = [];

get children(): Box<ElementType<T>>[] {
if (this.state.type === 'root') {
Expand All @@ -3117,10 +3118,10 @@ export class Box<T> {
);
}

let { prevChildren, state } = this;
let { prevChildren, prevValues, state } = this;
let newChildren: Box<ElementType<T>>[] = value.map((element, index) => {
let found = prevChildren.find((oldBox, i) =>
state.useIndexBasedKeys ? index === i : oldBox.value === element,
let found = prevChildren.find((_oldBox, i) =>
state.useIndexBasedKeys ? index === i : this.prevValues[i] === element,
);
if (found) {
if (state.useIndexBasedKeys) {
Expand All @@ -3129,7 +3130,9 @@ export class Box<T> {
// mutating a watched array in a rerender will spawn another rerender which
// infinitely recurses.
} else {
prevChildren.splice(prevChildren.indexOf(found), 1);
let toRemoveIndex = prevChildren.indexOf(found);
prevChildren.splice(toRemoveIndex, 1);
prevValues.splice(toRemoveIndex, 1);
if (found.state.type === 'root') {
throw new Error('bug');
}
Expand All @@ -3146,6 +3149,7 @@ export class Box<T> {
}
});
this.prevChildren = newChildren;
this.prevValues = newChildren.map((child) => child.value);
return newChildren;
}
}
Expand Down
153 changes: 153 additions & 0 deletions packages/host/tests/integration/components/card-basics-test.gts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,8 @@ import {
triggerEvent,
} from '@ember/test-helpers';

import { tracked } from '@glimmer/tracking';

import percySnapshot from '@percy/ember';
import format from 'date-fns/format';
import parseISO from 'date-fns/parseISO';
Expand Down Expand Up @@ -2283,6 +2285,157 @@ module('Integration | card-basics', function (hooks) {
await waitUntil(() => cleanWhiteSpace(root.textContent!) === 'Quint');
});

test('Re-ordering items in a linksToMany field will preserve the template and box component state', async function (assert) {
class Fitted extends Component<typeof Pet1> {
@tracked counter = 0;

incrementCounter = () => {
this.counter++;
};
<template>
{{@model.name}}
<button
{{on 'click' this.incrementCounter}}
data-test-increment-counter
>Increment</button>
<div data-test-counter>
{{this.counter}}
</div>
</template>
}

class FittedPrime extends Component<typeof Pet1Prime> {
@tracked counter = 0;

incrementCounter = () => {
this.counter++;
};
<template>
<div data-test-different-template>Different Template</div>
{{@model.name}}
<button
{{on 'click' this.incrementCounter}}
data-test-increment-counter
>Increment</button>
<div data-test-counter>
{{this.counter}}
</div>
</template>
}
class Pet1 extends CardDef {
@field name = contains(StringField);
static fitted = Fitted;
}

class Pet1Prime extends Pet1 {
static fitted = FittedPrime;
}

class Person1 extends CardDef {
@field pets = linksToMany(Pet1);
static isolated = class Embedded extends Component<typeof this> {
reorder = () => {
if (
this.args.model.pets &&
this.args.model.pets[0] &&
this.args.model.pets[1]
) {
this.args.model.pets = [
this.args.model.pets[1],
this.args.model.pets[0],
];
}
};
<template>
<button
{{on 'click' this.reorder}}
data-test-reorder
>Reorder</button>
<@fields.pets @format='fitted' />
</template>
};
}

loader.shimModule(`${testRealmURL}test-cards`, {
Pet1,
Pet1Prime,
Person1,
});

let pet1 = new Pet1({ name: 'jersey' });
let pet2 = new Pet1Prime({ name: 'boboy' });
await saveCard(pet1, `${testRealmURL}Pet/pet1`, loader);
await saveCard(pet2, `${testRealmURL}Pet/pet2`, loader);
let person = new Person1({
name: 'Mango',
pets: [pet1, pet2],
});
await renderCard(loader, person, 'isolated');
assert
.dom(`[data-test-plural-view-item="0"] [data-test-counter]`)
.hasText('0');
assert
.dom(
`[data-test-plural-view-item="0"][data-test-card="${testRealmURL}Pet/pet1"]`,
)
.containsText('jersey');

await click(
`[data-test-plural-view-item="0"] [data-test-increment-counter]`,
);
await click(
`[data-test-plural-view-item="0"] [data-test-increment-counter]`,
);
assert
.dom(`[data-test-plural-view-item="0"] [data-test-counter]`)
.hasText('2');
assert
.dom(
`[data-test-plural-view-item="0"][data-test-card="${testRealmURL}Pet/pet1"]`,
)
.containsText('jersey');
assert
.dom(`[data-test-plural-view-item="0"] [data-test-different-template]`)
.doesNotExist();
assert
.dom(`[data-test-plural-view-item="1"] [data-test-counter]`)
.hasText('0');
assert
.dom(
`[data-test-plural-view-item="1"][data-test-card="${testRealmURL}Pet/pet2"]`,
)
.containsText('boboy');
assert
.dom(`[data-test-plural-view-item="1"] [data-test-different-template]`)
.exists();
await click('[data-test-reorder]'); //Reorder
assert
.dom(`[data-test-plural-view-item="0"] [data-test-counter]`)
.hasText('0');
assert
.dom(
`[data-test-plural-view-item="0"][data-test-card="${testRealmURL}Pet/pet2"]`,
)
.containsText('boboy');
assert
.dom(`[data-test-plural-view-item="0"] [data-test-counter]`)
.hasText('0');
assert
.dom(`[data-test-plural-view-item="0"] [data-test-different-template]`)
.exists();
assert
.dom(
`[data-test-plural-view-item="1"][data-test-card="${testRealmURL}Pet/pet1"]`,
)
.containsText('jersey');
assert
.dom(`[data-test-plural-view-item="1"] [data-test-counter]`)
.hasText('2');
assert
.dom(`[data-test-plural-view-item="1"] [data-test-different-template]`)
.doesNotExist();
});

test('rerender when a containsMany field is fully replaced', async function (assert) {
class Person extends CardDef {
@field pets = containsMany(StringField);
Expand Down
55 changes: 55 additions & 0 deletions packages/host/tests/unit/box-test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
import { RenderingTestContext } from '@ember/test-helpers';

import { module, test } from 'qunit';

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

import { lookupLoaderService } from '../helpers';
import { setupRenderingTest } from '../helpers/setup';

let cardApi: typeof import('https://cardstack.com/base/card-api');

let loader: Loader;

module('Unit | box', function (hooks) {
setupRenderingTest(hooks);
hooks.beforeEach(function (this: RenderingTestContext) {
loader = lookupLoaderService().loader;
});
hooks.beforeEach(async function () {
cardApi = await loader.import(`${baseRealm.url}card-api`);
});

test('Box children maintain object strict equality after re-ordering', async function (assert) {
let { Box } = cardApi;
let parentCardModel = {
someField: [],
};
let childCard1Model = {};
let childCard2Model = {};
let childCard3Model = {};
let box = Box.create(parentCardModel);
let boxArr = box.field('someField');
let childValues: any = [childCard1Model, childCard2Model, childCard3Model];
boxArr.set(childValues);
assert.strictEqual(boxArr.children[0].value, childCard1Model);
assert.strictEqual(boxArr.children[1].value, childCard2Model);
assert.strictEqual(boxArr.children[2].value, childCard3Model);
assert.strictEqual(boxArr.children[0].name, '0');
assert.strictEqual(boxArr.children[1].name, '1');
assert.strictEqual(boxArr.children[2].name, '2');

let childValuesReordered: any = [
childCard2Model,
childCard3Model,
childCard1Model,
];
boxArr.set(childValuesReordered);
assert.strictEqual(boxArr.children[0].value, childCard2Model);
assert.strictEqual(boxArr.children[1].value, childCard3Model);
assert.strictEqual(boxArr.children[2].value, childCard1Model);
assert.strictEqual(boxArr.children[0].name, '0');
assert.strictEqual(boxArr.children[1].name, '1');
assert.strictEqual(boxArr.children[2].name, '2');
});
});

0 comments on commit 8f1e981

Please sign in to comment.