- create an Anuglar2 Dart project with your usual tooling
- add
angular2_components: ^0.2.2
as a dependency inpubspec.yaml
Let's keep two copies of the GWT sources in the project.
The first copy in gwt/original
will be unchanged.
The second copy in gwt/backlog
will help us to keep track of the
current backlog of the migration: files and logic that needs to be
processed in one way or another.
There are many files in our backlog that can be deleted easily:
- build.xml
- war/WEB-INF/*
- war/gradient_bg_th.png (duplicate)
- war/Mail.html
- c.g.g.s.m/Mail.gwt.xml
Before deleting, check the content in case it uses features that need to be addressed in the Dart code.
While checking war/Mail.html
there are a few small touches that
should be adapted (<meta>
, <title>
, <noscript>
).
- move
war/favicon.ico
->web/favicon.ico
We create a <link>
reference in web/index.html
that points
to global.css
with the few CSS styles that are outside of our
main component's scope.
global.gss
contains some references tobody
Mail.java
setsmargin: 0px
and disables scrollbars on the outer window.
- create the
TopPanel
component inlib/nav/top/top_panel.dart
with its usualhtml
andcss
files - add it to the
app_component.html
:<top-panel></top-panel>
- reference it in the
AppComponent
's annotation:directives: const [TopPanel],
- start processing
TopPanel.ui.xml
:- move
logo.png
to thelib/nav/top/
directory - move the styles from
<ui:style>
totop_panel.css
- copy the structure inside
<g:UIBinder>
totop_panel.html
- move
Migrating the logo:
- the
ui:image
element becomes<img src="..."/>
- to reference the logo, use
src="packages/gwt_mail_sample/nav/top/logo.png"
- add the CSS class
logo
to the element - remove the
gwt-sprite: "logo";
from the CSS, there is no use for it
Migrating the g:HTMLPanel
:
-
the logo
div
is not used anymore as we have theimg
element above -
class="{style.statusDiv}"
becomesclass="statusDiv"
-
the
g:Anchor
reference becomes much simpler:in the template:
<a href="" (click)="signOut($event)">Sign Out</a> <a href="" (click)="showAbout($event)">About</a>
in the controller (after importing
dart:html
):void signOut(MouseEvent event) { } void showAbout(MouseEvent event) { }
Implementing the actions:
- the
signOut
is simple: it callswindow.alert()
- the
showAbout
requires the about dialog, let's leave a TODO for now
There are a few tweaks to be made:
- move the anchor-related (
a
) styles to global.css - in both method, add
event.preventDefault();
, because we don't want to have a place change when the user clicks on them
- create the
AboutDialog
component inlib/nav/about/about_dialog.dart
with its usualhtml
andcss
files - move
gwt-logo.png
to the same directory - move the
.logo
style fromAboutDialog.ui.xml
intoabout_dialog.css
(without thegwt-sprite
attribute) - the rest of the styling and Java code is better served with a clean re-implementation with the material modal dialog component
To reference the about dialog:
-
put
<about-dialog></about-dialog>
intop_panel.html
and add the directive and a@ViewChild
reference to the controller class:@Component( directives: const [AboutDialog],
@ViewChild(AboutDialog) AboutDialog aboutDialog;
This will inject a reference of the about dialog's controller into the top panel, making it straightforward to display it when needed.
To get started with the material model dialog, take a look into the Angular component examples. Using the headered dialog example provided 95% of the functionality, with the following tweaks:
-
the dialog's maximum width is set to 60% via CSS
-
*ngIf="visible"
is used to lazily initialize the dialog -
the
gwt-logo.png
is referenced by thepackages/...
resource path with thelogo
CSS classThere is no conflict, the same CSS class name can be used in separate components (e.g.
logo
both here and inTopPanel
), because Angular generates scoping rules for each of them.
The left-side menu (Shortcuts) resembles the expansion panel component, however it requires several customization.
-
create the
SidePanel
component inlib/nav/side/side_panel.dart
with its usualhtml
andcss
files -
move
contactsgroup.png
,mailboxesgroup.png
,tasksgroup.png
andgradient_bg_dark.png
to the same directory -
add the component to
AppComponent
:<side-panel>
to the templateSidePanel
to thedirectives
annotation- position it to the right (e.g. floating with
max-width: 250px
)
-
migrate the CSS styles from
Shortcuts.ui.xml
:.shortcuts
becomes:host
.stackHeader
becomes:host-context header
- add a bit margin for the
.content
-
For each of the panels, a template like this covers the functionality, repeat it for tasks and contacts:
<material-expansionpanel flat [showSaveCancel]="false" [expanded]="selectedPanel == 'mailboxes'" (open)="open('mailboxes')" (close)="close($event, 'mailboxes')"> <div name> <img src="packages/gwt_mail_sample/nav/side/mailboxesgroup.png" /> Mailboxes </div> <div class="content">TODO: add tree of mailboxes.</div> </material-expansionpanel>
-
In the component:
String selectedPanel = 'mailboxes'; void open(String panel) { selectedPanel = panel; } void close(AsyncAction action, String panel) { if (panel == selectedPanel) action.cancel(); }
<<<<<<< HEAD
String selectedPanel = 'mailboxes';
to keep track of the currently selected and active panel- the close event ensures that only the panel's self-close gets cancelled. =======
The task list is a static component without any real binding. In the following code we will re-implement it in a way that the list becomes dynamic, and the checked state of the tasks are bound to a real field.
-
create the
TaskList
component inlib/task/task_list.dart
with its usualhtml
, but without anycss
files (no extra styling required) -
reference it in
SidePanel
's directives list and template -
the backing task object can be as simple as:
class TaskItem { String label; bool isDone; TaskItem(this.label, {this.isDone: false}); }
-
put a list of these in our component:
class TaskList { List<TaskItem> items = [ new TaskItem('Get groceries'), new TaskItem('Walk the dog'), // ... }
-
bind it with
*ngFor
in the template (using material checkbox):<div *ngFor="let item of items"> <material-checkbox [(checked)]="item.isDone" [label]="item.label"></material-checkbox> </div>
25433f9... Migrate Tasks
-
create the
ContactList
component inlib/contact/contact_list.dart
with its usualhtml
andcss
files -
move
default_photo.jpg
to the same directory -
move the
.contacts
CSS styles fromContacts.ui.xml
tocontact_list.css
-
move the
.popup
,.photo
,.right
, and.email
CSS styles fromContactPopup.ui.xml
tocontact_list.css
(removegwt-sprite: "photo";
) -
create a const to point to it:
const String defaultPhotoUrl = 'packages/gwt_mail_sample/contact/default_photo.jpg';
-
reference it in
SidePanel
's directives list and template
Similarly to the TaskList
component, we'll create a backing component,
we'll create a backing object to drive the template:
-
the backing contact object:
class ContactItem { String name; String email; String photoUrl; ContactItem(this.name, this.email, {this.photoUrl: defaultPhotoUrl}); }
-
the list of these items in the main component:
class ContactList { List<ContactItem> items = [ new ContactItem('Benoit Mandelbrot', '[email protected]'), new ContactItem('Albert Einstein', '[email protected]'), // ... ];
-
bind it with
*ngFor
in the template<div class="contacts"> <div *ngFor="let item of items"> <a href="" (click)="showPopup($event, item)">{{item.name}}</a> </div> </div>
-
implement the skeleton of the
showPopup
method:ContactItem selected; void showPopup(MouseEvent event, ContactItem item) { selected = item; event.preventDefault(); }
The material popup component requires a bit of preparation:
-
both the
source
andvisible
needs to be bound:<material-popup *ngIf="popupVisible" [source]="popupSource" [(visible)]="popupVisible">
Note: using the same
popupVisible
flag to create it has the added benefit of automatic cleanup after closing/hiding it. -
the controller needs to initialize these fields:
PopupSource popupSource; bool popupVisible = false; void showPopup(MouseEvent event, ContactItem item) { selected = item; event.preventDefault(); popupVisible = true; Element element = event.currentTarget; Point p = new Point(element.offsetLeft + 14, element.offsetTop + 14); popupSource = new PopupSource.fromRectangle(new Rectangle.fromPoints(p, p)); }
The positioning with
(+14, +14)
offset is coming fromContacts.java
. -
migrate the template from
ContactPopup.ui.xml
and put it inside the<material-popup>
element:<div class="popup"> <img [src]="selected.photoUrl" class="photo"/> <div class="right"> <div>{{selected.name}}</div> <div class="email">{{selected.email}}</div> </div> </div>
- create the
MailFolder
component inlib/mail/folder/mail_folder.dart
with its usualhtml
andcss
files - move the
drafts.png
,home.png
,inbox.png
,noimage.png
,sent.png
,templates.png
, andtrash.png
to the same directory - reference it in
SidePanel
's directives list and template
While the GWT implementation uses a heavyweight tree component, the
mailbox tree can be modeled and built on top of *ngFor
with some
additional logic:
-
a
FolderItem
needs to keep track of some fields:class FolderItem { String iconUrl; String label; bool isSelected = false; }
-
to make it into a tree, one needs to keep track of the
parent and
children`bool isExpanded; FolderItem parent; List<FolderItem> children;
-
an item is visible if and only if all of its ancestors are visible and expanded:
bool get isRoot => parent == null; bool get isVisible => isRoot || (parent.isVisible && parent.isExpanded);
-
open/close toggle buttons are visible only if it has children:
bool get hasChildren => children?.isNotEmpty ?? false; bool get expandVisible => hasChildren && !isExpanded; bool get collapseVisible => hasChildren && isExpanded;
-
to preserve the tree-like visual layout, one needs to indent based on the depth of the item:
int get depth => parent == null ? 0 : parent.depth + 1; int get indentPx => depth * 16;
-
build the tree of the sample inbox folders:
FolderItem root = new FolderItem('[email protected]', iconUrl: '$baseUrl/home.png', children: [ new FolderItem('Inbox', iconUrl: '$baseUrl/inbox.png'), new FolderItem('Drafts', iconUrl: '$baseUrl/drafts.png'), new FolderItem('Templates', iconUrl: '$baseUrl/templates.png'), new FolderItem('Sent', iconUrl: '$baseUrl/sent.png'), new FolderItem('Trash', iconUrl: '$baseUrl/trash.png'), new FolderItem('custom-1', children: [ new FolderItem('custom-1-1'), new FolderItem('custom-1-2'), new FolderItem('custom-1-3'), ]), ]);
Note: make sure the constructor sets the
parent
field of thechildren
. -
traverse the tree into a flattened list:
List<FolderItem> items = []; void _traverseItems(FolderItem item) { items.add(item); item?.children?.forEach(_traverseItems); } _traverseItems(root);
-
the template should be a combination of list iteration with visibility check:
<div *ngFor="let item of items"> <div *ngIf="item.isVisible" class="item"> <!-- here comes the content --> </div> </div>
-
the
.item
CSS style is a flexbox which renders the following blocks as they were table rows -
indentation is a simple padding:
<div [style.width.px]="item.indentPx"> </div>
-
the tree controls (
+
and-
) is handled by the following template:<div class="toggle"> <span *ngIf="item.expandVisible" (click)="item.toggle()">➕</span> <span *ngIf="item.collapseVisible" (click)="item.toggle()"> ➖</span> </div>
and code:
void toggle() { isExpanded = !isExpanded; }
-
put there an icon
<img [src]="item.iconUrl" class="icon"/>
and the label:
<div [class.selected]="item.isSelected" (click)="selectFolder(item)">{{item.label}}</div>
-
implement the
selectFolder
method:- deselect the previously selected item
- store the current one and set its
isSelected
flag
With that, we have a simplified tree component that:
- looks and works like a tree (indentation and tree controls)
- supports custom icons and styles
- keep track of a single selected item (which can later trigger the loading the list of messages for that e-mail folder)
In the GWT application, the list of e-mails is stored in MailItems.java
as a static field, and it is generated in a consistent way. The mail-related
components access it though the static methods.
This approach makes testing, mocking and encapsulation harder, and instead, we'll use an injected service to achieve better coupling.
-
create
lib/mail/mail_service.dart
-
migrate
MailItem.java
and create the following data holder class:class MailItem { String sender; String email; String subject; String body; MailItem(this.sender, this.email, this.subject, this.body); }
-
create the service interface:
abstract class MailService { }
-
add folder selection:
String get selectedFolder; Future selectFolder(String label);
selectFolder
is an asynchronous method that returns with aFuture
, which will complete once the service completes loading the first page. -
add information about the current folder:
int get mailCount; List<MailItem> get pageItems;
-
add pagination information and page switching methods:
int get pageIndex; int get pageCount; int get pageSize; Future nextPage(); Future prevPage();
Similarly to the above, the methods complete when the service finishes loading the mail items.
The next step is to create a mock MailService
implementation:
-
create
lib/mail/mock_mail_service.dart
and start implementing the previously create interface:- keep track of the
selectedFolder
,mailCount
,pageIndex
,pageCount
andpageItems
properties as private fields - hardcode
int get pageSize => 20
for simplicity
- keep track of the
-
copy the prepared values from
MailItems.java
into private top-level fields (some values need to be fixed, e.g. escape$
signs) -
redirect all async methods to a single mail item generator:
Future nextPage() => _generateItems(selectedFolder, pageIndex + 1); Future prevPage() => _generateItems(selectedFolder, pageIndex - 1); Future selectFolder(String label) => _generateItems(label, 0); Future _generateItems(String label, int newPageIndex) async { }
To implement the _generateItems
method, follow similar logic as
in MailItems.createFakeMail()
, with the following additions:
- initialize e-mail count based on the
label
's hash - check if
page
is valid for that count and reset to0
if needed - calculate how many items needs to be on the page and generate them
- use
label
,_pageIndex, and
index` (on page) to initialize the "random" pointers when creating a new mail item
To make it available for components through injection, register it
in main.dart
as a provided service. Note that the we register the
abstract interface, and provide a concrete instance that implements it:
main() {
bootstrap(AppComponent, [
new Provider(MailService, useValue: new MockMailService()),
]);
}
Injecting it into the MailFolder
component requires a small change
in the constructor:
class MailFolder {
final MailService mailService;
MailFolder(this.mailService) {
// ...
Add its use to the selectFolder
method:
void selectFolder(FolderItem item) {
// ...
mailService.selectFolder(item.label);
}
Assuming e-mail is always ordered by descending date, the older >
and < newer
buttons in the NavBar can be easily matched with the
MailService
's nextPage()
and prevPage()
methods:
-
create the
MailNavBar
component inlib/mail/list/mail_nav_bar.dart
with its usualhtml
andcss
files -
add the component to
AppComponent
:<mail-navbar>
to the templateMailNavBar
to thedirectives
annotation
-
inject the
MailService
into this new component -
create a very simple template like:
<material-button dense *ngIf="hasNewer" (click)="newer()">< newer</material-button> {{start}}-{{end}} of {{total}} <material-button dense [disabled]="!hasOlder" (click)="older()">older ></material-button>
Note: while the newer button becomes visible only after the first page, the older button is always there, but may be disabled. That way the height of the component doesn't change if neither of them is visible.
-
implement the above field and methods like the following:
int get total => mailService.mailCount; bool get hasNewer => mailService.pageIndex > 0; bool get hasOlder => end < total; void newer() { mailService.prevPage(); }
-
adapt the
.anchor
CSS style fromNavBar.ui.xml
and apply it onmaterial-button
As we don't have a full-blown open source material table yet,
we will fill in the gap with *ngFor
and some styling.
- create the
MailList
component inlib/mail/list/mail_list.dart
with its usualhtml
andcss
files - inject the
MailService
into this new component
Add the component to AppComponent
, and at the same time remove
the MailNavBar
from it (we'll add it to the header of the list):
-
<mail-list>
to the template -
MailList
to thedirectives
annotation -
use a new CSS style in a
div
to position the mail list component:.right-side { margin-left: 260px; }
Adapt the style properties for the table, mixing new ones with
MailList.ui.xml
:
-
create
.table
style to set the outer border -
use
.row
with flexbox to create tabular layout -
set fixed width for the
sender
andemail
columns, restrict their size increase (flex-grow: 0
) -
set padding for the columns
-
set background shade and gradient for the
.header
-
set different background for hovering and for the selected row
-
introduce a content area where rows are going to be scrolled:
.content { height: 200px; overflow: auto; cursor: pointer; }
Create the base layout of the table:
<div class="table">
<div class="header">
<div class="row">
<div class="col sender">Sender</div>
<div class="col email">Email</div>
<div class="col subject">
Subject
</div>
<mail-nav-bar></mail-nav-bar>
</div>
</div>
<div class="content">
<div *ngFor="let item of items"
class="row"
(click)="selectRow(item)"
[class.selected]="isSelectedRow(item)">
<div class="col sender">{{item.sender}}</div>
<div class="col email">{{item.email}}</div>
<div class="col subject">{{item.subject}}</div>
</div>
</div>
</div>
The items
can be a simple pass-through:
List<MailItem> get items => mailService.pageItems;
To align the mail-nav-bar
to the right, use some additional styling:
mail-nav-bar {
display: block;
text-align: right;
flex-grow: 1;
}
In the simplest case isSelectedRow
and selectRow
could be implemented
by introducing a new MailItem selected
field in the component, but we
know that we want to expose the same instance in the MailDetail
, a
component that we'll build in the next step.
One way to share the data between the two is to place the field inside
MailService
, and update it each time the mailbox folder or the pagination
changes:
void selectRow(MailItem item) {
mailService.selectedItem = item;
}
bool isSelectedRow(MailItem item) => mailService.selectedItem == item;
-
create the
MailDetail
component inlib/mail/detail/mail_detail.dart
with its usualhtml
andcss
files -
inject the
MailService
into this new component -
add the component to
AppComponent
:<mail-detail>
to the templateMailDetail
to thedirectives
annotation
-
copy most of the styling from
MailDetail.ui.xml
-
migrate the template, it will be roughly as simple as:
<div class="detail"> <div class="header"> <div class="headerItem">{{subject}}</div> <div class="headerItem"><b>From: </b>{{sender}}</div> <div class="headerItem"><b>To: </b>{{recipient}}</div> </div> <div class="body" [innerHTML]="body"></div> </div>
-
the fields shall be delegates:
String get subject => mailService.selectedItem?.subject; String get sender => mailService.selectedItem?.sender; String get recipient => '[email protected]'; String get body => mailService.selectedItem?.body;
-
add margin and overflow CSS properties
Check the remaining files, but everything should have been migrated and they are safe to remove.
- replace the GWT logos with the Dart logo
- update borders with a light-gray version (
rgba(0,0,0,0.12)
) - remove the dark gradient background from headers, make it much lighter
- remove
text-shadow
styles - increase global font size (11pt)
- use more whitespace and a nicer hovering effect in side menus
- add ripple to the mail item selection
- don't hide the 'newer' button, disable instead
- add proper site name to the top nav, remove logo overlap
- add link to the source code in the top nav
- replace icons in side panel with material glyphs
- replace icons in mail folder with material glyphs (and migrate to the new list widget)
- let the side panel to be collapsed
In the absence of layout- or docking panels, we can keep track of our panels' desired dimension and bind it to the appropriate CSS property.
-
in
MailList
component create a new property:@Input() int height = 200;
-
bind it to the height CSS:
<div class="content" [style.height.px]="height">
Note: at this point the
height: 200px
can be removed from the CSS. -
create three flex-based panel list in
AppComponent
:- one for the top (with fixed height) and the rest
- one for the side panel (with a resizer) and the rest of mail panels
- one for the separation of
mail-list
andmail-detail
-
create new fields for the dimensions:
int sideWidthPx = 250; int mailHeightPx = 250;
-
bind their values to the components:
<side-panel [style.flex-basis.px]="sideWidthPx"></side-panel> <mail-list [height]="mailHeightPx"></mail-list>
-
Handling the resize can be implemented by listening on
mousedown
events, and on each of these, track themousemove
andmouseup
on thedocument
Element. For example:<div class="side-resizer" (mousedown)="resizeSide($event)"></div>
void resizeSide(MouseEvent down) { int originX = down.client.x; int originWidth = sideWidthPx; StreamSubscription subscription = document.onMouseMove.listen((MouseEvent move) { move.preventDefault(); move.stopPropagation(); int newWidth = originWidth + move.client.x - originX; sideWidthPx = max(200, min(newWidth, 500)); }); document.onMouseUp.first.then((MouseEvent up) { subscription.cancel(); }); }
-
set the appropriate mouse cursor on the
-resizer
styles
The internal scroll handling (overflow:auto
) of the components
doesn't make it easy to stretch the components to fill the screen.
Instead, we can monitor the layout with DomService
and act on any
event the affects the layout.
The following guide is for MailDetail
, implement a similar
mechanism for SidePanel
:
-
Annotate the bottommost
div
with#bottom
(or create a new one at the end of the html template):<div #bottom></div>
-
Make sure it is injected into the component:
@ViewChild('bottom') ElementRef bottomRef;
-
Initialize a height value for the content area, and calculate the gap (the difference that it needs to add to the value):
int heightPx = 200; int _calculateGap() { Element element = bottomRef.nativeElement; int bottom = element.offsetTop + element.offsetHeight; return window.innerHeight - bottom; }
-
Import and inject
DomService
. It is a useful utility that enables very efficient (and forced relayout-free) tracking of changes on the UI. -
Implement
AfterContentInit
andOnDestroy
on the component class. On initialization we subscribe to layout tracking, and on destroy the subscription needs be cleared up:StreamSubscription _layoutSubscription; @override ngAfterContentInit() { _layoutSubscription = domService.trackLayoutChange(_calculateGap, (int gap) { heightPx = max(10, heightPx + gap); }, runInAngularZone: true); } @override ngOnDestroy() { _layoutSubscription?.cancel(); _layoutSubscription = null; }
-
Bind the height value in the template:
<div class="body" [innerHTML]="body" [style.height.px]="heightPx"></div>
Developing the application and working with the material components was was easy, because we did import everything available, like the following code:
import 'package:angular_components/angular_components.dart';
@Component(
// ...
directives: const [materialDirectives],
providers: const [materialProviders],
)
But the convenience has it price: it prevents proper tree-shaking, and the
dart2js
compiler won't be able to decide which components need to be
included in final build. To make it easier for the tools, as a last step, we
shall clear up our dependencies, and only import the directly used ones:
import 'package:angular_components/src/components/material_popup/material_popup.dart';
import 'package:angular_components/src/laminate/popup/popup.dart';
@Component(
// ...
directives: const [MaterialPopupComponent],
)
Guiding tree-shaking with the above approach helps to remove a good chunk
of unused code. In addition to that, we can fine-tune the dart2js
compilation
with additional command line attributes in the pubspec.yaml
:
- $dart2js:
commandLineOptions: [--trust-type-annotations --trust-primitives]