Skip to content

Commit

Permalink
Ability for Panel components to auto parse attributes -> this.attrs (#39
Browse files Browse the repository at this point in the history
)

* Ability for Panel components to self reflect it's metadata and auto attrs -> state updates

* initialized

* newVal isn't needed

* Add deprecated warning for controlled component

* rename attr-reflection-app to attr-app

* update attributes to this.attrs

* Document $attrs

* dashed cased in this.attrs

* hasAttribute dom shim

* tslint

* attrs-reflection-app

* attrs-reflection-app tests

* add schema query test from customElements registry

* remove extra whitespace

* single line comment

* attrsSchema doc

* jsdoc doesn't show docs for statics. 🤷

Remove duplicated info with the .d.ts

* move style-override out of attrsSchema

* SPAAACE

* some nit fixes

* attrschema js dc

* spaace

* PR feedback

* some malformed tests

* jsdoc comment
  • Loading branch information
nojvek authored Aug 31, 2018
1 parent 5d880e4 commit 9098fc1
Show file tree
Hide file tree
Showing 11 changed files with 267 additions and 34 deletions.
13 changes: 13 additions & 0 deletions lib/component-utils/controlled-component.js
Original file line number Diff line number Diff line change
@@ -1,6 +1,19 @@
import Component from '../component';
import {EMPTY_DIV} from '../dom-patcher';

/**
* @deprecated
* ControlledComponent is deprecated. Compose with a normal component and controller
*
* @example
* constructor() {
* super(...arguments);
* this.controller = new ExampleController({store: this});
* this.setConfig(`defaultState`, this.controller.defaultState);
* }
*/


export default class ControlledComponent extends Component {
constructor() {
super(...arguments);
Expand Down
52 changes: 51 additions & 1 deletion lib/component.js
Original file line number Diff line number Diff line change
Expand Up @@ -213,6 +213,7 @@ class Component extends WebComponent {
// appState and isStateShared of child components will be overwritten by parent/root
// when the component is connected to the hierarchy
this.state = {};
this.attrs = {};
this.appState = this.getConfig(`appState`);
if (!this.appState) {
this.appState = {};
Expand Down Expand Up @@ -284,7 +285,9 @@ class Component extends WebComponent {
this.getJSONAttribute(`data-state`),
this._stateFromAttributes()
);

Object.assign(this.state, newState);
Object.keys(this.constructor.attrsSchema).forEach(attr => this._updateAttr(attr));

if (Object.keys(this.getConfig(`routes`)).length) {
this.router = new Router(this, {historyMethod: this.historyMethod});
Expand Down Expand Up @@ -314,14 +317,33 @@ class Component extends WebComponent {
this.initialized = false;
}

/**
* Attributes schema that defines the component's html attributes and their types
* Panel auto parses attribute changes into this.attrs object and $attrs template helper
*
* @typedef {object} AttrSchema
* @prop {'string' | 'number' | 'boolean' | 'json'} type - type of the attribute
* if not set, the attr parser will interpret it as 'string'
* @prop {string} default - value if the attr is not defined
* @prop {number} description - description of the attribute, what it does e.t.c
*
* @type {Object.<string, AttrSchema>}
*/
static get attrsSchema() {
return {};
}

static get observedAttributes() {
return [`style-override`];
return [`style-override`].concat(Object.keys(this.attrsSchema));
}

attributeChangedCallback(attr, oldVal, newVal) {
this._updateAttr(attr);

if (attr === `style-override`) {
this._applyStyles(newVal);
}

if (this.isPanelRoot && this.initialized) {
this.update();
}
Expand Down Expand Up @@ -352,6 +374,7 @@ class Component extends WebComponent {
$app: this.appState,
$component: this,
$helpers: this.helpers,
$attrs: this.attrs,
}));
} catch (e) {
this.logError(`Error while rendering ${this.toString()}`, this, e.stack);
Expand Down Expand Up @@ -396,6 +419,33 @@ class Component extends WebComponent {
return state;
}

/**
* Parses html attribute using type information from attrsSchema and updates this.attrs
* @param {string} attr - attribute name
*/
_updateAttr(attr) {
const attrsSchema = this.constructor.attrsSchema;
if (attrsSchema.hasOwnProperty(attr)) {
const attrSchema = attrsSchema[attr];
const attrType = attrSchema.type || `string`;
let attrValue = null;

if (!this.hasAttribute(attr) && attrSchema.hasOwnProperty(`default`)) {
attrValue = attrSchema.default;
} else if (attrType === `string`) {
attrValue = this.getAttribute(attr);
} else if (attrType === `boolean`) {
attrValue = this.isAttributeEnabled(attr);
} else if (attrType === `number`) {
attrValue = this.getNumberAttribute(attr);
} else if (attrType === `json`) {
attrValue = this.getJSONAttribute(attr);
}

this.attrs[attr] = attrValue;
}
}

// update helpers

// Update a given state store (this.state or this.appState), with option
Expand Down
36 changes: 35 additions & 1 deletion lib/index.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -63,9 +63,23 @@ declare namespace Component {
[hookName: string]: (params: any) => void;
}

interface TemplateScope<AppState = {}> {
/** AppState of the root panel component */
$app: AppState;

/** Attributes parsed from component's html attributes using attrsSchema */
$attrs: {[attr: string]: any};

/** A reference to the component itself */
$component: WebComponent;

/** Helpers defined in component config */
$helpers: Helpers;
}

interface ConfigOptions<State, AppState> {
/** Function transforming state object to virtual dom tree */
template(state: State): VNode;
template(scope: (TemplateScope<AppState> & State)): VNode;

/** Component-specific Shadow DOM stylesheet */
css?: string;
Expand Down Expand Up @@ -94,11 +108,31 @@ declare namespace Component {
/** Whether to use Shadow DOM */
useShadowDom?: boolean;
}

interface AttrSchema {
/** Type of the attribute, default is 'string' */
type?: 'string' | 'number' | 'boolean' | 'json';

/** Default value if the attr is not defined */
default?: any;

/** Description of attribute, what it does e.t.c */
description?: string;
}
}

type ConfigOptions<State, AppState> = Component.ConfigOptions<State, AppState>;

export class Component<State, AppState = {}> extends WebComponent {
/**
* Attributes schema that defines the component's html attributes and their types
* Panel auto parses attribute changes into this.attrs object and $attrs template helper
*/
static attrsSchema: {[attr: string]: Component.AttrSchema};

/** Attributes parsed from component's html attributes using attrsSchema */
attrs: {[attr: string]: any};

/** State object to share with nested descendant components */
appState: AppState;

Expand Down
5 changes: 5 additions & 0 deletions lib/isorender/dom-shims.js
Original file line number Diff line number Diff line change
Expand Up @@ -51,13 +51,18 @@ class HTMLElement extends Element {
}
}

hasAttribute(name) {
return !!this.attributes.find(attr => attr.name === name);
}

__attrIsObserved(name) {
if (!this.__observedAttrs) {
this.__observedAttrs = this.constructor.observedAttributes || [];
}
return this.__observedAttrs.includes(name);
}
}

global.HTMLElement = HTMLElement;


Expand Down
76 changes: 76 additions & 0 deletions test/browser/component.js
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
/* eslint no-unused-expressions:0 */

import nextAnimationFrame from './nextAnimationFrame';
import {compactHtml} from '../utils';

describe(`Simple Component instance`, function() {
let el;
Expand Down Expand Up @@ -190,6 +191,81 @@ describe(`Simple Component instance`, function() {
});
});

describe(`Simple Component instance with attrsSchema`, function() {
let el;

beforeEach(async function() {
document.body.innerHTML = ``;
el = document.createElement(`attrs-reflection-app`);
el.setAttribute(`str-attr`, `hello world`);

document.body.appendChild(el);
await nextAnimationFrame();
});

it(`renders template`, function() {
expect(el.innerHTML).to.equal(compactHtml(`
<div class="attrs-reflection-app">
<p>str-attr: "hello world"</p>
<p>bool-attr: true</p>
<p>number-attr: 0</p>
<p>json-attr: null</p>
</div>
`));
});

it(`updates attrs`, function() {
expect(el.attrs).to.deep.equal({
'str-attr': `hello world`,
'bool-attr': true,
'number-attr': 0,
'json-attr': null,
});
});

it(`reacts to attr updates`, async function() {
el.setAttribute(`str-attr`, `foo bae`);
el.setAttribute(`bool-attr`, `false`);
el.setAttribute(`number-attr`, `500843`);
el.setAttribute(`json-attr`, `{"foo": "bae"}`);

expect(el.attrs).to.deep.equal({
'str-attr': `foo bae`,
'bool-attr': false,
'number-attr': 500843,
'json-attr': {foo: `bae`},
});

await nextAnimationFrame();

expect(el.innerHTML).to.equal(compactHtml(`
<div class="attrs-reflection-app">
<p>str-attr: "foo bae"</p>
<p>bool-attr: false</p>
<p>number-attr: 500843</p>
<p>json-attr: {"foo":"bae"}</p>
</div>
`));
});

it(`can query schema from customElements registry`, async function() {
const component = customElements.get(`attrs-reflection-app`);
expect(component.attrsSchema).to.deep.equal({
'str-attr': {type: `string`},
'bool-attr': {type: `boolean`, default: true},
'number-attr': {type: `number`, default: 0},
'json-attr': {type: `json`},
});

expect(component.observedAttributes).to.deep.equal([
`style-override`,
`str-attr`,
`bool-attr`,
`number-attr`,
`json-attr`,
]);
});
});

describe(`Nested Component instance`, function() {
let el, childEl;
Expand Down
3 changes: 3 additions & 0 deletions test/browser/index.js
Original file line number Diff line number Diff line change
@@ -1,7 +1,10 @@
/* global chai */
import 'babel-polyfill';

import '../fixtures'; // import fixtures

// import tests
import './component';
import './component-utils';

chai.config.truncateThreshold = 0; // nicer deep equal errors
15 changes: 0 additions & 15 deletions test/fixtures/attr-reflection-app.js

This file was deleted.

19 changes: 19 additions & 0 deletions test/fixtures/attrs-reflection-app.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
import {Component, h} from '../../lib';

export class AttrsReflectionApp extends Component {
static get attrsSchema() {
return {
'str-attr': {type: `string`},
'bool-attr': {type: `boolean`, default: true},
'number-attr': {type: `number`, default: 0},
'json-attr': {type: `json`},
};
}
get config() {
return {
template: scope => h(`div`, {class: {'attrs-reflection-app': true}},
Object.entries(scope.$attrs).map(([attr, val]) => h(`p`, `${attr}: ${JSON.stringify(val)}`)),
),
};
}
}
4 changes: 2 additions & 2 deletions test/fixtures/index.js
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import {AttrReflectionApp} from './attr-reflection-app';
import {AttrsReflectionApp} from './attrs-reflection-app';
import {BreakableApp} from './breakable-app';
import {ControlledApp} from './controlled-app';
import {CssNoShadowApp} from './css-no-shadow-app';
Expand All @@ -8,7 +8,7 @@ import {ProxyApp, EventProducer} from './proxy-app';
import {ShadowDomApp} from './shadow-dom-app';
import {SimpleApp} from './simple-app';

customElements.define(`attr-reflection-app`, AttrReflectionApp);
customElements.define(`attrs-reflection-app`, AttrsReflectionApp);
customElements.define(`breakable-app`, BreakableApp);
customElements.define(`controlled-app`, ControlledApp);
customElements.define(`css-no-shadow-app`, CssNoShadowApp);
Expand Down
Loading

0 comments on commit 9098fc1

Please sign in to comment.