Skip to content

Commit

Permalink
feat(bindable): support getter/setter (#1753)
Browse files Browse the repository at this point in the history
BREAKING CHANGE: remove `BindableObserver`, bindable observer now can be of different types of observers
  • Loading branch information
bigopon committed May 1, 2023
1 parent cefe30c commit 4279851
Show file tree
Hide file tree
Showing 23 changed files with 618 additions and 596 deletions.
68 changes: 55 additions & 13 deletions docs/user-docs/components/bindable-properties.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,9 +5,9 @@ description: >-
libraries.
---

# Bindable properties
## Bindable properties

When creating components, sometimes you will want the ability for data to be passed into them. The `@bindable` decorator allows you to specify one or more bindable properties for a component.
When creating components, sometimes you will want the ability for data to be passed into them instead of their host elements. The `@bindable` decorator allows you to specify one or more bindable properties for a component.

The `@bindable` attribute also can be used with custom attributes as well as custom elements. The decorator denotes bindable properties on components on the view model of a component.

Expand Down Expand Up @@ -72,7 +72,7 @@ export class NameComponent {

You can then use the component in this way,\``<name-component first-name="John" last-name="Smith"></name-component>`

### Calling a change function when bindable is modified
## Calling a change function when bindable is modified

By default, Aurelia will call a change callback (if it exists) which takes the bindable property name followed by `Changed` added to the end. For example, `firstNameChanged(newVal, previousVal)` would fire every time the `firstName` bindable property is changed.

Expand Down Expand Up @@ -182,7 +182,7 @@ In some cases, you want to make an impact on the value that is binding. For such

```typescript
@bindable({
set: value => function(value), /* HERE */
set: value => someFunction(value), /* HERE */
// Or set: value => value,
mode: /* ... */
})
Expand All @@ -206,16 +206,10 @@ Suppose you have a `carousel` component in which you want to enable `navigator`

In version two, you can easily implement such a capability with the `set` feature.

To make things easier, first design a new type that accepts `true` and `false` as a string and a boolean.
```typescript
export type BooleanString = "true" | "false" | true | false /* boolean */;
```
Define your property like this:

```typescript
@bindable({ set: /* ? */, mode: BindingMode.toView }) public navigator: BooleanString = false;
@bindable({ set: /* ? */, mode: BindingMode.toView }) public navigator: boolean = false;
```

For `set` part, we need functionality to check the input. If the value is one of the following, we want to return `true`, otherwise, we return the `false` value.
Expand All @@ -235,17 +229,65 @@ export function truthyDetector(value: unknown) {
Now, we should set `truthyDetector` function as follows:

```typescript
@bindable({ set: truthyDetector, mode: BindingMode.toView }) public navigator: BooleanString = false;
@bindable({ set: truthyDetector, mode: BindingMode.toView }) public navigator: boolean = false;
```

Although, there is another way to write the functionality too:

```typescript
@bindable({ set: v => v === '' || v === true || v === "true", mode: BindingMode.toView }) public navigator: BooleanString = false;
@bindable({ set: v => v === '' || v === true || v === "true", mode: BindingMode.toView }) public navigator: boolean = false;
```

You can simply use any of the above four methods to enable/disable your feature. As you can see, `set` can be used to transform the values being bound into your bindable property and offer more predictable results when dealing with primitives like booleans and numbers.

## Bindable & getter/setter

By default, you'll find yourself work with binable and field most of the time, like the examples given above. But there' cases where
it makes sense to have bindable as a getter, or a pair of getter/setter to do more logic when get/set.

For example, a component card nav that allow parent component to query its active status.
With bindable on field, it would be written like this:

```ts
@customElement({ name: 'card-nav', template })
export class CardNav implements ICustomElementViewModel {
@bindable routes: RouteLink[] = [];

@bindable({ mode: BindingMode.fromView }) active?: string;

bound() {
this.setActive();
}

setActive() {
this.active = this.routes.find((y) => y.isActive)?.path;
}

handleClick(route: RouteLink) {
this.routes.forEach((x) => (x.isActive = x === route));
this.setActive();
}
}
```
Note that because `active` value needs to computed from other variables, we have to "actively" call `setActive`. It's not a big deal, but sometimes not desirable.

For cases like this, we can turn `active` into a getter, and decorate it with bindable, like the following:
```ts
@customElement({ name: 'card-nav', template })
export class CardNav implements ICustomElementViewModel {
@bindable routes: RouteLink[] = [];

@bindable({ mode: BindingMode.fromView }) get active() {
return this.routes.find((y) => y.isActive)?.path;
}

handleClick(route: RouteLink) {
this.routes.forEach((x) => (x.isActive = x === route));
}
}
```
Simpler, since the value of `active` is computed, and observed based on the properties/values accessed inside the getter.

## Bindable coercion

The bindable setter section shows how to adapt the value is bound to a `@bindable` property. One common usage of the setter is to coerce the values that are bound from the view. Consider the following example.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ In some cases, you want to make an impact on the value that is binding. For such

```typescript
@bindable({
set: value => function(value), /* HERE */
set: value => someFunction(value), /* HERE */
// Or set: value => value,
mode: /* ... */
})
Expand Down Expand Up @@ -57,14 +57,13 @@ export function truthyDetector(value: unknown) {
Now, we should set `truthyDetector` function as following:

```typescript
@bindable({ set: truthyDetector, mode: BindingMode.toView }) public navigator: BooleanString = false;
@bindable({ set: truthyDetector, mode: BindingMode.toView }) public navigator: boolean = false;
```

Although, there is another way to write the functionality too

```typescript
@bindable({ set: v => v === '' || v === true || v === "true", mode: BindingMode.toView }) public navigator: BooleanString = false;
@bindable({ set: v => v === '' || v === true || v === "true", mode: BindingMode.toView }) public navigator: boolean = false;
```

You can simply use any of the above four methods to enable/disable your feature.
114 changes: 1 addition & 113 deletions packages/__tests__/2-runtime/property-observation.spec.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,8 @@
import { noop, Primitive, IIndexable } from '@aurelia/kernel';
import { Primitive, IIndexable } from '@aurelia/kernel';
import {
PrimitiveObserver,
SetterObserver
} from '@aurelia/runtime';
import { BindableObserver } from '@aurelia/runtime-html';
import { SpySubscriber, assert, TestContext, ChangeSet } from '@aurelia/testing';

const getName = (o: any) => Object.prototype.toString.call(o).slice(8, -1);
Expand Down Expand Up @@ -175,115 +174,4 @@ describe('2-runtime/property-observation.spec.ts', function () {
}
});
});

describe('BindableObserver', function () {
function createFixture(obj: IIndexable, key: string) {
const _ctx = TestContext.create();
const sut = new BindableObserver(obj, key, `${key ? key.toString() : `${key}`}Changed`, noop, {} as any, { enableCoercion: false, coerceNullish: false });

return { sut };
}

it('initializes the default callback to undefined', function () {
const values = createObjectArr();
values.forEach(_value => {
const observer = createFixture({}, 'a');
assert.strictEqual(observer['callback'], void 0, `observer['callback']`);
});
});

describe('getValue()', function () {
const objectArr = createObjectArr();
const propertyNameArr = [undefined, null, Symbol(), '', 'foo', 'length', '__proto__'];
for (const object of objectArr) {
for (const propertyName of propertyNameArr) {
it(`should correctly handle ${getName(object)}[${typeof propertyName}]`, function () {
const { sut } = createFixture(object, propertyName as any);
sut.subscribe(new SpySubscriber());
const actual = sut.getValue();
assert.strictEqual(actual, object[propertyName], `actual`);
});
}
}
});

describe('setValue()', function () {
const valueArr = [undefined, null, 0, '', {}];
const objectArr = createObjectArr();
const propertyNameArr = [undefined, null, Symbol(), '', 'foo'];
for (const object of objectArr) {
for (const propertyName of propertyNameArr) {
for (const value of valueArr) {
it(`should correctly handle ${getName(object)}[${typeof propertyName}]=${getName(value)}`, function () {
const { sut } = createFixture(object, propertyName as any);
sut.subscribe(new SpySubscriber());
sut.setValue(value);
assert.strictEqual(object[propertyName], value, `object[propertyName]`);
});
}
}
}
});

describe('subscribe()', function () {
const propertyNameArr = [undefined, null, Symbol(), '', 'foo', 1];
const objectArr = createObjectArr();
for (const object of objectArr) {
for (const propertyName of propertyNameArr) {
it(`can handle ${getName(object)}[${typeof propertyName}]`, function () {
const { sut } = createFixture(object, propertyName as any);
sut.subscribe(new SpySubscriber());
});
}
}

const valueArr = [0, '', {}];
const callsArr = [1, 2];
for (const calls of callsArr) {
for (const propertyName of propertyNameArr) {
for (const value of valueArr) {
const subscribersArr = [
[new SpySubscriber()],
[new SpySubscriber(), new SpySubscriber(), new SpySubscriber()],
[new SpySubscriber(), new SpySubscriber(), new SpySubscriber(), new SpySubscriber(), new SpySubscriber(), new SpySubscriber(), new SpySubscriber(), new SpySubscriber(), new SpySubscriber(), new SpySubscriber()]
];
for (const subscribers of subscribersArr) {
const object = {};
it(`should notify ${subscribers.length} subscriber(s) for ${getName(object)}[${typeof propertyName}]=${getName(value)}`, function () {
const { sut } = createFixture(object, propertyName as any);
for (const subscriber of subscribers) {
sut.subscribe(subscriber);
}
const prevValue = object[propertyName];
sut.setValue(value);
for (const subscriber of subscribers) {
assert.deepStrictEqual(
subscriber.changes,
[
new ChangeSet(0, value, prevValue),
],
);
}
if (calls === 2) {
sut.setValue(prevValue);
for (const subscriber of subscribers) {
assert.deepStrictEqual(
subscriber.changes,
[
new ChangeSet(0, value, prevValue),
new ChangeSet(1, prevValue, value),
],
);
}
}
for (const subscriber of subscribers) {
sut.unsubscribe(subscriber);
}
});
}
}
}
}
});
});
});

0 comments on commit 4279851

Please sign in to comment.