Skip to content

Getting Started

Kevin Mas Ruiz edited this page Dec 30, 2019 · 9 revisions

morphonent is bundled as a single ES module that can be loaded on the browser. However, for production use, you probably would like to bundle your own application with morphonent, so it's a single small download for your customers.

The library itself is not opinionated on how you design your application, however it aims to promote immutability, polymorphism and event driven architectures. We won't cover all topics on this tutorial, but after a few minutes and a cup of coffee, you will be ready to write your own application with morphonent from scratch.

Bootstrapping your application

morphonent is not going to forbid you choosing the tools you like: you can use it with any bundler that supports ES modules. However, for this tutorial, we will be using webpack.

For building your own application, let's start from the beginning. We are going to create a new project with yarn. We are going to use yarn on the entire tutorial, but feel free to use npm if you are more comfortable with it.

$> mkdir my-awesome-app
$> cd my-awesome-app
$> yarn init

We are going to need webpack and morphonent, so let's install both of them.

# We are going to use jest and jest-babel to test our application, so we can use modern js features in our tests.
$> yarn add -D webpack webpack-cli webpack-dev-server @babel/core @babel/preset-env babel-jest jest morphonent-test
# morphonent is the only library needed in production
$> yarn add morphonent

We are going to assume some basic configuration so we can move forward. Create a file named webpack.config.js and paste the following configuration:

var path = require('path');

module.exports = {
	entry: path.join(__dirname, 'app', 'index.js'),
  output: {
    filename: path.join('main.js')
  },
  devServer: {
    contentBase: path.join('dist'),
    compress: true,
    port: 9000,
  }
};

Open your package.json and add the following scripts:

"scripts": {
...
    "build": "webpack --config webpack.config.js",
    "start": "webpack-dev-server --config webpack.config.js",
    "test": "jest --watchAll",
    "test:once": "jest"
...  
},

Create a folder named dist and add the following index.html file inside:

<html>
    <head>
        <script src="main.js"></script>
    </head>
    <body>
    </body>
</html>

webpack will be compiling the file located at app/index.js so let's create it with a simple skeleton:

import { renderOn, element } from 'morphonent'

function helloWorld() {
    return element('div', {}, 'Hello World!')
}

window.onload = () => {
    renderOn('body', helloWorld)
}

Now if we start webpack:

$> yarn start

and if we open the browser in http://localhost:9000/ we will see our first hello world application!

Creating our first component

In morphonent, components are just normal functions that return elements. An element is anything that can be eventually rendered:

  • a DOM tree built with the element function.
  • a string
  • a transition
  • an array of elements
  • a promise

So let's start writing a test that will check that our component structure is what we need. For that, we are going to create a new file called app/index.test.js and we are going to write the following content:

import { testing } from 'morphonent-test'
import helloWorld from './index'

describe('hello world', () => {
    it('should render "Hello World!"', async () => {
        const message = await testing(helloWorld).textContent();
        expect(message).toBe('Hello World!')
    })
})

Now we can create our initial application:

import { renderOn, element } from 'morphonent'

export default function helloWorld() {
    return element('div', {}, 'Hello World!')
}

window.onload = () => {
    renderOn('body', helloWorld)
}

Interacting with components

However, a text that does nothing is not really useful. Let's make a button that will count the number of times it has been clicked.

Let's change our component to be a button that receives as a parameter the number of times it has been clicked and change the content as needed:

// app/index.test.js
import { testing } from 'morphonent-test'
import button from './index'

describe('button', () => {
    it('should render the number of times it has been clicked', async () => {
        const message = await testing(button(0)).textContent();
        expect(message).toContain('0')
    })
})

// app/index.js
export function button(times) {
    return element('button', {}, "This button has been clicked " + times + " times")
}

window.onload = () => {
    renderOn('body', button(0))
}

Now, let's add an event handler to the button. Event handlers are named like DOM event handlers, so you won't need to learn anything new nor remember special conventions. If you want to handle a click, onclick is your event handler.

In morphonent, components do not mutate when they receive new events. What they suffer is a transformation: they become other components. The other component can be of any type. Let's see an example, so it's more clear:

// app/index.test.js
import { testing, click } from 'morphonent-test'
// ...
// new test
it('should increase the number of times when clicked', async () => {
    const message = await testing(button(0)).trigger(click()).textContent();
    expect(message).toContain('1')
})
// ...
// app/index.js
// ...
export function button(times) {
    return element('button', { onclick: () => button(times + 1) }, "This button has been clicked " + times + " times")
}
// ...

Now if you click on the button you will see how the counter increases. As mentioned before, components do not mutate but become other components. What you actually do is, whenever there is a change (usually triggered by DOM events, but there are other ways) the event handler will return the new component that will be rendered. It doesn't need to be the same component to work.

Let's make a bit more complex change. When we arrive to 3clicks, we will show a game over message. Let's start with our game over component:

// app/index.test.js
import { button, gameOver } from './index'

describe('gameOver', () => {
    it('should render a game over message', async () => {
        const message = await testing(gameOver()).textContent()
        expect(message).toBe('Game over! You won!')
    })

    it('should be blue', async () => {
        const style = await testing(gameOver()).attribute('style')
        expect(style).toBe('color: blue')
    })
})
// app/index.js
function gameOver() {
    return element('h1', { style: 'color: blue' }, 'Game over! You won!')
}

This component is still not used, but it's defined. Now we are going to define an interaction. An interaction is bound to a persona (a type of user, in our case, a unique player) and are the handlers of events and define how components are going to change over time.

// app/index.test.js
import { button, gameOver, playerPlays } from './index'

describe('player', () => {
    describe('play', () => {
        it('should render a new button with the new count, if count was less than 3', async () => {
            const message = await testing(playerPlays(0)).trigger(click()).textContent()
            expect(message).toContain('1')
        })

        it('should render game over if number of clicks is 3', async () => {
            const message = await testing(playerPlays(2)).trigger(click()).textContent()
            expect(message).toBe('Game over! You won!')
        })
    })
})

// app/index.js
export function playerPlays(numberOfTimesClicked) {
    if (numberOfTimesClicked >= 3) {
        return gameOver()
    } else {
        return button(numberOfTimesClicked);
    }
}

Now, if you play a bit with the button and click 3 times, it will become a gameOver component!

Fun Fact: Event handlers are also components, as they are functions that receive parameters (the event that happened) and return an element.

Composing components

Because components are functions, you can compose them to create bigger components easily. When we compose components, however, we can differentiate two types of components:

  • Root component acts as the parent and accountant to render all other components. Event handlers results will be applied at this level. For example, if a subcomponent receives an event handler and returns a new component, the component will be replaced at the root component level, not at the subcomponent level.
  • Subcomponents are tied to the root component.

Let's create a simple application that will log whatever we tell to it. Let's start with the log itself. It will be a function that, given an array of objects { when: Date, text: String } will render a HTML list.

function log(items) {
    return element("ul", {}, items.map(
        item => element("li", {}, "[" + item.when + "] " + item.text)
    ))
}

Now let's create a component that will be used to add new items to the log. It will consist of an input text and a button.

function logger(currentLog, { logAdded, logChanged }) {
    return element("div", {},
        element("input", { type: "text", value: currentLog, onchange: (event) => logChanged(event.target.value) }),
        element("button", { onclick: () => logAdded({ when: new Date(), text: currentLog }) }, "Add Log")
    )
}

This component needs two event handlers:

  • logAdded. When we click on the button, we need to add a new log.
  • logChanged. When we change the content of the input, we will be storing the information.

Now we need a root component that will:

  • Set up event handlers
  • Render the logger and the log components
function application(currentLog, logs) {
    return [
        logger(currentLog, { logAdded: (log) => application("", logs.concat(log)), logChanged: (log) => application(log, logs) }),
        log(logs)
    ]
}

window.onload = () => {
    renderOn('body', application('', []))
}

Next Steps

Clone this wiki locally