Skip to content

Commit

Permalink
[WIP] Align implementation of bind({path}) and bind({all})
Browse files Browse the repository at this point in the history
  • Loading branch information
tbuschto committed Mar 18, 2020
1 parent e90fd70 commit 36f18dc
Show file tree
Hide file tree
Showing 9 changed files with 307 additions and 310 deletions.
23 changes: 17 additions & 6 deletions doc/databinding/@bind.md
Original file line number Diff line number Diff line change
Expand Up @@ -49,12 +49,6 @@ In JavaScript the only difference is how - if at all - the decorated property is
myNumber;
```

Change events are fired for the decorated *component property* when the *target element* fires change events.

> See example apps ["bind-two-way-change-events"](../../examples/bind-two-way-change-events) (TypeScript) and ["bind-two-way-change-events-jsx"](../../examples/bind-two-way-change-events-jsx) (JavaScript/JSX).
As with one-way bindings, setting the *component property* to `undefined` resets the *target property* to its initial value for when the binding was first established.

## @bind(config)

Like [`@bind(path)`](#bindpath) or [`@bindAll(bindings)`](./@bindAll.md), but allows to give additional options as supported by [`@property`](./@property.md).
Expand Down Expand Up @@ -104,3 +98,20 @@ A [`typeGuard`](./@property.md#configtypeguard) may be given to perform value ch

A [`type`](./@property.md#configtype) may be given to enforce type checks in JavaScript.

### Properties eligible for bindings

Any *component property* can be used for two-way bindings, unless it's explicitly implemented with a setter and getter, or with `Object.defineProperty`. These are not supported. The target property needs to generate change events for the two-way binding to work. This is already the case for all built-in properties of Tabris.js widgets.

> See example apps ["bind-two-way-change-events"](../../examples/bind-two-way-change-events) (TypeScript) and ["bind-two-way-change-events-jsx"](../../examples/bind-two-way-change-events-jsx) (JavaScript/JSX).
If the target widget itself is a custom component the recommended way to implement change events is using [`@property`](./@property.md). Note that there is no need to explicitly create an event API, `@bind` can 'talk' directly to `@property`. However, an explicit implementation is also possible.

## Edge Cases

As with one-way bindings, setting the *component property* to `undefined` resets the *target property* to its initial value for when the binding was first established. The component property will also adopt that value, so both stay in syc.

If the *component property* converts or ignores the incoming value of the *target property*, the target property will follow and also bet set to the new component property value.

If a *target property* converts or ignores the incoming value of the *component property*, the component property will ignore that and keep its own value. The two properties are out-of-sync in this case.

If either property throws when set, the error will be propagated to the caller that originally caused the value change. In this case the two properties *may* end up out-of-sync.
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@
"build": "webpack --mode=production && npm run dts",
"dts": "dts-generator --name tabris-decorators --project ./tsconfig-dts.json --out ./dist/index.d.ts --main tabris-decorators/index",
"test": "npm run lint && ts-mocha -p ./tsconfig-test.json ./test/*.spec.ts ./test/*.spec.tsx ./test/*.spec.js ./test/*.spec.jsx",
"test:file": "ts-mocha -p ./tsconfig-test.json",
"test:file": "ts-mocha -p ./tsconfig-test.json --bail",
"lint": "eslint --color --f visualstudio --ext .js,.jsx,.ts,.tsx src test examples",
"safePublish": "npm test && npm run build && npm publish",
"start": "run-script-os",
Expand Down
95 changes: 56 additions & 39 deletions src/decorators/bind.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,9 @@
import {Composite} from 'tabris';
import {property} from './property';
import {Injector, injector} from '../api/Injector';
import {createBoundProperty} from '../internals/createBoundProperty';
import {processTwoWayBindings, TwoWayBindingPaths} from '../internals/processTwoWayBinding';
import {initAllTwoWayBindings, initSingleTwoWayBinding} from '../internals/processTwoWayBinding';
import {applyDecorator, getPropertyType, isPrimitiveType} from '../internals/utils';
import {checkIsComponent, checkPropertyExists, isUnchecked, parseTargetPath, postAppendHandlers, TypeGuard, UserType, WidgetInterface} from '../internals/utils-databinding';
import {checkIsComponent, checkPropertyExists, isUnchecked, parseTargetPath, postAppendHandlers, TargetPath, TypeGuard, UserType, WidgetInterface} from '../internals/utils-databinding';

export interface BindAllConfig<ValidKeys extends string> {
typeGuard?: TypeGuard;
Expand All @@ -21,15 +20,19 @@ export interface BindSingleConfig {
path: string;
}

export type TwoWayBinding = {
baseProto: WidgetInterface,
baseProperty: string,
path: string | null,
export type BindSuperConfig = {
componentProto: WidgetInterface,
componentProperty: string,
targetPath: TargetPath | null,
all: TwoWayBindingPaths | null,
typeGuard: TypeGuard | null,
userType: UserType<any> | null
};

export type TwoWayBindingPaths = {
[sourceProperty: string]: TargetPath
};

export type BindAllDecorator<ValidKeys extends string> = <
PropertyName extends string,
Target extends {[P in PropertyName]: {[SubProperty in ValidKeys]: any}} & Composite
Expand Down Expand Up @@ -81,51 +84,52 @@ export function bind<ValidKeys extends string>(config: BindAllConfig<ValidKeys>)
export function bind(...args: any[]): any {
return applyDecorator('bind', args, (baseProto: WidgetInterface, baseProperty: string) => {
const isShorthand = typeof args[0] === 'string';
const binding: TwoWayBinding = {
baseProto,
baseProperty,
path: isShorthand ? args[0] : args[0].path,
const pathString = isShorthand ? args[0] : args[0].path;
const binding: BindSuperConfig = {
componentProto: baseProto,
componentProperty: baseProperty,
targetPath: pathString ? parseTargetPath(pathString) : null,
all: parseAll(isShorthand ? null : args[0].all),
typeGuard: isShorthand ? null : args[0].typeGuard,
userType: isShorthand ? null : args[0].type
};
checkParameters(binding);
applyTwoWayBinding(binding);
setTimeout(() => {
try {
checkIsComponent(baseProto);
} catch (ex) {
console.error('Can not apply @bind to property ' + baseProperty, ex);
}
});
preCheckComponentProperty(binding);
configureComponentProperty(binding);
postAppendHandlers(binding.componentProto).push(createInitializer(binding));
scheduleIsComponentCheck(binding);
});
}

function applyTwoWayBinding(binding: TwoWayBinding) {
if (binding.path) {
createBoundProperty(binding);
} else {
checkBasePropertyType(binding);
const propertyConfig = {
typeGuard: createTypeGuard(binding),
type: binding.userType
};
property(propertyConfig)(binding.baseProto, binding.baseProperty);
postAppendHandlers(binding.baseProto).push(base => processTwoWayBindings(base, binding));
}
function configureComponentProperty(binding: BindSuperConfig) {
const propertyConfig = {
typeGuard: binding.all ? createBindAllTypeGuard(binding) : binding.typeGuard,
type: binding.userType
};
property(propertyConfig)(binding.componentProto, binding.componentProperty);
}

function checkParameters(binding: TwoWayBinding) {
if (binding.path && binding.all) {
function createInitializer(binding: BindSuperConfig): (instance: WidgetInterface) => void {
return binding.all
? base => initAllTwoWayBindings(base, binding)
: base => initSingleTwoWayBinding(base, binding);
}

function checkParameters(binding: BindSuperConfig) {
if (binding.targetPath && binding.all) {
throw new Error('@bind can not have "path" and "all" option simultaneously');
}
if (!binding.path && !Object.keys(binding.all).length) {
if (!binding.targetPath && !Object.keys(binding.all).length) {
throw new Error('Missing binding path(s)');
}
}

function checkBasePropertyType(binding: TwoWayBinding) {
const {baseProto, baseProperty, userType} = binding;
function preCheckComponentProperty(binding: BindSuperConfig) {
if (binding.targetPath) {
// Will be checked on initialization
return;
}
const {componentProto: baseProto, componentProperty: baseProperty, userType} = binding;
const type = userType || getPropertyType(baseProto, baseProperty);
if (isPrimitiveType(type)) {
throw new Error('Property type needs to extend Object');
Expand All @@ -143,15 +147,15 @@ function parseAll(all: {[key: string]: string}): TwoWayBindingPaths | null {
return bindings;
}

function createTypeGuard(binding: TwoWayBinding) {
function createBindAllTypeGuard(binding: BindSuperConfig) {
const sourceProperties = Object.keys(binding.all);
const baseProperty = binding.baseProperty;
const baseProperty = binding.componentProperty;
return (value: any) => {
if (value) {
if (!(value instanceof Object)) {
throw new Error('Value needs to extend Object');
}
const className = binding.baseProto.constructor.name;
const className = binding.componentProto.constructor.name;
for (const sourceProperty of sourceProperties) {
checkPropertyExists(value, sourceProperty, 'Object');
if (isUnchecked(value, sourceProperty)) {
Expand All @@ -171,3 +175,16 @@ function createTypeGuard(binding: TwoWayBinding) {
return binding.typeGuard ? binding.typeGuard(value) : true;
};
}

function scheduleIsComponentCheck(binding: BindSuperConfig) {
setTimeout(() => {
try {
checkIsComponent(binding.componentProto);
} catch (ex) {
const target = binding.all ? JSON.stringify(binding.all) : binding.targetPath.join('.');
console.error(
`Binding "${binding.componentProperty}" <-> "${target}" failed to initialize: ` + ex.message
);
}
});
}
Loading

0 comments on commit 36f18dc

Please sign in to comment.