Writing Posts
In previous part we created our classes and services to handle data fetching from server. We also learned something about the vital parts of IMA.js - object container and server-side rendering. In this smaller section of the tutorial, we're going to be processing input from user and sending those data to the server.
Processing input from user
To write new posts, we need to address several issues:
- Notifying the controller that the user submitted the new post.
- Sending the post to our server via HTTP (remember, we don't have an actual REST API backend, so we're going to mock this).
- Waiting for our post to be saved.
- Showing the updated list of posts.
We want the controller to handle submitting posts to the guest book in our
application instead of the postingForm
component to maintain a single
"source of truth" in our application. This should be the case for all
information that is related to the page as a whole. Local information (for
example starting music playback when the user clicks the play button of some
player component) may remain stored within the component itself, as it is not
necessarily important to the overall state of the page.
We'll use another IMA.js service to notify the controller that the user submitted a new post - the EventBus. In case you did not read the details about communication between the controller and the view , the EventBus is an internal event system, built on top of DOM events, used for communication like this.
Updating the form
First update the <form ...
markup in the view of our PostingForm
component
(app/component/postingForm/PostingForm.jsx
) by adding an onSubmit
event
listener:
- JavaScript
- TypeScript
<form action="" method="post" onSubmit={e => this._onSubmit(e)}>
<form action='' method='post' onSubmit={e => handleSubmit(e)}>
Then we need to hook our inputs to _onChange()
handler which will set the contents of
those input to the state of our PostingForm
component.
- JavaScript
- TypeScript
<input
id="postForm-name"
className="form-control"
type="text"
name="author"
value={this.state.author}
onChange={e => this._onChange(e)}
placeholder="Your name"
/>
...
<textarea
id="postForm-content"
className="form-control"
name="content"
value={this.state.content}
onChange={e => this._onChange(e)}
placeholder="What would you like to tell us?"
/>
<input
id='postForm-name'
className='form-control'
type='text'
name='author'
value={post.author}
onChange={e => handleChange(e)}
placeholder='Your name'
/>
...
<textarea
id='postForm-content'
className='form-control'
name='content'
value={post.content}
onChange={e => handleChange(e)}
placeholder='What would you like to tell us?'
/>
We can't forget to define the default state for these two keys:
- JavaScript
- TypeScript
#containerRef;
constructor(props, context) {
super(props, context);
this.#containerRef = createRef();
this.state = {
author: '',
content: ''
};
}
type Post = {
author: string;
content: string;
};
export function PostingForm() {
const containerRef: RefObject<HTMLDivElement> = createRef();
const [post, setPost] = useState<Post>({
author: '',
content: '',
});
...
}
TypeScript: Again, instead of the constructor, we used
useState
hook from React and definedPost
type for the newly defined state.
Import the createRef
from React to the
beginning of the file:
- JavaScript
- TypeScript
import { createRef } from 'react';
import { RefObject, createRef } from 'react';
...and add ref={this.#containerRef}
to the first div
in the the component:
- JavaScript
- TypeScript
...
render() {
return (
<div className='posting-form card' ref={this.#containerRef}>
<form action='' method='post' onSubmit={e => this._onSubmit(e)}>
...
...
return (
<div className='posting-form card' ref={containerRef}>
<form action='' method='post' onSubmit={e => handleSubmit(e)}>
...
This adds some internal state to our form component, which we'll maintain separately from the main page state maintained by the home page controller.
Now we need to define the _onChange()
handler. We're going to use the
name
attribute of input and textarea fields so both can be handled by defining only one method.
But feel free to define onChange
handlers for each input separately, if that suits you better.
Our _onChange()
handler will look like this:
- JavaScript
- TypeScript
_onChange(event) {
this.setState({
[event.target.name]: event.target.value
});
}
const handleChange = (event: any): void => {
setPost({ ...post, [event.target.name]: event.target.value });
};
TypeScript: In our functional component we replaced the
_onChange()
method withhandleChange()
arrow function.
The only thing that remains is to define the _onSubmit()
in our component:
- JavaScript
- TypeScript
_onSubmit(event) {
event.preventDefault();
this.fire(this.#containerRef.current, 'postSubmitted', {
author: this.state.author,
content: this.state.content
});
// Reset the state after submitting
this.setState({
author: '',
content: ''
});
}
const { fire } = useComponent();
const handleSubmit = (event: any): void => {
event.preventDefault();
fire(containerRef.current as EventTarget, 'postSubmitted', post);
setPost({ author: '', content: '' });
};
Firing EventBus events
We can fire EventBus events through this.fire()
method that is available
to us by extending the AbstractComponent
. So in this example
we fire the postSubmitted
event through EventBus with the form data as
the event data, clear the form, and finally we prevent the browser from
submitting the form to the server.
Typescript: In functional components, the
fire
method is provided to us by theuseComponent
hook, which gives us access to the utility methods.
The this.fire()
method is a short-hand for this.utils.$EventBus.fire(this, ...)
call, which fires the custom DOM event using the EventBus. The this.utils
property is set to the view utils - various objects, data and services that
are useful for rendering the UI - and is obtained from the React context.
The value returned by this.utils
is configurable in the
app/config/bind.js
configuration file and is represented by the constant
$Utils
.
Capturing EventBus events
Now we need a way to capture the event in our home page controller, so open up
the home controller (the app/page/home/HomeController.js
file) and add the
following method:
- JavaScript
- TypeScript
onPostSubmitted(eventData) {
// TODO
}
onPostSubmitted(eventData: PostData) {
// TODO
}
The IMA.js will automatically invoke this method when the postSubmitted
event
bus event occurs. For details on how this mechanism works, please refer to the
Emitting events using the EventBus
section of the third chapter of this tutorial.
Notice that our onPostSubmitted()
event listener is a public method. This is
because it represents the (event) interface for the view components.
Updating our post service classes
Before we fill our onPostSubmitted()
event listener with content however,
we need to update our post model classes first. Open the post factory class (app/model/post/PostFactory.js
)
and add the following method for creating a single post:
- JavaScript
- TypeScript
createEntity(entityData) {
return new PostEntity(entityData);
}
createEntity(entityData: PostData): PostEntity {
return new PostEntity(entityData);
}
Since we don't like to repeat ourselves, update the return
statement in the
createList()
method as well:
- JavaScript
- TypeScript
return entities.map(entityData => this.createEntity(entityData));
return entities.map((entityData: PostData) => this.createEntity(entityData));
Now add the following method for creating new posts to the post resource
(app/model/post/PostResource.js
):
- JavaScript
- TypeScript
createPost(postData) {
return this._http
.post('http://localhost:3001/static/static/public/posts.json', postData)
.then(response => this._factory.createEntity(response.body));
}
type PostApiCreateResponse = {
body: PostData;
};
export class PostResource {
...
createPost(postData: PostData): Promise<PostEntity> {
return this._http
.post('http://localhost:3001/static/static/public/posts.json', postData)
.then((response: PostApiCreateResponse) =>
this._factory.createEntity(response.body)
);
}
}
This method accepts a plain object containing the new post data and submits
them to the server using an HTTP POST request. The _http.post()
method sends
the HTTP POST request and returns a promise that resolves to the server's
response with the response body parsed as JSON. We then use the server's
response to create a post entity representing the saved post.
Next we need to create a method for creating posts in our post service
(app/model/post/PostService.js
):
- JavaScript
- TypeScript
createPost(postData) {
postData.id = null;
return this._resource.createPost(postData);
}
createPost(postData: PostData): Promise<PostEntity> {
postData.id = null;
return this._resource.createPost(postData);
}
This method sets the id
field to null
as it is expected for posts that were
not created yet (the post IDs should be generated by our backend) and uses the
post resource to create the post. The method returns a promise that resolves to
the post entity representing the created post.
Defining the onPostSubmitted
method
With that in place, we can now fill in the contents of the onPostSubmitted()
event listener in the home page controller (app/page/home/HomeController.js
):
- JavaScript
- TypeScript
this._postService
.createPost(eventData)
.then(() => this._postService.getPosts())
.then(posts => this.setState({ posts }));
this._postService
.createPost(eventData)
.then(() => this._postService.getPosts())
.then((posts: PostData[]) => this.setState({ posts }));
This snippet calls the createPost()
method with our event data, waits for the
post to be created, then requests the current list of posts from the post
service and updates the posts
field in the view's state using the
setState()
method. The setState()
method updated only the fields of the
state that are present in the provided state object without modifying the rest,
and notifies the view about the new state so that the view is re-rendered.
Updating the API
Now that everything is wired up, we can start submitting new posts, right? Well, not so fast. Remember, we do not have an actual REST API backend, so the HTTP POST request will fail and no new post will be created.
Since we don't want to implement an actual backend, we will work around this issue by implementing a mock HTTP agent that fetches the posts from the server and then acts as if sending subsequent requests to the server while managing our state (the created posts) locally and creating responses on spot without any actual communication with the server. This approach is useful for both tests and our simple tutorial.
To create our HTTP mock create the app/mock
directory and the
app/mock/MockHttpAgent.js
file with the following content:
import { HttpAgentImpl } from '@ima/core';
const GET_DELAY = 70; // milliseconds
const POST_DELAY = 90; // milliseconds
export default class MockHttpAgent extends HttpAgentImpl {
static get $dependencies() {
return ['$HttpAgentProxy', '$Cache', '$CookieStorage', '$Settings.$Http'];
}
constructor(proxy, cache, cookie, config) {
super(proxy, cache, cookie, config);
this._posts = null;
}
get(url, data, options = {}) {
if (!this._posts) {
return super.get(url, data, options).then(response => {
this._posts = response.body;
return {
body: this._posts.map(post => Object.assign({}, post))
};
});
}
return new Promise(resolve => {
setTimeout(() => {
resolve({
body: this._posts.map(post => Object.assign({}, post))
});
}, GET_DELAY);
});
}
post(url, data, options = {}) {
if (!this._posts) {
return this.get(url, {}).then(() => this.post(url, data));
}
return new Promise(resolve => {
setTimeout(() => {
let clone = Object.assign({}, data);
clone.id = this._posts[0].id + 1;
this._posts.unshift(clone);
resolve({
body: Object.assign({}, clone)
});
}, POST_DELAY);
});
}
}
Let's take this class apart and take a look at what it does. We extend the
ima/http/HttpAgent
class which is the HTTP agent provided by IMA.js, so
we need to obtain its dependencies in our constructor
(proxy, cache, cookie, config
) and pass them to the super-constructor.
Next we set up the _posts
field that we'll use to keep track of all posts and few REST API methods:
The
get()
method checks whether we already have the posts fetched from the server, and, if we don't, it uses the super-implementation to fetch them and store them in the_posts
field. If the posts have already been fetched, the method returns a promise that resolves to a clone of the posts after the configured delay.The
post()
method checks whether we already have the posts fetched from the server, and, if we don't, it fetches them using theget()
method and then calls itself again. If we already have the posts fetched, the method clones the data passed to it in parameters, generates an ID, stores the new record as the first element of the_posts
array while shifting the rest of the posts and resolves the returned promise after the configured delay to the stored post.
We included the delays in our get()
and post()
methods to simulate the
latency imposed by a real networking. Also notice how we always clone the data
we receive before storing them internally and return only clones of our
internal posts storage. This is to emulate the server behavior reliably, so
that new posts won't modify previously returned post arrays and later
modifications of data passed to or received from our mock server won't modify
the internal state or data returned by other calls to our methods.
To wire up our HTTP mock into our application, we need to update the dependencies of the app/model/post/PostResource.js
:
- JavaScript
- TypeScript
import PostFactory from './PostFactory';
import MockHttpAgent from 'app/mock/MockHttpAgent';
export default class PostResource {
static get $dependencies() {
return [MockHttpAgent, PostFactory];
}
...
}
import MockHttpAgent from 'app/mock/MockHttpAgent';
export class PostResource {
static $dependencies: Dependencies = [MockHttpAgent, PostFactory];
declare _http: MockHttpAgent;
...
}
Go ahead and check the result in the browser, you will now be able to write new posts to our guestbook (which will disappear once you reload the page, since we keep the posts only in our HTTP mock).