In Part 1 we constructed the UI for our Chat sign in screen and wrote a basic flow to transition to the Chatroom UI once the username is successfully validated.
In this part, we will refactor our working solution and implement a basic router to handle transitioning between views. Here's the diagram again of the chat flow:
We have a bit to cover, so without further ado lets begin!
1. Encapsulate logic for sign in UI
Currently we have our application logic inside web/main.dart
, which will be a maintenance nightware should we continue to build on that logic! So we are going to manage the logic for each screen in a View
class.
Create the directory below inside the web
folder with the listed files:
views/
chat_room.dart
chat_signin.dart
view.dart
Now in view.dart
, create an interface which will be used as a blueprint for our View classes:
abstract class View {
void onEnter(); // Run this when the view is loaded in
void onExit(); // Run this when we exit the view
void prepare(); // Prepare the view template, register event handlers etc... before mounting on the page
void render(); // Render the view on the screen
}
At this point some of you may be wondering why we are using abstract class
instead of the interface
keyword? Answer: The Dart team made it so! The rationale is that classes are implicit interfaces so to simplify things they stuck with abstract classes. This means that we can either extend or implement an abstract class.
Let's use this interface to implement our view inside chat_signin.dart
:
// Absolute imports
import 'dart:html';
// Relative imports
import './view.dart';
class ChatSigninView implements View {
ChatSigninView() : _contents = DocumentFragment() {
onEnter();
}
/// Properties
DocumentFragment _contents;
DivElement chatSigninBox;
ParagraphElement validationBox;
InputElement nameField;
ButtonElement submitBtn;
HttpRequest _response;
@override
void onEnter() {
prepare();
render();
}
@override
void onExit() {}
@override
void prepare() {}
@override
void render() {}
}
Before the constructor body is run, we initiate _contents
with a new DocumentFragment
. We will populate it with our template and define our event handlers before inserting into the page.
Let's implement the prepare()
method to use _contents
:
@override
void prepare() {
_contents.innerHtml = '''
<div id="ChatSignin">
<h1 class="title">Chatter 🎯</h1>
<div class="columns">
<div class="column is-6">
<div class="field">
<label class="label">Please enter your name</label>
<div class="control is-expanded has-icons-left">
<input class="input is-medium" type="text" placeholder="Enter your name and hit ENTER" />
<span class="icon is-medium is-left">
<i class="fas fa-user"></i>
</span>
</div>
<p class="help is-danger"></p>
</div>
<div class="field">
<div class="control">
<button class="button is-medium is-primary">
Join chat
</button>
</div>
</div>
</div>
</div>
</div>
''';
chatSigninBox = _contents.querySelector('#ChatSignin');
validationBox = chatSigninBox.querySelector('p.help');
nameField = chatSigninBox.querySelector('input[type="text"]');
submitBtn = chatSigninBox.querySelector('button');
_addEventListeners(); // TODO: Implement this method
}
And now to implement _addEventListeners()
and its related methods:
class ChatSigninView implements View {
...
...
void _addEventListeners() {
// Event listeners on form controls
nameField.addEventListener('input', _inputHandler);
submitBtn.addEventListener('click', _clickHandler);
}
void _inputHandler(evt) {
if (nameField.value.trim().isNotEmpty) {
nameField.classes
..removeWhere((className) => className == 'is-danger')
..add('is-success');
validationBox.text = '';
} else {
nameField.classes
..removeWhere((className) => className == 'is-success')
..add('is-danger');
}
}
void _clickHandler(evt) async {
// Validate name field
if (nameField.value.trim().isEmpty) {
nameField.classes.add('is-danger');
validationBox.text = 'Please enter your name';
return;
}
submitBtn.disabled = true;
// Submit name to backend via POST
try {
_response = await HttpRequest.postFormData(
'http://localhost:9780/signin',
{
'username': nameField.value,
},
);
// Handle success response and switch view
onExit();
} catch (e) {
// Handle failure response
submitBtn
..disabled = false
..text = 'Failed to join chat. Try again?';
}
}
}
Most of the logic above has been moved from web/main.dart
from Part 1 of the series.
The _inputHandler()
private method is responsible for validating the input text field and adding the appropriate classes. The _clickHandler()
private method will validate and submit the form, POSTing to the endpoint at http://localhost:9780/signin
. The result is assigned to the _response
instance variable. We then call the onExit()
method, which will pass the response data to the next view.
Let's render the prepared document fragment to the screen:
@override
void render() {
querySelector('#app')
..innerHtml = ''
..append(_contents);
}
And define our exit strategy:
@override
void onExit() {
nameField.removeEventListener('input', _inputHandler);
submitBtn.removeEventListener('click', _clickHandler);
// Swap view to chat room
// TODO: Transition to Chat room screen
}
To see this view in the browser, update web/main.dart
:
import './views/chat_signin.dart';
void main() {
ChatSigninView();
}
Change the <body>
markup of web/index.html
as follows:
<section class="section">
<div class="container" id="app">
<!-- Views will be rendered here -->
</div>
</section>
Run the webdev
server and visit http://localhost:8080
in the browser:
webdev serve --live-reload
2. Encapsulate logic for Chat room UI
Define a ChatRoomView
class inside web/views/chat_room.dart
:
// Absolute imports
import 'dart:html';
// Relative imports
import './view.dart';
class ChatRoomView implements View {
ChatRoomView(this.params)
: _contents = DocumentFragment() {
onEnter();
}
/// Properties
Map params;
DocumentFragment _contents;
DivElement chatRoomBox;
DivElement chatRoomLog;
InputElement messageField;
ButtonElement sendBtn;
@override
void onEnter() {
prepare();
render();
}
@override
void onExit() {
_removeEventListeners(); // TODO: Implement this method
// TODO: Transition to chat sign in screen
}
@override
void prepare() {
_contents.innerHtml = '''
<div id="ChatRoom">
<h1 class="title">Chatroom</h1>
<div class="tile is-ancestor">
<div class="tile is-8 is-vertical is-parent">
<div class="tile is-child box">
<div id="ChatRoomLog"></div>
</div>
<div class="tile is-child">
<div class="field has-addons">
<div class="control is-expanded has-icons-left">
<input id="ChatRoomMessageInput" class="input is-medium" type="text" placeholder="Enter message" />
<span class="icon is-medium is-left">
<i class="fas fa-keyboard"></i>
</span>
</div>
<div class="control">
<button id="ChatRoomSendBtn" class="button is-medium is-primary">
Send
<span class="icon is-medium">
<i class="fas fa-paper-plane"></i>
</span>
</button>
</div>
</div>
</div>
</div>
</div>
</div>
''';
chatRoomBox = _contents.querySelector('#ChatRoom');
chatRoomLog = chatRoomBox.querySelector('#ChatRoomLog');
messageField = chatRoomBox.querySelector('#ChatRoomMessageInput');
sendBtn = chatRoomBox.querySelector('#ChatRoomSendBtn');
_addEventListeners(); // TODO: Implement this method next
}
@override
void render() {
querySelector('#app')
..innerHtml = ''
..append(_contents);
}
}
And now to implement _addEventListeners()
and _removeEventListeners()
:
void _addEventListeners() {
sendBtn.disabled = true;
/// Event listeners
messageField.addEventListener('input', _messageFieldInputHandler);
sendBtn.addEventListener('click', _sendBtnClickHandler);
}
void _removeEventListeners() {
messageField.removeEventListener('input', _messageFieldInputHandler);
sendBtn.removeEventListener('click', _sendBtnClickHandler);
}
void _messageFieldInputHandler(e) {
// Disable the send button if message input is empty
sendBtn.disabled = messageField.value.isEmpty;
}
void _sendBtnClickHandler(e) {
// TODO: Broadcast message to other chat users
messageField.value = '';
}
Now that the logic for both views are encapsulated, we need to be able to transition between the two. Now you can do this easily by replacing the TODO: Transition to * screen
with instantiating the class for the view you want to launch, so ChatRoomView()
or ChatSigninView()
.
I'm not too fond of that approach since it means importing one class into the other and vice versa. I would rather delegate this to a separate class to handle that concern.
That brings us to Step #3 below...
3. Implement a Router
class for handling view transition
In web/router.dart
define a Router
class:
import './views/view.dart';
// Type definition to label a Function that returns a `View` type
typedef ViewInstantiateFn = View Function(Map data);
class Router {
Router() : _routes = [];
List<Map<String, ViewInstantiateFn>> _routes;
register(String path, ViewInstantiateFn viewInstance) {
// The key `path` is a computed property
// It could also be written as {'$path': viewInstance}
_routes.add({path: viewInstance});
}
go(String path, {Map params = null}) {
// Find the matching `Map` object in _routes
// and invoke it's `View` object instance
_routes.firstWhere(
(Map<String, ViewInstantiateFn> route) => route.containsKey(path),
orElse: () => null,
)[path](params ?? {});
}
}
Router router = Router();
The Router
class contains a list of routes under the _routes
instance variable. Each route is a Map
containing a key name which is the path and the value of that key is a function that returns a View
object. We've created a type definition called ViewInstantiateFn
to represent the structure of that function.
To add a route we will call the register()
method, passing it a path and a function to instantiate our View
. To transition to the view we will call the go()
method, passing it a path and a map of parameters to be consumed by the View
.
The last line of the file exports a Router
instance.
Let's use this class in web/main.dart
:
import './router.dart';
import './views/chat_room.dart';
import './views/chat_signin.dart';
void main() {
router
..register('/', (_) => ChatSigninView())
..register('/chat-room', (params) => ChatRoomView(params))
..go('/');
}
Go to web/views/chat_signin.dart
and replace // TODO: Transition to Chat room screen
with a call to router.go()
:
import '../router.dart'; // <-- Remember to import this
..
..
@override
void onExit() {
nameField.removeEventListener('input', _inputHandler);
submitBtn.removeEventListener('click', _clickHandler);
// Swap view to chat room
router.go('/chat-room', params: {'username': _response.responseText}); // <-- Added this line
}
Go to web/views/chat_room.dart
and replace // TODO: Transition to chat sign in screen
in onExit()
method:
import '../router.dart'; // <-- Remember to import this
..
..
@override
void onExit() {
_removeEventListeners();
router.go('/'); // <-- Added this line
}
Now run the backend server in a separate terminal:
dart bin/server.dart
Here's what we should now have:
Add this css rule in web/styles.css
to chat room message log bigger:
#ChatRoomLog {
height: 300px;
overflow-y: scroll;
}
Conclusion
And this concludes Part 2 of the series. In Part 3, we will implement the chat conversation logic and complete this series.
As always, I hope this was insightful and you learnt something new today. And the working solution is here.
Like, share and follow me 😍 for more content on Dart.
Top comments (5)
This was harder for me to read the tags.
although this was the same as
document.innerHTML
in Javascript, it was still painful to read. are there any alternative solution of not storing the multi-line string html tags?Hey Vince,
Thanks for the feedback.
Off the top of my head you could try putting the string in a separate html file and lazy-loading it in.
Alternatively, you could move this back to
web/index.html
like it was in Part 1 and toggle its visibility 👎🏾. Perhaps even better you could try moving it toweb/index.html
in a<template>
tag and have logic to extract and render the contents whenever you need. EDIT: There is a TemplateElement class to help with that.Hope that helps.
Thanks for the second part Jermaine! Eagerly waiting for the last part of the series!
Hi Ratul, Part 3 is now here.
Thanks a ton! I really enjoyed this series, I hope you do some more posts on Dart on the backend.