Skip to content

Commit

Permalink
[TypeScript] Allow converting any constructor to JSX component
Browse files Browse the repository at this point in the history
By adding a factory to the prototype (for TypeScript support it can't be
the constructor itself, unfortunately), any class can be used as an JSX
element without having to extend the core framework. This will make it
easier for us and for users to introduce new JSX types (such as Popover)
or modify the behavior for specific subtypes.

With this change the existence of said factory is what marks a JSX
element, and no longer the jsxProperties property. That simplifies the
JSX generator a bit.

Adjust input-tsx example. Documentation to follow.

Change-Id: I559d33a653e78f54f8a6ba080ae30428e16ea588
  • Loading branch information
tbuschto committed Aug 8, 2018
1 parent ef5e43f commit 57e19e0
Show file tree
Hide file tree
Showing 11 changed files with 154 additions and 87 deletions.
3 changes: 2 additions & 1 deletion .eslintrc
Expand Up @@ -9,7 +9,8 @@
"es6": true
},
"globals": {
"tabris": false
"tabris": false,
"JSX": false
},
"rules": {
"no-empty": "off",
Expand Down
7 changes: 4 additions & 3 deletions doc/api/WidgetCollection.json
Expand Up @@ -318,11 +318,12 @@
}
},
"properties": {
"jsxProperties": {
"[JSX.jsxFactory]": {
"description": "This function is called by the framework to create JSX elements from the WidgetCollection class.",
"type": "JSX.JsxFactory",
"static": true,
"readonly": true,
"type": "JsxPropertiesObject",
"description": "When using JSX with TypeScript (`.tsx` files), the type of this property determines which JSX attributes are valid for this class."
"ts_only": true
},
"length": {
"static": true,
Expand Down
6 changes: 6 additions & 0 deletions doc/api/widgets/Widget.json
Expand Up @@ -115,6 +115,12 @@
"type": "JsxPropertiesObject",
"description": "When using JSX with TypeScript (`.tsx` files), the type of this property determines which JSX attributes are valid for this widget."
},
"[JSX.jsxFactory]": {
"description": "This function is called to create JSX widget elements. You may override it in your own subclass to create custom JSX behavior. **The function is not called with any context, i.e. `this` is not available.**",
"type": "JSX.JsxFactory",
"static": true,
"readonly": true
},
"id": {
"type": "string",
"description": "A string to identify the widget by using selectors. IDs are optional. It is strongly recommended that they are unique within a page."
Expand Down
96 changes: 47 additions & 49 deletions examples/input-tsx/input.tsx
@@ -1,6 +1,6 @@
import {
Widget, ScrollView, Slider, TextView, Picker, CheckBox, Switch, TextInput, ui, ScrollViewProperties,
EventObject, RadioButton, Properties, Partial, CompositeProperties, Composite, WidgetCollection
Slider, TextView, Picker, CheckBox, Switch, TextInput, ui, ScrollView, Button,
EventObject, RadioButton, Properties, Composite, WidgetCollection, JSXProperties, Listeners
} from 'tabris';

const STYLE = {
Expand All @@ -18,70 +18,68 @@ const STYLE = {
'#luggageWeight': {right: 10, width: 50}
};

type ReservationFormCreateArgs = Properties<ReservationForm> & {
classes: string[]; countries: string[];
};

class ReservationForm extends Composite {

public readonly tsProperties: CompositeProperties & Partial<this, 'message'>;

public readonly jsxProperties: ReservationFormCreateArgs & JSX.CompositeEvents & {
onConfirm?: (ev: EventObject<ReservationForm>) => void
};
public readonly jsxProperties: Composite['jsxProperties']
& JSXProperties<this, 'classes' | 'countries' | 'message' | 'onConfirm'>;
public readonly classes: string[];
public readonly countries: string[];
public readonly onConfirm: Listeners = new Listeners(this, 'confirm');

constructor(args?: ReservationFormCreateArgs) {
super();
let {classes, countries, ...properties} = args;
constructor({classes, countries, message, ...properties}: Properties<ReservationForm>) {
super(properties);
this.classes = classes;
this.countries = countries;
this.append(
<scrollView class='stretch' direction='vertical'>
<composite id='interactive' class='top'>
<textView class='col1 label' alignment='left'>Name:</textView>
<textInput class='col2 labeled' id='name' message='Full Name'/>
<textView class='col1 label'>Flyer Number</textView>
<textInput class='col2 labeled' keyboard='number' message='Flyer Number'/>
<textView class='col1 label'>Passphrase:</textView>
<textInput class='col2 labeled' type='password' message='Passphrase'/>
<textView class='col1 label'>Country:</textView>
<picker
<ScrollView class='stretch' direction='vertical'>
<Composite id='interactive' class='top'>
<TextView class='col1 label' alignment='left'>Name:</TextView>
<TextInput class='col2 labeled' id='name' message='Full Name'/>
<TextView class='col1 label'>Flyer Number</TextView>
<TextInput class='col2 labeled' keyboard='number' message='Flyer Number'/>
<TextView class='col1 label'>Passphrase:</TextView>
<TextInput class='col2 labeled' type='password' message='Passphrase'/>
<TextView class='col1 label'>Country:</TextView>
<Picker
id='country'
class='col2 labeled'
itemCount={countries.length}
itemText={index => countries[index]}/>
<textView class='col1 label'>Class:</textView>
<picker
itemCount={this.countries.length}
itemText={index => this.countries[index]}/>
<TextView class='col1 label'>Class:</TextView>
<Picker
id='class'
class='col2 labeled'
itemCount={classes.length}
itemText={index => classes[index]}/>
<textView class='col1 label'>Seat:</textView>
<radioButton class='col2 labeled'>Window</radioButton>
<radioButton class='col2 stacked'>Aisle</radioButton>
<radioButton class='col2 stacked' checked={true}>Anywhere</radioButton>
<composite class='group'>
<textView class='col1 grouped'>Luggage:</textView>
<slider
itemCount={this.classes.length}
itemText={index => this.classes[index]}/>
<TextView class='col1 label'>Seat:</TextView>
<RadioButton class='col2 labeled'>Window</RadioButton>
<RadioButton class='col2 stacked'>Aisle</RadioButton>
<RadioButton class='col2 stacked' checked={true}>Anywhere</RadioButton>
<Composite class='group'>
<TextView class='col1 grouped'>Luggage:</TextView>
<Slider
class='grouped'
id='luggageSlider'
onSelectionChanged={ev => this.luggageWeightText = `${ev.value} Kg`}/>
<textView class='grouped' id='luggageWeight'>0 Kg</textView>
</composite>
<checkBox class='col2 stacked' id='veggie'>Vegetarian</checkBox>
<composite class='group'>
<textView class='col1 grouped'>Vegetarian</textView>
<switch class='col2 grouped' id='miles'/>
</composite>
<button
<TextView class='grouped' id='luggageWeight'>0 Kg</TextView>
</Composite>
<CheckBox class='col2 stacked' id='veggie'>Vegetarian</CheckBox>
<Composite class='group'>
<TextView class='col1 grouped'>Vegetarian</TextView>
<Switch class='col2 grouped' id='miles'/>
</Composite>
<Button
class='colspan'
id='confirm'
text='Place Reservation'
background='#8b0000'
textColor='white'
onSelect={() => this.trigger('confirm', new EventObject<this>()) }/>
</composite>
<textView class='colspan' id='message'/>
</scrollView>
).set(properties)._apply(STYLE);
</Composite>
<TextView class='colspan' id='message'/>
</ScrollView>
)._apply(STYLE);
this.message = message;
}

public children() {
Expand Down
31 changes: 22 additions & 9 deletions src/tabris/JSX.js
Expand Up @@ -2,15 +2,20 @@ import Widget from './Widget';
import WidgetCollection from './WidgetCollection';
import {omit} from './util';

export const jsxFactory = Symbol('jsxFactory');

export function createElement(jsxType, attributes, ...children) {
let Type = typeToConstructor(jsxType);
let appendable = flattenChildren(children).filter(child => child instanceof Widget);
if (Type === WidgetCollection) {
if (attributes) {
throw new Error('JSX: WidgetCollection can not have attributes');
}
return new WidgetCollection(appendable);
const fn = typeAsFunction(jsxType);
if (fn.prototype && fn.prototype[jsxFactory]) {
return fn.prototype[jsxFactory].call(null, fn, attributes, children);
} else if (!fn.prototype) {
return fn.call(null, attributes, children);
}
throw new Error(('JSX: Unsupported type ' + fn.name).trim());
}

Widget.prototype[jsxFactory] = (Type, attributes, children) => {
let appendable = flattenChildren(children).filter(child => child instanceof Widget);
let result = new Type(getPropertiesMap(attributes || {}, children), children);
if (result instanceof WidgetCollection) {
return result;
Expand All @@ -19,9 +24,17 @@ export function createElement(jsxType, attributes, ...children) {
return result.append instanceof Function ? result.append.apply(result, appendable) : result;
}
throw new Error(('JSX: Unsupported type ' + Type.name).trim());
}
};

WidgetCollection.prototype[jsxFactory] = (Type, attributes, children) => {
let appendable = flattenChildren(children).filter(child => child instanceof Widget);
if (attributes) {
throw new Error('JSX: WidgetCollection can not have attributes');
}
return new WidgetCollection(appendable);
};

function typeToConstructor(jsxType) {
function typeAsFunction(jsxType) {
if (jsxType instanceof Function) {
return jsxType;
}
Expand Down
28 changes: 27 additions & 1 deletion test/tabris/JSX.test.js
@@ -1,6 +1,6 @@
import {expect, mockTabris, spy} from '../test';
import ClientStub from './ClientStub';
import {createElement} from '../../src/tabris/JSX';
import {createElement, jsxFactory} from '../../src/tabris/JSX';
import WidgetCollection from '../../src/tabris/WidgetCollection';
import Composite from '../../src/tabris/widgets/Composite';
import Button from '../../src/tabris/widgets/Button';
Expand Down Expand Up @@ -242,6 +242,32 @@ describe('JSX', function() {
expect(() => createElement('unknownWidget', null)).to.throw();
});

it('calls factory of custom type', function() {
class Foo {

constructor() {
this.isFoo = true;
}

[jsxFactory](type, props, children) {
let result = new Foo();
result.jsxType = type;
result.that = this;
result.props = props;
result.children = children;
return result;
}
}

expect(createElement(Foo, {foo: 'bar'}, 'a', 'b')).to.deep.equal({
isFoo: true,
jsxType: Foo,
that: null,
props: {foo: 'bar'},
children: ['a', 'b']
});
});

});

});
5 changes: 0 additions & 5 deletions test/typescript/JSX.fail.tsx
Expand Up @@ -80,14 +80,9 @@ let noIntrinsicElements: any = <textInput />;
(13,
(14,
(15,
(16,
'jsxProperties' is missing in type 'Widget'
(17,
'jsxProperties' is missing in type 'NativeObject'
(18,
'jsxProperties' is missing in type 'Device'
(19,
'jsxProperties' is missing in type 'App'
(28,
'foo' does not exist
(39,
Expand Down
8 changes: 7 additions & 1 deletion test/typescript/JSX.test.tsx
Expand Up @@ -56,6 +56,11 @@ class MyCustomWidgetWithUnpackedListeners extends tabris.Composite {

}

class NonWidgetElement implements JSX.ElementClass {
[JSX.jsxFactory](type: {new (): any }, properties: object, children: Array<any>) {
return new type();
}
}

let custom1: MyCustomWidget = <MyCustomWidget height={23}/>;
let custom2: MyCustomWidgetWithCustomJsx = <MyCustomWidgetWithCustomJsx height={23} bar='foo'/>;
Expand All @@ -66,4 +71,5 @@ let custom3: MyCustomWidgetWithUnpackedListeners = <MyCustomWidgetWithUnpackedLi
const str: string = event.value;
const widget: MyCustomWidgetWithUnpackedListeners = event.target;
}}
/>;
/>;
let custom4: NonWidgetElement = <NonWidgetElement />;
4 changes: 2 additions & 2 deletions tools/api-schema.json
Expand Up @@ -54,14 +54,14 @@
"description": "Change events will be generated automatically if type extends NativeObject",
"type": "object",
"patternProperties": {
"(^_?[a-z]\\w+$)|(^\\[key\\: number\\]$)": {"$ref": "#/definitions/property"}
"(^_?[a-z]\\w+$)|(^\\[.*\\]$)": {"$ref": "#/definitions/property"}
},
"additionalProperties": false
},
"methods": {
"type": "object",
"patternProperties": {
"^_?[a-z]\\w+$": {
"(^_?[a-z]\\w+$)|(^\\[.*\\]$)": {
"anyOf": [
{"$ref": "#/definitions/method"},
{"type": "array", "items": {"$ref": "#/definitions/method"}}
Expand Down
35 changes: 22 additions & 13 deletions tools/generate-dts.ts
Expand Up @@ -202,7 +202,7 @@ function createMethod(

function renderProperties(text: TextBuilder, def: ExtendedApi) {
const properties = Object.assign({}, def.properties, getClassDependentProperties(def));
const filter = name => name !== 'jsxProperties' || def.constructor.access !== 'protected';
const filter = name => name !== '[JSX.jsxFactory]' || def.constructor.access !== 'protected';
Object.keys(properties || {}).filter(filter).sort().forEach(name => {
text.append('');
text.append(createProperty(name, properties, def));
Expand Down Expand Up @@ -280,6 +280,9 @@ function decodeType(param: Partial<schema.Parameter & schema.Property>, def: Ext
case (PROPERTIES_OBJECT):
return createPropertiesObject(def, ops);

case ('JSX.JsxFactory'):
return def.constructor.access !== 'public' ? 'never' : param.type;

default:
return param.ts_type || param.type;
}
Expand All @@ -301,15 +304,15 @@ function createPropertiesObject(def: ExtendedApi, ops: PropertyOps) {
}

function createJsxPropertiesObject(def: ExtendedApi) {
if (def.constructor.access !== 'public') {
return 'never';
}
// NOTE: unlike with method parameters (i.e. "set(properties)") TypeScript can not auto-exclude properties
// of specific types because mapped types do not always work correctly with "this" based properties.
// For that reason all supporter JSX properties need to be added explicitly.
const inherit = def.isWidget && def.parent.type !== 'Widget';
const forbidden = def.constructor.access !== 'public' && def.parent && def.parent.constructor.access === 'public';
const inherit = def.isWidget && def.parent.type !== 'NativeObject';
const props = jsxPropertiesOf(def).concat(!inherit ? jsxPropertiesOf(def.parent) : []);
if (inherit && props.length) {
if (forbidden) {
return 'never';
} else if (inherit && props.length) {
return `${def.parent.type}['jsxProperties'] & JSXProperties<${def.type}, '${props.join(`' | '`)}'>`;
} else if (inherit && !props.length) {
return `${def.parent.type}['jsxProperties']`;
Expand Down Expand Up @@ -369,17 +372,23 @@ function isClassDependentMethod(def: ExtendedApi, method: Methods) { // methods
}

function isClassDependentProperty(def: ExtendedApi, property: schema.Property): boolean {
if (property.type === 'JSX.JsxFactory') {
return (def.constructor.access === 'public' && def.parent && def.parent.constructor.access === 'protected')
|| (def.constructor.access === 'private' && def.parent && def.parent.constructor.access === 'public');
}
return property.type === JSX_PROPERTIES_OBJECT;
}

function isClassDependentParameter(def: ExtendedApi, parameter: schema.Parameter) {
const autoExtendable = def.isWidget
&& def.type !== 'Widget'
&& !hasReadOnlyProperties(def)
&& !hasFunctionProperties(def)
&& !hasStaticProperties(def);
const newProps = Object.keys(def.properties || {}).filter(prop => !def.properties[prop].readonly);
return parameter.type === PROPERTIES_OBJECT && newProps && !autoExtendable;
if (parameter.type === PROPERTIES_OBJECT) {
const autoExtendable = def.isWidget
&& !hasReadOnlyProperties(def)
&& !hasFunctionProperties(def)
&& !hasStaticProperties(def);
const newProps = Object.keys(def.properties || {}).filter(prop => !def.properties[prop].readonly);
return newProps && !autoExtendable;
}
return false;
}

function hasReadOnlyProperties(def: ExtendedApi) {
Expand Down

0 comments on commit 57e19e0

Please sign in to comment.