If you don't any experience building component this guide should help you understand some of the core concept used in Bearer Framework.
Stencil Heritage
Bearer heavily use Stencil behind the Scene to render components, as a result many of the concepts & documentations shown below is inherited. Thanks to the Stencil team for their amazing work 🙏
Bearer components are created by adding a new file with a .tsx extension, such as my-first-component.tsx
, and placing them in the /views
or /views/components
directory. The .tsx extension is required since Bearer components are built using JSX and TypeScript.
Bearer provides two kinds of components, see Scenario Anatomy.
Here is an example of what a Bearer component looks like:
import { Component, Intent, BearerFetch } from '@bearer/core'
@Component({
tag: 'list-repositories'
})
export class ListRepositories {
@Intent('ListRepositories') fetcher: BearerFetch
render() {
return <bearer-scrollable fetcher={this.fetcher} />
}
}
Once compiled, this components can be used in HTML just like any other tag.
<list-repositories />
Nesting
Components can be composed easily by adding the HTML tag to the JSX code. Since components are just HTML tags, nothing needs to be imported to component within another component.
Here is an example:
import { Component, Prop } from '@bearer/core'
@Component({
tag: 'my-embedded-component'
})
export class MyEmbeddedComponent {
@Prop() color: string = 'blue';
render() {
return (
<div>My favorite color is {this.color}</div>
)
}
}
import { Component, Prop } from '@bearer/core'
@Component({
tag: 'my-parent-component'
})
export class MyParentComponent {
render() {
return (
<my-embedded-component color="red"></my-embedded-component>
)
}
}
Bearer components are rendered using JSX, a popular, declarative template syntax. Each component has a render function that returns a tree of components that are rendered to the DOM at runtime.
Data binding
Components often need to render dynamic data. To do this in JSX, use { } around a variable:
render() {
return (
<div>Hello {this.name}</div>
)
}
Conditionals
If we want to conditionally render different content, we can use JavaScript if/else statements: Here, if name
is not defined, we can just render a different element.
render() {
if (this.name) {
return ( <div>Hello {this.name}</div> )
} else {
return ( <div>Hello, World</div> )
}
}
Additionally, inline conditionals can be created using the JavaScript ternary operator:
render() {
return (
{this.name
? <p>Hello {this.name}</p>
: <p>Hello World</p>
}
)
}
Loops
Loops can be created in JSX using either traditional loops when creating JSX trees, or using array operators such as map
when inlined in existing JSX.
In the example below, we're going to assume the component has a local property called todos
which is a list of todo objects. We'll use the map function on the array to loop over each item in the map, and to convert it to something else - in this case JSX.
render() {
return (
<ul>
{this.todos.map((todo) =>
<li>
<div>{todo.taskName}</div>
<div>{todo.isCompleted}</div>
</li>
)}
</ul>
)
}
Each step through the map
function creates a new JSX sub tree and adds it to the array returned from map
, which is then drawn in the JSX tree above it.
Components have numerous lifecycle methods which can be used to know when the component "will" and "did" load, update, and unload. These methods can be added to a component to hook into operations at the right time.
Implement one of the following methods within a component class and it will get automatically call them in the right order:
import { Component } from '@bearer/core';
@Component({
tag: 'my-component'
})
export class MyComponent {
/**
* The component is about to load and it has not
* rendered yet.
*
* This is the best place to make any data updates
* before the first render.
*
* componentWillLoad will only be called once.
*/
componentWillLoad() {
console.log('Component is about to be rendered');
}
/**
* The component has loaded and has already rendered.
*
* Updating data in this method will cause the
* component to re-render.
*
* componentDidLoad will only be called once.
*/
componentDidLoad() {
console.log('Component has been rendered');
}
/**
* The component is about to update and re-render.
*
* Called multiple times throughout the life of
* the component as it updates.
*
* componentWillUpdate is not called on the first render.
*/
componentWillUpdate() {
console.log('Component will update and re-render');
}
/**
* The component has just re-rendered.
*
* Called multiple times throughout the life of
* the component as it updates.
*
* componentWillUpdate is not called on the
* first render.
*/
componentDidUpdate() {
console.log('Component did update');
}
/**
* The component did unload and the element
* will be destroyed.
*/
componentDidUnload() {
console.log('Component removed from the DOM');
}
}
Rendering State
It's always recommended to make any rendered state updates within componentWillLoad()
or componentWillUpdate()
, since these are the methods which get called before the render()
method. Alternatively, updating rendered state with the componentDidLoad()
or componentDidUpdate()
methods will cause another re-render, which isn't ideal for performance.
If state must be updated in componentDidUpdate()
, it has the potential of getting components stuck in an infinite loop. If updating state within componentDidUpdate()
is unavoidable, then the method should also come with a way to detect if the props or state is "dirty" or not (is the data actually different or is it the same as before). By doing a dirty check, componentDidUpdate()
is able to avoid rendering the same data, and which in turn calls componentDidUpdate()
again.
Lifecycle Hierarchy
A useful feature of lifecycle methods is that they take their child component's lifecycle into consideration too. For example, if the parent component, cmp-a
, has a child component, cmp-b
, then cmp-a
isn't considered "loaded" until cmp-b
has finished loading. Another way to put it is that the deepest components finish loading first, then the componentDidLoad()
calls bubble up.
It's also important to note that even though components are lazy-loaded, and have asynchronous rendering, the lifecycle methods are still called in the correct order. So while the top-level component could have already been loaded, all of its lifecycle methods are still called in the correct order, which means it'll wait for a child components to finish loading. The same goes for the exact opposite, where the child components may already be ready while the parent isn't.
In the example below we have a simple hierarchy of components. The numbered list shows the order of which the lifecycle methods will fire.
<cmp-a>
<cmp-b>
<cmp-c></cmp-c>
</cmp-b>
</cmp-a>
1 cmp-a
- componentWillLoad()
2 cmp-b
- componentWillLoad()
3 cmp-c
- componentWillLoad()
4 cmp-c
- componentDidLoad()
5 cmp-b
- componentDidLoad()
6 cmp-a
- componentDidLoad()
Even if some components may or may not be already loaded, the entire component hierarchy waits on its child components to finish loading and rendering.
Components can emit data and events using the Event Emitter decorator.
To dispatch Custom DOM events for other components to handle, use the @Event()
decorator.
import { Event, EventEmitter } from '@bearer/core';
export class TodoList {
@Event() todoCompleted: EventEmitter;
todoCompletedHandler(todo: Todo) {
this.todoCompleted.emit(todo);
}
}
The code above will dispatch a custom DOM event called todoCompleted
.
Listening for Events
The Listen()
decorator is for handling events dispatched from @Events
.
In the example below, assume that a child component, TodoList
, emits a todoCompleted
event using the EventEmitter
.
import { Listen } from '@bearer/core';
export class TodoApp {
@Listen('todoCompleted')
todoCompletedHandler(event: CustomEvent) {
console.log('Received the custom todoCompleted event: ', event.detail);
}
}
Handlers can also be registered for an event on a specific element. This is useful for listening to application-wide events. In the example below, we're going to listen for the scroll event.
import { Listen } from '@bearer/core';
export class TodoList {
@Listen('body:scroll')
handleScroll(ev) {
console.log('the body was scrolled', ev);
}
}
Using events in JSX
You can also bind listeners to events directly in JSX. This works very similar to normal DOM events such as onClick
.
Lets use our TodoList component from above:
import { Event, EventEmitter } from '@bearer/core';
export class TodoList {
@Event() todoCompleted: EventEmitter;
todoCompletedHandler(todo: Todo) {
this.todoCompleted.emit(todo);
}
}
Bearer makes it easy to build rich, interactive components using Decorators.
Component & RootComponent
Each Bearer Component must be decorated with either a @Component()
or @RootComponent()
decorator.
Every Scenario must provide at least one RootComponent in order to be Integrated.
The @RootComponent()
decorator looks like this:
import { RootComponent } from '@bearer/core';
@RootComponent({
role: 'action',
group: 'feature',
})
Every Root Component end-up being served as a WebComponent, the HTML tag associated is generated using: <ScenarioName-RootComponentGroup-RootComponentName />
.
The @Component()
decorator:
import { Component } from '@bearer/core';
@Component({
tag: 'create-reminder',
})
Output and Input
The Output
decorator is used to store information that can be retrieved later. It is automatically connected with the SaveState
Intent that will be responsible for storing in Bearer's storage.
import { RootComponent, Output } from '@bearer/core'
import '@bearer/ui'
@RootComponent({
role: 'action',
group: 'feature'
})
export class FeatureAction {
@Output() foo: any = []
myMethod = ({ data, complete }): void => {
this.foo = [this.foo, data.foo]
complete()
}
render() {
return (
<bearer-navigator
direction="right"
complete={this.myMethod}
>
</bearer-navigator>
)
}
}
import { SaveState, TOAUTH2AuthContext, TSaveStateCallback } from '@bearer/intents'
export default class SaveFooIntent {
static intentType: any = SaveState
static action(
_context: TOAUTH2AuthContext,
_params: any,
body: any,
state: any,
callback: TSaveStateCallback
): void {
callback({
state: {
state,
foo: body.foo
},
data: body.foo
})
}
}
The Input
decorator is used to retrieve stored information. It is connected to the RetrieveState
Intent that is going to retrieve the data from Bearer's storage automatically.
import { RootComponent, Input } from '@bearer/core'
import '@bearer/ui'
@RootComponent({
role: 'display',
group: 'feature'
})
export class FeatureDisplay {
@Input() foo: any = []
render() {
return (
<ul>
{this.foo.map(item => (
<li></li>
))}
</ul>
)
}
}
import { RetrieveState, TOAUTH2AuthContext, TRetrieveStateCallback } from '@bearer/intents'
import Client from './client'
export default class RetrieveFooIntent {
static intentType: any = RetrieveState
static action(context: TOAUTH2AuthContext, _params: any, state: any, callback: TRetrieveStateCallback) {
callback({ data: state.foo })
}
}
The Input
and the Output
decorator works well together.
When 2 Root Components are using an Input
and an Output
are on the same page, they will communicate with each other automatically.
State
The @State()
decorator can be used to manage internal data for a component. This means that a user cannot modify this data from outside the component, but the component can modify it however it sees fit. Any changes to a @State()
property will cause the components render
function to be called again.
import { State } from '@bearer/core';
export class TodoList {
@State() completedTodos: Todo[];
completeTodo(todo: Todo) {
// This will cause our render function to be called again
this.completedTodos = [this.completedTodos, todo];
}
render() {
//
}
}
Intent
Intent decorator ease the way to retrieve data from the intents created. It handles all the complexity behind data fetching for you.
Here is a simple usage:
import { Component, Intent, BearerFetch } from '@bearer/core';
@Component({
tag: 'list-pull-request',
})
class ListPullRequest {
@Intent('listPullRequest') fetcher: BearerFetch
render(){
return (
<bearer-scrollable fetcher={this.fetcher} />
)
}
}
We added a fetcher
property to our component. Then we passed it to the bearer-scrollable
component. When bearer-scrollable
is mounted, it uses our fetcher
to fetch data from the listPullRequest
intent implemented.
Passing extra parameter to the fetcher.
In the example below we pass extra information to the fetcher, going all its way to the the intent:
import { Component, Intent, State, BearerFetch } from '@bearer/core';
@Component({
tag: 'advanced-fetcher',
})
class AdvancedFetcher {
@Intent('listPullRequest') fetcher: BearerFetch
@Prop() repositoryName: string
customizedFetcher: BearerFetch = (params = {}): Promise<any> => {
return this.fetcher({ params, repositoryName: this.repositoryName })
}
render(){
return (
<bearer-scrollable fetcher={this.customizedFetcher} />
)
}
}
SaveState
The SaveState
is automatically created for you when you use the @Output
Decorator.
See Component Communication
Each scenario must have at least one Intent
with SaveState
type either you want to store data (save pull requests references) or you want to perform an action without expecting to store any data (create a slack reminder).
import { RootComponent, Intent, IntentType State, BearerFetch } from '@bearer/core';
@RootComponent({
role: 'action',
group: 'feature',
})
class FeactureAction {
@Intent('saveState', IntentType.SaveState) fetcher: BearerFetch
completeScenario = ({ data, complete }) => {
this.fetcher({ body: data }).then(() => {
// update local BearerState if needed
complete()
})
}
render(){
return (
<bearer-navigator complete={this.completeScenario}>
//many screens
</bearer-navigator>
)
}
}
When a user complete the feature action flow then the data (coming from the navigator completion) is sent to the completeScenario
method.
Retrieving Scenario State with Intent Decorator
RetrieveState
The RetrieveState
is automatically created for you when you use the @Input
Decorator.
See Component Communication
In some cases, we want to retrieve the current state of the scenario to display it within the display
component. Here's how to achieve this:
import { RootComponent, Intent, IntentType, BearerState, BearerFetch, State } from '@bearer/core';
@RootComponent({
role: 'display',
group: 'feature',
})
class FeactureDisplay {
@Intent('RetrieveState', IntentType.RetrieveState) fetcher: BearerFetch
@State() loading: boolean = true
@BearerState() attachedPullRequests: Array<any> = []
componentDidLoad() {
this.loading = true
this.fetcher()
.then(({ data }) => {
if (data) {
this.attachedPullRequests = data
}
this.loading = false
})
.catch(error => {
console.error('Error while fetching', error)
this.loading = false
})
}
render() {
if (this.loading) {
return <bearer-loading />
}
return (
<ul>
{this.attachedPullRequests.map(pr => <li>{pr.title}</li>)}
</ul>
)
}
}
Prop
Props are custom attribute/properties exposed publicly on the element that developers can provide values for. Children components should not know about or reference parent components, so Props should be used to pass data down from the parent to the child. Components need to explicitly declare the Props they expect to receive using the @Prop()
decorator. Props can be a number
, string
, boolean
, or even an Object
or Array
. By default, when a member decorated with a @Prop()
decorator is set, the component will efficiently re-render.
import { Prop } from '@bearer/core';
export class TodoList {
@Prop() color: string;
@Prop() favoriteNumber: number;
@Prop() isSelected: boolean;
@Prop() myHttpService: MyHttpService;
}
Within the TodoList
class, the Props are accessed via the this
operator.
logColor() {
console.log(this.color)
}
Externally, Props are set on the element (In HTML, you must set attributes using dash-case):
<todo-list color="blue" favorite-number="24" is-selected="true"></todo-list>
in JSX you set an attribute using camelCase:
<todo-list color="blue" favoriteNumber="24" isSelected="true"></todo-list>
Prop value mutability
It's important to know, that a Prop is by default immutable from inside the component logic. Once a value is set by a user, the component cannot update it internally.
However, it's possible to explicitly allow a Prop to be mutated from inside the component, by declaring it as mutable, as in the example below:
import { Prop } from '@bearer/core';
export class NameElement {
@Prop({ mutable: true }) name: string = 'Bearer';
componentDidLoad() {
this.name = 'Bearer Beta';
}
}
import { Prop } from '@bearer/core';
export class NameElement {
@Prop() name: string = 'Bearer';
}
Watch
When a user updates a property, Watch
will fire what ever method it's attached to and pass that method the new value of the prop along with the old value. Watch
is useful for validating props or handling side effects.
import { Prop, Watch } from '@bearer/core';
export class LoadingIndicator {
@Prop() activated: boolean;
@Watch('activated')
watchHandler(newValue: boolean, oldValue: boolean) {
console.log('The new value of activated is: ', newValue);
}
}
@Component({
tag: 'my-name',
styleUrl: 'my-name.scss'
})
export class MyName {
@State() value: string;
handleSubmit(e) {
e.preventDefault()
console.log(this.value);
// Post data to our Intent
}
handleChange(event) {
this.value = event.target.value;
}
render() {
return (
<form onSubmit={(e) => this.handleSubmit(e)}>
<label>
Name:
<input type="text" value={this.value} onInput={(event) => this.handleChange(event)} />
</label>
<input type="submit" value="Submit" />
</form>
);
}
}
Let's go over what is happening here. First we bind the value of the input to a state variable, in this case this.value
. We then set our state variable to the new value of the input with the handleChange
method we have bound to onInput
. onInput
will fire every keystroke that the user types into the input.
Advanced forms
Here is an example of a component with a more advanced form:
@Component({
tag: 'my-name',
styleUrl: 'my-name.scss'
})
export class MyName {
@State() value: string;
@State() selectValue: string;
@State() secondSelectValue: string;
@State() avOptions: any[];
handleSubmit() {
console.log(this.value);
}
handleChange(event) {
this.value = event.target.value;
if (event.target.validity.typeMismatch) {
console.log('this element is not valid')
}
}
handleSelect(event) {
console.log(event.target.value);
this.selectValue = event.target.value;
}
handleSecondSelect(event) {
console.log(event.target.value);
this.secondSelectValue = event.target.value;
}
render() {
return (
<form onSubmit={(e) => this.handleSubmit(e)}>
<label>
Email:
<input type="email" value={this.value} onInput={(e) => this.handleChange(e)} />
</label>
<select onInput={(event) => this.handleSelect(event)}>
<option value="volvo" selected={this.selectValue === 'volvo'}>Volvo</option>
<option value="saab" selected={this.selectValue === 'saab'}>Saab</option>
<option value="mercedes" selected={this.selectValue === 'mercedes'}>Mercedes</option>
<option value="audi" selected={this.selectValue === 'audi'}>Audi</option>
</select>
<select onInput={(event) => this.handleSecondSelect(event)}>
{this.avOptions.map(recipient => (
<option value={recipient.id} selected={this.selectedReceiverIds.indexOf(recipient.id) !== -1}>{recipient.name}</option>
))}
</select>
<input type="submit" value="Submit" />
</form>
);
}
}
This form is a little more advanced in that it has two select inputs along with an email input. We also do validity checking of our email input in the handleChange
method. We handle the select
element in a very similar manner to how we handle text inputs.
For the validity checking, we are using the constraint validation api that is built right into the browser to check if the user is actually entering an email or not.
By default components use Shadow Dom, meaning DOM and Style of each component is encapsulated from the outside world.
The @Component
and @RootComponent
decorators provide a styleUrl
property used to define the stylesheet associated with a component (relative path):
@Component({
tag: 'create-reminder',
styleUrl: 'CreateReminder.css'
})