Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Improve routing to components (and testing) #4030

Closed
chasenlehara opened this issue Mar 9, 2018 · 9 comments
Closed

Improve routing to components (and testing) #4030

chasenlehara opened this issue Mar 9, 2018 · 9 comments
Labels

Comments

@chasenlehara
Copy link
Member

chasenlehara commented Mar 9, 2018

TLDR: It's difficult to set up routing between the state set on the application view model and the components and bindings that need to be rendered. This proposal provides a mechanism to make this easier by:

  • Allowing components to easily instantiated with new.
    const c = new LoginPage({})
    c.element
    c.viewModel
  • Allowing components to be used as values to be inserted in stache templates:
    const template = stache("<div>{{component}}</div>")({component: c}); 
  • Allowing components to be instantiated with bindings.
    const c = new LoginPage({
      isLoggedIn: defineMap.bind("loggedIn")
    });

This was discussed on a recent live stream (11:12).

Motivation

Currently, most of our guides show using can-stache to figure out how to render a component. This creates cumbersome switch statements in stache like this:

{{#switch(componentToShow)}}
    {{#case("home")}}
        <page-home isLoggedIn:from="isLoggedIn" logout:from="logout"/>
    {{/case}}
    {{#case("tasks")}}
        <task-editor id:from="taskId" logout:from="logout"/>
    {{/case}}
    {{#case("login")}}
        <page-login isLoggedIn:bind="isLoggedIn" />
    {{/case}}
    {{#default}}
        <h2>Page Missing</h2>
    {{/default}}
{{/switch}}

Logic is MUCH harder to write in stache than in JS.

So, the alternative way Bitballs renders its page components is a popular pattern is to create a {{{pageComponent(scope)}}} in your index.stache and the helper will progressively load the required component and render it in the template.

We’d like to make this pattern easier so any component can be instantiated by its constructor function and rendered in a template, which would make the above code more simple and ease additional use cases (such as testing components).

This pattern is easy to get wrong as nodeLists matter. Something like the following might leak because nodeLists might not be setup correctly:

const homePageFrag =stache('<home-page />')();

const outerTemplate = stache(`{{# if(true) }} {{homePageFrag}} {{/if}}`)

document.body.appendChild(
  outerTemplate({})
);

Overview

There are a few parts to this:

  • The signature and result of Creating components
    const c = new LoginPage({});
    c.element
    c.viewModel
    
  • How to correctly insert the component in a template.

Creating components

Checkout this Routing Example.

Proposal:

const componentInstance = new ComponentType({
  viewModel: {}, // Object<String, ValueObservable|*>
  templates: {}, // Object<String, renderer>
  content: renderer 
})

Where:

  • ComponentType is an extended Component
  • componentInstance is an instance of that component (NOT an element). To access the element and view model:
    • componentInstance.element
    • componentInstance.viewModel
  • viewModel - An object of strings to values to create the ViewModel with. For example:
    {
      viewModel: {name: "Justin"}
    }
    This will set the component's viewModel instance's name to "Justin". If values are observable values (like computes, Observations, SimpleObservable, etc), this will be used to setup bindings similar to can-view-stache bindings. For example, the following will two-way bind between the viewModel's name and the simpleObservable:
    {
      viewModel: {name: new SimpleObservable("Justin") }
    }
    The direction of the binding can be determined by the symbols implemented on the object. If the observable object has:
    • getValue -> name:from='observable'
    • setValue -> name:to='observable'
    • Both get and set value -> name:bind='observable'

    Later I'll suggest changes to our observables to support this.
    @phillipskevin Is this a problem with sticky bindings?

  • templates - An optional object of slot names to renderers:
    {
      templates: {
        "email-input": stache(`<input value='abc'/>)`
      }
    }
  • content - Just like templates, but for the <content> tag.

Creating observables for to, from, and two-way binding.

I think DefineMap and observe.Object can get .from, .to and .bind methods:

const me = new DefineMap({name: "Justin"});

const getName = me.from("name");
const setName = me.to("name");
const getAndSet = me.bind("name");

Alternatively, we don't need to shorten them that much:

const me = new DefineMap({name: "Justin"});

const getName = me.valueFrom("name");
const setName = me.valueTo("name");
const getAndSet = me.valueBind("name");

I think can-value should have these built in so they can be used on anything:

const value = require("can-value");

const me = new DefineMap({name: "Justin"});

const getName = value.from( me, "name");
const setName = value.to( me, "name");
const getAndSet = value.bind( me, "name");

How these get rendered.

If new Component() returns an instance of a component, we need to get the element to add to the DOM. We might also need the ability to talk to nodeLists.

So, I think we need a way for entities like components (maybe Controls too), to tell stache how to hook them up. This is probably a symbol. I propose can.element for now.

TESTING

This really helps testing. No more having to call out to stache to test a component:

test("changing a property", function(){
  const component = new MyNameComponent({
    viewModel: {name: "Justin"}
  });
  component.viewModel.name = "Bohdi";
  assert.ok( /Bohdi/.test( component.element.innerHTML ), "updated something to bohdi");
});

How we teach this

In addition to showing this pattern as one of the API signatures in can-component’s docs, we would most likely show this pattern in our new routing guide and a future testing guide.

@justinbmeyer justinbmeyer changed the title WIP Proposal: Instantiate & render can-component instances from their constructor functions PROPOSAL: Improve routing to components. Mar 9, 2018
@chasenlehara chasenlehara changed the title PROPOSAL: Improve routing to components. Proposal: Improve routing to components Mar 9, 2018
@justinbmeyer justinbmeyer changed the title Proposal: Improve routing to components Proposal: Improve routing to components (and testing) Mar 9, 2018
@jeroencornelissen
Copy link

jeroencornelissen commented Mar 27, 2018

Looks very promising. 👍
Would be very useful if you could access the component.element directly inside the component.viewModel. Now we often have to set it in the connectedCallback.

@justinbmeyer
Copy link
Contributor

Can you create another issue about having element on the viewModel?

@jeroencornelissen
Copy link

@justinbmeyer #4081

@chasenlehara
Copy link
Member Author

This has been released! There were a few packages involved in making this happen, so let’s go through them:

First is can-value 1.0. can-value makes it possible to create new observables that are bound to a specific key in another observable. The example below shows can-value.bind() being used to get an observable that can get and set outer.inner.key:

import DefineMap from "can-define/map/map";
import value from "can-value";

const outer = new DefineMap({
  inner: {
    key: "hello"
  }
});

const keyObservable = value.bind(outer, "inner.key");

Now if we read keyObservable.value, we get the value at outer.inner.key:

keyObservable.value; // is "hello"

We can also set keyObservable.value to change the value at outer.inner.key:

keyObservable.value = "aloha";
// Now outer.inner.key === "aloha"

@chasenlehara
Copy link
Member Author

Next, let’s talk about can-component 4.2, which makes it possible to instantiate component instances with new without rendering the instances in a template. This is useful when you:

  • have complex logic for switching between different components (e.g. routing)
  • want to create components without adding them to the page (e.g. testing)

The following defines a MyGreeting component and creates a my-greeting element by calling new on the component’s constructor function:

const HelloWorld = Component.extend({
	tag: "hello-world",
	view: `
		<can-slot name="greetingTemplate" />
		<content>world</content>
		<ul>{{#each(items)}} {{this}} {{/each}}</ul>
	`,
	ViewModel: {
		items: {}
	}
});

// Create a new instance of our component
const componentInstance = new HelloWorld({

	// values with which to initialize the component’s view model
	viewModel: {
		items: ["eat"]
	},

	// can-stache template to replace any <content> elements in the component’s view
	content: "<em>{{message}}</em>",

	// <can-template> strings rendered by can-stache with the scope
	templates: {
		greetingTemplate: "{{greeting}}"
	},

	// scope with which to render the <content> and templates
	scope: {
		greeting: "Hello",
		message: "friend"
	}
});

myGreetingInstance.element; // is like <my-greeting>Hello <em>friend</em> <ul> <li>eat</li> </ul></my-greeting>

myGreetingInstance.viewModel; // is HelloWorld.ViewModel{items: ["eat"]}

Changing the component’s view model will cause its element and any bindings to be updated:

myGreetingInstance.viewModel.items.push("sleep");

myGreetingInstance.element; // is like <my-greeting>Hello <em>friend</em> <ul> <li>eat</li> <li>sleep</li> </ul></my-greeting>

@chasenlehara
Copy link
Member Author

Now let’s tie those new features of can-component and can-value together.

The viewModel option accepts values from can-value, which means you can one-way or two-way bind values from another view model to the component’s view model. For example:

import Component from "can-component";
import DefineMap from "can-define/map/map";
import value from "can-value";

const appVM = new DefineMap({
  association: "friend"
});

const MyGreeting = Component.extend({
  tag: "my-greeting",
  view: "{{greeting}} {{subject}}",
  ViewModel: {
    greeting: "string",
    subject: "string"
  }
});

const myGreetingInstance = new MyGreeting({
  viewModel: {
    greeting: "Hello",
    subject: value.bind(appVM, "association")
  }
});

myGreetingInstance.element; // is <my-greeting>Hello friend</my-greeting>

myGreetingInstance.viewModel; // is MyGreeting.ViewModel{subject: "friend"}

The way the component is instantiated above is similar to this example below, assuming it’s rendered by can-stache with appVM as the current scope:

<my-greeting greeting:raw="Hello" subject:bind="association"></my-greeting>

can-value’s bind, from, and to methods can be used to can recreate two-way, one-way parent-to-child, and one-way child-to-parent bindings, respectively.

const appVM = new DefineMap({
  family: {
    first: "Milo",
    last: "Flanders"
  }
});

const NameComponent = Component.extend({
  tag: "name-component",
  view: "{{fullName}}",
  ViewModel: {
    givenName: "string",
    familyName: "string",
    get fullName() {
      return this.givenName + " " + this.familyName;
    }
  }
});

const componentInstance = new NameComponent({
  viewModel: {
    givenName: value.from(appVM, "family.first"),
    familyName: value.bind(appVM, "family.last"),
    fullName: value.to(appVM, "family.full"),
  }
});

The way the component is instantiated above is similar to this example below, assuming it’s rendered by can-stache with appVM as the current scope:

<my-greeting
  givenName:from="family.first"
  familyName:bind="family.last"
  fullName:to="family.full"
></my-greeting>

This will result in an appVM with the following data:

{
  family: {
    first: "Milo",
    full: "Milo Flanders",
    last: "Flanders"
  }
}

Changing the component’s view model will cause its element and any bindings to be updated:

componentInstance.viewModel.familyName = "Smith";

componentInstance.element; // is <name-component>Milo Smith</name-component>

appVM.family.last; // is "Smith"

If you voted for this proposal and the above doesn’t excite you, let me know how I can explain this better because it should BLOW YOUR MIND.

@chasenlehara
Copy link
Member Author

For the icing on this cake, I’d like to show you what’s new in can-stache 4.9. This release makes it possible to render component instances in a template with the “unescaped” (triple-curly) tags:

import Component from "can-component";
import stache from "can-stache";

const MyGreeting = Component.extend({
  tag: "my-greeting",
  view: "<p>Hello {{subject}}</p>",
  ViewModel: {
    subject: "string"
  }
});

const myGreetingInstance = new MyGreeting({
  viewModel: {
    subject: "friend"
  }
});

const template = stache("<div>{{{componentInstance}}}</div>");

const fragment = template({
  componentInstance: myGreetingInstance
});

fragment; //-> <div><my-greeting><p>Hello friend</p></my-greeting></div>

myGreetingInstance is just a variable in an example above, but this means you can return a component instance from a property in your view model and have that rendered in your template!

@chasenlehara
Copy link
Member Author

Creating new component instances in your view model and rendering them with can-stache means it’s possible to do something like what was described in the original proposal:

https://gist.github.com/chasenlehara/3e20479e3cf0fa53510e00bd7062c535#file-route-example-js-L34

Hurrah! 🎉

Of course, there are more details to be shown in the upcoming routing and testing guides, but hopefully this gives you enough info to start using these new features in your application today. 🏁

@chasenlehara
Copy link
Member Author

All of this has been released in can@4.3.0.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
Projects
None yet
Development

No branches or pull requests

3 participants